---
name: image_batch_processing_automation
description: "Batch process many images from a JSON manifest with Pillow: resize to stated pixel sizes, convert between JPEG, PNG, and WEBP, rename each output, apply per-file quality settings, preview the work with a dry-run plan, write a per-file result log, and verify every output by reopening it and checking its dimensions and format. Use when a folder of images needs the same rule-based transforms applied at volume."
metadata: { "includeInPrompt": true }
---

# Image Batch Processing Automation

## Purpose

Apply the same set of rule-based transforms to many images in one controlled run. The manifest is the contract: it names each source file, the output name, the exact output size, the output format, and the quality setting where the format uses one. The run has three distinct passes. A dry-run plan prints what would happen and writes nothing. A real run writes the outputs and a per-file result log. A verification pass reopens every output and checks its size and format against the manifest. Keeping those passes separate is the core of the skill: a bad size or a wrong format is caught in the plan or the verify step, not found by eye in a folder of finished files.

Scope:
- image-cleanup-upscale cleans and upscales ONE image with AI/Lanczos and quality checks (this skill is bulk rule-based transforms over many files)
- pdf-processing-automation is the same shape for PDFs
- spreadsheet-automation is the same shape for spreadsheets
- photo-dump-curation is selecting photos, not transforming them
- This skill covers resize, format conversion, output renaming, and quality settings for JPEG, PNG, and WEBP files, driven by a manifest, with a dry-run plan, a per-file result log, and a verification pass. It does not crop by content, retouch, upscale with AI, edit metadata, or curate which photos deserve keeping.
- Verification note, 2026-10-22: Pillow version checked live on PyPI on the drafting date via curl to https://pypi.org/pypi/Pillow/json, which returned version 13.0.0. The locally installed Pillow on the drafting machine was 10.2.0, reported by python3 -c "import PIL; print(PIL.__version__)", running on Python 3.12.3, and every scripted run below used that local 10.2.0 install. No install was needed. ImageMagick was probed locally and is NOT present: `which convert magick` returned no path and both `convert -version` and `magick -version` returned "command not found", so ImageMagick is excluded from this skill entirely and no ImageMagick command is named or used here. Pillow calls actually run in the fixture passes: Image.new, Image.save, Image.open with the format, size, and mode attributes read back, Image.resize with Image.LANCZOS, and Image.convert to RGB for an RGBA source saved as JPEG. The fixture run in the Operating Rules section produced 3 source images (PNG 400x300, JPEG 200x400, PNG RGBA 100x100), a dry-run plan of 3 jobs that wrote no output directory, a real run reporting 3 ok and 0 failed with a 3-row results.jsonl, and a verification pass reporting verified: 3/3 and verdict: PASS. Two extra probes also passed: an RGBA source converted to JPEG at 25x25 reopened as JPEG, 25x25, mode RGB, and a manifest pointing at a missing source printed a FAIL line, wrote the failure to its log, and returned exit code 1. WebP support on the local install was confirmed with PIL.features.check('webp') returning True before WebP was included in the allowed formats. What was NOT verified: the script was not run under Pillow 13.0.0, so behavior on the current PyPI release is untested here; batches larger than the 3-file fixture were not run, so timing and memory on hundreds or thousands of files are untested; animated images, EXIF orientation, embedded color profiles, and CMYK sources were not tested and are out of scope until a probe run covers them; PNG quality behavior was not tested because PNG saving in this script takes no quality argument at all.

## Workflow

1. Inventory the sources before writing the manifest.
   - List the files to process and read their real sizes and formats with Pillow, for example by opening each file and reading size and format from the image object. Do not write manifest entries from file extensions alone: a .jpg name on a PNG file is common in dumped folders, and the plan step reports the format Pillow actually reads.
   - Group the work by the transform each group needs. One manifest can hold jobs with different sizes and formats, but a clear grouping makes the plan easier to read and the failures easier to locate.
   - Decide the output directory now and keep it separate from the source directory. Writing outputs beside sources with new names still invites mix-ups; a separate output folder makes the run reversible by clearing one folder and running again.

