teawhite's picture
add docs+code
880dff9 verified
Raw History Blame Contribute Delete
13.9 kB
#!/usr/bin/env python3
"""生成相机偏移 sidecar:data/latent/arope_sidecar.pt(结构见 SPEC「文件契约」)。
每个 clip 存逐帧 / 逐 cell 的玩家屏幕投影位移(dx, dy)px,训练侧据此算世界 RoPE
和 known/new 掩码。数据来源只有 data/meta/*.jsonl + data/logs/<session>/:
meta 里有 video_t / session / clip_id(转场行有 src_a / src_b / n_pre),与 .pt 里的
元数据是同一份(encode_latents.py 直接从 meta 抄进去),所以不必 torch.load 22,782 个
.pt(每个 3 MB);只对 meta 缺的 clip 才回退读 .pt。
增益修正:verify_sidecar.py 对视频实测后得到 gain_correction(分 x/y,含符号),
本脚本用 --gain_fit 把它乘进 offset_px / frame_offset_px / walk_speed_px_s,
sidecar 里存的是**已乘上**的值;M_px832 仍是日志里 M 的原始换算,方便回溯。
python -m actionrope.sidecar # 未修正版(gain = [1, 1])
python -m actionrope.sidecar --gain_fit outputs/sidecar_check/gain_fit.json
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
import torch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from actionrope import geometry as G # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA = os.path.join(ROOT, "data")
VERSION = "arope_sidecar_v1"
SESSIONS = ("20260821_190601_787", "20260823_201942_753")
CLIP_SECONDS = (G.FRAMES - 1) / G.FPS # 首帧到末帧 5.0 s
STRAIGHT_RT_MIN = 0.9 # 直行 clip 的实时率下限(世界降速的不算)
# worker 侧的全局 tracks:fork 之后子进程直接继承,不用 pickle 传 2.3M 样本的数组
_TRACKS: dict = {}
def load_meta(meta_dir: str) -> dict[str, dict]:
"""读全部 meta 行,按 clip_id 索引。"""
rows = {}
for f in sorted(glob.glob(os.path.join(meta_dir, "*.jsonl"))):
with open(f, encoding="utf-8") as fh:
for line in fh:
r = json.loads(line)
rows[r["clip_id"]] = r
return rows
def meta_from_pt(pool_dir: str, clip_id: str) -> dict:
"""meta 里没有的 clip 回退读 .pt 的元数据(不取 latent)。"""
d = torch.load(os.path.join(pool_dir, clip_id + ".pt"), map_location="cpu", weights_only=False)
d.pop("latent", None)
d["clip_id"] = clip_id
if "video_t" not in d and "video_t0" in d:
d["video_t"] = d["video_t0"]
return d
def _plain_offsets(job):
"""worker:一个普通 clip 的逐帧偏移 + valid。"""
clip_id, session, video_t, max_dt = job
tracks = _TRACKS[session]
off, valid, reason = G.sample_frame_offsets(tracks, video_t, return_valid=True, max_sample_dt=max_dt)
want_t = float(video_t) + np.arange(G.FRAMES) / G.FPS
idx = G.nearest_log_index(tracks, want_t)
max_log_dt = float(np.abs(tracks["vid_t"][idx] - want_t).max())
return clip_id, np.nan_to_num(off).astype(np.float32), bool(valid), reason, max_log_dt
def compute_plain(rows: dict, workers: int, max_dt: float) -> dict[str, dict]:
jobs = [(cid, r["session"], r["video_t"], max_dt) for cid, r in rows.items()
if r.get("session") in SESSIONS]
out = {}
with ProcessPoolExecutor(workers) as ex:
for cid, off, valid, reason, max_log_dt in ex.map(_plain_offsets, jobs, chunksize=256):
out[cid] = dict(frame_offset_px=off, valid=valid, reason=reason,
max_log_dt=max_log_dt, kind="plain")
return out
def compute_transitions(rows: dict, plain: dict) -> dict[str, dict]:
"""转场 clip:帧 < n_pre 抄 src_a,之后接 src_b 的增量(SPEC 的连续性规则)。"""
out = {}
for cid, r in rows.items():
if r.get("session") != "transition":
continue
a, b = plain.get(r["src_a"]), plain.get(r["src_b"])
if a is None or b is None:
out[cid] = dict(frame_offset_px=np.zeros((G.FRAMES, 2), np.float32), valid=False,
reason="missing_src", max_log_dt=float("nan"), kind=r["kind"])
continue
off = G.transition_frame_offsets(a["frame_offset_px"], b["frame_offset_px"], int(r["n_pre"]))
reasons = [x for x in (a["reason"], b["reason"]) if x]
out[cid] = dict(frame_offset_px=off.astype(np.float32), valid=a["valid"] and b["valid"],
reason="src:" + "|".join(reasons) if reasons else "",
max_log_dt=max(a["max_log_dt"], b["max_log_dt"]), kind=r["kind"])
return out
def is_straight(r: dict, rt_min: float = STRAIGHT_RT_MIN) -> bool:
"""直行 clip:21 个 cell 动作全同且 ≠ idle、实时率够高。"""
acts = r.get("actions") or []
return (len(acts) == G.CELLS and len(set(acts)) == 1 and acts[0] != 0
and float(r.get("rt_ratio", 0.0)) >= rt_min)
def walk_speed_stats(rows: dict, clips: dict) -> dict:
"""直行 clip 的 |Δoffset|/秒:x 用左右走的(action 3/7),y 用上下走的(1/5)。
按 session 分别给中位,整体取两 session 合并的中位。"""
per = {}
for cid, r in rows.items():
c = clips.get(cid)
if c is None or not c["valid"] or r.get("session") not in SESSIONS or not is_straight(r):
continue
act = r["actions"][0]
d = c["frame_offset_px"][-1] - c["frame_offset_px"][0]
s = per.setdefault(r["session"], dict(x=[], y=[], all=[]))
if act in (3, 7):
s["x"].append(abs(float(d[0])) / CLIP_SECONDS)
if act in (1, 5):
s["y"].append(abs(float(d[1])) / CLIP_SECONDS)
s["all"].append(float(np.hypot(*d)) / CLIP_SECONDS)
out = {}
for sid, s in per.items():
out[sid] = {k: (float(np.median(v)) if v else float("nan")) for k, v in s.items()}
out[sid].update({f"n_{k}": len(v) for k, v in s.items()})
allx = sum((s["x"] for s in per.values()), []); ally = sum((s["y"] for s in per.values()), [])
out["median"] = [float(np.median(allx)) if allx else float("nan"),
float(np.median(ally)) if ally else float("nan")]
return out
def build_sidecar(meta_dir: str, pool_dir: str, logs_dir: str, out_path: str, workers: int,
gain_fit: dict | None, max_dt: float, notes_extra: str = "") -> dict:
t0 = time.time()
rows = load_meta(meta_dir)
pool_ids = sorted(f[:-3] for f in os.listdir(pool_dir) if f.endswith(".pt"))
missing = [cid for cid in pool_ids if cid not in rows]
if missing:
print(f"meta 里缺 {len(missing)} 个 clip,回退读 .pt", flush=True)
for cid in missing:
rows[cid] = meta_from_pt(pool_dir, cid)
rows = {cid: rows[cid] for cid in pool_ids}
print(f"pool {len(pool_ids):,} 个 clip · meta {len(rows):,} 行 · 回退 {len(missing)}", flush=True)
for sid in SESSIONS:
_TRACKS[sid] = G.load_session_tracks(logs_dir, sid)
M_px832 = _TRACKS[SESSIONS[0]]["M_px832"]
for sid in SESSIONS[1:]:
if not np.allclose(_TRACKS[sid]["M_px832"], M_px832):
raise ValueError("两个 session 的 M 不一致,sidecar 的 meta.M_px832 无法唯一")
plain = compute_plain(rows, workers, max_dt)
trans = compute_transitions(rows, plain)
clips_raw = {**plain, **trans}
print(f"偏移计算完成 {len(clips_raw):,} 个 · {time.time() - t0:.1f}s", flush=True)
# 增益修正(含符号)。先用未修正的值算步行速度,再乘 |gain|(速度是幅值,符号不参与)。
gain = np.array((gain_fit or {}).get("gain_correction", [1.0, 1.0]), np.float64)
speed_raw = walk_speed_stats(rows, clips_raw)
walk_speed = [speed_raw["median"][0] * abs(gain[0]), speed_raw["median"][1] * abs(gain[1])]
clips = {}
for cid, c in clips_raw.items():
fo = c["frame_offset_px"].astype(np.float64) * gain[None, :]
clips[cid] = {
"offset_px": torch.from_numpy(G.frames_to_cells(fo).astype(np.float32)),
"frame_offset_px": torch.from_numpy(fo.astype(np.float32)),
"valid": bool(c["valid"]),
"kind": c["kind"],
"max_log_dt": float(c["max_log_dt"]),
}
n_valid = sum(int(c["valid"]) for c in clips.values())
notes = (f"frame_offset_px = 玩家屏幕投影位移(p_i − p_0,M_px832 = 日志 M_px1280 按 832/1280、480/720 缩放)"
f"× gain_correction;背景相对屏幕的位移 = −frame_offset_px。"
f"日志按 video_t=video_t0+i/16 最近邻采样(与 make_clips 一致);"
f"valid=False 当任一帧最近邻样本时间差 > {max_dt} s、坐标 NaN、所选日志样本 vt 倒退或相邻差 > max({G.MAX_LOG_JUMP_S}, 1/16+2×{max_dt}+0.1) s"
f"(跨 offset 台阶)或单帧位移 > {G.MAX_STEP_PX} px;"
f"转场 clip 的 valid = src_a 且 src_b 均 valid。max_log_dt 是该 clip 最近邻时间差的最大值,"
f"可据此放宽 valid 而不必重算。walk_speed_px_s 由直行 clip(21 cell 动作全同≠idle、rt_ratio≥{STRAIGHT_RT_MIN})"
f"的 |Δoffset|/{CLIP_SECONDS:.0f}s 取中位,已乘 |gain_correction|。")
if gain_fit is None:
notes += " gain_correction=[1,1]:尚未经 verify_sidecar.py 实测校验。"
else:
notes += " " + gain_fit.get("decision", "")
if notes_extra:
notes += " " + notes_extra
meta = {
"version": VERSION,
"M_px832": M_px832.tolist(),
"gain_correction": [float(gain[0]), float(gain[1])],
"gain_fit": (gain_fit or {}).get("gain_fit", {}),
"walk_speed_px_s": [float(walk_speed[0]), float(walk_speed[1])],
"walk_speed_px_s_raw": speed_raw,
"n_clips": len(clips), "n_valid": n_valid,
"max_sample_dt": float(max_dt),
"notes": notes,
}
sidecar = {"meta": meta, "clips": clips}
# 先写临时文件再原子替换,避免半截文件被训练侧读到
os.makedirs(os.path.dirname(out_path), exist_ok=True)
tmp = out_path + ".tmp"
torch.save(sidecar, tmp)
os.replace(tmp, out_path)
print(f"写出 {out_path} · {os.path.getsize(out_path) / 1e6:.0f} MB · {time.time() - t0:.1f}s", flush=True)
print_stats(rows, clips_raw, clips, meta)
return sidecar
def print_stats(rows: dict, clips_raw: dict, clips: dict, meta: dict):
n = len(clips)
kinds = {}
for c in clips.values():
k = kinds.setdefault(c["kind"], [0, 0]); k[0] += 1; k[1] += int(c["valid"])
print(f"\n== 统计 n_clips={n:,} n_valid={meta['n_valid']:,} ({meta['n_valid'] / n * 100:.1f}%)")
for k, (a, b) in sorted(kinds.items()):
print(f" {k:<8} {a:6,} · valid {b:6,} ({b / max(a, 1) * 100:.1f}%)")
reasons = {}
for c in clips_raw.values():
if not c["valid"]:
key = c["reason"].split(" ")[0]
reasons[key] = reasons.get(key, 0) + 1
print(f" invalid 原因: {reasons}")
dts = np.array([c["max_log_dt"] for c in clips.values() if np.isfinite(c["max_log_dt"])])
print(" 若放宽 max_sample_dt 的 valid 数: " + " ".join(
f"{th}s→{int((dts <= th).sum()) + sum(1 for c in clips.values() if not np.isfinite(c['max_log_dt']) and c['valid']):,}"
for th in (0.15, 0.3, 0.5, 1.0, 1.5)))
for sid in SESSIONS:
ids = [cid for cid, r in rows.items() if r.get("session") == sid]
v = sum(int(clips[c]["valid"]) for c in ids)
print(f" {sid}: {len(ids):,} clip · valid {v:,} ({v / max(len(ids), 1) * 100:.1f}%)")
last = np.stack([c["offset_px"][G.CELLS - 1].numpy() for c in clips.values() if c["valid"]])
mag = np.hypot(last[:, 0], last[:, 1])
pct = [5, 25, 50, 75, 95, 99]
print(f" |offset_px[20]| 分位 {pct}: {np.percentile(mag, pct).round(1).tolist()} (valid clip)")
print(f" |dx[20]| 分位: {np.percentile(np.abs(last[:, 0]), pct).round(1).tolist()}")
print(f" |dy[20]| 分位: {np.percentile(np.abs(last[:, 1]), pct).round(1).tolist()}")
sp = meta["walk_speed_px_s_raw"]
for sid in SESSIONS:
if sid in sp:
s = sp[sid]
print(f" 步行速度中位(未修正) {sid}: x {s['x']:.1f} px/s (n={s['n_x']}) · "
f"y {s['y']:.1f} px/s (n={s['n_y']}) · |v| {s['all']:.1f} px/s (n={s['n_all']})")
print(f" walk_speed_px_s (已乘 |gain|={[abs(g) for g in meta['gain_correction']]}): "
f"{[round(v, 2) for v in meta['walk_speed_px_s']]}")
def main():
ap = argparse.ArgumentParser(description="生成 ARoPE 相机偏移 sidecar")
ap.add_argument("--meta", default=os.path.join(DATA, "meta"))
ap.add_argument("--pool", default=os.path.join(DATA, "latent", "pool"))
ap.add_argument("--logs", default=os.path.join(DATA, "logs"))
ap.add_argument("--out", default=os.path.join(DATA, "latent", "arope_sidecar.pt"))
ap.add_argument("--workers", type=int, default=16)
ap.add_argument("--gain_fit", default=None,
help="verify_sidecar.py 写出的 gain_fit.json;给了就把 gain_correction 乘进去")
ap.add_argument("--max_sample_dt", type=float, default=G.MAX_SAMPLE_DT,
help="valid 判定:最近邻日志样本时间差上限(秒),SPEC 默认 0.15")
ap.add_argument("--notes", default="", help="附加到 meta.notes 的说明")
args = ap.parse_args()
gain_fit = None
if args.gain_fit:
with open(args.gain_fit, encoding="utf-8") as fh:
gain_fit = json.load(fh)
build_sidecar(args.meta, args.pool, args.logs, args.out, args.workers, gain_fit,
args.max_sample_dt, args.notes)
if __name__ == "__main__":
main()