精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航

归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。

目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。

自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。

任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。

入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。

新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
This commit is contained in:
keita
2026-10-21 14:48:41 +08:00
parent 53fdd3b13f
commit df91b1110e
159 changed files with 6121 additions and 1617 deletions
@@ -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)
@@ -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)
@@ -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
@@ -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()
@@ -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))
@@ -0,0 +1,107 @@
"""kalman_box_filter_test.py —— KalmanBoxFilter 单元测试。
移植自 reference-c++/target_tracker/tests/kalman_box_filter_test.cpp。
覆盖(视觉追踪技术路线 §4):初始化状态、静止目标收敛、匀速目标速度收敛
且预测超前一个 dt、未初始化 predict 返回 None。
"""
import math
import pytest
from target_tracker.kalman_box_filter import BoxCXCYWH, KalmanBoxFilter
def test_uninitialized_predict_returns_none():
kf = KalmanBoxFilter()
assert not kf.initialized
assert kf.predict() is None
# 未初始化时 update 无副作用
kf.update(BoxCXCYWH(100.0, 100.0, 20.0, 20.0))
assert not kf.initialized
assert kf.predict() is None
for v in kf.state:
assert v == 0.0
def test_initiate_sets_state_correctly():
kf = KalmanBoxFilter()
kf.initiate(BoxCXCYWH(320.0, 240.0, 40.0, 30.0))
assert kf.initialized
expected = [320.0, 240.0, 40.0, 30.0, 0.0, 0.0, 0.0, 0.0]
state = kf.state
for i, want in enumerate(expected):
assert state[i] == want, f"index={i}"
# 初始化后 predict 返回状态前 4 维(速度为零 → 位置不变,§4.4)
pred = kf.predict()
assert pred is not None
assert pred.cx == pytest.approx(320.0, abs=1e-9)
assert pred.cy == pytest.approx(240.0, abs=1e-9)
assert pred.w == pytest.approx(40.0, abs=1e-9)
assert pred.h == pytest.approx(30.0, abs=1e-9)
def test_static_target_converges_to_observation():
kf = KalmanBoxFilter()
z = BoxCXCYWH(100.0, 150.0, 40.0, 60.0)
kf.initiate(BoxCXCYWH(104.0, 156.0, 42.0, 62.0)) # 带初始偏差,验证收敛能力
for _ in range(50):
kf.predict()
kf.update(z)
state = kf.state
assert state[0] == pytest.approx(z.cx, abs=0.5) # cx
assert state[1] == pytest.approx(z.cy, abs=0.5) # cy
assert state[2] == pytest.approx(z.w, abs=0.5) # w
assert state[3] == pytest.approx(z.h, abs=0.5) # h
# 静止目标速度应收敛到约 0
assert state[4] == pytest.approx(0.0, abs=0.1)
assert state[5] == pytest.approx(0.0, abs=0.1)
def test_constant_velocity_converges_and_predict_leads_one_dt():
kf = KalmanBoxFilter()
vx = 3.0 # 像素/帧
vy = 2.0 # 像素/帧
# 目标从 (100, 100) 出发匀速运动,每帧观测一次(predict → update)
z = BoxCXCYWH(100.0, 100.0, 30.0, 30.0)
kf.initiate(z)
for _ in range(60):
kf.predict()
z.cx += vx
z.cy += vy
kf.update(z)
state = kf.state
# 速度收敛到真实速度(像素/帧)
assert state[4] == pytest.approx(vx, abs=0.3), "vx 未收敛"
assert state[5] == pytest.approx(vy, abs=0.3), "vy 未收敛"
# 位置贴合当前观测
assert state[0] == pytest.approx(z.cx, abs=1.5)
assert state[1] == pytest.approx(z.cy, abs=1.5)
# 再预测一帧:应超前当前观测一个 dt(≈ 速度 × 1 帧)
pred = kf.predict()
assert pred is not None
assert pred.cx == pytest.approx(z.cx + vx, abs=1.0), "预测未超前一个 dt"
assert pred.cy == pytest.approx(z.cy + vy, abs=1.0), "预测未超前一个 dt"
def test_small_target_noise_scaling_stable():
# 动态噪声不退化:小尺寸目标多帧观测仍稳定收敛(§4.2 噪声与尺寸成比例)
kf = KalmanBoxFilter()
z = BoxCXCYWH(50.0, 60.0, 4.0, 4.0) # 小目标(4×4 像素)
kf.initiate(z)
for _ in range(30):
kf.predict()
kf.update(z)
state = kf.state
assert state[0] == pytest.approx(z.cx, abs=0.5)
assert state[1] == pytest.approx(z.cy, abs=0.5)
assert math.isfinite(state[2])
assert math.isfinite(state[3])
@@ -0,0 +1,364 @@
"""target_tracker_test.py —— TargetTracker 单元测试。
移植自 reference-c++/target_tracker/tests/target_tracker_test.cpp。
逐条覆盖 docs/视觉追踪技术路线.md §9.4 行为一致性验收用例(硬验收标准):
1. 空检测帧序列:Coast 持续 max_lost_frames 帧,第 max_lost_frames+1 帧判丢
2. ID 严格递增不复用(含 Reset 后归零)
3. 锁定不被抢占;锁定目标死亡后切换到新目标
4. min_hits 回退:无已确认实测轨道时新轨道也可当选主目标
5. cost=1.0 恰好不构成匹配(严格小于)
6. 置信度优先:两轨道竞争同一检测,置信度高者获胜
另含 NaN/Inf 防御与 §2.3/§6.3/§9.5 行为测试。
"""
import math
import pytest
from target_tracker.target_tracker import (Detection, FrameShape, TargetTracker,
TrackerConfig)
def make_det(x1, y1, x2, y2, conf, cls=0):
return Detection(x1, y1, x2, y2, conf, cls)
# ============================================================================
# 验收用例 1(§9.4-1):空检测帧序列
# ============================================================================
def test_empty_frames_coast_then_lost():
tracker = TargetTracker() # 默认配置 max_lost_frames = 8
assert tracker.get_config().max_lost_frames == 8
# 第 1 帧:给出一个检测,建立轨道并锁定为主目标
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.9)])
assert r.tracked
assert r.primary_id is not None
assert not r.is_predicted # 当帧有实测
assert r.lost_frames == 0
assert r.n_active == 1
assert r.box is not None
assert r.raw is not None # 当帧关联到原始检测框
# 后续空检测帧:应持续 Coast 共 max_lost_frames 帧(is_predicted=True)
for i in range(1, 9):
r = tracker.update([])
assert r.tracked, f"第 {i} 帧 Coast 期间不应判丢"
assert r.is_predicted, f"第 {i} 帧应为纯预测输出"
assert r.lost_frames == i
assert r.primary_id is not None
assert r.box is not None # Coast 期间继续输出预测框
assert r.raw is None # Coast 时 raw 为空(§2.3)
assert r.confidence == pytest.approx(0.9) # 继承最近实测置信度(§2.3)
# 第 max_lost_frames + 1 帧:轨道死亡回收 → tracked=False
r = tracker.update([])
assert not r.tracked
assert r.primary_id is None
assert r.box is None
assert r.lost_frames == 0 # 丢失时 lost_frames=0(§2.3)
assert r.n_active == 0
# ============================================================================
# 验收用例 2(§9.4-2):ID 单调性
# ============================================================================
def test_id_strictly_increasing_no_reuse():
cfg = TrackerConfig()
cfg.max_lost_frames = 1 # 缩短 Coast 窗口,加速轨道死亡
tracker = TargetTracker(cfg)
# 目标 A 出现 → id 0
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9)])
assert r.primary_id is not None
id_a = r.primary_id
assert id_a == 0
# 目标 A 消失至死亡(max_lost=1:第 2 帧 Coast、第 3 帧死亡回收)
tracker.update([])
r = tracker.update([])
assert not r.tracked
assert tracker.active_count() == 0
# 目标 B 出现(同一位置):ID 不复用,严格递增 → id 1
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9)])
assert r.primary_id is not None
id_b = r.primary_id
assert id_b == 1
assert id_b > id_a
# 目标 C 同帧新建 → id 2(同帧多目标亦严格递增)
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9),
make_det(400.0, 300.0, 430.0, 330.0, 0.8)])
assert tracker.active_count() == 2
max_id = max(tracker.active_tracks().keys())
assert max_id == 2
# Reset 后计数器归零(§6.2):新轨道重新从 id 0 开始
tracker.reset()
assert tracker.active_count() == 0
assert not tracker.last_result().tracked
r = tracker.update([make_det(50.0, 50.0, 70.0, 70.0, 0.9)])
assert r.primary_id is not None
assert r.primary_id == 0
# ============================================================================
# 验收用例 3(§9.4-3):锁定不被抢占
# ============================================================================
def test_lock_not_stolen_and_switch_after_death():
cfg = TrackerConfig()
cfg.max_lost_frames = 3
tracker = TargetTracker(cfg)
# 低置信度目标 A 建立并锁定
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.4)])
assert r.primary_id is not None
id_a = r.primary_id
# A 消失进入 Coast;期间出现更高置信度新目标 B(远离 A,不会被误关联)
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
assert r.primary_id is not None
assert r.primary_id == id_a, "Coast 期间锁定不应被高置信度目标抢占"
assert r.is_predicted
assert tracker.active_count() == 2 # A(Coast)与 B 并存
# 继续 Coast,锁定依旧延续
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
assert r.primary_id == id_a
assert r.is_predicted
# A 继续丢失直至死亡(max_lost=3:lost 达 4 帧时回收)
for _ in range(2):
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
# A 已死亡删除 → 锁定解除,重新选举切换到 B(此时 B 已确认且实测)
assert r.tracked
assert r.primary_id != id_a, "锁定目标死亡后应切换到新目标"
assert not r.is_predicted
# 后续 B 持续作为主目标
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
assert r.primary_id is not None
assert r.primary_id != id_a
# ============================================================================
# 验收用例 4(§9.4-4):min_hits 回退
# ============================================================================
def test_min_hits_fallback_allows_new_track():
cfg = TrackerConfig()
cfg.min_hits = 5 # 抬高确认门槛,保证测试期间轨道始终未确认
tracker = TargetTracker(cfg)
# 首帧新建轨道 hits=1 < min_hits:候选池 confirmed 为空 → 退回 measured
r = tracker.update([make_det(200.0, 150.0, 240.0, 190.0, 0.6)])
assert r.tracked, "无已确认轨道时新轨道应能当选主目标"
assert r.primary_id is not None
assert r.primary_id == 0
assert not r.is_predicted
# 再跟几帧(hits 增长但仍未达 min_hits=5),依旧当选
for _ in range(3):
r = tracker.update([make_det(200.0, 150.0, 240.0, 190.0, 0.6)])
assert r.tracked
assert r.primary_id == 0
# ============================================================================
# 验收用例 5(§9.4-5):cost 边界(cost = 1.0 恰好不匹配)
# ============================================================================
def test_cost_exactly_one_does_not_match():
cfg = TrackerConfig()
cfg.max_lost_frames = 1
cfg.max_association_dist = 200.0
cfg.dist_weight = 1.0 # 纯距离项,IoU 项权重为 0 且两框零交集
cfg.iou_weight = 0.0
tracker = TargetTracker(cfg)
# 建立轨道:中心 (100,100)
r = tracker.update([make_det(90.0, 90.0, 110.0, 110.0, 0.9)])
assert r.primary_id is not None
id_a = r.primary_id
# 下一帧检测中心 (100,300):距离 = 200 = Dmax → cost = 1.0,恰好不匹配
r = tracker.update([make_det(90.0, 290.0, 110.0, 310.0, 0.9)])
# 未匹配 → 原轨道 Coast、检测新建轨道;两个目标并存
assert tracker.active_count() == 2, "cost=1.0 不应匹配,应新建轨道"
# 原轨道(锁定)进入 Coast:lost_frames=1
assert r.tracked
assert r.is_predicted
assert r.primary_id == id_a
assert r.lost_frames == 1
# 对照:距离略小于阈值(199px → cost=0.995 < 1.0)应匹配成功
tracker2 = TargetTracker(cfg)
r = tracker2.update([make_det(90.0, 90.0, 110.0, 110.0, 0.9)])
id_b = r.primary_id
r = tracker2.update([make_det(90.0, 289.0, 110.0, 309.0, 0.9)])
assert tracker2.active_count() == 1, "cost<1.0 应匹配成功,不应新建轨道"
assert r.primary_id is not None
assert r.primary_id == id_b
assert not r.is_predicted # 实测命中
# ============================================================================
# 验收用例 6(§9.4-6):置信度优先
# ============================================================================
def test_higher_confidence_track_wins_detection():
tracker = TargetTracker()
# 同帧建立两条轨道:A 置信度 0.5、B 置信度 0.9,相距 80px(不互相重叠)
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.5),
make_det(180.0, 100.0, 200.0, 120.0, 0.9)])
assert tracker.active_count() == 2
# 找出两条轨道 ID(按置信度区分)
id_low = None
id_high = None
for track_id, track in tracker.active_tracks().items():
if track.confidence > 0.8:
id_high = track_id
else:
id_low = track_id
assert id_low is not None
assert id_high is not None
# 下一帧只给一个检测,放在两轨道预测中心之间(各距 40px,对称):
# 距离项对两者相同,贪婪按置信度降序 → 高置信度轨道 B 优先匹配
r = tracker.update([make_det(140.0, 100.0, 160.0, 120.0, 0.7)])
tracks = tracker.active_tracks()
assert len(tracks) == 2
# 获胜者:命中(lost=0、hits=2、置信度更新)
assert tracks[id_high].lost_count == 0, "高置信度轨道应获胜"
assert tracks[id_high].hits == 2
assert tracks[id_high].confidence == pytest.approx(0.7)
# 失败者:未匹配进入 Coast(lost=1、hits 不变)
assert tracks[id_low].lost_count == 1, "低置信度轨道应落选"
assert tracks[id_low].hits == 1
assert tracks[id_low].confidence == pytest.approx(0.5)
# ============================================================================
# NaN/Inf 防御(评审修复项)
# ============================================================================
def test_non_finite_detections_are_discarded():
tracker = TargetTracker()
nan = math.nan
inf = math.inf
# 第 1 帧:建立正常轨道
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.9)])
assert r.tracked
# 第 2 帧:混入 NaN 坐标 / Inf 坐标 / NaN 置信度检测与一个正常检测:
# 非有限检测全部被丢弃,仅正常检测参与关联,不新建轨道
r = tracker.update([make_det(nan, 100.0, 140.0, 140.0, 0.9),
make_det(100.0, -inf, 140.0, 140.0, 0.9),
make_det(100.0, 100.0, nan, 140.0, 0.9),
make_det(102.0, 102.0, 142.0, 142.0, nan),
make_det(101.0, 101.0, 141.0, 141.0, 0.9)])
assert tracker.active_count() == 1, "非有限检测不应新建轨道"
assert r.tracked
assert not r.is_predicted # 正常检测命中,实测输出
# 主目标输出未被 NaN 污染
assert math.isfinite(r.center[0])
assert math.isfinite(r.center[1])
assert r.box is not None
assert math.isfinite(r.box.x1) and math.isfinite(r.box.y1)
assert math.isfinite(r.box.x2) and math.isfinite(r.box.y2)
assert math.isfinite(r.confidence)
assert r.raw is not None # 实测帧 raw 有值
# 第 3 帧:仅含 NaN 检测 → 等价空帧,轨道 Coast 不崩溃
r = tracker.update([make_det(nan, nan, nan, nan, nan)])
assert r.tracked
assert r.is_predicted
assert r.lost_frames == 1
assert r.raw is None # Coast 时 raw 为空(§2.3)
assert math.isfinite(r.center[0])
# 持续 NaN 检测直至轨道死亡回收,全程不崩溃
for _ in range(10):
r = tracker.update([make_det(nan, 0.0, 10.0, 10.0, 0.9)])
assert not r.tracked
assert tracker.active_count() == 0
# ============================================================================
# 补充行为测试(§2.3 / §6.3 / §9.5)
# ============================================================================
def test_lost_result_fields_are_empty():
tracker = TargetTracker()
r = tracker.update([]) # 空帧序列起点:无任何轨道
assert not r.tracked
assert r.primary_id is None
assert r.box is None
assert r.raw is None
assert r.lost_frames == 0
assert r.n_active == 0
assert r.confidence == 0.0
# LastResult 返回同一快照(§9.5 跨线程读取入口)
cached = tracker.last_result()
assert not cached.tracked
def test_coasting_fallback_chooses_nearest_to_frame_center():
# Coast 兜底选举:无实测轨道时选离画面中心最近的 Coast 轨道(§6.3)。
# 锁定延续优先:需先让锁定轨道死亡解除锁定,才会进入重新选举分支。
cfg = TrackerConfig()
cfg.max_lost_frames = 2
tracker = TargetTracker(cfg)
# F1:角落目标(高置信度)先建立并被锁定
tracker.update([make_det(10.0, 10.0, 30.0, 30.0, 0.95)])
# F2:画面中心附近目标建立;角落目标丢失(1)
tracker.update([make_det(300.0, 220.0, 340.0, 260.0, 0.9)])
# F3:远处目标建立;角落丢失(2)、中心目标丢失(1)
tracker.update([make_det(600.0, 450.0, 620.0, 470.0, 0.85)])
# F4:全部丢失:角落丢失(3) > max_lost 死亡解除锁定;
# 其余两条 Coast 存活 → 重新选举选离画面中心最近者
r = tracker.update([], FrameShape(480, 640))
assert r.tracked
assert r.is_predicted
assert r.center[0] == pytest.approx(320.0, abs=25.0) # 靠近画面中心 (320,240)
assert r.center[1] == pytest.approx(240.0, abs=25.0)
assert r.n_active == 2 # 角落轨道已回收
def test_locked_primary_kept_while_measured():
# 锁定延续优先于置信度选举(§6.3 ①)
tracker = TargetTracker()
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.4)])
assert r.primary_id is not None
locked_id = r.primary_id
# 高置信度新目标出现并持续,锁定目标也持续实测 → primary_id 不变
for i in range(5):
r = tracker.update([make_det(100.0 + i, 100.0, 120.0 + i, 120.0, 0.4),
make_det(400.0, 300.0, 430.0, 330.0, 0.95)])
assert r.primary_id is not None
assert r.primary_id == locked_id
assert not r.is_predicted
def test_default_frame_center_is_320x240():
# frame_shape 缺省按 640×480 画面中心 (320,240) 计算(§8.2 / §2.2)
cfg = TrackerConfig()
cfg.max_lost_frames = 2
tracker = TargetTracker(cfg)
# F1:角落目标先建立并被锁定(高置信度)
tracker.update([make_det(0.0, 0.0, 20.0, 20.0, 0.95)])
# F2:左侧目标(中心 (280,240),距默认中心 40px)建立
tracker.update([make_det(270.0, 230.0, 290.0, 250.0, 0.9)])
# F3:右侧目标(中心 (500,240),距默认中心 180px)建立
tracker.update([make_det(490.0, 230.0, 510.0, 250.0, 0.85)])
# F4:全部丢失:锁定轨道死亡解除锁定,两条 Coast 轨道兜底选举
r = tracker.update([])
assert r.tracked
assert r.is_predicted
assert r.center[0] == pytest.approx(280.0, abs=30.0) # 左侧更近,当选
assert r.center[1] == pytest.approx(240.0, abs=30.0)
@@ -0,0 +1,159 @@
"""tracklet_test.py —— Tracklet 与几何工具单元测试。
移植自 reference-c++/target_tracker/tests/tracklet_test.cpp。
覆盖(视觉追踪技术路线 §3.2/§5.2/§6.1/§7):框互转、IoU 边界、
IsDead/IsCoasting 判定(严格大于)、Predict/Update/MarkMiss 计数语义。
"""
import pytest
from target_tracker.tracklet import (BoxXYXY, BoxCXCYWH, Tracklet, compute_iou,
to_center_size, to_xyxy)
# ---- 框表示互转(§7) ----
def test_round_trip_preserves_values():
box = BoxXYXY(10.0, 20.0, 50.0, 80.0)
c = to_center_size(box)
assert c.cx == 30.0 # (10+50)/2
assert c.cy == 50.0 # (20+80)/2
assert c.w == 40.0 # 50−10
assert c.h == 60.0 # 80−20
back = to_xyxy(c)
assert back.x1 == box.x1
assert back.y1 == box.y1
assert back.x2 == box.x2
assert back.y2 == box.y2
# ---- IoU 边界(§5.2) ----
def test_identical_boxes_return_one():
a = BoxXYXY(10.0, 10.0, 30.0, 30.0)
assert compute_iou(a, a) == pytest.approx(1.0, abs=1e-12) # 完全重合 = 1
def test_disjoint_boxes_return_zero():
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
b = BoxXYXY(20.0, 20.0, 30.0, 30.0)
assert compute_iou(a, b) == 0.0 # 零交集
def test_touching_edges_return_zero():
# 仅边/角相接:交集宽高为 0 → 交集面积 ≤ 0 返回 0(§5.2)
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
b = BoxXYXY(10.0, 0.0, 20.0, 10.0) # 右边缘相接
assert compute_iou(a, b) == 0.0
c = BoxXYXY(10.0, 10.0, 20.0, 20.0) # 角点相接
assert compute_iou(a, c) == 0.0
def test_empty_box_returns_zero():
normal = BoxXYXY(0.0, 0.0, 10.0, 10.0)
zero_area = BoxXYXY(5.0, 5.0, 5.0, 5.0) # 零面积
inverted = BoxXYXY(10.0, 10.0, 0.0, 0.0) # 反向(负宽高)
assert compute_iou(normal, zero_area) == 0.0
assert compute_iou(zero_area, normal) == 0.0
assert compute_iou(normal, inverted) == 0.0
assert compute_iou(zero_area, zero_area) == 0.0
def test_partial_overlap_is_correct():
# A = [0,0,10,10](面积 100),B = [5,5,15,15](面积 100),交集 25
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
b = BoxXYXY(5.0, 5.0, 15.0, 15.0)
assert compute_iou(a, b) == pytest.approx(25.0 / 175.0, abs=1e-12)
assert compute_iou(b, a) == pytest.approx(25.0 / 175.0, abs=1e-12) # 对称性
# ---- 构造与派生状态(§3.2 / §6.1) ----
def test_construction_initial_fields():
t = Tracklet(7, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 1, 8)
assert t.track_id == 7
assert t.lost_count == 0
assert t.max_lost == 8
assert t.confidence == 0.9
assert t.cls_id == 1
assert t.age == 1 # 创建时为 1(§3.2)
assert t.hits == 1 # 创建时为 1(§3.2)
assert t.last_center == (100.0, 100.0)
assert t.last_box is not None # 创建当帧即赋 last_box(§7 步骤 5)
assert t.last_box.x1 == 90.0
assert t.last_box.y1 == 90.0
assert t.last_box.x2 == 110.0
assert t.last_box.y2 == 110.0
assert not t.is_dead
assert not t.is_coasting
def test_is_dead_uses_strictly_greater():
# IsDead 使用严格大于:lost_count == max_lost 仍存活(§6.1 注记 2)
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
for _ in range(8):
t.mark_miss()
assert not t.is_dead, f"lost_count={t.lost_count} 未超过 max_lost 不应判死"
assert t.lost_count == 8
t.mark_miss() # lost_count = 9 > 8
assert t.is_dead
def test_is_coasting_when_lost_positive():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
assert not t.is_coasting
t.mark_miss()
assert t.is_coasting # lost_count > 0 即 Coast(§3.2)
# ---- 生命周期方法计数语义(§7) ----
def test_predict_advances_age_and_updates_last_state():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
age_before = t.age
t.predict() # 速度为零:位置不变,但 age+1、last_box 更新为预测框
assert t.age == age_before + 1
assert t.last_box is not None
assert t.last_center[0] == pytest.approx(100.0, abs=1e-9)
assert t.last_center[1] == pytest.approx(100.0, abs=1e-9)
# hits / lost_count / confidence 不受预测影响
assert t.hits == 1
assert t.lost_count == 0
assert t.confidence == 0.9
def test_update_resets_lost_and_increments_hits():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
t.mark_miss()
t.mark_miss()
assert t.lost_count == 2
assert t.is_coasting
age_before = t.age
box = BoxXYXY(150.0, 160.0, 170.0, 180.0)
t.update(box, 0.75, 2)
assert t.lost_count == 0 # 匹配成功清零(§7)
assert t.hits == 2 # hits+1
assert t.age == age_before + 1 # age+1
assert t.confidence == 0.75
assert t.cls_id == 2
assert t.last_center == (160.0, 170.0) # ((150+170)/2, (160+180)/2)
assert t.last_box is not None
assert t.last_box.x1 == box.x1
assert t.last_raw_box is not None # 实测框同步记录
assert t.last_raw_box.x2 == box.x2
assert not t.is_coasting
def test_mark_miss_increments_lost_and_age():
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
age_before = t.age
t.mark_miss()
assert t.lost_count == 1
assert t.age == age_before + 1
# MarkMiss 不影响 hits / confidence
assert t.hits == 1
assert t.confidence == 0.9