How would I read a raw RGBA4444 image using Pillow?

Question:

I’m trying to read a ‘.waltex’ image, which is a ‘walaber image’. It’s basically just in raw image format. The problem is, it uses ‘RGBA8888’, ‘RGBA4444’, ‘RGB565’ and ‘RGB5551’ (all of which can be determined from the header), and I could not find a way to use these color specs in PIL.

I’ve tried doing this

from PIL import Image

with open('Carl.waltex', 'rb') as file:
    rawdata = file.read()

image = Image.frombytes('RGBA', (1024,1024), rawdata, 'raw', 'RGBA;4B')
image.show()

I’ve tried all the 16-bit raw modes in the last input, and I couldn’t find a single one that worked. To be clear, this file is specifically ‘RGBA4444’ with little endian, 8 bytes per pixel.

If you need the file, then I can link it.

Asked By: ego-lay atman-bay

||

Answers:

Updated Answer

I have made some changes to my original code so that:

  • you can pass a filename to read as parameter
  • it parses the header and checks the magic string and derives the format (RGBA8888 or RGBA4444) and height and width automatically
  • it now handles RGBA8888 like your newly-shared sample image

So, it looks like this:

#!/usr/bin/env python3

import struct
import sys
import numpy as np
from PIL import Image

def loadWaltex(filename):

    # Open input file in binary mode
    with open(filename, 'rb') as fd:
        # Read 16 byte header and extract metadata
        # https://zenhax.com/viewtopic.php?t=14164
        header = fd.read(16)
        magic, vers, fmt, w, h, _ = struct.unpack('4sBBHH6s', header)
        if magic != b'WALT':
            sys.exit(f'ERROR: {filename} does not start with "WALT" magic string')

        # Check if fmt=0 (RGBA8888) or fmt=3 (RGBA4444)
        if fmt == 0:
            fmtdesc = "RGBA8888"
            # Read remainder of file (part following header)
            data = np.fromfile(fd, dtype=np.uint8)
            R = data[0::4].reshape((h,w))
            G = data[1::4].reshape((h,w))
            B = data[2::4].reshape((h,w))
            A = data[3::4].reshape((h,w))
            # Stack the channels to make RGBA image
            RGBA = np.dstack((R,G,B,A))
        else:
            fmtdesc = "RGBA4444"
            # Read remainder of file (part following header)
            data = np.fromfile(fd, dtype=np.uint16).reshape((h,w))
            # Split the RGBA444 out from the uint16
            R = (data>>12) & 0xf
            G = (data>>8) & 0xf
            B = (data>>4) & 0xf
            A = data & 0xf
            # Stack the channels to make RGBA image
            RGBA = np.dstack((R,G,B,A)).astype(np.uint8) << 4

        # Debug info for user
        print(f'Filename: {filename}, version: {vers}, format: {fmtdesc} ({fmt}), w: {w}, h: {h}')
    
        # Make into PIL Image
        im = Image.fromarray(RGBA)
        return im

if __name__ == "__main__":
    # Load image specified by first parameter
    im = loadWaltex(sys.argv[1])
    im.save('result.png')

And when you run it with:

./decodeRGBA.py objects.waltex

You get:

enter image description here

The debug output for your two sample images is:

Filename: Carl.waltex, version: 1, format: RGBA4444 (3), w: 1024, h: 1024
Filename: objects.waltex, version: 1, format: RGBA8888 (0), w: 256, h: 1024

Original Answer

I find using Numpy is the easiest approach for this type of thing, and it is also highly performant:

#!/usr/bin/env python3

import numpy as np
from PIL import Image

# Define the known parameters of the image and read into Numpy array
h, w, offset = 1024, 1024, 16
data = np.fromfile('Carl.waltex', dtype=np.uint16, offset=offset).reshape((h,w))

# Split the RGBA4444 out from the uint16
R = (data >> 12) & 0xf
G = (data >>  8) & 0xf
B = (data >>  4) & 0xf
A =  data        & 0xf

