Back to Posts

Simple Graphics Format

The Simple Graphics Format (.sgf) is a file format I created to store simple images with a low palette size (255 or less). It was originally written in September 2024, but I have rewritten it recently to get some better documentation of the process. The purpose of this project was to learn how to create a file format, and get more comfortable with writing binary data. Additionally, as a little side-goal, I wanted to see if I could create something smaller than its equivalent png file!

Constraints

To keep this project simple, I set some simple constraints to keep the files small and easy to generate. The primary constraint is the 255-color palette size. This won't have much of an effect in the first version, but will become important later on. Another constraint, which likely won't be an issue, is the max size of an image. I set the width and height to use shorts (2 bytes), so the largest image sgf can store is 65,536 x 65,536. With those constraints down, the sgf file format is best suited for simple images, such as pixel art (which is what I use, so that works out well). For testing, I used a variety of images I created for my current game project, all of which can be found here

Environment

I used python to create the image converter (which should work with any standard format). I also used the following packages:

Version 1

The first version I created was very simple in order to make sure the workflow I had worked. The simplest approach is to store the image size and each individual pixel color. So this is what I did. Since I'm planning on storing everything in binary, I used python's struct module to pack and unpack values.

Packing to SGF

In order to unpack and display our custom file format, we first need to be able to pack data into this format, which means we need data. For this, I used Pillow's Image module
from PIL import Image
import numpy as np

# Load an image
image = Image.open(file_path)

# Get the size and pixel data
size = image.size
pixels = image.getdata()

# Convert to a numpy array with 8-bit elements
pixels = np.array(pixels, dtype=np.uint8)
Then, we need to convert these values to binary and save them to a file
import struct

with open("file_path.sgf", 'wb') as file:
    file.write(struct.pack('HH', size[0], size[1]))
    file.write(pixels.tobytes())
The struct.pack method is doing a lot of heavy lifting for us. It takes in a format (in our case, 'HH' means two unsigned shorts), and the values to pack. numpy also has a useful method to convert an array into a stream of bytes, so that's easy enough.

Unpacking the SGF file

Now that we have an actual file to work with, we can start to unpack these files into a form we can use. We'll be looking at Pillow's Image.frombytes() method in order to go from an array to a viewable image. But first, we need to parse the file
bp = 0

def parse_bytes(format: str):
    nonlocal bp

    out = struct.unpack(format, data[bp:bp+struct.calcsize(format)])
    bp += struct.calcsize(format)

    return out[0]

data = None
with open("file_name.sgf", 'rb') as file:
    data = file.read()

size = [parse_bytes('H'), parse_bytes('H')]

pixels = np.frombuffer(data, offset=bp, dtype=uint8)
Here, I implemented a parse_bytes helper method to help with parsing the binary input. We keep track of where we are in the binary file using bp (the "binary pointer"). This helper method automatically slices the data, unpacks it, and moves the pointer ahead. This version of the parsing code is as clean as it's going to look - we just unpack the size (two shorts), and allow numpy to parse the remaining bytes as an array.

The last thing we need to do is to convert this data into a useable format. As mentioned before, this is fairly simple with the Pillow library
image = Image.frombytes(mode="RGBA", size=size, data=pixels)

Benchmarks

Now it's time for the exciting part! Remember, one of my goals with this project was to create an efficient file format, potentially comparable to a PNG. So lets see the results...
File NameImage SizePalette SizeSGF Generation TimePNG SizeSGF SizeDifference
forest_5.png(630, 360)80 colors68 ms91.81 KB907.2 KB988.14%
forest_6.png(630, 360)76 colors66 ms90.76 KB907.2 KB999.56%
ground_map.png(480, 540)9 colors75 ms9.77 KB1.04 MB10,607.78%
diagonal.png(64, 64)127 colors2 ms374 B16.39 KB4,381.82%
horizontal.png(64, 64)64 colors3 ms219 B16.39 KB7,483.11%
random.png(64, 64)255 colors2 ms5.99 KB16.39 KB273.82%
vertical.png(64, 64)64 colors2 ms219 B16.39 KB7,483.11%
white_square.png(512, 512)1 colors74 ms2.21 KB1.05 MB47,425.60%
battle.png(600, 270)12 colors47 ms10.95 KB648.0 KB5,915.68%
overworld.png(230, 120)10 colors8 ms6.11 KB110.4 KB1,806.05%
deco_flower.png(420, 240)9 colors28 ms7.14 KB403.2 KB5,645.53%
deco_leaf.png(420, 240)6 colors31 ms4.96 KB403.2 KB8,120.93%
green_battle.png(420, 240)10 colors27 ms8.06 KB403.2 KB4,999.43%
green_overworld.png(60, 20)5 colors1 ms410 B4.8 KB1,171.71%
Average-52 colors31.0 ms17.07 KB424.15 KB7,664.45%
...well that's a little disappointing. I'm happy with how long it took to get from png to sgf, but our sgf files were on average 7,664% the size of their equivalent png. However, it's definitely not unexpected considering we have no compression whatsoever at this point. Our data is as expanded as it can get (within reason). So, to deal with that...

