AI Meme Engineer (3/4) – Meme Image Generation

Welcome to part 3, where we will be getting started on the image generation logic. The code for this is not too bad but it does require some explanation. We’ll make sure to go over it in detail.

The first thing we’ll need is Pillow!

No, not that kind of pillow. Pillow, originally known as PIL (Python Imaging Library), is a library that adds image processing capabilities for Python. So let’s install it first by running this command in your terminal:

pip install pillow

Starting on the meme image editor

With that out of the way, let’s get coding! First create a new file called meme_image_editor.py in your project root folder:

πŸ“ Meme_Gen
    πŸ“ fonts
        πŸ“„ ARIAL.TTF
        πŸ“„ ComicSansMS3.ttf
        πŸ“„ impact.ttf
    πŸ“ output
    πŸ“ templates
        πŸ–Ό (all the images in here...)
    πŸ“„ .env
    πŸ“„ get_meme.py
    πŸ“„ load_meme_data.py
    πŸ“„ meme_data.json
    πŸ“„ meme_image_editor.py  --> create this file
    πŸ“„ system_instructions.py

Now inside this new file, let’s start by importing the necessary modules:

import textwrap
from pathlib import Path
from uuid import uuid4

from PIL import Image, ImageDraw, ImageFont

from load_meme_data import MemeData

First up we have textwrap. This module is a part of the standard Python Library and provides utilities for formatting and wrapping text. It is particularly useful for ensuring that text fits within a certain width, which will be perfect for us when overlaying text on our images.

Next, we have Path and uuid4. Path is a class from the pathlib module that represents the path to a file or directory, our output image folder for example. uuid4 is a function from the uuid module that generates a random UUID (Universally Unique Identifier) which we will use to name our output images so they don’t overwrite each other.

Finally, we have Image, ImageDraw, and ImageFont from the Pillow library. These classes will allow us to work with the image data, and you will see them used in action later.

Next up, let’s define some constants:

ROOT_DIRECTORY = Path(__file__).resolve().parent
IMAGE_FOLDER = ROOT_DIRECTORY / "templates"
FONT_FOLDER = ROOT_DIRECTORY / "fonts"
OUTPUT_FOLDER = ROOT_DIRECTORY / "output"
LINE_HEIGHT_MULTIPLIER = 1.4

First we get the ROOT_DIRECTORY by giving Path the __file__ attribute of the current module (meme_image_editor.py) and using .resolve() to get the path to this file. We then use .parent to get the parent directory which contains this file, thus the root directory of our project.

Now we can easily create paths to our templates, fonts, and output folders by using the / operator to concatenate the ROOT_DIRECTORY with the folder name.

The last constant LINE_HEIGHT_MULTIPLIER will be used later to make sure the text has the correct height for the box coordinates it’s supposed to fit in. It’s declared here so we can easily adjust the number later if needed, but I’ve found 1.4 to work very well through trial and error.

Utility functions

We’ll create a couple of utility functions before we get into the main logic. First of all, most memes are typed IN ALL CAPITAL LETTERS, but not all of them. For the UNO Draw 25 meme, for example, I prefer the comic sans font in lowercase. We haven’t asked ChatGPT to account for this and we don’t have to as this is very easy to fix:

def handle_text_caps(font_name: str, text: str) -> str:
    if font_name == "impact.ttf":
        return text.upper()
    elif font_name == "ComicSansMS.ttf":
        return text.lower()
    return text

Our utility function handle_text_caps takes in the font_name and the text as strings. If the font is Impact I want the return to be in ALL CAPITAL LETTERS. If the font is Comic Sans, I want the return to be in all lowercase. For Arial I want the text to be as is, so there is no need to name it as it will just pass through this function unchanged.

Note: If you did use extra fonts or different names, make sure to use the correct names for your setup here.

The next thing we’ll need is a utility function which gives us a rough estimate of how wide a typical character is for a particular font at a particular size.

Why do we need to know this? Well, the textwrap function that we will use later requires a width for each line of text before it should wrap to the next line. This is not measured in pixels but in the number of characters before the line should wrap. So we need to know the width of a character in a particular font and size to calculate how many characters can fit inside of our pixel coordinates for the textbox.

So add the following function:

def get_char_width_in_px(font: ImageFont.FreeTypeFont, font_name: str) -> int:
    representative_character = handle_text_caps(font_name, "A")
    char_left_top_right_bottom = font.getbbox(representative_character)
    return char_left_top_right_bottom[2] - char_left_top_right_bottom[0]

We named the function get_char_width_in_px and it takes in a font object of type ImageFont.FreeTypeFont and a font_name as a string. ImageFont.FreeTypeFont is just the name of the class that ImageFont uses to represent a font. We will get this type of object later in our main logic when we load the font and then pass it into this function.

First we get a representative character for the font. In this case, we use the letter “A”, either as a capital letter or not, using our handle_text_caps function to decide. Different letters have different widths, so using A is just a rough estimate, but it works well enough for our purposes.

The ImageFont.FreeTypeFont has a method called getbbox (get bounding box) which returns a tuple of 4 integers representing the left, top, right, and bottom coordinates of the bounding box of the character. So basically a list of 4 numbers that represent the width and height of the character.

We’re going to return the value of the right coordinate (located in index 2) minus the left coordinate (located in index 0) to get the width of the character in pixels which is the return for the function, being the width of a single letter a for this font at this size.

The next utility function is going to be very easy! We need a function to generate unique filenames for our output images. Add this function to your code:

def get_unique_filename() -> Path:
    return OUTPUT_FOLDER / f"{uuid4()}.png"

This function is called get_unique_filename and it returns a Path object. We use the uuid4 function to generate a random UUID and then concatenate it with the .png extension and the OUTPUT_FOLDER path to create a unique filename for our output images. This path will look something like ‘output\0d2e83d3-f0d9-4a53-a270-dd8bde38d207.png’.

Next up is a utility function to calculate the total height of a wrapped text. So say we have some text that is wrapped over 4 lines total, we need to know the exact pixel height for those lines of text combined for a given font and font size.

Why do we need to know the exact height of a block of several lines of wrapped text? Well, we have the x, y, width, and height coordinates of where the text is supposed to go in our memedata. We need a way to test if the height of this block of text at the current font size will fit within the height we defined for the textbox in our meme_data.json file.

Let’s take it one step at a time though, this function is only going to be concerned with calculating the total height of the block of text and returning the value:

def calculate_text_height(
    drawing, lines: list[str], font: ImageFont.FreeTypeFont
) -> int:
    total_text_height = 0
    for line in lines:
        bbox_for_line = drawing.textbbox((0, 0), line, font=font)
        bbox_top = bbox_for_line[1]
        bbox_bottom = bbox_for_line[3]
        total_text_height += bbox_bottom - bbox_top
    return int(total_text_height * LINE_HEIGHT_MULTIPLIER)

Let’s go over this as it may seem a bit confusing to read, starting with the input arguments. We have drawing which is an ImageDraw object we will pass in from the main function later. This ImageDraw object allows us to draw stuff, in this case test text just to see what the height will be. The second argument is lines which is a list of strings, each string being a line of text that has been wrapped by the textwrap module. So imagine something like: