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:
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 imageimage = Image.open(file_path)
# Get the size and pixel datasize = image.size
pixels = image.getdata()
# Convert to a numpy array with 8-bit elementspixels = np.array(pixels, dtype=np.uint8)
Then, we need to convert these values to binary and save them to a file
import struct
withopen("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 = 0defparse_bytes(format: str):nonlocal bp
out = struct.unpack(format, data[bp:bp+struct.calcsize(format)])
bp += struct.calcsize(format)
return out[0]
data = Nonewithopen("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
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 Name
Image Size
Palette Size
SGF Generation Time
PNG Size
SGF Size
Difference
forest_5.png
(630, 360)
80 colors
68 ms
91.81 KB
907.2 KB
988.14%
forest_6.png
(630, 360)
76 colors
66 ms
90.76 KB
907.2 KB
999.56%
ground_map.png
(480, 540)
9 colors
75 ms
9.77 KB
1.04 MB
10,607.78%
diagonal.png
(64, 64)
127 colors
2 ms
374 B
16.39 KB
4,381.82%
horizontal.png
(64, 64)
64 colors
3 ms
219 B
16.39 KB
7,483.11%
random.png
(64, 64)
255 colors
2 ms
5.99 KB
16.39 KB
273.82%
vertical.png
(64, 64)
64 colors
2 ms
219 B
16.39 KB
7,483.11%
white_square.png
(512, 512)
1 colors
74 ms
2.21 KB
1.05 MB
47,425.60%
battle.png
(600, 270)
12 colors
47 ms
10.95 KB
648.0 KB
5,915.68%
overworld.png
(230, 120)
10 colors
8 ms
6.11 KB
110.4 KB
1,806.05%
deco_flower.png
(420, 240)
9 colors
28 ms
7.14 KB
403.2 KB
5,645.53%
deco_leaf.png
(420, 240)
6 colors
31 ms
4.96 KB
403.2 KB
8,120.93%
green_battle.png
(420, 240)
10 colors
27 ms
8.06 KB
403.2 KB
4,999.43%
green_overworld.png
(60, 20)
5 colors
1 ms
410 B
4.8 KB
1,171.71%
Average
-
52 colors
31.0 ms
17.07 KB
424.15 KB
7,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 colorscolors = [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 usedfile.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 inrange(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.
defparse_bytes(format: str):nonlocal bp
out = struct.unpack(format, data[bp:bp+struct.calcsize(format)])
bp += struct.calcsize(format)
iflen(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 = 0while 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 indexesindexes = np.frombuffer(data, offset=bp, dtype=np.uint8)
# Replace indexes with colorspixels = 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 Name
Image Size
Palette Size
SGF Generation Time
PNG Size
SGF Size
Difference
forest_5.png
(630, 360)
80 colors
43ms
91.81 KB
227.12 KB
247.39%
forest_6.png
(630, 360)
76 colors
45ms
90.76 KB
227.11 KB
250.23%
ground_map.png
(480, 540)
9 colors
48ms
9.77 KB
259.24 KB
2,652.35%
diagonal.png
(64, 64)
127 colors
1ms
374 B
4.61 KB
1,232.35%
horizontal.png
(64, 64)
64 colors
2ms
219 B
4.36 KB
1,989.50%
random.png
(64, 64)
255 colors
1ms
5.99 KB
5.12 KB
85.56%
vertical.png
(64, 64)
64 colors
1ms
219 B
4.36 KB
1,989.50%
white_square.png
(512, 512)
1 colors
50ms
2.21 KB
262.15 KB
11,856.76%
battle.png
(600, 270)
12 colors
29ms
10.95 KB
162.05 KB
1,479.40%
overworld.png
(230, 120)
10 colors
5ms
6.11 KB
27.64 KB
452.23%
deco_flower.png
(420, 240)
9 colors
18ms
7.14 KB
100.84 KB
1,411.94%
deco_leaf.png
(420, 240)
6 colors
18ms
4.96 KB
100.83 KB
2,030.80%
green_battle.png
(420, 240)
10 colors
17ms
8.06 KB
100.84 KB
1,250.40%
green_overworld.png
(60, 20)
5 colors
1ms
410 B
1.23 KB
298.78%
Average
-
52 colors
19.93ms
17.07 KB
106.25 KB
1,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 = Nonereps = 0for pixel in pixels:
if previous != palette[pixel]:
# if there was a previous color, add it to the bodyif previous != None:
data += struct.pack('BB', reps, previous)
reps = 1else:
# Start a new line of repetitionif 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 _ inrange(reps)]
checked += reps
# PIL doesn't accept standard python arrayspixels = np.array(pixels, dtype=np.uint8)
Benchmarks
With all those changes complete, we are done with this version of the sgf file format!
File Name
Image Size
Palette Size
SGF Generation Time
PNG Size
SGF Size
Difference
forest_5.png
(630, 360)
80 colors
61ms
91.81 KB
136.66 KB
148.85%
forest_6.png
(630, 360)
76 colors
49ms
90.76 KB
142.65 KB
157.17%
ground_map.png
(480, 540)
9 colors
45ms
9.77 KB
9.23 KB
94.46%
diagonal.png
(64, 64)
127 colors
2ms
374 B
8.71 KB
2,327.54%
horizontal.png
(64, 64)
64 colors
2ms
219 B
8.45 KB
3,859.82%
random.png
(64, 64)
255 colors
2ms
5.99 KB
9.18 KB
153.40%
vertical.png
(64, 64)
64 colors
1ms
219 B
389 B
177.63%
white_square.png
(512, 512)
1 colors
44ms
2.21 KB
2.07 KB
93.49%
battle.png
(600, 270)
12 colors
28ms
10.95 KB
18.4 KB
167.98%
overworld.png
(230, 120)
10 colors
6ms
6.11 KB
15.96 KB
261.10%
deco_flower.png
(420, 240)
9 colors
16ms
7.14 KB
12.06 KB
168.87%
deco_leaf.png
(420, 240)
6 colors
18ms
4.96 KB
7.9 KB
159.09%
green_battle.png
(420, 240)
10 colors
17ms
8.06 KB
12.27 KB
152.08%
green_overworld.png
(60, 20)
5 colors
1ms
410 B
397 B
96.83%
Average
-
52 colors
20.86ms
17.07 KB
27.45 KB
572.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 datawith gzip.open("file_name.sgf", 'rb') as file:
data = file.read()
# store datawithopen("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 Name
Image Size
Palette Size
SGF Generation Time
PNG Size
SGF Size
Difference
forest_5.png
(630, 360)
80 colors
57ms
91.81 KB
34.53 KB
37.61%
forest_6.png
(630, 360)
76 colors
65ms
90.76 KB
34.81 KB
38.35%
ground_map.png
(480, 540)
9 colors
42ms
9.77 KB
4.89 KB
50.01%
diagonal.png
(64, 64)
127 colors
2ms
374 B
626 B
167.38%
horizontal.png
(64, 64)
64 colors
2ms
219 B
340 B
155.25%
random.png
(64, 64)
255 colors
2ms
5.99 KB
6.04 KB
100.95%
vertical.png
(64, 64)
64 colors
1ms
219 B
290 B
132.42%
white_square.png
(512, 512)
1 colors
44ms
2.21 KB
43 B
1.94%
battle.png
(600, 270)
12 colors
27ms
10.95 KB
3.51 KB
32.08%
overworld.png
(230, 120)
10 colors
7ms
6.11 KB
2.71 KB
44.33%
deco_flower.png
(420, 240)
9 colors
17ms
7.14 KB
1.69 KB
23.63%
deco_leaf.png
(420, 240)
6 colors
18ms
4.96 KB
1.39 KB
27.94%
green_battle.png
(420, 240)
10 colors
17ms
8.06 KB
2.92 KB
36.26%
green_overworld.png
(60, 20)
5 colors
1ms
410 B
198 B
48.29%
Average
-
52 colors
21.57ms
17.07 KB
6.71 KB
64.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.