# Stack the 4 individual channels to make an RGBA image
RGBA = np.dstack((R,G,B,A)).astype(np.uint8) << 4

# Make into PIL Image
im = Image.fromarray(RGBA)
im.save('result.png')

enter image description here


Note: Your image has 16 bytes of padding at the start. Sometimes that amount is variable. A useful technique in that case is to read the entire file, work out how many useful samples of pixel data there are (in your case 1024*1024), and then slice the data to take the last N samples – thereby ignoring any variable padding at the start. That would look like this:

# Define the known parameters of the image and read into Numpy array
h, w = 1024, 1024
data = np.fromfile('Carl.waltex', dtype=np.uint16)[-h*w:].reshape((h,w))

If you don’t like Numpy and prefer messing about with lists and structs, you can get exactly the same result like this:

#!/usr/bin/env python3

import struct
from PIL import Image

# Define the known parameters of the image
h, w, offset = 1024, 1024, 16
data = open('Carl.waltex', 'rb').read()[offset:]

# Unpack into bunch of h*w unsigned shorts
uint16s = struct.unpack("H" * h *w, data)

# Build a list of RGBA tuples
pixels = []
for RGBA4444 in uint16s:
    R = (RGBA4444 >> 8) & 0xf0
    G = (RGBA4444 >> 4) & 0xf0
    B =  RGBA4444       & 0xf0
    A = ( RGBA4444      & 0xf) << 4
    pixels.append((R,G,B,A))

# Push the list of RGBA tuples into an empty image
RGBA = Image.new('RGBA', (w,h))
RGBA.putdata(pixels)
RGBA.save('result.png')

Note that the Numpy approach is 60x faster than the list-based approach:

Numpy: 3.6 ms ± 73.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
listy: 213 ms ± 712 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

Note: These images and the waltex file format seem to be from the games "Where’s My Water?" and "Where’s My Perry?". I got some hints as to the header format from ZENHAX.

Answered By: Mark Setchell

Now that I have a better idea of how your Waltex files work, I attempted to write a custom PIL Plugin for them – a new experience for me. I’ve put it as a different answer because the approach is very different.

You use it very simply like this:

from PIL import Image
import WaltexImagePlugin

im = Image.open('objects.waltex')
im.show()

You need to save the following as WaltexImagePlugin.py in the directory beside your main Python program:

from PIL import Image, ImageFile
import struct

def _accept(prefix):
    return prefix[:4] == b"WALT"

class WaltexImageFile(ImageFile.ImageFile):

    format = "Waltex"
    format_description = "Waltex texture image"

    def _open(self):
        header = self.fp.read(HEADER_LENGTH)
        magic, vers, fmt, w, h, _ = struct.unpack('4sBBHH6s', header)

        # size in pixels (width, height)
        self._size = w, h

        # mode setting
        self.mode = 'RGBA'

        # Decoder
        if fmt == 0:
            # RGBA8888
            # Just use built-in raw decoder
            self.tile = [("raw", (0, 0) + self.size, HEADER_LENGTH, (self.mode, 
0, 1))]
        elif fmt == 3:
            # RGBA4444
            # Use raw decoder with custom RGBA;4B unpacker
            self.tile = [("raw", (0, 0) + self.size, HEADER_LENGTH, ('RGBA;4B', 
0, 1))]


Image.register_open(WaltexImageFile.format, WaltexImageFile, _accept)

Image.register_extensions(
    WaltexImageFile.format,
    [
        ".waltex"
    ],
)

HEADER_LENGTH = 16

It works perfectly for your RGBA888 images, but cannot quite handle the byte ordering of your RGBA444 file, so you need to reverse it for those images. I used this:

...
...
im = Image.open(...)

# Split channels and recombine in correct order
a, b, c, d = im.split()
im = Image.merge((c,d,a,b))

If anyone knows how to use something in the Unpack.c file to do this correctly, please ping me. Thank you.

Answered By: Mark Setchell