Drawing and converting formats
Annotating images with ImageDraw, compositing with alpha, and converting safely between pixel modes in a batch.
Drawing and compositing
from PIL import Image, ImageDraw, ImageFont
with Image.open("card.jpg").convert("RGB") as im:
draw = ImageDraw.Draw(im)
draw.rectangle((20, 20, 220, 90), fill="black", outline="white", width=3)
draw.line((20, 120, 620, 120), fill="white", width=2)
draw.ellipse((560, 30, 620, 90), outline="red", width=4)
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 32)
text = "In stock"
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
w, h = right - left, bottom - top
cx, cy = 120, 55 # centre of the rectangle above
draw.text((cx - w / 2 - left, cy - h / 2 - top), text, font=font, fill="white")
# translucent banner: draw on an RGBA layer, then composite it down
overlay = Image.new("RGBA", im.size, (0, 0, 0, 0))
ImageDraw.Draw(overlay).rectangle(
(0, im.height - 120, im.width, im.height), fill=(0, 0, 0, 150))
im = Image.alpha_composite(im.convert("RGBA"), overlay).convert("RGB")
im.save("annotated.jpg", quality=90)ImageDraw.Drawwrites directly into the image it wraps; there is nothing to flush afterwards.textbbox(Pillow 8+) returns the tight box around the glyphs. The oldertextsizewas deprecated and removed.- The default font is tiny and fixed-size. Load a real font with
ImageFont.truetypeand cache it outside a loop — parsing a TTF per image is a measurable cost. - Coordinates are inclusive at the top-left and exclusive at the bottom-right, so a rectangle of
(0, 0, 10, 10)is ten pixels wide. - True alpha blending needs an RGBA destination; on an RGB image the alpha value is ignored and
fill=(0, 0, 0, 150)draws solid black.
Modes and batch conversion
from pathlib import Path
from PIL import Image
src, dst = Path("incoming"), Path("web")
dst.mkdir(exist_ok=True)
for path in sorted(src.glob("*.png")):
with Image.open(path) as im:
im.load()
im = im.convert("RGBA") # P and LA need this before reading alpha
background = Image.new("RGB", im.size, "white") # JPEG has no alpha
background.paste(im, mask=im.getchannel("A"))
background = background.convert("RGB")
out = dst / (path.stem + ".jpg")
background.save(out, quality=82, optimize=True, progressive=True)
print(path.name, "->", out.name, background.size)| Mode | Stores | Convert when |
|---|---|---|
| 1 | One bit per pixel | Line art and thresholded masks |
| L | 8-bit greyscale | Grey thumbnails, masks, luminance checks |
| P | 8-bit palette index | GIF and small icons; convert to RGB before drawing |
| RGB | Three 8-bit channels | Everyday photographs and JPEG output |
| RGBA | RGB plus an alpha channel | Compositing; flatten before JPEG |
| CMYK | Four ink channels | Print workflows only; most operations are limited |
| I;16, F | 16-bit integer, 32-bit float | Scientific data; write PNG or TIFF, never JPEG |
⚠️
Drawing on a
P-mode image writes palette indices, not colours, so fill="red" produces whatever colour happens to sit at that index. Convert to RGB first. In the other direction, never save I;16 or F data as JPEG — the precision is silently lost.FAQ
How do I add a watermark to hundreds of images?
Create the watermark layer once (including the font and its rendered text), then for each file open, paste it with an alpha mask and save. Rebuilding the text image inside the loop is the usual reason a batch job is slow.
Why are my converted images darker or inverted?
You are probably dropping alpha against a black background, or treating an
L image as colour. Composite onto an explicit white background and check im.mode before converting.Related
Opening, inspecting and saving images Resizing, cropping and rotating
Last refreshed 2026-09-18.