File size: 11,313 Bytes
880dff9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | """actionrope/geometry.py 的纯 CPU 单测:帧/cell 映射、known/new 掩码几何、日志采样、转场连续性。
/opt/dlami/nvme/zhiyangdeng/ActionRoPE/.venv/bin/python -m pytest tests/test_geometry.py
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from actionrope import geometry as G # noqa: E402
# ------------------------------------------------------------ cell ↔ 帧 ----
def test_cell_frames_partition():
"""21 个 cell 恰好不重不漏地覆盖 81 帧,cell 0 只含帧 0。"""
assert G.cell_frames(0) == [0]
assert G.cell_frames(1) == [1, 2, 3, 4]
assert G.cell_frames(20) == [77, 78, 79, 80]
seen = [f for k in range(G.CELLS) for f in G.cell_frames(k)]
assert seen == list(range(G.FRAMES))
with pytest.raises(ValueError):
G.cell_frames(21)
def test_frames_to_cells_mean():
off = np.zeros((G.FRAMES, 2))
off[:, 0] = np.arange(G.FRAMES) * 2.0 # dx 每帧 +2
off[:, 1] = -np.arange(G.FRAMES) * 0.5 # dy 每帧 −0.5
cells = G.frames_to_cells(off)
assert cells.shape == (G.CELLS, 2)
assert np.allclose(cells[0], 0.0)
# cell 1 = 帧 1..4,均值帧号 2.5
assert np.allclose(cells[1], [5.0, -1.25])
assert np.allclose(cells[20], [np.mean([77, 78, 79, 80]) * 2.0, -np.mean([77, 78, 79, 80]) * 0.5])
with pytest.raises(ValueError):
G.frames_to_cells(np.zeros((80, 2)))
# ------------------------------------------------------------ known / new ----
def test_known_mask_zero_offset_all_known():
off = np.zeros((3, 2))
assert G.known_mask_tokens(off).all()
assert G.known_mask_latent(off).all()
assert G.known_mask_tokens(off).shape == (3, G.TOK_H, G.TOK_W)
assert G.known_mask_latent(off).shape == (3, G.LAT_H, G.LAT_W)
def test_known_mask_dx_plus_64_right_two_columns_new():
"""玩家右走 64 px ⇒ 背景左移 ⇒ 屏幕最右 2 列 token 是首帧没见过的。"""
m = G.known_mask_tokens(np.array([[64.0, 0.0]]))[0]
assert not m[:, -2:].any()
assert m[:, :-2].all()
# latent 分辨率:64 px = 4 个 latent 像素
ml = G.known_mask_latent(np.array([[64.0, 0.0]]))[0]
assert not ml[:, -4:].any()
assert ml[:, :-4].all()
def test_known_mask_dy_minus_32_top_row_new():
"""玩家上走 32 px(dy<0)⇒ 背景下移 ⇒ 顶部 1 行 token 是新的。"""
m = G.known_mask_tokens(np.array([[0.0, -32.0]]))[0]
assert not m[0].any()
assert m[1:].all()
ml = G.known_mask_latent(np.array([[0.0, -32.0]]))[0]
assert not ml[:2].any()
assert ml[2:].all()
def test_known_mask_fractional_and_boundary():
"""半格偏移落在闭区间边界上仍算 known;再多一点就是 new。"""
m = G.known_mask_tokens(np.array([[16.0, 0.0]]))[0] # 0.5 token
assert m.all()
m = G.known_mask_tokens(np.array([[16.5, 0.0]]))[0]
assert not m[:, -1].any() and m[:, :-1].all()
# 左上方向:dx=−40 ⇒ −1.25 token ⇒ 只有最左 1 列 new(j=0 → −1.25 < −0.5;j=1 → −0.25 ≥ −0.5 仍 known)
m = G.known_mask_tokens(np.array([[-40.0, 0.0]]))[0]
assert not m[:, 0].any() and m[:, 1:].all()
m = G.known_mask_tokens(np.array([[-48.0, 0.0]]))[0] # −1.5 ⇒ j=1 恰在边界 −0.5,known
assert not m[:, 0].any() and m[:, 1:].all()
m = G.known_mask_tokens(np.array([[-49.0, 0.0]]))[0]
assert not m[:, :2].any() and m[:, 2:].all()
def test_known_mask_per_frame_independent():
off = np.array([[0.0, 0.0], [64.0, 0.0], [0.0, -32.0], [1000.0, 0.0]])
m = G.known_mask_tokens(off)
assert m[0].all()
assert not m[1][:, -2:].any()
assert not m[2][0].any()
assert not m[3].any() # 位移超过整幅 ⇒ 全 new
# ------------------------------------------------------------ 转场连续性 ----
def test_transition_offsets_continuity():
rng = np.random.default_rng(0)
a = np.cumsum(rng.normal(size=(G.FRAMES, 2)), 0); a -= a[0]
b = np.cumsum(rng.normal(size=(G.FRAMES, 2)), 0); b -= b[0]
n_pre = 17
out = G.transition_frame_offsets(a, b, n_pre)
assert out.shape == (G.FRAMES, 2)
assert np.allclose(out[:n_pre], a[:n_pre])
# 接缝处的增量来自 B:off[n_pre] − off[n_pre−1] = B[n_pre] − B[n_pre−1]
assert np.allclose(out[n_pre] - out[n_pre - 1], b[n_pre] - b[n_pre - 1])
assert np.allclose(np.diff(out[n_pre:], axis=0), np.diff(b[n_pre:], axis=0))
assert np.allclose(out[0], 0.0)
# 全程与 A 或 B 同源时退化为原序列
assert np.allclose(G.transition_frame_offsets(a, a, n_pre), a)
with pytest.raises(ValueError):
G.transition_frame_offsets(a, b, 0)
# ------------------------------------------------------------ 日志采样 ----
def _synthetic_tracks(tmp_path, offset_segments, M_px1280, n=400, dt=0.05, gap=None):
"""造一个最小 session:直线匀速走 x,可选在中间挖一段日志断档。"""
vt = np.arange(n) * dt
x = 2.0 * vt # 2 世界单位/s
y = np.zeros(n); z = np.zeros(n)
if gap is not None:
keep = (vt < gap[0]) | (vt > gap[1])
vt, x, y, z = vt[keep], x[keep], y[keep], z[keep]
# 故意加重复时间戳,load_session_tracks 必须去掉
vt = np.concatenate([vt, vt[:3]]); x = np.concatenate([x, x[:3]])
y = np.concatenate([y, y[:3]]); z = np.concatenate([z, z[:3]])
d = tmp_path / "sess"
d.mkdir()
np.savez(d / "state.npz", vt=vt, x=x.astype(np.float32), y=y.astype(np.float32),
z=z.astype(np.float32), qf=np.arange(vt.size), rg=np.zeros(vt.size, np.int32))
(d / "offsets.json").write_text(json.dumps(dict(segments=offset_segments, M_px1280=M_px1280)))
return G.load_session_tracks(str(tmp_path), "sess")
def test_load_session_tracks_dedupe_and_scale(tmp_path):
M = [[-40.0, 0.0], [0.0, 18.0], [0.0, 27.0]]
tr = _synthetic_tracks(tmp_path, [dict(vt_lo=0.0, vt_hi=1e12, offset=0.1)], M)
assert tr["vt"].size == 400 and np.all(np.diff(tr["vt"]) > 0)
assert np.allclose(tr["M_px832"][:, 0], np.array(M)[:, 0] * 832 / 1280)
assert np.allclose(tr["M_px832"][:, 1], np.array(M)[:, 1] * 480 / 720)
assert np.allclose(tr["vid_t"], tr["vt"] + 0.1)
u, v = G.project_px832(tr, 1.0, 0.0, 0.0)
assert np.isclose(u, -40.0 * 832 / 1280) and np.isclose(v, 0.0)
def test_offset_piecewise_constant(tmp_path):
M = [[-40.0, 0.0], [0.0, 18.0], [0.0, 27.0]]
segs = [dict(vt_lo=0.0, vt_hi=10.0, offset=0.1), dict(vt_lo=10.0, vt_hi=1e12, offset=-4.0)]
tr = _synthetic_tracks(tmp_path, segs, M)
assert G.offset_at(tr, 5.0) == 0.1
assert G.offset_at(tr, 10.0) == -4.0 # 段以 vt_lo 为界,右侧闭
assert G.offset_at(tr, 15.0) == -4.0
def test_sample_frame_offsets_linear_walk(tmp_path):
"""匀速沿世界 +x 走 2/s:M[0,0]=−40 ⇒ 1280 画幅下 −80 px/s ⇒ 832 画幅 −52 px/s。"""
M = [[-40.0, 0.0], [0.0, 18.0], [0.0, 27.0]]
tr = _synthetic_tracks(tmp_path, [dict(vt_lo=0.0, vt_hi=1e12, offset=0.1)], M)
off, valid, reason = G.sample_frame_offsets(tr, video_t0=2.1, return_valid=True)
assert valid and reason == ""
assert off.shape == (G.FRAMES, 2)
assert np.allclose(off[0], 0.0)
# 最近邻采样:20 Hz 日志对 16 fps 的帧最多差 0.025 s ⇒ 1.3 px
expect = -52.0 * np.arange(G.FRAMES) / G.FPS
assert np.abs(off[:, 0] - expect).max() < 1.5
assert np.allclose(off[:, 1], 0.0)
assert np.isfinite(G.sample_frame_offsets(tr, 2.1)).all()
def test_sample_frame_offsets_gap_invalid(tmp_path):
M = [[-40.0, 0.0], [0.0, 18.0], [0.0, 27.0]]
tr = _synthetic_tracks(tmp_path, [dict(vt_lo=0.0, vt_hi=1e12, offset=0.0)], M, gap=(5.0, 5.6))
off, valid, reason = G.sample_frame_offsets(tr, video_t0=3.0, return_valid=True)
assert not valid and reason.startswith("log_gap")
assert np.isnan(G.sample_frame_offsets(tr, 3.0)).all()
# 阈值放宽到 0.5 s 就有效(最近邻误差最大 0.3 s)
_, valid2, _ = G.sample_frame_offsets(tr, 3.0, return_valid=True, max_sample_dt=0.5)
assert valid2
# 完全落在日志范围之外 ⇒ 断档
_, valid3, reason3 = G.sample_frame_offsets(tr, video_t0=100.0, return_valid=True)
assert not valid3 and reason3.startswith("log_gap")
def test_sample_frame_offsets_teleport_invalid(tmp_path):
M = [[-40.0, 0.0], [0.0, 18.0], [0.0, 27.0]]
tr = _synthetic_tracks(tmp_path, [dict(vt_lo=0.0, vt_hi=1e12, offset=0.0)], M)
tr["x"] = tr["x"].copy(); tr["x"][tr["vt"] > 4.0] += 50.0 # 传送 50 世界单位 = 1300 px
_, valid, reason = G.sample_frame_offsets(tr, video_t0=3.0, return_valid=True)
assert not valid and reason.startswith("teleport")
def test_sample_frame_offsets_log_jump_invalid(tmp_path):
"""offset 台阶:vt=10 处 offset 从 0 跳到 −4 ⇒ vid_t 局部非单调,跨过台阶的 clip 最近邻会跳到
4 s 之后的日志样本(时间差仍 < 0.15 s),必须按 log_jump 判 invalid。"""
M = [[-40.0, 0.0], [0.0, 18.0], [0.0, 27.0]]
segs = [dict(vt_lo=0.0, vt_hi=10.0, offset=0.0), dict(vt_lo=10.0, vt_hi=1e12, offset=-4.0)]
tr = _synthetic_tracks(tmp_path, segs, M)
assert np.diff(tr["vid_t"]).min() < 0
_, valid, reason = G.sample_frame_offsets(tr, video_t0=4.0, return_valid=True)
assert not valid and reason.startswith("log_jump")
# 台阶之前 / 之后整段落在单调区间里的 clip 不受影响
_, valid_a, _ = G.sample_frame_offsets(tr, video_t0=0.5, return_valid=True)
_, valid_b, _ = G.sample_frame_offsets(tr, video_t0=8.0, return_valid=True)
assert valid_a and valid_b
def test_action_dirs_unit():
n = np.hypot(G.ACTION_DIRS[:, 0], G.ACTION_DIRS[:, 1])
assert np.isclose(n[0], 0.0) and np.allclose(n[1:], 1.0)
assert np.allclose(G.ACTION_DIRS[7], [1.0, 0.0]) # moving right
assert np.allclose(G.ACTION_DIRS[1], [0.0, -1.0]) # moving up(屏幕 v 向下为正)
def test_shaped_loss_weight():
"""dist 形状:known=1,紧贴足迹边界 ≈1,远处到 new_weight;σ 调制只作用于额外部分。"""
import torch
from actionrope.arope import loss_weight_map
off = torch.tensor([[[0.0, 0.0], [96.0, 0.0]]]) # 帧 1 右移 96 px = 6 latent 格 ⇒ 最右 6 列 new
flat = loss_weight_map(off, 30, 52, new_weight=3.0)[0, 0]
assert flat[1, :, :46].eq(1).all() and flat[1, :, 46:].eq(3).all() and flat[0].eq(1).all()
w = loss_weight_map(off, 30, 52, new_weight=3.0, ramp_latent=4.0)[0, 0]
assert w[0].eq(1).all() and w[1, :, :46].eq(1).all()
col = w[1, 0, 46:].tolist() # 距边界 0.5,1.5,…,5.5 格 ⇒ 1+2·min(d/4,1)
assert abs(col[0] - 1.25) < 1e-5 and abs(col[1] - 1.75) < 1e-5 and abs(col[3] - 2.75) < 1e-5 and col[4] == 3.0 and col[5] == 3.0
ws = loss_weight_map(off, 30, 52, new_weight=3.0, ramp_latent=4.0, sigma=torch.tensor([1.0]))[0, 0]
assert abs(ws[1, 0, 51] - 4.0) < 1e-5 # 1 + 2·1.5
w0 = loss_weight_map(off, 30, 52, new_weight=3.0, ramp_latent=4.0, sigma=torch.tensor([0.0]))[0, 0]
assert abs(w0[1, 0, 51] - 2.0) < 1e-5 # 1 + 2·0.5
assert ws[1, :, :46].eq(1).all()
|