foitin/pipeline/autohitbox.py
m3ultra b067bf7f0b Scaffold: deterministic 60Hz combat core, drop-in character format, MODELBEAST pipeline
- Godot 4.7 project; fixed-tick FSM fighter, facing-relative input buffer
  with numpad motion parsing, box collision, training-mode hitbox overlay
- characters/ = self-describing drop-in folders (spec in _spec/); two
  generated placeholder fighters (alpha, beta)
- pipeline/: pack_character.py + autohitbox.py (pose/silhouette hurtboxes)
- STUDY.md: reverse-engineering study of the BKB reference games

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:45:44 +10:00

146 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Auto-generate hurtboxes from pose estimation over rendered frames.
The single biggest modernization over the 1990s digitized workflow: BKB's
.zon files carry 9 HAND-PLACED boxes per frame; here a skeleton pass
generates them. Uses rtmlib (RTMPose/DWPose family) if installed:
pip install rtmlib onnxruntime
Falls back to alpha-channel bounding analysis when no pose model is
available — cruder (3 stacked boxes from the silhouette) but functional.
Writes/updates the "hurtboxes" key of each move's data.json in place.
Hit boxes (active attack frames) stay hand-authored — that's ~4 frames
per attack and it's game design, not labor.
Usage:
python3 pipeline/autohitbox.py characters/hero # all moves
python3 pipeline/autohitbox.py characters/hero --move jab
"""
import argparse
import json
from pathlib import Path
import numpy as np
from PIL import Image
try:
from rtmlib import Body # type: ignore
HAVE_POSE = True
except ImportError:
HAVE_POSE = False
# COCO keypoint groups -> the three classic zones
HEAD_KPS = [0, 1, 2, 3, 4] # nose, eyes, ears
TORSO_KPS = [5, 6, 11, 12] # shoulders, hips
LEG_KPS = [11, 12, 13, 14, 15, 16] # hips, knees, ankles
PAD = {"head": 14, "torso": 18, "legs": 12}
def rects_from_pose(kps, scores, w, h, ground_y, cx):
"""keypoints (COCO 17) -> up to 3 local-space rects [x,y,w,h]."""
out = []
for name, idxs in (("head", HEAD_KPS), ("torso", TORSO_KPS), ("legs", LEG_KPS)):
pts = [kps[i] for i in idxs if scores[i] > 0.35]
if len(pts) < 2:
continue
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
pad = PAD[name]
x0, x1 = min(xs) - pad, max(xs) + pad
y0, y1 = min(ys) - pad, max(ys) + pad
if name == "legs":
y1 = ground_y # legs always reach the ground pivot
out.append([round(x0 - cx), round(y0 - ground_y),
round(x1 - x0), round(y1 - y0)])
return out
def rects_from_alpha(img: Image.Image, ground_y, cx):
"""Fallback: slice the alpha silhouette into head/torso/legs thirds."""
a = np.array(img.split()[-1])
rows = np.where(a.max(axis=1) > 32)[0]
if rows.size == 0:
return []
top, bot = int(rows[0]), int(rows[-1])
height = bot - top
bands = [(top, top + height // 4), (top + height // 4, top + height * 5 // 8),
(top + height * 5 // 8, ground_y)]
out = []
for y0, y1 in bands:
band = a[y0:max(y1, y0 + 1)]
cols = np.where(band.max(axis=0) > 32)[0]
if cols.size == 0:
continue
out.append([int(cols[0]) - cx, y0 - ground_y,
int(cols[-1] - cols[0]), y1 - y0])
return out
def process_move(move_dir: Path, body) -> None:
frames = sorted((move_dir / "frames").glob("*.png"))
if not frames:
return
data_path = move_dir / "data.json"
data = json.loads(data_path.read_text())
total = data.get("total", len(frames))
per_frame = {}
for img_path in frames:
img = Image.open(img_path).convert("RGBA")
w, h = img.size
cx, ground_y = w // 2, h - 12
if body is not None:
arr = np.array(img.convert("RGB"))[:, :, ::-1] # BGR
kps, scores = body(arr)
rects = rects_from_pose(kps[0], scores[0], w, h, ground_y, cx) if len(kps) else []
else:
rects = rects_from_alpha(img, ground_y, cx)
if rects:
per_frame[img_path.stem] = rects
if not per_frame:
print(f" {move_dir.name}: no silhouette found, skipped")
return
# default = the median frame's boxes; per-tick overrides where they differ a lot
keys = sorted(per_frame)
default = per_frame[keys[len(keys) // 2]]
frames_out = {}
n = len(keys)
for i, k in enumerate(keys):
if per_frame[k] != default:
t0 = int(i * total / n)
t1 = max(t0, int((i + 1) * total / n) - 1)
frames_out[f"{t0}-{t1}"] = per_frame[k]
data["hurtboxes"] = {"default": default}
if frames_out:
data["hurtboxes"]["frames"] = frames_out
data_path.write_text(json.dumps(data, indent=2))
print(f" {move_dir.name}: default {len(default)} boxes, {len(frames_out)} frame overrides")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("char_dir", help="characters/<id>")
ap.add_argument("--move", default=None)
args = ap.parse_args()
body = None
if HAVE_POSE:
body = Body(mode="balanced", backend="onnxruntime", device="cpu")
print("pose model: rtmlib Body")
else:
print("rtmlib not installed - using alpha-silhouette fallback "
"(pip install rtmlib onnxruntime for pose-based boxes)")
moves_root = Path(args.char_dir) / "moves"
targets = [moves_root / args.move] if args.move else sorted(moves_root.iterdir())
for move_dir in targets:
if move_dir.is_dir():
process_move(move_dir, body)
if __name__ == "__main__":
main()