A folder of camera photos, made web-ready automatically.
Ride Me Cycles' product photos came back from a phone camera at several megabytes each, in whatever size the camera felt like. The web store needed a consistent, compressed thumbnail for every one, and doing that by hand didn't scale past a handful of bikes.
BlackTor Group LtdDartmoor, UK
A note on this page
A reference, not a client write-up.
Illustrative, per the note on the main Python page: not real client work. It sits alongside the Duplicate Customers and Nightly Backup pages as a deeper dive on one script that comes up often enough to be worth its own write-up.
The issue
A few hundred photos, resized one at a time, by hand.
A new season's product photography arrives as roughly two hundred camera-original JPEGs and PNGs, each several megabytes and an inconsistent size, needing to become a uniform 1200-pixel JPEG the web store can load quickly. Opening each one in an image editor, resizing, and re-exporting by hand is the kind of job that's fine once and unworkable every season.
It also needs to survive being re-run: a handful of photos get retaken after a first pass, and reprocessing the other one hundred and ninety that were already fine wastes time for no reason.
Batch pipeline
resize_all_photos(): a thread pool over Pillow, skipping what's already done
"""resize_product_photos.py
Batch-converts Ride Me Cycles' raw product photography into the web
store's standard thumbnail: 1200px on the long edge, re-encoded as JPEG,
quality 85. Already-processed images are skipped by checking the
destination file's existence first, so a partial re-run only does the
work that's still missing.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from PIL import Image
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
MAX_EDGE_PIXELS: int = 1200
JPEG_QUALITY: int = 85
SOURCE_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png"})
def resize_one_photo(source_path: Path, destination_dir: Path) -> Path | None:
"""Resize a single photo to MAX_EDGE_PIXELS on its long edge and save
it as a JPEG in destination_dir, keeping the source filename's stem.
Returns the path written, or None if the file was skipped (already
processed, or not a recognised image type) so the caller can report
an accurate count without treating a skip as a failure.
"""
if source_path.suffix.lower() not in SOURCE_EXTENSIONS:
return None
destination_path = destination_dir / f"{source_path.stem}.jpg"
if destination_path.exists():
logger.info("Skipping %s: already processed", source_path.name)
return None
with Image.open(source_path) as source_image:
# RGB conversion first: some source PNGs carry an alpha channel,
# and JPEG has no transparency, so saving without this step
# raises OSError: cannot write mode RGBA as JPEG.
rgb_image = source_image.convert("RGB")
rgb_image.thumbnail((MAX_EDGE_PIXELS, MAX_EDGE_PIXELS), Image.Resampling.LANCZOS)
rgb_image.save(destination_path, "JPEG", quality=JPEG_QUALITY, optimize=True)
logger.info("Wrote %s", destination_path.name)
return destination_path
def resize_all_photos(source_dir: Path, destination_dir: Path, max_workers: int = 8) -> int:
"""Resize every photo in source_dir in parallel and return how many
were actually written (excluding skips).
A thread pool, not a process pool, is the right tool here: Pillow's
own decode/resize/encode calls release the GIL internally, so this
work is effectively I/O- and native-code-bound rather than pure-
Python CPU-bound, and threads avoid the per-worker start-up cost a
process pool would add for a job this size.
"""
destination_dir.mkdir(parents=True, exist_ok=True)
source_paths = list(source_dir.iterdir())
written_count = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(resize_one_photo, path, destination_dir): path
for path in source_paths
}
for future in as_completed(futures):
source_path = futures[future]
try:
if future.result() is not None:
written_count += 1
except OSError:
logger.exception("Failed to process %s", source_path.name)
return written_count
if __name__ == "__main__":
total_written = resize_all_photos(
source_dir=Path("photos/raw"),
destination_dir=Path("photos/web"),
)
logger.info("Done: %d photo(s) written", total_written)
resize_one_photo does one file's work and returns early, before opening anything, if a matching output already exists: re-running the script after retaking a handful of photos only redoes those, not the other one hundred and ninety.
The thread pool is what makes the batch itself fast: each photo's decode, resize and re-encode happens inside Pillow's C code, which releases Python's Global Interpreter Lock while it runs, so eight photos genuinely process at once rather than one after another with the appearance of concurrency.
Each photo's failure is caught inside its own future.result() call, so one corrupt file logs an error and gets skipped, rather than taking down the whole run partway through a batch of two hundred.
Result
A season's photography, processed in minutes, not an afternoon.
The script runs unattended after each photography session, and re-running it after a reshoot only touches the photos that actually changed.