Files
aetherbound-guild/tools/process_dev2_compact_recruit_units.py
T

236 lines
9.4 KiB
Python

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