双目测距方案重构与其他小修改
This commit is contained in:
@@ -1,302 +0,0 @@
|
||||
"""stereo_ranger 测距核心单元测试。
|
||||
|
||||
规格:docs/双目测距方案.md §4(几何)、§5(视差/有效性/误差)、§6(关联)、
|
||||
§7(滤波)。场景全部由"目标在左目系 (X, Y, Z)"解析投影生成左右框,
|
||||
再反推验证测距/方位输出。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger.config import StereoConfig
|
||||
from stereo_ranger.stereo_ranger import (
|
||||
DISP_SOURCE_CENTER,
|
||||
DISP_SOURCE_EDGE,
|
||||
StereoRanger,
|
||||
focal_from_hfov,
|
||||
range_error_abs,
|
||||
range_error_rel,
|
||||
)
|
||||
from stereo_ranger.types import Detection
|
||||
|
||||
CFG = StereoConfig()
|
||||
FB = CFG.fx_px * CFG.baseline_m # 57.6 px·m
|
||||
|
||||
|
||||
def box(cx, cy, w=100.0, h=60.0, cls_id=0, conf=1.0, track_id=0):
|
||||
return Detection(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2,
|
||||
conf, cls_id, track_id)
|
||||
|
||||
|
||||
def target_pair(x_m, y_m, z_m, w=100.0, h=60.0, cls_id=0, conf=1.0, track_id=0):
|
||||
"""目标中心在左目系 (x_m, y_m, z_m),解析投影为左右目同尺寸框。"""
|
||||
u_l = CFG.fx_px * x_m / z_m + CFG.cx_px
|
||||
v_l = CFG.fy_px * y_m / z_m + CFG.cy_px
|
||||
u_r = u_l - FB / z_m
|
||||
return (box(u_l, v_l, w, h, cls_id, conf, track_id),
|
||||
box(u_r, v_l, w, h, cls_id, conf, track_id))
|
||||
|
||||
|
||||
def one_report(ranger, left, right, **kw):
|
||||
reports = ranger.update(left, right, **kw)
|
||||
assert len(reports) == len(left)
|
||||
return reports
|
||||
|
||||
|
||||
# ---- §4.2 / §5.3 几何与误差公式 ----
|
||||
|
||||
def test_focal_from_hfov_matches_doc_examples():
|
||||
assert focal_from_hfov(1920.0, 90.0) == pytest.approx(960.0)
|
||||
assert focal_from_hfov(1920.0, 60.0) == pytest.approx(1662.769)
|
||||
|
||||
|
||||
def test_range_error_matches_doc_table():
|
||||
# 文档 §5.3 示意表:B=0.06, F=960, δD=1 px
|
||||
expected = {5: 0.4340, 10: 1.7361, 15: 3.9063, 20: 6.9444,
|
||||
30: 15.625, 50: 43.4028, 100: 173.611}
|
||||
for z, err in expected.items():
|
||||
assert range_error_abs(z, 960.0, 0.06, 1.0) == pytest.approx(err, rel=1e-3)
|
||||
assert range_error_rel(10.0, 960.0, 0.06, 1.0) == pytest.approx(1.7361 / 10.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_on_axis_target_z_and_bearing():
|
||||
left, right = target_pair(0.0, 0.0, 10.0)
|
||||
r = one_report(StereoRanger(), [left], [right], frame_sequence=7, timestamp_s=1.5)[0]
|
||||
assert r.matched and r.valid
|
||||
assert r.forward_distance_m == pytest.approx(10.0, rel=1e-9)
|
||||
assert r.disparity_px == pytest.approx(FB / 10.0)
|
||||
assert r.disparity_source == DISP_SOURCE_EDGE
|
||||
assert r.lateral_m == pytest.approx(0.0, abs=1e-9)
|
||||
assert r.vertical_m == pytest.approx(0.0, abs=1e-9)
|
||||
assert r.slant_range_m == pytest.approx(10.0, rel=1e-9)
|
||||
assert r.frame_sequence == 7 and r.timestamp_s == 1.5
|
||||
# 虚拟双目中心:两目中心平均,y 取左目(§11.3)
|
||||
assert r.centre_pixel_x_mid == pytest.approx((left.cx + right.cx) / 2.0)
|
||||
assert r.centre_pixel_y_mid == left.cy
|
||||
|
||||
|
||||
def test_off_axis_target_xyz_and_slant_range():
|
||||
left, right = target_pair(1.2, 0.3, 10.0)
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert r.valid
|
||||
assert r.lateral_m == pytest.approx(1.2, rel=1e-9)
|
||||
assert r.vertical_m == pytest.approx(0.3, rel=1e-9)
|
||||
assert r.slant_range_m == pytest.approx(math.sqrt(1.2**2 + 0.3**2 + 10.0**2), rel=1e-9)
|
||||
assert r.error_est_m == pytest.approx(range_error_abs(10.0, CFG.fx_px, CFG.baseline_m, CFG.disp_noise_px))
|
||||
|
||||
|
||||
def test_x_uses_left_camera_frame_not_virtual_center():
|
||||
# X 定义在左目系(§4.2),用左目中心像素;方位 x_mid 是虚拟相机量(§11.3)
|
||||
left, right = target_pair(0.0, 0.0, 10.0)
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert r.lateral_m == pytest.approx(0.0, abs=1e-9) # 目标在左光轴上
|
||||
assert r.centre_pixel_x_mid < left.cx # 但虚拟中心相对左光轴偏左 B/2
|
||||
|
||||
|
||||
def test_width_diff_on_right_edge_only_does_not_taint_left_edge():
|
||||
# §5.1 主方案:同侧(左)边缘对视差免疫"宽度差"。右框仅向 x2 侧扩宽
|
||||
# 时,左边缘不变、中心被拉偏——边缘取点仍得精确 Z。
|
||||
left, right = target_pair(0.0, 0.0, 10.0)
|
||||
right_extended = Detection(right.x1, right.y1, right.x2 + 40.0, right.y2)
|
||||
r = one_report(StereoRanger(), [left], [right_extended])[0]
|
||||
assert r.matched and r.valid
|
||||
assert r.forward_distance_m == pytest.approx(10.0, rel=1e-9)
|
||||
assert r.disparity_source == DISP_SOURCE_EDGE
|
||||
assert right_extended.width != left.width # 确有宽度差
|
||||
|
||||
|
||||
def test_center_fallback_when_edge_out_of_window(caplog):
|
||||
# 左框过宽使左边缘视差越出窗口:回退中心并节流警告(§5.1),同实例内不刷屏
|
||||
left, right = target_pair(0.0, 0.0, 10.0)
|
||||
left_wider = box(left.cx, left.cy, w=300.0, h=60.0)
|
||||
ranger = StereoRanger()
|
||||
r = one_report(ranger, [left_wider], [right])[0]
|
||||
r2 = one_report(ranger, [left_wider], [right])[0]
|
||||
assert r.matched and r.valid
|
||||
assert r.forward_distance_m == pytest.approx(10.0, rel=1e-9)
|
||||
assert r.disparity_source == DISP_SOURCE_CENTER
|
||||
assert r2.disparity_source == DISP_SOURCE_CENTER
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_center_fallback_disabled_leaves_unmatched():
|
||||
left, right = target_pair(0.0, 0.0, 10.0)
|
||||
left_wider = box(left.cx, left.cy, w=300.0, h=60.0)
|
||||
cfg = StereoConfig(use_center_fallback=False)
|
||||
r = one_report(StereoRanger(cfg), [left_wider], [right])[0]
|
||||
assert not r.matched and not r.valid
|
||||
assert r.forward_distance_m is None
|
||||
|
||||
|
||||
# ---- §6 关联硬约束与贪心一一配对 ----
|
||||
|
||||
def test_class_mismatch_rejected():
|
||||
left, _ = target_pair(0.0, 0.0, 10.0)
|
||||
_, right = target_pair(0.0, 0.0, 10.0, cls_id=1)
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert not r.matched
|
||||
|
||||
|
||||
def test_vertical_residual_beyond_tau_rejected():
|
||||
left, _ = target_pair(0.0, 0.0, 10.0)
|
||||
right = box(CFG.cx_px - FB / 10.0, left.cy + 3.0) # |Δy| = 3 > τy = 2
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert not r.matched
|
||||
|
||||
|
||||
def test_height_ratio_beyond_tau_rejected():
|
||||
left, _ = target_pair(0.0, 0.0, 10.0)
|
||||
_, right = target_pair(0.0, 0.0, 10.0, h=40.0) # |60-40|/60 > 0.25
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert not r.matched
|
||||
|
||||
|
||||
def test_cost_equal_one_rejected():
|
||||
# 文档 §6:仅当 cost < 1 时接受(严格小于)
|
||||
# dy = 1 px -> 0.5,dh 比 = 0.125 -> 0.5,cost 精确 = 1.0
|
||||
left, _ = target_pair(0.0, 0.0, 10.0)
|
||||
x_off = FB / 10.0
|
||||
right = Detection(left.x1 - x_off, left.y1 + 4.75,
|
||||
left.x1 - x_off + 52.5, left.y1 + 4.75 + 52.5)
|
||||
# 右框中心 = 左框中心 + 1 px(dy=1),高度 52.5(dh 比 0.125)
|
||||
assert right.cy == pytest.approx(left.cy + 1.0)
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert not r.matched
|
||||
|
||||
|
||||
def test_disparity_out_of_z_window_rejected():
|
||||
# 目标过近(D > FB/z_min)与过远(D < FB/z_max)均不可配对(§6)
|
||||
near_l, near_r = target_pair(0.0, 0.0, 0.5)
|
||||
far_l, far_r = target_pair(0.0, 0.0, 60.0)
|
||||
r = one_report(StereoRanger(), [near_l], [near_r])[0]
|
||||
assert not r.matched
|
||||
r = one_report(StereoRanger(), [far_l], [far_r])[0]
|
||||
assert not r.matched
|
||||
|
||||
|
||||
def test_greedy_one_to_one_crossing_indices():
|
||||
# 目标 A(光轴上, Z=10) 与 B(右偏, Z=6);右目框以下标交叉顺序传入。
|
||||
# 贪心须按代价配对:L0->R_A、L1->R_B,即使 R_B 在下标 0 处。
|
||||
l_a, r_a = target_pair(0.0, 0.0, 10.0)
|
||||
l_b, r_b = target_pair(0.3, 0.0, 6.0)
|
||||
reports = one_report(StereoRanger(), [l_a, l_b], [r_b, r_a])
|
||||
assert all(r.matched for r in reports)
|
||||
assert reports[0].forward_distance_m == pytest.approx(10.0, rel=1e-9)
|
||||
assert reports[1].forward_distance_m == pytest.approx(6.0, rel=1e-9)
|
||||
assert reports[0].ref_bbox == (l_a.x1, l_a.y1, l_a.x2, l_a.y2)
|
||||
|
||||
|
||||
def test_track_id_continuity_preference():
|
||||
# §6:存在轨迹标识时优先匹配相同 track id
|
||||
l_a, r_a = target_pair(0.0, 0.0, 10.0, track_id=7)
|
||||
l_b, r_b = target_pair(0.3, 0.0, 8.0, track_id=8)
|
||||
reports = one_report(StereoRanger(), [l_a, l_b], [r_b, r_a])
|
||||
by_track = {r.track_id: r.forward_distance_m for r in reports}
|
||||
assert by_track[7] == pytest.approx(10.0, rel=1e-9)
|
||||
assert by_track[8] == pytest.approx(8.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_unmatched_left_reports_bearing_only():
|
||||
left, right = target_pair(0.0, 0.0, 10.0)
|
||||
stray = box(500.0, 300.0, cls_id=3, conf=0.8)
|
||||
reports = one_report(StereoRanger(), [left, stray], [right], frame_sequence=42, timestamp_s=1.5)
|
||||
assert reports[0].matched and reports[0].valid
|
||||
u = reports[1]
|
||||
assert not u.matched and not u.valid
|
||||
assert u.forward_distance_m is None
|
||||
assert u.centre_pixel_x_mid == stray.cx and u.centre_pixel_y_mid == stray.cy
|
||||
assert u.confidence == 0.8 and u.class_id == 3
|
||||
assert u.frame_sequence == 42 and u.timestamp_s == 1.5
|
||||
|
||||
|
||||
def test_empty_sides():
|
||||
ranger = StereoRanger()
|
||||
assert ranger.update([], []) == []
|
||||
r = one_report(ranger, [box(100.0, 100.0)], [])[0]
|
||||
assert not r.matched
|
||||
|
||||
|
||||
def test_report_order_follows_left_detections():
|
||||
ranger = StereoRanger()
|
||||
l1, r1 = target_pair(0.0, 0.0, 10.0, cls_id=0)
|
||||
l2, r2 = target_pair(0.0, 0.0, 8.0, cls_id=2)
|
||||
reports = ranger.update([l2, l1], [r1, r2]) # 输出顺序与输入左目一致
|
||||
assert [r.class_id for r in reports] == [2, 0]
|
||||
assert reports[0].confidence == min(l2.conf, r2.conf)
|
||||
|
||||
|
||||
# ---- §5.4 有效性判定 ----
|
||||
|
||||
def test_beyond_range_tolerance_invalid_but_distance_kept():
|
||||
left, right = target_pair(0.0, 0.0, 15.0) # δZ ≈ 3.9 m > 2 m
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert r.matched
|
||||
assert not r.valid
|
||||
assert r.forward_distance_m == pytest.approx(15.0, rel=1e-9)
|
||||
assert r.lateral_m is not None # 方位/坐标仍发布(§5.4 域外标记无效)
|
||||
assert r.error_est_m == pytest.approx(range_error_abs(15.0, CFG.fx_px, CFG.baseline_m, CFG.disp_noise_px))
|
||||
|
||||
|
||||
def test_within_range_tolerance_valid():
|
||||
left, right = target_pair(0.0, 0.0, 10.0) # δZ ≈ 1.74 m <= 2 m
|
||||
r = one_report(StereoRanger(), [left], [right])[0]
|
||||
assert r.valid
|
||||
|
||||
|
||||
def test_invalid_when_disparity_below_dmin():
|
||||
# 放宽 range_tol 以单独暴露 D_min 判据:D = 2.3 px < 3 px -> 无效
|
||||
cfg = StereoConfig(range_tol_m=50.0)
|
||||
left, right = target_pair(0.0, 0.0, 25.0)
|
||||
r = one_report(StereoRanger(cfg), [left], [right])[0]
|
||||
assert r.matched and not r.valid
|
||||
|
||||
|
||||
# ---- §7 时间滤波(可选) ----
|
||||
|
||||
def moving_pair(z_m, track_id=1, cls_id=0):
|
||||
"""返回 (左框列表, 右框列表):目标沿光轴移动,track_id 可配。"""
|
||||
left = box(CFG.cx_px, CFG.cy_px, cls_id=cls_id, track_id=track_id)
|
||||
right = box(CFG.cx_px - FB / z_m, CFG.cy_px, cls_id=cls_id, track_id=track_id)
|
||||
return [left], [right]
|
||||
|
||||
|
||||
def test_filter_smooths_and_resets_on_jump():
|
||||
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3, filter_jump_ratio=0.2,
|
||||
range_tol_m=50.0) # 放宽有效域,让 10~13 m 均有效以观察滤波
|
||||
ranger = StereoRanger(cfg)
|
||||
z1 = one_report(ranger, *moving_pair(10.0), frame_sequence=1)[0]
|
||||
assert z1.forward_distance_m == pytest.approx(10.0, rel=1e-9) # 首帧置位
|
||||
z2 = one_report(ranger, *moving_pair(11.0), frame_sequence=2)[0] # 跳变 10% <= 20%:平滑
|
||||
assert z2.forward_distance_m == pytest.approx(0.7 * 10.0 + 0.3 * 11.0, rel=1e-9)
|
||||
z3 = one_report(ranger, *moving_pair(13.0), frame_sequence=3)[0] # 跳变 26% > 20%:重置
|
||||
assert z3.forward_distance_m == pytest.approx(13.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_filter_skipped_without_track_id():
|
||||
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3)
|
||||
ranger = StereoRanger(cfg)
|
||||
one_report(ranger, *moving_pair(10.0, track_id=0), frame_sequence=1)
|
||||
z2 = one_report(ranger, *moving_pair(11.0, track_id=0), frame_sequence=2)[0]
|
||||
assert z2.forward_distance_m == pytest.approx(11.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_reset_clears_filter_state():
|
||||
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3, range_tol_m=50.0)
|
||||
ranger = StereoRanger(cfg)
|
||||
one_report(ranger, *moving_pair(10.0), frame_sequence=1) # 状态 = 10.0
|
||||
ranger.reset()
|
||||
# 若不重置,10 -> 11 会平滑为 10.3;重置后应直接输出原始 11.0
|
||||
z = one_report(ranger, *moving_pair(11.0), frame_sequence=2)[0]
|
||||
assert z.forward_distance_m == pytest.approx(11.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_invalid_measurements_do_not_pollute_filter_state():
|
||||
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3, range_tol_m=50.0)
|
||||
ranger = StereoRanger(cfg)
|
||||
one_report(ranger, *moving_pair(10.0), frame_sequence=1)
|
||||
z2 = one_report(ranger, *moving_pair(25.0), frame_sequence=2)[0] # D < D_min:无效
|
||||
assert not z2.valid
|
||||
z3 = one_report(ranger, *moving_pair(10.5), frame_sequence=3)[0]
|
||||
# 无效帧未污染状态:仍按与首帧 10.0 平滑
|
||||
assert z3.forward_distance_m == pytest.approx(0.7 * 10.0 + 0.3 * 10.5, rel=1e-9)
|
||||
Reference in New Issue
Block a user