From pillow-consistency
Write, review, refactor, or debug Python image-processing code with Pillow / PIL (Image.open, resize, crop, convert, save, thumbnails, ImageDraw text, EXIF) using one canonical, modern idiom set. Use this skill whenever code loads, transforms, generates, or saves images in Python, or when the user hits "module 'PIL.Image' has no attribute 'ANTIALIAS'", "cannot write mode RGBA as JPEG", sideways photos after processing, textsize/getsize attribute errors, washed-out colors after numpy round-trips, or asks resize vs thumbnail. Trigger it even when the user just says "make thumbnails of these photos" or "add a watermark" — without saying the words "Pillow" or "PIL."
How this skill is triggered — by the user, by Claude, or both
Slash command
/pillow-consistency:pillow-consistencyThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Pillow 10 removed the constants and metrics APIs that dominate training data —
Pillow 10 removed the constants and metrics APIs that dominate training data —
Image.ANTIALIAS, draw.textsize, font.getsize — so generated code crashes on
modern installs; and the library's quieter semantics (lazy open, mutating thumbnail
vs returning resize, mode/format coupling, EXIF orientation) produce silently wrong
images. This skill pins the canonical idiom set for Pillow 10+.
| Always | Never | Why |
|---|---|---|
from PIL import Image, ImageDraw, ImageFont | import PIL alone or legacy import Image | Submodules are the API; PIL.Image isn't imported by import PIL. |
with Image.open(path) as im: im.load() (or finish work inside the block) | passing lazily-opened images around | open is lazy and holds the file handle; leaks and "seek of closed file" errors follow. |
Image.Resampling.LANCZOS (.BICUBIC, .NEAREST) | Image.ANTIALIAS, Image.BICUBIC constants | The bare constants were removed in 10.0; ANTIALIAS crashes every old snippet. |
im = im.resize(size) — rebind, it returns new | expecting resize to mutate (or thumbnail to return) | resize returns a copy; thumbnail mutates in place (and keeps aspect). Mixing them up drops work or distorts. |
im.convert("RGB") before JPEG save | saving RGBA/P/LA to JPEG directly | JPEG has no alpha/palette: raises OSError: cannot write mode RGBA as JPEG. |
ImageOps.exif_transpose(im) right after opening photos | ignoring EXIF orientation | Phone photos carry rotation in metadata; processing strips it → sideways output. |
draw.textbbox((x, y), text, font=font) / font.getbbox / draw.textlength | draw.textsize, font.getsize | Removed in 10.0; bbox APIs are the only metrics now. |
ImageFont.truetype("DejaVuSans.ttf", 24) with an explicit size | ImageFont.load_default() for visible text | The default bitmap font ignores sizing needs (only newer Pillow can size it). |
box/coords as (left, upper, right, lower), origin top-left | width/height or center-based assumptions | Crops and pastes misplace by half an image when the convention is wrong. |
ImageOps.fit(im, (w, h)) for exact-size thumbnails | resize-that-distorts or pad-guessing | fit center-crops to the aspect then resizes — the "cover" behavior people want. |
House style — safe thumbnails:
from PIL import Image, ImageOps
def make_thumbnail(src: str, dst: str, size: tuple[int, int] = (400, 400)) -> None:
with Image.open(src) as im:
im = ImageOps.exif_transpose(im) # honor camera rotation
im.thumbnail(size, Image.Resampling.LANCZOS) # in-place, keeps aspect
if im.mode != "RGB": # JPEG can't take RGBA/P
im = im.convert("RGB")
im.save(dst, "JPEG", quality=85, optimize=True)
"RGB" 3×8-bit, "RGBA" +alpha, "L" grayscale, "P" palette,
"1" bilevel, "I;16"/"F" high-depth. Operations behave per-mode: pasting RGBA
onto RGB without passing the alpha as mask flattens transparency to black boxes —
base.paste(overlay, (x, y), overlay) (third arg = mask) or
Image.alpha_composite(base_rgba, overlay).convert("P")/GIF saves quantize to 256 colors — gradients band; that's the
format, not a bug; pick PNG when it matters.convert("1") dithers by default; pass dither=Image.Dither.NONE for masks.np.asarray(im) gives (height, width[, channels]) uint8;
Image.fromarray(arr) infers mode from shape/dtype — float arrays must be scaled to
0–255 uint8 first or output is garbage/black.im.format is only set by open (None on new/processed images) — save infers
from the filename extension; a wrong extension silently writes that wrong format.getpixel/putpixel loops are orders of magnitude too slow — use point(),
channel ops (split/merge), ImageEnhance, or numpy for bulk pixel math.draw.multiline_textbbox.Image.MAX_IMAGE_PIXELS raises DecompressionBombError
on huge inputs — handle or consciously raise the limit for trusted sources.Target Pillow 10+ (11 keeps the idioms). The 10.0 removals that break old snippets:
Image.ANTIALIAS/LINEAR/CUBIC constants → Image.Resampling.*;
draw.textsize/font.getsize/getoffset → textbbox/getbbox/textlength;
Image.LANCZOS et al. survive as aliases of the enum members, but write the enum.
"PIL" survives only as the import name — the installed package is pillow; the
original PIL died in 2011.
exif_transpose photos and note
im.mode/im.size.thumbnail/fit for
aspect-preserving sizing, resize for exact, Resampling.LANCZOS for photo
downscales.alpha_composite; draw text with truetype fonts and
bbox metrics.resize, pixel loops, format-by-wrong-extension.For the mode/format matrices, removed-API migration table, drawing/text recipes, and
numpy interop rules, read references/pillow-patterns.md.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub guidogl/pillow-consistency --plugin pillow-consistency