Making a simple sprite sheet generator in Python


Intro

I don't always render frames for sprite sheets - - but when I do, I can't find a proper tool to merge them.
So I thought, why not make my own script to generate sprite sheets from single frames, something easy to use and simple. My goto language this time was Python, but as always the concept can be implemented in almost any language.

Let's define what such a 'sprite sheet generation' script would have to accomplish before we begin

  1. Find frames in a folder
  2. Merge frames into a single image
  3. Save the image as a new file

This is pretty much the highest level of abstraction, so let's split each of these tasks up to get a better idea of what we actually need to program

  1. Find frames in a folder
    • Index files
    • Read files
    • Store files in memory
  2. Merge frames into a single image (Sprite sheet)
    • Calculate dimensions
    • Create new sprite sheet
    • Iterate over the frames
      • Cut frame
      • Paste into sprite sheet
  3. Save the sprite sheet as a PNG file
    • well... just save the file?!

Sounds simple, right?
The best way to find out is to dive right into Python and try...


Imports and variables

Before we start, we need to import some libraries. We'll need Imagefrom Pillow to handle the image processing, os to handle the files, math to help us with some calculations and time to get information about the date and time.

Then we can define some of our variables like the maximum amount of sprites per row, a variable to hold our loaded frames and the width/height of the frames/sprite sheet. The latter will be calculated later on so initiating them with 0 will suffice.

from PIL import Image  
import os, math, time  
max_sprites_row = 3.0

frames = []

tile_width = 0  
tile_height = 0

spritesheet_width = 0  
spritesheet_height = 0  

Handling the files

First of all, we want to store the filenames of all our frames in a list called files. To accomplish that we use the listdir method from os and feed it "frames/" as the input folder.

"frames/" will be the place where all the single frames we want to merge go. This will leave us with a list of all the files, but they are not sorted correcly.

>>> files
['img0006.png', 'img0008.png', 'img0002.png', 'img0004.png', 'img0007.png', 'img0010.png', 'img0001.png', 'img0003.png', 'img0009.png', 'img0005.png']

Because we need to merge those frames in the correct order though, we have to fix that. A quick files.sort() should do the trick in most cases.
If it doesn't, consider renaming your frames

files = os.listdir("frames/")  
files.sort()  

Now we should get

>>> files
['img0001.png', 'img0002.png', 'img0003.png', 'img0004.png', 'img0005.png', 'img0006.png', 'img0007.png', 'img0008.png', 'img0009.png', 'img0010.png']

The next steps are to iterate over each file, check whether it's actually an image or some unsupported file, and based on that, either lord the file and append it to our frames list or "pass".

for current_file in files :  
    try:
        with Image.open("frames/" + current_file) as im :
            frames.append(im.getdata())
    except:
        print(current_file + " is not a valid image")

We use the getdata()4 method to save the actual 'image core' into the list, because the image file itself will be closed automatically.


Preparing the sprite sheet

Now we'll get the dimensions of the first frame. Based on those, assuming that all frames are of equal size, we can calculate the final width and height of the sprite sheet. We're going to need them to create our sprite sheet canvas which the frames will be drawn onto.

for current_file in files :  
    try:
        with Image.open("frames/" + current_file) as im :
            frames.append(im.getdata())
    except:
        print(current_file + " is not a valid image")

tile_width = frames[0].size[0]  
tile_height = frames[0].size[1]  

To get the final height, we first check if we even have enough frames to fill more than one row. If we do, take the height of one tile and multiply it by the number of rows we require to fit all frames. How do we know how many rows we need? Simple, just take the total amount of frames divided by the maximum amount of frames-per-row. We'll assign that value to required_rows

e.g. 25 frames / 5 frames-per-row would yield 5 rows

Because some combinations of total frames and max-frames-per-row will sooner or later cause our required_rows to be uneven, we have to have at least one float in the equation, hence the ".0" in the max_frames_row initialization.
(see Imports and variables)

Thus python will give us the "exact" solution without rounding. We're then going to ceil1 that value. By ceiling the result we assure that we won't get e.g. 3.3333 rows but a full 4th row.

if len(frames) > max_frames_row :  
    required_rows = math.ceil(len(frames)/max_frames_row)
    print(required_rows)
    spritesheet_height = tile_height * required_rows
