Project: Image Batch Resizer
1 · The lesson
readYou'll build a CLI tool that takes a folder full of photos and creates smaller copies for the web. It keeps the aspect ratio, skips damaged files and works on several images in parallel. A tool like this saves time whenever you need to upload a large set of photos.
What you'll practise: third-party packages (Pillow), pathlib filesystem iteration, per-file error isolation, argparse, ThreadPoolExecutor, idempotent jobs.
Step 1: The Simplest Version
First install Pillow. It is the most widely used Python imaging library, and a maintained fork of the original PIL.
pip install Pillow
Then the simplest possible resize:
from PIL import Image img = Image.open("photo.jpg") img = img.resize((800, 600)) img.save("photo_small.jpg") print("done")
Only three lines do the work. Run it on any photo.jpg, and you'll get photo_small.jpg in the same folder. Pillow decodes the JPEG, resamples it and encodes it again. You only gave it the new dimensions.
Step 2: Keep the Aspect Ratio
resize((800, 600)) can distort your images. If your source is 4000x3000, you get 800x600, and it looks right because the ratio is the same. But with a 4000x2250 source, resize((800, 600)) stretches the image vertically, and faces look stretched.
Use thumbnail() instead:
from PIL import Image img = Image.open("photo.jpg") img.thumbnail((800, 600)) # changes img itself and keeps the aspect ratio img.save("photo_small.jpg") print(img.size) # for example (800, 450), never bigger than the box
The difference:
| Method | Behaviour |
|---|---|
resize((w, h)) | Forces exact w x h. Can stretch. |
thumbnail((w, h)) | In-place. Scales DOWN to fit inside (w, h), keeping ratio. Never enlarges. |
Use thumbnail() when you want "make this fit in a 1200px box". Use resize() when you really need exact pixel sizes (thumbnails in a grid, inputs for an ML model).
Step 3: Process a Whole Folder
Resizing one photo is useful, but the real value is resizing a whole folder at once. Use pathlib to loop over the folder, and a context manager so that each file is closed correctly.
from pathlib import Path from PIL import Image source = Path("photos") destination = Path("photos_resized") destination.mkdir(exist_ok=True) # safe to repeat: no error if it already exists for path in source.glob("*.jpg"): with Image.open(path) as img: img.thumbnail((1200, 1200)) out = destination / path.name img.save(out) print(f" {path.name} → {out}")
A few notes:
glob("*.jpg")returns only.jpgfiles. For JPEG, PNG and WebP, loop over all files and filter on.suffix.lower(). (A pattern likeglob("*.[jJpPwW]*")also matches other files, such as.pdfand.py.)with Image.open(path) as img:makes sure the file is closed, even ifsave()raises an error. Pillow'sopen()is lazy: it doesn't read the pixel data until you need it, so the file stays open. The context manager closes it, which matters especially on Windows.mkdir(exist_ok=True)makes the script safe to run again. You don't get aFileExistsErroron the second run.
Step 4: Handle Errors Per File
One damaged JPEG in a folder of 500 should NOT stop the whole batch. Put the code for each file in try/except (see exceptions), and count the successes and failures.
from pathlib import Path from PIL import Image, UnidentifiedImageError source = Path("photos") destination = Path("photos_resized") destination.mkdir(exist_ok=True) ok, failed = 0, [] for path in source.glob("*.jpg"): try: with Image.open(path) as img: img.thumbnail((1200, 1200)) img.save(destination / path.name) ok += 1 except (UnidentifiedImageError, OSError) as e: # UnidentifiedImageError: not actually an image # OSError: truncated file, permission denied, disk full failed.append((path.name, str(e))) print(f"{ok} resized, {len(failed)} failed") for name, err in failed: print(f" ⚠️ {name}: {err}")
Catch only the specific exceptions you can handle: UnidentifiedImageError for "not an image", and OSError for I/O problems. A bare except: would also catch KeyboardInterrupt, so Ctrl-C could not stop the script.
For a batch tool, it is better to collect the failures and report them at the end than to stop at the first error.
Step 5: Real CLI Arguments
Paths written in the code are fine while you're learning. But the tool is much more useful when you can run it on any folder. Use argparse.
import argparse from pathlib import Path from PIL import Image, UnidentifiedImageError def parse_args(): p = argparse.ArgumentParser(description="Batch-resize images.") p.add_argument("input_dir", type=Path, help="folder containing source images") p.add_argument("output_dir", type=Path, help="folder to write resized images") p.add_argument("--max-width", type=int, default=1200, help="longest side in pixels (default 1200)") p.add_argument("--format", choices=["jpeg", "png", "webp"], default=None, help="convert all outputs to this format (default: keep original)") p.add_argument("--quality", type=int, default=85, help="JPEG/WebP quality 1-95 (default 85)") p.add_argument("--recursive", action="store_true", help="include sub-directories") return p.parse_args() def find_images(root, recursive): pattern = "**/*" if recursive else "*" exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"} for p in root.glob(pattern): if p.is_file() and p.suffix.lower() in exts: yield p def main(): args = parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) for src in find_images(args.input_dir, args.recursive): try: with Image.open(src) as img: img.thumbnail((args.max_width, args.max_width)) ext = f".{args.format}" if args.format else src.suffix dst = args.output_dir / (src.stem + ext) img.save(dst, quality=args.quality) print(f" {src.name} → {dst.name}") except (UnidentifiedImageError, OSError) as e: print(f" ⚠️ {src.name}: {e}") # In a real script: # if __name__ == "__main__": # main()
Usage:
python resize.py ./photos ./web --max-width 1200 --format webp --quality 80 --recursive
argparse creates --help for you, checks that the int arguments are numbers and rejects unknown flags. choices= allows only the listed formats: if you type --format webm by mistake, argparse rejects it before your code runs.
Step 6: Polished Final Version (Parallel, Idempotent, Progress)
A batch of 500 photos needs a lot of I/O and a lot of CPU time. Pillow releases the GIL during the heavy resampling work, so threads give a real speedup with very little extra code. (Processes are another option. See multiprocessing for when to use them.) Add a progress indicator and skip files that already exist, and you have a tool you will want to keep using.
import argparse from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from PIL import Image, UnidentifiedImageError IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"} def find_images(root, recursive): pattern = "**/*" if recursive else "*" for p in root.glob(pattern): if p.is_file() and p.suffix.lower() in IMAGE_EXTS: yield p def resize_one(src, output_dir, max_dim, fmt, quality, skip_existing): ext = f".{fmt}" if fmt else src.suffix dst = output_dir / (src.stem + ext) if skip_existing and dst.exists(): return ("skipped", src.name) try: with Image.open(src) as img: img.thumbnail((max_dim, max_dim)) save_kwargs = {} if (fmt or src.suffix.lower()) in (".jpg", ".jpeg", "jpeg", "webp", ".webp"): save_kwargs["quality"] = quality img.save(dst, **save_kwargs) return ("ok", src.name) except (UnidentifiedImageError, OSError) as e: return ("failed", f"{src.name}: {e}") def main(): p = argparse.ArgumentParser(description="Batch-resize images in parallel.") p.add_argument("input_dir", type=Path) p.add_argument("output_dir", type=Path) p.add_argument("--max-width", type=int, default=1200) p.add_argument("--format", choices=["jpeg", "png", "webp"], default=None) p.add_argument("--quality", type=int, default=85) p.add_argument("--recursive", action="store_true") p.add_argument("--workers", type=int, default=8) p.add_argument("--force", action="store_true", help="re-resize even if output exists") args = p.parse_args(["photos", "photos_resized"]) # demo values; in real use: p.parse_args() args.input_dir = Path("photos") args.output_dir = Path("photos_resized") args.output_dir.mkdir(parents=True, exist_ok=True) images = list(find_images(args.input_dir, args.recursive)) total = len(images) print(f"Found {total} images. Resizing with {args.workers} workers...") counts = {"ok": 0, "skipped": 0, "failed": 0} with ThreadPoolExecutor(max_workers=args.workers) as pool: futures = [ pool.submit(resize_one, src, args.output_dir, args.max_width, args.format, args.quality, not args.force) for src in images ] for i, fut in enumerate(as_completed(futures), 1): status, msg = fut.result() counts[status] += 1 print(f"\r {i}/{total} ok={counts['ok']} skipped={counts['skipped']} failed={counts['failed']}", end="") print() # newline after the progress line if counts["failed"]: print(f"⚠️ {counts['failed']} failures. Check the log.") # In a real script: if __name__ == "__main__": main()
This version adds three improvements:
- Threads:
ThreadPoolExecutorruns the I/O and the resampling (which releases the GIL) in parallel, so a large batch finishes much faster. For a nicer progress bar, usetqdm:pip install tqdmand wrapas_completed(futures)intqdm(...). - Idempotency: because of
skip_existing, if you add a few photos and run the script again, it processes only the new ones. The output folder works like a cache. - Format conversion:
--format webpconverts every image to WebP. WebP files are usually smaller than JPEG files of the same visual quality, so your pages load faster.
Stretch Goals
1. EXIF preservation: read img.info.get("exif"), then pass it as exif= to save(). Otherwise the resized copy loses the rotation, camera and GPS metadata.
2. Auto-orient before resize: from PIL import ImageOps; img = ImageOps.exif_transpose(img). Many phone photos are stored sideways, with an EXIF tag that says "rotate me 90°". If you resize without applying this rotation, and the EXIF tag is lost, the thumbnail appears sideways.
3. Watermark overlay: use ImageDraw.text() or Image.paste() to put text or a logo in the bottom-right corner before saving.
4. Smart crop for variants: from each source, create a 1200x1200 square version and a 1080x1920 portrait version, centred on the largest face (use opencv-python for face detection).
5. --replace mode: after a successful resize, delete the original. This is dangerous, so require a confirmation prompt and an extra --i-mean-it flag.
🎯 Your Turn: compute_new_size
thumbnail() does this calculation for you, but it is useful to know the maths when you write your own resizing code, plan CSS layouts or set the size of canvas elements. Given an image's original dimensions and a maximum dimension (longest side), return the new (width, height) that fits the constraint while preserving aspect ratio. Never enlarge: if the image already fits within max_dim, return the original size unchanged.
def compute_new_size(orig_w, orig_h, max_dim): """Scale (orig_w, orig_h) so the longest side equals max_dim, preserving aspect ratio. Never upscale. Return ints. """ # TODO 1: if both sides already fit, return (orig_w, orig_h) # TODO 2: compute the scale factor: max_dim / longest_side # TODO 3: apply scale to both dimensions, round to int pass # Test assert compute_new_size(4000, 3000, 1200) == (1200, 900) assert compute_new_size(3000, 4000, 1200) == (900, 1200) assert compute_new_size(800, 600, 1200) == (800, 600) # no upscale assert compute_new_size(1200, 1200, 1200) == (1200, 1200) # exact fit print("all passed")
Hint 1: Which side is longest?
longest = max(orig_w, orig_h). The scale factor is then max_dim / longest.
Hint 2: Don't upscale
Iflongest <= max_dim, just return the original dimensions. Otherwise compute the scale and apply.
Show full solution
def compute_new_size(orig_w, orig_h, max_dim): longest = max(orig_w, orig_h) if longest <= max_dim: return (orig_w, orig_h) scale = max_dim / longest return (round(orig_w * scale), round(orig_h * scale)) # Verify for w, h, m, expected in [ (4000, 3000, 1200, (1200, 900)), (3000, 4000, 1200, (900, 1200)), (800, 600, 1200, (800, 600)), (1200, 1200, 1200, (1200, 1200)), (5000, 1000, 1000, (1000, 200)), ]: got = compute_new_size(w, h, m) assert got == expected, f"({w},{h}) max={m}: got {got}, want {expected}" print(f" ({w}, {h}) max={m} → {got}")
The "never upscale" rule matters: enlarging an image makes it blurry and adds no detail. Pillow's thumbnail() follows the same rule.
What You Learned
- Pillow basics:
Image.open(),resize()vsthumbnail(), andsave()with quality and format options. pathlib.Path.glob()for filtering files by extension.- Per-file error handling: one bad file doesn't stop the batch.
argparsefor real CLI tools with--helpand type-checked arguments.ThreadPoolExecutorfor parallel work. It works well here because Pillow releases the GIL.- Idempotent jobs: skipping files that already exist makes a script safe to run again.
You can use this tool in many projects: images for blog posts, preparing ML datasets, photos uploaded by customers. The skip-if-exists pattern is also used in build systems, data pipelines and CDN caches, so you now understand one of the ideas they are built on.
Next: Rule-Based Chatbot, about pattern matching, conversation state, and the limits of a chatbot built from if statements.
Found this useful? Share it with a friend who’s learning Python.