#!/usr/bin/env python3 """Extract fixed-grid identities, normalize retained components, and manifest them.""" from __future__ import annotations import argparse from collections import deque import hashlib import json from pathlib import Path from PIL import Image from process_green_grid_assets import remove_green def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("image", type=Path) parser.add_argument("--rows", type=int, required=True) parser.add_argument("--cols", type=int, required=True) parser.add_argument("--names", required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--manifest", type=Path, required=True) parser.add_argument("--min-component-area", type=int, default=200) parser.add_argument("--content-fraction", type=float, default=0.70) return parser.parse_args() def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def retained_components( alpha: Image.Image, min_area: int ) -> tuple[Image.Image, list[int], tuple[int, int, int, int] | None]: width, height = alpha.size values = list(alpha.getdata()) visited = bytearray(width * height) retained = bytearray(width * height) areas: list[int] = [] combined: tuple[int, int, int, int] | None = None for start, value in enumerate(values): if value == 0 or visited[start]: continue visited[start] = 1 queue: deque[int] = deque([start]) pixels: list[int] = [] min_x = max_x = start % width min_y = max_y = start // width while queue: current = queue.popleft() pixels.append(current) x, y = current % width, current // width min_x, max_x = min(min_x, x), max(max_x, x) min_y, max_y = min(min_y, y), max(max_y, y) for neighbor_y in range(max(0, y - 1), min(height, y + 2)): row_start = neighbor_y * width for neighbor_x in range(max(0, x - 1), min(width, x + 2)): neighbor = row_start + neighbor_x if values[neighbor] and not visited[neighbor]: visited[neighbor] = 1 queue.append(neighbor) if len(pixels) < min_area: continue areas.append(len(pixels)) for pixel in pixels: retained[pixel] = values[pixel] bound = (min_x, min_y, max_x + 1, max_y + 1) if combined is None: combined = bound else: combined = ( min(combined[0], bound[0]), min(combined[1], bound[1]), max(combined[2], bound[2]), max(combined[3], bound[3]), ) retained_alpha = Image.new("L", alpha.size) retained_alpha.putdata(retained) return retained_alpha, areas, combined def normalized_asset(image: Image.Image, pixels: int, fraction: float) -> Image.Image: target = max(1, round(pixels * fraction)) scale = min(target / image.width, target / image.height) size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) content = image.convert("RGBa").resize(size, Image.Resampling.LANCZOS).convert("RGBA") cleaned: list[tuple[int, int, int, int]] = [] for red, green, blue, alpha in content.getdata(): if green - red > 45 and green - blue > 45: cleaned.append((0, 0, 0, 0)) else: cleaned.append((red, green, blue, alpha)) content.putdata(cleaned) canvas = Image.new("RGBA", (pixels, pixels), (0, 0, 0, 0)) canvas.alpha_composite(content, ((pixels - size[0]) // 2, (pixels - size[1]) // 2)) return canvas def main() -> None: args = parse_args() names = [name.strip() for name in args.names.split(",") if name.strip()] if len(names) != args.rows * args.cols: raise SystemExit("name count must equal rows * cols") if args.min_component_area < 1 or not 0 < args.content_fraction < 1: raise SystemExit("invalid component area or content fraction") source = Image.open(args.image).convert("RGBA") width, height = source.size args.output_dir.mkdir(parents=True, exist_ok=True) records: list[dict[str, object]] = [] for index, name in enumerate(names): row, col = divmod(index, args.cols) cell = ( round(col * width / args.cols), round(row * height / args.rows), round((col + 1) * width / args.cols), round((row + 1) * height / args.rows), ) keyed = remove_green(source.crop(cell)) retained_alpha, areas, bound = retained_components( keyed.getchannel("A"), args.min_component_area ) if not areas or len(areas) > 8 or bound is None: raise SystemExit(f"{name}: retained component count {len(areas)}") if bound[0] == 0 or bound[1] == 0 or bound[2] == keyed.width or bound[3] == keyed.height: raise SystemExit(f"{name}: retained content touches identity window {bound}") keyed.putalpha(retained_alpha) crop = keyed.crop(bound) outputs: dict[str, dict[str, object]] = {} for suffix, pixels in (("@1x", 128), ("@2x", 256)): path = args.output_dir / f"{name}{suffix}.png" normalized_asset(crop, pixels, args.content_fraction).save(path) outputs[suffix] = {"path": path.name, "sha256": sha256(path)} records.append( { "id": name, "cell": list(cell), "content_bound_in_cell": list(bound), "component_areas": areas, "outputs": outputs, } ) manifest = { "source": args.image.as_posix(), "source_sha256": sha256(args.image), "grid": [args.rows, args.cols], "min_component_area": args.min_component_area, "content_fraction": args.content_fraction, "records": records, } args.manifest.parent.mkdir(parents=True, exist_ok=True) args.manifest.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") print(f"COMPONENT_GRID_PROCESS_OK assets={len(names) * 2} manifest={args.manifest}") if __name__ == "__main__": main()