Content & Marketing

Add Text Overlays to AI Images with Python

Composite crisp captions onto AI-generated images with Pillow: canvas-based font sizing, textbbox measuring, word wrap, scrims, strokes and a CSV batch loop.

By the end of this guide you will have a single overlay() function that takes an image file and a line of text and returns a finished, correctly sized graphic with a caption that is centred, wrapped and readable on any background. It takes about twenty minutes to build, and it runs offline once your base images exist. The last step turns it into a batch job that reads a spreadsheet of captions and writes a folder of finished files.

This guide belongs to AI Image Generation with Python and DALL·E, and it is the piece that makes generated art actually usable in marketing. Image models are still unreliable at rendering words: they paint shapes that resemble letters rather than setting real type, so you get near-misses, doubled letters and invented characters. The fix is a division of labour. Ask the model for a clean, text-free background, then composite the words yourself with Pillow, the standard Python imaging library.

Prerequisites

You need Python 3.10 or newer and a working virtual environment. If that is new to you, start with Create a Python Virtual Environment for AI and come back. Then install the packages:

pip install "Pillow>=10.0" "openai>=1.30" "httpx>=0.27" "python-dotenv>=1.0"

Pillow does all the drawing. The openai and httpx packages are only needed if you generate the base image in the same script rather than loading one you already have; python-dotenv reads your key from a file so it never sits in your source code.

You also need one TrueType font file — a .ttf or .otf on disk. Pillow cannot use a font by its name the way a design tool can; it needs a path. Every operating system ships with several:

Linux    /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
macOS    /System/Library/Fonts/Supplemental/Arial Bold.ttf
Windows  C:\Windows\Fonts\arialbd.ttf

Copy the one you like into your project folder so the script works on any machine you move it to. If you plan to generate backgrounds too, put your key in a .env file next to your script:

OPENAI_API_KEY=sk-your-key-here

Then add that file to .gitignore before you write another line, so the key never reaches a public repository:

echo ".env" >> .gitignore

A leaked key gets found and spent by strangers, often within the hour. If your key is rejected once you start calling the API, Fix the 401 Unauthorized Error in OpenAI Python walks through the causes.

Step 1 — Get a base image and fit it to your canvas

Everything downstream depends on knowing the exact pixel size you are working at, so decide the canvas first and force the base image to match. A canvas is simply the final width and height in pixels: 1200 by 630 for a blog header, 1080 by 1080 for a square social post.

ImageOps.fit does the whole job in one call. It scales the source so it covers the target and crops whatever hangs over the edge, which keeps the composition centred instead of squashing it. The LANCZOS filter is the high-quality resampling algorithm — it decides what colour each new pixel becomes when the grid changes, and it keeps edges crisp when you shrink an image.

from PIL import Image, ImageOps

CANVAS = (1200, 630)  # width, height in pixels


def load_canvas(path: str, size: tuple[int, int] = CANVAS) -> Image.Image:
    """Open an image file and fit it to the exact canvas size, cropping the overflow."""
    img = Image.open(path).convert("RGB")
    return ImageOps.fit(img, size, method=Image.Resampling.LANCZOS)


base = load_canvas("background.png")
print(base.size)  # (1200, 630)

Always fit down from a larger source. Enlarging a small image invents detail that was never there and the result looks soft. If you are generating the background in the same run, ask for the largest size the model offers and tell it plainly to leave the words out:

import io
import os

import httpx
from dotenv import load_dotenv
from openai import OpenAI
from PIL import Image

load_dotenv()  # reads .env, which is in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def generate_base(prompt: str) -> Image.Image:
    """Generate a background with no lettering and return it as a Pillow image."""
    response = client.images.generate(
        model="dall-e-3",
        prompt=f"{prompt}. No text, no words, no letters. Keep the lower third calm and uncluttered.",
        size="1792x1024",
        quality="standard",
        n=1,
        response_format="url",
    )
    raw = httpx.get(response.data[0].url, timeout=60).content
    return Image.open(io.BytesIO(raw)).convert("RGB")

The pipeline from here is fixed, and it is worth holding the whole shape in your head before you write the rest.

The six stages of compositing a caption onto a generated image A six-stage flow that runs from opening the base image, fitting it to the canvas, loading the font, measuring the caption, wrapping and centring it, and finally drawing the scrim and saving the file. Load base image Image.open Fit to canvas ImageOps.fit Load the font ImageFont.truetype Measure the text draw.textbbox Wrap and centre draw.textlength Scrim, then save img.save(PNG)
Every overlay follows the same six stages; only the measuring and wrapping steps in the middle need any real thought.

