307 lines
11 KiB
Python
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()
|