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.Draw writes directly into the image it wraps; there is nothing to flush afterwards.
  • textbbox (Pillow 8+) returns the tight box around the glyphs. The older textsize was deprecated and removed.
  • The default font is tiny and fixed-size. Load a real font with ImageFont.truetype and 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)
ModeStoresConvert when
1One bit per pixelLine art and thresholded masks
L8-bit greyscaleGrey thumbnails, masks, luminance checks
P8-bit palette indexGIF and small icons; convert to RGB before drawing
RGBThree 8-bit channelsEveryday photographs and JPEG output
RGBARGB plus an alpha channelCompositing; flatten before JPEG
CMYKFour ink channelsPrint workflows only; most operations are limited
I;16, F16-bit integer, 32-bit floatScientific 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.

Opening, inspecting and saving images Resizing, cropping and rotating

Last refreshed 2026-09-18.