Files
aetherbound-guild/tools/prepare_rsk002_clean_master.py
T

138 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Remove dynamic actors, fact glyphs and command-state art from RSK-002."""
from __future__ import annotations
import argparse
from pathlib import Path
import cv2
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = ROOT / "docs/design-authority/pages/RSK-002-risk-commitment/approved/v001/master.png"
DEFAULT_OUTPUT = ROOT / "runtime/assets/entry/rsk_002_risk_commitment/risk-commitment-static-master.png"
def rect(mask: np.ndarray, x0: float, y0: float, x1: float, y1: float) -> None:
height, width = mask.shape
cv2.rectangle(
mask,
(round(x0 * width), round(y0 * height)),
(round(x1 * width), round(y1 * height)),
255,
thickness=-1,
)
def grabcut_foreground(image: np.ndarray, mask: np.ndarray, bounds: tuple[float, float, float, float]) -> None:
"""Add a segmented actor silhouette without erasing the surrounding world."""
height, width = mask.shape
x0, y0, x1, y1 = [round(value * width) for value in (bounds[0], bounds[1], bounds[2], bounds[3])]
top, bottom = round(bounds[1] * height), round(bounds[3] * height)
left, right = round(bounds[0] * width), round(bounds[2] * width)
crop = image[top:bottom, left:right]
if crop.size == 0:
return
gc = np.full(crop.shape[:2], cv2.GC_PR_BGD, dtype=np.uint8)
margin_x = max(2, round(crop.shape[1] * 0.12))
margin_y = max(2, round(crop.shape[0] * 0.08))
gc[:margin_y, :] = cv2.GC_BGD
gc[-margin_y:, :] = cv2.GC_BGD
gc[:, :margin_x] = cv2.GC_BGD
gc[:, -margin_x:] = cv2.GC_BGD
# The central body/face corridor is a conservative foreground seed; the
# surrounding margin lets GrabCut preserve sky and stone between units.
seed_x0, seed_x1 = round(crop.shape[1] * 0.28), round(crop.shape[1] * 0.72)
seed_y0, seed_y1 = round(crop.shape[0] * 0.14), round(crop.shape[0] * 0.88)
gc[seed_y0:seed_y1, seed_x0:seed_x1] = cv2.GC_PR_FGD
bgd = np.zeros((1, 65), dtype=np.float64)
fgd = np.zeros((1, 65), dtype=np.float64)
cv2.grabCut(crop, gc, None, bgd, fgd, 6, cv2.GC_INIT_WITH_MASK)
foreground = (gc == cv2.GC_FGD) | (gc == cv2.GC_PR_FGD)
target = mask[top:bottom, left:right]
target[foreground] = 255
def build(source: Path, output: Path) -> None:
image = cv2.imread(str(source), cv2.IMREAD_COLOR)
if image is None:
raise SystemExit(f"could not read {source}")
if image.shape[1] != 2048 or image.shape[0] != 1152:
raise SystemExit(f"expected 2048x1152, got {image.shape[1]}x{image.shape[0]}")
mask = np.zeros(image.shape[:2], dtype=np.uint8)
# Actors, attached fact markers and identity bars are dynamic PTY-002
# entities. Segment each silhouette independently so the painted overlook
# and Party arc remain intact between units.
for bounds in [
(0.490, 0.255, 0.590, 0.675),
(0.585, 0.255, 0.705, 0.675),
(0.690, 0.225, 0.815, 0.685),
(0.795, 0.225, 0.940, 0.705),
]:
grabcut_foreground(image, mask, bounds)
# Small attached shield/coverage markers and identity bars sit outside
# the GrabCut silhouettes. Keep these masks narrow so the stone/sky between
# actors is not erased.
for center in [0.535, 0.635, 0.735, 0.845]:
rect(mask, center - 0.026, 0.350, center + 0.026, 0.405)
rect(mask, center - 0.026, 0.430, center + 0.028, 0.535)
rect(mask, center - 0.030, 0.575, center + 0.030, 0.625)
# The six consequence glyphs and value wells are runtime fact slots. Keep
# the outer parchment/ledger shell and separators, but remove source marks
# from each inner cell.
for left in [0.095, 0.235, 0.375, 0.515, 0.655, 0.795]:
rect(mask, left, 0.685, left + 0.055, 0.775)
# Command symbols are stateful (Cancel, acknowledgement, Begin Battle).
# Preserve the rail shell and neutralize the generated symbols; Engine
# draws the accepted semantic icons and disabled styling later.
for bounds in [
(0.070, 0.855, 0.145, 0.955),
(0.465, 0.845, 0.545, 0.955),
(0.820, 0.850, 0.915, 0.955),
]:
rect(mask, *bounds)
# Settings is a utility action, not a static state marker.
rect(mask, 0.925, 0.015, 0.990, 0.105)
# A moderate radius reconstructs stone/sky/paper texture without preserving
# glyph edges. The mask is intentionally retained for audit and later
# dynamic-layer checks.
cleaned = cv2.inpaint(image, mask, 3, cv2.INPAINT_NS)
output.parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(str(output), cleaned):
raise SystemExit(f"could not write {output}")
mask_path = output.with_name("dynamic-regions.png")
if not cv2.imwrite(str(mask_path), mask):
raise SystemExit(f"could not write {mask_path}")
report = output.with_name("static-master-report.md")
report.write_text(
"# RSK-002 Clean Static Master\n\n"
f"- Source: `{source}`\n"
f"- Output: `{output}`\n"
f"- Dynamic-region mask: `{mask_path}`\n"
"- Removed: four generated actors, attached fact/identity markers, six consequence glyph/value wells, command symbols and Settings symbol.\n"
"- Preserved: painted overlook, flags, selected-risk relic shell, Party arc, consequence-band shell, command-rail shell and parchment separators.\n"
"- Runtime policy: Recruit identity, labels, values, semantic icons, selection, confirmation and hit areas are engine-owned.\n",
encoding="utf-8",
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
build(args.source, args.output)
if __name__ == "__main__":
main()