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
- Find frames in a folder
- Merge frames into a single image
- 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
- Find frames in a folder
- Index files
- Read files
- Store files in memory
- Merge frames into a single image (Sprite sheet)
- Calculate dimensions
- Create new sprite sheet
- Iterate over the frames
- Cut frame
- Paste into sprite sheet
- 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()
[1] 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 ceil[2] 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[3] 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[4] (%
) 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[5] 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
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.htmlmath.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.htmlmath.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.htmlModulo (%): The % (modulo) operator yields the remainder from the division of the first argument by the second.
https://docs.python.org/2/reference/expressions.htmlISO 8601 Data elements and interchange formats: https://en.wikipedia.org/wiki/ISO_8601