Skip to content

Commit af133de

Browse files
committed
test(timing): 负费重投射回归测试 + 校准迁移测试
TestGetLogicalFrameF64: 端点/精确命中/插值/边界容差(6 测试) TestGetLogicalFrame: internal->display 转换(4 测试) TestNegativeCostReprojection: 早期帧对齐 + Rust reference 交叉验证(5 测试) TestCalibrationMigration: v1->v2 自动迁移(3 测试)
1 parent d700311 commit af133de

1 file changed

Lines changed: 269 additions & 3 deletions

File tree

tests/test_timing.py

Lines changed: 269 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
import numpy as np
66

7+
from aao import config
78
from aao.core.timing import tick
8-
from aao.core.timing.time_source import format_timer
9+
from aao.core.timing.calibration import CalibrationProfile, FullCalibrationData
10+
from aao.core.timing.time_source import TimeSource, format_timer
911

1012

1113
class TestFormatTimer:
@@ -24,8 +26,7 @@ def test_one_minute(self) -> None:
2426
assert format_timer(1800) == "01:00:00"
2527

2628
def test_mixed(self) -> None:
27-
# 1 分 2 秒 3 帧 = 60+30*2+3? 不对:total_frames=分*60*30+秒*30+帧
28-
# 1:02:03 = 1*1800 + 2*30 + 3 = 1863
29+
# 1 分 2 秒 3 帧 = 1*1800 + 2*30 + 3 = 1863
2930
assert format_timer(1863) == "01:02:03"
3031

