Python Pillow: Draw Text, Shapes, and Watermarks
python pillow draw text shapes and watermarks: Learn how to use Python Pillow to draw text, shapes, and semi-transparent watermarks on images, with practical code exam...
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 the essential techniques for using python pillow draw text shapes and watermarks effectively, including 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)
The first argument to text() is a tuple (x, y) specifying the top-left corner of the text bounding box. 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) and then paste it with a mask, but be aware that rotation expands the canvas and requires careful positioning.
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 the font's internal padding. For centering, compute the width and height as shown earlier and subtract them from the target coordinates. For right-alignment, 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.getbbox() or font.getmetrics() for precise 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. If you only need a simple watermark, you can draw text directly on the original image using an RGBA fill, but the text will not be semi-transparent unless the image mode is RGBA. In practice, the separate-layer approach is cleaner and avoids corrupting the original image.
JPEG output: When saving to JPEG, the image is converted to RGB, discarding alpha. Ensure you convert explicitly to avoid surprises. Use quality to control file size, but note that higher quality increases output size.
Saving and Output Formats
The save() method handles format detection from the file extension. For PNG, alpha is preserved. For JPEG, alpha is dropped. If you need to preserve transparency, use PNG or TIFF.
result.save("output.png") # preserves alpha result.save("output.jpg", quality=90) # drops alpha
When saving to formats that support metadata, you can pass additional parameters like dpi 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 and watermark layer if possible to avoid repeated allocations. The following pattern is efficient for a directory of images:
import os from PIL import Image, ImageDraw, ImageFont 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)
This loop processes each image without retaining large buffers between iterations, keeping memory usage bounded.