236 lines
9.4 KiB
Python
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()
|