else:  
    spritesheet_height = tile_height

To determine the width of our sprite sheet we just multiply the tile_width with the maximum frames that can go into one line, and if we don't have enough frames to fill a line, just multiply it by the amount of frames we have.

if len(frames) > max_frames_row :  
    spritesheet_width = tile_width * max_frames_row
    required_rows = math.ceil(len(frames)/max_frames_row)
    spritesheet_height = tile_height * required_rows
else:  
    spritesheet_width = tile_width*len(frames)
    spritesheet_height = tile_height

Now we can initialize our sprite sheet with the given dimensions after casting them to integers. This will give us a blank canvas with a transparent background

spritesheet = Image.new("RGBA",(int(spritesheet_width), int(spritesheet_height)))  
spritesheet.save("spritesheet.png", "PNG")  

Finding coordinates

Before we can start copying and pasting our frames onto our newly created canvas, we have to figure out how to get each frames alignment.
What we need are the coordinates on the final sprite sheet where we have to paste the frame. In our case we need the top and left corrdinate:
The coordinates we need to paste the frame

Just like finding the required amount of rows by dividing all frames by max_frames_row we can find the exact row our current frame needs to go by dividing its index by max_frames_row.

Instead of ceiling it we have to floor2 this time. Otherwise, every frame but the first would be one row too low. The whole thing will look like this:
How we get the tile row

We will multiply the tile_height by this value so we get the top position for our frame in the sprite sheet. That's the reason why we start counting rows at 0 (tile_height * 0 will always be 0 and thus be at the very top)

The top position could also be called offset. E.g with a tile height of 200px the offset from the top for tile 9 would be: 200*2 = 400px

for current_frame in frames :  
    top = tile_height * math.floor((frames.index(current_frame))/max_frames_row)

To get the horizontal position of the frame we'll simply take it's index and modulo3 (%) it by the max_sprite_row like this:
How we calculate the column/left position

frames.index(current_frame) % max_frames_row  


Multiplying the tile_width with this value will get us the position of the left border where our current frame will be on the sprite sheet. (the column)

The bottom and right coordinate of our frame on the sprite sheet is way easier to get by just adding the width and height of one tile to the top and left coordinate.

for current_frame in frames :  
    top = tile_height * math.floor((frames.index(current_frame))/max_frames_row)
    left = tile_width * (frames.index(current_frame) % max_frames_row)

    bottom = top + tile_height
    right = left + tile_width

These will define the box where the frame will be pasted. Because the paste()method doesn't accept floats we have to cast all the items of boxto integers.

for current_frame in frames :  
    top = tile_height * math.floor((frames.index(current_frame))/max_frames_row)
    left = tile_width * (frames.index(current_frame) % max_frames_row)
    bottom = top + tile_height
    right = left + tile_width

    box = (left,top,right,bottom)
    box = [int(i) for i in box]

Copying and pasting

This is where the most important part happens, the copying and pasting from our original frames into the sprite sheet.
Now that we have all the sizes and coordinates we need we'll just cut the entire current_frameand store it in cut_frame

    cut_frame = current_frame.crop((0,0,tile_width,tile_height))

Mind the double "((" because crop expects a tuple!
Pasting this into the sprite sheet is now as easy as

spritesheet.paste(cut_frame, box)  

And that's all there is to transfering the frame onto the sprite sheet canvas.

for current_frame in frames :  
    top = tile_height * math.floor((frames.index(current_frame))/max_frames_row)
    left = tile_width * (frames.index(current_frame) % max_frames_row)
    bottom = top + tile_height
    right = left + tile_width

    box = (left,top,right,bottom)
    box = [int(i) for i in box]
    cut_frame = current_frame.crop((0,0,tile_width,tile_height))

    spritesheet.paste(cut_frame, box)

The only thing that's left to do now...


Saving the sprite sheet

This is just one line of code

sprite sheet.save("spritesheet.png", "PNG")  

Every time we now run the script, all frames will be merged into a single sprite sheet which is being saved to spritesheet.png. This would be enough to happily generate loads of sprite sheets, but you'd have to be careful because the old one would be overwritten every time a new one is being generated.However, that's an easy fix. We can just append the current date and time

