teawhite's picture
add docs+code
880dff9 verified
Raw History Blame Contribute Delete
57.5 kB
#!/usr/bin/env python3
"""bg50 四科判决(只跑 ARoPE 臂):复现旧报告《世界RoPE·四科判决》的口径。
考场:data/eval/eybx_bg_50/{01..50}_*.png(50 个未见背景,中央已合成玩家),文本条件 NULL_PROMPT
(训练时场景 dropout 用的串;这些背景不属于 18 个训练场景,不配场景从句,--scene 可人为覆盖)。
四科(度量全在 CPU 上只读 mp4,位移一律用 verify_sidecar.measure_shift:SIFT+RANSAC 仿射场在玩家位置取值,
返回的是"背景位移"= −frame_offset_px):
1. 增益线性 gain_<bg>_x<mult> 10 个背景 × {0.5, 0.75, 1.0, 1.25, 1.5}× 直线右走;帧 0→80 实测位移 vs 指令位移过原点回归,报斜率 / R²。
2. 折返闭环 back_<bg> 50 个背景,1.0× 步速右走 40 帧再左走 40 帧回原点(frame_offset[80] = 0);
终帧 vs 首帧的 PSNR / SSIM / NCC(明暗对齐后、中心 160×160 以外),回归残差(终帧相对首帧的实测位置误差 px),
位置补偿后 NCC;守门:帧 0→40 的实走距离。
3. 运动平滑 straight_<bg> 50 个背景,1.0× 直线右走;逐帧水平速度(相邻帧 SIFT,失败退化为隔 2 帧):倒退帧数、速度 std/mean、归一 jerk、平均速度/承诺。
4. 方向服从 同一批 straight 帧 0→80 累计位移(measure_displacement 的 sift 链式)与指令的角度误差 / 幅度比 / 失控数。
用法:
python -m actionrope.eval_bg50 --ckpt outputs/arope_5k/step-5000.safetensors --run step5000 --stages gen,measure --gpus 0-7
python -m actionrope.eval_bg50 --run step5000 --stages measure # 只重算度量
python -m actionrope.eval_bg50 --selfcheck # 合成数据自检(不需要 GPU)
产物 outputs/eval_bg50/<run>/:plan.jsonl、videos/<id>.mp4 + <id>.json、metrics.jsonl、summary.json、report.md、figures.png、selfcheck.json。
"""
from __future__ import annotations
import argparse
import json
import math
import os
import subprocess
import sys
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from actionrope import geometry as G # noqa: E402
PY = os.path.join(ROOT, ".venv", "bin", "python")
EVAL_DIR = os.path.join(ROOT, "data", "eval", "eybx_bg_50")
OUT_ROOT = os.path.join(ROOT, "outputs", "eval_bg50")
GAIN_MULTS = (0.5, 0.75, 1.0, 1.25, 1.5)
GAIN_N_BG = 10
BACK_MULT = 1.0 # 折返也用 1 倍速(人定:全部科目统一 1×)
STRAIGHT_MULT = 1.0
BACK_TURN = 40 # 帧 1..40 向右、41..80 向左
CENTER_BOX = 160 # 折返相似度剔除的中心角色框(px,正方形)
ANGLE_FAIL_DEG = 15.0
RATIO_FAIL = 0.5
# 旧报告《世界RoPE·四科判决》里世界 RoPE@5k 的数字(任务给出的部分;其余未给出记 None)
OLD_REPORT = {
"gain_slope": 1.06, "gain_r2": 0.95,
"back_psnr_med": None, "back_ssim_med": None, "back_ncc_med": None,
"back_resid_med": None, "back_ncc_comp_med": None,
"smooth_backward_frames_total": 6,
"smooth_speed_cv_med": None, "smooth_jerk_med": None, "smooth_speed_ratio_med": None,
"dir_angle_med": None, "dir_angle_p90": None, "dir_ratio_med": None, "dir_fail": None,
}
# ------------------------------------------------------------------ 素材 / 计划 ----
def list_backgrounds() -> list[dict]:
"""manifest.jsonl 里 status=ok 的背景,按文件名的两位序号排序。"""
rows = []
with open(os.path.join(EVAL_DIR, "manifest.jsonl"), encoding="utf-8") as fh:
for line in fh:
if line.strip():
rows.append(json.loads(line))
rows = [r for r in rows if r.get("status") == "ok"]
for r in rows:
r["idx"] = int(r["file"][:2])
r["path"] = os.path.join(EVAL_DIR, r["file"])
rows.sort(key=lambda r: r["idx"])
return rows
def parse_bgs(spec: str | None, rows: list[dict]) -> list[dict]:
if not spec:
return rows
want = set()
for tok in spec.split(","):
tok = tok.strip()
if "-" in tok:
a, b = tok.split("-")
want.update(range(int(a), int(b) + 1))
elif tok:
want.add(int(tok))
return [r for r in rows if r["idx"] in want]
def parse_gpus(spec: str) -> list[int]:
out = []
for tok in spec.split(","):
tok = tok.strip()
if "-" in tok:
a, b = tok.split("-")
out += list(range(int(a), int(b) + 1))
elif tok:
out.append(int(tok))
return out
def straight_offsets(vx: float) -> np.ndarray:
"""直线右走:frame_offset[i] = (i/16 · vx, 0),玩家向右 ⇒ 背景左移。"""
off = np.zeros((G.FRAMES, 2), np.float64)
off[:, 0] = np.arange(G.FRAMES) / G.FPS * vx
return off
def back_offsets(vx: float, turn: int = BACK_TURN) -> np.ndarray:
"""折返:帧 1..turn−1 每帧 +vx/16,帧 turn / turn+1 各 +½ / −½(2 帧线性掉头),帧 turn+2..80 每帧 −vx/16。
正负步数严格相等 ⇒ frame_offset[80] = 0(不靠 cell 数凑)。"""
vel = np.zeros(G.FRAMES, np.float64)
vel[1:turn] = vx
vel[turn] = 0.5 * vx
vel[turn + 1] = -0.5 * vx
vel[turn + 2:] = -vx
off = np.zeros((G.FRAMES, 2), np.float64)
off[:, 0] = np.cumsum(vel) / G.FPS
assert abs(off[-1, 0]) < 1e-9, off[-1, 0]
return off
def build_plan(rows: list[dict], walk_x: float, seed: int) -> list[dict]:
plan = []
for r in rows[:GAIN_N_BG]:
for m in GAIN_MULTS:
off = straight_offsets(walk_x * m)
plan.append(dict(id=f"gain_{r['idx']:02d}_x{m:.2f}", exam="gain", bg=r["idx"], bg_file=r["file"],
mult=m, instruction=f"right x{m:.2f} straight", frame_offset_px=off.tolist(),
expected_bg_shift_80=(-off[80]).tolist(), seed=seed))
for r in rows:
off = back_offsets(walk_x * BACK_MULT)
plan.append(dict(id=f"back_{r['idx']:02d}", exam="back", bg=r["idx"], bg_file=r["file"], mult=BACK_MULT,
instruction=f"right x{BACK_MULT} 40 frames then left x{BACK_MULT} 40 frames",
frame_offset_px=off.tolist(), expected_bg_shift_80=(-off[80]).tolist(),
expected_bg_shift_turn=(-off[BACK_TURN]).tolist(), turn_frame=BACK_TURN, seed=seed))
for r in rows:
off = straight_offsets(walk_x * STRAIGHT_MULT)
plan.append(dict(id=f"straight_{r['idx']:02d}", exam="straight", bg=r["idx"], bg_file=r["file"],
mult=STRAIGHT_MULT, instruction=f"right x{STRAIGHT_MULT:.2f} straight",
frame_offset_px=off.tolist(), expected_bg_shift_80=(-off[80]).tolist(), seed=seed))
return plan
def video_done(vdir: str, vid: str) -> bool:
"""mp4 与 json 都在,且 json 记录的字节数与 mp4 实际一致 ⇒ 视为完整,可跳过。"""
mp4, js = os.path.join(vdir, vid + ".mp4"), os.path.join(vdir, vid + ".json")
if not (os.path.isfile(mp4) and os.path.isfile(js)):
return False
try:
with open(js, encoding="utf-8") as fh:
info = json.load(fh)
return bool(info.get("ok")) and info.get("mp4_bytes") == os.path.getsize(mp4) and info.get("frames") == G.FRAMES
except (json.JSONDecodeError, OSError):
return False
# ------------------------------------------------------------------ 生成(GPU 子进程)----
def gen_worker(run_dir: str, ckpt: str, rank: int, world: int, steps: int, shift: float, scene: str | None,
text_table: str | None):
"""一个子进程只加载一次模型,跑 plan 里 idx % world == rank 的条目。"""
import torch
from actionrope import infer as I
from actionrope.arope import loss_weight_map, MASK_EMBEDDING_NAME
from actionrope.prompts import NULL_PROMPT, strip_action
from diffsynth.utils.data import save_video
plan = [json.loads(l) for l in open(os.path.join(run_dir, "plan.jsonl"), encoding="utf-8") if l.strip()]
mine = [p for i, p in enumerate(plan) if i % world == rank]
vdir = os.path.join(run_dir, "videos")
os.makedirs(vdir, exist_ok=True)
todo = [p for p in mine if not video_done(vdir, p["id"])]
log = lambda s: print(f"[gen rank{rank}] {s}", flush=True)
log(f"{len(mine)} 条,其中 {len(todo)} 条待生成")
if not todo:
return
t0 = time.time()
# 臂按 ckpt 自动识别(baseline/SPEC.md):arope / linear / xattn / adaln 文本剥动作词;plain / prompt 动作走文本
pipe, dit, arm = I.load_models(ckpt, None, "cuda")
arm_name, _ = I.resolve_arm(ckpt, None)
text_arm = arm_name in I.TEXT_ARMS
has_mask = hasattr(dit, MASK_EMBEDDING_NAME)
enc = I.TextEncoder(text_table or I.TEXT_TABLE["plain" if text_arm else "arope"], "cuda")
per_cell = getattr(arm, "per_cell_context", False) and getattr(arm, "require_per_cell", False)
if scene:
prompts = [strip_action(p) for p in I.compose_cell_prompts(scene, None, [7] * G.CELLS)]
else:
prompts = [NULL_PROMPT] * G.CELLS
context = context_ids = None
if not text_arm: # 动作走文本的臂每条视频的文本不同,在循环里按指令拼
context, context_ids = I.build_context(enc, prompts, "cuda", per_cell=per_cell)
log(f"模型加载 {time.time() - t0:.0f}s · arm={arm_name} mask_channel={has_mask} · 文本 {prompts[0]!r} · t5_fallback={enc.n_fallback}")
z0_cache: dict[str, torch.Tensor] = {}
for p in todo:
t1 = time.time()
info = dict(id=p["id"], exam=p["exam"], bg=p["bg"], bg_file=p["bg_file"], instruction=p["instruction"],
mult=p["mult"], frame_offset_px=p["frame_offset_px"], expected_bg_shift_80=p["expected_bg_shift_80"],
ckpt=ckpt, steps=steps, shift=shift, seed=p["seed"], prompt=prompts[0], mask_channel=has_mask)
try:
if p["bg_file"] not in z0_cache:
z0_cache.clear()
img = I.load_first_frame(os.path.join(EVAL_DIR, p["bg_file"]))
z0_cache[p["bg_file"]] = (img, I.encode_first_frame(pipe, img))
img, z0 = z0_cache[p["bg_file"]]
frame_off = np.array(p["frame_offset_px"], np.float64)
offset_px = G.frames_to_cells(frame_off)
info["offset_px"] = offset_px.tolist()
# 逐 cell 真实屏幕方向标签(与 infer.main 的 true_labels 同一算法):cell 速度 px/s,一个 cell = 4 帧 = 0.25 s
vel = np.zeros_like(offset_px)
vel[1:] = (offset_px[1:] - offset_px[:-1]) / 0.25
true_labels = [I.velocity_to_label(v, mirror=False) for v in vel]
info["action_idx"] = true_labels
info["arm"] = arm_name
off_px_t = torch.tensor(offset_px, dtype=torch.float32).unsqueeze(0)
if text_arm:
# 动作走文本:无场景从句时用数据集的裸动作串(场景 dropout 用的那 9 条,表里有)
cell_prompts = (I.compose_cell_prompts(scene, None, true_labels) if scene
else [I.BARE_TMPL.format(action=I.ACTIONS[l]) for l in true_labels])
ctx, ctx_ids = I.build_context(enc, cell_prompts, "cuda", per_cell=per_cell)
info["prompt"] = cell_prompts[1]
else:
ctx, ctx_ids = context, context_ids
if arm_name == "arope":
off_t = off_px_t.to("cuda")
# 与 infer.main 一致:mask 通道输入 = loss_weight_map(...)==1(known),有 mask 通道才给
mask_in = loss_weight_map(off_t).eq(1).float() if has_mask else None
else:
off_t, mask_in = None, None # baseline 臂:普通 RoPE、无 mask,动作走 action_inputs
action_inputs = None
if arm is not None:
action_inputs = {k: v.to("cuda") for k, v in I.make_action_inputs(off_px_t, torch.tensor([true_labels])).items()}
ts = time.time()
latents = I.sample(dit, z0, ctx, ctx_ids, off_t, mask_in, steps=steps, shift=shift,
cfg=1.0, seed=p["seed"], arm=arm, action_inputs=action_inputs, log=lambda s: None)
torch.cuda.synchronize()
info["sample_sec"] = time.time() - ts
ts = time.time()
frames = I.decode_video(pipe, latents)
info["decode_sec"] = time.time() - ts
mp4 = os.path.join(vdir, p["id"] + ".mp4")
save_video(list(frames), mp4, fps=int(G.FPS), quality=9)
info.update(ok=True, frames=int(frames.shape[0]), resolution=[int(frames.shape[2]), int(frames.shape[1])],
mp4_bytes=os.path.getsize(mp4), total_sec=time.time() - t1,
peak_mem_gb=torch.cuda.max_memory_allocated() / 1e9)
except Exception as e: # 单条失败不拖垮分片
info.update(ok=False, error=repr(e), total_sec=time.time() - t1)
log(f"{p['id']} FAILED: {e!r}")
with open(os.path.join(vdir, p["id"] + ".json"), "w", encoding="utf-8") as fh:
json.dump(info, fh, ensure_ascii=False, indent=1, default=float)
log(f"{p['id']} · {info['total_sec']:.0f}s · ok={info['ok']}")
log(f"分片完成 · {time.time() - t0:.0f}s")
def stage_gen(args, run_dir: str) -> dict:
gpus = parse_gpus(args.gpus)
plan = [json.loads(l) for l in open(os.path.join(run_dir, "plan.jsonl"), encoding="utf-8") if l.strip()]
vdir = os.path.join(run_dir, "videos")
os.makedirs(vdir, exist_ok=True)
n_todo = sum(not video_done(vdir, p["id"]) for p in plan)
print(f"[gen] {len(plan)} 条计划,{n_todo} 条待生成,{len(gpus)} 张卡 {gpus}", flush=True)
t0 = time.time()
procs, pids = [], []
log_dir = os.path.join(run_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
for rank, gpu in enumerate(gpus):
env = dict(os.environ, CUDA_VISIBLE_DEVICES=str(gpu), PYTHONPATH=ROOT, DIFFSYNTH_SKIP_DOWNLOAD="True")
cmd = [PY, "-m", "actionrope.eval_bg50", "--_worker", "--run", args.run, "--ckpt", args.ckpt,
"--rank", str(rank), "--world", str(len(gpus)), "--steps", str(args.steps), "--shift", str(args.shift)]
if args.scene:
cmd += ["--scene", args.scene]
if args.text_table:
cmd += ["--text_table", args.text_table]
fh = open(os.path.join(log_dir, f"gen_rank{rank}_gpu{gpu}.log"), "a")
pr = subprocess.Popen(cmd, env=env, stdout=fh, stderr=subprocess.STDOUT, cwd=ROOT)
procs.append((pr, fh))
pids.append(pr.pid)
with open(os.path.join(run_dir, "gen_pids.json"), "w") as fh:
json.dump(dict(pids=pids, gpus=gpus, started=time.time()), fh)
rc = []
for pr, fh in procs:
rc.append(pr.wait())
fh.close()
gen_sec = time.time() - t0
n_fail = 0
for p in plan:
js = os.path.join(vdir, p["id"] + ".json")
ok = False
if os.path.isfile(js):
with open(js, encoding="utf-8") as fh:
ok = bool(json.load(fh).get("ok"))
n_fail += not ok
stat = dict(gen_wall_sec=gen_sec, n_plan=len(plan), n_generated_now=n_todo, n_failed=n_fail, worker_rc=rc, gpus=gpus)
with open(os.path.join(run_dir, "gen_stat.json"), "w") as fh:
json.dump(stat, fh, indent=1)
print(f"[gen] 完成 · 墙钟 {gen_sec:.0f}s · 失败 {n_fail} · rc={rc}", flush=True)
return stat
# ------------------------------------------------------------------ 度量(CPU)----
def _gray(frames: np.ndarray) -> np.ndarray:
import cv2
return np.stack([cv2.cvtColor(f, cv2.COLOR_RGB2GRAY) for f in frames], 0)
def per_frame_velocity(gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""帧 i→i+1 的背景位移 (dx, dy) [80,2];相邻帧 SIFT 测不出时退化为隔 2 帧(i→i+2 或 i−1→i+1)的一半。
source[i]:1=相邻,2=隔 2 帧,0=测不出(NaN)。"""
from actionrope.verify_sidecar import measure_shift
n = gray.shape[0]
v = np.full((n - 1, 2), np.nan)
src = np.zeros(n - 1, int)
cache: dict[tuple[int, int], dict | None] = {}
def shift(a, b):
if (a, b) not in cache:
cache[(a, b)] = measure_shift(gray[a], gray[b])
return cache[(a, b)]
for i in range(n - 1):
m = shift(i, i + 1)
if m is not None:
v[i] = (m["dx"], m["dy"]); src[i] = 1
continue
for a, b in ((i, i + 2), (i - 1, i + 1)):
if 0 <= a and b < n:
m = shift(a, b)
if m is not None:
v[i] = (m["dx"] / 2, m["dy"] / 2); src[i] = 2
break
return v, src
def cumulative_shift(gray: np.ndarray, k: int) -> dict:
"""帧 0→k 的累计背景位移:measure_displacement 的 sift(直接测得出用直接,否则链式)。"""
from actionrope.infer import measure_displacement
m = measure_displacement(gray, k=k)
return dict(sift=m["sift"], source=m["sift_source"], direct=m["sift_direct"], chain=m["sift_chain"],
chain_failed_at=m.get("sift_chain_failed_at"))
def _region_mask(h: int = G.H, w: int = G.W, box: int = CENTER_BOX) -> np.ndarray:
m = np.ones((h, w), bool)
cy, cx = h // 2, w // 2
m[cy - box // 2: cy + box // 2, cx - box // 2: cx + box // 2] = False
return m
def _match_gain(fk: np.ndarray, f0: np.ndarray, m: np.ndarray) -> np.ndarray:
"""把终帧逐通道做线性明暗对齐到首帧(区域内均值 / 标准差匹配),剔除整体明暗漂移。"""
out = fk.astype(np.float64).copy()
for c in range(fk.shape[2]):
a, b = fk[..., c][m].astype(np.float64), f0[..., c][m].astype(np.float64)
sa, sb = a.std(), b.std()
g = sb / sa if sa > 1e-6 else 1.0
out[..., c] = (fk[..., c].astype(np.float64) - a.mean()) * g + b.mean()
return np.clip(out, 0, 255)
def psnr_region(a: np.ndarray, b: np.ndarray, m: np.ndarray) -> float:
mse = np.mean((a[m].astype(np.float64) - b[m].astype(np.float64)) ** 2)
return 99.0 if mse < 1e-12 else float(10 * np.log10(255.0 ** 2 / mse))
def ssim_region(a: np.ndarray, b: np.ndarray, m: np.ndarray) -> float:
"""灰度 SSIM(高斯窗 11×11 σ=1.5,Wang 2004 常数),只在 m 区域平均。"""
import cv2
a = a.astype(np.float64); b = b.astype(np.float64)
C1, C2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
blur = lambda x: cv2.GaussianBlur(x, (11, 11), 1.5)
mu_a, mu_b = blur(a), blur(b)
va = blur(a * a) - mu_a ** 2
vb = blur(b * b) - mu_b ** 2
cov = blur(a * b) - mu_a * mu_b
s = ((2 * mu_a * mu_b + C1) * (2 * cov + C2)) / ((mu_a ** 2 + mu_b ** 2 + C1) * (va + vb + C2))
return float(s[m].mean())
def ncc_region(a: np.ndarray, b: np.ndarray, m: np.ndarray) -> float:
x = a[m].astype(np.float64); y = b[m].astype(np.float64)
x -= x.mean(); y -= y.mean()
d = np.sqrt((x * x).sum() * (y * y).sum())
return float((x * y).sum() / d) if d > 1e-9 else 0.0
def translate(img: np.ndarray, t: tuple[float, float]) -> tuple[np.ndarray, np.ndarray]:
"""dst(p) = src(p + t):把"背景位移了 t"的帧平移回去与帧 0 对齐;返回 (对齐图, 有效区掩码)。"""
import cv2
M = np.array([[1, 0, -t[0]], [0, 1, -t[1]]], np.float64)
h, w = img.shape[:2]
out = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
valid = cv2.warpAffine(np.full((h, w), 255, np.uint8), M, (w, h), flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT, borderValue=0) > 0
return out, valid
def back_metrics(frames: np.ndarray, gray: np.ndarray, turn: int) -> dict:
f0, fk = frames[0], frames[-1]
m = _region_mask()
fk_adj = _match_gain(fk, f0, m)
g0, gk = gray[0].astype(np.float64), _gray(np.clip(fk_adj, 0, 255).astype(np.uint8)[None])[0].astype(np.float64)
out = dict(psnr=psnr_region(fk_adj, f0, m), ssim=ssim_region(gk, g0, m), ncc=ncc_region(gk, g0, m))
# 回归残差:终帧相对首帧的实测背景位置误差(SIFT 直接,测不出退链式)
cs = cumulative_shift(gray, G.FRAMES - 1)
out["resid_source"] = cs["source"]
if cs["sift"] is None:
out.update(resid_dx=None, resid_dy=None, resid_px=None, ncc_comp=None)
else:
dx, dy = cs["sift"]
out.update(resid_dx=dx, resid_dy=dy, resid_px=float(math.hypot(dx, dy)))
aligned, valid = translate(fk_adj, (dx, dy))
ga = _gray(np.clip(aligned, 0, 255).astype(np.uint8)[None])[0].astype(np.float64)
out["ncc_comp"] = ncc_region(ga, g0, m & valid)
out["psnr_comp"] = psnr_region(aligned, f0, m & valid)
# 守门:实走距离 = 帧 0→turn 的位移
ct = cumulative_shift(gray, turn)
out["walk_source"] = ct["source"]
out["walk_dx"] = None if ct["sift"] is None else ct["sift"][0]
out["walk_px"] = None if ct["sift"] is None else float(math.hypot(*ct["sift"]))
return out
def smooth_metrics(gray: np.ndarray, commit_px_per_frame: float) -> dict:
"""直线右走:逐帧水平速度(背景位移,指令方向为负)。"""
v, src = per_frame_velocity(gray)
vx = v[:, 0]
ok = np.isfinite(vx)
n_ok = int(ok.sum())
out = dict(n_frames_measured=n_ok, n_adjacent=int((src == 1).sum()), n_skip2=int((src == 2).sum()),
n_unmeasured=int((src == 0).sum()), vx=[None if not np.isfinite(x) else float(x) for x in vx],
vy=[None if not np.isfinite(x) else float(x) for x in v[:, 1]])
if n_ok < 8:
out.update(backward_frames=None, backward_frames_1px=None, backward_max_px=None, speed_cv=None,
speed_cv_cell=None, jerk_norm=None, speed_mean=None, speed_ratio=None)
return out
vv = vx[ok]
# 指令右走 ⇒ 背景速度应为负;符号翻正 = 倒退帧
out["backward_frames"] = int((vv > 0).sum())
out["backward_frames_1px"] = int((vv > 1.0).sum())
mean = float(vv.mean())
out["speed_mean"] = mean
out["speed_cv"] = float(vv.std() / abs(mean)) if abs(mean) > 1e-6 else None
out["speed_ratio"] = float(mean / commit_px_per_frame)
out["backward_max_px"] = float(vv.max()) if (vv > 0).any() else 0.0
# 逐 cell(4 帧)平均后的速度 CV:因果 VAE 每 4 帧一个 latent cell,帧内节奏抖动在这一步被平均掉
vs = np.where(ok, vx, np.nan)
vc = np.nanmean(vs.reshape(-1, 4), 1)
out["speed_cv_cell"] = float(np.nanstd(vc) / abs(np.nanmean(vc))) if np.isfinite(vc).sum() >= 4 else None
# 归一 jerk:速度序列的二阶差分 RMS / |平均速度|(只在连续可测段上取差分)
j = np.diff(vs, n=2)
j = j[np.isfinite(j)]
out["jerk_norm"] = float(np.sqrt((j ** 2).mean()) / abs(mean)) if j.size and abs(mean) > 1e-6 else None
return out
def direction_metrics(gray: np.ndarray, expected: np.ndarray) -> dict:
cs = cumulative_shift(gray, G.FRAMES - 1)
out = dict(source=cs["source"], expected=expected.tolist())
if cs["sift"] is None:
out.update(measured=None, angle_deg=None, ratio=None, fail=True, fail_reason="unmeasured")
return out
d = np.array(cs["sift"], np.float64)
ang = math.degrees(math.atan2(d[1], d[0]) - math.atan2(expected[1], expected[0]))
ang = (ang + 180) % 360 - 180
ratio = float(np.linalg.norm(d) / np.linalg.norm(expected))
fail = abs(ang) > ANGLE_FAIL_DEG or ratio < RATIO_FAIL
out.update(measured=d.tolist(), angle_deg=float(ang), ratio=ratio, fail=bool(fail),
fail_reason=("angle" if abs(ang) > ANGLE_FAIL_DEG else "") + ("ratio" if ratio < RATIO_FAIL else ""))
return out
def measure_one(item: dict, frames: np.ndarray | None = None) -> dict:
"""一条视频的全部度量。item 来自 plan(含 exam / frame_offset_px);frames 不给就读 item['mp4']。"""
from actionrope.infer import read_frames
t0 = time.time()
out = dict(id=item["id"], exam=item["exam"], bg=item["bg"], mult=item["mult"])
try:
if frames is None:
frames = read_frames(item["mp4"])
if frames.shape[0] != G.FRAMES:
raise ValueError(f"帧数 {frames.shape[0]} != {G.FRAMES}")
gray = _gray(frames)
fo = np.array(item["frame_offset_px"], np.float64)
expected = -fo[80]
out["expected_bg_shift_80"] = expected.tolist()
if item["exam"] == "gain":
cs = cumulative_shift(gray, G.FRAMES - 1)
out["measured_80"] = cs["sift"]
out["source"] = cs["source"]
out["commanded_dx"] = float(expected[0])
out["measured_dx"] = None if cs["sift"] is None else float(cs["sift"][0])
elif item["exam"] == "back":
out.update(back_metrics(frames, gray, item.get("turn_frame", BACK_TURN)))
out["expected_walk_dx"] = float(-fo[item.get("turn_frame", BACK_TURN), 0])
elif item["exam"] == "straight":
commit = float((-fo[80, 0]) / (G.FRAMES - 1)) # px/帧,负值
out["commit_px_per_frame"] = commit
out["smooth"] = smooth_metrics(gray, commit)
out["direction"] = direction_metrics(gray, expected)
out["ok"] = True
except Exception as e:
out.update(ok=False, error=repr(e))
out["measure_sec"] = time.time() - t0
return out
def _measure_job(item):
return measure_one(item)
def _med(xs) -> float | None:
xs = [x for x in xs if x is not None and np.isfinite(x)]
return float(np.median(xs)) if xs else None
def _p90(xs) -> float | None:
xs = [x for x in xs if x is not None and np.isfinite(x)]
return float(np.percentile(xs, 90)) if xs else None
def fit_origin(x: np.ndarray, y: np.ndarray) -> dict:
"""过原点回归 y = k·x:k = Σxy/Σx²;R² = 1 − SS_res/SS_tot(SS_tot 绕均值)。"""
k = float((x * y).sum() / (x * x).sum())
res = y - k * x
ss_tot = float(((y - y.mean()) ** 2).sum())
r2 = float(1 - (res ** 2).sum() / ss_tot) if ss_tot > 0 else None
return dict(slope=k, r2=r2, n=int(x.size), resid_rms=float(np.sqrt((res ** 2).mean())))
def summarize(metrics: list[dict], walk_x: float) -> dict:
s: dict = {"n_measured": len(metrics), "n_measure_failed": sum(not m.get("ok") for m in metrics)}
# 1. 增益线性:指令幅值 vs 实测幅值(都取沿指令方向的分量,指令右走 ⇒ 背景 dx<0,取负号后为正)
g = [m for m in metrics if m["exam"] == "gain" and m.get("ok") and m.get("measured_dx") is not None]
if g:
x = np.array([-m["commanded_dx"] for m in g]); y = np.array([-m["measured_dx"] for m in g])
fit = fit_origin(x, y)
by_mult = {}
for mult in GAIN_MULTS:
ys = [-m["measured_dx"] / (-m["commanded_dx"]) for m in g if abs(m["mult"] - mult) < 1e-6]
by_mult[f"{mult:.2f}"] = dict(n=len(ys), ratio_med=_med(ys))
# 敏感性:逐个剔除一个背景重新拟合,记录影响最大的那个(生成的画面若带纵深视差,SIFT 链式在玩家位置取值会失稳)
per_bg = {}
for bg in sorted({m["bg"] for m in g}):
keep = np.array([m["bg"] != bg for m in g])
f = fit_origin(x[keep], y[keep])
ratios = [-m["measured_dx"] / (-m["commanded_dx"]) for m in g if m["bg"] == bg]
per_bg[bg] = dict(slope_wo=f["slope"], r2_wo=f["r2"], ratio_med=float(np.median(ratios)))
worst = max(per_bg, key=lambda k: per_bg[k]["r2_wo"])
s["gain"] = dict(slope=fit["slope"], r2=fit["r2"], n=fit["n"], resid_rms_px=fit["resid_rms"],
leave_one_bg_out={str(k): v for k, v in per_bg.items()},
worst_bg=int(worst), slope_wo_worst=per_bg[worst]["slope_wo"], r2_wo_worst=per_bg[worst]["r2_wo"],
n_unmeasured=sum(1 for m in metrics if m["exam"] == "gain" and m.get("measured_dx") is None),
ratio_by_mult=by_mult, points=[[float(a), float(b)] for a, b in zip(x, y)])
# 2. 折返闭环
b = [m for m in metrics if m["exam"] == "back" and m.get("ok")]
if b:
s["back"] = dict(n=len(b), psnr_med=_med([m["psnr"] for m in b]), psnr_p10=_p90([-m["psnr"] for m in b]) and -_p90([-m["psnr"] for m in b]),
ssim_med=_med([m["ssim"] for m in b]), ncc_med=_med([m["ncc"] for m in b]),
resid_px_med=_med([m["resid_px"] for m in b]), resid_px_p90=_p90([m["resid_px"] for m in b]),
resid_dx_med=_med([m["resid_dx"] for m in b]), resid_dy_med=_med([m["resid_dy"] for m in b]),
ncc_comp_med=_med([m.get("ncc_comp") for m in b]), psnr_comp_med=_med([m.get("psnr_comp") for m in b]),
walk_px_med=_med([m["walk_px"] for m in b]), walk_dx_med=_med([m["walk_dx"] for m in b]),
expected_walk_dx=b[0].get("expected_walk_dx"),
n_resid_unmeasured=sum(m["resid_px"] is None for m in b),
n_walk_unmeasured=sum(m["walk_px"] is None for m in b),
n_walk_short=sum(1 for m in b if m["walk_px"] is not None and m["walk_px"] < 0.5 * abs(b[0].get("expected_walk_dx", 1))),
psnr_all=[m["psnr"] for m in b])
# 3. 运动平滑
st = [m for m in metrics if m["exam"] == "straight" and m.get("ok")]
if st:
sm = [m["smooth"] for m in st]
bf = [x["backward_frames"] for x in sm if x["backward_frames"] is not None]
s["smooth"] = dict(n=len(sm), backward_frames_total=int(sum(bf)), backward_frames_1px_total=int(sum(x["backward_frames_1px"] for x in sm if x["backward_frames_1px"] is not None)),
n_videos_with_backward=int(sum(1 for v in bf if v > 0)), backward_frames_per_video=bf,
backward_max_px=max((x["backward_max_px"] or 0.0) for x in sm),
speed_cv_med=_med([x["speed_cv"] for x in sm]), speed_cv_p90=_p90([x["speed_cv"] for x in sm]),
speed_cv_cell_med=_med([x["speed_cv_cell"] for x in sm]),
vx_mean_by_frame=[float(v) for v in np.nanmean(np.array([[np.nan if v is None else v for v in x["vx"]] for x in sm]), 0)],
jerk_norm_med=_med([x["jerk_norm"] for x in sm]), jerk_norm_p90=_p90([x["jerk_norm"] for x in sm]),
speed_ratio_med=_med([x["speed_ratio"] for x in sm]), speed_ratio_mean=float(np.mean([x["speed_ratio"] for x in sm if x["speed_ratio"] is not None])),
speed_mean_px_per_frame_med=_med([x["speed_mean"] for x in sm]),
commit_px_per_frame=st[0]["commit_px_per_frame"],
frames_adjacent=int(sum(x["n_adjacent"] for x in sm)), frames_skip2=int(sum(x["n_skip2"] for x in sm)),
frames_unmeasured=int(sum(x["n_unmeasured"] for x in sm)))
d = [m["direction"] for m in st]
s["direction"] = dict(n=len(d), expected=d[0]["expected"], angle_abs_med=_med([abs(x["angle_deg"]) for x in d if x["angle_deg"] is not None]),
angle_abs_p90=_p90([abs(x["angle_deg"]) for x in d if x["angle_deg"] is not None]),
angle_signed_med=_med([x["angle_deg"] for x in d if x["angle_deg"] is not None]),
ratio_med=_med([x["ratio"] for x in d]), ratio_p10=(lambda v: None if v is None else -v)(_p90([-x["ratio"] for x in d if x["ratio"] is not None])),
n_fail=int(sum(x["fail"] for x in d)), n_unmeasured=int(sum(x["measured"] is None for x in d)),
fail_ids=[m["id"] for m in st if m["direction"]["fail"]],
dy_med=_med([x["measured"][1] for x in d if x["measured"] is not None]))
s["old_report_world_rope_5k"] = OLD_REPORT
s["walk_speed_px_s_x"] = walk_x
return s
def stage_measure(args, run_dir: str, plan: list[dict]) -> dict:
vdir = os.path.join(run_dir, "videos")
items = []
n_missing = 0
for p in plan:
if video_done(vdir, p["id"]):
items.append(dict(p, mp4=os.path.join(vdir, p["id"] + ".mp4")))
else:
n_missing += 1
print(f"[measure] {len(items)} 条视频,{n_missing} 条缺失/未完成 · {args.workers} 进程", flush=True)
t0 = time.time()
with ProcessPoolExecutor(args.workers) as ex:
metrics = list(ex.map(_measure_job, items, chunksize=1))
print(f"[measure] 度量完成 · {time.time() - t0:.0f}s", flush=True)
with open(os.path.join(run_dir, "metrics.jsonl"), "w", encoding="utf-8") as fh:
for m in metrics:
fh.write(json.dumps(m, ensure_ascii=False, default=float) + "\n")
return dict(metrics=metrics, measure_sec=time.time() - t0, n_missing=n_missing)
# ------------------------------------------------------------------ 合成自检 ----
def synth_video(img: np.ndarray, bg_shift: np.ndarray) -> np.ndarray:
"""按逐帧"背景位移"(帧 i 相对帧 0,四舍五入到整数 px)造 81 帧视频。
背景位移 t ⇒ 帧 k 位置 s 的内容在帧 0 位于 s − t ⇒ 帧 k = 画布上从 x0 − tx 起的窗口。
横向用 [镜像 | img | 镜像] 的 3W 宽画布滑窗(非周期,|位移| ≤ 832 px 不会像 np.roll 那样回绕成 t ∓ W),
纵向位移小,直接 np.roll。"""
h, w = img.shape[:2]
canvas = np.concatenate([img[:, ::-1], img, img[:, ::-1]], 1)
out = np.empty((bg_shift.shape[0],) + img.shape, np.uint8)
for i, (tx, ty) in enumerate(bg_shift):
x0 = w - int(round(tx))
if not 0 <= x0 <= 2 * w:
raise ValueError(f"合成横向位移 {tx} 超出画布")
out[i] = np.roll(canvas[:, x0:x0 + w], int(round(ty)), axis=0)
return out
def selfcheck(walk_x: float, bg_path: str) -> dict:
"""度量代码在合成平移视频上的自检:速度 / 位移 / 倒退帧 / 残差 / 补偿都应逐位可预期。"""
from actionrope.infer import load_first_frame
img = load_first_frame(bg_path)
res = {"bg": os.path.basename(bg_path)}
# A. 直线右走 1.0×:每帧背景 −4.33 px(整数取整后 −4/−5 交替),无倒退帧,0→80 = −346
fo = straight_offsets(walk_x)
fr = synth_video(img, -fo)
m = measure_one(dict(id="synth_straight", exam="straight", bg=0, mult=1.0, frame_offset_px=fo.tolist()), fr)
exp_v = np.diff(np.round(-fo[:, 0]))
vx = np.array([np.nan if v is None else v for v in m["smooth"]["vx"]])
res["straight"] = dict(expected_dx_80=float(round(-fo[80, 0])), measured_dx_80=m["direction"]["measured"][0],
measured_dy_80=m["direction"]["measured"][1], angle_deg=m["direction"]["angle_deg"],
ratio=m["direction"]["ratio"], backward_frames=m["smooth"]["backward_frames"],
expected_backward_frames=0, n_unmeasured=m["smooth"]["n_unmeasured"],
n_skip2=m["smooth"]["n_skip2"],
vel_max_abs_err_px=float(np.nanmax(np.abs(vx - exp_v))),
speed_ratio=m["smooth"]["speed_ratio"], speed_cv=m["smooth"]["speed_cv"],
expected_speed_cv=float(exp_v.std() / abs(exp_v.mean())), jerk_norm=m["smooth"]["jerk_norm"])
# B. 直线右走但帧 20/21/22 各倒退 3 px(3 个倒退帧),再继续
bg = -fo[:, 0].copy()
bg = np.round(bg)
bg[20:] += 3 * 3 # 从帧 20 起整体少走 9 px
bg[20] = bg[19] + 3; bg[21] = bg[20] + 3; bg[22] = bg[21] + 3
shift = np.stack([bg, np.zeros_like(bg)], 1)
fr = synth_video(img, shift)
m = measure_one(dict(id="synth_backward", exam="straight", bg=0, mult=1.0, frame_offset_px=fo.tolist()), fr)
res["backward"] = dict(expected_backward_frames=3, backward_frames=m["smooth"]["backward_frames"],
backward_frames_1px=m["smooth"]["backward_frames_1px"],
expected_dx_80=float(bg[80]), measured_dx_80=m["direction"]["measured"][0],
vx_frames_19_23=[m["smooth"]["vx"][i] for i in range(19, 24)])
# C. 折返:右 40 帧回 40 帧,终帧 = 首帧(roll 回绕)⇒ 残差 0、PSNR 99、NCC 1;实走距离 ≈ 256
fo_b = back_offsets(walk_x * BACK_MULT)
fr = synth_video(img, -fo_b)
m = measure_one(dict(id="synth_back", exam="back", bg=0, mult=BACK_MULT, frame_offset_px=fo_b.tolist(), turn_frame=BACK_TURN), fr)
res["back_exact"] = dict(psnr=m["psnr"], ssim=m["ssim"], ncc=m["ncc"], resid_px=m["resid_px"], ncc_comp=m["ncc_comp"],
walk_dx=m["walk_dx"], expected_walk_dx=m["expected_walk_dx"])
# D. 折返但终帧多走了 (+12, −5) px(回归残差应 ≈ 13 px;补偿后 NCC 应回到 ≈1,补偿前明显低)
shift = -fo_b.copy()
shift[80] += (12, -5)
fr = synth_video(img, shift)
m = measure_one(dict(id="synth_back_resid", exam="back", bg=0, mult=BACK_MULT, frame_offset_px=fo_b.tolist(), turn_frame=BACK_TURN), fr)
res["back_resid"] = dict(expected_resid=[12, -5], resid_dx=m["resid_dx"], resid_dy=m["resid_dy"], resid_px=m["resid_px"],
expected_resid_px=float(math.hypot(12, 5)), psnr=m["psnr"], ncc=m["ncc"], ncc_comp=m["ncc_comp"],
psnr_comp=m.get("psnr_comp"))
# E. 折返 + 终帧整体变亮 20%(明暗漂移)⇒ 明暗对齐后 PSNR/NCC 仍应接近 exact
fr = synth_video(img, -fo_b)
fr[80] = np.clip(fr[80].astype(np.float64) * 1.2 + 10, 0, 255).astype(np.uint8)
m = measure_one(dict(id="synth_back_bright", exam="back", bg=0, mult=BACK_MULT, frame_offset_px=fo_b.tolist(), turn_frame=BACK_TURN), fr)
res["back_bright"] = dict(psnr=m["psnr"], ssim=m["ssim"], ncc=m["ncc"], resid_px=m["resid_px"])
# F. 增益:5 档合成 ⇒ 斜率 1、R² 1
pts = []
for mult in GAIN_MULTS:
fo_g = straight_offsets(walk_x * mult)
fr = synth_video(img, -fo_g)
m = measure_one(dict(id=f"synth_gain_{mult}", exam="gain", bg=0, mult=mult, frame_offset_px=fo_g.tolist()), fr)
pts.append((-m["commanded_dx"], -m["measured_dx"]))
x = np.array([p[0] for p in pts]); y = np.array([p[1] for p in pts])
res["gain"] = dict(fit_origin(x, y), points=[[float(a), float(b)] for a, b in pts])
# 判定
checks = dict(
straight_dx=abs(res["straight"]["measured_dx_80"] - res["straight"]["expected_dx_80"]) < 3,
straight_backward=res["straight"]["backward_frames"] == 0,
straight_vel=res["straight"]["vel_max_abs_err_px"] < 1.0,
backward_count=res["backward"]["backward_frames"] == 3,
back_exact=res["back_exact"]["resid_px"] < 1.5 and res["back_exact"]["ncc"] > 0.99,
back_resid=abs(res["back_resid"]["resid_px"] - res["back_resid"]["expected_resid_px"]) < 1.5 and res["back_resid"]["ncc_comp"] > 0.98,
back_bright=res["back_bright"]["ncc"] > 0.99 and res["back_bright"]["psnr"] > 35,
gain=abs(res["gain"]["slope"] - 1) < 0.02 and res["gain"]["r2"] > 0.999,
)
res["checks"] = checks
res["all_pass"] = all(checks.values())
return res
# ------------------------------------------------------------------ 报告 / 图 ----
def make_figure(summary: dict, metrics: list[dict], path: str):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 3, figsize=(15, 4.4))
ax = axes[0]
g = summary.get("gain")
if g:
pts = np.array(g["points"])
ax.scatter(pts[:, 0], pts[:, 1], s=18, alpha=0.7, label="videos")
xs = np.linspace(0, pts[:, 0].max() * 1.05, 10)
ax.plot(xs, g["slope"] * xs, "r-", label=f"fit: slope {g['slope']:.3f}, R² {g['r2']:.3f}")
ax.plot(xs, xs, "k--", lw=0.8, label="identity")
ax.set_xlabel("commanded |dx| (px, frame 0→80)"); ax.set_ylabel("measured |dx| (px)")
ax.legend(fontsize=8)
ax.set_title("1. gain linearity (0.5–1.5×, 10 bg)")
ax = axes[1]
b = summary.get("back")
if b:
ax.hist(b["psnr_all"], bins=20, color="tab:green", alpha=0.8)
ax.axvline(b["psnr_med"], color="r", label=f"median {b['psnr_med']:.1f} dB")
ax.set_xlabel("PSNR(frame 80, frame 0) dB, outside center 160²"); ax.legend(fontsize=8)
ax.set_title("2. return-trip PSNR (50 bg)")
ax = axes[2]
sm = summary.get("smooth")
if sm:
bf = sm["backward_frames_per_video"]
ax.hist(bf, bins=range(0, max(bf + [1]) + 2), color="tab:orange", alpha=0.8, align="left")
ax.set_xlabel("backward frames per video"); ax.set_ylabel("videos")
ax.set_title(f"3. backward frames (total {sm['backward_frames_total']} / 50 bg)")
fig.tight_layout()
fig.savefig(path, dpi=130)
plt.close(fig)
def _f(v, nd=2):
return "—" if v is None else (f"{v:.{nd}f}" if isinstance(v, float) else str(v))
def make_report(summary: dict, sc: dict | None, gen_stat: dict | None, run: str, ckpt: str | None) -> str:
o = OLD_REPORT
g, b, sm, d = summary.get("gain", {}), summary.get("back", {}), summary.get("smooth", {}), summary.get("direction", {})
L = [f"# bg50 四科判决 · run `{run}`", "",
f"ckpt `{ckpt}` · 文本条件 NULL_PROMPT · 50 步 shift 5 · bf16 · seed 固定 · 首帧 26:15 居中裁剪后缩放到 832×480。",
f"横向步速 walk_speed_px_s[0] = {summary['walk_speed_px_s_x']:.2f} px/s(1.0×);折返/直行只用横向指令,纵向为 0"
"(纵向增益已知偏大 ~25%,见 README 已知问题 3)。位移量法:SIFT+RANSAC 仿射场在玩家位置取值(`verify_sidecar.measure_shift`),"
"帧 0→k 直接测不出时每 16 帧链式累加(`infer.measure_displacement`)。", ""]
if gen_stat:
L += [f"生成:{gen_stat['n_plan']} 条计划,本次生成 {gen_stat['n_generated_now']} 条,墙钟 {gen_stat['gen_wall_sec']:.0f} s,"
f"{len(gen_stat['gpus'])} 卡,失败 {gen_stat['n_failed']} 条。", ""]
L += ["## 汇总(与旧报告世界 RoPE@5k 并列)", "",
"| 科目 | 指标 | 本次 step-5000 | 旧报告 世界RoPE@5k |", "|---|---|---|---|",
f"| 1 增益线性 | 斜率 | {_f(g.get('slope'), 3)} | {o['gain_slope']} |",
f"| 1 增益线性 | R² | {_f(g.get('r2'), 3)} | {o['gain_r2']} |",
f"| 1 增益线性 | 样本数 / 测不出 | {g.get('n')} / {g.get('n_unmeasured')} | — |",
f"| 2 折返闭环 | PSNR 中位 (dB) | {_f(b.get('psnr_med'))} | {_f(o['back_psnr_med'])} |",
f"| 2 折返闭环 | SSIM 中位 | {_f(b.get('ssim_med'), 3)} | {_f(o['back_ssim_med'])} |",
f"| 2 折返闭环 | NCC 中位 | {_f(b.get('ncc_med'), 3)} | {_f(o['back_ncc_med'])} |",
f"| 2 折返闭环 | 回归残差中位 (px) / p90 | {_f(b.get('resid_px_med'), 1)} / {_f(b.get('resid_px_p90'), 1)} | {_f(o['back_resid_med'])} |",
f"| 2 折返闭环 | 补偿后 NCC 中位 | {_f(b.get('ncc_comp_med'), 3)} | {_f(o['back_ncc_comp_med'])} |",
f"| 2 折返闭环 | 实走距离中位 (px, 帧 0→40) / 指令 | {_f(b.get('walk_px_med'), 1)} / {_f(abs(b.get('expected_walk_dx') or 0), 1)} | — |",
f"| 3 运动平滑 | 倒退帧总数(50 景) | {sm.get('backward_frames_total')}(>1 px:{sm.get('backward_frames_1px_total')};含倒退帧的视频 {sm.get('n_videos_with_backward')}) | {o['smooth_backward_frames_total']} |",
f"| 3 运动平滑 | 归一速度波动 std/mean 中位 / p90 | {_f(sm.get('speed_cv_med'), 3)} / {_f(sm.get('speed_cv_p90'), 3)} | {_f(o['smooth_speed_cv_med'])} |",
f"| 3 运动平滑 | 归一 jerk 中位 / p90 | {_f(sm.get('jerk_norm_med'), 3)} / {_f(sm.get('jerk_norm_p90'), 3)} | {_f(o['smooth_jerk_med'])} |",
f"| 3 运动平滑 | 平均速度/承诺 中位 | {_f(sm.get('speed_ratio_med'), 3)}(承诺 {_f(sm.get('commit_px_per_frame'))} px/帧) | {_f(o['smooth_speed_ratio_med'])} |",
f"| 4 方向服从 | \\|角度误差\\| 中位 / p90 (°) | {_f(d.get('angle_abs_med'))} / {_f(d.get('angle_abs_p90'))} | {_f(o['dir_angle_med'])} |",
f"| 4 方向服从 | 幅度比中位 | {_f(d.get('ratio_med'), 3)} | {_f(o['dir_ratio_med'])} |",
f"| 4 方向服从 | 失控数(\\|角\\|>15° 或 幅<0.5)/ 测不出 | {d.get('n_fail')} / {d.get('n_unmeasured')} | {_f(o['dir_fail'])} |",
""]
rb = g.get("ratio_by_mult", {})
L += ["### 第一科 增益线性", "",
"各档实测/指令幅度比中位:" + "、".join(f"{k}× {_f(v['ratio_med'], 3)}(n={v['n']})" for k, v in rb.items()) + "。",
f"同一批 10 个背景、只改指令位移,实测帧 0→80 横向位移对指令位移过原点回归斜率 {_f(g.get('slope'), 3)}、R² {_f(g.get('r2'), 3)}"
f"(旧报告 {o['gain_slope']} / {o['gain_r2']})。",
("斜率 <1:1.5× 档幅度比明显掉到 " + _f(rb.get('1.50', {}).get('ratio_med'), 3) + ",高速档欠冲拉低了斜率" if (g.get('slope') or 1) < 1 else
"斜率 >1:模型在未见背景上仍有系统性超调") + ",与 README 里训练场景 tidal_flats 首帧的三档斜率 ≈1.05 不同 —— 未见背景下增益略欠、离散更大;"
f"R² 反映不同背景之间的离散(残差 RMS {_f(g.get('resid_rms_px'), 1)} px)。散点里明显偏低的一组点全来自背景 {(g.get('worst_bg') if g.get('worst_bg') is not None else -1):02d}"
f"(幅度比中位 {_f(g.get('leave_one_bg_out', {}).get(str(g.get('worst_bg')), {}).get('ratio_med'), 3)}):生成画面带纵深视差"
"(bg 01 赛博城市:远景楼群带比地面带慢 ~15%),SIFT 仿射场在玩家位置取值时各段锁到不同层,16 帧链式给 −235 px 而逐帧速度累加给 −391 px(指令 −433);"
f"这是量法在视差场景下的失稳而非模型不动。剔除该背景后斜率 {_f(g.get('slope_wo_worst'), 3)}、R² {_f(g.get('r2_wo_worst'), 3)}(作敏感性参考,头条仍按旧报告口径全量报)。", "",
"### 第二科 折返闭环", "",
f"{BACK_MULT}× 步速右走 40 帧再左走 40 帧,frame_offset[80] = 0。守门:帧 0→40 实走距离中位 {_f(b.get('walk_px_med'), 1)} px"
f"(指令 {_f(abs(b.get('expected_walk_dx') or 0), 1)} px,{b.get('n_walk_short')} 条 <50%,{b.get('n_walk_unmeasured')} 条测不出),确实走出去了。",
f"终帧 vs 首帧(明暗线性对齐、剔除中心 160×160):PSNR 中位 {_f(b.get('psnr_med'))} dB、SSIM {_f(b.get('ssim_med'), 3)}、NCC {_f(b.get('ncc_med'), 3)};"
f"回归残差中位 {_f(b.get('resid_px_med'), 1)} px(dx 中位 {_f(b.get('resid_dx_med'), 1)}、dy 中位 {_f(b.get('resid_dy_med'), 1)}),"
f"按残差平移对齐后 NCC {_f(b.get('ncc_comp_med'), 3)}(PSNR {_f(b.get('psnr_comp_med'))} dB)。",
"补偿后 NCC 与补偿前的差距是位置误差贡献;补偿后仍达不到 1 的部分是往返期间生成内容对首帧的遗忘 / 重绘。", "",
"### 第三科 运动平滑", "",
f"1.0× 直线右走 50 条,逐帧水平速度:相邻帧 SIFT 成功 {sm.get('frames_adjacent')} 帧、退化隔 2 帧 {sm.get('frames_skip2')}、测不出 {sm.get('frames_unmeasured')}。",
f"倒退帧(指令右走而实测背景速度翻正)总数 {sm.get('backward_frames_total')}(旧报告 {o['smooth_backward_frames_total']}),>1 px 的 {sm.get('backward_frames_1px_total')};"
f"归一速度波动中位 {_f(sm.get('speed_cv_med'), 3)}、归一 jerk(速度二阶差分 RMS/|均速|)中位 {_f(sm.get('jerk_norm_med'), 3)},"
f"平均速度/承诺中位 {_f(sm.get('speed_ratio_med'), 3)}。",
f"倒退帧全部是幅值 ≤ {_f(sm.get('backward_max_px'))} px 的停顿帧(>1 px 的为 0),集中在 cell 边界(帧 20→21、42→43),不是真正的往回走。"
f"逐帧速度呈稳定的帧内节奏(各视频均值序列前 8 帧 {[round(v, 1) for v in sm.get('vx_mean_by_frame', [])[:8]]} px/帧,跨视频 std 仅 0.4–1.1 px),"
f"来自因果 VAE 每 4 帧一个 latent cell 的时间压缩;按 cell(4 帧)平均后速度波动中位降到 {_f(sm.get('speed_cv_cell_med'), 3)}"
"(合成整数平移的下限 0.108),即宏观速度平稳、微观有周期抖动;jerk 同理主要反映这个节奏,只宜横向比较。", "",
"### 第四科 方向服从", "",
f"同一批直线右走视频,帧 0→80 累计位移(链式 SIFT)与指令 (−{abs(d.get('expected', [0])[0]) if d.get('expected') else 0:.0f}, 0) 的角度误差 |角| 中位 {_f(d.get('angle_abs_med'))}°、p90 {_f(d.get('angle_abs_p90'))}°"
f"(带符号中位 {_f(d.get('angle_signed_med'))}°,纵向漂移 dy 中位 {_f(d.get('dy_med'), 1)} px),幅度比中位 {_f(d.get('ratio_med'), 3)},"
f"失控 {d.get('n_fail')} 条({', '.join(d.get('fail_ids', [])) or '无'}),测不出 {d.get('n_unmeasured')} 条。",
"角度误差主要来自纵向漂移(指令纵向为 0),幅度比与第一科 1.0× 档一致。", ""]
if sc:
st, bw, be, br, bb, gg = sc["straight"], sc["backward"], sc["back_exact"], sc["back_resid"], sc["back_bright"], sc["gain"]
L += ["## 度量代码合成数据自检", "",
f"用 `{sc['bg']}` 的 832×480 首帧按指令 np.roll 平移造视频(整数 px),全部通过:**{sc['all_pass']}**({sc['checks']})。", "",
"| 合成用例 | 预期 | 实测 |", "|---|---|---|",
f"| 直线 1.0× 帧 0→80 dx | {st['expected_dx_80']:.0f} | {st['measured_dx_80']:.1f}(dy {st['measured_dy_80']:.1f},角 {st['angle_deg']:.2f}°,幅比 {st['ratio']:.3f}) |",
f"| 直线 1.0× 逐帧速度 | −4/−5 交替,倒退帧 0 | 最大逐帧误差 {st['vel_max_abs_err_px']:.2f} px,倒退帧 {st['backward_frames']},测不出 {st['n_unmeasured']},隔 2 帧 {st['n_skip2']} |",
f"| 直线 1.0× 速度 std/mean | {st['expected_speed_cv']:.3f}(整数取整固有) | {st['speed_cv']:.3f};jerk {st['jerk_norm']:.3f};速度/承诺 {st['speed_ratio']:.3f} |",
f"| 注入 3 个倒退帧(+3 px) | 倒退帧 3,dx80 {bw['expected_dx_80']:.0f} | 倒退帧 {bw['backward_frames']}(>1 px {bw['backward_frames_1px']}),dx80 {bw['measured_dx_80']:.1f},帧 19–23 速度 {[round(v, 1) for v in bw['vx_frames_19_23']]} |",
f"| 折返精确回原点 | 残差 0,PSNR 99,NCC 1,实走 {abs(be['expected_walk_dx']):.0f} | 残差 {be['resid_px']:.2f} px,PSNR {be['psnr']:.1f},SSIM {be['ssim']:.3f},NCC {be['ncc']:.4f},补偿后 {be['ncc_comp']:.4f},实走 dx {be['walk_dx']:.1f} |",
f"| 折返终帧多走 (+12, −5) | 残差 13.0 px,补偿后 NCC ≈1 | 残差 ({br['resid_dx']:.1f}, {br['resid_dy']:.1f}) = {br['resid_px']:.1f} px,PSNR {br['psnr']:.1f},NCC {br['ncc']:.3f} → 补偿后 {br['ncc_comp']:.4f}(PSNR {br['psnr_comp']:.1f}) |",
f"| 折返 + 终帧亮 20% | 明暗对齐后 ≈ 精确用例 | PSNR {bb['psnr']:.1f},SSIM {bb['ssim']:.3f},NCC {bb['ncc']:.4f},残差 {bb['resid_px']:.2f} |",
f"| 增益 5 档合成 | 斜率 1,R² 1 | 斜率 {gg['slope']:.4f},R² {gg['r2']:.5f} |", ""]
L += ["## 文件", "", "`plan.jsonl`(每条视频的 id / 背景 / 指令 / frame_offset)、`videos/<id>.mp4|json`、`metrics.jsonl`、`summary.json`、`figures.png`、`selfcheck.json`。"]
return "\n".join(L)
# ------------------------------------------------------------------ 入口 ----
def main(argv=None):
ap = argparse.ArgumentParser(description="bg50 四科判决(ARoPE 臂)")
ap.add_argument("--ckpt", default=os.path.join(ROOT, "outputs", "arope_5k", "step-5000.safetensors"))
ap.add_argument("--run", default="step5000")
ap.add_argument("--stages", default="gen,measure", help="gen,measure 的子集")
ap.add_argument("--gpus", default="0-7")
ap.add_argument("--steps", type=int, default=50)
ap.add_argument("--shift", type=float, default=5.0)
ap.add_argument("--bgs", default=None, help="背景序号,如 1-10,15;默认全部 50")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--scene", default=None, help="人为覆盖文本条件(scene key 或 'in a ...' 从句);默认 NULL_PROMPT")
ap.add_argument("--text_table", default=None)
ap.add_argument("--workers", type=int, default=48, help="度量阶段 CPU 进程数")
ap.add_argument("--selfcheck", action="store_true", help="只跑合成数据自检")
ap.add_argument("--no_selfcheck", action="store_true", help="measure 阶段跳过自检")
ap.add_argument("--_worker", action="store_true", help=argparse.SUPPRESS)
ap.add_argument("--rank", type=int, default=0, help=argparse.SUPPRESS)
ap.add_argument("--world", type=int, default=1, help=argparse.SUPPRESS)
args = ap.parse_args(argv)
run_dir = os.path.join(OUT_ROOT, args.run)
os.makedirs(run_dir, exist_ok=True)
if args._worker:
gen_worker(run_dir, args.ckpt, args.rank, args.world, args.steps, args.shift, args.scene, args.text_table)
return
import torch
from actionrope.infer import SIDECAR
sc_meta = torch.load(SIDECAR, map_location="cpu", weights_only=False)["meta"]
walk_x = float(sc_meta["walk_speed_px_s"][0])
rows = parse_bgs(args.bgs, list_backgrounds())
if args.selfcheck:
res = selfcheck(walk_x, rows[0]["path"])
print(json.dumps(res, ensure_ascii=False, indent=1, default=float))
with open(os.path.join(run_dir, "selfcheck.json"), "w", encoding="utf-8") as fh:
json.dump(res, fh, ensure_ascii=False, indent=1, default=float)
return res
stages = [s.strip() for s in args.stages.split(",") if s.strip()]
plan = build_plan(rows, walk_x, args.seed)
plan_path = os.path.join(run_dir, "plan.jsonl")
if os.path.isfile(plan_path):
old = [json.loads(l) for l in open(plan_path, encoding="utf-8") if l.strip()]
old_ids = {p["id"]: p for p in old}
for p in plan: # 计划必须与已有的一致,否则续跑会把不同指令的视频当成同一条
if p["id"] in old_ids and not np.allclose(old_ids[p["id"]]["frame_offset_px"], p["frame_offset_px"]):
raise RuntimeError(f"{plan_path} 里 {p['id']} 的指令与本次不同,换 --run")
with open(plan_path, "w", encoding="utf-8") as fh:
for p in plan:
fh.write(json.dumps(p, ensure_ascii=False) + "\n")
print(f"[plan] {len(plan)} 条(gain {sum(p['exam'] == 'gain' for p in plan)} / back {sum(p['exam'] == 'back' for p in plan)} / "
f"straight {sum(p['exam'] == 'straight' for p in plan)})· walk_x {walk_x:.2f} px/s · run_dir {run_dir}", flush=True)
gen_stat = None
if "gen" in stages:
gen_stat = stage_gen(args, run_dir)
elif os.path.isfile(os.path.join(run_dir, "gen_stat.json")):
with open(os.path.join(run_dir, "gen_stat.json")) as fh:
gen_stat = json.load(fh)
if "measure" in stages:
sc = None
sc_path = os.path.join(run_dir, "selfcheck.json")
if args.no_selfcheck and os.path.isfile(sc_path):
with open(sc_path, encoding="utf-8") as fh:
sc = json.load(fh)
print(f"[selfcheck] 复用 {sc_path}(all_pass={sc['all_pass']})", flush=True)
elif not args.no_selfcheck:
t0 = time.time()
sc = selfcheck(walk_x, rows[0]["path"])
sc["sec"] = time.time() - t0
with open(sc_path, "w", encoding="utf-8") as fh:
json.dump(sc, fh, ensure_ascii=False, indent=1, default=float)
print(f"[selfcheck] all_pass={sc['all_pass']} {sc['checks']} · {sc['sec']:.0f}s", flush=True)
mres = stage_measure(args, run_dir, plan)
summary = summarize(mres["metrics"], walk_x)
summary.update(run=args.run, ckpt=args.ckpt, n_plan=len(plan), n_videos_missing=mres["n_missing"],
measure_sec=mres["measure_sec"], gen_stat=gen_stat,
selfcheck_all_pass=None if sc is None else sc["all_pass"], seed=args.seed, steps=args.steps,
prompt=args.scene or "NULL_PROMPT")
with open(os.path.join(run_dir, "summary.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=1, default=float)
make_figure(summary, mres["metrics"], os.path.join(run_dir, "figures.png"))
rep = make_report(summary, sc, gen_stat, args.run, args.ckpt)
with open(os.path.join(run_dir, "report.md"), "w", encoding="utf-8") as fh:
fh.write(rep)
print(rep)
return summary
if __name__ == "__main__":
main()