返回 ppt-master
analyze_images.py
根目录 / skills / ppt-master / scripts / analyze_images.py
1 #!/usr/bin/env python3
2 """
3 Image Size Analysis Tool
4 ========================
5 Reports objective parameters for all images in a folder. It does not resolve a
6 canvas, prescribe a layout, or generate Strategist recommendations.
7
8 Usage:
9 python scripts/analyze_images.py <images_folder_path>
10 python scripts/analyze_images.py projects/xxx/images
11
12 Output:
13 - Analysis report displayed in console
14 - Generates image_analysis.csv under the project's analysis/ directory
15 (sibling of the images folder), alongside the PPTX intake bundle
16 """
17
18 import argparse
19 import csv
20 import json
21 import os
22 import sys
23 import tempfile
24 from pathlib import Path
25
26 from console_encoding import configure_utf8_stdio
27
28 configure_utf8_stdio()
29
30 try:
31 from PIL import Image, ImageOps
32 except ImportError:
33 print("Error: PIL/Pillow not installed. Run: pip install Pillow")
34 sys.exit(1)
35
36 IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
37 OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
38 REPORT_WIDTH = 100
39 CATEGORY_WIDTH = 50
40
41 ImageAnalysis = dict[str, object]
42
43
44 def _load_image_manifest(images_dir: str) -> dict[str, dict]:
45 """Load optional DOCX image metadata keyed by generated filename."""
46 manifest_path = Path(images_dir) / "image_manifest.json"
47 if not manifest_path.is_file():
48 return {}
49 try:
50 data = json.loads(manifest_path.read_text(encoding="utf-8"))
51 except (OSError, json.JSONDecodeError) as exc:
52 print(f"[WARN] Cannot read image manifest: {exc}")
53 return {}
54 if not isinstance(data, list):
55 return {}
56
57 manifest: dict[str, dict] = {}
58 for item in data:
59 if not isinstance(item, dict):
60 continue
61 filename = item.get("filename")
62 if isinstance(filename, str):
63 manifest[filename] = item
64 return manifest
65
66
67 def _manifest_ratio(meta: dict | None) -> float | None:
68 """Return a positive display ratio from manifest metadata."""
69 if not meta:
70 return None
71 value = meta.get("display_ratio")
72 if not isinstance(value, (int, float)):
73 return None
74 ratio = float(value)
75 return ratio if ratio > 0 else None
76
77
78 def _manifest_display_size(meta: dict, ratio: float) -> tuple[int, int]:
79 """Return a display-sized stand-in for vector media dimensions."""
80 width_in = meta.get("display_width_in")
81 height_in = meta.get("display_height_in")
82 if isinstance(width_in, (int, float)) and isinstance(height_in, (int, float)):
83 width = max(1, int(round(float(width_in) * 96)))
84 height = max(1, int(round(float(height_in) * 96)))
85 return width, height
86
87 width_emu = meta.get("display_width_emu")
88 height_emu = meta.get("display_height_emu")
89 if isinstance(width_emu, int) and isinstance(height_emu, int):
90 width = max(1, int(round(width_emu / 914400 * 96)))
91 height = max(1, int(round(height_emu / 914400 * 96)))
92 return width, height
93
94 width = 960
95 height = max(1, int(round(width / ratio)))
96 return width, height
97
98
99 def _manifest_usage_count(meta: dict | None) -> int:
100 """Return how many source occurrences point to one asset."""
101 if not meta:
102 return 1
103 usage_count = meta.get("usage_count")
104 if isinstance(usage_count, int) and usage_count > 0:
105 return usage_count
106 occurrences = meta.get("occurrences")
107 if isinstance(occurrences, list) and occurrences:
108 return len(occurrences)
109 return 1
110
111
112 def _manifest_ratio_variants(meta: dict | None) -> str:
113 """Return a compact list of display ratio variants from manifest metadata."""
114 if not meta:
115 return ""
116 variants = meta.get("display_ratio_variants")
117 if not isinstance(variants, list):
118 return ""
119 ratios = [
120 f"{float(value):.2f}"
121 for value in variants
122 if isinstance(value, (int, float)) and value > 0
123 ]
124 return ";".join(ratios)
125
126
127 def _apply_manifest_metadata(result: ImageAnalysis, meta: dict | None) -> None:
128 """Copy optional manifest fields into an image analysis row."""
129 result["usage_count"] = _manifest_usage_count(meta)
130 result["display_ratio_variants"] = _manifest_ratio_variants(meta)
131 if not meta:
132 return
133
134 source_ext = meta.get("source_ext")
135 original_filename = meta.get("original_filename")
136 if isinstance(source_ext, str):
137 result["source_ext"] = source_ext
138 if isinstance(original_filename, str):
139 result["original_filename"] = original_filename
140 result["asset_kind"] = meta.get("asset_kind", "bitmap")
141 result["svg_renderable"] = meta.get("svg_renderable", True)
142 result["pptx_native_supported"] = meta.get("pptx_native_supported", True)
143
144
145 def _has_transparent_pixels(image: Image.Image) -> bool:
146 """Return whether any frame contains a pixel with alpha below 255."""
147 original_frame = image.tell()
148 frame_count = int(getattr(image, "n_frames", 1))
149 try:
150 for frame_index in range(frame_count):
151 image.seek(frame_index)
152 if "A" not in image.getbands() and "transparency" not in image.info:
153 continue
154 rgba = image.convert("RGBA")
155 alpha = rgba.getchannel("A")
156 try:
157 extrema = alpha.getextrema()
158 finally:
159 alpha.close()
160 rgba.close()
161 if extrema and extrema[0] < 255:
162 return True
163 finally:
164 image.seek(original_frame)
165 return False
166
167
168 def _result_from_manifest(
169 filename: str,
170 filepath: str,
171 meta: dict,
172 ) -> ImageAnalysis | None:
173 """Build an analysis row for vector media Pillow cannot decode."""
174 ratio = _manifest_ratio(meta)
175 if ratio is None:
176 return None
177 width, height = _manifest_display_size(meta, ratio)
178 result: ImageAnalysis = {
179 'filename': filename,
180 'width': width,
181 'height': height,
182 'aspect_ratio': ratio,
183 'pixel_aspect_ratio': None,
184 'source_display_ratio': ratio,
185 'ratio_source': 'manifest',
186 'format': Path(filename).suffix.lstrip('.').upper(),
187 'has_transparent_pixels': None,
188 'category': classify_ratio(ratio),
189 'filesize_kb': os.path.getsize(filepath) / 1024,
190 }
191 _apply_manifest_metadata(result, meta)
192 suffix = Path(filename).suffix.lower()
193 is_office_vector = suffix in OFFICE_VECTOR_EXTENSIONS
194 result["asset_kind"] = meta.get(
195 "asset_kind",
196 "office_vector" if is_office_vector else "vector",
197 )
198 result["svg_renderable"] = meta.get("svg_renderable", suffix == ".svg")
199 result["pptx_native_supported"] = meta.get(
200 "pptx_native_supported",
201 is_office_vector or suffix == ".svg",
202 )
203 return result
204
205
206 def classify_ratio(aspect_ratio: float) -> str:
207 """Classify an image by its objective aspect-ratio range.
208
209 Ranges: >2.0 ultra-wide, 1.5-2.0 wide, 1.2-1.5 standard
210 landscape, 0.8-1.2 near square, and <0.8 portrait.
211 """
212 if aspect_ratio > 2.0:
213 return "Ultra-wide"
214 elif aspect_ratio > 1.5:
215 return "Wide landscape"
216 elif aspect_ratio > 1.2:
217 return "Standard landscape"
218 elif aspect_ratio > 0.8:
219 return "Near square"
220 else:
221 return "Portrait"
222
223
224 def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]:
225 """Analyze all image files in a directory.
226
227 Args:
228 images_dir: Directory that contains image files.
229
230 Returns:
231 Sorted image analysis records and supported files that could not be read.
232 """
233
234 results: list[ImageAnalysis] = []
235 errors: list[str] = []
236 manifest = _load_image_manifest(images_dir)
237
238 for filename in sorted(os.listdir(images_dir)):
239 filepath = os.path.join(images_dir, filename)
240 if not os.path.isfile(filepath):
241 continue
242
243 suffix = Path(filename).suffix.lower()
244 meta = manifest.get(filename)
245
246 if suffix in IMAGE_EXTENSIONS:
247 try:
248 with Image.open(filepath) as img:
249 image_format = img.format or suffix.lstrip(".").upper()
250 has_transparent_pixels = _has_transparent_pixels(img)
251 oriented = ImageOps.exif_transpose(img)
252 try:
253 width, height = oriented.size
254 finally:
255 if oriented is not img:
256 oriented.close()
257
258 aspect_ratio = width / height
259
260 result: ImageAnalysis = {
261 'filename': filename,
262 'width': width,
263 'height': height,
264 'aspect_ratio': aspect_ratio,
265 'pixel_aspect_ratio': aspect_ratio,
266 'source_display_ratio': _manifest_ratio(meta),
267 'ratio_source': 'native',
268 'format': image_format,
269 'has_transparent_pixels': has_transparent_pixels,
270 'category': classify_ratio(aspect_ratio),
271 'filesize_kb': os.path.getsize(filepath) / 1024
272 }
273 _apply_manifest_metadata(result, meta)
274 results.append(result)
275 except (
276 EOFError,
277 OSError,
278 SyntaxError,
279 ValueError,
280 ZeroDivisionError,
281 Image.DecompressionBombError,
282 ) as exc:
283 message = f"{filename}: {exc}"
284 errors.append(message)
285 print(f"[WARN] Cannot read {message}")
286 elif meta:
287 result = _result_from_manifest(filename, filepath, meta)
288 if result:
289 results.append(result)
290 else:
291 message = f"{filename}: manifest has no valid display_ratio"
292 errors.append(message)
293 print(f"[WARN] Cannot analyze {message}")
294 elif suffix in OFFICE_VECTOR_EXTENSIONS:
295 message = f"{filename}: image_manifest.json metadata is required"
296 errors.append(message)
297 print(f"[WARN] Cannot analyze {message}")
298
299 return results, errors
300
301
302 def analyze_images(images_dir: str) -> list[ImageAnalysis]:
303 """Analyze readable image files while preserving the existing public API."""
304 results, _ = _analyze_images(images_dir)
305 return results
306
307
308 def print_results(results: list[ImageAnalysis]) -> None:
309 """Print the analysis report to stdout."""
310
311 print("\n" + "=" * REPORT_WIDTH)
312 print("Image Size Analysis Report")
313 print("=" * REPORT_WIDTH)
314
315 print(
316 f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} "
317 f"{'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Filename'}"
318 )
319 print("-" * REPORT_WIDTH)
320
321 for i, img in enumerate(results, 1):
322 ratio_source = str(img.get('ratio_source', 'native'))
323 usage_count = int(img.get('usage_count', 1))
324 base = (
325 f"{i:<4} {img['width']:<7} {img['height']:<7} "
326 f"{img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} "
327 f"{img['filesize_kb']:<10.1f}KB {img['category']:<20}"
328 )
329 print(f"{base} {img['filename'][:40]}")
330
331 print("-" * REPORT_WIDTH)
332 print(f"Total: {len(results)} images\n")
333
334 # Group statistics by objective aspect-ratio ranges.
335 print("\nGroup by Aspect Ratio:")
336 print("-" * CATEGORY_WIDTH)
337
338 categories = {
339 "Ultra-wide (>2.0)": [],
340 "Wide (1.5-2.0)": [],
341 "Standard (1.2-1.5)": [],
342 "Square (0.8-1.2)": [],
343 "Portrait (<0.8)": [],
344 }
345
346 for img in results:
347 ar = img['aspect_ratio']
348 if ar > 2.0:
349 categories["Ultra-wide (>2.0)"].append(img)
350 elif ar > 1.5:
351 categories["Wide (1.5-2.0)"].append(img)
352 elif ar > 1.2:
353 categories["Standard (1.2-1.5)"].append(img)
354 elif ar > 0.8:
355 categories["Square (0.8-1.2)"].append(img)
356 else:
357 categories["Portrait (<0.8)"].append(img)
358
359 for cat, imgs in categories.items():
360 if imgs:
361 print(f"\n{cat}: {len(imgs)} images")
362 for img in imgs[:5]: # Show only the first 5
363 print(f" - {img['width']}x{img['height']} (ratio {img['aspect_ratio']:.2f}) - {img['filename'][:35]}...")
364 if len(imgs) > 5:
365 print(f" ... and {len(imgs) - 5} more")
366
367 native_only = [
368 img for img in results
369 if img.get('asset_kind') == 'office_vector'
370 and not img.get('svg_renderable', True)
371 ]
372 if native_only:
373 print("\nOffice vector assets for PPTX native passthrough:")
374 for img in native_only[:10]:
375 original = img.get('original_filename', img['filename'])
376 print(f" - {original} (display ratio {img['aspect_ratio']:.2f}; SVG preview not supported)")
377 if len(native_only) > 10:
378 print(f" ... and {len(native_only) - 10} more")
379
380
381 def _format_optional_number(value: object, digits: int = 2) -> str:
382 """Format a numeric value for CSV, leaving unavailable facts blank."""
383 if not isinstance(value, (int, float)):
384 return ""
385 return f"{float(value):.{digits}f}"
386
387
388 def save_csv(results: list[ImageAnalysis], csv_path: str | Path) -> None:
389 """Atomically save analysis results to a standards-compliant CSV file."""
390 target = Path(csv_path)
391 target.parent.mkdir(parents=True, exist_ok=True)
392 header = [
393 "No",
394 "Filename",
395 "Width",
396 "Height",
397 "AspectRatio",
398 "PixelAspectRatio",
399 "SourceDisplayRatio",
400 "RatioSource",
401 "Format",
402 "HasTransparentPixels",
403 "UsageCount",
404 "DisplayRatioVariants",
405 "AssetKind",
406 "SvgRenderable",
407 "PptxNativeSupported",
408 "SizeKB",
409 "Category",
410 ]
411
412 temporary_path: Path | None = None
413 try:
414 with tempfile.NamedTemporaryFile(
415 "w",
416 encoding="utf-8",
417 newline="",
418 prefix=f".{target.name}.",
419 suffix=".tmp",
420 dir=target.parent,
421 delete=False,
422 ) as handle:
423 temporary_path = Path(handle.name)
424 writer = csv.writer(handle, lineterminator="\n")
425 writer.writerow(header)
426 for index, image in enumerate(results, 1):
427 row = [
428 index,
429 image["filename"],
430 image["width"],
431 image["height"],
432 _format_optional_number(image["aspect_ratio"]),
433 _format_optional_number(image.get("pixel_aspect_ratio")),
434 _format_optional_number(image.get("source_display_ratio")),
435 image.get("ratio_source", "native"),
436 image.get("format", ""),
437 image.get("has_transparent_pixels", ""),
438 image.get("usage_count", 1),
439 image.get("display_ratio_variants", ""),
440 image.get("asset_kind", "bitmap"),
441 image.get("svg_renderable", True),
442 image.get("pptx_native_supported", True),
443 _format_optional_number(image["filesize_kb"], digits=1),
444 image["category"],
445 ]
446 writer.writerow(row)
447 os.replace(temporary_path, target)
448 temporary_path = None
449 finally:
450 if temporary_path is not None:
451 temporary_path.unlink(missing_ok=True)
452
453 print(f"\n[REPORT] Image analysis CSV: {target}")
454
455
456 def main(argv: list[str] | None = None) -> int:
457 """Run the CLI entry point."""
458 parser = argparse.ArgumentParser(
459 description="Analyze objective image-file facts"
460 )
461 parser.add_argument(
462 "images_dir",
463 help="Path to the images directory"
464 )
465 args = parser.parse_args(argv)
466 images_dir = Path(args.images_dir).resolve()
467
468 if not images_dir.exists():
469 print(f"Error: Directory not found: {images_dir}")
470 return 1
471
472 if not images_dir.is_dir():
473 print(f"Error: Not a directory: {images_dir}")
474 return 1
475
476 print(f"Analyzing: {images_dir}")
477
478 results, errors = _analyze_images(str(images_dir))
479
480 if results:
481 print_results(results)
482 else:
483 print("No readable supported image files found in the directory.")
484
485 analysis_dir = images_dir.parent / "analysis"
486 csv_path = analysis_dir / "image_analysis.csv"
487 save_csv(results, csv_path)
488
489 if errors:
490 print(
491 f"[ERROR] {len(errors)} supported image file(s) could not be analyzed; "
492 "the current report was still written.",
493 file=sys.stderr,
494 )
495 return 1
496 return 0
497
498
499 if __name__ == "__main__":
500 raise SystemExit(main())
501
501 lines PYTHON