Step 2 — Size the font from the canvas and measure the string

Hard-coding a font size is the mistake that makes overlay scripts fragile. A size of 54 pixels looks right on a 1200-pixel header and disappears on a 1920-pixel banner. Derive the size from the canvas width instead, using a divisor you can tune per format, and the same function serves every destination.

Measuring comes next. Pillow gives you two tools. draw.textlength(text, font=font) returns a single number, the horizontal advance in pixels — that is what you want for wrapping decisions. draw.textbbox((0, 0), text, font=font) returns four numbers, the left, top, right and bottom edges of the ink relative to the anchor point you passed in, which is what you want when you need the real height of a line including its descenders.

from PIL import Image, ImageDraw, ImageFont

FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"


def load_font(canvas_width: int, divisor: int = 22) -> ImageFont.FreeTypeFont:
    """Pick a font size proportional to the canvas so overlays scale with the image."""
    size = max(16, canvas_width // divisor)
    return ImageFont.truetype(FONT_PATH, size)


img = Image.new("RGB", (1200, 630), "white")  # stand-in for your base image
draw = ImageDraw.Draw(img)
font = load_font(img.width)

caption = "Ship faster with one Python script"
left, top, right, bottom = draw.textbbox((0, 0), caption, font=font)

print("font size:", font.size)          # 54
print("width:", right - left)           # ink width in pixels
print("advance:", draw.textlength(caption, font=font))

Note what you are not doing: counting characters. Character counts lie, because a capital W is roughly three times the width of a lowercase i. Ask the font.

If you are on an older tutorial you may see draw.textsize(). That method was removed in Pillow 10 and raises an AttributeError today. textbbox and textlength replace it.

Step 3 — Wrap the caption and centre the block

Real captions are longer than one line. The wrapping rule is simple: add words to the current line until adding the next one would cross your maximum width, then start a new line. Because you measure with the actual font, the break lands exactly where the pixels run out.

Reserve a margin first. A margin of about six percent of the canvas width on each side keeps text clear of the edge, where social platforms sometimes overlay their own interface. What is left is your wrap width.

def wrap_lines(draw, text: str, font, max_width: int) -> list[str]:
    """Split text into lines that each fit inside max_width pixels."""
    lines, current = [], ""
    for word in text.split():
        candidate = f"{current} {word}".strip()
        if not current or draw.textlength(candidate, font=font) <= max_width:
            current = candidate
        else:
            lines.append(current)
            current = word
    if current:
        lines.append(current)
    return lines


def block_size(draw, lines: list[str], font, spacing: int) -> tuple[int, int, int]:
    """Return the width, height and per-line step of the wrapped text block."""
    widest = max(draw.textlength(line, font=font) for line in lines)
    ascent, descent = font.getmetrics()
    line_height = ascent + descent + spacing
    return int(widest), line_height * len(lines) - spacing, line_height

The not current or guard matters: a single word longer than the wrap width would otherwise loop forever producing empty lines. With the guard it simply overhangs, and you shorten the caption or lower the font size.

font.getmetrics() returns the ascent (how far glyphs rise above the baseline) and the descent (how far tails like a lowercase g drop below it). Their sum is the natural line height for that font at that size; adding a quarter of the font size as extra spacing gives the block room to breathe. Once you know the block's width and height, positioning is arithmetic — the horizontal centre is half the canvas width, and a bottom-aligned block starts at the canvas height minus the margin minus the block height.

Anatomy of a captioned canvas and the three numbers that control it A 1200 by 630 canvas containing a safe area inset by a five percent margin, a scrim panel holding two centred lines of caption text, with callouts naming the font size, wrap width and scrim opacity. 1200 x 630 canvas safe area: 6% margin Ship faster with one Python script base image, resized Font size canvas_w // 22 Wrap width canvas_w minus 12% Scrim opacity alpha 140 of 255
Three numbers drive the whole layout: the font size derived from the canvas width, the wrap width left over after the margins, and the opacity of the panel behind the words.

Step 4 — Guarantee legibility with a scrim or a stroke

White text on a generated photograph works until the photograph has a bright cloud exactly where your caption sits. You need a treatment that makes the words readable regardless of what is underneath, and there are two that hold up.

A scrim is a semi-transparent rectangle painted between the image and the text. Because Pillow cannot draw partial transparency straight onto an RGB image, you paint the rectangle on a separate transparent layer and merge the two with alpha_composite. An alpha of 140 out of 255 darkens the background enough to carry white type while still showing the artwork through.

from PIL import Image, ImageDraw


def draw_scrim(img: Image.Image, box: tuple[int, int, int, int], alpha: int = 140) -> Image.Image:
    """Darken a rectangular region so text drawn on top of it stays readable."""
    overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
    ImageDraw.Draw(overlay).rounded_rectangle(box, radius=18, fill=(0, 0, 0, alpha))
    return Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")

A stroke outlines each glyph instead of covering the picture. Pillow builds this into draw.text() — pass stroke_width in pixels and stroke_fill for the outline colour, and it draws the outline behind the letterform in a single call. Roughly eight percent of the font size is a good starting thickness; much more and the counters inside letters like e and a start to close up.

Which one to reach for depends entirely on what is behind the words.

Choosing between a scrim panel and a stroke outline A decision tree that inspects the backdrop behind the caption: busy or varied backdrops get a semi-transparent scrim panel, flat or plain backdrops get a stroke outline, and both branches end in a full-size preview check. Check the backdrop behind the caption Busy or varied photo, gradient, crowd Flat or plain solid colour area Draw a scrim box alpha 120 to 160 Stroke the glyphs stroke_width 4 px Preview at 100% and on a phone
Pick the treatment from the backdrop, not from taste: scrims rescue busy photographs, strokes preserve flat artwork, and both need a look at full size before you ship.

Whichever you choose, judge it at 100 percent zoom and again scaled down to thumbnail size. Text that reads on a laptop can vanish in a phone feed, which is where most of these images will actually be seen.

Step 5 — Wrap it all in a reusable overlay() function

Now assemble the pieces. This function takes a file path and a caption, and writes a finished image. Everything that might change per format — canvas size, font divisor, treatment — is a keyword argument with a sensible default, so a caller who wants the standard look passes three arguments and nothing else.

The one new detail is anchor="ma" on draw.text(). Pillow's anchor codes are two letters: the first sets the horizontal reference, the second the vertical. m means the x you pass is the middle of the line, and a means the y you pass is the ascender top. Together they let you draw a centred line without subtracting half its width yourself.

from pathlib import Path

from PIL import Image, ImageDraw, ImageFont, ImageOps


def overlay(
    image_path: str,
    caption: str,
    output_path: str,
    size: tuple[int, int] = CANVAS,
    divisor: int = 22,
    style: str = "scrim",
) -> str:
    """Composite a wrapped, centred caption onto an image and save it as PNG."""
    img = load_canvas(image_path, size)
    font = load_font(img.width, divisor)
    draw = ImageDraw.Draw(img)

    margin = int(img.width * 0.06)
    lines = wrap_lines(draw, caption, font, img.width - 2 * margin)
    text_w, text_h, line_height = block_size(
        draw, lines, font, spacing=int(font.size * 0.25)
    )

    centre_x = img.width // 2
    top_y = img.height - margin - text_h

    if style == "scrim":
        pad = int(font.size * 0.45)
        img = draw_scrim(
            img,
            (
                centre_x - text_w // 2 - pad,
                top_y - pad,
                centre_x + text_w // 2 + pad,
                top_y + text_h + pad,
            ),
        )
        draw = ImageDraw.Draw(img)

    stroke = int(font.size * 0.08) if style == "stroke" else 0
    for i, line in enumerate(lines):
        draw.text(
            (centre_x, top_y + i * line_height),
            line,
            font=font,
            fill=(255, 255, 255),
            anchor="ma",
            stroke_width=stroke,
            stroke_fill=(0, 0, 0),
        )

    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
    img.save(output_path, format="PNG", optimize=True)
    return output_path

With that in place, a batch run is a loop over a spreadsheet. Save a captions.csv with three columns and let the script do the rest:

image,caption,output
raw/launch.png,Ship faster with one Python script,out/launch.png
raw/pricing.png,Every plan includes the full API,out/pricing.png
import csv


def run_batch(csv_path: str, style: str = "scrim") -> None:
    """Apply overlay() to every row of a CSV, logging failures instead of stopping."""
    with open(csv_path, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            try:
                written = overlay(row["image"], row["caption"], row["output"], style=style)
                print(f"wrote {written}")
            except Exception as exc:
                print(f"skipped {row['image']}: {exc}")


if __name__ == "__main__":
    run_batch("captions.csv")

Catching the exception per row is what makes the loop usable on real data: one missing file or malformed row logs a line and the other fifty still finish. If your spreadsheet needs tidying before it reaches this loop, Cleaning CSV Data with Pandas for AI covers the usual repairs. Once the folder of finished images exists, Bulk-Schedule Social Posts with Python can push them out on a calendar.

Canvas size to font size quick reference

Start from the divisor in this table, render one image, and adjust by one or two either way until the caption looks right at the size people will actually view it.

DestinationCanvas (px)DivisorStarting font size
Square social post1080 x 10802054 px
Blog or link header1200 x 6302254 px
Video thumbnail1280 x 7201585 px
Wide banner1600 x 9002466 px

Thumbnails get the smallest divisor because they are viewed tiny; banners get the largest because the viewer is close and the caption is decorative rather than the headline.

Troubleshooting

  • OSError: cannot open resource — Pillow could not find the font file. The path in FONT_PATH is wrong, or you passed a font name instead of a path. Copy a .ttf into your project folder and point at it with a relative path such as "fonts/Inter-Bold.ttf".
  • AttributeError: 'ImageDraw' object has no attribute 'textsize' — you are following a tutorial written for Pillow 9 or earlier. Replace draw.textsize(text, font) with draw.textlength(text, font=font) for width, or draw.textbbox((0, 0), text, font=font) for full bounds.
  • ValueError: image has wrong modeImage.alpha_composite needs both images in RGBA mode, and your base is RGB. Convert on the way in and back on the way out, exactly as draw_scrim does with img.convert("RGBA") and .convert("RGB").
  • OSError: image file is truncated — the downloaded image bytes are incomplete, usually a timeout mid-transfer. Re-download rather than patching around it; if generation itself is being throttled, Fix the 429 Rate-Limit Error in Python explains the backoff pattern.

If an error you hit is not in this list, Read a Python Traceback in Five Minutes will get you to the offending line quickly.

When to use this vs. alternatives

  • Use Pillow when the text is the point and the volume is real. Twenty captioned images from a spreadsheet, regenerated whenever the wording changes, is exactly what this code is for. It also composes cleanly with generation, as in Batch-Generate Product Images with DALL·E and Python.
  • Use a design tool when the layout is bespoke. If every graphic needs a different arrangement of type, shapes and logos, dragging it in a visual editor is faster than describing it in code. Pillow shines on repetition, not on one-off art direction.
  • Use an HTML-to-image renderer when you need rich typography. Multiple typefaces, coloured spans, right-to-left scripts and precise kerning are easier to express in CSS than in Pillow draw calls. The cost is a headless browser in your stack; for a single centred caption that is far more machinery than the job needs.

The habit worth keeping from this guide is the split between what a model is good at and what a library is good at. Let the model invent the picture, and let deterministic code place anything that has to be exactly right — a price, a name, a call to action. The same reasoning applies to burned-in captions for video, which is where Generate SRT Subtitles with Python and AI picks up.

Back to AI Image Generation with Python and DALL·E.

Frequently asked questions

Why can't I just ask the image model to write the text?

Image models paint pixels that look like letters rather than setting real type, so words come out misspelled, doubled or melted, especially at small sizes. Compositing the caption yourself with Pillow gives you the exact wording, the exact font and pixel-sharp edges every single time, at no extra API cost.

What font size should I use for a text overlay?

Derive it from the canvas width rather than hard-coding a number. Dividing the width by about 22 gives a comfortable body caption, and dividing by 15 gives a bold headline that reads on a phone. That way the same code produces balanced type on a 1080-pixel square and a 1920-pixel banner.

How do I measure how wide a string will be in Pillow?

Call draw.textlength(text, font=font) for a single number in pixels, or draw.textbbox((0, 0), text, font=font) for the full left, top, right and bottom bounds. Both need the font object you will actually draw with, because width depends on the typeface and the size, not the character count.

What is a scrim and when should I use one?

A scrim is a semi-transparent dark rectangle drawn between the image and the words so the text always has a consistent backdrop. Use one when the background is a busy photo or a gradient. On flat, plain areas a thin stroke outline around the glyphs is lighter-touch and keeps more of the artwork visible.

Does adding text with Pillow reduce the image quality?

No. Pillow draws the glyphs directly into the pixel data, so the type is as sharp as the canvas allows. Quality loss comes from resizing a small image upwards or saving as a low-quality JPEG. Fit the base image down to your target canvas with the LANCZOS filter and save as PNG.