Version 2

...we move on to version 2! The main change we introduce is a color palette. This is where that 255 palette size comes into play. The idea is that we will store the palette colors in a linear array, and then store the index of those colors in that palette, rather than the colors themselves. This means we will have to use some extra space in the header to store the palette, but in turn, the body of our file goes from a line of 4-byte colors to just 1-byte indexes. To implement this, we first need to know what colors we use

Packing to SGF

# Returns an array of [[count, color]]
colors = Image.getcolors()

# Isolates just the colors
colors = [color[1] for color in image.getcolors()]
Once again, the Pillow library simplifies the process significantly, this time with the getcolors method. That method also gives us how many pixels use each color, but we don't need that information. Once we have isolated just the colors, we need to store this into our sgf file. We'll store this information right after the size, but before the main body
# ...Store the image's size

# Store how many colors we used
file.write(struct.pack('B', len(colors)))

# Store the array of colors (palette)
file.write(np.array(colors, dtype=uint8).tobytes())

# ...Store the image's body
We need to store how many total colors we used so we know how many colors to parse later on. The last thing we need to do is store the indexes of each color into the file's body.
# Replaces the numpy array since we don't need tobytes()
pixels = list(image.getdata())

# Creates a hashmap in the form {color: index}
palette = {colors[index]: index for index in range(len(colors))}

for pixel in pixels:
    file.write(struct.pack('B', palette[pixel]))

Unpacking the SGF file

Unsurprisingly, the unpacking process is simply the reverse of the packing process. However, there's a small update to the parse_bytes helper method we can implement first.
def parse_bytes(format: str):
    nonlocal bp
    
    out = struct.unpack(format, data[bp:bp+struct.calcsize(format)])
    bp += struct.calcsize(format)
    
    if len(out) == 1:
        return out[0]
    return out

size = parse_bytes('HH')
This update allows us to parse multiple chunks of data at once. struct.unpack returns a tuple which is why we originally just returned out[0]. However, we won't always want a single chunk of data to be parsed at once. The first example, included above, is allowing the parse_bytes method to simply return a tuple for the size, rather than manually parsing the width and height, then adding them to a new tuple.

The next thing we need to do is parse the color palette. To do this, we need to first get the palette size, then iterate through each color, parsing and adding it to a hashmap
color_count = parse_bytes('B')
palette = {}
i = 0

while i < color_count:
    palette[i] = parse_bytes('BBBB')
    
    i += 1
Once we have our palette, we can iterate through the body, replacing each index with its respective color
# Load indexes
indexes = np.frombuffer(data, offset=bp, dtype=np.uint8)

# Replace indexes with colors
pixels = np.array([value for index in indexes for value in palette[index]], dtype=uint8)
That second line is a big reason why I enjoy coding in python so much. It comes with a bunch of odd features, but once you understand how they work, it can make writing code a lot quicker. Basically, we create a new list by extracting the R, G, B, and A values from the color, which we got by looking up the index from the palette. Expanding this out, we can get
for index in indexes:
    color = palette[index]
    
    for value in color:
        pixels.append(value)
With that, we can pass in the size and pixel data in a similar manner to version 1, and view the results for this second version!

Benchmarks

