#!/usr/bin/env python3 """Slice and edge-clean the DEV-2 Recruit sticker sheets.""" from __future__ import annotations from collections import deque import hashlib import json from pathlib import Path import subprocess from PIL import Image ROOT = Path(__file__).resolve().parents[1] GENERATION = ROOT / "docs/prototype/generation/dev2_character_stickers_01" PROCESSED = GENERATION / "processed" RUNTIME = ROOT / "runtime/assets/entry/recruit_stickers" KEY = (255, 0, 255) BACKGROUND_THRESHOLD = 132 BACKGROUND_SEED_THRESHOLD = 12 SPILL_STRENGTH = 20 SPILL_COLOR_DISTANCE = 72 SPILL_SEARCH_RADIUS = 8 INSET = 0.98 RUNTIME_SIZE = 512 GENERATED = [ ("PF-B02", "sheet_a.png", 0, 0), ("PF-B03", "sheet_a.png", 0, 1), ("PF-B06", "sheet_a.png", 1, 0), ("PF-B08", "sheet_a.png", 1, 1), ("PF-B09", "sheet_b.png", 0, 0), ("PF-B10", "sheet_b.png", 0, 1), ("PF-B11", "sheet_b.png", 1, 0), ("PF-B12", "sheet_b.png", 1, 1), ] REUSED = { "PF-B01": "runtime/assets/actors/hearthguard.png", "PF-B04": "runtime/assets/actors/wayrunner.png", "PF-B05": "runtime/assets/actors/farstring.png", "PF-B07": "runtime/assets/actors/kindhand.png", } def sha256(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 key_distance(rgb: tuple[int, int, int]) -> int: return max(abs(rgb[0] - KEY[0]), abs(rgb[1] - KEY[1]), abs(rgb[2] - KEY[2])) def cell_crop(sheet: Image.Image, row: int, column: int) -> Image.Image: width, height = sheet.size left = round(column * width / 2) right = round((column + 1) * width / 2) top = round(row * height / 2) bottom = round((row + 1) * height / 2) cell_width = right - left cell_height = bottom - top crop_width = round(cell_width * INSET) crop_height = round(cell_height * INSET) center_x = (left + right) / 2 center_y = (top + bottom) / 2 return sheet.crop(( round(center_x - crop_width / 2), round(center_y - crop_height / 2), round(center_x + crop_width / 2), round(center_y + crop_height / 2), )).convert("RGBA") def remove_connected_background(image: Image.Image) -> Image.Image: width, height = image.size pixels = image.load() background = bytearray(width * height) queue: deque[tuple[int, int]] = deque() def consider(x: int, y: int) -> None: index = y * width + x if background[index]: return if key_distance(pixels[x, y][:3]) > BACKGROUND_THRESHOLD: return background[index] = 1 queue.append((x, y)) # The generated figures can enclose keyed background between limbs and # props. Seed every nearly exact key pixel, then flood only through colors # close to the key so dark violet costume details remain protected. for y in range(height): for x in range(width): if key_distance(pixels[x, y][:3]) <= BACKGROUND_SEED_THRESHOLD: background[y * width + x] = 1 queue.append((x, y)) while queue: x, y = queue.popleft() if x > 0: consider(x - 1, y) if x + 1 < width: consider(x + 1, y) if y > 0: consider(x, y - 1) if y + 1 < height: consider(x, y + 1) result = image.copy() output = result.load() for y in range(height): for x in range(width): if background[y * width + x]: output[x, y] = (0, 0, 0, 0) return result def despill_key(image: Image.Image) -> tuple[Image.Image, int]: width, height = image.size source = image.load() result = image.copy() output = result.load() changed = 0 def is_spill(pixel: tuple[int, int, int, int]) -> bool: red, green, blue, alpha = pixel return ( alpha > 0 and min(red, blue) - green >= SPILL_STRENGTH and abs(red - blue) <= SPILL_COLOR_DISTANCE and max(red, blue) >= 24 ) def touches_transparency(x: int, y: int) -> bool: for offset_y in range(-3, 4): for offset_x in range(-3, 4): near_x = x + offset_x near_y = y + offset_y if 0 <= near_x < width and 0 <= near_y < height and source[near_x, near_y][3] == 0: return True return False for y in range(height): for x in range(width): pixel = source[x, y] if not is_spill(pixel) or not touches_transparency(x, y): continue replacement = None for radius in range(1, SPILL_SEARCH_RADIUS + 1): for near_y in range(max(0, y - radius), min(height, y + radius + 1)): for near_x in range(max(0, x - radius), min(width, x + radius + 1)): candidate = source[near_x, near_y] if candidate[3] > 0 and not is_spill(candidate): replacement = candidate break if replacement is not None: break if replacement is not None: break if replacement is None: neutral_green = max(pixel[1], min(pixel[0], pixel[2]) - 4) output[x, y] = (pixel[0], neutral_green, pixel[2], pixel[3]) else: output[x, y] = (replacement[0], replacement[1], replacement[2], pixel[3]) changed += 1 return result, changed def qa(image: Image.Image) -> dict[str, object]: alpha = image.getchannel("A") bounds = alpha.getbbox() if bounds is None: raise RuntimeError("processed sticker is blank") width, height = image.size border_alpha = 0 for x in range(width): border_alpha = max(border_alpha, alpha.getpixel((x, 0)), alpha.getpixel((x, height - 1))) for y in range(height): border_alpha = max(border_alpha, alpha.getpixel((0, y)), alpha.getpixel((width - 1, y))) if border_alpha != 0: raise RuntimeError(f"visible pixels touch the canvas border: alpha={border_alpha}") return { "dimensions": [width, height], "content_bounds": list(bounds), "minimum_padding": min(bounds[0], bounds[1], width - bounds[2], height - bounds[3]), "border_alpha_max": border_alpha, "alpha_extrema": list(alpha.getextrema()), } def main() -> None: PROCESSED.mkdir(parents=True, exist_ok=True) RUNTIME.mkdir(parents=True, exist_ok=True) sheets: dict[str, Image.Image] = {} records = [] for profession_id, sheet_name, row, column in GENERATED: if sheet_name not in sheets: sheets[sheet_name] = Image.open(GENERATION / "raw" / sheet_name).convert("RGBA") processed, despill_pixels = despill_key( remove_connected_background(cell_crop(sheets[sheet_name], row, column)) ) slug = profession_id.lower().replace("-", "_") processed_path = PROCESSED / f"{slug}.png" runtime_path = RUNTIME / f"{slug}-512.png" processed.save(processed_path, "PNG") runtime = processed.resize((RUNTIME_SIZE, RUNTIME_SIZE), Image.Resampling.LANCZOS) runtime.save(runtime_path, "PNG") records.append({ "id": f"ART-PRO-{profession_id.removeprefix('PF-')}-STICKER", "profession_id": profession_id, "source_type": "generated_2x2_grid_cell", "source_sheet": str((GENERATION / "raw" / sheet_name).relative_to(ROOT)), "cell": [row, column], "processed_path": str(processed_path.relative_to(ROOT)), "runtime_path": str(runtime_path.relative_to(ROOT)), "processed_sha256": sha256(processed_path), "runtime_sha256": sha256(runtime_path), "qa": {**qa(runtime), "despill_pixels": despill_pixels}, "imported": False, "integrated": False, "runtime_seen": False, "agent_visual_reviewed": False, "human_reviewed": False, "device_verified": False, "release_accepted": False, }) reports = [] for report_path in sorted((GENERATION / "reports").glob("sheet_*.json")): reports.append(json.loads(report_path.read_text(encoding="utf-8"))) source_revision = subprocess.check_output( ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip() manifest = { "kind": "aetherbound-dev2-character-stickers-01", "runtime_role": "Initial Company person-bearing controls", "source_revision": source_revision, "provider": "API Mart", "model": "gpt-image-2", "generated_calls": len(reports), "generated_cost_usd": sum(float(report.get("cost", 0.0)) for report in reports), "processing": { "command": "python3 tools/process_dev2_character_stickers.py", "grid": "2x2", "inset": INSET, "background_key": "#FF00FF", "background_threshold": BACKGROUND_THRESHOLD, "background_seed_threshold": BACKGROUND_SEED_THRESHOLD, "spill_strength": SPILL_STRENGTH, "spill_color_distance": SPILL_COLOR_DISTANCE, "spill_search_radius": SPILL_SEARCH_RADIUS, "background_method": "edge-connected flood fill", "runtime_size": [RUNTIME_SIZE, RUNTIME_SIZE], }, "provider_reports": reports, "reused": [ { "profession_id": profession_id, "source_path": source_path, "sha256": sha256(ROOT / source_path), } for profession_id, source_path in REUSED.items() ], "records": records, "license_status": "project-authored generative output", } manifest_path = GENERATION / "asset-manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") print( "ABG_DEV2_STICKER_PROCESS_OK generated=8 reused=4 size=512 " f"calls={manifest['generated_calls']} cost_usd={manifest['generated_cost_usd']:.3f} " "border_alpha=0" ) if __name__ == "__main__": main()