| 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 |