### Intro

Whenever I happen to render some 2D animations for a spritesheet I'm stuck with a ton of single frames because I can't find a proper tool to merge them quickly.
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
• Store files in memory
1. Merge frames into a single image (Sprite sheet)
• Calculate dimensions
• Create new sprite sheet
• Iterate over the frames
• Cut frame
• Paste into sprite sheet
1. 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 `Image`from 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()` 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.size
tile_height = frames.size
``````

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 ceil 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: 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 floor this time. Otherwise, every frame but the first would be one row too low. The whole thing will look like this: 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 modulo (`%`) it by the `max_sprite_row` like this: ``````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 `box`to 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_frame`and 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 ISO8601 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.size
tile_height = frames.size

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. 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 [...]

↩︎
2. 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

↩︎
3. 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

↩︎
4. 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

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

↩︎
{"ft_lang_label":"__label__en","ft_lang_prob":0.7094406,"math_prob":0.9405596,"size":13035,"snap":"2021-43-2021-49","text_gpt3_token_len":3303,"char_repetition_ratio":0.17757654,"word_repetition_ratio":0.16522624,"special_character_ratio":0.2617568,"punctuation_ratio":0.13336052,"nsfw_num_words":0,"has_unicode_error":false,"math_prob_llama3":0.9824934,"pos_list":[0,1,2,3,4,5,6],"im_url_duplicate_count":[null,3,null,3,null,3,null],"WARC_HEADER":"{\"WARC-Type\":\"response\",\"WARC-Date\":\"2021-10-16T06:21:27Z\",\"WARC-Record-ID\":\"<urn:uuid:440f628b-9b2a-48df-9f71-dacdb25b5064>\",\"Content-Length\":\"44932\",\"Content-Type\":\"application/http; msgtype=response\",\"WARC-Warcinfo-ID\":\"<urn:uuid:8b2c4850-12ff-4fc4-9e0a-e25c6c75d9bd>\",\"WARC-Concurrent-To\":\"<urn:uuid:eb775237-b619-4d05-ad76-a62da6bcaa60>\",\"WARC-IP-Address\":\"37.120.177.241\",\"WARC-Target-URI\":\"https://minzkraut.com/2016/11/23/making-a-simple-spritesheet-generator-in-python/\",\"WARC-Payload-Digest\":\"sha1:L6PBDIV52Z6NGIDW3WAN6AKHCPT22N5V\",\"WARC-Block-Digest\":\"sha1:QIHZ5PH65MDRR5LO2BCJLYH37IGTJEYU\",\"WARC-Identified-Payload-Type\":\"text/html\",\"warc_filename\":\"/cc_download/warc_2021/CC-MAIN-2021-43/CC-MAIN-2021-43_segments_1634323583423.96_warc_CC-MAIN-20211016043926-20211016073926-00117.warc.gz\"}"}