Files
aetherbound-guild/tools/process_dev2_character_stickers.py
T

283 lines
10 KiB
Python

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