44
55import numpy as np
66
7+ from aao import config
78from 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
1113class 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