#!/usr/bin/env python3 """Slice and edge-clean the unified DEV-2 compact Recruit unit sheet.""" from __future__ import annotations import hashlib import json from pathlib import Path import subprocess from PIL import Image, ImageDraw from process_dev2_character_stickers import ( despill_key, qa, remove_connected_background, ) ROOT = Path(__file__).resolve().parents[1] GENERATION = ROOT / "docs/prototype/generation/dev2_compact_recruit_units_02" PROCESSED = GENERATION / "processed" RUNTIME = ROOT / "runtime/assets/entry/recruit_stickers" ROWS = 3 COLS = 4 INSET = 1.0 GRID_BORDER = 6 RUNTIME_SIZE = 512 PROFESSION_IDS = [f"PF-B{index:02d}" for index in range(1, 13)] SHEET_PATH = GENERATION / "raw/sheet_all_repair.png" REPORT_PATH = GENERATION / "reports/sheet_all_repair.json" 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 cell_crop(sheet: Image.Image, row: int, column: int) -> Image.Image: width, height = sheet.size left = round(column * width / COLS) right = round((column + 1) * width / COLS) top = round(row * height / ROWS) bottom = round((row + 1) * height / ROWS) 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 crop = 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") pixels = crop.load() for y in range(crop.height): for x in range(crop.width): if x < GRID_BORDER or y < GRID_BORDER or x >= crop.width - GRID_BORDER or y >= crop.height - GRID_BORDER: pixels[x, y] = (255, 0, 255, 255) return crop def remove_residual_key_regions(image: Image.Image) -> tuple[Image.Image, int]: """Clear generated magenta pockets enclosed by dark character contours.""" result = image.copy() pixels = result.load() removed = 0 for y in range(result.height): for x in range(result.width): red, green, blue, alpha = pixels[x, y] if ( alpha > 0 and red >= 150 and blue >= 115 and min(red, blue) - green >= 15 and abs(red - blue) <= 115 ): pixels[x, y] = (0, 0, 0, 0) removed += 1 return result, removed def write_contact_sheet(runtime_paths: list[Path]) -> None: cell_size = 256 margin = 16 label_height = 28 width = COLS * cell_size + (COLS + 1) * margin height = ROWS * (cell_size + label_height) + (ROWS + 1) * margin contact = Image.new("RGBA", (width, height), (24, 30, 39, 255)) draw = ImageDraw.Draw(contact) checker = Image.new("RGBA", (cell_size, cell_size), (238, 238, 238, 255)) checker_draw = ImageDraw.Draw(checker) checker_size = 16 for y in range(0, cell_size, checker_size): for x in range(0, cell_size, checker_size): if (x // checker_size + y // checker_size) % 2: checker_draw.rectangle( (x, y, x + checker_size - 1, y + checker_size - 1), fill=(207, 207, 207, 255), ) for index, runtime_path in enumerate(runtime_paths): row = index // COLS column = index % COLS x = margin + column * (cell_size + margin) y = margin + row * (cell_size + label_height + margin) contact.alpha_composite(checker, (x, y)) sprite = Image.open(runtime_path).convert("RGBA").resize( (cell_size, cell_size), Image.Resampling.LANCZOS ) contact.alpha_composite(sprite, (x, y)) draw.text((x, y + cell_size + 6), PROFESSION_IDS[index], fill=(235, 239, 245, 255)) output_path = GENERATION / "outputs/contact-sheet.png" output_path.parent.mkdir(parents=True, exist_ok=True) contact.convert("RGB").save(output_path, "PNG") def main() -> None: PROCESSED.mkdir(parents=True, exist_ok=True) RUNTIME.mkdir(parents=True, exist_ok=True) sheet_path = SHEET_PATH sheet = Image.open(sheet_path).convert("RGBA") records = [] for index, profession_id in enumerate(PROFESSION_IDS): row = index // COLS column = index % COLS keyed = remove_connected_background(cell_crop(sheet, row, column)) keyed, residual_key_pixels = remove_residual_key_regions(keyed) processed, despill_pixels = despill_key(keyed) 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-')}-COMPACT-UNIT", "profession_id": profession_id, "source_type": "generated_4x3_grid_cell", "source_sheet": str(sheet_path.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, "residual_key_pixels_removed": residual_key_pixels, }, "imported": False, "integrated": False, "runtime_seen": False, "agent_visual_reviewed": False, "human_reviewed": False, "device_verified": False, "release_accepted": False, }) provider_report = json.loads(REPORT_PATH.read_text(encoding="utf-8")) family_reports = [ json.loads(path.read_text(encoding="utf-8")) for path in sorted((GENERATION / "reports").glob("sheet_all*.json")) ] prior_reports = [ json.loads(path.read_text(encoding="utf-8")) for path in sorted((ROOT / "docs/prototype/generation/dev2_character_stickers_01/reports").glob("sheet_*.json")) ] source_revision = subprocess.check_output( ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip() task_generated_calls = len(prior_reports) + len(family_reports) task_generated_cost_usd = sum( float(report.get("cost", 0.0)) for report in prior_reports + family_reports ) write_contact_sheet([ROOT / record["runtime_path"] for record in records]) manifest = { "kind": "aetherbound-dev2-compact-recruit-units-02", "runtime_role": "Unified base-Profession units for Initial Company and Guild", "source_revision": source_revision, "provider": "API Mart", "model": "gpt-image-2", "family_generated_calls": len(family_reports), "family_generated_cost_usd": sum( float(report.get("cost", 0.0)) for report in family_reports ), "task_generated_calls": task_generated_calls, "task_generated_cost_usd": task_generated_cost_usd, "remaining_task_calls": 0, "processing": { "command": "python3 tools/process_dev2_compact_recruit_units.py", "grid": "4x3", "inset": INSET, "grid_border_clear": GRID_BORDER, "background_key": "#FF00FF", "background_method": "exact-key seeded connected flood fill plus boundary despill", "runtime_size": [RUNTIME_SIZE, RUNTIME_SIZE], }, "provider_report": provider_report, "generation_attempts": [ { "prompt": "docs/prototype/generation/dev2_compact_recruit_units_02/prompts/sheet_all.txt", "report": "docs/prototype/generation/dev2_compact_recruit_units_02/reports/sheet_all.json", "source_sheet": "docs/prototype/generation/dev2_compact_recruit_units_02/raw/sheet_all.png", "decision": "rejected", "rejection_reason": "Internally consistent, but tall 6.5-7-head anatomy and ornate key-art density do not match the accepted compact ordinary field-unit language.", }, { "prompt": "docs/prototype/generation/dev2_compact_recruit_units_02/prompts/sheet_all_repair.txt", "report": "docs/prototype/generation/dev2_compact_recruit_units_02/reports/sheet_all_repair.json", "source_sheet": "docs/prototype/generation/dev2_compact_recruit_units_02/raw/sheet_all_repair.png", "decision": "accepted_for_runtime_validation", }, ], "records": records, "supersedes": "docs/prototype/generation/dev2_character_stickers_01/asset-manifest.json", "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_COMPACT_UNIT_PROCESS_OK generated=12 grid=4x3 size=512 " f"task_calls={manifest['task_generated_calls']} " f"task_cost_usd={manifest['task_generated_cost_usd']:.3f} border_alpha=0" ) if __name__ == "__main__": main()