283 lines
10 KiB
Python
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()
|