双目测距方案重构与其他小修改

This commit is contained in:
keita
2026-09-10 16:16:05 +08:00
parent 3325465359
commit 538ae4c6c7
39 changed files with 2285 additions and 1444 deletions
-76
View File
@@ -1,76 +0,0 @@
"""StereoConfig 默认值、派生区间与 JSON 加载测试(规格 §2:参数全部外置)。"""
import json
import pytest
from stereo_ranger.config import StereoConfig, load_from_json
VALID = {
"fx_px": 1663.0, "fy_px": 1663.0, "cx_px": 960.0, "cy_px": 540.0,
"baseline_m": 0.12, "z_min_m": 2.0, "z_max_m": 40.0,
"vertical_tau_px": 3.0, "height_ratio_tau": 0.3,
"disparity_min_px": 4.0, "range_tol_m": 3.0, "disp_noise_px": 1.5,
"use_center_fallback": False, "filter_enabled": True,
"filter_alpha": 0.5, "filter_jump_ratio": 0.1, "warn_interval_s": 1.0,
}
def test_defaults_and_disparity_window():
cfg = StereoConfig()
assert cfg.baseline_m == 0.06 and cfg.fx_px == 960.0
# FB = 57.6,z 窗口 [1, 50] m -> D 窗口 [57.6/50, 57.6/1]
lo, hi = cfg.disparity_bounds_px()
assert lo == pytest.approx(1.152)
assert hi == pytest.approx(57.6)
@pytest.mark.parametrize("kwargs", [
{"baseline_m": 0.0},
{"fx_px": -1.0},
{"fy_px": 0.0},
{"cx_px": -1.0},
{"z_min_m": 0.0},
{"z_max_m": 10.0, "z_min_m": 20.0},
{"vertical_tau_px": 0.0},
{"height_ratio_tau": 0.0},
{"disparity_min_px": 0.0},
{"range_tol_m": 0.0},
{"disp_noise_px": 0.0},
{"filter_alpha": 0.0},
{"filter_alpha": 1.5},
{"filter_jump_ratio": -0.1},
{"warn_interval_s": -1.0},
])
def test_invalid_config_raises(kwargs):
with pytest.raises(ValueError):
StereoConfig(**kwargs)
def test_load_from_json_roundtrip(tmp_path):
p = tmp_path / "stereo.json"
p.write_text(json.dumps(VALID), encoding="utf-8")
cfg = load_from_json(str(p))
for field, value in VALID.items():
assert getattr(cfg, field) == value
lo, hi = cfg.disparity_bounds_px()
assert hi == pytest.approx(1663.0 * 0.12 / 2.0) # FB / z_min
assert lo == pytest.approx(1663.0 * 0.12 / 40.0) # FB / z_max
def test_load_from_json_missing_field_reports_name(tmp_path):
p = tmp_path / "stereo.json"
data = dict(VALID)
del data["baseline_m"]
p.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ValueError, match="baseline_m"):
load_from_json(str(p))
def test_load_from_json_unknown_field_rejected(tmp_path):
p = tmp_path / "stereo.json"
data = dict(VALID)
data["magic_extra"] = 1
p.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ValueError, match="magic_extra"):
load_from_json(str(p))
@@ -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)
@@ -3,8 +3,9 @@
移植自 reference-c++/visual_track_control/tests/tracking_control_law_test.cpp。
覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉
超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道三种
无距离降级动作、首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道无距离
降级动作(含 EXIT 门限/grace 重置/零门限立即退出)、距离超龄回落、首拍变化率
限幅(S6)、机体系→NED 输出旋转(A4)。
"""
import math
@@ -26,7 +27,7 @@ def make_config() -> TrackingConfig:
cfg = TrackingConfig()
cfg.control.frequency_hz = 20.0
cfg.control.visual_stale_ms = 200
cfg.control.radar_stale_ms = 500
cfg.control.distance_stale_ms = 500
cfg.control.attitude_stale_ms = 300
cfg.camera.image_width = 1000
cfg.camera.image_height = 1000
@@ -55,7 +56,7 @@ def make_config() -> TrackingConfig:
def make_snapshot(now_ms: int) -> ControlSnapshot:
"""新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜。"""
"""新鲜快照:目标在画面中心,姿态/追踪/距离均新鲜。"""
return ControlSnapshot(
track=TrackState(tracked=True, is_predicted=False,
center=(500.0, 500.0)),
@@ -213,9 +214,9 @@ def test_vertical_velocity_clamped():
def test_distance_approach_when_far():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
@@ -223,17 +224,17 @@ def test_distance_approach_when_far():
def test_distance_retreat_clamped_when_too_close():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.radar_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(-1.0, abs=1e-9)
def test_no_distance_hold_zeros_vx():
law = TrackingControlLaw(make_config()) # 缺省 kHold
law = TrackingControlLaw(make_config()) # 基准配置 HOLD
snap = make_snapshot(1000)
snap.associated_to_target = False
snap.distance_valid = False
out = law.update(snap, 1000)
assert out.mode is ControlMode.VELOCITY_HEADING
assert out.vx_mps == 0.0
@@ -244,29 +245,79 @@ def test_no_distance_slow_approach():
cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.associated_to_target = False
snap.distance_valid = False
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(0.5, abs=1e-9)
def test_no_distance_exit_degrades():
def test_no_distance_exit_waits_grace_then_degrades():
# S1 拍板:EXIT 需持续无距离达门限才降级;grace 期间按 SLOW_APPROACH 行为
cfg = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 200
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.associated_to_target = False
snap.distance_valid = False
out1 = law.update(snap, 1000)
assert out1.mode is ControlMode.VELOCITY_HEADING
assert out1.vx_mps == pytest.approx(0.5, abs=1e-9)
snap.track_time_ms = 1200 # 保持视觉/姿态新鲜,聚焦距离通道
snap.attitude_time_ms = 1200
out2 = law.update(snap, 1200) # 恰达门限 → 降级
assert out2.mode is ControlMode.DEGRADED_HOLD
assert out2.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
assert not out2.valid
def test_no_distance_exit_grace_resets_on_valid_frame():
cfg = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 200
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.distance_valid = False
assert law.update(snap, 1000).mode is ControlMode.VELOCITY_HEADING
snap.track_time_ms = 1100 # 有效距离帧:重置计时
snap.attitude_time_ms = 1100
snap.distance_valid = True
snap.target_distance_m = 30.0
snap.distance_time_ms = 1100
assert law.update(snap, 1100).mode is ControlMode.VELOCITY_HEADING
snap.track_time_ms = 1250 # 再次无效:重新计时
snap.attitude_time_ms = 1250
snap.distance_valid = False
snap.target_distance_m = 0.0
assert law.update(snap, 1250).mode is ControlMode.VELOCITY_HEADING # 0 ms < 200 ms
snap.track_time_ms = 1500
snap.attitude_time_ms = 1500
out = law.update(snap, 1500) # 250 ms ≥ 200 ms → 降级
assert out.mode is ControlMode.DEGRADED_HOLD
assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
def test_no_distance_exit_zero_grace_degrades_immediately():
# grace = 0 保留旧"首个无效拍即退出"行为
cfg = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 0
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.distance_valid = False
out = law.update(snap, 1000)
assert out.mode is ControlMode.DEGRADED_HOLD
assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
assert not out.valid
def test_stale_radar_falls_back_to_no_distance():
law = TrackingControlLaw(make_config()) # kHold
def test_stale_distance_falls_back_to_no_distance():
law = TrackingControlLaw(make_config()) # HOLD
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用
out = law.update(snap, 1000)
assert out.mode is ControlMode.VELOCITY_HEADING
assert out.vx_mps == 0.0
@@ -280,9 +331,9 @@ def test_first_tick_vx_slews_from_zero():
cfg.accel_limit.ax_mps2 = 2.0
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 # 通道输出本应为 3.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0 # 通道输出本应为 3.0
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000) # 首拍:dt 按 1/20 Hz 兜底
assert out.vx_mps == pytest.approx(2.0 * (1.0 / 20.0), abs=1e-9) # 0.1
@@ -293,9 +344,9 @@ def test_output_rotated_to_ned_by_yaw():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.yaw_rad = math.pi / 2.0 # 机头朝东
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
# 机体前向 3.0 → 东向 3.0,北向 ≈ 0
assert out.vx_mps == pytest.approx(0.0, abs=1e-9)
@@ -306,9 +357,9 @@ def test_zero_yaw_keeps_forward_as_north():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.yaw_rad = 0.0
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
assert out.vy_mps == pytest.approx(0.0, abs=1e-9)
@@ -321,17 +372,17 @@ def test_reset_restores_slew_from_zero():
cfg.accel_limit.ax_mps2 = 2.0
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 1000
for t in range(1000, 3000, 50):
snap.track_time_ms = t
snap.radar_time_ms = t
snap.distance_time_ms = t
snap.attitude_time_ms = t
law.update(snap, t) # 若干拍后 vx 已爬升到限幅 3.0
law.reset()
snap.track_time_ms = 3000
snap.radar_time_ms = 3000
snap.distance_time_ms = 3000
snap.attitude_time_ms = 3000
out = law.update(snap, 3000)
# Reset 后限幅器归零:又从 0.1 开始渐变
@@ -15,7 +15,7 @@ from pathlib import Path
import pytest
from visual_track_control.config import (HeadingMode, NoDistanceAction,
load_from_json)
TrackingConfig, load_from_json)
_REPO_ROOT = Path(__file__).resolve().parents[3]
REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json"
@@ -27,7 +27,7 @@ def full_config() -> dict:
"control": {
"frequency_hz": 20.0,
"visual_stale_ms": 200,
"radar_stale_ms": 500,
"distance_stale_ms": 500,
"attitude_stale_ms": 300,
},
"camera": {
@@ -57,6 +57,7 @@ def full_config() -> dict:
"retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold",
"no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000,
},
"accel_limit": {
"ax_mps2": 2.0,
@@ -84,7 +85,9 @@ def test_load_real_config_succeeds():
assert cfg.vertical.vz_gain_mps_per_deg == 0.1
assert cfg.vertical.vz_limit_mps == 2.0
assert cfg.distance.d_exp_m == 10.0
assert cfg.distance.no_distance_action is NoDistanceAction.HOLD
assert cfg.distance.no_distance_action is NoDistanceAction.SLOW_APPROACH
assert cfg.distance.no_distance_exit_grace_ms == 3000
assert cfg.control.distance_stale_ms == 500
assert cfg.accel_limit.ax_mps2 == 2.0
@@ -169,3 +172,18 @@ def test_fov_out_of_range_throws(tmp_path):
with pytest.raises(ValueError) as exc_info:
load_from_json(path)
assert "camera.fov_h_deg" in str(exc_info.value)
def test_default_no_distance_action_is_slow_approach():
# S1 拍板:双目有效域外无距离是常态,默认定速接近(纪要 D4/S1)
assert (TrackingConfig().distance.no_distance_action
is NoDistanceAction.SLOW_APPROACH)
def test_negative_exit_grace_throws(tmp_path):
config = full_config()
config["distance"]["no_distance_exit_grace_ms"] = -1
path = write_temp_json(tmp_path, config, "vtc_cfg_badgrace.json")
with pytest.raises(ValueError) as exc_info:
load_from_json(path)
assert "no_distance_exit_grace_ms" in str(exc_info.value)