Files
heros_web/scripts/audit-unit-sprite-sheets.py

307 lines
11 KiB
Python

from __future__ import annotations
import argparse
import json
from collections import defaultdict
from dataclasses import dataclass, asdict
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
FRAME = 313
BASE_ROWS = 4
BASE_COLS = 16
ACTION_ROWS = 4
ACTION_COLS = 36
BATTLE_TILE_PX = 50
DEFAULT_MANIFEST = {
"approvedBaseline": [
"unit-liu-bei",
"unit-guan-yu",
"unit-zhang-fei",
"unit-rebel",
"unit-rebel-archer",
"unit-rebel-cavalry",
],
"pendingApproval": ["unit-shu-infantry"],
}
@dataclass
class SheetMetrics:
key: str
exists: bool
size: tuple[int, int] | None = None
partial_alpha: int | None = None
opaque_pixels: int | None = None
border_hits: int | None = None
min_width: int | None = None
max_width: int | None = None
min_height: int | None = None
max_height: int | None = None
min_bottom: int | None = None
max_bottom: int | None = None
empty_frames: int | None = None
width_swing: int | None = None
height_swing: int | None = None
bottom_swing: int | None = None
battle_min_width_px: float | None = None
battle_max_width_px: float | None = None
battle_min_height_px: float | None = None
battle_max_height_px: float | None = None
status: str = "missing"
def measure_sheet(path: Path, key: str, rows: int, cols: int, check_motion_stability = False) -> SheetMetrics:
if not path.exists():
return SheetMetrics(key=key, exists=False)
image = Image.open(path).convert("RGBA")
alpha = np.array(image.getchannel("A"))
metrics = SheetMetrics(
key=key,
exists=True,
size=image.size,
partial_alpha=int(np.count_nonzero((alpha > 0) & (alpha < 255))),
opaque_pixels=int(np.count_nonzero(alpha == 255)),
border_hits=0,
empty_frames=0,
status="ok",
)
expected_size = (FRAME * cols, FRAME * rows)
widths: list[int] = []
heights: list[int] = []
bottoms: list[int] = []
if image.size != expected_size:
metrics.status = f"bad-size expected={expected_size}"
return metrics
for row in range(rows):
for col in range(cols):
frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME]
ys, xs = np.where(frame > 0)
if len(xs) == 0:
metrics.empty_frames = (metrics.empty_frames or 0) + 1
continue
widths.append(int(xs.max() - xs.min() + 1))
heights.append(int(ys.max() - ys.min() + 1))
bottoms.append(int(ys.max()))
if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2:
metrics.border_hits = (metrics.border_hits or 0) + 1
if widths:
metrics.min_width = min(widths)
metrics.max_width = max(widths)
metrics.min_height = min(heights)
metrics.max_height = max(heights)
metrics.min_bottom = min(bottoms)
metrics.max_bottom = max(bottoms)
metrics.width_swing = metrics.max_width - metrics.min_width
metrics.height_swing = metrics.max_height - metrics.min_height
metrics.bottom_swing = metrics.max_bottom - metrics.min_bottom
metrics.battle_min_width_px = round(metrics.min_width * BATTLE_TILE_PX / FRAME, 1)
metrics.battle_max_width_px = round(metrics.max_width * BATTLE_TILE_PX / FRAME, 1)
metrics.battle_min_height_px = round(metrics.min_height * BATTLE_TILE_PX / FRAME, 1)
metrics.battle_max_height_px = round(metrics.max_height * BATTLE_TILE_PX / FRAME, 1)
issues = []
if metrics.partial_alpha:
issues.append(f"partial-alpha={metrics.partial_alpha}")
if metrics.border_hits:
issues.append(f"border-hits={metrics.border_hits}")
if metrics.empty_frames:
issues.append(f"empty-frames={metrics.empty_frames}")
if (
check_motion_stability
and metrics.max_height is not None
and metrics.min_height is not None
and metrics.max_height - metrics.min_height > 48
):
issues.append(f"height-swing={metrics.max_height - metrics.min_height}")
if (
check_motion_stability
and metrics.max_bottom is not None
and metrics.min_bottom is not None
and metrics.max_bottom - metrics.min_bottom > 36
):
issues.append(f"bottom-swing={metrics.max_bottom - metrics.min_bottom}")
metrics.status = "ok" if not issues else "; ".join(issues)
return metrics
def classify(stem: str) -> str:
if stem in {"unit-liu-bei", "unit-guan-yu", "unit-zhang-fei"}:
return "batch1-approved-core"
if stem in {"unit-rebel", "unit-rebel-archer", "unit-rebel-cavalry"}:
return "batch1-approved-rebel"
if stem.startswith("unit-shu-"):
return "batch2-shu"
if stem.startswith("unit-wei-"):
return "batch3-wei"
if stem.startswith("unit-wu-"):
return "batch4-wu"
if stem.startswith("unit-rebel-"):
return "batch5-rebel-other"
if stem.startswith("unit-nanman-") or stem == "unit-meng-huo":
return "batch6-nanman-special"
return "batch7-unique-officer"
def unit_stems() -> list[str]:
return sorted(path.stem for path in UNIT_DIR.glob("unit-*.png") if not path.stem.endswith("-actions"))
def load_manifest(path: Path = MANIFEST_PATH) -> dict:
if not path.exists():
return DEFAULT_MANIFEST
manifest = json.loads(path.read_text(encoding="utf-8"))
return {
**DEFAULT_MANIFEST,
**manifest,
}
def review_stage(stem: str, manifest: dict) -> str:
if stem in set(manifest.get("approvedBaseline", [])):
return "approved-baseline"
if stem in set(manifest.get("pendingApproval", [])):
return "pending-approval"
return "queued-rework"
def audit() -> tuple[list[dict], dict[str, int], dict[str, int]]:
rows: list[dict] = []
counts: dict[str, int] = defaultdict(int)
stage_counts: dict[str, int] = defaultdict(int)
manifest = load_manifest()
for stem in unit_stems():
base = measure_sheet(UNIT_DIR / f"{stem}.png", stem, BASE_ROWS, BASE_COLS, check_motion_stability=True)
actions = measure_sheet(UNIT_DIR / f"{stem}-actions.png", f"{stem}-actions", ACTION_ROWS, ACTION_COLS)
batch = classify(stem)
stage = review_stage(stem, manifest)
counts[batch] += 1
stage_counts[stage] += 1
rows.append(
{
"batch": batch,
"review_stage": stage,
"stem": stem,
"base": asdict(base),
"actions": asdict(actions),
}
)
return rows, dict(sorted(counts.items())), dict(sorted(stage_counts.items()))
def write_markdown(rows: list[dict], counts: dict[str, int], stage_counts: dict[str, int], path: Path) -> None:
lines = [
"# Unit Sprite Rework Audit",
"",
"This file is generated by `scripts/audit-unit-sprite-sheets.py`.",
"",
f"Review stages are loaded from `{MANIFEST_PATH.relative_to(ROOT).as_posix()}`.",
"",
"Visual review boards are generated by `scripts/render-unit-sprite-review-boards.py`.",
"",
"- Review index: `unit-sprite-review-index.png`",
"- Batch 1 core: `unit-sprite-review-batch1-approved-core.png`",
"- Batch 1 rebel: `unit-sprite-review-batch1-approved-rebel.png`",
"- Batch 2 Shu: `unit-sprite-review-batch2-shu.png`",
"- Batch 3 Wei: `unit-sprite-review-batch3-wei.png`",
"- Batch 4 Wu: `unit-sprite-review-batch4-wu.png`",
"- Batch 5 rebel other: `unit-sprite-review-batch5-rebel-other.png`",
"- Batch 6 Nanman/special: `unit-sprite-review-batch6-nanman-special.png`",
"- Batch 7 unique officer: `unit-sprite-review-batch7-unique-officer.png`",
"",
"## Batch Counts",
"",
]
for batch, count in counts.items():
lines.append(f"- {batch}: {count}")
lines.extend(["", "## Review Stage Counts", ""])
for stage, count in stage_counts.items():
lines.append(f"- {stage}: {count}")
lines.extend(
[
"",
"## Batch Plan",
"",
"- batch1-approved-core/rebel: already approved baseline, use as quality reference.",
"- batch2-shu: Shu generic infantry/spearman/archer/cavalry/strategist/officer variants.",
"- batch3-wei: Wei generic infantry/archer/cavalry/strategist/officer variants.",
"- batch4-wu: Wu generic infantry/archer/cavalry/strategist/officer variants.",
"- batch5-rebel-other: remaining rebel/bandit/yellow-turban variants outside approved batch1.",
"- batch6-nanman-special: Nanman and special tribal unit variants.",
"- batch7-unique-officer: unique officer sheets not covered by earlier batches.",
"",
"## Review Stage",
"",
"- `approved-baseline`: approved hand-painted reference units.",
"- `pending-approval`: technically prepared, waiting for user approval before expansion/commit/deploy.",
"- `queued-rework`: listed for later hand-painted rework; technical `ok` does not mean visually approved.",
"",
"## Sheet Status",
"",
"| batch | stage | stem | base | actions | base bbox | action bbox | battle base px | motion swing |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for row in rows:
base = row["base"]
actions = row["actions"]
base_bbox = f'{base.get("min_width")}..{base.get("max_width")} x {base.get("min_height")}..{base.get("max_height")}'
action_bbox = f'{actions.get("min_width")}..{actions.get("max_width")} x {actions.get("min_height")}..{actions.get("max_height")}'
battle_base = (
f'{base.get("battle_min_width_px")}..{base.get("battle_max_width_px")} x '
f'{base.get("battle_min_height_px")}..{base.get("battle_max_height_px")}'
)
motion = (
f'w{base.get("width_swing")} '
f'h{base.get("height_swing")} '
f'b{base.get("bottom_swing")}'
)
lines.append(
f'| {row["batch"]} | {row["review_stage"]} | `{row["stem"]}` | {base["status"]} | {actions["status"]} | {base_bbox} | {action_bbox} | {battle_base} | {motion} |'
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Audit 313px unit sprite sheets.")
parser.add_argument("--json", type=Path, default=DOCS_DIR / "unit-sprite-rework-audit.json")
parser.add_argument("--markdown", type=Path, default=DOCS_DIR / "unit-sprite-rework-audit.md")
args = parser.parse_args()
rows, counts, stage_counts = audit()
args.json.write_text(
json.dumps(
{
"manifest": MANIFEST_PATH.relative_to(ROOT).as_posix(),
"counts": counts,
"stage_counts": stage_counts,
"rows": rows,
},
indent=2,
),
encoding="utf-8",
)
write_markdown(rows, counts, stage_counts, args.markdown)
print(f"Wrote {args.json}")
print(f"Wrote {args.markdown}")
if __name__ == "__main__":
main()