Skill Tracer
Skills › Scientific Agent Skills › pptx-posters › inventory_images.py

inventory_images.py

252 lines · skills/pptx-posters/scripts/inventory_images.py at 5f2bc03

1#!/usr/bin/env python3
2"""Build a hashed local-image inventory with final effective DPI."""
3
4from __future__ import annotations
5
6import argparse
7import sys
8import warnings
9from importlib.metadata import PackageNotFoundError, version
10from pathlib import Path
11from typing import Any
13from _common import CliError, emit_json, resolve_local_asset
14from _manifest import ASSET_SUFFIXES, load_and_validate_manifest
16MAX_IMAGE_PIXELS = 100_000_000
19def _pillow_version() -> str:
20 try:
21 return version("Pillow")
22 except PackageNotFoundError:
23 return "unknown"
26def _read_image(path: Path) -> dict[str, Any]:
27 try:
28 from PIL import Image
29 except ImportError as exc:
30 raise CliError(
31 "Pillow is required for image inspection; install the exact pin with "
32 "`uv pip install \"Pillow==12.3.0\"`"
33 ) from exc
34 Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
35 try:
36 with warnings.catch_warnings():
37 warnings.simplefilter("error", Image.DecompressionBombWarning)
38 with Image.open(path) as image:
39 image.verify()
40 with Image.open(path) as image:
41 width, height = image.size
42 image_format = image.format
43 mode = image.mode
44 frames = int(getattr(image, "n_frames", 1))
45 image.load()
46 has_icc_profile = bool(image.info.get("icc_profile"))
47 has_alpha = "A" in image.getbands()
48 exif_entry_count = len(image.getexif())
49 text_keys = sorted(
50 str(key) for key in getattr(image, "text", {}).keys()
51 )
52 metadata_keys = sorted(str(key) for key in image.info)
53 except (OSError, SyntaxError, Image.DecompressionBombError) as exc:
54 raise CliError(f"cannot safely decode image {path}: {exc}") from exc
55 except Image.DecompressionBombWarning as exc:
56 raise CliError(f"image exceeds the pixel safety policy: {path}") from exc
57 if image_format not in {"PNG", "JPEG"}:
58 raise CliError(f"image format must be PNG or JPEG: {path}")
59 if frames != 1:
60 raise CliError(f"animated or multi-frame images are forbidden: {path}")
61 if mode not in {"RGB", "RGBA", "L", "LA"}:
62 raise CliError(
63 f"image mode {mode!r} requires conversion and author review: {path}"
64 )
65 return {
66 "format": image_format,
67 "mode": mode,
68 "width_px": width,
69 "height_px": height,
70 "frames": frames,
71 "has_alpha": has_alpha,
72 "has_icc_profile": has_icc_profile,
73 "exif_entry_count": exif_entry_count,
74 "embedded_text_keys": text_keys,
75 "metadata_keys": metadata_keys,
76 }
79def build_inventory(
80 manifest_path: Path,
81 document: dict[str, Any],
82 validation: dict[str, Any],
83) -> dict[str, Any]:
84 assets = {asset["id"]: asset for asset in document["assets"]}
85 placements: dict[str, list[dict[str, Any]]] = {
86 asset_id: [] for asset_id in assets
87 }
88 scale = float(validation["physical_output"]["print_scale"])
89 minimum_dpi = float(document["quality"]["minimum_raster_dpi_final"])
90 issues: list[dict[str, Any]] = []
91 image_records: list[dict[str, Any]] = []
93 for element in document["elements"]:
94 if element["type"] == "image":
95 placements[element["asset_id"]].append(element)
97 for asset_id, asset in assets.items():
98 path = resolve_local_asset(
99 manifest_path,
100 asset["path"],
101 suffixes=ASSET_SUFFIXES,
102 )
103 metadata = _read_image(path)
104 suspicious_metadata = sorted(
105 {
106 key
107 for key in metadata["metadata_keys"]
108 if any(
109 marker in key.lower()
110 for marker in (
111 "comment",
112 "description",
113 "exif",
114 "iptc",
115 "photoshop",
116 "software",
117 "xmp",
118 "xml",
119 )
120 )
121 }
122 | set(metadata["embedded_text_keys"])
123 )
124 if metadata["exif_entry_count"] or suspicious_metadata:
125 issues.append(
126 {
127 "code": "HIDDEN_IMAGE_METADATA",
128 "asset_id": asset_id,
129 "message": (
130 "image contains EXIF or textual/application metadata; "
131 "flatten and strip it in an author-reviewed offline workflow, "
132 "then rehash and reapprove the asset"
133 ),
134 "exif_entry_count": metadata["exif_entry_count"],
135 "metadata_keys": suspicious_metadata,
136 }
137 )
138 asset_placements: list[dict[str, Any]] = []
139 for element in placements[asset_id]:
140 box_width = float(element["width_in"])
141 box_height = float(element["height_in"])
142 fit_scale = min(
143 box_width / int(metadata["width_px"]),
144 box_height / int(metadata["height_px"]),
145 )
146 design_width = int(metadata["width_px"]) * fit_scale
147 design_height = int(metadata["height_px"]) * fit_scale
148 final_width = design_width * scale
149 final_height = design_height * scale
150 dpi_x = int(metadata["width_px"]) / final_width
151 dpi_y = int(metadata["height_px"]) / final_height
152 effective_dpi = min(dpi_x, dpi_y)
153 placement = {
154 "element_id": element["id"],
155 "box_width_in_design": box_width,
156 "box_height_in_design": box_height,
157 "placed_width_in_design": design_width,
158 "placed_height_in_design": design_height,
159 "placed_width_in_final": final_width,
160 "placed_height_in_final": final_height,
161 "effective_dpi_x_final": dpi_x,
162 "effective_dpi_y_final": dpi_y,
163 "minimum_effective_dpi_final": effective_dpi,
164 "pass": effective_dpi + 1e-6 >= minimum_dpi,
165 }
166 if not placement["pass"]:
167 issues.append(
168 {
169 "code": "EFFECTIVE_DPI_TOO_LOW",
170 "asset_id": asset_id,
171 "element_id": element["id"],
172 "message": (
173 f"{effective_dpi:.1f} DPI is below the manifest's "
174 f"{minimum_dpi:.1f} DPI final-output requirement"
175 ),
176 }
177 )
178 if asset["role"] == "qr_code":
179 placement["qr_manual_check"] = (
180 "Test this rendered QR code on the final proof with multiple "
181 "devices; the exact fallback URL is separate visible text."
182 )
183 asset_placements.append(placement)
184 image_records.append(
185 {
186 "id": asset_id,
187 "relative_path": asset["path"],
188 "sha256": asset["sha256"],
189 "source_id": asset["source_id"],
190 "license": asset["license"],
191 "provenance": asset["provenance"],
192 "role": asset["role"],
193 "alt_text": asset["alt_text"],
194 "metadata": metadata,
195 "placements": asset_placements,
196 }
197 )
198 return {
199 "schema_version": "1.0",
200 "manifest": {
201 "path": str(manifest_path),
202 "content_sha256": validation["content_sha256"],
203 },
204 "pass": not issues,
205 "pillow_version": _pillow_version(),
206 "pixel_safety_limit": MAX_IMAGE_PIXELS,
207 "minimum_raster_dpi_final": minimum_dpi,
208 "minimum_raster_dpi_basis": {
209 "kind": document["quality"]["raster_dpi_basis"],
210 "source_id": document["quality"]["raster_dpi_source_id"],
211 },
212 "images": image_records,
213 "issues": issues,
214 "notice": (
215 "Effective DPI is pixels divided by placed inches at final physical "
216 "output, not file metadata DPI. Inspect the printer proof for resampling, "
217 "compression, color conversion, and QR reliability."
218 ),
219 }
222def build_parser() -> argparse.ArgumentParser:
223 parser = argparse.ArgumentParser(
224 description=(
225 "Fully decode approved local PNG/JPEG assets, report hashes, provenance, "
226 "permission, and metadata, and calculate effective DPI at final physical "
227 "placement. No network or image generation is used."
228 )
229 )
230 parser.add_argument("manifest", help="approved local poster manifest JSON")
231 parser.add_argument("--output", help="optional new JSON asset-manifest path")
232 return parser
235def main(argv: list[str] | None = None) -> int:
236 parser = build_parser()
237 try:
238 args = parser.parse_args(argv)
239 manifest_path, document, validation = load_and_validate_manifest(
240 args.manifest,
241 verify_assets=True,
242 require_approval=True,
243 )
244 report = build_inventory(manifest_path, document, validation)
245 emit_json(report, output=args.output)
246 return 0 if report["pass"] else 1
247 except CliError as exc:
248 parser.exit(2, f"error: {exc}\n")
251if __name__ == "__main__":
252 sys.exit(main())