python.blacktorgroup.com/Product Photos

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.

A translucent black cube etched with a moss-green map of the world's continents, glowing softly against black.
BlackTor Group Ltd Dartmoor, 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.