spritesheet.save("spritesheet" + time.strftime("%Y-%m-%dT%H-%M-%S") + ".png", "PNG")  

The filenames will look something like this

minzkraut:~/workspace/spritesheet $ ls -lah  
total 1.5M  
drwxr-xr-x 4 minzkraut minzkraut 4.0K Nov 24 15:19 ./  
drwxr-xr-x 7 minzkraut minzkraut 4.0K Nov 23 14:01 ../  
-rw-r--r-- 1 minzkraut minzkraut 4.4K Nov 24 15:13 createSpriteSheet.py
drwxr-xr-x 2 minzkraut minzkraut 4.0K Nov 24 13:08 frames/  
-rw-r--r-- 1 minzkraut minzkraut 203K Nov 24 15:13 spritesheet2016-11-24T15-13-35.png
-rw-r--r-- 1 minzkraut minzkraut 203K Nov 24 15:19 spritesheet2016-11-24T15-19-30.png
-rw-r--r-- 1 minzkraut minzkraut 203K Nov 24 15:19 spritesheet2016-11-24T15-19-31.png
-rw-r--r-- 1 minzkraut minzkraut 203K Nov 24 15:19 spritesheet2016-11-24T15-19-32.png
-rw-r--r-- 1 minzkraut minzkraut 203K Nov 24 15:19 spritesheet2016-11-24T15-19-33.png

I chose that format according to ISO86015 but replaced the colons with hyphens for compatibility reasons with Windows filesystems. This might not be optimal in terms of readability, but it guarantees a unique file name for every generated sprite sheet.


Conclusion

It's not hard to quickly generate sprite sheets from frames using Python and the Pillow library in under 50 lines of code. Even though this script might not be well optimised regarding speed/resource handling or usability, it sure does its job. By applying some simple math, we can create multiline sheets in a left to right order. I've tested it with 150+ frames, 125x270 each, without any alignment or performance issues.
The complete script is available on GitHub at /JanGross/SpriteSheetGenerator or can be copied below:

from PIL import Image  
import os, math, time  
max_frames_row = 10.0  
frames = []  
tile_width = 0  
tile_height = 0

spritesheet_width = 0  
spritesheet_height = 0

files = os.listdir("frames/")  
files.sort()  
print(files)

for current_file in files :  
    try:
        with Image.open("frames/" + current_file) as im :
            frames.append(im.getdata())
    except:
        print(current_file + " is not a valid image")

tile_width = frames[0].size[0]  
tile_height = frames[0].size[1]

if len(frames) > max_frames_row :  
    spritesheet_width = tile_width * max_frames_row
    required_rows = math.ceil(len(frames)/max_frames_row)
    spritesheet_height = tile_height * required_rows
else:  
    spritesheet_width = tile_width*len(frames)
    spritesheet_height = tile_height

print(spritesheet_height)  
print(spritesheet_width)

spritesheet = Image.new("RGBA",(int(spritesheet_width), int(spritesheet_height)))

for current_frame in frames :  
    top = tile_height * math.floor((frames.index(current_frame))/max_frames_row)
    left = tile_width * (frames.index(current_frame) % max_frames_row)
    bottom = top + tile_height
    right = left + tile_width

    box = (left,top,right,bottom)
    box = [int(i) for i in box]
    cut_frame = current_frame.crop((0,0,tile_width,tile_height))

    spritesheet.paste(cut_frame, box)

spritesheet.save("spritesheet" + time.strftime("%Y-%m-%dT%H-%M-%S") + ".png", "PNG")  

Footnotes
  1. math.ceil: Return the ceiling of x as a float, the smallest integer value greater than or equal to x.
    https://docs.python.org/2/library/math.html

  2. math.floor: Return the floor of x as a float, the largest integer value less than or equal to x.
    https://docs.python.org/2/library/math.html

  3. Modulo (%): The % (modulo) operator yields the remainder from the division of the first argument by the second.
    https://docs.python.org/2/reference/expressions.html

  4. im.getdata(): Returns the contents of this image as a sequence object containing pixel values [...] Note that the sequence object returned by this method is an internal PIL data type [...]
    http://pillow.readthedocs.io/en/3.1.x/reference/Image.html

  5. ISO 8601 Data elements and interchange formats: https://en.wikipedia.org/wiki/ISO_8601