Files
aetherbound-guild/tools/process_component_grid_assets.py

168 lines
6.4 KiB
Python

#!/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()