2. Write the manifest as JSON with one job per output file.
   - Shape: a top-level output_dir string and a jobs list. Each job has source, output, width, height, and format, plus an optional quality integer. Source paths are resolved relative to the manifest file, and output_dir is resolved relative to the manifest file too.
   - Example job: {"source": "src/photo_a.png", "output": "web_photo_a.jpg", "width": 200, "height": 150, "format": "JPEG", "quality": 80}. That job reads src/photo_a.png, resizes it to exactly 200x150, converts it to JPEG at quality 80, and writes it as web_photo_a.jpg inside output_dir.
   - Width and height are the exact output size in pixels. This script does not preserve aspect ratio on its own: if the target ratio differs from the source ratio, the image is stretched to the stated size. Compute a matching height from the source ratio first when stretching would be wrong, and put that computed pair in the manifest.
   - Output names are the rename step. Use a consistent pattern across the batch, such as a prefix plus the original stem, so the outputs sort well and their source is still traceable. Two jobs must never share one output name: the second write would silently replace the first, and the result log would still show both as ok.
   - Allowed formats are JPEG, PNG, and WEBP, written in upper case. Any other format value stops that job with an unsupported format error in the plan and run passes.

3. Run the dry-run plan and read every line.
   - Run the script with --manifest and --dry-run. The plan checks that each source exists, opens it, and prints one PLAN line per job with the source size and format it read, plus the output name, size, and format from the manifest.
   - The plan writes nothing. In the fixture run, the output directory did not exist after the dry run, which is the check to repeat on real work: if files appeared, something other than the plan wrote them.
   - Read the plan against the intent, not against the manifest. The manifest and the plan agree by construction. The question is whether the sizes, formats, and names in the plan are the ones the job actually needs. A transposed width and height is the most common error and it is plainly visible in the plan lines.
   - Fix manifest errors at the manifest. Do not patch outputs after the run to correct a wrong size or format, because the next run from the same manifest would recreate the error.

4. Check the format and mode traps before the real run.
   - JPEG has no alpha channel. A source with mode RGBA, LA, or P saved as JPEG is converted to RGB by the script before saving, which drops the alpha by flattening it onto the converted color values. If the transparency matters, choose PNG or WEBP for that job instead.
   - Quality applies to JPEG and WEBP jobs only, as an integer passed to the save call. PNG jobs take no quality argument in this script, and a quality value written on a PNG job is ignored. Set quality per job, not per batch, when a batch mixes small thumbnails with hero images.
   - Resizing uses Image.LANCZOS, a high quality downsampling filter that also works for enlargement. Enlarging a small source to a large stated size will produce a soft result; the verify pass confirms dimensions and format only, and says nothing about sharpness. Keep stated sizes at or below the source size unless a soft result is acceptable.
   - A corrupt or truncated source fails at open or resize time for that job only. The run continues with the remaining jobs and records the failure, per step 6.

5. Run the batch.
   - Run the script with --manifest and --run. The output directory is created if it does not exist. Each job is opened, resized, converted if the JPEG rule in step 4 applies, and saved under its output name in the stated format.
   - The run prints one OK or FAIL line per job as it goes, then a results_log line naming the log file, then a done line with the ok and failed counts. The exit code is 0 when no job failed and 1 when at least one job failed, so a calling script or a scheduled job can detect a partial batch without parsing the output.
   - A rerun with the same manifest overwrites the outputs and rewrites the result log from scratch. That is the intended recovery path: fix the manifest or replace a bad source, then run the whole batch again, rather than running a partial fix by hand.

6. Read the per-file result log.
   - The log is results.jsonl inside the output directory, one JSON object per job in manifest order. An ok row carries source, output, width, height, format, and status ok. A failed row carries source, output, status fail, and an error string stating the cause, such as a missing source path.
   - Treat any failed count above zero as an unfinished batch, even when most jobs passed. The outputs for the ok jobs are valid, but the batch as ordered is incomplete until the failed jobs are fixed and the rerun shows 0 failed.
   - Keep the log with the outputs. It is the record that ties each output name back to its source and its stated transform, which matters when the same source is processed again later at a different size.

