Python PIL/Pillow
A comprehensive image processing library for Python enabling creation, manipulation, and analysis of images.
Python PIL/Pillow Cheatsheet
A comprehensive image processing library for Python enabling creation, manipulation, and analysis of images.
Overview
Pillow is the friendly fork of PIL (Python Imaging Library), providing extensive file format support, efficient internal representation, and powerful image processing capabilities. It's the go-to library for image manipulation tasks in Python.
flowchart TD
subgraph Input["Image Sources"]
A[File]
B[URL]
C[Bytes]
D[NumPy Array]
end
subgraph Pillow["Pillow Processing"]
E[Image Object]
F[Drawing]
G[Filters]
H[Transforms]
I[Colour Ops]
end
subgraph Output["Output Formats"]
J[JPEG]
K[PNG]
L[GIF]
M[WebP]
N[PDF]
end
A & B & C & D --> E
E --> F & G & H & I
F & G & H & I --> E
E --> J & K & L & M & N
Installation
pip install Pillow
Image Creation and Manipulation
Key Concepts
- Image object is the central class representing an image
- Modes define pixel format (L=greyscale, RGB, RGBA, CMYK, etc.)
- Size is a tuple of (width, height) in pixels
- Coordinate system starts at (0, 0) in top-left corner
- Operations return new images unless specified otherwise
Common Patterns
from PIL import Image
# Open an existing image
img = Image.open('photo.jpg')
# Create a new blank image
img = Image.new('RGB', (800, 600), color='white')
# Get image properties
width, height = img.size
mode = img.mode
format = img.format
# Save image
img.save('output.png')
# Close image (free resources)
img.close()
Examples
Creating Images from Scratch
from PIL import Image
# Create blank images with different modes
rgb_img = Image.new('RGB', (400, 300), color=(255, 128, 0)) # Orange
rgba_img = Image.new('RGBA', (400, 300), color=(255, 0, 0, 128)) # Semi-transparent red
grey_img = Image.new('L', (400, 300), color=128) # Medium grey
# Create image from pixel data
pixels = [(r, g, b) for r in range(256) for g in range(256) for b in [128]]
img = Image.new('RGB', (256, 256))
img.putdata(pixels)
# Create from bytes
import struct
raw_data = struct.pack('3B' * 4, 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0)
img = Image.frombytes('RGB', (2, 2), raw_data)
Basic Transformations
from PIL import Image
img = Image.open('photo.jpg')
# Resize image
resized = img.resize((800, 600))
# Resize maintaining aspect ratio
img.thumbnail((800, 800)) # Modifies in place
# Rotate image
rotated = img.rotate(45) # 45 degrees counter-clockwise
rotated_expand = img.rotate(45, expand=True) # Expand canvas to fit
# Flip image
flipped_h = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
flipped_v = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
# Crop image (left, upper, right, lower)
cropped = img.crop((100, 100, 500, 400))
# Paste one image onto another
background = Image.new('RGB', (1000, 1000), 'white')
background.paste(img, (100, 100))
# Paste with transparency mask
foreground = Image.open('overlay.png')
background.paste(foreground, (50, 50), foreground) # Use alpha as mask
Working with Pixel Data
from PIL import Image
img = Image.open('photo.jpg')
# Get single pixel
pixel = img.getpixel((100, 100)) # Returns (R, G, B)
# Set single pixel
img.putpixel((100, 100), (255, 0, 0)) # Set to red
# Get all pixels as list
pixels = list(img.getdata())
# Modify all pixels
new_pixels = [(r, g, 0) for r, g, b in pixels] # Remove blue channel
img.putdata(new_pixels)
# Access pixel data efficiently with load()
pixel_access = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b = pixel_access[x, y]
pixel_access[x, y] = (r, g, b // 2) # Reduce blue
NumPy Integration
from PIL import Image
import numpy as np
# PIL to NumPy
img = Image.open('photo.jpg')
arr = np.array(img)
# Manipulate with NumPy
arr = arr * 0.5 # Darken
arr = np.clip(arr, 0, 255).astype(np.uint8)
# NumPy to PIL
img_from_array = Image.fromarray(arr)
# Create gradient with NumPy
gradient = np.linspace(0, 255, 256).astype(np.uint8)
gradient = np.tile(gradient, (256, 1))
img = Image.fromarray(gradient, mode='L')
File Format Support
Key Concepts
- Format detection is automatic when opening files
- Save format is inferred from filename extension
- Quality settings vary by format (JPEG uses 1-100, PNG uses compression level)
- Metadata (EXIF, ICC profiles) can be preserved or stripped
flowchart LR
subgraph Lossy["Lossy Formats"]
A[JPEG]
B[WebP]
end
subgraph Lossless["Lossless Formats"]
C[PNG]
D[BMP]
E[TIFF]
end
subgraph Animated["Animated Formats"]
F[GIF]
G[WebP]
H[APNG]
end
subgraph Special["Special Formats"]
I[PDF]
J[ICO]
K[EPS]
end
Common Patterns
from PIL import Image
img = Image.open('photo.jpg')
# Save as different formats
img.save('output.png') # Format from extension
img.save('output.webp', format='WEBP') # Explicit format
# Save with quality settings
img.save('output.jpg', quality=85, optimise=True)
img.save('output.png', compress_level=6)
img.save('output.webp', quality=80, method=6)
# Get supported formats
print(Image.registered_extensions())
Examples
JPEG Handling
from PIL import Image
img = Image.open('photo.jpg')
# Save with quality control
img.save('high_quality.jpg', quality=95, optimise=True)
img.save('low_quality.jpg', quality=30)
# Save with progressive encoding (better for web)
img.save('progressive.jpg', quality=85, progressive=True)
# Preserve EXIF data
exif_data = img.info.get('exif')
if exif_data:
img.save('with_exif.jpg', exif=exif_data)
# Save with subsampling control
img.save('output.jpg', quality=85, subsampling=0) # 4:4:4 (best quality)
PNG Handling
from PIL import Image
# Create with transparency
img = Image.new('RGBA', (400, 300), (255, 0, 0, 128))
# Save with compression
img.save('output.png', compress_level=9) # Max compression
# Save with optimised palette
rgb_img = Image.open('photo.jpg')
palette_img = rgb_img.quantize(colors=256)
palette_img.save('indexed.png')
# Add metadata
from PIL import PngImagePlugin
meta = PngImagePlugin.PngInfo()
meta.add_text('Author', 'Your Name')
meta.add_text('Description', 'Image description')
img.save('with_metadata.png', pnginfo=meta)
GIF and Animation
from PIL import Image
# Save animated GIF
frames = []
for i in range(10):
frame = Image.new('RGB', (100, 100), (i * 25, 0, 255 - i * 25))
frames.append(frame)
frames[0].save(
'animation.gif',
save_all=True,
append_images=frames[1:],
duration=100, # ms per frame
loop=0 # 0 = infinite loop
)
# Read animated GIF
gif = Image.open('animation.gif')
print(f"Frames: {gif.n_frames}")
# Extract all frames
extracted_frames = []
for frame_num in range(gif.n_frames):
gif.seek(frame_num)
extracted_frames.append(gif.copy())
# Convert to palette mode for smaller GIF
rgb_img = Image.open('photo.jpg')
gif_img = rgb_img.convert('P', palette=Image.Palette.ADAPTIVE, colors=256)
gif_img.save('converted.gif')
WebP Format
from PIL import Image
img = Image.open('photo.jpg')
# Lossy WebP
img.save('lossy.webp', quality=80)
# Lossless WebP
img.save('lossless.webp', lossless=True)
# Animated WebP
frames = [Image.open(f'frame_{i}.png') for i in range(5)]
frames[0].save(
'animated.webp',
save_all=True,
append_images=frames[1:],
duration=100,
loop=0
)
Format Conversion
from PIL import Image
def convert_format(input_path, output_path):
"""Convert image between formats with appropriate settings."""
img = Image.open(input_path)
# Handle RGBA to RGB for JPEG
if output_path.lower().endswith('.jpg') or output_path.lower().endswith('.jpeg'):
if img.mode == 'RGBA':
# Create white background
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
img.save(output_path)
# Batch conversion
import os
def batch_convert(input_dir, output_dir, output_format):
os.makedirs(output_dir, exist_ok=True)
for filename in os.listdir(input_dir):
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')):
input_path = os.path.join(input_dir, filename)
output_name = os.path.splitext(filename)[0] + output_format
output_path = os.path.join(output_dir, output_name)
convert_format(input_path, output_path)
Drawing and Text Rendering
Key Concepts
- ImageDraw module provides drawing primitives
- Coordinates are (x, y) tuples or bounding boxes
- Colours can be names, hex codes, or RGB/RGBA tuples
- Fonts require TrueType/OpenType font files for custom text
- Anti-aliasing is automatic for most operations
flowchart TD
A[ImageDraw Object] --> B[Shapes]
A --> C[Text]
A --> D[Lines]
B --> B1[Rectangle]
B --> B2[Ellipse]
B --> B3[Polygon]
B --> B4[Arc]
C --> C1[Simple Text]
C --> C2[Multiline]
C --> C3[Custom Font]
D --> D1[Line]
D --> D2[Polyline]
Common Patterns
from PIL import Image, ImageDraw, ImageFont
# Create drawing context
img = Image.new('RGB', (800, 600), 'white')
draw = ImageDraw.Draw(img)
# Draw shapes
draw.rectangle([10, 10, 100, 100], fill='blue', outline='black')
draw.ellipse([120, 10, 220, 100], fill='red')
draw.line([(0, 0), (800, 600)], fill='green', width=3)
# Draw text
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)
draw.text((100, 200), 'Hello World', fill='black', font=font)
Examples
Drawing Shapes
from PIL import Image, ImageDraw
img = Image.new('RGB', (600, 400), 'white')
draw = ImageDraw.Draw(img)
# Rectangle (filled and outline)
draw.rectangle([20, 20, 120, 80], fill='blue', outline='black', width=2)
# Rounded rectangle
draw.rounded_rectangle([140, 20, 260, 80], radius=10, fill='green')
# Ellipse
draw.ellipse([280, 20, 380, 100], fill='red', outline='darkred', width=2)
# Circle (square bounding box)
draw.ellipse([400, 30, 470, 100], fill='orange')
# Polygon
points = [(50, 150), (100, 200), (80, 250), (20, 250), (0, 200)]
draw.polygon(points, fill='purple', outline='black')
# Arc (portion of ellipse)
draw.arc([120, 130, 220, 230], start=0, end=270, fill='blue', width=3)
# Pie slice (filled arc)
draw.pieslice([240, 130, 340, 230], start=45, end=315, fill='cyan', outline='black')
# Chord (arc with straight line connecting ends)
draw.chord([360, 130, 460, 230], start=0, end=180, fill='yellow', outline='black')
img.save('shapes.png')
Drawing Lines and Paths
from PIL import Image, ImageDraw
img = Image.new('RGB', (600, 400), 'white')
draw = ImageDraw.Draw(img)
# Simple line
draw.line([(10, 10), (200, 100)], fill='black', width=2)
# Polyline (connected lines)
points = [(250, 50), (300, 100), (350, 50), (400, 100), (450, 50)]
draw.line(points, fill='blue', width=3)
# Dashed line (manual implementation)
def draw_dashed_line(draw, start, end, dash_length=10, gap_length=5, fill='black', width=1):
import math
x1, y1 = start
x2, y2 = end
dx = x2 - x1
dy = y2 - y1
length = math.sqrt(dx * dx + dy * dy)
dash_count = int(length / (dash_length + gap_length))
for i in range(dash_count + 1):
start_ratio = i * (dash_length + gap_length) / length
end_ratio = min((i * (dash_length + gap_length) + dash_length) / length, 1)
sx = x1 + dx * start_ratio
sy = y1 + dy * start_ratio
ex = x1 + dx * end_ratio
ey = y1 + dy * end_ratio
draw.line([(sx, sy), (ex, ey)], fill=fill, width=width)
draw_dashed_line(draw, (10, 200), (590, 200), fill='red', width=2)
img.save('lines.png')
Text Rendering
from PIL import Image, ImageDraw, ImageFont
img = Image.new('RGB', (800, 600), 'white')
draw = ImageDraw.Draw(img)
# Default font (limited)
draw.text((20, 20), 'Default Font', fill='black')
# TrueType font
try:
font_path = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
font_small = ImageFont.truetype(font_path, 24)
font_large = ImageFont.truetype(font_path, 48)
font_bold = ImageFont.truetype(font_path.replace('.ttf', '-Bold.ttf'), 36)
except OSError:
# Fallback
font_small = ImageFont.load_default()
font_large = font_small
font_bold = font_small
# Draw text with different fonts
draw.text((20, 60), 'Small Text', fill='blue', font=font_small)
draw.text((20, 100), 'Large Text', fill='red', font=font_large)
draw.text((20, 170), 'Bold Text', fill='green', font=font_bold)
# Multiline text
multiline = "Line 1\nLine 2\nLine 3"
draw.multiline_text((20, 250), multiline, fill='black', font=font_small, spacing=10)
# Get text bounding box for positioning
bbox = draw.textbbox((0, 0), 'Centred Text', font=font_large)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Centre text on image
x = (img.width - text_width) // 2
y = 400
draw.text((x, y), 'Centred Text', fill='purple', font=font_large)
# Text with outline/stroke
def draw_text_with_outline(draw, position, text, font, fill, outline, outline_width=2):
x, y = position
# Draw outline
for dx in range(-outline_width, outline_width + 1):
for dy in range(-outline_width, outline_width + 1):
if dx != 0 or dy != 0:
draw.text((x + dx, y + dy), text, font=font, fill=outline)
# Draw text
draw.text(position, text, font=font, fill=fill)
draw_text_with_outline(draw, (20, 500), 'Outlined Text', font_large, 'white', 'black', 2)
img.save('text.png')
Creating Charts and Diagrams
from PIL import Image, ImageDraw, ImageFont
def draw_bar_chart(data, title, output_path):
"""Draw a simple bar chart."""
# Dimensions
width = 600
height = 400
margin = 60
bar_width = 40
spacing = 20
img = Image.new('RGB', (width, height), 'white')
draw = ImageDraw.Draw(img)
# Calculate scales
max_value = max(data.values())
chart_height = height - 2 * margin
scale = chart_height / max_value
# Draw bars
x = margin
colours = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6']
for i, (label, value) in enumerate(data.items()):
bar_height = value * scale
y = height - margin - bar_height
# Draw bar
colour = colours[i % len(colours)]
draw.rectangle([x, y, x + bar_width, height - margin], fill=colour)
# Draw label
try:
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 12)
except OSError:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), label, font=font)
text_width = bbox[2] - bbox[0]
draw.text((x + (bar_width - text_width) // 2, height - margin + 5),
label, fill='black', font=font)
# Draw value
draw.text((x + 5, y - 20), str(value), fill='black', font=font)
x += bar_width + spacing
# Draw title
try:
title_font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 18)
except OSError:
title_font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), title, font=title_font)
title_width = bbox[2] - bbox[0]
draw.text(((width - title_width) // 2, 15), title, fill='black', font=title_font)
img.save(output_path)
# Usage
data = {'Jan': 45, 'Feb': 62, 'Mar': 38, 'Apr': 71, 'May': 55}
draw_bar_chart(data, 'Monthly Sales', 'bar_chart.png')
Image Filtering and Enhancements
Key Concepts
- ImageFilter module provides predefined and custom filters
- ImageEnhance module adjusts brightness, contrast, colour, sharpness
- Filters are applied using
img.filter()method - Convolution kernels enable custom filter effects
- Multiple filters can be chained together
flowchart LR
subgraph Filters["ImageFilter"]
A[BLUR]
B[SHARPEN]
C[EDGE_ENHANCE]
D[EMBOSS]
E[CONTOUR]
end
subgraph Enhance["ImageEnhance"]
F[Brightness]
G[Contrast]
H[Color]
I[Sharpness]
end
J[Original Image] --> Filters
J --> Enhance
Filters --> K[Processed Image]
Enhance --> K
Common Patterns
from PIL import Image, ImageFilter, ImageEnhance
img = Image.open('photo.jpg')
# Apply predefined filters
blurred = img.filter(ImageFilter.BLUR)
sharpened = img.filter(ImageFilter.SHARPEN)
edges = img.filter(ImageFilter.FIND_EDGES)
# Apply enhancements
enhancer = ImageEnhance.Brightness(img)
brightened = enhancer.enhance(1.5) # 1.0 = original, >1 = brighter
enhancer = ImageEnhance.Contrast(img)
contrasted = enhancer.enhance(1.3)
Examples
Built-in Filters
from PIL import Image, ImageFilter
img = Image.open('photo.jpg')
# Blur filters
blur = img.filter(ImageFilter.BLUR)
gaussian = img.filter(ImageFilter.GaussianBlur(radius=5))
box_blur = img.filter(ImageFilter.BoxBlur(radius=5))
# Sharpen filters
sharpen = img.filter(ImageFilter.SHARPEN)
unsharp = img.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3))
# Edge detection
edges = img.filter(ImageFilter.FIND_EDGES)
contour = img.filter(ImageFilter.CONTOUR)
edge_enhance = img.filter(ImageFilter.EDGE_ENHANCE)
edge_enhance_more = img.filter(ImageFilter.EDGE_ENHANCE_MORE)
# Emboss and detail
emboss = img.filter(ImageFilter.EMBOSS)
detail = img.filter(ImageFilter.DETAIL)
smooth = img.filter(ImageFilter.SMOOTH)
smooth_more = img.filter(ImageFilter.SMOOTH_MORE)
# Median filter (good for noise reduction)
median = img.filter(ImageFilter.MedianFilter(size=5))
# Min/Max filters
min_filter = img.filter(ImageFilter.MinFilter(size=3))
max_filter = img.filter(ImageFilter.MaxFilter(size=3))
mode_filter = img.filter(ImageFilter.ModeFilter(size=3))
Custom Convolution Kernels
from PIL import Image, ImageFilter
img = Image.open('photo.jpg')
# Custom sharpen kernel
sharpen_kernel = ImageFilter.Kernel(
size=(3, 3),
kernel=[
0, -1, 0,
-1, 5, -1,
0, -1, 0
],
scale=1,
offset=0
)
custom_sharpen = img.filter(sharpen_kernel)
# Custom emboss kernel
emboss_kernel = ImageFilter.Kernel(
size=(3, 3),
kernel=[
-2, -1, 0,
-1, 1, 1,
0, 1, 2
],
scale=1,
offset=128
)
custom_emboss = img.filter(emboss_kernel)
# Custom edge detection (Sobel)
sobel_x = ImageFilter.Kernel(
size=(3, 3),
kernel=[
-1, 0, 1,
-2, 0, 2,
-1, 0, 1
],
scale=1,
offset=128
)
# High-pass filter
high_pass = ImageFilter.Kernel(
size=(3, 3),
kernel=[
-1, -1, -1,
-1, 9, -1,
-1, -1, -1
],
scale=1,
offset=0
)
Image Enhancements
from PIL import Image, ImageEnhance
img = Image.open('photo.jpg')
# Brightness adjustment
enhancer = ImageEnhance.Brightness(img)
darker = enhancer.enhance(0.5) # 50% brightness
brighter = enhancer.enhance(1.5) # 150% brightness
# Contrast adjustment
enhancer = ImageEnhance.Contrast(img)
low_contrast = enhancer.enhance(0.5)
high_contrast = enhancer.enhance(2.0)
# Colour saturation
enhancer = ImageEnhance.Color(img)
desaturated = enhancer.enhance(0.0) # Greyscale
saturated = enhancer.enhance(2.0) # Double saturation
# Sharpness
enhancer = ImageEnhance.Sharpness(img)
soft = enhancer.enhance(0.0) # Blurred
sharp = enhancer.enhance(2.0) # Sharpened
# Combine multiple enhancements
def enhance_photo(img, brightness=1.0, contrast=1.0, colour=1.0, sharpness=1.0):
"""Apply multiple enhancements to an image."""
if brightness != 1.0:
img = ImageEnhance.Brightness(img).enhance(brightness)
if contrast != 1.0:
img = ImageEnhance.Contrast(img).enhance(contrast)
if colour != 1.0:
img = ImageEnhance.Color(img).enhance(colour)
if sharpness != 1.0:
img = ImageEnhance.Sharpness(img).enhance(sharpness)
return img
enhanced = enhance_photo(img, brightness=1.1, contrast=1.2, sharpness=1.3)
Advanced Processing
from PIL import Image, ImageFilter, ImageOps
img = Image.open('photo.jpg')
# Auto-enhance operations
equalized = ImageOps.equalize(img) # Histogram equalisation
auto_contrast = ImageOps.autocontrast(img, cutoff=1)
# Invert colours
inverted = ImageOps.invert(img)
# Posterize (reduce colours)
posterized = ImageOps.posterize(img, bits=3)
# Solarize
solarized = ImageOps.solarize(img, threshold=128)
# Greyscale
greyscale = ImageOps.grayscale(img)
# Apply multiple operations
def create_vintage_effect(img):
"""Create a vintage photo effect."""
# Reduce saturation
img = ImageEnhance.Color(img).enhance(0.6)
# Add slight yellow tint
r, g, b = img.split()
r = ImageEnhance.Brightness(r.convert('RGB')).enhance(1.1).split()[0]
img = Image.merge('RGB', (r, g, b))
# Reduce contrast
img = ImageEnhance.Contrast(img).enhance(0.9)
# Add vignette
img = add_vignette(img)
return img
def add_vignette(img, intensity=0.3):
"""Add vignette effect to image."""
from PIL import ImageDraw
width, height = img.size
mask = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(mask)
# Create radial gradient
for i in range(min(width, height) // 2):
alpha = int(255 * (1 - (i / (min(width, height) / 2)) ** 2 * intensity))
draw.ellipse([i, i, width - i, height - i], fill=alpha)
img = img.copy()
img.putalpha(255)
black = Image.new('RGBA', (width, height), (0, 0, 0, 255))
return Image.composite(img, black, mask).convert('RGB')
Colour Management
Key Concepts
- Colour modes define how pixels store colour information
- Channels are individual colour components (R, G, B, A, etc.)
- Palettes map indices to colours for P mode images
- ICC profiles enable colour-accurate conversions
- Alpha channel stores transparency information
Common Patterns
from PIL import Image
img = Image.open('photo.jpg')
# Convert between modes
rgb = img.convert('RGB')
rgba = img.convert('RGBA')
grey = img.convert('L')
palette = img.convert('P')
# Split into channels
r, g, b = rgb.split()
# Merge channels
merged = Image.merge('RGB', (r, g, b))
# Get histogram
histogram = img.histogram()
Examples
Mode Conversions
from PIL import Image
img = Image.open('photo.jpg')
# Common mode conversions
rgb_img = img.convert('RGB') # RGB colour
rgba_img = img.convert('RGBA') # RGB with alpha
grey_img = img.convert('L') # Greyscale
binary_img = img.convert('1') # Black and white
cmyk_img = img.convert('CMYK') # Print colours
palette_img = img.convert('P') # 256-colour palette
# Convert with dithering for binary
binary_dithered = img.convert('1', dither=Image.Dither.FLOYDSTEINBERG)
# Convert to palette with custom colours
palette_img = img.quantize(colors=16, method=Image.Quantize.MEDIANCUT)
# Mode information
print(f"Mode: {img.mode}")
print(f"Bands: {img.getbands()}") # e.g., ('R', 'G', 'B')
Working with Channels
from PIL import Image, ImageOps
img = Image.open('photo.jpg').convert('RGB')
# Split channels
r, g, b = img.split()
# Save individual channels
r.save('red_channel.png')
g.save('green_channel.png')
b.save('blue_channel.png')
# Swap channels
bgr = Image.merge('RGB', (b, g, r))
# Create channel from scratch
alpha = Image.new('L', img.size, 128) # Semi-transparent
rgba = Image.merge('RGBA', (r, g, b, alpha))
# Manipulate single channel
r = r.point(lambda x: min(255, x + 50)) # Increase red
modified = Image.merge('RGB', (r, g, b))
# Channel operations
blended = Image.blend(r, g, alpha=0.5) # Blend two channels
# Create mask from channel
mask = r.point(lambda x: 255 if x > 128 else 0)
Colour Adjustments
from PIL import Image
img = Image.open('photo.jpg').convert('RGB')
# Adjust RGB levels using point()
def adjust_levels(img, r_adjust=0, g_adjust=0, b_adjust=0):
"""Adjust RGB levels individually."""
r, g, b = img.split()
r = r.point(lambda x: max(0, min(255, x + r_adjust)))
g = g.point(lambda x: max(0, min(255, x + g_adjust)))
b = b.point(lambda x: max(0, min(255, x + b_adjust)))
return Image.merge('RGB', (r, g, b))
# Warm tone (increase red, decrease blue)
warm = adjust_levels(img, r_adjust=20, b_adjust=-20)
# Cool tone (increase blue, decrease red)
cool = adjust_levels(img, r_adjust=-20, b_adjust=20)
# Gamma correction
def gamma_correct(img, gamma):
"""Apply gamma correction."""
inv_gamma = 1.0 / gamma
table = [int((i / 255) ** inv_gamma * 255) for i in range(256)]
if img.mode == 'RGB':
return img.point(table * 3)
elif img.mode == 'L':
return img.point(table)
gamma_corrected = gamma_correct(img, 2.2)
# Colour temperature adjustment
def adjust_temperature(img, temperature):
"""Adjust colour temperature. Positive = warmer, negative = cooler."""
r, g, b = img.split()
if temperature > 0:
r = r.point(lambda x: min(255, x + temperature))
b = b.point(lambda x: max(0, x - temperature))
else:
r = r.point(lambda x: max(0, x + temperature))
b = b.point(lambda x: min(255, x - temperature))
return Image.merge('RGB', (r, g, b))
warmer = adjust_temperature(img, 30)
Working with Palettes and Indexed Colour
from PIL import Image
# Create palette image
img = Image.open('photo.jpg')
# Quantize to limited palette
quantized = img.quantize(colors=256)
# Get palette data
palette = quantized.getpalette() # Returns list of R, G, B values
# Custom palette
custom_palette = []
for i in range(256):
custom_palette.extend([i, i, i]) # Greyscale palette
palette_img = Image.new('P', (256, 256))
palette_img.putpalette(custom_palette)
# Apply palette to image
indexed = img.quantize(palette=palette_img)
# Web-safe palette
web_safe = img.quantize(colors=216, method=Image.Quantize.FASTOCTREE)
Transparency and Alpha
from PIL import Image
# Add alpha channel
img = Image.open('photo.jpg').convert('RGBA')
# Create circular mask
width, height = img.size
mask = Image.new('L', (width, height), 0)
from PIL import ImageDraw
draw = ImageDraw.Draw(mask)
draw.ellipse([0, 0, width, height], fill=255)
# Apply mask as alpha
img.putalpha(mask)
# Modify existing alpha
r, g, b, a = img.split()
a = a.point(lambda x: x // 2) # Make 50% transparent
img = Image.merge('RGBA', (r, g, b, a))
# Remove transparency (composite onto background)
background = Image.new('RGB', img.size, 'white')
background.paste(img, mask=img.split()[3])
# Create gradient alpha
def create_gradient_alpha(size, direction='horizontal'):
"""Create gradient transparency."""
width, height = size
alpha = Image.new('L', size)
for x in range(width):
for y in range(height):
if direction == 'horizontal':
value = int(255 * x / width)
else:
value = int(255 * y / height)
alpha.putpixel((x, y), value)
return alpha
gradient = create_gradient_alpha(img.size, 'horizontal')
img.putalpha(gradient)
Common Use Cases
Thumbnails
from PIL import Image
import os
def create_thumbnail(input_path, output_path, size=(200, 200)):
"""Create a thumbnail preserving aspect ratio."""
with Image.open(input_path) as img:
img.thumbnail(size, Image.Resampling.LANCZOS)
img.save(output_path)
def create_square_thumbnail(input_path, output_path, size=200):
"""Create a square thumbnail with centre crop."""
with Image.open(input_path) as img:
# Calculate crop dimensions
width, height = img.size
min_dim = min(width, height)
left = (width - min_dim) // 2
top = (height - min_dim) // 2
right = left + min_dim
bottom = top + min_dim
# Crop to square and resize
img = img.crop((left, top, right, bottom))
img = img.resize((size, size), Image.Resampling.LANCZOS)
img.save(output_path)
def batch_thumbnails(input_dir, output_dir, size=(200, 200)):
"""Create thumbnails for all images in a directory."""
os.makedirs(output_dir, exist_ok=True)
for filename in os.listdir(input_dir):
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
input_path = os.path.join(input_dir, filename)
output_path = os.path.join(output_dir, f'thumb_{filename}')
create_thumbnail(input_path, output_path, size)
print(f'Created thumbnail: {output_path}')
Watermarks
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
def add_text_watermark(img_path, text, output_path, opacity=0.5):
"""Add text watermark to image."""
img = Image.open(img_path).convert('RGBA')
# Create watermark layer
watermark = Image.new('RGBA', img.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(watermark)
# Get font
try:
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
img.width // 20)
except OSError:
font = ImageFont.load_default()
# Calculate text position (bottom right)
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = img.width - text_width - 20
y = img.height - text_height - 20
# Draw text with opacity
alpha = int(255 * opacity)
draw.text((x, y), text, fill=(255, 255, 255, alpha), font=font)
# Composite
result = Image.alpha_composite(img, watermark)
result.convert('RGB').save(output_path)
def add_image_watermark(img_path, watermark_path, output_path, position='bottom-right', opacity=0.5, scale=0.2):
"""Add image watermark to photo."""
img = Image.open(img_path).convert('RGBA')
watermark = Image.open(watermark_path).convert('RGBA')
# Scale watermark
new_width = int(img.width * scale)
ratio = new_width / watermark.width
new_height = int(watermark.height * ratio)
watermark = watermark.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Adjust opacity
r, g, b, a = watermark.split()
a = a.point(lambda x: int(x * opacity))
watermark = Image.merge('RGBA', (r, g, b, a))
# Calculate position
margin = 20
positions = {
'top-left': (margin, margin),
'top-right': (img.width - new_width - margin, margin),
'bottom-left': (margin, img.height - new_height - margin),
'bottom-right': (img.width - new_width - margin, img.height - new_height - margin),
'centre': ((img.width - new_width) // 2, (img.height - new_height) // 2)
}
x, y = positions.get(position, positions['bottom-right'])
# Paste watermark
img.paste(watermark, (x, y), watermark)
img.convert('RGB').save(output_path)
def add_tiled_watermark(img_path, text, output_path, opacity=0.1):
"""Add tiled (repeated) text watermark."""
img = Image.open(img_path).convert('RGBA')
# Create watermark layer
watermark = Image.new('RGBA', img.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(watermark)
try:
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 30)
except OSError:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
alpha = int(255 * opacity)
# Tile the watermark
y = 0
row = 0
while y < img.height:
x = -text_width // 2 if row % 2 else 0
while x < img.width:
draw.text((x, y), text, fill=(128, 128, 128, alpha), font=font)
x += text_width + 50
y += text_height + 50
row += 1
result = Image.alpha_composite(img, watermark)
result.convert('RGB').save(output_path)
Image Comparison and Analysis
from PIL import Image, ImageChops, ImageStat
import math
def compare_images(img1_path, img2_path):
"""Compare two images and return similarity score."""
img1 = Image.open(img1_path).convert('RGB')
img2 = Image.open(img2_path).convert('RGB')
# Resize if different sizes
if img1.size != img2.size:
img2 = img2.resize(img1.size)
# Calculate difference
diff = ImageChops.difference(img1, img2)
# Calculate RMS difference
stat = ImageStat.Stat(diff)
rms = math.sqrt(sum([s ** 2 for s in stat.mean]) / 3)
# Convert to similarity (0-100%)
similarity = max(0, 100 - rms)
return similarity
def get_image_stats(img_path):
"""Get statistical information about an image."""
img = Image.open(img_path)
stat = ImageStat.Stat(img)
return {
'size': img.size,
'mode': img.mode,
'mean': stat.mean,
'median': stat.median,
'stddev': stat.stddev,
'extrema': stat.extrema
}
def find_dominant_colours(img_path, num_colours=5):
"""Find dominant colours in an image."""
img = Image.open(img_path).convert('RGB')
# Reduce to palette
small = img.resize((100, 100))
result = small.quantize(colors=num_colours)
# Get palette
palette = result.getpalette()
colours = []
for i in range(num_colours):
r = palette[i * 3]
g = palette[i * 3 + 1]
b = palette[i * 3 + 2]
colours.append((r, g, b))
return colours
Batch Processing
from PIL import Image
import os
from concurrent.futures import ThreadPoolExecutor
def process_image(args):
"""Process a single image."""
input_path, output_path, operations = args
try:
img = Image.open(input_path)
for op, params in operations:
if op == 'resize':
img.thumbnail(params, Image.Resampling.LANCZOS)
elif op == 'convert':
img = img.convert(params)
elif op == 'rotate':
img = img.rotate(params, expand=True)
img.save(output_path)
return True, input_path
except Exception as e:
return False, f"{input_path}: {e}"
def batch_process(input_dir, output_dir, operations, max_workers=4):
"""Batch process images in parallel."""
os.makedirs(output_dir, exist_ok=True)
tasks = []
for filename in os.listdir(input_dir):
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
input_path = os.path.join(input_dir, filename)
output_path = os.path.join(output_dir, filename)
tasks.append((input_path, output_path, operations))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
results = list(executor.map(process_image, tasks))
success = sum(1 for r in results if r[0])
print(f"Processed {success}/{len(tasks)} images")
for success, msg in results:
if not success:
print(f"Error: {msg}")
# Usage
operations = [
('resize', (800, 800)),
('convert', 'RGB')
]
batch_process('input_images', 'output_images', operations)
Performance Considerations
Memory Management
from PIL import Image
# Use context manager to ensure cleanup
with Image.open('large_image.jpg') as img:
# Process image
thumbnail = img.copy()
thumbnail.thumbnail((200, 200))
thumbnail.save('thumbnail.jpg')
# Image automatically closed
# Manually close when not using context manager
img = Image.open('large_image.jpg')
# ... process ...
img.close()
# Load image without keeping file handle open
img = Image.open('image.jpg')
img.load() # Load pixel data into memory
# File can now be closed/moved
# For very large images, use draft mode
img = Image.open('huge_image.jpg')
img.draft('RGB', (1000, 1000)) # Load at reduced resolution
Processing Speed Optimisation
from PIL import Image
# Use fastest resampling for previews
preview = img.resize((100, 100), Image.Resampling.NEAREST) # Fastest
# Use LANCZOS for quality output
final = img.resize((800, 600), Image.Resampling.LANCZOS) # Highest quality
# Resampling methods (fastest to slowest):
# NEAREST < BOX < BILINEAR < HAMMING < BICUBIC < LANCZOS
# For batch operations, reuse objects
from PIL import ImageDraw, ImageFont
font = ImageFont.truetype('font.ttf', 24) # Load once
def add_watermark(img_path, output_path):
img = Image.open(img_path)
draw = ImageDraw.Draw(img)
draw.text((10, 10), 'Watermark', font=font) # Reuse font
img.save(output_path)
img.close()
# Use NumPy for pixel-level operations (much faster)
import numpy as np
img = Image.open('image.jpg')
arr = np.array(img)
# NumPy operations are vectorised and fast
arr = arr * 0.5 # Darken all pixels
arr = np.clip(arr, 0, 255).astype(np.uint8)
img = Image.fromarray(arr)
Efficient I/O
from PIL import Image
import io
# Save to bytes buffer (for web/network)
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=85)
image_bytes = buffer.getvalue()
# Load from bytes
img = Image.open(io.BytesIO(image_bytes))
# Progressive JPEG for faster perceived loading
img.save('progressive.jpg', progressive=True, quality=85)
# Optimise PNG
img.save('optimised.png', optimise=True)
# Use appropriate quality settings
# JPEG: 85 is good balance of size/quality
# WebP: 80-85 for photos, use lossless for graphics
img.save('output.jpg', quality=85, optimise=True)
img.save('output.webp', quality=80, method=4) # method 0-6 (slower = smaller)
Lazy Loading and Deferred Operations
from PIL import Image
# Pillow uses lazy loading - image data loaded on first access
img = Image.open('large.jpg') # Only reads header
print(img.size) # Still no pixel data loaded
pixels = img.load() # NOW pixel data is loaded
# Operations can be chained efficiently
img = Image.open('photo.jpg')
img = img.crop((0, 0, 500, 500)) # Deferred
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) # Deferred
img = img.resize((100, 100)) # May trigger evaluation
img.save('result.jpg') # Final evaluation
Quick Reference
| Operation | Code |
|---|---|
| Open image | img = Image.open('file.jpg') |
| Create blank | img = Image.new('RGB', (800, 600), 'white') |
| Save image | img.save('output.png') |
| Resize | img.resize((800, 600), Image.Resampling.LANCZOS) |
| Thumbnail | img.thumbnail((200, 200)) |
| Crop | img.crop((left, top, right, bottom)) |
| Rotate | img.rotate(45, expand=True) |
| Flip horizontal | img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) |
| Convert mode | img.convert('RGB') |
| Get size | width, height = img.size |
| Get pixel | pixel = img.getpixel((x, y)) |
| Set pixel | img.putpixel((x, y), (r, g, b)) |
| Paste image | img.paste(overlay, (x, y), mask) |
| Apply filter | img.filter(ImageFilter.BLUR) |
| Adjust brightness | ImageEnhance.Brightness(img).enhance(1.5) |
| Draw rectangle | draw.rectangle([x1, y1, x2, y2], fill='blue') |
| Draw text | draw.text((x, y), 'text', fill='black', font=font) |
| Split channels | r, g, b = img.split() |
| Merge channels | img = Image.merge('RGB', (r, g, b)) |
| NumPy array | arr = np.array(img) |
| From NumPy | img = Image.fromarray(arr) |
| Save JPEG quality | img.save('out.jpg', quality=85) |
| Animated GIF | frames[0].save('anim.gif', save_all=True, append_images=frames[1:]) |
Common Issues and Solutions
Issue: Cannot Write Mode RGBA as JPEG
Problem: OSError: cannot write mode RGBA as JPEG
Solution:
from PIL import Image
img = Image.open('image.png') # RGBA image
# Convert to RGB with white background
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
img.save('output.jpg')
Issue: Image File is Truncated
Problem: OSError: image file is truncated
Solution:
from PIL import Image, ImageFile
# Allow loading truncated images
ImageFile.LOAD_TRUNCATED_IMAGES = True
img = Image.open('truncated.jpg')
Issue: DecompressionBombError
Problem: PIL.Image.DecompressionBombError: Image size exceeds limit
Solution:
from PIL import Image
# Increase or disable pixel limit
Image.MAX_IMAGE_PIXELS = 500000000 # 500 million pixels
# Or disable completely (use with caution)
Image.MAX_IMAGE_PIXELS = None
img = Image.open('huge_image.jpg')
Issue: Cannot Identify Image File
Problem: PIL.UnidentifiedImageError: cannot identify image file
Solution:
from PIL import Image
import io
# Verify file exists and is readable
import os
if not os.path.exists('image.jpg'):
print("File not found")
# Check file format manually
with open('image.jpg', 'rb') as f:
header = f.read(10)
print(header) # Check magic bytes
# Try opening from bytes
with open('image.jpg', 'rb') as f:
img = Image.open(io.BytesIO(f.read()))
# Verify image format
print(Image.registered_extensions())
Issue: Poor Quality After Resize
Problem: Resized images look pixelated or blurry
Solution:
from PIL import Image
img = Image.open('photo.jpg')
# Use high-quality resampling
resized = img.resize((800, 600), Image.Resampling.LANCZOS)
# For thumbnails, preserve sharpness
img.thumbnail((200, 200), Image.Resampling.LANCZOS)
# Apply sharpening after resize
from PIL import ImageFilter
resized = resized.filter(ImageFilter.UnsharpMask(radius=1, percent=50, threshold=3))
Issue: Colours Look Wrong After Save
Problem: Colours appear different after saving
Solution:
from PIL import Image
img = Image.open('photo.jpg')
# Preserve ICC profile
icc_profile = img.info.get('icc_profile')
if icc_profile:
img.save('output.jpg', icc_profile=icc_profile)
# For CMYK to RGB conversion
if img.mode == 'CMYK':
img = img.convert('RGB')
# Check colour mode
print(f"Mode: {img.mode}") # Should be RGB for JPEG
Issue: Memory Error with Large Images
Problem: MemoryError when processing large images
Solution:
from PIL import Image
# Process in tiles
def process_large_image(input_path, output_path, tile_size=1024):
img = Image.open(input_path)
width, height = img.size
result = Image.new(img.mode, img.size)
for y in range(0, height, tile_size):
for x in range(0, width, tile_size):
box = (x, y, min(x + tile_size, width), min(y + tile_size, height))
tile = img.crop(box)
# Process tile
processed = tile.filter(ImageFilter.BLUR)
result.paste(processed, box)
result.save(output_path)
# Use draft mode to load at reduced resolution
img = Image.open('huge.jpg')
img.draft('RGB', (2000, 2000))
img.load()
Issue: Font Not Found
Problem: OSError: cannot open resource
Solution:
from PIL import ImageFont
# Use system fonts
font_paths = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', # Linux
'/System/Library/Fonts/Helvetica.ttc', # macOS
'C:/Windows/Fonts/arial.ttf', # Windows
]
font = None
for path in font_paths:
try:
font = ImageFont.truetype(path, 24)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default() # Fallback
# Or use pillow-fonts package
# pip install pillow-fonts
from PIL import ImageFont
font = ImageFont.truetype('DejaVuSans.ttf', 24)
Issue: Transparency Lost When Saving
Problem: Transparent areas become black or white
Solution:
from PIL import Image
img = Image.open('transparent.png')
# Save as PNG to preserve transparency
img.save('output.png')
# Convert RGBA to RGB with custom background
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (200, 200, 200)) # Grey background
background.paste(img, mask=img.split()[3])
background.save('output.jpg')
Related Topics
- NumPy - Efficient numerical operations on image arrays
- OpenCV - Advanced computer vision and image processing
- scikit-image - Scientific image analysis and processing
- ImageMagick - Command-line image manipulation tool
- Cairo - Vector graphics and PDF generation
- Matplotlib - Data visualisation and figure generation