Pillow is the de-facto Python image processing library. pip install Pillow. from PIL import Image, ImageFilter, ImageEnhance, ImageDraw, ImageFont, ImageOps. Open: img = Image.open("photo.jpg"). Info: img.size (W,H), img.mode (RGB/RGBA/L), img.format. Save: img.save("out.png"), img.save("out.jpg", quality=85, optimize=True). Resize: img.resize((w, h), Image.LANCZOS). Thumbnail: img.thumbnail((800, 800), Image.LANCZOS) — in-place, preserves aspect ratio. Crop: img.crop((left, upper, right, lower)). Rotate: img.rotate(angle, expand=True, fillcolor=(0,0,0)). Flip: img.transpose(Image.FLIP_LEFT_RIGHT). Mode convert: img.convert("L") (grayscale), img.convert("RGBA"). Paste: base.paste(overlay, (x,y), mask=overlay). Blend: Image.blend(img1, img2, alpha=0.5). Filter: img.filter(ImageFilter.GaussianBlur(radius=3)), img.filter(ImageFilter.UnsharpMask(radius=2, percent=150)). Enhance: ImageEnhance.Contrast(img).enhance(1.5), ImageEnhance.Brightness(img).enhance(0.8). Draw: draw = ImageDraw.Draw(img), draw.rectangle([x0,y0,x1,y1], outline="red", width=2), draw.text((x,y), "label", fill="white", font=font). Font: font = ImageFont.truetype("arial.ttf", 32). NumPy: arr = np.array(img), img = Image.fromarray(arr.astype(np.uint8)). EXIF: info = img._getexif(). Color ops: r,g,b = img.split(), merged = Image.merge("RGB", (r,g,b)). Autocontrast: ImageOps.autocontrast(img). Equalize: ImageOps.equalize(img.convert("L")). GIF frames: for frame in ImageSequence.Iterator(gif): .... Claude Code generates Pillow image pipelines, thumbnail generators, watermark tools, and batch photo processors.
CLAUDE.md for Pillow
## Pillow Stack
- Version: Pillow >= 10.0
- Open/Save: Image.open(path) | img.save(path, quality=85, optimize=True)
- Transform: img.resize((w,h), Image.LANCZOS) | .crop(box) | .rotate(deg, expand=True)
- Mode: img.convert("RGB"/"L"/"RGBA"/"CMYK") — always explicit
- Filter: img.filter(ImageFilter.GaussianBlur(3)) | UnsharpMask | FIND_EDGES
- Enhance: ImageEnhance.Contrast/Brightness/Color/Sharpness(img).enhance(factor)
- Draw: ImageDraw.Draw(img) → .rectangle/.text/.line/.ellipse
- NumPy: np.array(img) | Image.fromarray(arr.astype(np.uint8))
Pillow Image Processing Pipeline
# vision/pillow_pipeline.py — image processing with Pillow
from __future__ import annotations
import io
import os
from pathlib import Path
from typing import Optional
import numpy as np
from PIL import (
Image, ImageFilter, ImageEnhance, ImageDraw, ImageFont,
ImageOps, ImageSequence,
)
# ── 1. I/O and basic info ─────────────────────────────────────────────────────
def open_image(path: str | bytes | io.BytesIO) -> Image.Image:
"""Open an image from path or bytes. Converts EXIF rotation if present."""
img = Image.open(path)
try:
img = ImageOps.exif_transpose(img) # Fix camera rotation
except Exception:
pass
return img
def save_image(
img: Image.Image,
path: str,
quality: int = 85,
optimize: bool = True,
remove_exif: bool = True,
) -> str:
"""
Save image with sensible defaults.
remove_exif=True strips GPS and personal metadata from JPEGs.
"""
Path(path).parent.mkdir(parents=True, exist_ok=True)
kwargs = {"optimize": optimize}
if path.lower().endswith((".jpg", ".jpeg")):
kwargs["quality"] = quality
if remove_exif:
data = list(img.getdata())
clean = Image.new(img.mode, img.size)
clean.putdata(data)
img = clean
img.save(path, **kwargs)
return path
def image_info(img: Image.Image) -> dict:
"""Return size, mode, DPI, format, and file size estimate."""
buf = io.BytesIO()
img.save(buf, format="png")
return {
"width": img.size[0],
"height": img.size[1],
"mode": img.mode,
"format": img.format,
"aspect": round(img.size[0] / img.size[1], 3),
"bytes_png": len(buf.getvalue()),
}
# ── 2. Resize and crop operations ─────────────────────────────────────────────
def make_thumbnail(
img: Image.Image,
max_size: tuple[int, int] = (800, 800),
resampling: int = Image.LANCZOS,
) -> Image.Image:
"""
Resize image so neither dimension exceeds max_size, preserving aspect ratio.
In-place on a copy — original is not modified.
"""
out = img.copy()
out.thumbnail(max_size, resampling)
return out
def resize_exact(
img: Image.Image,
width: int,
height: int,
) -> Image.Image:
"""Resize to exact dimensions (may distort aspect ratio)."""
return img.resize((width, height), Image.LANCZOS)
def crop_center(
img: Image.Image,
width: int,
height: int,
) -> Image.Image:
"""Center-crop to given dimensions."""
img_w, img_h = img.size
left = (img_w - width) // 2
upper = (img_h - height) // 2
return img.crop((left, upper, left + width, upper + height))
def smart_fit(
img: Image.Image,
target: tuple[int, int],
fill_color: tuple = (0, 0, 0),
) -> Image.Image:
"""
Resize image to exactly target size by:
1. Scaling to fit within target while preserving AR
2. Padding with fill_color to reach exact dimensions
"""
img_copy = img.copy()
img_copy.thumbnail(target, Image.LANCZOS)
canvas = Image.new(img.mode if img.mode != "P" else "RGB",
target, fill_color)
offset = ((target[0] - img_copy.size[0]) // 2,
(target[1] - img_copy.size[1]) // 2)
canvas.paste(img_copy, offset)
return canvas
def crop_with_padding(
img: Image.Image,
box: tuple[int, int, int, int],
padding: int = 10,
) -> Image.Image:
"""Crop a bounding box with extra padding, clamped to image bounds."""
w, h = img.size
l = max(0, box[0] - padding)
u = max(0, box[1] - padding)
r = min(w, box[2] + padding)
d = min(h, box[3] + padding)
return img.crop((l, u, r, d))
# ── 3. Filters and enhancement ────────────────────────────────────────────────
def apply_filters(
img: Image.Image,
blur: float = 0.0,
sharpen: float = 0.0, # UnsharpMask percent
find_edges: bool = False,
) -> Image.Image:
"""Apply one or more Pillow convolution filters."""
out = img
if blur > 0:
out = out.filter(ImageFilter.GaussianBlur(radius=blur))
if sharpen > 0:
out = out.filter(ImageFilter.UnsharpMask(radius=2, percent=int(sharpen), threshold=3))
if find_edges:
out = out.convert("L").filter(ImageFilter.FIND_EDGES)
return out
def adjust_image(
img: Image.Image,
contrast: float = 1.0,
brightness: float = 1.0,
color: float = 1.0,
sharpness: float = 1.0,
) -> Image.Image:
"""
Apply ImageEnhance adjustments.
1.0 = no change, >1 = more, <1 = less.
"""
out = img
if contrast != 1.0:
out = ImageEnhance.Contrast(out).enhance(contrast)
if brightness != 1.0:
out = ImageEnhance.Brightness(out).enhance(brightness)
if color != 1.0:
out = ImageEnhance.Color(out).enhance(color)
if sharpness != 1.0:
out = ImageEnhance.Sharpness(out).enhance(sharpness)
return out
def auto_enhance(img: Image.Image) -> Image.Image:
"""Auto-enhance: equalize histogram, then autocontrast."""
gray = img.convert("L")
eq = ImageOps.equalize(gray)
if img.mode == "L":
return ImageOps.autocontrast(eq)
r, g, b = img.split()
r_eq = ImageOps.equalize(r)
g_eq = ImageOps.equalize(g)
b_eq = ImageOps.equalize(b)
return ImageOps.autocontrast(Image.merge("RGB", (r_eq, g_eq, b_eq)))
# ── 4. Compositing and watermarks ────────────────────────────────────────────
def add_text_watermark(
img: Image.Image,
text: str,
position: str = "bottom-right", # "bottom-right" | "center" | "top-left"
font_size: int = 36,
opacity: int = 128, # 0-255
color: tuple = (255, 255, 255),
) -> Image.Image:
"""
Add semi-transparent text watermark to an image.
Creates a separate RGBA layer for the text, then composites.
"""
base = img.convert("RGBA")
txt_layer = Image.new("RGBA", base.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(txt_layer)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
font_size)
except OSError:
font = ImageFont.load_default()
# Get text bounding box
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
W, H = base.size
margin = 20
pos_map = {
"bottom-right": (W - tw - margin, H - th - margin),
"bottom-left": (margin, H - th - margin),
"top-right": (W - tw - margin, margin),
"top-left": (margin, margin),
"center": ((W - tw) // 2, (H - th) // 2),
}
pos = pos_map.get(position, pos_map["bottom-right"])
draw.text(pos, text, fill=(*color, opacity), font=font)
return Image.alpha_composite(base, txt_layer).convert(img.mode)
def add_image_watermark(
base: Image.Image,
watermark: Image.Image,
position: str = "bottom-right",
scale: float = 0.15,
opacity: float = 0.5,
) -> Image.Image:
"""Composite an image watermark (logo) onto the base image."""
base_out = base.convert("RGBA")
W, H = base_out.size
wm = watermark.convert("RGBA")
# Scale watermark to `scale` fraction of base width
new_w = int(W * scale)
new_h = int(new_w * wm.size[1] / wm.size[0])
wm = wm.resize((new_w, new_h), Image.LANCZOS)
# Adjust opacity
r, g, b, a = wm.split()
a = a.point(lambda x: int(x * opacity))
wm.putalpha(a)
margin = 15
pos_map = {
"bottom-right": (W - new_w - margin, H - new_h - margin),
"bottom-left": (margin, H - new_h - margin),
"top-right": (W - new_w - margin, margin),
"top-left": (margin, margin),
"center": ((W - new_w) // 2, (H - new_h) // 2),
}
pos = pos_map.get(position, (W - new_w - margin, H - new_h - margin))
base_out.paste(wm, pos, wm)
return base_out.convert(base.mode)
# ── 5. Drawing annotations ────────────────────────────────────────────────────
def draw_bounding_boxes(
img: Image.Image,
boxes: list[dict], # [{"box": [x0,y0,x1,y1], "label": str, "color": str}]
line_width: int = 2,
font_size: int = 18,
) -> Image.Image:
"""
Annotate image with bounding boxes and labels.
Typically used for object detection visualization.
"""
out = img.copy().convert("RGB")
draw = ImageDraw.Draw(out)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
font_size)
except OSError:
font = ImageFont.load_default()
for item in boxes:
box = item.get("box", [0, 0, 10, 10])
label = item.get("label", "")
color = item.get("color", "red")
draw.rectangle(box, outline=color, width=line_width)
if label:
# Label background
bbox = draw.textbbox((box[0], box[1] - font_size - 4), label, font=font)
draw.rectangle(bbox, fill=color)
draw.text((box[0], box[1] - font_size - 4), label,
fill="white", font=font)
return out
# ── 6. Batch processing and numpy interop ────────────────────────────────────
def batch_resize(
input_dir: str,
output_dir: str,
max_size: tuple = (512, 512),
exts: tuple = (".jpg", ".jpeg", ".png", ".webp"),
) -> int:
"""Batch-resize all images in a directory. Returns count of processed images."""
Path(output_dir).mkdir(parents=True, exist_ok=True)
count = 0
for f in Path(input_dir).iterdir():
if f.suffix.lower() in exts:
img = open_image(str(f))
out = make_thumbnail(img, max_size)
save_image(out, str(Path(output_dir) / f.name))
count += 1
return count
def to_numpy(img: Image.Image) -> np.ndarray:
"""Convert PIL Image to (H, W, C) uint8 NumPy array."""
return np.array(img.convert("RGB"), dtype=np.uint8)
def from_numpy(arr: np.ndarray) -> Image.Image:
"""Convert (H, W, C) or (H, W) float/uint8 array to PIL Image."""
if arr.dtype != np.uint8:
arr = (arr * 255).clip(0, 255).astype(np.uint8)
mode = "RGB" if arr.ndim == 3 and arr.shape[2] == 3 else "L"
return Image.fromarray(arr, mode=mode)
def extract_gif_frames(
gif_path: str,
output_dir: str = None,
max_frames: int = 60,
) -> list[Image.Image]:
"""Extract frames from an animated GIF."""
frames = []
with Image.open(gif_path) as gif:
for i, frame in enumerate(ImageSequence.Iterator(gif)):
if i >= max_frames:
break
frame_copy = frame.copy().convert("RGB")
frames.append(frame_copy)
if output_dir:
Path(output_dir).mkdir(parents=True, exist_ok=True)
frame_copy.save(f"{output_dir}/frame_{i:04d}.png")
return frames
# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import tempfile
print("Pillow Image Processing Demo")
print("=" * 50)
# Create a test image
img = Image.new("RGB", (800, 600), color=(180, 200, 220))
draw = ImageDraw.Draw(img)
draw.rectangle([100, 100, 300, 250], fill=(255, 100, 100), outline=(200, 0, 0), width=3)
draw.ellipse([400, 150, 650, 400], fill=(100, 180, 100), outline=(0, 150, 0), width=3)
draw.text((50, 520), "Pillow Demo Image", fill=(50, 50, 50))
with tempfile.TemporaryDirectory() as tmpdir:
orig_path = f"{tmpdir}/original.jpg"
save_image(img, orig_path, quality=90)
print(f"\nCreated test image: {image_info(img)}")
# Thumbnail
thumb = make_thumbnail(img, (200, 200))
save_image(thumb, f"{tmpdir}/thumb.jpg")
print(f"Thumbnail: {thumb.size}")
# Center crop
cropped = crop_center(img, 400, 400)
save_image(cropped, f"{tmpdir}/cropped.jpg")
print(f"Center crop: {cropped.size}")
# Smart fit
fitted = smart_fit(img, (300, 300), fill_color=(240, 240, 240))
save_image(fitted, f"{tmpdir}/fitted.jpg")
print(f"Smart fit: {fitted.size}")
# Enhancement
enhanced = adjust_image(img, contrast=1.4, brightness=1.1, sharpness=1.3)
save_image(enhanced, f"{tmpdir}/enhanced.jpg")
print(f"Enhanced image saved")
# Watermark
wm = add_text_watermark(img, "© 2024 Demo", opacity=180)
save_image(wm, f"{tmpdir}/watermark.jpg")
print(f"Watermark added")
# Bounding boxes
annotated = draw_bounding_boxes(img, [
{"box": [100, 100, 300, 250], "label": "rectangle", "color": "blue"},
{"box": [400, 150, 650, 400], "label": "ellipse", "color": "green"},
])
save_image(annotated, f"{tmpdir}/annotated.jpg")
print(f"Annotations drawn")
# NumPy round-trip
arr = to_numpy(img)
restored = from_numpy(arr)
print(f"\nNumPy round-trip: array {arr.shape} → PIL {restored.size}")
print(f"\nAll outputs saved to {tmpdir}")
For the OpenCV alternative for computer vision — OpenCV handles real-time video processing and C++ speed while Pillow’s ImageDraw.Draw for annotations, ImageEnhance for photographic quality adjustments, and ImageOps.exif_transpose for camera rotation correction are simpler Python-native APIs for image processing rather than computer vision, and Image.fromarray / np.array(img) provide seamless interop with NumPy, scikit-image, and PyTorch when CV pipelines need to start from clean processed images. For the scikit-image alternative for scientific image analysis — scikit-image excels at morphological operations and segmentation while Pillow’s lightweight API handles the most common batch operations (thumbnailing, EXIF-aware open, JPEG export with quality settings, format conversion) in three lines, and make_thumbnail preserving aspect ratio without distortion is a common production requirement that scikit-image’s resize requires manual calculation for. The Claude Skills 360 bundle includes Pillow skill sets covering open with EXIF correction, thumbnail and smart fit, center crop, GaussianBlur and UnsharpMask, ImageEnhance adjustments, auto histogram equalization, text and image watermarking, bounding box annotation, batch resize, GIF frame extraction, and NumPy interop. Start with the free tier to try image processing code generation.