新增双目yolo实现python代码

This commit is contained in:
keita
2026-09-14 15:05:03 +08:00
parent 538ae4c6c7
commit 3a8e2c7f4b
65 changed files with 2467 additions and 10 deletions
+94
View File
@@ -0,0 +1,94 @@
"""StereoConfig 校验与 JSON 加载测试(规格 §9:配置外置、fail-fast)。"""
import json
from pathlib import Path
import pytest
from stereo_ranger import StereoConfig, load_from_json
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "stereo_ranger.json"
def _config_dict() -> dict:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
def _write(tmp_path: Path, data: dict) -> str:
path = tmp_path / "stereo.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return str(path)
def test_defaults_are_valid_and_domains_are_consistent():
cfg = StereoConfig()
assert cfg.focal_baseline_px_m == pytest.approx(cfg.fx_px * cfg.baseline_m)
assert cfg.z_min_m == pytest.approx(cfg.focal_baseline_px_m / cfg.disparity_max_px)
assert cfg.z_max_m == pytest.approx(cfg.focal_baseline_px_m / cfg.disparity_min_px)
assert cfg.z_error_budget_max_m == pytest.approx((cfg.max_delta_z_m * cfg.focal_baseline_px_m) ** 0.5)
def test_load_from_json_matches_defaults():
assert load_from_json(str(CONFIG_PATH)) == StereoConfig()
def test_missing_field_rejected(tmp_path):
data = _config_dict()
data.pop("baseline_m")
with pytest.raises(ValueError, match="缺少配置字段"):
load_from_json(_write(tmp_path, data))
def test_unknown_field_rejected(tmp_path):
data = _config_dict()
data["radar_fallback"] = True
with pytest.raises(ValueError, match="未知配置字段"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("fx_px", "960"),
("consistency_N_frames", 3.5),
("smooth_alpha", True),
],
)
def test_type_error_rejected(tmp_path, field, value):
data = _config_dict()
data[field] = value
with pytest.raises(ValueError, match="配置类型非法"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("fx_px", 0.0),
("fy_px", -1.0),
("cx_px", -0.5),
("cy_px", -0.5),
("baseline_m", 0.0),
("tau_y_px", 0.0),
("tau_h_ratio", 0.0),
("disparity_min_px", 0.0),
("disparity_min_px", 60.0),
("max_delta_z_m", 0.0),
("consistency_N_frames", 0),
("consistency_M_frames", 2),
("consistency_jump_px", 0.0),
("smooth_alpha", 0.0),
("smooth_alpha", 1.5),
("jump_reset_m", 0.0),
("filter_reset_frames", 0),
("isolation_gate_px", 0.0),
],
)
def test_invalid_values_rejected(field, value):
with pytest.raises(ValueError, match="配置非法"):
StereoConfig(**{field: value})
def test_boundary_values_accepted():
cfg = StereoConfig(disparity_min_px=3.0, disparity_max_px=3.0 + 1e-9, smooth_alpha=1.0, consistency_M_frames=3)
assert cfg.z_max_m > cfg.z_min_m
@@ -0,0 +1,80 @@
"""一致性监控测试(规格 §6.7/§11.4:N 帧降级、M 帧判 invalid、正常帧清零、每目标隔离)。"""
import pytest
from stereo_ranger import ConsistencyMonitor
def _monitor() -> ConsistencyMonitor:
return ConsistencyMonitor(jump_px=3.0, degrade_frames=3, invalid_frames=5)
def test_first_frame_has_no_reference_jump():
status = _monitor().advance("t", 10.0)
assert status.jump_px is None
assert status.abnormal is False
assert status.streak == 0
def test_normal_jump_resets_streak():
monitor = _monitor()
monitor.advance("t", 10.0)
abnormal = monitor.advance("t", 16.0)
assert abnormal.abnormal and abnormal.streak == 1
normal = monitor.advance("t", 17.0)
assert not normal.abnormal and normal.streak == 0
def test_jump_at_threshold_is_normal():
monitor = _monitor()
monitor.advance("t", 10.0)
assert not monitor.advance("t", 13.0).abnormal # |ΔD| = 3.0 不超门限
def test_degrades_at_n_frames_and_invalidates_at_m_frames():
monitor = _monitor()
monitor.advance("t", 10.0)
disparities = [14.0, 18.0, 22.0, 26.0, 30.0] # 每帧跳变 4 px > 3 px
statuses = [monitor.advance("t", d) for d in disparities]
assert [s.streak for s in statuses] == [1, 2, 3, 4, 5]
assert [s.confidence_degraded for s in statuses] == [False, False, True, True, True]
assert [s.invalid for s in statuses] == [False, False, False, False, True]
def test_targets_are_isolated():
monitor = _monitor()
monitor.advance("a", 10.0)
monitor.advance("b", 20.0)
monitor.advance("a", 15.0) # a 跳变异常
assert monitor.streak("a") == 1
assert monitor.streak("b") == 0
def test_drop_clears_only_target():
monitor = _monitor()
monitor.advance("a", 10.0)
monitor.advance("a", 20.0)
monitor.drop("a")
assert monitor.streak("a") == 0
assert monitor.advance("a", 40.0).jump_px is None # 状态已清空,重新起始
def test_clear_resets_all():
monitor = _monitor()
monitor.advance("a", 10.0)
monitor.advance("a", 20.0)
monitor.clear()
assert monitor.streak("a") == 0
@pytest.mark.parametrize(
"kwargs",
[
{"jump_px": 0.0, "degrade_frames": 3, "invalid_frames": 5},
{"jump_px": 3.0, "degrade_frames": 0, "invalid_frames": 5},
{"jump_px": 3.0, "degrade_frames": 5, "invalid_frames": 3},
],
)
def test_invalid_ctor_args(kwargs):
with pytest.raises(ValueError):
ConsistencyMonitor(**kwargs)
@@ -0,0 +1,94 @@
"""时间滤波器测试(规格 §6.5/§11.4:低通收敛、跳变重置不拖尾、失效清理、每目标隔离)。"""
import pytest
from stereo_ranger import DistanceFilter
def _filter() -> DistanceFilter:
return DistanceFilter(alpha=0.3, jump_reset_m=5.0, reset_frames=5)
def test_first_value_initializes():
assert _filter().advance("t", 10.0) == pytest.approx(10.0)
def test_low_pass_step_response():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
assert distance_filter.advance("t", 12.0) == pytest.approx(10.6)
def test_low_pass_converges_to_measurement():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
value = 10.0
for _ in range(40):
value = distance_filter.advance("t", 12.0)
assert value == pytest.approx(12.0, abs=1e-3)
def test_alpha_one_is_identity():
distance_filter = DistanceFilter(alpha=1.0, jump_reset_m=5.0, reset_frames=5)
distance_filter.advance("t", 10.0)
assert distance_filter.advance("t", 12.0) == pytest.approx(12.0)
def test_jump_reset_without_tail():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
assert distance_filter.advance("t", 20.0) == pytest.approx(20.0) # |Δ| > 5 → 直接重置
assert distance_filter.value("t") == pytest.approx(20.0)
assert distance_filter.advance("t", 20.2) == pytest.approx(20.06) # 之后恢复低通
def test_invalidate_keeps_state_before_threshold_and_clears_after():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
for _ in range(4):
distance_filter.invalidate("t")
assert distance_filter.value("t") == pytest.approx(10.0)
assert distance_filter.invalid_streak("t") == 4
distance_filter.invalidate("t")
assert distance_filter.value("t") is None
assert distance_filter.advance("t", 30.0) == pytest.approx(30.0) # 重新初始化而非平滑
def test_valid_frame_resets_invalid_streak():
distance_filter = _filter()
distance_filter.advance("t", 10.0)
distance_filter.invalidate("t")
distance_filter.invalidate("t")
distance_filter.advance("t", 10.5)
assert distance_filter.invalid_streak("t") == 0
def test_invalidate_without_state_is_noop():
distance_filter = _filter()
distance_filter.invalidate("t")
assert distance_filter.value("t") is None
def test_targets_are_isolated():
distance_filter = _filter()
distance_filter.advance("a", 10.0)
distance_filter.advance("b", 100.0)
assert distance_filter.advance("a", 12.0) == pytest.approx(10.6)
assert distance_filter.value("b") == pytest.approx(100.0)
distance_filter.drop("a")
assert distance_filter.value("a") is None
assert distance_filter.value("b") == pytest.approx(100.0)
@pytest.mark.parametrize(
"kwargs",
[
{"alpha": 0.0, "jump_reset_m": 5.0, "reset_frames": 5},
{"alpha": 1.5, "jump_reset_m": 5.0, "reset_frames": 5},
{"alpha": 0.3, "jump_reset_m": 0.0, "reset_frames": 5},
{"alpha": 0.3, "jump_reset_m": 5.0, "reset_frames": 0},
],
)
def test_invalid_ctor_args(kwargs):
with pytest.raises(ValueError):
DistanceFilter(**kwargs)
+103
View File
@@ -0,0 +1,103 @@
"""测距计算与有效性判定测试(规格 §3.2/§6.4/§6.6/§7.1/§11.4)。"""
import math
import pytest
from stereo_ranger import (
Detection,
StereoConfig,
confidence_from_cost,
evaluate_validity,
forward_distance_m,
range_error_abs_m,
range_error_rel,
solve_range,
virtual_center_px,
)
CFG = StereoConfig()
def _boxes_for_point(x_m: float, y_m: float, z_m: float, width_px: float = 50.0, height_px: float = 40.0):
"""由左目系 (X, Y, Z) 反推左右目框(同宽,边缘视差 = 中心视差 = F·B/Z)。"""
d_px = CFG.focal_baseline_px_m / z_m
x_l = CFG.cx_px + CFG.fx_px * x_m / z_m
y_l = CFG.cy_px + CFG.fy_px * y_m / z_m
left = Detection(x_l - width_px / 2, y_l - height_px / 2, x_l + width_px / 2, y_l + height_px / 2)
right = Detection(
x_l - width_px / 2 - d_px, y_l - height_px / 2, x_l + width_px / 2 - d_px, y_l + height_px / 2
)
return left, right, d_px
def test_solve_range_recovers_ground_truth():
# §11.4:已知 Z 反推 D 构造框对,断言 Z = F·B/D 回收
left, right, d_px = _boxes_for_point(0.6, 0.3, 6.0)
solution = solve_range(left, right, d_px, CFG)
assert solution.z_m == pytest.approx(6.0)
assert solution.x_m == pytest.approx(0.6)
assert solution.y_m == pytest.approx(0.3)
assert solution.r_m == pytest.approx(math.sqrt(0.6**2 + 0.3**2 + 6.0**2))
def test_forward_distance_rejects_non_positive_disparity():
with pytest.raises(ValueError):
forward_distance_m(0.0, CFG)
with pytest.raises(ValueError):
forward_distance_m(-2.0, CFG)
def test_virtual_center_equals_midline_camera_projection():
# §6.6 恒等式:xL = cx + F(X_C + B/2)/Z,xR = cx + F(X_C − B/2)/Z ⇒ c_mid = cx + F·X_C/Z
z_m = 5.0
x_center_m = 0.57
x_l = CFG.cx_px + CFG.fx_px * (x_center_m + CFG.baseline_m / 2.0) / z_m
x_r = CFG.cx_px + CFG.fx_px * (x_center_m - CFG.baseline_m / 2.0) / z_m
left = Detection(x_l - 30.0, 480.0, x_l + 30.0, 520.0)
right = Detection(x_r - 30.0, 480.0, x_r + 30.0, 520.0)
center_mid = virtual_center_px(left, right)
assert center_mid[0] == pytest.approx(CFG.cx_px + CFG.fx_px * x_center_m / z_m)
assert center_mid[1] == pytest.approx(500.0)
def test_error_model_is_quadratic_in_z():
assert range_error_abs_m(10.0, CFG) == pytest.approx(10.0**2 / CFG.focal_baseline_px_m)
assert range_error_rel(10.0, CFG) == pytest.approx(10.0 / CFG.focal_baseline_px_m)
assert range_error_abs_m(20.0, CFG) == pytest.approx(4.0 * range_error_abs_m(10.0, CFG))
def test_validity_gate_1_out_of_domain():
decision = evaluate_validity(CFG.disparity_min_px - 0.1, 30.0, CFG)
assert not decision.in_domain and not decision.valid
def test_validity_gate_2_error_budget_blocks_far_field():
# 域内(D = 4.8 px → Z = 12 m)但 δZ = 2.5 m > 2 m,预算门拦截
d_px = 4.8
z_m = CFG.focal_baseline_px_m / d_px
decision = evaluate_validity(d_px, z_m, CFG)
assert decision.in_domain and not decision.budget_ok and not decision.valid
def test_validity_gate_2_boundary_sides():
z_limit = CFG.z_error_budget_max_m
assert evaluate_validity(CFG.focal_baseline_px_m / z_limit, z_limit * 0.999, CFG).budget_ok
assert not evaluate_validity(CFG.focal_baseline_px_m / z_limit, z_limit * 1.001, CFG).budget_ok
def test_validity_requires_finite_z():
assert not evaluate_validity(5.0, None, CFG).valid
assert not evaluate_validity(5.0, float("inf"), CFG).valid
def test_domain_boundaries_are_inclusive():
assert evaluate_validity(CFG.disparity_min_px, 19.2, CFG).in_domain
assert evaluate_validity(CFG.disparity_max_px, 1.0, CFG).in_domain
def test_confidence_from_cost_linear_clamped():
assert confidence_from_cost(0.0) == pytest.approx(1.0)
assert confidence_from_cost(0.4) == pytest.approx(0.6)
assert confidence_from_cost(1.0) == pytest.approx(0.0)
assert confidence_from_cost(1.5) == pytest.approx(0.0)
@@ -0,0 +1,87 @@
"""匹配器测试(规格 §6.2/§11.4:硬约束各一例、贪心排序、一一配对、cost ≥ 1 拒绝)。"""
import pytest
from stereo_ranger import Detection, GreedyMatcher, SamplePoint, StereoConfig, sample_point
CFG = StereoConfig()
MATCHER = GreedyMatcher()
def _point(cx: float, cy: float, *, w: float = 60.0, h: float = 40.0, class_id: int = 0) -> SamplePoint:
return sample_point(
Detection(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0, class_id=class_id)
)
def test_class_mismatch_rejected():
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0, class_id=1)], CFG) == []
def test_dy_beyond_tau_rejected():
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 502.1)], CFG) == []
def test_dy_within_tau_accepted_with_cost():
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 501.9)], CFG)
assert len(pairs) == 1
assert pairs[0].cost == pytest.approx(1.9 / CFG.tau_y_px)
def test_height_ratio_beyond_tau_rejected():
assert MATCHER.match([_point(1000.0, 500.0, h=100.0)], [_point(1000.0, 500.0, h=140.0)], CFG) == []
def test_height_ratio_near_tau_accepted():
pairs = MATCHER.match([_point(1000.0, 500.0, h=120.0)], [_point(1000.0, 500.0, h=159.0)], CFG)
assert len(pairs) == 1
def test_cost_at_one_rejected():
# dy = tau_y ⇒ cost = 1.0,严格 cost < 1 才接受(§6.2)
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 502.0)], CFG) == []
def test_combined_cost_at_one_rejected():
# dy/tau = 0.5 与 Δh/(tau_h·h_max) = 0.5 相加恰为 1.0 → 拒绝
assert MATCHER.match([_point(1000.0, 500.0, h=112.0)], [_point(1000.0, 501.0, h=128.0)], CFG) == []
def test_greedy_pairs_cheapest_first_and_one_to_one():
left = [_point(1000.0, 500.0), _point(1000.0, 501.0)]
right = [_point(1000.0, 500.0), _point(1000.0, 501.0)]
pairs = MATCHER.match(left, right, CFG)
assert [(p.left_index, p.right_index) for p in pairs] == [(0, 0), (1, 1)]
assert pairs[0].cost == pytest.approx(0.0)
def test_one_left_claims_cheapest_right():
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0), _point(1000.0, 500.5)], CFG)
assert [(p.left_index, p.right_index) for p in pairs] == [(0, 0)]
def test_two_lefts_cannot_share_one_right():
left = [_point(1000.0, 500.0), _point(1000.0, 500.2)]
right = [_point(1000.0, 500.0)]
pairs = MATCHER.match(left, right, CFG)
assert len(pairs) == 1
assert pairs[0].left_index == 0
def test_unmatched_right_is_not_paired():
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0), _point(1400.0, 500.0)], CFG)
assert len(pairs) == 1
assert pairs[0].right_index == 0
def test_non_finite_points_excluded():
bad = sample_point(Detection(float("nan"), 500.0, 1060.0, 540.0))
good = _point(1000.0, 500.0)
assert MATCHER.match([bad], [good], CFG) == []
assert MATCHER.match([good], [bad], CFG) == []
def test_zero_height_box_rejected():
left = sample_point(Detection(970.0, 500.0, 1030.0, 500.0))
right = sample_point(Detection(970.0, 500.0, 1030.0, 500.0))
assert MATCHER.match([left], [right], CFG) == []
@@ -0,0 +1,79 @@
"""视差取点器测试(规格 §6.1/§11.4:同侧边缘、框宽差隔离、越窗回退、边界含等号)。"""
import pytest
from stereo_ranger import Detection, DisparitySource, StereoConfig, sample_point, select_disparity
CFG = StereoConfig()
def _box(x1: float, x2: float, *, y1: float = 500.0, y2: float = 540.0) -> Detection:
return Detection(x1, y1, x2, y2)
def _disparity(left: Detection, right: Detection):
return select_disparity(sample_point(left), sample_point(right), CFG)
def test_left_edge_used_as_primary_sampling():
d = _disparity(_box(1000.0, 1060.0), _box(990.4, 1050.4))
assert d.source is DisparitySource.LEFT_EDGE
assert d.d_px == pytest.approx(9.6)
assert d.in_domain and d.positive and not d.fallback_used
def test_box_width_difference_does_not_enter_edge_disparity():
# §11.4:构造两目不等宽框,断言同侧边缘视差相同(框宽差不进入视差)
left = _box(1000.0, 1100.0) # 宽 100
right_narrow = _box(994.0, 1024.0) # 宽 30,左边缘差同 6 px
right_wide = _box(994.0, 1094.0) # 宽 100
d_narrow = _disparity(left, right_narrow)
d_wide = _disparity(left, right_wide)
assert d_narrow.d_px == pytest.approx(6.0)
assert d_wide.d_px == pytest.approx(6.0)
def test_center_disparity_would_be_polluted_by_width_difference():
# 对照:中心视差 41 px = 边缘视差 6 px + 框宽差的一半 35 px,证明主取点选择边缘的理由
left = _box(1000.0, 1100.0) # 宽 100, center 1050
right_narrow = _box(994.0, 1024.0) # 宽 30, center 1009
assert (left.cx - right_narrow.cx) == pytest.approx(41.0)
def test_center_fallback_when_edge_out_of_window():
# 边缘视差 60 px 越窗(> 57.6),中心视差 40 px 在域内 → 回退中心并标记
left = _box(1000.0, 1060.0) # center 1030
right = _box(940.0, 1040.0) # center 990
d = _disparity(left, right)
assert d.fallback_used is True
assert d.source is DisparitySource.CENTER_FALLBACK
assert d.d_px == pytest.approx(40.0)
assert d.in_domain and d.positive
def test_both_out_of_window_keeps_edge_value_for_diagnostics():
left = _box(1000.0, 1200.0) # center 1100
right = _box(900.0, 1000.0) # center 950
d = _disparity(left, right)
assert d.d_px == pytest.approx(100.0) # 边缘值保留(诊断 + 违例告警)
assert not d.in_domain and not d.fallback_used and d.positive
def test_domain_boundaries_are_inclusive():
assert _disparity(_box(1000.0, 1060.0), _box(997.0, 1057.0)).in_domain # D = 3.0 = 下限
# 上限用二进制可精确表示的 57.5 构造:十进制字面量 57.6 无法精确表示,
# 框坐标相减会落到 57.6 常量之上,边界断言被浮点舍入干扰
exact_cfg = StereoConfig(disparity_max_px=57.5)
left, right = _box(1000.0, 1060.0), _box(942.5, 1002.5)
assert select_disparity(sample_point(left), sample_point(right), exact_cfg).in_domain # D = 57.5 = 上限
def test_negative_disparity_flagged_as_violation():
d = _disparity(_box(1000.0, 1060.0), _box(1010.0, 1070.0))
assert d.d_px == pytest.approx(-10.0)
assert not d.positive and not d.in_domain
def test_non_finite_box_marked_unusable():
assert sample_point(Detection(float("nan"), 0.0, 10.0, 10.0)).finite is False
assert sample_point(Detection(0.0, 0.0, 10.0, 10.0)).finite is True
@@ -0,0 +1,292 @@
"""全流程测试(规格 §5/§11.4:单目标、多目标、单目退化、非同帧拒收、越界、跳变)。
合成框对驱动,无需真实图像:几何是解析可断言的(§11.4)。
"""
import logging
import math
from typing import List, Optional, Tuple
import pytest
from stereo_ranger import (
Degradation,
Detection,
DetectionFrame,
Eye,
MatchPair,
StereoConfig,
StereoRanger,
)
CFG = StereoConfig()
LOGGER_NAME = "stereo_ranger"
def _boxes(
x_center_m: float = 0.0,
y_m: float = 0.0,
z_m: float = 6.0,
*,
w_m: float = 0.5,
h_m: float = 0.3,
class_id: int = 0,
track_id: Optional[int] = None,
confidence: float = 0.95,
) -> Tuple[Detection, Detection]:
"""模组中心系 (X_C, Y, Z) 目标 → 左右目检测框(两目同宽,边缘视差 = 中心视差)。"""
x_l = CFG.cx_px + CFG.fx_px * (x_center_m + CFG.baseline_m / 2.0) / z_m
x_r = CFG.cx_px + CFG.fx_px * (x_center_m - CFG.baseline_m / 2.0) / z_m
y = CFG.cy_px + CFG.fy_px * y_m / z_m
w_px = CFG.fx_px * w_m / z_m
h_px = CFG.fy_px * h_m / z_m
def _box(cx_px: float) -> Detection:
return Detection(
cx_px - w_px / 2.0,
y - h_px / 2.0,
cx_px + w_px / 2.0,
y + h_px / 2.0,
confidence=confidence,
class_id=class_id,
track_id=track_id,
)
return _box(x_l), _box(x_r)
def _pair_with_disparity(d_px: float, *, y_center: float = 500.0, class_id: int = 0) -> Tuple[Detection, Detection]:
"""构造视差恰为 d_px 的等宽框对(d 取 0.5 的整数倍时浮点精确)。"""
width, height = 60.0, 40.0
left = Detection(1000.0 - width / 2, y_center - height / 2, 1000.0 + width / 2, y_center + height / 2, class_id=class_id)
right = Detection(
1000.0 - d_px - width / 2,
y_center - height / 2,
1000.0 - d_px + width / 2,
y_center + height / 2,
class_id=class_id,
)
return left, right
def _frame(frame_id: int, detections: List[Detection]) -> DetectionFrame:
return DetectionFrame(frame_id=frame_id, detections=detections)
def test_single_target_complete_valid_record():
ranger = StereoRanger()
left, right = _boxes(x_center_m=0.0, z_m=6.0)
records = ranger.update(_frame(1, [left]), _frame(1, [right]), now_ms=1000)
assert len(records) == 1
record = records[0]
assert record.degradation is Degradation.COMPLETE
assert record.valid is True
assert record.forward_distance_m == pytest.approx(6.0)
assert record.slant_range_m == pytest.approx(math.hypot(CFG.baseline_m / 2.0, 6.0)) # R 含左目系 +B/2 横向偏移
assert record.disparity_px == pytest.approx(CFG.focal_baseline_px_m / 6.0)
assert record.center_mid_px == pytest.approx((CFG.cx_px, CFG.cy_px))
assert record.reference_box == left.bbox
assert record.reference_eye is Eye.LEFT
assert record.confidence == pytest.approx(1.0)
assert record.frame_id == 1 and record.timestamp_ms == 1000
def test_filter_smooths_across_valid_frames():
ranger = StereoRanger()
left_1, right_1 = _boxes(z_m=6.0)
left_2, right_2 = _boxes(z_m=6.5)
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].valid is True
assert records[0].forward_distance_m == pytest.approx(6.0 + 0.3 * (6.5 - 6.0))
# 参考斜距与已发布 Z 同基准:按 z_out/z_raw 从本帧原始斜距精确缩放(R ≥ Z)
raw_slant = math.hypot(CFG.baseline_m / 2.0, 6.5)
assert records[0].slant_range_m == pytest.approx(raw_slant * 6.15 / 6.5)
def test_jump_reset_skips_smoothing():
ranger = StereoRanger()
left_1, right_1 = _boxes(z_m=4.0)
left_2, right_2 = _boxes(z_m=10.0) # 跳变 6 m > jump_reset_m = 5 m
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].forward_distance_m == pytest.approx(10.0)
def test_far_field_out_of_domain_still_matched():
# 越界 = 有效性门(决策):配对仍成立,c_mid(无偏方位)与 D 照常发布,无 Z
ranger = StereoRanger()
left, right = _boxes(x_center_m=0.6, z_m=25.0) # D ≈ 2.3 px < 3 px
records = ranger.update(_frame(1, [left]), _frame(1, [right]))
record = records[0]
assert record.degradation is Degradation.COMPLETE
assert record.valid is False
assert record.forward_distance_m is None
assert record.disparity_px == pytest.approx(2.304)
assert record.center_mid_px == pytest.approx((CFG.cx_px + CFG.fx_px * 0.6 / 25.0, CFG.cy_px))
assert record.slant_range_m == pytest.approx(math.hypot(0.6 + CFG.baseline_m / 2.0, 25.0))
def test_error_budget_gate_blocks_far_but_in_domain():
ranger = StereoRanger()
left, right = _boxes(z_m=12.0) # D = 4.8 px 域内,δZ = 2.5 m > 2 m
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.disparity_px == pytest.approx(4.8)
assert record.valid is False
assert record.forward_distance_m is None
assert record.slant_range_m == pytest.approx(math.hypot(CFG.baseline_m / 2.0, 12.0))
def test_error_budget_gate_passes_just_inside():
ranger = StereoRanger()
left, right = _pair_with_disparity(5.8) # Z ≈ 9.93 m,δZ ≈ 1.71 m ≤ 2 m
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.valid is True
assert record.forward_distance_m == pytest.approx(CFG.focal_baseline_px_m / 5.8)
def test_negative_disparity_violation(caplog):
ranger = StereoRanger()
left = Detection(1000.0, 500.0, 1060.0, 540.0)
right = Detection(1010.0, 500.0, 1070.0, 540.0)
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.degradation is Degradation.COMPLETE
assert record.valid is False
assert record.forward_distance_m is None
assert record.slant_range_m is None
assert record.disparity_px == pytest.approx(-10.0)
assert any("disparity_violation" in message for message in caplog.messages)
def test_center_fallback_keeps_target_valid(caplog):
ranger = StereoRanger()
left = Detection(1000.0, 500.0, 1060.0, 540.0) # 宽 60, center 1030
right = Detection(940.0, 500.0, 1040.0, 540.0) # 宽 100, center 990
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
assert record.disparity_px == pytest.approx(40.0) # 边缘 60 px 越窗 → 中心 40 px
assert record.valid is True
assert record.forward_distance_m == pytest.approx(CFG.focal_baseline_px_m / 40.0)
assert any("center_fallback" in message for message in caplog.messages)
def test_single_eye_degradation_left_and_right():
ranger = StereoRanger()
left, right = _boxes(x_center_m=0.3, z_m=4.0)
left_only = ranger.update(_frame(1, [left]), _frame(1, []))
assert len(left_only) == 1
record = left_only[0]
assert record.degradation is Degradation.BEARING_ONLY
assert record.valid is False
assert record.center_mid_px == pytest.approx((left.cx, left.cy))
assert record.forward_distance_m is None and record.disparity_px is None
assert record.reference_eye is Eye.LEFT
assert record.confidence == pytest.approx(left.confidence)
right_only = ranger.update(_frame(2, []), _frame(2, [right]))
assert len(right_only) == 1
assert right_only[0].reference_eye is Eye.RIGHT
assert right_only[0].center_mid_px == pytest.approx((right.cx, right.cy))
def test_records_order_left_first_then_unmatched_right():
ranger = StereoRanger()
left, right_matched = _boxes(z_m=6.0, class_id=0)
_, right_extra = _boxes(z_m=6.0, class_id=0, y_m=0.0)
right_extra = Detection(400.0, 500.0, 460.0, 540.0) # 远处无对应左框
records = ranger.update(_frame(1, [left]), _frame(1, [right_matched, right_extra]))
assert len(records) == 2
assert records[0].degradation is Degradation.COMPLETE
assert records[0].valid is True
assert records[1].degradation is Degradation.BEARING_ONLY
assert records[1].reference_eye is Eye.RIGHT
def test_frame_id_mismatch_rejects_frame_and_throttles_warning(caplog):
ranger = StereoRanger()
left, right = _boxes()
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
assert ranger.update(_frame(1, [left]), _frame(2, [right])) == []
assert ranger.update(_frame(3, [left]), _frame(4, [right])) == []
warnings = [message for message in caplog.messages if "frame_mismatch" in message]
assert len(warnings) == 1 # 第 1 次打印,第 2 次被节流
assert "累计 1 次" in warnings[0]
def test_empty_input_returns_empty_without_warning(caplog):
ranger = StereoRanger()
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
assert ranger.update(_frame(1, []), _frame(1, [])) == []
assert caplog.messages == []
def test_non_finite_box_dropped_with_warning(caplog):
ranger = StereoRanger()
bad = Detection(float("nan"), 500.0, 1060.0, 540.0)
good_left, good_right = _boxes(z_m=6.0)
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
records = ranger.update(_frame(1, [bad, good_left]), _frame(1, [good_right]))
assert len(records) == 1
assert records[0].valid is True
assert any("invalid_box" in message for message in caplog.messages)
def test_consistency_degrades_then_invalidates(caplog):
ranger = StereoRanger()
disparities = [9.5, 13.5, 17.5, 21.5, 25.5, 29.5] # 每帧跳变 4 px > 3 px
records = []
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
for index, d_px in enumerate(disparities, start=1):
left, right = _pair_with_disparity(d_px)
records.append(ranger.update(_frame(index, [left]), _frame(index, [right]))[0])
assert [record.valid for record in records] == [True, True, True, True, True, False]
assert records[3].confidence == pytest.approx(0.5) # 第 3 帧起置信度降级(N = 3)
assert records[5].forward_distance_m is None
assert records[5].confidence == pytest.approx(0.0)
assert any("consistency_invalid" in message for message in caplog.messages)
def test_track_id_keeps_state_across_large_pixel_move():
ranger = StereoRanger()
left_1, right_1 = _boxes(x_center_m=0.6, z_m=4.0, track_id=7)
left_2, right_2 = _boxes(x_center_m=0.0, z_m=4.5, track_id=7) # c_mid 移动 144 px > 门限
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].track_id == 7
assert records[0].forward_distance_m == pytest.approx(4.0 + 0.3 * (4.5 - 4.0))
def test_without_track_id_large_move_starts_new_state():
ranger = StereoRanger()
left_1, right_1 = _boxes(x_center_m=0.6, z_m=4.0)
left_2, right_2 = _boxes(x_center_m=0.0, z_m=4.5) # 最近邻越门限 → 新目标状态
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].forward_distance_m == pytest.approx(4.5) # 未平滑
def test_custom_matcher_injection():
class _NoMatchMatcher:
def match(self, left, right, config) -> List[MatchPair]:
return []
ranger = StereoRanger(matcher=_NoMatchMatcher())
left, right = _boxes(z_m=6.0)
records = ranger.update(_frame(1, [left]), _frame(1, [right]))
assert len(records) == 2
assert all(record.degradation is Degradation.BEARING_ONLY for record in records)
def test_reset_clears_state():
ranger = StereoRanger()
left_1, right_1 = _boxes(z_m=6.0)
left_2, right_2 = _boxes(z_m=6.5)
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
ranger.reset()
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
assert records[0].forward_distance_m == pytest.approx(6.5) # 未平滑(状态已清空)
@@ -0,0 +1,73 @@
"""目标隔离测试(规格 §4:track_id 优先 + 框位置最近邻、帧末老化清理)。"""
from stereo_ranger import StereoConfig, TargetStore
CFG = StereoConfig() # isolation_gate_px = 50,filter_reset_frames = 5
def test_track_key_stable_across_position_move():
store = TargetStore(CFG)
key_1 = store.resolve(0, 7, (100.0, 100.0), 1)
key_2 = store.resolve(0, 7, (400.0, 100.0), 2) # 位置大幅移动仍是同一目标
assert key_1 == key_2
assert store.count == 1
def test_nearest_neighbor_keeps_identity_within_gate():
store = TargetStore(CFG)
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
key_2 = store.resolve(0, None, (130.0, 140.0), 2) # 距离 50 ≤ 门限
assert key_1 == key_2
def test_beyond_gate_creates_new_key():
store = TargetStore(CFG)
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
key_2 = store.resolve(0, None, (200.0, 100.0), 2) # 距离 100 > 门限
assert key_1 != key_2
assert store.count == 2
def test_class_id_separates_states():
store = TargetStore(CFG)
assert store.resolve(0, None, (100.0, 100.0), 1) != store.resolve(1, None, (100.0, 100.0), 1)
def test_two_boxes_same_frame_claim_distinct_states():
store = TargetStore(CFG)
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
key_2 = store.resolve(0, None, (110.0, 100.0), 1) # 同帧第二框不得接管已占用状态
assert key_1 != key_2
assert store.count == 2
def test_state_evicted_after_reset_frames_unseen():
store = TargetStore(CFG)
key = store.resolve(0, None, (100.0, 100.0), 1)
store.distance_filter.advance(key, 5.0)
store.consistency.advance(key, 9.6)
for frame in range(2, 7): # 第 2~6 帧:连续 5 帧缺席
store.end_frame(frame)
assert store.count == 0
assert store.distance_filter.value(key) is None
assert store.consistency.streak(key) == 0
def test_present_but_invalid_keeps_position_state():
store = TargetStore(CFG)
key = store.resolve(0, None, (100.0, 100.0), 1)
store.distance_filter.advance(key, 5.0)
for frame in range(2, 8):
store.resolve(0, None, (100.0, 100.0), frame) # 每帧仍出现
store.distance_filter.invalidate(key)
assert store.count == 1 # 位置状态保留
assert store.distance_filter.value(key) is None # 滤波状态已按门限清空
def test_clear_resets_everything():
store = TargetStore(CFG)
key = store.resolve(0, 5, (100.0, 100.0), 1)
store.distance_filter.advance(key, 5.0)
store.clear()
assert store.count == 0
assert store.distance_filter.value(key) is None
@@ -0,0 +1,45 @@
"""告警节流测试(规格 §5:第 1 次 + 每满 100 次,带累计计数)。"""
import pytest
from stereo_ranger import WarnThrottle
def test_first_and_every_hundred_emit():
throttle = WarnThrottle() # 默认 every=100
assert throttle.should_emit("a") is True
for _ in range(98):
assert throttle.should_emit("a") is False
assert throttle.should_emit("a") is True # 第 100 次
for _ in range(99):
assert throttle.should_emit("a") is False
assert throttle.should_emit("a") is True # 第 200 次
assert throttle.count("a") == 200
def test_categories_are_independent():
throttle = WarnThrottle()
assert throttle.should_emit("a") is True
assert throttle.should_emit("b") is True
assert throttle.count("a") == 1
assert throttle.count("b") == 1
assert throttle.count("never_seen") == 0
def test_custom_interval():
throttle = WarnThrottle(every=3)
# 规格语义"第 1 次 + 计数每满 every" → 第 1/3/6 次打印
assert [throttle.should_emit("a") for _ in range(6)] == [True, False, True, False, False, True]
def test_clear_resets_counts():
throttle = WarnThrottle()
throttle.should_emit("a")
throttle.clear()
assert throttle.count("a") == 0
assert throttle.should_emit("a") is True
def test_invalid_interval():
with pytest.raises(ValueError):
WarnThrottle(every=0)
+96
View File
@@ -0,0 +1,96 @@
"""StereoYoloConfig 校验与 JSON 加载测试(需求说明 §4:配置外置、fail-fast)。"""
import json
from pathlib import Path
import pytest
from stereo_yolo import StereoYoloConfig, load_from_json
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "stereo_yolo.json"
def _config_dict() -> dict:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
def _write(tmp_path: Path, data: dict) -> str:
path = tmp_path / "stereo_yolo.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return str(path)
def test_load_from_json_matches_defaults():
assert load_from_json(str(CONFIG_PATH)) == StereoYoloConfig()
def test_whitelist_normalized_to_tuple():
cfg = StereoYoloConfig(class_whitelist=[0, 2])
assert cfg.class_whitelist == (0, 2)
def test_missing_field_rejected(tmp_path):
data = _config_dict()
data.pop("imgsz")
with pytest.raises(ValueError, match="缺少配置字段"):
load_from_json(_write(tmp_path, data))
def test_unknown_field_rejected(tmp_path):
data = _config_dict()
data["letterbox_scale"] = 0.5
with pytest.raises(ValueError, match="未知配置字段"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("model_path", 123),
("imgsz", "640"),
("imgsz", True),
("conf_threshold", "0.25"),
("iou_threshold", None),
("class_whitelist", 0),
("class_whitelist", ["0"]),
("device", 5),
("concurrency", 1),
],
)
def test_type_error_rejected(tmp_path, field, value):
data = _config_dict()
data[field] = value
with pytest.raises(ValueError, match="配置类型非法"):
load_from_json(_write(tmp_path, data))
@pytest.mark.parametrize(
"field,value",
[
("model_path", ""),
("imgsz", 0),
("imgsz", 600),
("conf_threshold", -0.1),
("conf_threshold", 1.5),
("iou_threshold", 0.0),
("class_whitelist", []),
("class_whitelist", [-1]),
("device", " "),
("concurrency", "batch"),
],
)
def test_invalid_values_rejected(field, value):
with pytest.raises(ValueError, match="配置非法"):
StereoYoloConfig(**{field: value})
def test_boundary_values_accepted():
cfg = StereoYoloConfig(
imgsz=32,
conf_threshold=0.0,
iou_threshold=1.0,
class_whitelist=(0,),
device="cpu",
concurrency="sequential",
)
assert cfg.imgsz == 32
+155
View File
@@ -0,0 +1,155 @@
"""StereoYoloFrontend 出口契约测试(需求说明 §2.2/§2.3/§2.5/§5)。
全部用注入的假检测器驱动:不需要模型权重、不需要图像素材、不需要硬件;
真并行用双人屏障断言(串行实现会屏障超时)。
"""
import logging
import threading
import numpy as np
import pytest
from stereo_ranger.types import Detection
from stereo_yolo import StereoYoloConfig, StereoYoloFrontend
class ScriptedDetector:
"""按调用顺序返回脚本项:list[Detection] 正常返回;Exception 抛出。"""
def __init__(self, script):
self._script = list(script)
self.calls = 0
def detect(self, image):
self.calls += 1
if not self._script:
raise AssertionError("假检测器脚本已耗尽(调用次数多于预期)")
item = self._script.pop(0)
if isinstance(item, Exception):
raise item
return list(item)
class BarrierDetector:
"""两次调用必须同时在途(屏障超时即证明串行或单线程执行)。"""
def __init__(self, barrier: threading.Barrier, detections):
self._barrier = barrier
self._detections = list(detections)
def detect(self, image):
self._barrier.wait(timeout=2.0)
return list(self._detections)
def _img(fill=0, shape=(8, 8, 3)) -> np.ndarray:
return np.full(shape, fill, dtype=np.uint8)
def _det(x1=1.0, y1=2.0, x2=11.0, y2=12.0, class_id=0, confidence=0.9, track_id=None) -> Detection:
return Detection(x1, y1, x2, y2, confidence=confidence, class_id=class_id, track_id=track_id)
def _frontend(script, **cfg_kwargs) -> StereoYoloFrontend:
kwargs = {"concurrency": "sequential"}
kwargs.update(cfg_kwargs)
return StereoYoloFrontend(StereoYoloConfig(**kwargs), detector=ScriptedDetector(script))
def test_frames_share_frame_id_and_keep_boxes():
left_box, right_box = _det(1.0, 2.0, 11.0, 12.0, confidence=0.77), _det(3.0, 4.0, 13.0, 14.0)
frontend = _frontend([[left_box], [right_box]])
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=7)
assert left_frame.frame_id == right_frame.frame_id == 7
assert left_frame.detections == (left_box,)
assert right_frame.detections == (right_box,)
assert left_frame.detections[0].confidence == pytest.approx(0.77)
assert left_frame.detections[0].track_id is None # 无追踪器时留空(§2.1)
assert frontend._detector.calls == 2
def test_whitelist_filters_by_class():
frontend = _frontend([[_det(class_id=3)], [_det(class_id=0)]], class_whitelist=(0,))
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=1)
assert left_frame.detections == ()
assert len(right_frame.detections) == 1 and right_frame.detections[0].class_id == 0
def test_no_whitelist_keeps_all_classes():
frontend = _frontend([[_det(class_id=3)], [_det(class_id=5)]])
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=1)
assert len(left_frame.detections) == 1 and len(right_frame.detections) == 1
def test_degenerate_boxes_dropped(caplog):
nan_box = _det(x1=float("nan"))
zero_width = _det(x1=5.0, x2=5.0)
inverted = _det(x1=20.0, x2=10.0)
good = _det()
frontend = _frontend([[nan_box, zero_width, inverted, good], []])
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=2)
assert left_frame.detections == (good,)
assert right_frame.detections == ()
assert "丢弃退化检测框" in caplog.text
assert "累计 1 次" in caplog.text
def test_boundary_touching_box_kept():
hairline = _det(x1=10.0, x2=10.0 + 1e-9)
frontend = _frontend([[hairline], []])
left_frame, _ = frontend.detect(_img(), _img(), frame_id=3)
assert left_frame.detections == (hairline,)
def test_one_eye_failure_isolated(caplog):
good = _det()
frontend = _frontend([[good], RuntimeError("boom")])
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=4)
assert left_frame.detections == (good,)
assert right_frame.detections == ()
assert left_frame.frame_id == right_frame.frame_id == 4
assert "右目推理失败" in caplog.text
def test_shape_mismatch_rejected(caplog):
frontend = _frontend([]) # 脚本为空:若检测器被调用会抛断言
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
left_frame, right_frame = frontend.detect(_img(shape=(8, 8, 3)), _img(shape=(8, 6, 3)), frame_id=5)
assert left_frame.detections == () and right_frame.detections == ()
assert left_frame.frame_id == right_frame.frame_id == 5
assert frontend._detector.calls == 0
assert "左右图尺寸不一致" in caplog.text
def test_parallel_eyes_run_concurrently():
barrier = threading.Barrier(2)
box = _det()
frontend = StereoYoloFrontend(
StereoYoloConfig(concurrency="parallel"),
detector=BarrierDetector(barrier, [box]),
)
try:
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=6)
finally:
frontend.close()
assert left_frame.detections == (box,)
assert right_frame.detections == (box,)
def test_close_is_idempotent():
frontend = StereoYoloFrontend(StereoYoloConfig(concurrency="parallel"), detector=ScriptedDetector([]))
frontend.close()
frontend.close()
+27
View File
@@ -0,0 +1,27 @@
"""split_sbs 裁切测试(需求说明 §2.3:sbs 单流形态)。"""
import numpy as np
import pytest
from stereo_yolo import split_sbs
def test_split_returns_left_and_right_halves():
image = np.zeros((4, 10, 3), dtype=np.uint8)
image[:, :5] = 1
image[:, 5:] = 2
left, right = split_sbs(image)
assert left.shape == (4, 5, 3) and right.shape == (4, 5, 3)
assert (left == 1).all() and (right == 2).all()
def test_odd_width_rejected():
with pytest.raises(ValueError, match="宽度须为偶数"):
split_sbs(np.zeros((4, 9, 3), dtype=np.uint8))
def test_non_image_rejected():
with pytest.raises(ValueError, match="宽度须为偶数"):
split_sbs(np.zeros((9,), dtype=np.uint8))