File NameImage SizePalette SizeSGF Generation TimePNG SizeSGF SizeDifference
forest_5.png(630, 360)80 colors43ms91.81 KB227.12 KB247.39%
forest_6.png(630, 360)76 colors45ms90.76 KB227.11 KB250.23%
ground_map.png(480, 540)9 colors48ms9.77 KB259.24 KB2,652.35%
diagonal.png(64, 64)127 colors1ms374 B4.61 KB1,232.35%
horizontal.png(64, 64)64 colors2ms219 B4.36 KB1,989.50%
random.png(64, 64)255 colors1ms5.99 KB5.12 KB85.56%
vertical.png(64, 64)64 colors1ms219 B4.36 KB1,989.50%
white_square.png(512, 512)1 colors50ms2.21 KB262.15 KB11,856.76%
battle.png(600, 270)12 colors29ms10.95 KB162.05 KB1,479.40%
overworld.png(230, 120)10 colors5ms6.11 KB27.64 KB452.23%
deco_flower.png(420, 240)9 colors18ms7.14 KB100.84 KB1,411.94%
deco_leaf.png(420, 240)6 colors18ms4.96 KB100.83 KB2,030.80%
green_battle.png(420, 240)10 colors17ms8.06 KB100.84 KB1,250.40%
green_overworld.png(60, 20)5 colors1ms410 B1.23 KB298.78%
Average-52 colors19.93ms17.07 KB106.25 KB1,944.80%
And we can see that this is much better! This second version is around 1/4 the size of the previous version, which makes perfect sense given what we updated. We turned each 4-byte color into a 1-byte index, which is 1/4 the size. We still have two more versions to get through, but we can already see some promising signs! Looking at the random.png test image, we can see that it is actually 80% of the size of its png equivalent! But like I said, we still have some work to do.

Version 3

The next major change we will introduce affects how we store neighboring colors. Especially for game sprites, we often have a lot of transparent space around the main focus of the image. However, we are currently storing all of those pixels individually. If we could group together lines of the same color, we could heavily reduce the average file size.

Packing to SGF

In order to add this repetition feature, we need to overhaul how we write the body of the file. We need to keep track of the previous pixel color, and how many of that color we've seen in the same line.
previous = None
reps = 0

for pixel in pixels:
    if previous != palette[pixel]:
        # if there was a previous color, add it to the body
        if previous != None:
            data += struct.pack('BB', reps, previous)
        reps = 1
    else:
        # Start a new line of repetition
        if reps >= 255:
            data += struct.pack('BB', reps, previous)
            reps = 0
        reps += 1
Since we are trying to limit the storage space used as much as possible, we again run into the limitations that using a single byte brings. Instead of just storing the pixel's palette index, we also store a 'repetition' byte that allows us to collapse multiple similar pixels into two bytes of information. Since we only allow 1 byte for repetition, we need to create a new line of repetition whenever we reach 255 repetition with the current color.

Unpacking the SGF file

Now to unpack this new format! The changes to unpacking are much simpler than the changes to packing the file. We need to keep track of how many pixels we've parsed, and loop until we have handled every pixel. At each step, we get the repetition count, and the pixel index
pixel_count = size[0] * size[1]
checked = 0

pixels = []

while checked < pixel_count:
    reps, index = parse_bytes('BB')
    
    # add a pixel for each repetition
    pixels += [palette[index] for _ in range(reps)]
    
    checked += reps

# PIL doesn't accept standard python arrays
pixels = np.array(pixels, dtype=np.uint8)

Benchmarks

With all those changes complete, we are done with this version of the sgf file format!
File NameImage SizePalette SizeSGF Generation TimePNG SizeSGF SizeDifference
forest_5.png(630, 360)80 colors61ms91.81 KB136.66 KB148.85%
forest_6.png(630, 360)76 colors49ms90.76 KB142.65 KB157.17%
ground_map.png(480, 540)9 colors45ms9.77 KB9.23 KB94.46%
diagonal.png(64, 64)127 colors2ms374 B8.71 KB2,327.54%
horizontal.png(64, 64)64 colors2ms219 B8.45 KB3,859.82%
random.png(64, 64)255 colors2ms5.99 KB9.18 KB153.40%
vertical.png(64, 64)64 colors1ms219 B389 B177.63%
white_square.png(512, 512)1 colors44ms2.21 KB2.07 KB93.49%
battle.png(600, 270)12 colors28ms10.95 KB18.4 KB167.98%
overworld.png(230, 120)10 colors6ms6.11 KB15.96 KB261.10%
deco_flower.png(420, 240)9 colors16ms7.14 KB12.06 KB168.87%
deco_leaf.png(420, 240)6 colors18ms4.96 KB7.9 KB159.09%
green_battle.png(420, 240)10 colors17ms8.06 KB12.27 KB152.08%
green_overworld.png(60, 20)5 colors1ms410 B397 B96.83%
Average-52 colors20.86ms17.07 KB27.45 KB572.74%
Overall, this is a pretty massive change. This new version has an average size that is about 1/4 the size of the previous version, and a lot of them are getting closer to their png equivalent! However, we can notice that some files got larger, notably the diagonal, horizontal, and random test images. This is really where one of the advantages of the png format comes in. It supports a wide variety of methods to generate images in different ways, while sgf really only has 1 method for compression.

