ActionRoPE / code /actionrope /geometry.py
teawhite's picture
add docs+code
880dff9 verified
Raw History Blame Contribute Delete
10.2 kB
"""几何工具:帧/cell 映射、日志 → 屏幕偏移采样、known/new 掩码。
sidecar 生成(actionrope/sidecar.py)和推理造指令(actionrope/infer.py)共用这一套,
避免两处各写一套时符号或分组规则悄悄不一致。
坐标约定(与 SPEC 一致):
屏幕 u 向右、v 向下,单位 px(832×480 画幅)
p_px1280 = [x, y, z] @ M_px1280 玩家的"屏幕投影",含高度项 y
p_px832 = p_px1280 * [832/1280, 480/720]
frame_offset_px[i] = p_px832(frame i) − p_px832(frame 0)
相机刚性钉在玩家上,所以背景相对屏幕的位移 = −frame_offset_px。这个符号由
actionrope/verify_sidecar.py 对视频实测,不是照抄日志。
"""
from __future__ import annotations
import json
import os
import numpy as np
FRAMES, CELLS, W, H = 81, 21, 832, 480
LAT_H, LAT_W, TOK_H, TOK_W = 30, 52, 15, 26
PX_PER_TOKEN, PX_PER_LATENT = 32, 16
FPS = 16.0
# 日志里的 M 是 1280×720 画幅下解出来的;clip 是直接 scale 到 832×480(不裁不补),
# 所以横纵各自按比例缩,横向 0.65、纵向 0.6667 —— 2.5% 的横向压缩要保留。
SCALE_1280_TO_832 = np.array([W / 1280.0, H / 720.0], np.float64)
# 采样有效性判定。日志 20 Hz,最近邻误差正常 ≤ 0.025 s;超过 0.15 s 说明日志断档。
# 玩家跑动上限 ~2.5 m/s × 27 px/m ≈ 70 px/s ≈ 4 px/帧,单帧 200 px 只可能是传送。
MAX_SAMPLE_DT = 0.15
MAX_STEP_PX = 200.0
# 相邻两帧所选日志样本的 vt 差:正常 ≤ 1/16 + 2×max_sample_dt(0.15 s 规则下 ≈ 0.36 s)。
# 远超这个量(或倒退)只会发生在 offsets.json 的 offset 台阶处(20260823 的 vt=14400,
# vid_t 局部非单调),那里的最近邻会跨过 4 s 的日志跳到另一侧,逐帧偏移出现 ~150 px 的
# 假跳变但没到 MAX_STEP_PX。阈值取 max(MAX_LOG_JUMP_S, 1/16 + 2×max_sample_dt + 0.1),
# 放宽 max_sample_dt 时随之放宽,台阶的 4 s 总是被抓住。
MAX_LOG_JUMP_S = 0.5
# 9 类动作 → 屏幕单位方向 (du, dv),索引与 data/code/scenes.py 的 ACTIONS 一致:
# 0 idle, 1 up, 2 up-left, 3 left, 4 down-left, 5 down, 6 down-right, 7 right, 8 up-right。
_S = np.sqrt(0.5)
ACTION_DIRS = np.array([
[0.0, 0.0], [0.0, -1.0], [-_S, -_S], [-1.0, 0.0], [-_S, _S],
[0.0, 1.0], [_S, _S], [1.0, 0.0], [_S, -_S],
], np.float64)
def cell_frames(k: int) -> list[int]:
"""cell k 覆盖的帧下标:cell 0 = 帧 0;cell k = 帧 4k−3 … 4k(Wan2.2 因果 VAE 的分组)。"""
if not 0 <= k < CELLS:
raise ValueError(f"cell 下标越界: {k}")
return [0] if k == 0 else list(range(4 * k - 3, 4 * k + 1))
def frames_to_cells(frame_offset_px: np.ndarray) -> np.ndarray:
"""逐帧偏移 [81,2] → 逐 cell 偏移 [21,2]:cell 内各帧取均值。cell 0 就是帧 0。"""
off = np.asarray(frame_offset_px, np.float64)
if off.shape != (FRAMES, 2):
raise ValueError(f"frame_offset_px 形状应为 ({FRAMES}, 2),得到 {off.shape}")
return np.stack([off[cell_frames(k)].mean(0) for k in range(CELLS)], 0)
def load_session_tracks(logs_dir: str, session: str) -> dict:
"""读一个 session 的日志:按 vt 排序去重的世界轨迹 + offset 分段 + 两种画幅下的 M。
与 data/code/make_clips.py 的 build_tracks 同一套处理:日志有重复时间戳,
不去重的话 gradient / 最近邻都会出问题;这里只保留 diff(vt) > 1e-6 的样本。
make_clips 用的是默认 argsort(不稳定排序),这里用 stable —— 两个 session 上核对过
保留下来的样本下标逐一相同(重复 vt 的样本本来就按写入顺序排在一起)。
"""
d = os.path.join(logs_dir, session)
st = np.load(os.path.join(d, "state.npz"))
o = np.argsort(st["vt"], kind="stable")
vt = st["vt"][o].astype(np.float64)
x = st["x"][o].astype(np.float64)
y = st["y"][o].astype(np.float64)
z = st["z"][o].astype(np.float64)
keep = np.concatenate([[True], np.diff(vt) > 1e-6])
vt, x, y, z = vt[keep], x[keep], y[keep], z[keep]
with open(os.path.join(d, "offsets.json"), encoding="utf-8") as fh:
align = json.load(fh)
segs = align["segments"]
off_los = np.array([s["vt_lo"] for s in segs], np.float64)
off_vals = np.array([s["offset"] for s in segs], np.float64)
if align.get("M_px1280") is None:
raise ValueError(f"{session} 的 offsets.json 没有 M_px1280")
M_px1280 = np.array(align["M_px1280"], np.float64)
if M_px1280.shape != (3, 2):
raise ValueError(f"M_px1280 形状应为 (3, 2),得到 {M_px1280.shape}")
M_px832 = M_px1280 * SCALE_1280_TO_832[None, :]
tracks = dict(session=session, vt=vt, x=x, y=y, z=z,
off_los=off_los, off_vals=off_vals,
M_px1280=M_px1280, M_px832=M_px832)
# video_t = vt + offset(vt)。20260823 在 vt=14400 有 −4.13 s 的台阶,vid_t 在那里
# 局部不单调;make_clips 就是在这条数组上 searchsorted 的,为了逐帧真值一致这里照做,
# 落在台阶附近的 clip 靠 MAX_SAMPLE_DT / MAX_LOG_JUMP_S 判 invalid。
tracks["vid_t"] = vt + offset_at(tracks, vt)
return tracks
def offset_at(tracks: dict, vt):
"""分段常数 offset(vt)。段以 vt_lo 为界,右闭左开与 make_clips 一致。"""
i = np.clip(np.searchsorted(tracks["off_los"], vt, side="right") - 1,
0, tracks["off_vals"].size - 1)
return tracks["off_vals"][i]
def project_px832(tracks: dict, x, y, z):
"""世界坐标 → 832×480 屏幕投影 (u, v)。支持标量或同形数组。"""
M = tracks["M_px832"]
x = np.asarray(x, np.float64); y = np.asarray(y, np.float64); z = np.asarray(z, np.float64)
u = x * M[0, 0] + y * M[1, 0] + z * M[2, 0]
v = x * M[0, 1] + y * M[1, 1] + z * M[2, 1]
return u, v
def nearest_log_index(tracks: dict, video_t: np.ndarray) -> np.ndarray:
"""按 video_t 取最近邻日志样本下标(与 make_clips.process_segment 逐帧一致)。"""
vid_t = tracks["vid_t"]
i = np.searchsorted(vid_t, video_t)
i = np.clip(i, 1, vid_t.size - 1)
prev = i - 1
return np.where(np.abs(vid_t[i] - video_t) <= np.abs(vid_t[prev] - video_t), i, prev)
def sample_frame_offsets(tracks: dict, video_t0: float, n_frames: int = FRAMES,
return_valid: bool = False, max_sample_dt: float = MAX_SAMPLE_DT):
"""clip 的逐帧相机偏移 [n_frames, 2](dx, dy),帧 0 恒为 (0, 0)。
帧 i 的 video_t = video_t0 + i/16,日志按 video_t 最近邻取样。
valid 判定(任一条不满足 ⇒ False):
· 每帧最近邻日志样本的时间差 ≤ max_sample_dt(否则是日志断档)
· 坐标无 NaN
· 所选日志样本的 vt 单调且相邻差 ≤ max(MAX_LOG_JUMP_S, 1/16 + 2·max_sample_dt + 0.1)
(否则跨过了 offset 台阶)
· 单帧位移 ≤ MAX_STEP_PX(否则是传送)
默认只返回偏移数组,且 invalid 时整个数组填 NaN —— 调用方不检查也用不到脏值。
return_valid=True 时返回 (offsets_raw, valid, reason),raw 里保留实际算出的值供排查。
"""
want_t = float(video_t0) + np.arange(n_frames, dtype=np.float64) / FPS
idx = nearest_log_index(tracks, want_t)
dt = np.abs(tracks["vid_t"][idx] - want_t)
u, v = project_px832(tracks, tracks["x"][idx], tracks["y"][idx], tracks["z"][idx])
off = np.stack([u - u[0], v - v[0]], 1)
dvt = np.diff(tracks["vt"][idx]) if n_frames > 1 else np.zeros(1)
reason = ""
if not np.isfinite(off).all():
reason = "nan"
elif dt.max() > max_sample_dt:
reason = f"log_gap dt={dt.max():.3f}s"
elif n_frames > 1 and (dvt.min() < 0 or dvt.max() > max(MAX_LOG_JUMP_S, 1.0 / FPS + 2.0 * max_sample_dt + 0.1)):
reason = f"log_jump dvt=[{dvt.min():.3f},{dvt.max():.3f}]s"
else:
step = np.hypot(*np.diff(off, axis=0).T)
if step.size and step.max() > MAX_STEP_PX:
reason = f"teleport step={step.max():.1f}px"
valid = reason == ""
if return_valid:
return off, valid, reason
return off if valid else np.full_like(off, np.nan)
def transition_frame_offsets(off_a: np.ndarray, off_b: np.ndarray, n_pre: int) -> np.ndarray:
"""转场 clip 的逐帧偏移:帧 < n_pre 抄 src_a,之后接 src_b 的增量,累计位移保持连续。
i < n_pre : off[i] = offA[i]
i >= n_pre : off[i] = offA[n_pre−1] + (offB[i] − offB[n_pre−1])
"""
off_a = np.asarray(off_a, np.float64); off_b = np.asarray(off_b, np.float64)
if off_a.shape != off_b.shape or off_a.shape[1] != 2:
raise ValueError(f"src_a/src_b 偏移形状不一致: {off_a.shape} vs {off_b.shape}")
if not 1 <= n_pre <= off_a.shape[0]:
raise ValueError(f"n_pre 越界: {n_pre}")
out = off_a.copy()
out[n_pre:] = off_a[n_pre - 1] + (off_b[n_pre:] - off_b[n_pre - 1])
return out
def _known_mask(offset_px: np.ndarray, h: int, w: int, px_per_unit: float) -> np.ndarray:
"""cell k 中格 (i_h, j_w) 的世界坐标 = (i_h + dy/px, j_w + dx/px);落在首帧足迹
[−0.5, h−0.5] × [−0.5, w−0.5] 内 ⇒ known。边界取闭区间,偏移恰为半格时仍算 known。"""
off = np.asarray(offset_px, np.float64)
if off.ndim != 2 or off.shape[1] != 2:
raise ValueError(f"offset_px 形状应为 (F, 2),得到 {off.shape}")
ih = np.arange(h, dtype=np.float64)[None, :, None] + off[:, 1, None, None] / px_per_unit
jw = np.arange(w, dtype=np.float64)[None, None, :] + off[:, 0, None, None] / px_per_unit
ok_h = (ih >= -0.5) & (ih <= h - 0.5)
ok_w = (jw >= -0.5) & (jw <= w - 0.5)
return ok_h & ok_w
def known_mask_tokens(offset_px: np.ndarray) -> np.ndarray:
"""token 分辨率 known 掩码 [F, 15, 26](token = 32 px)。"""
return _known_mask(offset_px, TOK_H, TOK_W, PX_PER_TOKEN)
def known_mask_latent(offset_px: np.ndarray) -> np.ndarray:
"""latent 分辨率 known 掩码 [F, 30, 52](latent 像素 = 16 px)。"""
return _known_mask(offset_px, LAT_H, LAT_W, PX_PER_LATENT)