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.
./install.sh image-batch-processing-automationname: 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 }
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:
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.Deliver these items, in this order:
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())
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.