Since the diagonal and horizontal test images don't really have any left-to-right neighbors, this new method only makes these worse. Additionally, the random test image doesn't have any correlation between neighbors, so adding this new repetition bit only serves to double the size. There are ways around this, which I will discuss more later on, but it's important to keep in mind that for my use case, simple pixel art game sprites, the issues we found in the test images likely won't appear.

If we remove the diagonal and horizontal test images, we have an average size difference of 152.58%, which is pretty good considering the limited methods of optimization we used. However, I have one more trick up my sleeve...

Version 4

This version is more of a cherry on top than antyhing major. The png format implements various methods to get its file size down, and then it compresses itself with the DEFLATE algorithm. So we will do something similar by using the gzip algorithm. They have the same underlying methods, but I decided to go with gzip somewhat arbitrarily.

All we need to do is add our final module, gzip (which is included in python). The changes are very simple, we just need to change two lines
import gzip

# read data
with gzip.open("file_name.sgf", 'rb') as file:
    data = file.read()

# store data
with open("file_name.sgf", 'wb') as file:
    file.write(gzip.compress(data))
These two changes make sure to decode the file when reading, and encode it when saving it to file.

Benchmarks

File NameImage SizePalette SizeSGF Generation TimePNG SizeSGF SizeDifference
forest_5.png(630, 360)80 colors57ms91.81 KB34.53 KB37.61%
forest_6.png(630, 360)76 colors65ms90.76 KB34.81 KB38.35%
ground_map.png(480, 540)9 colors42ms9.77 KB4.89 KB50.01%
diagonal.png(64, 64)127 colors2ms374 B626 B167.38%
horizontal.png(64, 64)64 colors2ms219 B340 B155.25%
random.png(64, 64)255 colors2ms5.99 KB6.04 KB100.95%
vertical.png(64, 64)64 colors1ms219 B290 B132.42%
white_square.png(512, 512)1 colors44ms2.21 KB43 B1.94%
battle.png(600, 270)12 colors27ms10.95 KB3.51 KB32.08%
overworld.png(230, 120)10 colors7ms6.11 KB2.71 KB44.33%
deco_flower.png(420, 240)9 colors17ms7.14 KB1.69 KB23.63%
deco_leaf.png(420, 240)6 colors18ms4.96 KB1.39 KB27.94%
green_battle.png(420, 240)10 colors17ms8.06 KB2.92 KB36.26%
green_overworld.png(60, 20)5 colors1ms410 B198 B48.29%
Average-52 colors21.57ms17.07 KB6.71 KB64.03%
Well that was surprisingly effective! With that seemingly small change, we have reduced our average file size by another 1/4. That means overall, this final version is 1/64 the size of our original implementation! And even more excitingly, our average file size is 64% the size of their respective png! Even our worst offenders from the previous version are just 50% larger than their respective png file. I find it almost comical how our smallest file is just 2% the size of its respective png, being just 43 bytes in size.

Conclusion

Overall, I'm very proud of how this project turned out. We were able to get the sgf file sizes so much smaller than I would have ever expected, succeeding in my goal of creating a file size smaller than png on average! There are still a few things that would be good additions, but I'll save that for a later time. The biggest improvement I can think of is the addition of a "flag byte" in the header. As discussed before, we only check neighbors from left-to-right, not up-to-down. Adding a bit flag to swtich to that mode could reduce some file sizes. We could also add some support for larger max sizes, or larger color palettes. But I'm pretty content with how this iteration turned out.