7. Run the verification pass on the outputs.
   - Run the script with --manifest and --verify. Each output is reopened with Pillow and its actual format and size are read from the file itself, then compared with the width, height, and format in the manifest.
   - Each job prints one VERIFY line with expected and actual values and a PASS or FAIL result. A missing output prints result MISSING. The pass ends with a verified count and a verdict line: PASS only when every job passed.
   - The verify pass reads the files on disk, not the run's own claims, so it catches a wrong-format save, a swapped output pair, and a file replaced after the run. Run it as a separate step after the run, including after any rerun.
   - Verification covers dimensions and format only. It does not check visual quality, color, sharpness, file size, or whether the right picture content landed under the right name. Spot-check a few outputs by eye for content, especially after a manifest with many similar names.

8. Handle failures by cause, then rerun.
   - Missing source: the path in the manifest is wrong or the file moved. Fix the path relative to the manifest file, not relative to the folder the command happened to run from.
   - Unsupported format: the format value is outside JPEG, PNG, and WEBP. Correct the value or drop the job; the script will not guess a near match.
   - Save or open errors on one file with others passing: suspect that one source file first. Open it alone with Pillow and read its size, format, and mode before changing the batch.
   - A verify FAIL with matching dimensions but a wrong format usually means the output file predates the current manifest, for example an older run wrote it and the latest run failed before reaching that job. Rerun the batch, then verify again.

9. Deliver the batch as a set.
   - Deliver the output folder, the manifest that produced it, and the results.jsonl from the final run, together with the full verify output. Any one of those alone is hard to audit later.
   - State the counts plainly: jobs in the manifest, ok and failed in the final run, and the verified count from the verification pass. If the counts disagree, say so and say why, rather than reporting only the best number.
   - Note any job where the stated size enlarged the source, any JPEG job whose source carried transparency, and any job left failed. Those are the spots where the stated rules produced a result a reader of the counts alone would not expect.

## Output Contract

Deliver these items, in this order:

1. Manifest: the JSON file used for the final run, with output_dir and the full jobs list, so the batch can be reproduced exactly.
2. Output folder: the image files written by the final run, named as the manifest states, in the formats and sizes the manifest states.
3. Result log: results.jsonl from the output folder of the final run, one row per job, showing status ok for every delivered job and the error string for any job left failed.
4. Run output: the full printed output of the final run, ending with the done line. On the worked fixture in the Operating Rules section the run ends with done: 3 ok, 0 failed.
5. Verification output: the full printed output of the verification pass run after the final run. On the worked fixture it ends with verified: 3/3 and verdict: PASS.
6. Notes: any enlargements, any transparency dropped by a JPEG conversion, any failed jobs and their causes, and anything the verification pass does not cover that was checked by eye.

## Operating Rules

1. The manifest is the single source of truth for the batch. Sizes, formats, names, and quality values live there, not in ad hoc command flags or in post-run fixes.
2. No real run without a dry-run plan read first. The plan costs one command and catches transposed sizes, wrong paths, and unsupported formats before any file is written.
3. Every number reported about the outputs comes from reading the files: the plan reads the sources, the verify pass reads the outputs. Do not report a size or format from the manifest alone as if it were the file's state.
4. A batch with any failed job is unfinished. Fix the cause, rerun the whole batch, and verify again. Partial hand fixes break the link between the manifest, the log, and the outputs.
5. Output names are unique within a batch. Duplicate names overwrite silently and the log will not reveal it, so uniqueness is checked when the manifest is written.
6. Quality is set per job for JPEG and WEBP only. PNG jobs carry no quality value, and claiming one was applied to a PNG output is an error in the delivery notes.
7. Verification is dimensions and format only. Visual content, sharpness, and color need a separate by-eye check, and the delivery notes must not present a verify PASS as a quality pass.
8. Use the script below for the plan, run, and verify passes. Save it as image_batch.py, then run it. Put its printed outputs in the deliverable as items 4 and 5 of the Output Contract, and report numbers from those outputs.

### Batch script (image_batch.py)

Run: `python3 image_batch.py --make-fixture fixture` builds the worked fixture in a folder named fixture. Then `python3 image_batch.py --manifest fixture/manifest.json --dry-run` prints the plan, `python3 image_batch.py --manifest fixture/manifest.json --run` writes the outputs and the log, and `python3 image_batch.py --manifest fixture/manifest.json --verify` checks them. Pillow is the only dependency. Install it with pip if the import fails, and record the version used.

Expected output for the fixture passes, in order:

