#!/usr/bin/env python3 """ Image Size Analysis Tool ======================== Reports objective parameters for all images in a folder. It does not resolve a canvas, prescribe a layout, or generate Strategist recommendations. Usage: python scripts/analyze_images.py python scripts/analyze_images.py projects/xxx/images Output: - Analysis report displayed in console - Generates image_analysis.csv under the project's analysis/ directory (sibling of the images folder), alongside the PPTX intake bundle """ import argparse import csv import json import os import sys import tempfile from pathlib import Path from console_encoding import configure_utf8_stdio configure_utf8_stdio() try: from PIL import Image, ImageOps except ImportError: print("Error: PIL/Pillow not installed. Run: pip install Pillow") sys.exit(1) IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"} OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"} REPORT_WIDTH = 100 CATEGORY_WIDTH = 50 ImageAnalysis = dict[str, object] def _load_image_manifest(images_dir: str) -> dict[str, dict]: """Load optional DOCX image metadata keyed by generated filename.""" manifest_path = Path(images_dir) / "image_manifest.json" if not manifest_path.is_file(): return {} try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: print(f"[WARN] Cannot read image manifest: {exc}") return {} if not isinstance(data, list): return {} manifest: dict[str, dict] = {} for item in data: if not isinstance(item, dict): continue filename = item.get("filename") if isinstance(filename, str): manifest[filename] = item return manifest def _manifest_ratio(meta: dict | None) -> float | None: """Return a positive display ratio from manifest metadata.""" if not meta: return None value = meta.get("display_ratio") if not isinstance(value, (int, float)): return None ratio = float(value) return ratio if ratio > 0 else None def _manifest_display_size(meta: dict, ratio: float) -> tuple[int, int]: """Return a display-sized stand-in for vector media dimensions.""" width_in = meta.get("display_width_in") height_in = meta.get("display_height_in") if isinstance(width_in, (int, float)) and isinstance(height_in, (int, float)): width = max(1, int(round(float(width_in) * 96))) height = max(1, int(round(float(height_in) * 96))) return width, height width_emu = meta.get("display_width_emu") height_emu = meta.get("display_height_emu") if isinstance(width_emu, int) and isinstance(height_emu, int): width = max(1, int(round(width_emu / 914400 * 96))) height = max(1, int(round(height_emu / 914400 * 96))) return width, height width = 960 height = max(1, int(round(width / ratio))) return width, height def _manifest_usage_count(meta: dict | None) -> int: """Return how many source occurrences point to one asset.""" if not meta: return 1 usage_count = meta.get("usage_count") if isinstance(usage_count, int) and usage_count > 0: return usage_count occurrences = meta.get("occurrences") if isinstance(occurrences, list) and occurrences: return len(occurrences) return 1 def _manifest_ratio_variants(meta: dict | None) -> str: """Return a compact list of display ratio variants from manifest metadata.""" if not meta: return "" variants = meta.get("display_ratio_variants") if not isinstance(variants, list): return "" ratios = [ f"{float(value):.2f}" for value in variants if isinstance(value, (int, float)) and value > 0 ] return ";".join(ratios) def _apply_manifest_metadata(result: ImageAnalysis, meta: dict | None) -> None: """Copy optional manifest fields into an image analysis row.""" result["usage_count"] = _manifest_usage_count(meta) result["display_ratio_variants"] = _manifest_ratio_variants(meta) if not meta: return source_ext = meta.get("source_ext") original_filename = meta.get("original_filename") if isinstance(source_ext, str): result["source_ext"] = source_ext if isinstance(original_filename, str): result["original_filename"] = original_filename result["asset_kind"] = meta.get("asset_kind", "bitmap") result["svg_renderable"] = meta.get("svg_renderable", True) result["pptx_native_supported"] = meta.get("pptx_native_supported", True) def _has_transparent_pixels(image: Image.Image) -> bool: """Return whether any frame contains a pixel with alpha below 255.""" original_frame = image.tell() frame_count = int(getattr(image, "n_frames", 1)) try: for frame_index in range(frame_count): image.seek(frame_index) if "A" not in image.getbands() and "transparency" not in image.info: continue rgba = image.convert("RGBA") alpha = rgba.getchannel("A") try: extrema = alpha.getextrema() finally: alpha.close() rgba.close() if extrema and extrema[0] < 255: return True finally: image.seek(original_frame) return False def _result_from_manifest( filename: str, filepath: str, meta: dict, ) -> ImageAnalysis | None: """Build an analysis row for vector media Pillow cannot decode.""" ratio = _manifest_ratio(meta) if ratio is None: return None width, height = _manifest_display_size(meta, ratio) result: ImageAnalysis = { 'filename': filename, 'width': width, 'height': height, 'aspect_ratio': ratio, 'pixel_aspect_ratio': None, 'source_display_ratio': ratio, 'ratio_source': 'manifest', 'format': Path(filename).suffix.lstrip('.').upper(), 'has_transparent_pixels': None, 'category': classify_ratio(ratio), 'filesize_kb': os.path.getsize(filepath) / 1024, } _apply_manifest_metadata(result, meta) suffix = Path(filename).suffix.lower() is_office_vector = suffix in OFFICE_VECTOR_EXTENSIONS result["asset_kind"] = meta.get( "asset_kind", "office_vector" if is_office_vector else "vector", ) result["svg_renderable"] = meta.get("svg_renderable", suffix == ".svg") result["pptx_native_supported"] = meta.get( "pptx_native_supported", is_office_vector or suffix == ".svg", ) return result def classify_ratio(aspect_ratio: float) -> str: """Classify an image by its objective aspect-ratio range. Ranges: >2.0 ultra-wide, 1.5-2.0 wide, 1.2-1.5 standard landscape, 0.8-1.2 near square, and <0.8 portrait. """ if aspect_ratio > 2.0: return "Ultra-wide" elif aspect_ratio > 1.5: return "Wide landscape" elif aspect_ratio > 1.2: return "Standard landscape" elif aspect_ratio > 0.8: return "Near square" else: return "Portrait" def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]: """Analyze all image files in a directory. Args: images_dir: Directory that contains image files. Returns: Sorted image analysis records and supported files that could not be read. """ results: list[ImageAnalysis] = [] errors: list[str] = [] manifest = _load_image_manifest(images_dir) for filename in sorted(os.listdir(images_dir)): filepath = os.path.join(images_dir, filename) if not os.path.isfile(filepath): continue suffix = Path(filename).suffix.lower() meta = manifest.get(filename) if suffix in IMAGE_EXTENSIONS: try: with Image.open(filepath) as img: image_format = img.format or suffix.lstrip(".").upper() has_transparent_pixels = _has_transparent_pixels(img) oriented = ImageOps.exif_transpose(img) try: width, height = oriented.size finally: if oriented is not img: oriented.close() aspect_ratio = width / height result: ImageAnalysis = { 'filename': filename, 'width': width, 'height': height, 'aspect_ratio': aspect_ratio, 'pixel_aspect_ratio': aspect_ratio, 'source_display_ratio': _manifest_ratio(meta), 'ratio_source': 'native', 'format': image_format, 'has_transparent_pixels': has_transparent_pixels, 'category': classify_ratio(aspect_ratio), 'filesize_kb': os.path.getsize(filepath) / 1024 } _apply_manifest_metadata(result, meta) results.append(result) except ( EOFError, OSError, SyntaxError, ValueError, ZeroDivisionError, Image.DecompressionBombError, ) as exc: message = f"{filename}: {exc}" errors.append(message) print(f"[WARN] Cannot read {message}") elif meta: result = _result_from_manifest(filename, filepath, meta) if result: results.append(result) else: message = f"{filename}: manifest has no valid display_ratio" errors.append(message) print(f"[WARN] Cannot analyze {message}") elif suffix in OFFICE_VECTOR_EXTENSIONS: message = f"{filename}: image_manifest.json metadata is required" errors.append(message) print(f"[WARN] Cannot analyze {message}") return results, errors def analyze_images(images_dir: str) -> list[ImageAnalysis]: """Analyze readable image files while preserving the existing public API.""" results, _ = _analyze_images(images_dir) return results def print_results(results: list[ImageAnalysis]) -> None: """Print the analysis report to stdout.""" print("\n" + "=" * REPORT_WIDTH) print("Image Size Analysis Report") print("=" * REPORT_WIDTH) print( f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} " f"{'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Filename'}" ) print("-" * REPORT_WIDTH) for i, img in enumerate(results, 1): ratio_source = str(img.get('ratio_source', 'native')) usage_count = int(img.get('usage_count', 1)) base = ( f"{i:<4} {img['width']:<7} {img['height']:<7} " f"{img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} " f"{img['filesize_kb']:<10.1f}KB {img['category']:<20}" ) print(f"{base} {img['filename'][:40]}") print("-" * REPORT_WIDTH) print(f"Total: {len(results)} images\n") # Group statistics by objective aspect-ratio ranges. print("\nGroup by Aspect Ratio:") print("-" * CATEGORY_WIDTH) categories = { "Ultra-wide (>2.0)": [], "Wide (1.5-2.0)": [], "Standard (1.2-1.5)": [], "Square (0.8-1.2)": [], "Portrait (<0.8)": [], } for img in results: ar = img['aspect_ratio'] if ar > 2.0: categories["Ultra-wide (>2.0)"].append(img) elif ar > 1.5: categories["Wide (1.5-2.0)"].append(img) elif ar > 1.2: categories["Standard (1.2-1.5)"].append(img) elif ar > 0.8: categories["Square (0.8-1.2)"].append(img) else: categories["Portrait (<0.8)"].append(img) for cat, imgs in categories.items(): if imgs: print(f"\n{cat}: {len(imgs)} images") for img in imgs[:5]: # Show only the first 5 print(f" - {img['width']}x{img['height']} (ratio {img['aspect_ratio']:.2f}) - {img['filename'][:35]}...") if len(imgs) > 5: print(f" ... and {len(imgs) - 5} more") native_only = [ img for img in results if img.get('asset_kind') == 'office_vector' and not img.get('svg_renderable', True) ] if native_only: print("\nOffice vector assets for PPTX native passthrough:") for img in native_only[:10]: original = img.get('original_filename', img['filename']) print(f" - {original} (display ratio {img['aspect_ratio']:.2f}; SVG preview not supported)") if len(native_only) > 10: print(f" ... and {len(native_only) - 10} more") def _format_optional_number(value: object, digits: int = 2) -> str: """Format a numeric value for CSV, leaving unavailable facts blank.""" if not isinstance(value, (int, float)): return "" return f"{float(value):.{digits}f}" def save_csv(results: list[ImageAnalysis], csv_path: str | Path) -> None: """Atomically save analysis results to a standards-compliant CSV file.""" target = Path(csv_path) target.parent.mkdir(parents=True, exist_ok=True) header = [ "No", "Filename", "Width", "Height", "AspectRatio", "PixelAspectRatio", "SourceDisplayRatio", "RatioSource", "Format", "HasTransparentPixels", "UsageCount", "DisplayRatioVariants", "AssetKind", "SvgRenderable", "PptxNativeSupported", "SizeKB", "Category", ] temporary_path: Path | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", newline="", prefix=f".{target.name}.", suffix=".tmp", dir=target.parent, delete=False, ) as handle: temporary_path = Path(handle.name) writer = csv.writer(handle, lineterminator="\n") writer.writerow(header) for index, image in enumerate(results, 1): row = [ index, image["filename"], image["width"], image["height"], _format_optional_number(image["aspect_ratio"]), _format_optional_number(image.get("pixel_aspect_ratio")), _format_optional_number(image.get("source_display_ratio")), image.get("ratio_source", "native"), image.get("format", ""), image.get("has_transparent_pixels", ""), image.get("usage_count", 1), image.get("display_ratio_variants", ""), image.get("asset_kind", "bitmap"), image.get("svg_renderable", True), image.get("pptx_native_supported", True), _format_optional_number(image["filesize_kb"], digits=1), image["category"], ] writer.writerow(row) os.replace(temporary_path, target) temporary_path = None finally: if temporary_path is not None: temporary_path.unlink(missing_ok=True) print(f"\n[REPORT] Image analysis CSV: {target}") def main(argv: list[str] | None = None) -> int: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description="Analyze objective image-file facts" ) parser.add_argument( "images_dir", help="Path to the images directory" ) args = parser.parse_args(argv) images_dir = Path(args.images_dir).resolve() if not images_dir.exists(): print(f"Error: Directory not found: {images_dir}") return 1 if not images_dir.is_dir(): print(f"Error: Not a directory: {images_dir}") return 1 print(f"Analyzing: {images_dir}") results, errors = _analyze_images(str(images_dir)) if results: print_results(results) else: print("No readable supported image files found in the directory.") analysis_dir = images_dir.parent / "analysis" csv_path = analysis_dir / "image_analysis.csv" save_csv(results, csv_path) if errors: print( f"[ERROR] {len(errors)} supported image file(s) could not be analyzed; " "the current report was still written.", file=sys.stderr, ) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())