#!/usr/bin/env python3 """Generate and verify display-lossless PNG assets for the RGB565 LCD.""" from __future__ import annotations import argparse import hashlib import json import shutil import sys import tempfile from pathlib import Path from typing import Any, Iterable PROJECT_ROOT = Path(__file__).resolve().parent.parent SOURCE_DIR = PROJECT_ROOT / "components" / "platform" / "assets" / "lcd_source" OPTIMIZED_DIR = PROJECT_ROOT / "components" / "platform" / "assets" / "lcd_optimized" MANIFEST_NAME = "manifest.json" MANIFEST_VERSION = 1 class AssetError(RuntimeError): pass def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: for chunk in iter(lambda: source.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def png_files(directory: Path) -> list[Path]: if not directory.is_dir(): raise AssetError(f"Asset directory does not exist: {directory}") return sorted(path for path in directory.iterdir() if path.is_file() and path.suffix.lower() == ".png") def load_pillow() -> tuple[Any, str]: try: import PIL from PIL import Image except ImportError as exc: raise AssetError( "Pillow is required for this command. Install it with:\n" f" {sys.executable} -m pip install -r {PROJECT_ROOT / 'tools' / 'requirements-assets.txt'}" ) from exc return Image, PIL.__version__ def rgb565_value(pixel: tuple[int, int, int]) -> int: red, green, blue = pixel return ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3) def expand_rgb565(pixel: tuple[int, int, int]) -> tuple[int, int, int]: red, green, blue = pixel red5 = red >> 3 green6 = green >> 2 blue5 = blue >> 3 return ( (red5 << 3) | (red5 >> 2), (green6 << 2) | (green6 >> 4), (blue5 << 3) | (blue5 >> 2), ) def save_indexed_png(image_module: Any, size: tuple[int, int], pixels: list[tuple[int, int, int]], output: Path) -> int: palette_colors = sorted(set(pixels)) if len(palette_colors) > 256: raise AssetError("Indexed PNG requested with more than 256 colors") palette_index = {color: index for index, color in enumerate(palette_colors)} indices = bytes(palette_index[pixel] for pixel in pixels) indexed = image_module.frombytes("P", size, indices) flat_palette = [channel for color in palette_colors for channel in color] flat_palette.extend([0] * (768 - len(flat_palette))) indexed.putpalette(flat_palette) indexed.save(output, format="PNG", optimize=True, compress_level=9) return len(palette_colors) def assert_display_equivalent(source_rgb: Any, optimized_path: Path, image_module: Any) -> None: with image_module.open(optimized_path) as optimized_image: optimized_rgb = optimized_image.convert("RGB") if optimized_rgb.size != source_rgb.size: raise AssetError( f"Image dimensions changed for {optimized_path.name}: " f"{source_rgb.size} -> {optimized_rgb.size}" ) for index, (source_pixel, optimized_pixel) in enumerate( zip(source_rgb.get_flattened_data(), optimized_rgb.get_flattened_data()) ): if rgb565_value(source_pixel) != rgb565_value(optimized_pixel): x = index % source_rgb.width y = index // source_rgb.width raise AssetError( f"RGB565 mismatch in {optimized_path.name} at ({x}, {y}): " f"{source_pixel} -> {optimized_pixel}" ) def optimize_one(source_path: Path, output_path: Path, image_module: Any) -> dict[str, Any]: with image_module.open(source_path) as source_image: source_mode = source_image.mode rgba = source_image.convert("RGBA") alpha_min, alpha_max = rgba.getchannel("A").getextrema() if (alpha_min, alpha_max) != (255, 255): raise AssetError( f"Transparency is not supported by the RGB565 optimizer: {source_path.name} " f"has alpha range ({alpha_min}, {alpha_max})" ) source_rgb = rgba.convert("RGB") source_pixels = list(source_rgb.get_flattened_data()) rgb565_pixels = [expand_rgb565(pixel) for pixel in source_pixels] color_count = len(set(rgb565_pixels)) if color_count <= 256: output_mode = "indexed" save_indexed_png(image_module, source_rgb.size, rgb565_pixels, output_path) else: output_mode = "rgb" optimized_rgb = image_module.new("RGB", source_rgb.size) optimized_rgb.putdata(rgb565_pixels) optimized_rgb.save(output_path, format="PNG", optimize=True, compress_level=9) assert_display_equivalent(source_rgb, output_path, image_module) return { "source_sha256": sha256_file(source_path), "optimized_sha256": sha256_file(output_path), "width": source_rgb.width, "height": source_rgb.height, "source_mode": source_mode, "alpha_range": [alpha_min, alpha_max], "rgb565_colors": color_count, "output_mode": output_mode, "source_bytes": source_path.stat().st_size, "optimized_bytes": output_path.stat().st_size, } def generate() -> None: image_module, pillow_version = load_pillow() sources = png_files(SOURCE_DIR) if not sources: raise AssetError(f"No PNG assets found in {SOURCE_DIR}") OPTIMIZED_DIR.parent.mkdir(parents=True, exist_ok=True) temporary_dir = Path(tempfile.mkdtemp(prefix="lcd_optimized.", dir=OPTIMIZED_DIR.parent)) try: assets: dict[str, Any] = {} for source_path in sources: output_path = temporary_dir / source_path.name assets[source_path.name] = optimize_one(source_path, output_path, image_module) manifest = { "version": MANIFEST_VERSION, "generator": "tools/optimize_lcd_assets.py", "pillow_version": pillow_version, "assets": assets, "totals": { "asset_count": len(assets), "source_bytes": sum(item["source_bytes"] for item in assets.values()), "optimized_bytes": sum(item["optimized_bytes"] for item in assets.values()), }, } manifest_path = temporary_dir / MANIFEST_NAME manifest_path.write_text( json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) if OPTIMIZED_DIR.exists(): shutil.rmtree(OPTIMIZED_DIR) temporary_dir.replace(OPTIMIZED_DIR) except Exception: shutil.rmtree(temporary_dir, ignore_errors=True) raise reduction = 100.0 * (1.0 - manifest["totals"]["optimized_bytes"] / manifest["totals"]["source_bytes"]) print( f"Generated {len(assets)} LCD assets: " f"{manifest['totals']['source_bytes']} -> {manifest['totals']['optimized_bytes']} bytes " f"({reduction:.1f}% reduction)" ) def load_manifest() -> dict[str, Any]: manifest_path = OPTIMIZED_DIR / MANIFEST_NAME if not manifest_path.is_file(): raise AssetError(f"Asset manifest does not exist: {manifest_path}") try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise AssetError(f"Cannot read asset manifest: {manifest_path}: {exc}") from exc if manifest.get("version") != MANIFEST_VERSION or not isinstance(manifest.get("assets"), dict): raise AssetError(f"Unsupported or invalid asset manifest: {manifest_path}") return manifest def check_current() -> dict[str, Any]: manifest = load_manifest() assets = manifest["assets"] source_names = {path.name for path in png_files(SOURCE_DIR)} optimized_names = {path.name for path in png_files(OPTIMIZED_DIR)} manifest_names = set(assets) errors: list[str] = [] if source_names != manifest_names: errors.append( f"Source/manifest file set differs: missing={sorted(source_names - manifest_names)}, " f"stale={sorted(manifest_names - source_names)}" ) if optimized_names != manifest_names: errors.append( f"Optimized/manifest file set differs: missing={sorted(manifest_names - optimized_names)}, " f"extra={sorted(optimized_names - manifest_names)}" ) for name in sorted(source_names & manifest_names): source_path = SOURCE_DIR / name if sha256_file(source_path) != assets[name].get("source_sha256"): errors.append(f"Source asset changed after optimization: {name}") for name in sorted(optimized_names & manifest_names): optimized_path = OPTIMIZED_DIR / name if sha256_file(optimized_path) != assets[name].get("optimized_sha256"): errors.append(f"Optimized asset changed after generation: {name}") if errors: detail = "\n - ".join(errors) raise AssetError( "LCD optimized assets are out of date:\n" f" - {detail}\n" "Regenerate them with:\n" f" {sys.executable} {Path(__file__).resolve()} generate" ) print(f"LCD assets are current: {len(manifest_names)} files") return manifest def verify() -> None: manifest = check_current() image_module, _ = load_pillow() for name in sorted(manifest["assets"]): with image_module.open(SOURCE_DIR / name) as source_image: source_rgb = source_image.convert("RGB") assert_display_equivalent(source_rgb, OPTIMIZED_DIR / name, image_module) print(f"RGB565 display equivalence verified: {len(manifest['assets'])} files") def check_env() -> None: _, pillow_version = load_pillow() print(f"Asset environment is ready: Python {sys.version.split()[0]}, Pillow {pillow_version}") def parse_args(arguments: Iterable[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "command", choices=("check-env", "generate", "check-current", "verify"), help="operation to perform", ) return parser.parse_args(arguments) def main(arguments: Iterable[str] | None = None) -> int: args = parse_args(sys.argv[1:] if arguments is None else arguments) try: { "check-env": check_env, "generate": generate, "check-current": check_current, "verify": verify, }[args.command]() except AssetError as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())