```text
fixture_dir: fixture
fixture_files: 3
manifest: fixture/manifest.json
jobs: 3
output_dir: fixture/out
PLAN source=src/photo_a.png src_size=400x300 src_format=PNG output=web_photo_a.jpg size=200x150 format=JPEG
PLAN source=src/photo_b.jpg src_size=200x400 src_format=JPEG output=web_photo_b.png size=100x200 format=PNG
PLAN source=src/icon_c.png src_size=100x100 src_format=PNG output=web_icon_c.webp size=50x50 format=WEBP
dry_run: no files written
OK output=web_photo_a.jpg size=200x150 format=JPEG
OK output=web_photo_b.png size=100x200 format=PNG
OK output=web_icon_c.webp size=50x50 format=WEBP
results_log: fixture/out/results.jsonl
done: 3 ok, 0 failed
VERIFY output=web_photo_a.jpg expected=200x150/JPEG actual=200x150/JPEG result=PASS
VERIFY output=web_photo_b.png expected=100x200/PNG actual=100x200/PNG result=PASS
VERIFY output=web_icon_c.webp expected=50x50/WEBP actual=50x50/WEBP result=PASS
verified: 3/3
verdict: PASS
```

The fixture is built to exercise each path once: a PNG source converted to JPEG, a JPEG source converted to PNG in portrait shape, and an RGBA PNG source converted to WEBP at a smaller size. The results.jsonl from the fixture run holds three ok rows in manifest order with the width, height, and format of each job. A separate probe with an RGBA source saved as JPEG at 25x25 also passed verification and reopened as mode RGB, which is the conversion path described in step 4 of the Workflow.

image_batch.py. Save this code as image_batch.py:

