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.
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.
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.
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.
| Destination | Canvas (px) | Divisor | Starting font size |
|---|---|---|---|
| Square social post | 1080 x 1080 | 20 | 54 px |
| Blog or link header | 1200 x 630 | 22 | 54 px |
| Video thumbnail | 1280 x 720 | 15 | 85 px |
| Wide banner | 1600 x 900 | 24 | 66 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 inFONT_PATHis wrong, or you passed a font name instead of a path. Copy a.ttfinto 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. Replacedraw.textsize(text, font)withdraw.textlength(text, font=font)for width, ordraw.textbbox((0, 0), text, font=font)for full bounds.ValueError: image has wrong mode—Image.alpha_compositeneeds both images in RGBA mode, and your base is RGB. Convert on the way in and back on the way out, exactly asdraw_scrimdoes withimg.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.
Related guides
- Create YouTube Thumbnails with DALL-E 3 and Python — the same overlay idea tuned to one specific 1280 by 720 format.
- Batch-Generate Product Images with DALL·E and Python — produce the backgrounds this guide captions, at volume.
- Schedule Instagram Posts Using Python and AI — publish the finished square graphics on a schedule.
- AI Content Creation & Marketing Automation with Python — how image work fits into a wider content pipeline.
- Create a Python Virtual Environment for AI — set up an isolated environment before installing Pillow.