Saturday 14 January 2017

Retro bytes to PNG pixels with Python

After a long break from doing any retro game recreation in Python, I decided that since I had a few old personal game listings laying around from my ZX Speccy days that the idea of converting these to python could be fun at some later date.

However one thing the ZX Spectrum had that we didn't see on the ZX81 (with its lack of any real graphics capability) was user defined graphics, or UDG's as they were termed.


User Defined Graphics ... defined



UDG's allowed the character set in the Spectrum to be visually redefined using 8 bytes per character (characters were 8 x 8 pixels).  As is the case back in the day, you would draw up your graphics on grid paper in blocks of 8 x 8 squares, convert each line of these blocks into a decimal number and type it into the computer.

Now - obviously any conversion of old programs like this would require some way of translating these UDG graphics to files that I could load and use.  Bytes needed to be broken down into 8 pixels, and then every 8 needed to be recompiled into an 8 x 8 pixel PNG or other file format.

It turned out to be very simple.  Here's how I did it for those curious.

Creating and saving images

First of all, I knew I wanted to create images I could easily import.  I did a little brainstorming, thought about how I could code some way to store and save the binary data as a file...  However I wasn't that excited at the prospect of writing my own BMP saver (which is a relatively easy format to work with)

A little scouring around online, and I found a lot of mention of PIL (Python Imaging Library) which is a module that deals with everything I needed easily.  Once installed, it can be imported easily using...

import Image

With a little googling, there are plenty of examples of using this library online.  However you'll see in my examples further on that you only need a few very simple commands to do everything you need with it.

Getting them pesky graphics typed in...

I stored my graphics (typing in the rather faded byte values) as a list, broken into small 8 value lists. This was just like the 80's - sitting, carefully reading through the basic listing and typing numbers in.  Ah, what a flash back to a time where things just took hours to enter by keyboard.

For sake of length of this article, I've just shown the first 5 characters - there were 21 in total (which you'll see in the image at the end of this post)

# Bring in UDG data as a file later on.
# Its as much fun typing numbers as it was in the 1980s
udgBytes = [ [0,0,0,1,1,0,0,0],
             [0,0,0,128,128,0,0,0],
             [0,0,0,1,7,0,0,0],
             [0,0,0,128,224,0,0,0],
             [0,1,6,4,31,96,239,49] ]

As the graphics were stored in the Spectrum as ascii characters, I also set up a string containing all the letters and used an index value to jump through them.  The reason for this was purely as a way to generate a file name related to the letter that was used in the basic listing.

# Define characters for UDG export, plus index counter
udgChar = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
udgIdx = 0

With that all set up, we're ready to produce our images.

Bytes to pixels...

Pixels in the ZX Spectrum did not have individual colours.  A pixel was either on, or off... Each UDG was 8 pixels wide, and as 1 byte stores 8 bits, all pixels were converted from binary to their 0-255 value.

Knowing this, converting a byte value back to pixels is simply a case of getting its binary representation!  Of course, not a problem for python...  Once we know the pixel on/off state, we could easily just use iterators to read every 8 bits (the binary representation of the byte) and write it directly into an image before saving...

I did do this initially (I've added the code example at the end of this article if you are interested).  However PIL made converting bytes to images even easier with a function called Image.frombuffer - I could pass the 8 bytes and have it automatically taken care of!  Much easier (and a lot less typing) then a clumsy couple of iteration loops, converting digits to binary and then converting binary to pixels...

frombuffer needed 7 parameters - you can use less, but a warning popped up when I ran the code. It suggested that all 7 parameters should be used.



For reference, the parameters are as follows. It took a little time getting my head around using these properly, so this hopefully might save some of yours.
  • mode - what mode the image you are creating will be.  In this case "1" means 1 bit per pixel.
  • size - a tuple containing width and height
  • data - a string.  We convert the 8 byte list into a string that is processed
  • 'raw' - default decoder will translate as needed by the following 3 parameters
  • mode - what mode the data is in (in this case "1")
  • 0, 1 - 0 is stride (spacing between lines of pixels) and 1 indicates data is top-down

Converting bytes to a string can be done by converting the values into a list of chars using chr(byte), and then joining all of these chars into a single string.  Of course, as with anything in python we can use list mapping to generate our list.  We then convert that list to a string with join.

Obviously I'm processing a long list of multiple UDG byte values, so we iterate our way down this list, converting as we go using the aforementioned list mapping.

for readUDG in udgBytes:
    byteData = ''.join(map(chr,readUDG))
    im = Image.frombuffer("1",(8,8),byteData,"raw","1",0,1)

im now stores an 8 x 8 pixel 1 bit image!   Rather then write out a single bit image file,  PIL's convert function will translate it for us to RGB.

    im = im.convert("RGB")

Finally we create our filename by grabbing a letter from our udgChar list.  We create a .png file using PIL's save function.   Increment the udgIdx value to get the next letter for filenaming and repeat until we're done!

    fName = "udg_%s.png" % udgChar[udgIdx]
    im.save(fName,"PNG")
    udgIdx += 1


It's done!

Easy process, thanks to PIL, and I now have a nice simple way to extract my UDG data from BASIC listings!

21 in total - "Obliterator" game, well, maybe I'll convert you one day...

My next challenge of course will be how I can perhaps read data from emulator snapshot files for the ZX Spectrum.  I do have a few of my cassette files digitised for running in emulators that I'd like to extract data from.

That's probably a good excuse to sit and write a tool to browse data - but I'll leave that for another day in the near future. :)

The initial oldy...

For comparison, I've included the original code below to show the other approach that I started with.  In this version, the addition of a pixel scale is also added to allow the 8 x 8 pixel data to be scaled up.

As with the previous examples, I've cut down the udgBytes list to keep the code short and sweet.  Hopefully the code will be fairly self explanatory through the comments...  Enjoy!

import Image

# We can set a scale value here
pixScale = 16

# Create a new 8 x 8 pixel image
im = Image.new("RGB", (8 * pixScale,8 * pixScale))

# Use load function to create a pixel access object into udgMap
# This allows us to access pixel data as a 2D array
udgMap = im.load()

# Define characters for filename plus index counter
udgChar = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
udgIdx = 0

# Our UDG data - in this case, just one
udgBytes = [ [0,0,0,1,1,0,0,0] ]
             
# MAIN LOOP : Process all UDG data
for readUDG in udgBytes:
    
    # Convert each UDG as 8 char binary
    eightPixel = ["{0:08b}".format(b) for b in readUDG]

    # Create our 8 x 8 graphic
    pY = 0
    for pixLine in eightPixel:
        # Read each line, plot pixels across
        pX = 0
        for pixData in pixLine:
            # Create pixel (0 is black, 1 is white)
            rgbV = int(pixData) * 255
            pixCol = (rgbV,rgbV,rgbV)
            # Draw the pixel.
            udgMap[pX ,pY] = pixCol
            # If scaled up, we draw a pixScale size block
            if pixScale > 1:
                for dx in range(pixScale):
                    for dy in range(pixScale):
                        udgMap[pX + dx,pY + dy] = pixCol
            # Next pixel
            pX += pixScale
            
        # Go down to the next line
        pY += pixScale

    # Save the bitmap to disk as a PNG
    fName = "udg_%s.png" % udgChar[udgIdx]
    im.save(fName,"PNG")
    udgIdx += 1

Until next time...

0 comments:

Post a Comment