```python
#!/usr/bin/env python3
"""Batch image transforms driven by a JSON manifest.

Usage:
  python3 image_batch.py --make-fixture DIR
  python3 image_batch.py --manifest MANIFEST.json --dry-run
  python3 image_batch.py --manifest MANIFEST.json --run
  python3 image_batch.py --manifest MANIFEST.json --verify

Manifest shape:
  {
    "output_dir": "out",
    "jobs": [
      {"source": "photo_a.png", "output": "web_photo_a.jpg",
       "width": 200, "height": 150, "format": "JPEG", "quality": 80}
    ]
  }
Source paths are resolved relative to the manifest file.
output_dir is resolved relative to the manifest file.
width and height are the exact output size in pixels.
format is JPEG, PNG, or WEBP. quality applies to JPEG and WEBP only.
The --run mode writes results.jsonl inside output_dir, one JSON
object per job, and prints one OK or FAIL line per job.
The --verify mode reopens every output and checks size and format.
"""
import argparse
import json
import sys
from pathlib import Path

from PIL import Image

ALLOWED_FORMATS = ("JPEG", "PNG", "WEBP")


def build_fixture(root):
    root = Path(root)
    src = root / "src"
    src.mkdir(parents=True, exist_ok=True)
    specs = [
        ("photo_a.png", "RGB", (400, 300), (200, 30, 30)),
        ("photo_b.jpg", "RGB", (200, 400), (30, 200, 30)),
        ("icon_c.png", "RGBA", (100, 100), (30, 30, 200, 128)),
    ]
    for name, mode, size, color in specs:
        im = Image.new(mode, size, color)
        im.save(src / name)
    manifest = {
        "output_dir": "out",
        "jobs": [
            {"source": "src/photo_a.png", "output": "web_photo_a.jpg",
             "width": 200, "height": 150, "format": "JPEG", "quality": 80},
            {"source": "src/photo_b.jpg", "output": "web_photo_b.png",
             "width": 100, "height": 200, "format": "PNG"},
            {"source": "src/icon_c.png", "output": "web_icon_c.webp",
             "width": 50, "height": 50, "format": "WEBP", "quality": 90},
        ],
    }
    manifest_path = root / "manifest.json"
    manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
    print(f"fixture_dir: {root}")
    print(f"fixture_files: {len(specs)}")
    print(f"manifest: {manifest_path}")
    return 0


def load_jobs(manifest_path):
    manifest_path = Path(manifest_path)
    data = json.loads(manifest_path.read_text(encoding="utf-8"))
    base = manifest_path.parent
    output_dir = base / data.get("output_dir", "out")
    jobs = data["jobs"]
    return base, output_dir, jobs


def check_job(base, job):
    fmt = str(job["format"]).upper()
    if fmt not in ALLOWED_FORMATS:
        raise ValueError(f"unsupported format: {fmt}")
    width = int(job["width"])
    height = int(job["height"])
    if width <= 0 or height <= 0:
        raise ValueError("width and height must be positive")
    source = base / job["source"]
    if not source.is_file():
        raise FileNotFoundError(f"source missing: {source}")
    return source, width, height, fmt


def plan(manifest_path):
    base, output_dir, jobs = load_jobs(manifest_path)
    print(f"jobs: {len(jobs)}")
    print(f"output_dir: {output_dir}")
    for job in jobs:
        source, width, height, fmt = check_job(base, job)
        with Image.open(source) as im:
            print(
                f"PLAN source={job['source']} src_size={im.size[0]}x{im.size[1]} "
                f"src_format={im.format} output={job['output']} "
                f"size={width}x{height} format={fmt}"
            )
    print("dry_run: no files written")
    return 0


def run(manifest_path):
    base, output_dir, jobs = load_jobs(manifest_path)
    output_dir.mkdir(parents=True, exist_ok=True)
    log_path = output_dir / "results.jsonl"
    ok = 0
    failed = 0
    rows = []
    for job in jobs:
        try:
            source, width, height, fmt = check_job(base, job)
            with Image.open(source) as im:
                work = im.resize((width, height), Image.LANCZOS)
                if fmt == "JPEG" and work.mode not in ("RGB", "L"):
                    work = work.convert("RGB")
                dest = output_dir / job["output"]
                if fmt in ("JPEG", "WEBP") and "quality" in job:
                    work.save(dest, fmt, quality=int(job["quality"]))
                else:
                    work.save(dest, fmt)
            rows.append({"source": job["source"], "output": job["output"],
                         "width": width, "height": height, "format": fmt,
                         "status": "ok"})
            print(f"OK output={job['output']} size={width}x{height} format={fmt}")
            ok += 1
        except Exception as exc:
            rows.append({"source": job.get("source", "?"),
                         "output": job.get("output", "?"),
                         "status": "fail", "error": str(exc)})
            print(f"FAIL output={job.get('output', '?')} error={exc}")
            failed += 1
    with log_path.open("w", encoding="utf-8") as fh:
        for row in rows:
            fh.write(json.dumps(row, sort_keys=True) + "\n")
    print(f"results_log: {log_path}")
    print(f"done: {ok} ok, {failed} failed")
    return 0 if failed == 0 else 1


def verify(manifest_path):
    base, output_dir, jobs = load_jobs(manifest_path)
    passed = 0
    for job in jobs:
        fmt = str(job["format"]).upper()
        width = int(job["width"])
        height = int(job["height"])
        dest = output_dir / job["output"]
        if not dest.is_file():
            print(f"VERIFY output={job['output']} result=MISSING")
            continue
        with Image.open(dest) as im:
            actual_fmt = im.format
            actual_w, actual_h = im.size
        good = (actual_fmt == fmt and actual_w == width and actual_h == height)
        if good:
            passed += 1
        print(
            f"VERIFY output={job['output']} expected={width}x{height}/{fmt} "
            f"actual={actual_w}x{actual_h}/{actual_fmt} "
            f"result={'PASS' if good else 'FAIL'}"
        )
    print(f"verified: {passed}/{len(jobs)}")
    print(f"verdict: {'PASS' if passed == len(jobs) else 'FAIL'}")
    return 0 if passed == len(jobs) else 1


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--make-fixture", metavar="DIR")
    parser.add_argument("--manifest", metavar="PATH")
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--verify", action="store_true")
    args = parser.parse_args()
    if args.make_fixture:
        return build_fixture(args.make_fixture)
    if args.manifest and args.dry_run:
        return plan(args.manifest)
    if args.manifest and args.run:
        return run(args.manifest)
    if args.manifest and args.verify:
        return verify(args.manifest)
    parser.print_usage(sys.stderr)
    return 2


if __name__ == "__main__":
    raise SystemExit(main())
```
