Creating my own image generation API with Flask and Pillow
TLDR: I wanted to make my own version of Bannerbear, so I created a very basic Python app. It uses the Pillow library to generate the PNG images and the Flask library to serve the API. It mostly worked! It was fun. Would recommend as a weekend project.
Skip the preamble and go straight to the How it works section.
Sometimes you see someone's SaaS product and you get really jealous. Wicked jealous, in fact. The product is great, the marketing is superb, the story is exciting, the money is apparent, and the success of it all is so aluring.
This happened to me recently when I rediscovered a product that I'd found on Hacker News about a year ago: Bannerbear, which is a SaaS product that allows you to use web API calls to generate banner images that can be quickly and programatically used in social media posts, website content, and any other text-and-logo-on-a-nice-background-as-a-PNG-file use case.
Now, I haven't actually tried it out yet, but if the functionality matches the landing page sales pitch then, honestly…it's really cool.
I'd recently found myself in need of something like this – the company I co-own, Arc Analytics, is starting our first serious marketing effort soon, for which we'll be creating blog posts, videos, LinkedIn posts, and we may exlore using other social media options as well.
For all of our content, we'll need banner and thumbnail images to make our content stand out and have more of a consistent feel. I didn't like the idea of having to manually create all of these images myself every time we had new content to put out, especially if I wanted to ensure consistency. I also could have just paid for Bannerbear (and I still might), but free is also great and I think it's something I can solve myself. Since our team is small, anything laborious or costly that I can otherwise automate, I will.
So, I did! Nothing near as robust as Bannerbear, it's really more of a proof of concept to see how it could work. I created this small program using Python, specifically using the Pillow library to generate the images and serving it up with Flask. There are just two REST endpoints for the app: /new
and /test
:
/new
accepts aGET
call with atext
query parameter. Thetext
is the text that's placed on the image and then generated with Pillow, which is what's returned./test
is a webpage where a user can test the/new
API to preview the image it returns.
Now let's see how all the pieces fit together.
How it works
We have code that generates the image and then code that serves the API. Here's all of the code, which can be interacted with through Replit:
Let's first look at how we actually generate the images given user-provided text.
Image generation functions
We start this off by looking at our image_functions.py
file, which has all of our image-manipulation code and helper functions.
Here are the functions used:
generate_nonce() -> str
- This generates and then returns an 8 character string using numbers, upper-, and lowercase letters. We'll use this function to help us create folders with random names wherein we temporarily store the images.save_image(i: Image, file_name: str) -> str
- This function takes our generated image and a file name and then saves that image with the filename to a randomly-named folder.generate_image(text: str) -> Image
- This is the primary function. It takes a string, which is the text that we want on our image, and then uses the Pillow library to generate a PNG image, which is what is returned.
Going further in the generate_image()
function, here are the steps to generating our PNG file:
- At the top of our file, we import
Image
,ImageFont
, andImageDraw
from the PIL library. - We open our base image,
layered-waves-haikei.png
, which was generated with the incredible Haikei tool, and then instantiate a newImageDraw.Draw
instance with that image. - We load in our preferred font, Nexa Light.
- Then we set the margins for the text that will go into the image – I go with 50px, which I think looks nice.
- Now we need to calculate where to insert the line breaks. I was surprised that the Pillow library didn't have an option for this (or at least I couldn't figure it out), so we do some extra steps to achieve this:
- Take the width of the base image, minus the left and right margins we set before;
- Split the user-provided text on each space character so that we (mostly) get each word;
- Loop through each word, where we calculate the width of the word using the
draw.textlength()
Pillow function and check to see if it would make the current row of text too long for the image + margins. If it's not too long, we add the word to the current row of text and then go to the next word. If the word would make the current line too long, we add a line break and then that word starts the new line.
- Finally, the resulting block of properly-line-broken text gets written on the base image using the
draw.multiline_text()
Pillow function and then the resulting generated image is returned.
API serving functions
Now let's look at how we're serving up the application. Flask is my go-to for this since it's mature and easy to use. The Flask code is in our app.py
file.
Here are the routes I created for this project:
@app.route("/new", methods=["GET"])
/new_image()
- The/new
route is what we can use to actually generate an image with provided text. We provide our text using thetext
query parameter. This can also be provided as a form data element. When this route is used, here's what happens:- Check to see if the
dl
query parameter was included in the request (dl
is short for "download"). If it was, we'll ultimately return our generated image as an attachment. - If no
text
was provided in the request, we instead use some example text. - We then use the
generate_image()
function from ourimage_functions.py
file to generate the image with the user-provided text (or the example text if notext
was provided). - The filename for the generated image is "newimage" joined with the current UNIX timestamp.
- Then we use the
save_image()
function from ourimage_functions.py
file to store the image to a new folder. - Finally, we use the
send_file()
Flask function to return the generated image to the user.
- Check to see if the
@app.route("/test")
/test_new_image()
- This route is used to test out the above/new
functionality. It's a bare-bones webpage with a text input, submit button, and an image preview element.
How it could work better
This project was always meant to only be a little project, the goal of it being to improve my skills with Flask, Pillow, and Python in general. Because of this, it lacks a ton of features that would normally be important for an application like Bannerbear. It lacks strong security, more complete Image generation features like logo placement or different base image options, different font options, etc.
The most glaring thing it's missing, in my opinion, is the lack of temporary image clean-up – as it stands, those images and their "temporary folders" aren't temporary at at all! I had initially planned on adding a "automatically delete temp folders and their contents after 5 minutes" component as a way to learn how to use the Celery library, but I just never did that part 🤷♂️. If I were to revisit this project, that'd be the first thing I'd add.
Takeaways
This project was:
- Fun - I love jumping into a new coding project and learning how to use new functions, libraries, and patterns. This one was no exception!
- Fulfilling - Getting this to work was…chef's kiss.
I hope to revisit this project at some point, as there are a ton of ways I can make it better and more useable!