168 lines
6.4 KiB
Python
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()
|