3132
def test_custom_fps(self) -> None:
@@ -65,3 +66,268 @@ def test_all_black(self) -> None:
6566
frame = np.zeros((102, 1280, 3), dtype=np.uint8)
6667
w = tick.get_filled_pixel_width(frame, roi)
6768
assert w is None or w == 0
69+
70+
71+
class TestGetLogicalFrame:
72+
"""离散最近邻逻辑帧查找(正费路径用)。
73+
74+
pixel_map value = internal frame。返回 display = internal + 1
75+
(width=0 端点返回 0)。对应 Rust open_interior_internal_frame。
76+
"""
77+
78+
@staticmethod
79+
def _make_frame(fill_width: int, roi: tick.Roi, width: int = 200) -> np.ndarray:
80+
x1, x2, y = roi
81+
frame = np.full((y + 2, width, 3), 150, dtype=np.uint8)
82+
if fill_width > 0:
83+
frame[y, x1 : x1 + fill_width] = 255
84+
return frame
85+
86+
def test_width_zero_endpoint(self) -> None:
87+
roi = (100, 200, 100)
88+
pixel_map = {"0": 0, "3": 1, "7": 2} # internal
89+
frame = self._make_frame(0, roi)
90+
result = tick.get_logical_frame(frame, roi, pixel_map)
91+
assert result == 0
92+
93+
def test_exact_hit_returns_display(self) -> None:
94+
roi = (100, 200, 100)
95+
pixel_map = {"0": 0, "3": 1, "7": 2, "11": 3}
96+
frame = self._make_frame(3, roi)
97+
result = tick.get_logical_frame(frame, roi, pixel_map)
98+
assert result == 2 # display = internal(1) + 1
99+
100+
def test_approximate_match(self) -> None:
101+
roi = (100, 200, 100)
102+
pixel_map = {"0": 0, "10": 2, "20": 4}
103+
frame = self._make_frame(8, roi) # 距 10 差 2,容差内
104+
result = tick.get_logical_frame(frame, roi, pixel_map)
105+
assert result == 3 # display = internal(2) + 1
106+
107+
def test_beyond_tolerance(self) -> None:
108+
roi = (100, 200, 100)
109+
pixel_map = {"5": 1, "10": 3}
110+
frame = self._make_frame(50, roi)
111+
result = tick.get_logical_frame(frame, roi, pixel_map)
112+
assert result is None
113+
114+
115+
class TestGetLogicalFrameF64:
116+
"""浮点插值逻辑帧查找(负费重投射用)。
117+
118+
pixel_map value = internal frame。函数返回 display = internal + 1
119+
(width=0 端点返回 0)。对应 Rust lookup_interpolated_frame +
120+
open_interior_internal_frame_f64。
121+
"""
122+
123+
@staticmethod
124+
def _make_frame(fill_width: int, roi: tick.Roi, width: int = 200) -> np.ndarray:
125+
x1, x2, y = roi
126+
frame = np.full((y + 2, width, 3), 150, dtype=np.uint8)
127+
if fill_width > 0:
128+
frame[y, x1 : x1 + fill_width] = 255
129+
return frame
130+
131+
def test_width_zero_endpoint_returns_zero(self) -> None:
132+
"""width=0 是周期起点端点,display=0(不经过 internal+1)。"""
133+
roi = (100, 200, 100)
134+
pixel_map = {"0": 0, "5": 1, "10": 3}
135+
frame = self._make_frame(0, roi)
136+
result = tick.get_logical_frame_f64(frame, roi, pixel_map)
137+
assert result == 0.0
138+
139+
def test_exact_hit_returns_display(self) -> None:
140+
"""width=5, internal=1 → display=2。"""
141+
roi = (100, 200, 100)
142+
pixel_map = {"0": 0, "5": 1, "10": 3, "15": 5}
143+
frame = self._make_frame(5, roi)
144+
result = tick.get_logical_frame_f64(frame, roi, pixel_map)
145+
assert result == 2.0
146+
147+
def test_interpolation_on_internal_then_plus_one(self) -> None:
148+
"""width=7 在 (5,internal=1) 和 (10,internal=3) 之间。
149+
150+
internal 插值:1 + (7-5)/(10-5) * (3-1) = 1.8
151+
display = 1.8 + 1 = 2.8
152+
"""
153+
roi = (100, 200, 100)
154+
pixel_map = {"0": 0, "5": 1, "10": 3, "15": 5}
155+
frame = self._make_frame(7, roi)
156+
result = tick.get_logical_frame_f64(frame, roi, pixel_map)
157+
assert result is not None
158+
assert abs(result - 2.8) < 1e-9
159+
160+
def test_left_edge_within_tolerance(self) -> None:
161+
roi = (100, 200, 100)
162+
pixel_map = {"5": 1, "10": 3} # no width=0 entry
163+
frame = self._make_frame(3, roi) # 3 距 5 差 2,在容差 5 内
164+
result = tick.get_logical_frame_f64(frame, roi, pixel_map)
165+
assert result == 2.0 # display = internal(1) + 1
166+
167+
def test_right_edge_within_tolerance(self) -> None:
168+
roi = (100, 200, 100)
169+
pixel_map = {"5": 1, "10": 3}
170+
frame = self._make_frame(12, roi) # 12 距 10 差 2,在容差 5 内
171+
result = tick.get_logical_frame_f64(frame, roi, pixel_map)
172+
assert result == 4.0 # display = internal(3) + 1
173+
174+
def test_beyond_tolerance_returns_none(self) -> None:
175+
roi = (100, 200, 100)
176+
pixel_map = {"5": 1, "10": 3}
177+
frame = self._make_frame(50, roi) # 50 距最近点 10 差 40,超出容差
178+
result = tick.get_logical_frame_f64(frame, roi, pixel_map)
179+
assert result is None
180+
181+
182+
class TestNegativeCostReprojection:
183+
"""负费相位重投射:验证 multiplier 修正 + 插值精度 + 早期帧对齐。
184+
185+
pixel_map value = internal frame。tick 层返回 display = internal + 1。
186+
负费重投射:phase = display / total → round(phase * eff_total)。
187+
188+
旧代码两个 bug(已修复):
189+
A. round(phase * (eff-1)) → 下半段少 1 帧(改为 round(phase*eff), clamp)
190+
B. 离散最近邻丢亚帧精度(改为 f64 插值)
191+
C. pixel_map 用 display frame,插值起点 width=0→0 而非 internal+1=1,
192+
导致早期帧偏移(改为 internal frame + tick 层 +1 转换)
193+
"""
194+
195+
@staticmethod
196+
def _make_negative_frame(fill_width: int) -> np.ndarray:
197+
"""1280x720 BGR 帧:费用条填充 + 减号标记(触发 detect_negative_cost)。"""
198+
roi = tick.find_cost_bar_roi(1280, 720)
199+
x1, x2, y = roi
200+
frame = np.full((720, 1280, 3), 150, dtype=np.uint8)
201+
if fill_width > 0:
202+
frame[y, x1 : x1 + fill_width] = 255
203+
# 减号 ROI 全白 → detect_negative_cost 返回 True
204+
sx, sy, sw, sh = config.COST_SIGN_ROI
205+
frame[sy : sy + sh, sx : sx + sw] = 255
206+
return frame
207+
208+
@staticmethod
209+
def _make_calibration(pixel_map: dict[str, int], total_frames: int) -> FullCalibrationData:
210+
profile = CalibrationProfile(total_frames=total_frames, pixel_map=pixel_map)
211+
return FullCalibrationData(
212+
detection_mode="single",
213+
profiles=[profile],
214+
screen_width=1280,
215+
screen_height=720,
216+
)
217+
218+
@staticmethod
219+
def _standard_internal_map(total: int) -> dict[str, int]:
220+
"""标准校准生成的 internal frame pixel_map:width=w → internal=w-1。"""
221+
return {str(w): w - 1 for w in range(1, total)} | {"0": 0}
222+
223+
def test_phase_half_maps_to_midpoint(self) -> None:
224+
"""width=15, internal=14 → display=15 → phase=0.5 → round(0.5*60)=30。"""
225+
pixel_map = self._standard_internal_map(30)
226+
cal = self._make_calibration(pixel_map, 30)
227+
ts = TimeSource(cal)
228+
frame = self._make_negative_frame(15)
229+
lf = ts.update(frame)
230+
assert lf is not None
231+
assert lf == 30
232+
233+
def test_upper_half_not_off_by_one(self) -> None:
234+
"""width=22, internal=21 → display=22 → phase=0.733 → round(0.733*60)=44。"""
235+
pixel_map = self._standard_internal_map(30)
236+
cal = self._make_calibration(pixel_map, 30)
237+
ts = TimeSource(cal)
238+
frame = self._make_negative_frame(22)
239+
lf = ts.update(frame)
240+
assert lf is not None
241+
assert lf == 44
242+
243+
def test_early_frame_zero_width(self) -> None:
244+
"""width=0 → display=0 → phase=0 → lf=0。"""
245+
pixel_map = self._standard_internal_map(30)
246+
cal = self._make_calibration(pixel_map, 30)
247+
ts = TimeSource(cal)
248+
frame = self._make_negative_frame(0)
249+
lf = ts.update(frame)
250+
assert lf is not None
251+
assert lf == 0
252+
253+
def test_interpolation_preserves_subframe_precision(self) -> None:
254+
"""width=15 在 (10,internal=9) 和 (20,internal=19) 之间。
255+
256+
internal 插值:9 + (15-10)/(20-10) * (19-9) = 14.0
257+
display = 14.0 + 1 = 15.0 → phase=0.5 → round(0.5*60)=30
258+
"""
259+
pixel_map = self._standard_internal_map(30)
260+
cal = self._make_calibration(pixel_map, 30)
261+
ts = TimeSource(cal)
262+
frame = self._make_negative_frame(15)
263+
lf = ts.update(frame)
264+
assert lf is not None
265+
assert lf == 30
266+
267+
def test_matches_rust_reference_negative_cost_midpoint(self) -> None:
268+
"""交叉验证:对应 Rust reference 的 entering_negative_cost_at_same_phase_does_not_jump。
269+
270+
Rust 期望:width=15, negative, total=30 → logical_frame=30
271+
Python(internal frame pixel_map):width=15 → internal=14 → display=15
272+
→ phase=0.5 → round(0.5*60)=30。两边一致。
273+
"""
274+
pixel_map = self._standard_internal_map(30)
275+
cal = self._make_calibration(pixel_map, 30)
276+
ts = TimeSource(cal)
277+
frame = self._make_negative_frame(15)
278+
lf = ts.update(frame)
279+
assert lf is not None
280+
assert lf == 30 # Rust reference 期望值
281+
282+
283+
class TestCalibrationMigration:
284+
"""校准文件格式迁移:v1 (display frame) → v2 (internal frame)。"""
285+
286+
def test_migrate_v1_to_v2(self) -> None:
287+
from aao.core.timing.calibration import _migrate_v1_to_v2
288+
289+
# v1 格式:pixel_map value = display frame
290+
profiles_v1 = [{"total_frames": 30, "pixel_map": {"0": 0, "3": 2, "7": 3, "11": 4}}]
291+
migrated = _migrate_v1_to_v2(profiles_v1)
292+
pm = migrated[0]["pixel_map"]
293+
# width=0 不变(端点)
294+
assert pm["0"] == 0
295+
# 非零 width 的 frame -1(display → internal)
296+
assert pm["3"] == 1
297+
assert pm["7"] == 2
298+
assert pm["11"] == 3
299+
300+
def test_from_dict_migrates_v1(self) -> None:
301+
from aao.core.timing.calibration import from_dict
302+
303+
raw_v1 = {
304+
"detection_mode": "single",
305+
"profiles": [{"total_frames": 30, "pixel_map": {"0": 0, "5": 2, "10": 4}}],
306+
"screen_width": 1280,
307+
"screen_height": 720,
308+
"calibration_time": 0.0,
309+
}
310+
cal = from_dict(raw_v1)
311+
pm = cal.profiles[0].pixel_map
312+
# 迁移后 width=0 → 0, width=5 → 1 (internal), width=10 → 3 (internal)
313+
assert pm["0"] == 0
314+
assert pm["5"] == 1
315+
assert pm["10"] == 3
316+
317+
def test_from_dict_v2_no_migration(self) -> None:
318+
from aao.core.timing.calibration import from_dict
319+
320+
raw_v2 = {
321+
"format_version": 2,
322+
"detection_mode": "single",
323+
"profiles": [{"total_frames": 30, "pixel_map": {"0": 0, "5": 1, "10": 3}}],
324+
"screen_width": 1280,
325+
"screen_height": 720,
326+
"calibration_time": 0.0,
327+
}
328+
cal = from_dict(raw_v2)
329+
pm = cal.profiles[0].pixel_map
330+
# v2 不迁移
331+
assert pm["0"] == 0
332+
assert pm["5"] == 1
333+
assert pm["10"] == 3

0 commit comments

Comments
 (0)