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()