Python Pillow: Draw Text, Shapes, and Watermarks
Use Python Pillow's ImageDraw module to draw text and shapes and create semi-transparent watermarks. Includes practical code examples.
When you need to annotate images programmatically, Python Pillow provides a straightforward API for drawing text, shapes, and watermarks. The ImageDraw module is the core tool for this work. This article covers font handling, coordinate systems, transparency, and practical considerations for production scripts.
Setting Up the Image and ImageDraw
Drawing on an image with Pillow starts with an Image object and an ImageDraw instance bound to it. You can create a blank image or load an existing one. The ImageDraw.Draw() function returns a drawable object that modifies the image in place.
from PIL import Image, ImageDraw, ImageFont # Create a blank RGB image (white background) image = Image.new("RGB", (800, 600), color="white") draw = ImageDraw.Draw(image)
If you are working with an existing photo, load it with Image.open() and then create the draw object. Note that ImageDraw works on the image's current coordinate system: the origin (0, 0) is the top-left corner, the x-axis increases to the right, and the y-axis increases downward.
For formats that support transparency, such as PNG, you can use RGBA mode. This becomes important when creating watermarks that should not fully obscure the underlying image.
Drawing Text with Pillow
Text drawing requires a font object. Pillow's default font is a bitmap font that is small and often unsuitable for real-world use. You should load a TrueType or OpenType font using ImageFont.truetype().
font = ImageFont.truetype("arial.ttf", size=48) draw.text((50, 50), "Hello, Pillow", fill="black", font=font)
By default, the first argument to text() is the position of the text's top-left corner. The fill parameter accepts a color name, an RGB tuple, or an RGBA tuple when working with transparency.
For precise placement, you often need to know the text's dimensions. Use draw.textbbox() or draw.textlength() to measure the text before drawing. This is especially useful when centering text or aligning it to the right edge.
bbox = draw.textbbox((0, 0), "Sample", font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1]
Drawing Shapes: Lines, Rectangles, Ellipses, and Polygons
ImageDraw provides methods for common vector shapes. Each method takes coordinates as a bounding box or a list of points, and accepts outline and fill colors.
Lines
draw.line((100, 100, 700, 100), fill="blue", width=5)
The coordinates are the start and end points. The width parameter controls the line thickness in pixels.
Rectangles
draw.rectangle((200, 200, 600, 400), outline="red", width=3, fill="yellow")
The tuple defines the upper-left and lower-right corners. If you omit fill, the rectangle is transparent inside; if you omit outline, it has no border.
Ellipses and Circles
draw.ellipse((300, 300, 500, 500), outline="green", width=2, fill="lightblue")
The bounding box defines the ellipse. A square bounding box produces a circle.
Polygons
points = [(400, 100), (500, 200), (450, 300), (350, 250)] draw.polygon(points, outline="purple", fill="orange")
The polygon is closed automatically. The points are a list of (x, y) tuples.
All shape methods accept an RGBA fill or outline when the image mode supports it, allowing semi-transparent shapes.
Creating Watermarks with Transparency
A common watermark is semi-transparent text overlaid on an image. To achieve this, you need an RGBA image and an RGBA fill color with an alpha value less than 255.
from PIL import Image, ImageDraw, ImageFont image = Image.open("photo.jpg").convert("RGBA") watermark = Image.new("RGBA", image.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(watermark) font = ImageFont.truetype("arial.ttf", 72) text = "© Example" bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] # Position at bottom-right with 20px margin x = image.width - text_width - 20 y = image.height - text_height - 20 draw.text((x, y), text, font=font, fill=(255, 255, 255, 128)) # Composite the watermark onto the original image result = Image.alpha_composite(image, watermark) result = result.convert("RGB") # Drop alpha for JPEG output result.save("watermarked.jpg", quality=95)
The key is to draw on a separate transparent layer (watermark) and then use Image.alpha_composite() to blend it with the original. This avoids altering the original image's pixels until the final composite step. The alpha value 128 gives a 50% opacity effect. Adjust it to make the watermark more or less visible.
For a diagonal watermark, you can rotate the text layer before compositing. Use watermark.rotate(angle, expand=True). Because rotation expands the canvas, paste the rotated layer onto a full-size transparent layer or crop it back to the original dimensions before compositing.
Positioning Elements and Measuring Text
Accurate placement is often the hardest part of drawing on images. Pillow provides textbbox() and textlength() to measure text, but you must account for font-specific metrics like ascenders and descenders.
The bounding box returned by textbbox() includes glyph offsets and font metrics. For centered text, use x = (image.width - text_width) // 2 and y = (image.height - text_height) // 2. For right-aligned text, use image.width - text_width - margin.
When drawing multiple lines, you can increment the y-coordinate by the line height, which is roughly font.size * 1.2 for most fonts. However, the exact line height depends on the font metrics. Use font.getmetrics() for precise ascent and descent values.
For shapes, coordinates are absolute pixel positions. If you need to scale shapes relative to image size, compute them dynamically rather than hardcoding values. This keeps your code reusable across different image dimensions.
Performance and Memory Considerations
Drawing operations themselves are fast, but there are a few performance and memory concerns to keep in mind, especially when processing many images or large files.
Memory usage: Loading a high-resolution image into memory can consume hundreds of megabytes. When creating a watermark layer, you allocate a second image of the same size. For a 4000×3000 pixel image, that is two RGBA buffers of roughly 48 MB each. To reduce peak memory, consider drawing directly on the original image if you do not need the separate layer, or process tiles if the image is extremely large.
Font loading: ImageFont.truetype() loads the font file into memory each time it is called. If you process many images in a loop, load the font once and reuse the object. This avoids repeated disk I/O and font parsing.
Alpha compositing: Image.alpha_composite() creates a new image and copies both inputs. For large images, this is an expensive operation. Drawing directly on the original image replaces pixels instead of blending them, so for semi-transparent watermarks the separate-layer approach is the reliable choice.
JPEG output: JPEG does not support alpha. If an image is in RGBA mode, convert it to RGB before saving to JPEG.
Saving and Output Formats
The save() method handles format detection from the file extension. PNG preserves alpha. JPEG does not support alpha; convert RGBA images to RGB first.
result.save("output.png") # preserves alpha result.convert("RGB").save("output.jpg", quality=90) # JPEG does not support alpha
When saving to formats that support extra output options, pass parameters such as dpi, quality, or optimize. For web use, consider using optimize=True to reduce file size, but be aware that this may increase processing time.
For batch processing, open each image, draw the watermark, and save to a new file. Reuse the font object and any precomputed text metrics if possible. The following pattern is efficient for a directory of images:
import os from PIL import Image, ImageDraw, ImageFont os.makedirs("output", exist_ok=True) font = ImageFont.truetype("arial.ttf", 48) watermark_text = "© 2024" for filename in os.listdir("input"): if not filename.lower().endswith((".png", ".jpg", ".jpeg")): continue with Image.open(os.path.join("input", filename)) as img: img = img.convert("RGBA") layer = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(layer) bbox = draw.textbbox((0, 0), watermark_text, font=font) x = img.width - (bbox[2] - bbox[0]) - 10 y = img.height - (bbox[3] - bbox[1]) - 10 draw.text((x, y), watermark_text, font=font, fill=(255, 255, 255, 128)) result = Image.alpha_composite(img, layer).convert("RGB") result.save(os.path.join("output", filename), quality=90)
If your input files are transparent PNGs and you need to preserve that transparency, keep the final composite in RGBA mode and save as PNG instead of converting to RGB. This loop processes each image without retaining large buffers between iterations, keeping memory usage bounded.