Level 0 Capstone: Build a Photo-Processing Tool¶
Level 0 · Capstone · ⏱️ 6–10 hours · Uses: every Level 0 chapter
You've learned what images are, how they're stored, and how to handle them as arrays, files, and video. This capstone asks you to put it together in a real, reusable tool: a command-line program that batch-converts, resizes, annotates, and analyzes a whole folder of images. It's the kind of utility every vision engineer ends up writing, and building it well exercises every Level 0 habit: correct channel order, careful dtypes, aspect-preserving resizes, robust file handling, and looking at your results.
The scenario¶
Priya runs the product-photo pipeline for a small online shop. Every week, staff drop a folder of photos onto a shared drive: shots from phones and a DSLR, some PNG screenshots, the occasional WebP from a supplier, and, sometimes, a file that isn't really an image. Priya sends you this message:
"Before photos go on the site, someone has to shrink them, convert them to one format, put the product name on them, and add our watermark. Then I look through everything to catch the blurry and badly lit shots. It takes hours. Can you build a tool that does all of it in one command, gives me a single overview image to review, and a spreadsheet that tells me which photos look blurry or too dark?"
You'll build photo_tool.py, a script with two commands:
python photo_tool.py samples photos/creates a test folder.python photo_tool.py process photos/ out/ [options]processes every image inphotos/and writes results toout/.
The data¶
You don't have Priya's photos, so build a realistic test folder yourself. It must contain:
- At least six scikit-image sample photos, saved in a mix of formats: PNG, JPEG, WebP, and TIFF. Include gray ones (
camera,coins) and at least one larger than your target size (hubble_deep_fieldis 1000 × 872). - Synthetic images that you draw with NumPy and OpenCV: at least one with shapes and text on a gradient, and one in portrait orientation (taller than wide), to test aspect-ratio handling.
- Deliberately bad photos: a blurred copy of a sample (Gaussian blur, \(\sigma \approx 4\)) and an underexposed one (multiply by about 0.2).
- Problem files: a file with an image extension that isn't an image (for example, a
.jpgcontaining text), and a non-image file such asnotes.txtthat the tool should ignore.
Write this folder from your script's samples command, so anyone can recreate it.
Tasks¶
Part 1: Load and save robustly¶
- Find all images in the input folder by extension (case-insensitive), sorted by name, ignoring other files.
- Write
load_rgb(path)that returns an RGBuint8array for any supported file, including gray ones, and applies the EXIF orientation. It must raise a clear exception, not returnNone, for a file it can't decode. - Write
save_rgb(img, path, quality)that saves in the format implied by the extension (JPEG, PNG, or WebP), with the quality setting where it applies, and returns the file size in bytes.
Part 2: Transform¶
- Resize keeping the aspect ratio so the longest side is at most
--max-sidepixels. Never enlarge a small image. Use the right interpolation for shrinking. - Convert format: write every output in one format chosen with
--format(jpg,png, orwebp). - Caption: with
--caption TEXT, draw that text on every image; with--caption-from-filename, use each file's name. Draw it in white on a translucent dark strip along the bottom, and make sure it fits even on narrow images. - Watermark: with
--watermark TEXT, blend translucent text into a corner.
Part 3: Analyze and report¶
- For every image, measure: original width and height, output size, input and output file sizes in bytes, mean R, G, and B, brightness (mean luma, \(0.299R + 0.587G + 0.114B\)), and sharpness as the variance of the Laplacian of the gray image (explained in the hints).
- Flag images as
dark(brightness below a threshold) orblurry(sharpness below a threshold), with both thresholds settable on the command line. - Write everything to a CSV report, one row per input file, including a row with an error status for each file that couldn't be read.
Part 4: Review¶
- Write a contact sheet: one image with every processed photo letterboxed into a grid of equal cells, each labeled with its file name and any flags.
- Print a short summary: how many images were processed, which were skipped and why, and which were flagged.
- Open the contact sheet and the CSV. Do the flags agree with what you see? Find at least one false positive (a flagged image that's actually fine) and explain why the measure got it wrong.
Deliverables¶
photo_tool.py: one file, with functions you could import and reuse, and anargparsecommand-line interface withsamplesandprocesssubcommands and--helptext for every option.- The generated sample folder, and an output folder with the converted images,
contact_sheet.jpg, andreport.csv. - A short
NOTES.md(half a page): your design decisions (formats, interpolation, where you measure statistics), the false positives you found in Task 13, and what you'd change to make the blur and darkness flags more reliable.
Acceptance checklist¶
-
python photo_tool.py samples photos/followed bypython photo_tool.py process photos/ out/runs from a fresh terminal with no errors. - Every output image has the same aspect ratio as its input (within one pixel of rounding), and none is larger than
--max-sideor larger than its input. - Colors are correct in every output: no blue faces (BGR/RGB), and gray inputs come out gray.
- The broken file is reported as an error in the CSV and the summary, and processing continues.
notes.txtis ignored. - Captions and watermarks are readable and stay inside the image, including on the portrait image.
- The CSV has one row per input image with all the columns from Task 8, and the blurred and darkened samples are flagged.
-
--format,--quality,--max-side,--caption,--caption-from-filename, and--watermarkall work, and bad arguments (a missing folder, an invalid quality) give a clear error message and a nonzero exit code. - No
uint8overflow anywhere: blending uses saturating or float arithmetic.
Hints¶
Hint 1: reading files that OpenCV might refuse
cv2.imread returns None on failure and can't open some non-ASCII paths on Windows. Reading the bytes yourself avoids both problems: cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR). The default IMREAD_COLOR flag also converts gray files to 3 channels and applies EXIF orientation. Then convert BGR to RGB once, right away. For saving, cv2.imencode(ext, bgr, params) followed by buf.tofile(path) is the mirror image.
Hint 2: resizing without distortion
Compute one scale factor, \(s = \text{max\_side} / \max(H, W)\). If \(s \geq 1\), return the image unchanged. Otherwise resize to (round(W * s), round(H * s)). Remember that cv2.resize takes (width, height). Use cv2.INTER_AREA for shrinking: it averages and avoids aliasing.
Hint 3: translucent strips and watermarks
Draw the opaque shape or text on a copy of the image, then blend: cv2.addWeighted(overlay, alpha, img, 1 - alpha, 0). This saturates instead of wrapping and gives exactly \(\alpha \cdot \text{overlay} + (1 - \alpha) \cdot \text{img}\). To make text fit, measure it with cv2.getTextSize and shrink the font scale until it's narrower than the image.
Hint 4: measuring sharpness
The Laplacian of an image, \(\nabla^2 I = \partial^2 I / \partial x^2 + \partial^2 I / \partial y^2\), responds strongly at edges and fine texture and is near zero in smooth regions. A sharp photo has many strong responses, so the Laplacian's values vary a lot; a blurry one has weak responses everywhere. cv2.Laplacian(gray, cv2.CV_64F).var() computes it (use a float output type so negative values aren't clipped). It's a heuristic: it also depends on image content, contrast, noise, and size, so measure it at a consistent size, and expect false positives. Level 1's edges and gradients chapter covers the Laplacian properly.
Hint 5: the contact sheet
Reuse the letterbox function from Working with images and video. Allocate one white canvas of size (pad + rows * (cell_h + label_h + pad), pad + cols * (cell_w + pad), 3), compute each tile's top-left corner from divmod(k, cols), paste the letterboxed tile with slicing, and draw the label below it.
Hint 6: argparse subcommands
parser.add_subparsers(dest="command", required=True) creates subcommands; each add_parser gets its own arguments. A mutually exclusive group (add_mutually_exclusive_group) stops users from passing both --caption and --caption-from-filename. Make main(argv=None) accept an argument list, and pass it to parse_args(argv), so you can call main(["process", "photos", "out"]) from tests or a notebook.
Solution¶
Try the whole capstone before looking. When you're done, compare your work with the worked solution.