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

This commit is contained in:
keita
2026-09-10 16:16:05 +08:00
parent 3325465359
commit 538ae4c6c7
39 changed files with 2285 additions and 1444 deletions
+27 -9
View File
@@ -1,9 +1,12 @@
# utils —— reference-c++ 两个子项目的 Python 移植
# utils —— reference-c++ 的 Python 移植 + 双目测距新实现
将 `reference-c++/` 下的 `target_tracker`(多目标追踪过滤器)与
`visual_track_control`(视觉跟踪控制律)移植为 Python,**两包互不依赖**
(唯一的数据转换点在胶水层 demo)。
另有 `stereo_ranger`(双目稀疏目标测距):**按规格新实现**(非 C++ 移植),
规格 `docs/双目测距技术路线.md`;同样与既有两包互不依赖。
## 目录结构
```
@@ -12,19 +15,24 @@ utils/
│ └── README.md # 接口清单 + C++ 差异
├── visual_track_control/ # 包 2:三通道控制律 + MAVSDK offboard 网关
│ └── README.md # 接口清单 + C++ 差异
├── stereo_ranger/ # 包 3:双目稀疏目标测距(纯标准库,无第三方依赖)
│ └── README.md # 接口清单 + 与规格的偏差
├── demo_track_control.py # 胶水层集成演示(模拟检测 → 追踪 → 控制律 → 发送)
├── demo_stereo_ranger.py # 双目测距演示(合成框对,六场景,无硬件)
├── config/track_control.json # 控制律配置(拷贝自 reference-c++)
├── tests/ # pytest 测试(对应 C++ 全部 13 个测试文件)
│ ├── target_tracker/ # 27 条
│ └── visual_track_control/ # 78 条
├── conftest.py # 保证可从任意工作目录导入两包
├── config/stereo_ranger.json # 双目测距配置(占位值,待标定回填)
├── tests/ # pytest 测试
│ ├── target_tracker/ # 28 条(对应 C++ 测试文件)
│ ├── visual_track_control/ # 81 条(对应 C++ 测试文件)
│ └── stereo_ranger/ # 111 条(规格 §11.4 构件级 + 全流程)
├── conftest.py # 保证可从任意工作目录导入三包
└── pytest.ini # 收集 *_test.py 命名(与 C++ 测试文件名对齐)
```
## 环境
conda env `pytorch`(`D:\program file\conda\envs\pytorch`),Python 3.12,
依赖:numpy、mavsdk、pytest。
依赖:numpy、mavsdk、pytest(stereo_ranger 仅标准库)。
## 运行测试
@@ -33,8 +41,9 @@ cd utils
"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q
```
105 条测试全部通过(含 C++ 验收用例的逐条移植:§9.4 六条硬验收、A1/A2/A4
回归、PID anti-windup C1、超龄 C4、首拍限幅 S6)。
220 条测试全部通过:109 条 C++ 移植用例(含 §9.4 六条硬验收、A1/A2/A4
回归、PID anti-windup C1、超龄 C4、首拍限幅 S6)+ 111 条 stereo_ranger
(合成框对驱动,无需真实图像/硬件)。
## 运行演示
@@ -44,11 +53,17 @@ python utils/demo_track_control.py --speedup 10
# MAVSDK 实发(需飞控/SITL 在 udp://:14540)
python utils/demo_track_control.py --transport mavsdk
# 双目测距(合成框对,离线,无相机/飞控)
python utils/demo_stereo_ranger.py
```
时间线 13 s(20 Hz):正常跟踪 6 s(vx 从零渐变 → 逼近限速 3 m/s)→
控制链路时间线 13 s(20 Hz):正常跟踪 6 s(vx 从零渐变 → 逼近限速 3 m/s)→
目标消失 4 s(Coast 预测,60 帧后轨道判亡)→ 判零速刹车 3 s。
双目演示六场景:逼近(三道有效性门 + 滤波)→ 远场域外(方位仍发布)→
单目退化 → 非同帧拒收 → 一致性降级/判无效 → 多目标贪心配对。
## 移植约定
- MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支
@@ -56,3 +71,6 @@ python utils/demo_track_control.py --transport mavsdk
- 测试逐条移植 C++ gtest 断言(`EXPECT_DOUBLE_EQ` → 精确 `==`,
`EXPECT_NEAR` → `pytest.approx`);MAVSDK 部分因无 SITL/实机,仅 mock 验证。
- 行为修复项(C1/C2/C4、A1/A2/A4、S6)在测试文件头注释中标注对应章节。
- `stereo_ranger` 不是移植:按规格五步流程新实现,与规格的偏差(越界=有效性门、
视差域权威、补充 consistency_jump_px/isolation_gate_px 等)见其包 README;
⚠ 全部数值为占位值,标定完成前不得作为飞行控制决策依据。
+4 -3
View File
@@ -4,7 +4,7 @@
"control": {
"frequency_hz": 20.0,
"visual_stale_ms": 200,
"radar_stale_ms": 500,
"distance_stale_ms": 500,
"attitude_stale_ms": 300
},
@@ -36,8 +36,9 @@
"derivative_filter_coef": 0.2,
"approach_velocity_limit_mps": 3.0,
"retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold",
"no_distance_approach_limit_mps": 0.5
"no_distance_action": "slow_approach",
"no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000
},
"accel_limit": {
-96
View File
@@ -1,96 +0,0 @@
"""stereo_ranger demo:合成多目标场景演示单帧 update() 全流程。
本 demo 不使用任何检测器:按已知 (X, Y, Z) 目标解析投影生成左右目
检测框(模拟"外部检测器输出,框已在校正后原生坐标系"),并叠加 ±1 px
亚像素抖动,随后用 StereoRanger 测距并对照真值。
运行(pytorch 环境):
python utils/demo_stereo_ranger.py [--config path/to/stereo.json]
依赖仅为标准库 + stereo_ranger 包(将 utils/ 加入 PYTHONPATH 或于 utils/ 下运行)。
"""
import argparse
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from stereo_ranger import Detection, StereoConfig, StereoRanger, focal_from_hfov, load_from_json, range_error_abs
# 模拟相机:1920x1080,HFOV 60°(文档 §5.3 示例之一)
IMAGE_W = 1920
IMAGE_H = 1080
HFOV_DEG = 60.0
BASELINE_M = 0.06
def project_to_boxes(x_m, y_m, z_m, fx, fy, cx, cy, b, rng, w_m=0.5, h_m=0.3):
"""目标物理宽高 -> 左右目框;叠加亚像素抖动模拟检测噪声(种子固定)。"""
w_px = fx * w_m / z_m
h_px = fy * h_m / z_m
u_l = fx * x_m / z_m + cx
v_l = fy * y_m / z_m + cy
u_r = u_l - fx * b / z_m
jitter = lambda: rng.uniform(-1.0, 1.0) # ±1 px 框边缘抖动
left = Detection(u_l - w_px / 2 + jitter(), v_l - h_px / 2 + jitter(),
u_l + w_px / 2 + jitter(), v_l + h_px / 2 + jitter(),
conf=0.95, cls_id=0, track_id=1)
right = Detection(u_r - w_px / 2 + jitter(), v_l - h_px / 2 + jitter(),
u_r + w_px / 2 + jitter(), v_l + h_px / 2 + jitter(),
conf=0.9, cls_id=0, track_id=1)
return left, right
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default=None, help="StereoConfig JSON 文件(可选)")
args = parser.parse_args()
cfg = load_from_json(args.config) if args.config else StereoConfig(
fx_px=focal_from_hfov(IMAGE_W, HFOV_DEG),
fy_px=focal_from_hfov(IMAGE_W, HFOV_DEG),
cx_px=IMAGE_W / 2.0, cy_px=IMAGE_H / 2.0,
baseline_m=BASELINE_M,
range_tol_m=2.0,
)
ranger = StereoRanger(cfg)
rng = random.Random(20260909)
targets = [(0.0, 0.0, 5.0), (2.0, 0.5, 8.0), (-1.5, -0.4, 12.0)]
left_dets, right_dets = [], []
for x_m, y_m, z_m in targets:
l, r = project_to_boxes(x_m, y_m, z_m, cfg.fx_px, cfg.fy_px, cfg.cx_px, cfg.cy_px,
cfg.baseline_m, rng)
left_dets.append(l)
right_dets.append(r)
# 一个只在左目检出的干扰框 -> 应输出 matched=False 的方位记录
left_dets.append(Detection(100.0, 900.0, 160.0, 950.0, conf=0.6, cls_id=9))
reports = ranger.update(left_dets, right_dets, frame_sequence=1, timestamp_s=0.0)
print(f"内参: F={cfg.fx_px:.1f}px cx={cfg.cx_px:.0f} B={cfg.baseline_m:.3f}m "
f"Z 窗口 [{cfg.z_min_m}, {cfg.z_max_m}]m")
print(f"{'真值 Z':>8} {'估计 Z':>8} {'真值 X':>7} {'估计 X':>7} {'视差':>6} {'δZ估计':>7} "
f"{'有效':>5} {'x_mid':>7} 备注")
for i, r in enumerate(reports):
if not r.matched:
print(f"{'-':>8} {'-':>8} {'-':>7} {'-':>7} {'-':>6} {'-':>7} {'-':>5} "
f"{r.centre_pixel_x_mid:7.1f} 左目未配对(方位直发)")
continue
truth = targets[i]
print(f"{truth[2]:8.2f} {r.forward_distance_m:8.2f} {truth[0]:7.2f} {r.lateral_m:7.2f} "
f"{r.disparity_px:6.1f} {r.error_est_m:7.2f} {str(r.valid):>5} "
f"{r.centre_pixel_x_mid:7.1f} src={r.disparity_source}")
print(f"\n共 {len(reports)} 条记录({sum(1 for r in reports if r.valid)} 条有效)")
print("\n误差模型示意(规格 §5.3,δD = 1 px):")
print(f"{'Z (m)':>7} {'δZ (m)':>8}")
for z in [5, 10, 15, 20, 30]:
err = range_error_abs(z, cfg.fx_px, cfg.baseline_m, 1.0)
print(f"{z:7d} {err:8.2f}")
if __name__ == "__main__":
main()
+7 -7
View File
@@ -10,7 +10,7 @@
→ CommandSender(mock 记录 / MAVSDK offboard 实发)
场景时间线(20 Hz,--speedup 可加速):
0~ 6s 正常跟踪:目标匀速横移 + 雷达距离 30→10 m 前向逼近
0~ 6s 正常跟踪:目标匀速横移 + 双目距离 30→10 m 前向逼近
6~10s 目标消失:追踪器 Coast 预测(60 帧 = 3 s 后轨道判丢,
控制律自动切换零速刹车)
10~13s 持续判丢:维持零速刹车
@@ -73,8 +73,8 @@ def simulate_detections(tick: int, normal_ticks: int, period_s: float):
return [Detection(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0, 0.9)]
def simulate_radar(tick: int, normal_ticks: int, period_s: float):
"""场景雷达源:正常阶段关联成立、距离 30→10 m;之后未关联。"""
def simulate_distance(tick: int, normal_ticks: int, period_s: float):
"""场景双目距离源:正常阶段有效、距离 30→10 m;之后无效。"""
if tick >= normal_ticks:
return 0.0, False
distance = max(10.0, 30.0 - tick * period_s * 3.0)
@@ -155,15 +155,15 @@ async def run_demo(args) -> None:
for tick in range(total_ticks):
now_ms = 1000 + tick * period_ms
detections = simulate_detections(tick, normal_ticks, period_s)
radar_m, associated = simulate_radar(tick, normal_ticks, period_s)
distance_m, distance_valid = simulate_distance(tick, normal_ticks, period_s)
result = tracker.update(detections)
snap = ControlSnapshot(
track=track_result_to_state(result),
track_time_ms=now_ms, # 每拍取样即打戳(视觉始终新鲜)
radar_distance_m=radar_m,
associated_to_target=associated,
radar_time_ms=now_ms if associated else 0,
target_distance_m=distance_m,
distance_valid=distance_valid,
distance_time_ms=now_ms,
attitude_present=True,
attitude_time_ms=now_ms, # 模拟姿态源持续可用
yaw_rad=0.0, # 机头朝北(NED 旋转恒等)
+89 -42
View File
@@ -1,65 +1,112 @@
# stereo_ranger(Python)
# stereo_ranger(Python 版)
双目稀疏目标测距(规格:`docs/双目测距方案.md`)。输入左右目各自、**校正后原生坐标系**下的检测框,输出目标的 \(Z, X, Y, R\) 与方位。不做目标识别(YOLO)、不做图像采集与立体校正——对应规格 §10 中可独立替换的检测前端与采集预处理模块。
双目稀疏目标测距:对**同一时刻**左右两目已立体校正图像上的目标检测框做立体
几何解算,输出少量目标的**前向距离 Z + 斜距 R(参考)+ 虚拟双目中心方位
c_mid + 有效性/置信度**。不做目标识别、图像采集/立体校正、稠密深度。
仅依赖标准库(`math`/`logging`),不 import 本仓库任何其他模块。
规格:`docs/双目测距技术路线.md`(算法与语义权威)。
⚠ **全部参数为占位值**(焦距/标定待厂商数据):标定与地面真值验证完成前,
本模块输出**仅限静态标靶/回放/台架验证,不得作为飞行控制决策依据**(§12.3)。
## 接口清单
| 名称 | 说明 |
| --- | --- |
| `Detection(x1, y1, x2, y2, conf=1.0, cls_id=0, track_id=0)` | 输入检测框(px,左上/右下角) |
| `StereoConfig(...)` | 配置(默认值 = 规格示例值,见下) |
| `load_from_json(path)` | 从 JSON 构建配置(缺字段/未知字段/非法值抛 `ValueError`) |
| `StereoRanger(config=None)` | 测距器主类 |
| `.update(left_dets, right_dets, frame_sequence=0, timestamp_s=None) -> list[RangeReport]` | 单帧主流程(返回记录数与左目检测数一致、顺序一致) |
| `.reset()` / `.config` | 清空滤波状态 / 当前配置 |
| `RangeReport` | 输出记录(字段见 `types.py`) |
| `focal_from_hfov(w_px, hfov_deg)` / `range_error_abs(z, fx, B, δD=1)` / `range_error_rel(...)` | 工具函数(§5.2/§5.3) |
| `StereoRanger(config=None, matcher=None)` | 主入口;`matcher` 可注入自定义匹配器 |
| `.update(left, right, now_ms=0) -> List[RangeRecord]` | 单周期处理(§5 五步),纯计算、无 IO |
| `.reset()` | 清空全部运行期状态(滤波/一致性/隔离/节流计数) |
| `.config` / `.matcher` | 只读访问(配置与匹配器) |
| `StereoConfig` / `load_from_json(path)` | 17 项配置;缺项/未知字段/类型错误/非法取值一律 `ValueError`(fail-fast) |
| `.focal_baseline_px_m` / `.z_min_m` / `.z_max_m` / `.z_error_budget_max_m` | 派生只读量(F·B 常量、视差域互推的距离域、误差预算远界) |
| `Detection(x1,y1,x2,y2, confidence, class_id, track_id)` | 单框(校正后坐标系;含 `cx/cy/width/height/bbox` 属性) |
| `DetectionFrame(frame_id, detections)` | 单目单帧 |
| `RangeRecord` | 输出记录:`frame_id / class_id / degradation / valid / track_id / forward_distance_m(Z) / slant_range_m(R) / disparity_px(D) / center_mid_px(c_mid) / reference_box / reference_eye / confidence / timestamp_ms` |
| `Degradation` / `Eye` / `DisparitySource` | 枚举:完整/仅方位;左/右目;同侧边缘/中心回退 |
| `sample_point` / `SamplePoint` / `select_disparity` / `Disparity` | 取点器(§6.1,纯函数) |
| `Matcher`(Protocol)/ `GreedyMatcher` / `MatchPair` | 匹配器(§6.2,接口注入,可换匈牙利等) |
| `solve_range` / `RangeSolution` / `virtual_center_px` / `forward_distance_m` | 测距与方位源(§3.2/§6.6,纯函数) |
| `evaluate_validity` / `ValidityDecision` / `range_error_abs_m` / `range_error_rel` / `confidence_from_cost` / `DELTA_D_PX` | 有效性三道门与误差模型(§6.4/§7.1,纯函数) |
| `ConsistencyMonitor` / `ConsistencyStatus` | 一致性监控(§6.7,每目标状态) |
| `DistanceFilter` | 距离低通 + 跳变重置(§6.5,每目标状态) |
| `TargetStore` | 目标隔离与老化清理(§4,track_id 优先 + 最近邻兜底) |
| `WarnThrottle` | 告警节流(第 1 次 + 每满 100 次,带累计计数) |
## 与检测器的约定(规格 §4.3)
`logger` 名称为 `stereo_ranger`,走标准 `logging`。
1. 左右图像必须使用**完全一致**的预处理几何(同 letter-box、同缩放),由检测前端保证;
2. 传入本模块的框必须已映射回**原生分辨率**(视差在原生坐标计算),内参 `fx/fy/cx/cy` 亦对应原生分辨率;
3. 图像对须已完成立体校正(同一目标在两目 y 坐标一致);校正残留由 `vertical_tau_px` 容错;
4. YOLO 推理及左右目并行执行由调用方负责,本模块只收检测结果。
## 单帧流程(`update` 五步)
## 单帧流程(对照规格)
1. **同帧契约**:`left.frame_id != right.frame_id` → 拒收整帧、返回空列表、WARN 节流(§2.1);
2. **取点**:逐框取同侧(左)边缘 x 与框中心;坐标非有限值的框丢弃并 WARN(不参与解算也不发布);
3. **匹配**:硬约束(类别一致、`|Δy| ≤ tau_y_px`、框高相对差 ≤ `tau_h_ratio`)+ 代价升序贪心一一配对,`cost < 1` 才接受;
4. **配对目标**:`D → Z = F·B/D`(`D ≤ 0` 判违例)→ `R` 参考 → `c_mid = (cL + cR)/2` → 门① 域内 / 门② 误差预算 / 门③ 一致性 → 有效帧过 `DistanceFilter`,记 `Z_out`;
5. **未配对框**:发布仅方位记录(`BEARING_ONLY`,`valid = false`,右目未匹配同样发布);帧末对未出现目标记失效、连续 `filter_reset_frames` 帧未出现整体清理。
硬约束过滤(类别一致、`|Δy| ≤ τy`、高度比 `≤ τh`、视差落在 `FB/z_max ~ FB/z_min` 区间)→ 代价 `|Δy|/τy + |Δh|/(τh·max(hL,hR))` 升序贪心一一配对(严格 `cost < 1`;同 `track_id` 优先)→ 主取**同侧左边缘**视差,越出关联窗口时按配置回退中心视差(节流警告)→ `Z=FB/D`、`X=(xL−cx)Z/F`、`Y=(yL−cy)Z/F`、`R=√(X²+Y²+Z²)` → 有效性 `D ≥ D_min 且 δZ ≤ Z_tol` → 可选一阶低通滤波(按 (cls, track) 分键,跳变超门限重置;`track_id=0` 不过滤)。
记录顺序:先按左目框顺序(配对→完整 / 未配对→仅方位),再给未配对右目框的仅方位记录。
## 输出语义
## 实现决策(v1.0 冻结,已回填规格)
- `matched=True, valid=True`:有效测距;
- `matched=True, valid=False`:已配对但超出有效工作域(§5.4),距离/坐标仍发布供诊断,方位可用;
- `matched=False, valid=False`:左目检测本帧未配对(§6 失败处理),仅单目方位回退。
规格 `docs/双目测距技术路线.md` 已按下列决策修订(§4/§6.1/§6.2/§6.4/§6.7/
§9/§11 均已更新),本节保留决策动机与实现落点,供重写/移植核对。
方位源 `centre_pixel_x_mid` 为基线中点虚拟相机投影(§11.3);注意 `X` 属**左目坐标系**(§4.2),二者相差 `B/2`,勿混用。
1. **越界 = 有效性门,不是配对硬约束**:规格 §6.2 原把"视差在域内"列入
硬约束,与 §5/§6.3/§6.4/§2.3 的表述冲突;按 §5 主流程实现并回填——
越界目标仍配对、仍发布 `D` 与 `c_mid`(远场方位闭环的输入基础),仅
`valid = false`。
2. **视差域权威**:`disparity_min_px / disparity_max_px` 为真源,`z_min_m / z_max_m`
为派生只读属性;原 §9.2 占位值不自洽(`D_min = 3 px ⇔ 19.2 m ≠ 20 m`),
距离域配置项已从规格删除(§6.4/§9.2)。
3. **不实现 §6.1 的"左右边缘各算一次取中值"精化**:两支的 median 恒等于均值,
即框中心视差,与"同侧边缘"的理由矛盾,规格已删除该项(早期笔误),留
v1.1 亚像素精化。
4. **补充配置项**(原文 §9 未列、算法需要,规格 §9.3/§9.5 已回填):
`consistency_jump_px`(§6.7 视差跳变门限)、`isolation_gate_px`(§4 最近邻
兜底的门限)。
5. **δD 固定 1 px**:`DELTA_D_PX = 1.0`(§7.1 ⚠占位);标定回填后可参数化。
6. **R 与已发布 Z 同基准**:`R = R_raw · z_out / z_raw`(X/Y/Z 均 ∝ Z,精确换基,
保证 `R ≥ Z`);无效帧无 `Z_out` 时 `R` 取本帧原始值(诊断参考)。规格
§2.2/§6.5 已注明。
7. **置信度映射**(§6.4 ⚠占位):`clamp(1 − cost, 0, 1)`;连续跳变 ≥ N 帧乘 0.5
(`_CONFIDENCE_DEGRADE_FACTOR`);判无效置 0。
8. **一致性只在配对成功帧推进**:仅方位帧没有 D 观测量,不推进 D 序列;但目标
未出现的帧会记一次滤波失效(§6.5 失效清理),两条状态机职责不混。
9. **取点回退规则**:边缘视差越窗且中心视差在域内 → 回退中心
(`CENTER_FALLBACK` + WARN);两者均越窗 → 保留边缘值供诊断与违例告警。
10. **track_id 优先路径提前启用**(v1.1 语义,规格 §4 已按此冻结):左右
`track_id` 一致且 > 0 时用 `(track, 类别, id)` 作隔离键;最近邻只在无
track_id 的状态间匹配,一一归属。
11. **§3.2 勘误**:原文 `X_C = X + B/2` 与 §6.6 的符号相反(应为 `X_C = X − B/2`),
规格已修正;代码不输出 `X_C`,以 §6.6 恒等式为准。
## StereoConfig 关键项
## 运行期状态清单(§11.1,漏一个就变行为)
| 字段 | 默认 | 说明 |
| --- | --- | --- |
| `fx_px/fy_px/cx_px/cy_px` | 960/960/960/540 | 校正后内参(原生分辨率) |
| `baseline_m` | 0.06 | 基线 B |
| `z_min_m / z_max_m` | 1.0 / 50.0 | 距离窗口(派生视差关联区间) |
| `vertical_tau_px` / `height_ratio_tau` | 2.0 / 0.25 | 关联阈值 τy / τh |
| `disparity_min_px` / `range_tol_m` | 3.0 / 2.0 | 有效性判据 D_min / Z_tol |
| `disp_noise_px` | 1.0 | 误差模型 δD(用于 δZ 估计与有效性) |
| `use_center_fallback` | true | 边缘视差越窗时回退中心 |
| `filter_enabled` / `filter_alpha` / `filter_jump_ratio` | false / 0.3 / 0.2 | 时间滤波 |
| `warn_interval_s` | 5.0 | 诊断日志节流间隔 |
| 状态 | 所属 | 初值 | 改变时机 |
| --- | --- | --- | --- |
| 连续异常计数 ×目标 | `ConsistencyMonitor` | 0 | 每帧异常 +1 / 正常帧清零 |
| 上次滤波值、初始化标志、连续失效计数 ×目标 | `DistanceFilter` | 无 / false / 0 | 有效帧写入 / 首有效帧置位 / 无效帧 +1 |
| 最近位置、最近出现帧 ×目标 | `TargetStore` | 首帧写入 | 每次 `resolve` |
| 帧序号 | `StereoRanger` | 0 | 每帧 +1(拒收帧不加) |
| 告警计数 ×异常类 | `WarnThrottle` | 0 | 每次异常 |
## 运行
## 测试与演示
```bash
cd utils
"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q # 全部测试(149 条)
"D:/program file/conda/envs/pytorch/python.exe" demo_stereo_ranger.py # 合成场景 demo
"D:/program file/conda/envs/pytorch/python.exe" -m pytest tests/stereo_ranger -q # 111 条
"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q # 全量 220 条
python utils/demo_stereo_ranger.py # 六场景合成框对演示
```
## 未覆盖边界(属其他模块/后续版本)
构件级测试逐条对应 §11.4(取点器/匹配器/测距/有效性/滤波器/一致性/全流程),
**全部用合成框对驱动,无需真实图像**(几何是解析可断言的):
- 图像采集、立体校正 remap、帧同步、side-by-side 裁切(规格 §3);
- YOLO/检测器及其预处理(由调用方按上文约定接入);
- 亚像素边缘精化、跨帧跟踪(`track_id` 由外部追踪器提供后,时间滤波即可用)、与主测距传感器融合(§5.5/§11)。
- 取点:同侧边缘取值、框宽差不进入视差、中心对照、越窗回退、边界含等号、负视差;
- 匹配:硬约束各一例、`cost ≥ 1` 拒绝、贪心排序、一一配对;
- 测距:`Z = F·B/D` 回收、`D ≤ 0` 违例、`c_mid` 等权平均恒等式、误差平方增长;
- 有效性:三道门与边界归类、预算门通过/拦截两侧;
- 滤波/一致性:低通收敛、跳变重置不拖尾、失效清理、N 帧降级 / M 帧判无效 / 正常帧清零;
- 全流程:单目标、滤波、远场越界仍配对、单目退化、记录顺序、非同帧拒收与节流、
空输入、非有限框、track_id 跨大位移保持状态、最近邻越门限新建状态、匹配器注入、`reset`。
demo 覆盖:① 逼近(三道门依次生效 + 滤波)② 远场域外(方位照常发布)
③ 单目退化 ④ 非同帧拒收与节流 ⑤ 一致性降级/判无效 ⑥ 多目标贪心配对。
+57 -15
View File
@@ -1,30 +1,72 @@
"""stereo_ranger —— 双目稀疏目标测距(规格:docs/双目测距方案.md)。
"""stereo_ranger —— 双目稀疏目标测距(规格:docs/双目测距技术路线.md)。
纯算法包:输入校正后原生坐标系的左右目检测框,输出目标的 (Z, X, Y, R)
与方位。不做目标识别、图像采集与立体校正;仅依赖标准库。
纯算法包:输入校正后坐标系的同帧左右目检测框,输出目标距离记录(Z/R/D、
虚拟双目中心方位、valid/confidence)。不做目标识别、图像采集与立体校正;
仅依赖标准库。⚠ 全部数值参数为占位值,标定与地面验证完成前不得作为飞行
控制决策依据(§12.3 红线)。
"""
from .config import StereoConfig, load_from_json
from .stereo_ranger import (
DISP_SOURCE_CENTER,
DISP_SOURCE_EDGE,
StereoRanger,
focal_from_hfov,
range_error_abs,
from .consistency import ConsistencyMonitor, ConsistencyStatus
from .distance_filter import DistanceFilter
from .geometry import (
DELTA_D_PX,
RangeSolution,
ValidityDecision,
confidence_from_cost,
evaluate_validity,
forward_distance_m,
range_error_abs_m,
range_error_rel,
solve_range,
virtual_center_px,
)
from .types import BBoxXYXY, Detection, RangeReport
from .matching import GreedyMatcher, MatchPair, Matcher
from .sampling import Disparity, SamplePoint, sample_point, select_disparity
from .stereo_ranger import StereoRanger
from .target_store import TargetStore
from .types import (
BBoxXYXY,
Degradation,
Detection,
DetectionFrame,
DisparitySource,
Eye,
RangeRecord,
)
from .warn_throttle import WarnThrottle
__all__ = [
"BBoxXYXY",
"DISP_SOURCE_CENTER",
"DISP_SOURCE_EDGE",
"DELTA_D_PX",
"ConsistencyMonitor",
"ConsistencyStatus",
"Degradation",
"Detection",
"RangeReport",
"DetectionFrame",
"Disparity",
"DisparitySource",
"DistanceFilter",
"Eye",
"GreedyMatcher",
"MatchPair",
"Matcher",
"RangeRecord",
"RangeSolution",
"SamplePoint",
"StereoConfig",
"StereoRanger",
"focal_from_hfov",
"TargetStore",
"ValidityDecision",
"WarnThrottle",
"confidence_from_cost",
"evaluate_validity",
"forward_distance_m",
"load_from_json",
"range_error_abs",
"range_error_abs_m",
"range_error_rel",
"sample_point",
"select_disparity",
"solve_range",
"virtual_center_px",
]
+90 -49
View File
@@ -1,50 +1,51 @@
"""StereoConfig 与 JSON 配置加载。
"""StereoConfig 与 JSON 配置加载(§9 配置参数全量表)。
规格 docs/双目测距方案.md:全部数值参数均为配置项(§2),默认值取文档
示例值。配置校验在构造期一次性完成,热路径不读 JSON。
全部参数配置外置、构造期一次加载并校验:缺项、类型错误、非法取值一律拒绝
进入运行期(fail-fast)。所有默认值均为 ⚠占位值,未整定前不得用于飞行决策
(§12.3 红线 1/2)。
域权威(实现决策):视差域 disparity_min_px / disparity_max_px 为权威,
距离域 z_min_m / z_max_m 由 Z = F·B/D 派生为只读属性——域判定使用视差
(§11.2 直接观测量),两套阈值不再冗余存储、避免互推舍入不一致(§6.4)。
"""
import json
import math
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Any, Dict, Tuple
from typing import Any, Dict
# JSON 加载时的整数型字段(其余按数值校验)
_INT_FIELDS = frozenset({"consistency_N_frames", "consistency_M_frames", "filter_reset_frames"})
@dataclass
class StereoConfig:
# ---- 相机参数(校正后、对应原生分辨率,§3.1 / §4.3)----
# ---- 几何组(来自标定 P1/P2,§9.1;⚠全部为占位值)----
fx_px: float = 960.0
fy_px: float = 960.0
cx_px: float = 960.0
cy_px: float = 540.0
baseline_m: float = 0.06 # 基线 B
baseline_m: float = 0.06
# ---- 目标距离窗口:关联硬约束(§6),派生视差区间 [FB/z_max, FB/z_min] ----
z_min_m: float = 1.0
z_max_m: float = 50.0
# ---- 匹配组(§9.2)----
tau_y_px: float = 2.0
tau_h_ratio: float = 0.25
disparity_min_px: float = 3.0
disparity_max_px: float = 57.6 # ⚠占位:F=960/B=0.06 下 ⇔ 近界 1.0 m
# ---- 关联阈值(§6)----
vertical_tau_px: float = 2.0 # 垂直残差 tau_y
height_ratio_tau: float = 0.25 # 相对高度差 tau_h
# ---- 有效性组(§9.3)----
max_delta_z_m: float = 2.0 # 误差预算 δZ(δD = 1px) 上限
consistency_N_frames: int = 3 # 连续跳变降置信度的帧数
consistency_M_frames: int = 5 # 连续异常判 invalid 的帧数
consistency_jump_px: float = 3.0 # ⚠补充项:§9 未列,§6.7 的视差跳变门限
# ---- 视差取点与有效性(§5)----
disparity_min_px: float = 3.0 # D_min:D 低于此值直接无效
range_tol_m: float = 2.0 # Z_tol:δZ <= Z_tol 才算有效
disp_noise_px: float = 1.0 # δD(误差模型中的边缘定位噪声)
use_center_fallback: bool = True # 左边缘视差越窗时回退中心(§5.1)
# ---- 滤波组(§9.4)----
smooth_alpha: float = 0.3
jump_reset_m: float = 5.0
filter_reset_frames: int = 5
# ---- 可选时间滤波(§7,一阶低通 + 跳变重置)----
filter_enabled: bool = False
filter_alpha: float = 0.3
filter_jump_ratio: float = 0.2 # 相对跳变超此门限则重置滤波器
# ---- 诊断日志节流 ----
warn_interval_s: float = 5.0
def disparity_bounds_px(self) -> Tuple[float, float]:
"""关联用的视差区间 (lo, hi):由 z_min/z_max 派生(§6)。"""
fb = self.fx_px * self.baseline_m
return fb / self.z_max_m, fb / self.z_min_m
# ---- 目标隔离(§4:类别 + 框位置最近邻;⚠补充项)----
isolation_gate_px: float = 50.0
def __post_init__(self) -> None:
_require(self.fx_px > 0.0, "fx_px", "必须为正")
@@ -52,31 +53,71 @@ class StereoConfig:
_require(self.cx_px >= 0.0, "cx_px", "必须非负")
_require(self.cy_px >= 0.0, "cy_px", "必须非负")
_require(self.baseline_m > 0.0, "baseline_m", "必须为正")
_require(0.0 < self.z_min_m < self.z_max_m, "z_min_m/z_max_m", "须满足 0 < z_min_m < z_max_m")
_require(self.vertical_tau_px > 0.0, "vertical_tau_px", "必须为正")
_require(self.height_ratio_tau > 0.0, "height_ratio_tau", "必须为正")
_require(self.disparity_min_px > 0.0, "disparity_min_px", "必须为正")
_require(self.range_tol_m > 0.0, "range_tol_m", "必须为正")
_require(self.disp_noise_px > 0.0, "disp_noise_px", "必须为正")
_require(0.0 < self.filter_alpha <= 1.0, "filter_alpha", "须落在 (0, 1]")
_require(self.filter_jump_ratio >= 0.0, "filter_jump_ratio", "必须非负")
_require(self.warn_interval_s >= 0.0, "warn_interval_s", "必须非负")
_require(self.tau_y_px > 0.0, "tau_y_px", "必须为正")
_require(self.tau_h_ratio > 0.0, "tau_h_ratio", "必须为正")
_require(
0.0 < self.disparity_min_px < self.disparity_max_px,
"disparity_min_px/disparity_max_px",
"须满足 0 < 下限 < 上限",
)
_require(self.max_delta_z_m > 0.0, "max_delta_z_m", "必须为正")
_require(self.consistency_N_frames >= 1, "consistency_N_frames", "必须 ≥ 1")
_require(
self.consistency_M_frames >= self.consistency_N_frames,
"consistency_M_frames",
"必须 ≥ consistency_N_frames",
)
_require(self.consistency_jump_px > 0.0, "consistency_jump_px", "必须为正")
_require(0.0 < self.smooth_alpha <= 1.0, "smooth_alpha", "须落在 (0, 1]")
_require(self.jump_reset_m > 0.0, "jump_reset_m", "必须为正")
_require(self.filter_reset_frames >= 1, "filter_reset_frames", "必须 ≥ 1")
_require(self.isolation_gate_px > 0.0, "isolation_gate_px", "必须为正")
@property
def focal_baseline_px_m(self) -> float:
"""F·B:构造期一次算好的常量,热路径只做乘除(§11.1)。"""
return self.fx_px * self.baseline_m
@property
def z_min_m(self) -> float:
"""近界(由 disparity_max_px 派生)。"""
return self.focal_baseline_px_m / self.disparity_max_px
@property
def z_max_m(self) -> float:
"""远界(由 disparity_min_px 派生)。"""
return self.focal_baseline_px_m / self.disparity_min_px
@property
def z_error_budget_max_m(self) -> float:
"""误差预算门限对应的最远距离 sqrt(δZ_max·F·B)(δD = 1px,§7.2)。"""
return math.sqrt(self.max_delta_z_m * self.focal_baseline_px_m)
def load_from_json(path: str) -> StereoConfig:
"""从 JSON 文件构建配置。字段缺失或非法取值抛 ValueError(携带字段名)。"""
with open(path, encoding="utf-8") as f:
data: Dict[str, Any] = json.load(f)
known = {field.name for field in fields(StereoConfig)}
unknown = set(data) - known
"""从 JSON 文件构建配置;缺项/未知字段/类型错误/非法取值一律抛 ValueError。"""
with open(path, encoding="utf-8") as handle:
data: Dict[str, Any] = json.load(handle)
if not isinstance(data, dict):
raise ValueError("配置非法: 顶层须为 JSON 对象")
known = {item.name for item in fields(StereoConfig)}
unknown = sorted(set(data) - known)
if unknown:
raise ValueError(f"未知配置字段: {sorted(unknown)}")
missing = known - set(data)
raise ValueError(f"未知配置字段: {unknown}")
missing = sorted(known - set(data))
if missing:
raise ValueError(f"缺少配置字段: {sorted(missing)}")
raise ValueError(f"缺少配置字段: {missing}")
for name, value in data.items():
if isinstance(value, bool):
raise ValueError(f"配置类型非法: {name} 须为数值")
if name in _INT_FIELDS:
if not isinstance(value, int):
raise ValueError(f"配置类型非法: {name} 须为整数")
elif not isinstance(value, (int, float)):
raise ValueError(f"配置类型非法: {name} 须为数值")
return StereoConfig(**data)
def _require(ok: bool, field_name: str, message: str) -> None:
if not ok:
def _require(condition: bool, field_name: str, message: str) -> None:
if not condition:
raise ValueError(f"配置非法: {field_name} {message}")
+74
View File
@@ -0,0 +1,74 @@
"""一致性监控(§6.7):时序层面质量自检,每目标独立状态。
机载激光移除后无外部距离基准,飞行中只能靠自身时序一致性监控数据质量:
**只能降级、不能纠偏**(绝对精度靠地面静态验证)。匹配层面的质量(|Δy|
残差、框高一致性、代价门限)由匹配硬约束承担,本节只管时序层面。
"""
from dataclasses import dataclass
from typing import Dict, Hashable, Optional
@dataclass
class ConsistencyStatus:
"""单目标单帧一致性判定结果(§6.7)。"""
jump_px: Optional[float] # |D − 上次 D|;首帧无参考为 None
abnormal: bool # 本帧是否跳变超门限
streak: int # 连续异常帧数(含本帧,正常帧清零)
confidence_degraded: bool # streak ≥ N:置信度降级
invalid: bool # streak ≥ M:质量语义判无效
class ConsistencyMonitor:
"""每目标:上次视差 + 连续异常计数(§11.1 必须持久化的状态)。"""
def __init__(self, jump_px: float, degrade_frames: int, invalid_frames: int) -> None:
if jump_px <= 0.0:
raise ValueError("jump_px 必须为正")
if degrade_frames < 1:
raise ValueError("degrade_frames 必须 ≥ 1")
if invalid_frames < degrade_frames:
raise ValueError("invalid_frames 必须 ≥ degrade_frames")
self._jump_px = jump_px
self._degrade_frames = degrade_frames
self._invalid_frames = invalid_frames
self._last_d: Dict[Hashable, float] = {}
self._streak: Dict[Hashable, int] = {}
def advance(self, key: Hashable, disparity_px: float) -> ConsistencyStatus:
"""推进目标 key 的一致性判定(配对成功帧每帧一次)。"""
last = self._last_d.get(key)
if last is None:
jump: Optional[float] = None
abnormal = False
streak = 0
else:
jump = abs(disparity_px - last)
abnormal = jump > self._jump_px
streak = self._streak.get(key, 0) + 1 if abnormal else 0
self._last_d[key] = disparity_px
if streak:
self._streak[key] = streak
else:
self._streak.pop(key, None)
return ConsistencyStatus(
jump_px=jump,
abnormal=abnormal,
streak=streak,
confidence_degraded=streak >= self._degrade_frames,
invalid=streak >= self._invalid_frames,
)
def streak(self, key: Hashable) -> int:
"""目标 key 当前连续异常计数。"""
return self._streak.get(key, 0)
def drop(self, key: Hashable) -> None:
"""清理目标 key 的状态(目标老化清理时调用)。"""
self._last_d.pop(key, None)
self._streak.pop(key, None)
def clear(self) -> None:
self._last_d.clear()
self._streak.clear()
+68
View File
@@ -0,0 +1,68 @@
"""时间滤波器(§6.5):一阶低通 + 单帧跳变重置,每目标独立状态。
- 单帧毛刺由跳变重置处理,持续性异常由一致性监控(§6.7)处理,两者职责
不同不得合并;
- 滤波只在**有效帧**之间进行,无效/缺席帧不参与平滑;α = 1 等效不滤波;
- 连续失效(无效或缺席)达 filter_reset_frames 帧清空状态,下次有效帧重新
初始化(防止用陈旧状态平滑新测量)。
"""
from typing import Dict, Hashable, Optional
class DistanceFilter:
"""每目标:上次滤波值 + 连续失效计数(§11.1 必须持久化的状态)。"""
def __init__(self, alpha: float, jump_reset_m: float, reset_frames: int) -> None:
if not 0.0 < alpha <= 1.0:
raise ValueError("alpha 须落在 (0, 1]")
if jump_reset_m <= 0.0:
raise ValueError("jump_reset_m 必须为正")
if reset_frames < 1:
raise ValueError("reset_frames 必须 ≥ 1")
self._alpha = alpha
self._jump_reset_m = jump_reset_m
self._reset_frames = reset_frames
self._values: Dict[Hashable, float] = {}
self._invalid_streak: Dict[Hashable, int] = {}
def advance(self, key: Hashable, z_m: float) -> float:
"""有效帧推进:未初始化→直接置值;跳变超门限→重置;否则一阶低通。"""
previous = self._values.get(key)
self._invalid_streak.pop(key, None)
if previous is None:
self._values[key] = z_m
return z_m
if abs(z_m - previous) > self._jump_reset_m:
self._values[key] = z_m # 直接重置而非滤波,防止拖尾
return z_m
smoothed = previous + self._alpha * (z_m - previous)
self._values[key] = smoothed
return smoothed
def invalidate(self, key: Hashable) -> None:
"""无效/缺席帧:连续失效达门限后清空该目标滤波状态(§6.5 记失效)。"""
if key not in self._values:
return
streak = self._invalid_streak.get(key, 0) + 1
if streak >= self._reset_frames:
self._values.pop(key, None)
self._invalid_streak.pop(key, None)
else:
self._invalid_streak[key] = streak
def value(self, key: Hashable) -> Optional[float]:
"""目标 key 当前滤波值;未初始化/已清空为 None。"""
return self._values.get(key)
def invalid_streak(self, key: Hashable) -> int:
"""目标 key 当前连续失效计数(内部状态,供测试/诊断)。"""
return self._invalid_streak.get(key, 0)
def drop(self, key: Hashable) -> None:
self._values.pop(key, None)
self._invalid_streak.pop(key, None)
def clear(self) -> None:
self._values.clear()
self._invalid_streak.clear()
+84
View File
@@ -0,0 +1,84 @@
"""测距计算与有效性判定(§3.2/§6.4/§7.1):纯函数、无状态。
Z = F·B/D(前向分量,距离通道唯一消费量,§10.5);X/Y 以左目系表达;
R = sqrt(X² + Y² + Z²) 仅参考输出;c_mid = 两目框中心等权平均(虚拟双目
中心,§6.6,逐点精确、无近似)。
"""
import math
from dataclasses import dataclass
from typing import Optional, Tuple
from .config import StereoConfig
from .types import Detection
DELTA_D_PX = 1.0 # 误差预算的δD 基准(§7.2 定义 δZ(δD = 1px))
@dataclass(frozen=True)
class RangeSolution:
"""测距解算结果(Z 为前向分量,X/Y 属左目系,§3.2)。"""
z_m: float
x_m: float
y_m: float
r_m: float # 斜距(参考值,不进控制回路)
@dataclass(frozen=True)
class ValidityDecision:
"""有效性判定结果(§6.4 三道门的前两道;门③一致性由调用方合流)。"""
in_domain: bool # ① 域内:D ∈ [D_min, D_max]
budget_ok: bool # ② 误差预算:δZ(δD = 1px) ≤ δZ_max
valid: bool # ① 且 ②
def forward_distance_m(disparity_px: float, config: StereoConfig) -> float:
"""Z = F·B/D;调用方须先判 D ≤ 0 物理违例(§6.3/§11.2 除零纪律)。"""
if disparity_px <= 0.0:
raise ValueError(f"视差须为正(先判违例再除): D={disparity_px}")
return config.focal_baseline_px_m / disparity_px
def solve_range(left_box: Detection, right_box: Detection, disparity_px: float, config: StereoConfig) -> RangeSolution:
"""由视差与参考目(左目)框中心解算 (Z, X, Y, R)(§3.2)。"""
z_m = forward_distance_m(disparity_px, config)
x_m = (left_box.cx - config.cx_px) * z_m / config.fx_px
y_m = (left_box.cy - config.cy_px) * z_m / config.fy_px
r_m = math.sqrt(x_m * x_m + y_m * y_m + z_m * z_m)
return RangeSolution(z_m=z_m, x_m=x_m, y_m=y_m, r_m=r_m)
def virtual_center_px(left_box: Detection, right_box: Detection) -> Tuple[float, float]:
"""虚拟双目中心 c_mid = (cL + cR)/2(§6.6:等权平均 = 把相机搬到模组中线)。
与视差取点(同侧边缘)是两个不同的取点,不得混用(§6.6)。
"""
return ((left_box.cx + right_box.cx) / 2.0, (left_box.cy + right_box.cy) / 2.0)
def range_error_abs_m(z_m: float, config: StereoConfig, delta_d_px: float = DELTA_D_PX) -> float:
"""绝对误差估计 δZ ≈ Z²·δD/(F·B)(§7.1)。"""
return z_m * z_m * delta_d_px / config.focal_baseline_px_m
def range_error_rel(z_m: float, config: StereoConfig, delta_d_px: float = DELTA_D_PX) -> float:
"""相对误差估计 δZ/Z ≈ Z·δD/(F·B)(§7.1)。"""
return z_m * delta_d_px / config.focal_baseline_px_m
def evaluate_validity(disparity_px: float, z_m: Optional[float], config: StereoConfig) -> ValidityDecision:
"""有效性门①②(§6.4):域内 + 误差预算;z_m=None(D ≤ 0)时预算判失败。"""
in_domain = config.disparity_min_px <= disparity_px <= config.disparity_max_px
budget_ok = (
z_m is not None
and math.isfinite(z_m)
and range_error_abs_m(z_m, config) <= config.max_delta_z_m
)
return ValidityDecision(in_domain=in_domain, budget_ok=budget_ok, valid=in_domain and budget_ok)
def confidence_from_cost(cost: float) -> float:
"""匹配代价 → 置信度基值:线性映射 clamp(1 − cost, 0, 1)(§6.4 ⚠占位映射)。"""
return min(1.0, max(0.0, 1.0 - cost))
+82
View File
@@ -0,0 +1,82 @@
"""左右匹配器(§6.2):硬约束 + 代价升序贪心一一配对;接口注入。
默认 GreedyMatcher 为纯函数(§4 构件表);调用方可通过
StereoRanger(matcher=...) 注入匈牙利等替换实现而不动调用方(§10.3)。
硬约束(任一不满足直接拒绝配对,不进代价排序):类别一致、|Δy| ≤ τ_y、
框高相对差 ≤ τ_h。视差域 [D_min, D_max] 不参与配对:越界属有效性门
(§6.2 已回填,与 §5 失败路径表/§6.3/§6.4/§2.3 一致)。
"""
from dataclasses import dataclass
from typing import List, Protocol, Sequence
from .config import StereoConfig
from .sampling import SamplePoint
@dataclass(frozen=True)
class MatchPair:
"""一一配对结果:左右下标 + 匹配代价(§6.2)。"""
left_index: int
right_index: int
cost: float
class Matcher(Protocol):
"""匹配器接口(§4:接口注入,默认贪心)。"""
def match(
self,
left: Sequence[SamplePoint],
right: Sequence[SamplePoint],
config: StereoConfig,
) -> List[MatchPair]:
...
class GreedyMatcher:
"""硬约束过滤 + 代价升序贪心:cost < 1 才接受,一一配对。"""
def match(
self,
left: Sequence[SamplePoint],
right: Sequence[SamplePoint],
config: StereoConfig,
) -> List[MatchPair]:
tau_y = config.tau_y_px
tau_h = config.tau_h_ratio
candidates = []
for i, point_l in enumerate(left):
if not point_l.finite:
continue
for j, point_r in enumerate(right):
if not point_r.finite:
continue
if point_l.box.class_id != point_r.box.class_id:
continue
dy = abs(point_l.center_y - point_r.center_y)
if dy > tau_y:
continue
height_max = max(point_l.box.height, point_r.box.height)
if height_max <= 0.0:
continue
dh_ratio = abs(point_l.box.height - point_r.box.height) / height_max
if dh_ratio > tau_h:
continue
cost = dy / tau_y + dh_ratio / tau_h
if cost >= 1.0:
continue
candidates.append((cost, i, j))
candidates.sort(key=lambda item: (item[0], item[1], item[2]))
used_left = set()
used_right = set()
pairs: List[MatchPair] = []
for cost, i, j in candidates:
if i in used_left or j in used_right:
continue
pairs.append(MatchPair(left_index=i, right_index=j, cost=cost))
used_left.add(i)
used_right.add(j)
return pairs
+73
View File
@@ -0,0 +1,73 @@
"""视差取点器(§6.1):同侧(左)边缘为主方案,边缘视差越窗时回退框中心。
纯函数、无状态(§4 构件表)。为什么用同侧边缘:同一物理目标在两目中的
检测框宽并不严格相等,框中心视差会混入框宽差的一半,同侧边缘视差可抵消
框宽项。
注:§6.1 的"左右两边缘各算一次视差取中值"精化在数学上恒等于框中心视差
(两支的 median = 均值),与"中心视差混入框宽差"的理由矛盾,属原文笔误,
v1.0 不实现;规格 §6.1 已删除该项并注明(见包 README「实现决策」)。
"""
import math
from dataclasses import dataclass
from .config import StereoConfig
from .types import Detection, DisparitySource
@dataclass(frozen=True)
class SamplePoint:
"""单目单框取点结果:同侧边缘候选与框中心(§6.1/§6.6)。
finite=False 表示坐标非有限值:该框不可参与解算,由调用方丢弃并告警。
"""
box: Detection
edge_x: float # 同侧(左)边缘 x = box.x1
center_x: float
center_y: float
finite: bool
@dataclass(frozen=True)
class Disparity:
"""配对后的视差取点结果。"""
d_px: float
source: DisparitySource
fallback_used: bool # 边缘视差越窗、回退中心(需告警节流,§6.1)
in_domain: bool # d ∈ [D_min, D_max](含边界,§11.2)
positive: bool # d > 0(正视差是物理前提,§6.3)
def sample_point(detection: Detection) -> SamplePoint:
"""逐框取点(§5 第 ② 步,每目每框一次)。"""
finite = all(math.isfinite(value) for value in (detection.x1, detection.y1, detection.x2, detection.y2))
return SamplePoint(
box=detection,
edge_x=detection.x1,
center_x=detection.cx,
center_y=detection.cy,
finite=finite,
)
def select_disparity(left: SamplePoint, right: SamplePoint, config: StereoConfig) -> Disparity:
"""在同侧边缘视差与框中心视差之间选择(§6.1 主取点 + 越窗回退)。
规则:边缘视差在域内取边缘;否则若中心视差在域内则回退中心(告警节流);
两者均越窗时保留边缘值(供诊断与违例告警),由有效性门判无效。
"""
d_edge = left.edge_x - right.edge_x
if _in_domain(d_edge, config):
return Disparity(d_edge, DisparitySource.LEFT_EDGE, False, True, d_edge > 0.0)
d_center = left.center_x - right.center_x
if _in_domain(d_center, config):
return Disparity(d_center, DisparitySource.CENTER_FALLBACK, True, True, d_center > 0.0)
d = d_edge if math.isfinite(d_edge) else d_center
return Disparity(d, DisparitySource.LEFT_EDGE, False, False, d > 0.0)
def _in_domain(d_px: float, config: StereoConfig) -> bool:
return config.disparity_min_px <= d_px <= config.disparity_max_px
+192 -180
View File
@@ -1,215 +1,227 @@
"""StereoRanger —— 双目稀疏目标测距(纯算法,规格 docs/双目测距方案.md)。
"""StereoRanger —— 双目稀疏目标测距(规格 docs/双目测距技术路线.md)。
输入为左右目各自、校正后原生坐标系下的检测框列表;不做目标识别,不做
图像采集/立体校正。单帧主流程:关联(§6)→ 视差取点(§5.1)→
Z=F·B/D 与 (X, Y, R)(§4.2)→ 有效性(§5.4)→ 可选时间滤波(§7)。
依赖仅为标准库。
单周期五步(§5):① 同帧契约检查 → ② 取点 → ③ 左右匹配 →
④ 配对目标:测距 + 方位源 + 有效性 + 滤波 → ⑤ 单目退化发布。
输入为左右目各自、校正后原生坐标系下的检测框;不做目标识别,不做图像
采集/立体校正。纯计算单元(§11.3):单周期处理无阻塞、无 IO;同一实例
不得并发调用 update()。
"""
import logging
import math
import time
from typing import Dict, List, Optional, Sequence, Tuple
from typing import List, Optional, Sequence
from .config import StereoConfig
from .types import BBoxXYXY, Detection, RangeReport
from .geometry import confidence_from_cost, evaluate_validity, solve_range, virtual_center_px
from .matching import GreedyMatcher, Matcher, MatchPair
from .sampling import SamplePoint, sample_point, select_disparity
from .target_store import TargetStore
from .types import Degradation, Detection, DetectionFrame, Eye, RangeRecord
from .warn_throttle import WarnThrottle
logger = logging.getLogger("stereo_ranger")
DISP_SOURCE_EDGE = "left_edge"
DISP_SOURCE_CENTER = "center"
def focal_from_hfov(image_width_px: float, hfov_deg: float) -> float:
"""由水平视场与图像宽近似像素焦距:F = (W/2) / tan(HFOV/2)(§5.3)。"""
return (image_width_px / 2.0) / math.tan(math.radians(hfov_deg) / 2.0)
def range_error_abs(z_m: float, fx_px: float, baseline_m: float, delta_d_px: float = 1.0) -> float:
"""绝对距离误差估计 δZ ≈ Z^2·δD/(F·B)(§5.2)。"""
return z_m * z_m * delta_d_px / (fx_px * baseline_m)
def range_error_rel(z_m: float, fx_px: float, baseline_m: float, delta_d_px: float = 1.0) -> float:
"""相对距离误差 δZ/Z ≈ Z·δD/(F·B)(§5.2)。"""
return z_m * delta_d_px / (fx_px * baseline_m)
_CONFIDENCE_DEGRADE_FACTOR = 0.5 # ⚠占位:连续跳变 ≥ N 帧的置信度降级系数(§6.4)
class StereoRanger:
"""双目稀疏目标测距器。
"""双目稀疏目标测距器:每帧调用一次 update(),传入同帧左右目检测结果。"""
用法:每帧调用一次 update(),传入同一逻辑帧的左右目检测结果。
"""
def __init__(self, config: Optional[StereoConfig] = None):
self._cfg = config if config is not None else StereoConfig()
self._z_state: Dict[Tuple[int, int], float] = {} # (cls_id, track_id) -> 滤波 Z
self._warn_at: Dict[str, float] = {}
def __init__(self, config: Optional[StereoConfig] = None, matcher: Optional[Matcher] = None) -> None:
self._config = config if config is not None else StereoConfig()
self._matcher = matcher if matcher is not None else GreedyMatcher()
self._store = TargetStore(self._config)
self._warn = WarnThrottle()
self._frame_index = 0
@property
def config(self) -> StereoConfig:
return self._cfg
return self._config
@property
def matcher(self) -> Matcher:
return self._matcher
def reset(self) -> None:
self._z_state.clear()
self._warn_at.clear()
"""清空全部运行期状态(滤波 / 一致性 / 隔离 / 节流计数)。"""
self._store.clear()
self._warn.clear()
self._frame_index = 0
def update(
self,
left: Sequence[Detection],
right: Sequence[Detection],
frame_sequence: int = 0,
timestamp_s: Optional[float] = None,
) -> List[RangeReport]:
"""单帧主流程:返回报告列表(每个左目检测一条,保持左目输入顺序)。
def update(self, left: DetectionFrame, right: DetectionFrame, now_ms: int = 0) -> List[RangeRecord]:
"""单周期处理(§5):返回本帧记录列表。
配对成功且测距有效的报告 valid=True;配对成功但超出有效工作域的
valid=False(距离仍给出,§5.4);未配对的左目检测 matched=False,
仅发布单目方位(§6 失败处理)。
顺序:先按左目框顺序给出各自记录(配对→完整记录 / 未配对→仅方位),
再给出未配对的右目框的仅方位记录;左目存在时记录数与左目框数一致。
"""
pairs = self._match(left, right)
matched_r: Dict[int, int] = {l_idx: r_idx for l_idx, r_idx, _ in pairs}
reports: List[RangeReport] = []
for l_idx, det_l in enumerate(left):
r_idx = matched_r.get(l_idx)
if r_idx is None:
reports.append(self._unmatched_report(det_l, frame_sequence, timestamp_s))
config = self._config
if left.frame_id != right.frame_id:
self._warn_emit(
"frame_mismatch",
"左右帧标识不一致(L=%s, R=%s),拒收本帧",
left.frame_id,
right.frame_id,
)
return []
self._frame_index += 1
frame_index = self._frame_index
left_points = self._drop_invalid([sample_point(det) for det in left.detections])
right_points = self._drop_invalid([sample_point(det) for det in right.detections])
pairs = self._matcher.match(left_points, right_points, config)
pair_of_left = {pair.left_index: pair for pair in pairs}
paired_right = {pair.right_index for pair in pairs}
records: List[RangeRecord] = []
for index, point_l in enumerate(left_points):
pair = pair_of_left.get(index)
if pair is None:
records.append(self._bearing_only_record(point_l, left.frame_id, now_ms, Eye.LEFT))
continue
report = self._build_report(det_l, right[r_idx], frame_sequence, timestamp_s)
reports.append(report)
return reports
records.append(
self._complete_record(
point_l,
right_points[pair.right_index],
pair,
left.frame_id,
now_ms,
frame_index,
)
)
for index, point_r in enumerate(right_points):
if index in paired_right:
continue
records.append(self._bearing_only_record(point_r, left.frame_id, now_ms, Eye.RIGHT))
self._store.end_frame(frame_index)
return records
# ---- 内部实现 ----
def _match(self, left: Sequence[Detection], right: Sequence[Detection]) -> List[Tuple[int, int, Detection]]:
"""硬约束过滤 + 代价升序贪心一一配对(§6)。返回 (左下标, 右下标, 左框)。"""
cfg = self._cfg
d_lo, d_hi = cfg.disparity_bounds_px()
candidates: List[Tuple[int, int, Detection, float]] = []
for i, det_l in enumerate(left):
for j, det_r in enumerate(right):
if det_l.cls_id != det_r.cls_id:
continue
dy = abs(det_l.cy - det_r.cy)
if dy > cfg.vertical_tau_px:
continue
h_max = max(det_l.height, det_r.height)
if h_max <= 0.0:
continue
dh_ratio = abs(det_l.height - det_r.height) / h_max
if dh_ratio > cfg.height_ratio_tau:
continue
cost = dy / cfg.vertical_tau_px + dh_ratio / cfg.height_ratio_tau
if cost >= 1.0:
continue
d_edge = det_l.x1 - det_r.x1
if not (d_lo <= d_edge <= d_hi):
d_center = det_l.cx - det_r.cx
if cfg.use_center_fallback and (d_lo <= d_center <= d_hi):
self._warn("center_fallback", "左边缘视差越出窗口,回退中心视差(宽度差可能混入)")
else:
continue
same_track = det_l.track_id > 0 and det_l.track_id == det_r.track_id
candidates.append((0 if same_track else 1, cost, i, j, det_l))
candidates.sort(key=lambda c: (c[0], c[1]))
matched_l: set = set()
used_r: set = set()
pairs: List[Tuple[int, int, Detection]] = []
for _, _, i, j, det_l in candidates:
if i in matched_l or j in used_r:
continue
pairs.append((i, j, det_l))
matched_l.add(i)
used_r.add(j)
return pairs
def _complete_record(
self,
point_l: SamplePoint,
point_r: SamplePoint,
pair: MatchPair,
frame_id: int,
now_ms: int,
frame_index: int,
) -> RangeRecord:
"""配对成功的记录:测距 + 方位源 + 有效性(门①②)+ 一致性(门③)+ 滤波。"""
config = self._config
box_l = point_l.box
box_r = point_r.box
disparity = select_disparity(point_l, point_r, config)
center_mid = virtual_center_px(box_l, box_r)
def _build_report(self, det_l: Detection, det_r: Detection, seq: int, ts: Optional[float]) -> RangeReport:
cfg = self._cfg
d_edge = det_l.x1 - det_r.x1
d_lo, d_hi = cfg.disparity_bounds_px()
if d_lo <= d_edge <= d_hi:
d = d_edge
source = DISP_SOURCE_EDGE
else:
d = det_l.cx - det_r.cx
source = DISP_SOURCE_CENTER
z = cfg.fx_px * cfg.baseline_m / d
report = RangeReport(
frame_sequence=seq,
timestamp_s=ts,
class_id=det_l.cls_id,
track_id=det_l.track_id if det_l.track_id != 0 else det_r.track_id,
matched=True,
valid=False, # §5.4 判定见下
forward_distance_m=z,
disparity_px=d,
disparity_source=source,
confidence=min(det_l.conf, det_r.conf),
centre_pixel_x_mid=(det_l.cx + det_r.cx) / 2.0,
centre_pixel_y_mid=det_l.cy,
ref_bbox=(det_l.x1, det_l.y1, det_l.x2, det_l.y2),
if disparity.fallback_used:
self._warn_emit("center_fallback", "边缘视差越窗,回退框中心(框宽差可能混入视差)")
if not disparity.positive:
self._warn_emit("disparity_violation", "视差违例 D=%.2f px(须为正),该目标判无效", disparity.d_px)
elif not disparity.in_domain:
self._warn_emit(
"disparity_violation",
"视差超界 D=%.2f px(域 [%.2f, %.2f]),该目标判无效",
disparity.d_px,
config.disparity_min_px,
config.disparity_max_px,
)
key = self._store.resolve(
box_l.class_id,
_shared_track_id(box_l, box_r),
center_mid,
frame_index,
)
err = range_error_abs(z, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px)
if d >= cfg.disparity_min_px and err <= cfg.range_tol_m:
report.valid = True
z_out = self._filter_z(report.class_id, report.track_id, z)
report.forward_distance_m = z_out
report.error_est_m = range_error_abs(z_out, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px)
status = self._store.consistency.advance(key, disparity.d_px)
if status.invalid:
self._warn_emit(
"consistency_invalid",
"连续 %d 帧视差跳变超门限(%.1f px),质量判无效",
status.streak,
config.consistency_jump_px,
)
z_raw = config.focal_baseline_px_m / disparity.d_px if disparity.positive else None
solution = solve_range(box_l, box_r, disparity.d_px, config) if z_raw is not None else None
decision = evaluate_validity(disparity.d_px, z_raw, config)
valid = decision.valid and not status.invalid
confidence = confidence_from_cost(pair.cost)
if status.confidence_degraded:
confidence *= _CONFIDENCE_DEGRADE_FACTOR
if status.invalid:
confidence = 0.0
if valid:
z_out = self._store.distance_filter.advance(key, z_raw)
else:
report.error_est_m = err
self._fill_xyz(report, det_l.cx)
return report
self._store.distance_filter.invalidate(key)
z_out = None
def _fill_xyz(self, report: RangeReport, x_l_px: float) -> None:
"""由 Z 与左目(参考目)框中心像素回算左目系 X, Y, R(§4.2)。
# 参考斜距与已发布的前向距离同基准:X/Y/Z 均 ∝ Z,按 z_out/z_raw 缩放即精确换基
slant_range_m = None
if solution is not None:
slant_range_m = solution.r_m if z_out is None else solution.r_m * (z_out / z_raw)
注意:方位源 centre_pixel_x_mid 属于基线中点虚拟相机(§11.3),
而 X 定义在左目坐标系,二者相差 B/2,不得混用。
"""
cfg = self._cfg
z = report.forward_distance_m
x = (x_l_px - cfg.cx_px) * z / cfg.fx_px
y = (report.centre_pixel_y_mid - cfg.cy_px) * z / cfg.fy_px
report.lateral_m = x
report.vertical_m = y
report.slant_range_m = math.sqrt(x * x + y * y + z * z)
return RangeRecord(
frame_id=frame_id,
class_id=box_l.class_id,
degradation=Degradation.COMPLETE,
valid=valid,
track_id=box_l.track_id if box_l.track_id is not None else box_r.track_id,
forward_distance_m=z_out,
slant_range_m=slant_range_m,
disparity_px=disparity.d_px,
center_mid_px=center_mid,
reference_box=box_l.bbox,
reference_eye=Eye.LEFT,
confidence=confidence,
timestamp_ms=now_ms,
)
def _filter_z(self, cls_id: int, track_id: int, z: float) -> float:
"""可选一阶低通 + 跳变重置(§7)。track_id 为 0(无轨迹)时不过滤。"""
cfg = self._cfg
if not cfg.filter_enabled or track_id == 0:
return z
key = (cls_id, track_id)
prev = self._z_state.get(key)
if prev is None:
self._z_state[key] = z
return z
if abs(z - prev) / prev > cfg.filter_jump_ratio:
self._z_state[key] = z # 跳变:重置
return z
z_out = (1.0 - cfg.filter_alpha) * prev + cfg.filter_alpha * z
self._z_state[key] = z_out
return z_out
def _unmatched_report(self, det: Detection, seq: int, ts: Optional[float]) -> RangeReport:
return RangeReport(
frame_sequence=seq,
timestamp_s=ts,
class_id=det.cls_id,
track_id=det.track_id,
matched=False,
def _bearing_only_record(
self,
point: SamplePoint,
frame_id: int,
now_ms: int,
eye: Eye,
) -> RangeRecord:
"""单目退化记录(§5 第 ⑤ 步):仅方位,valid = false,无距离。"""
detection = point.box
return RangeRecord(
frame_id=frame_id,
class_id=detection.class_id,
degradation=Degradation.BEARING_ONLY,
valid=False,
confidence=det.conf,
centre_pixel_x_mid=det.cx,
centre_pixel_y_mid=det.cy,
ref_bbox=(det.x1, det.y1, det.x2, det.y2),
track_id=detection.track_id,
center_mid_px=(detection.cx, detection.cy),
reference_box=detection.bbox,
reference_eye=eye,
confidence=detection.confidence,
timestamp_ms=now_ms,
)
def _warn(self, category: str, message: str) -> None:
"""节流诊断(§7:失败日志须节流)。"""
now = time.monotonic()
last = self._warn_at.get(category)
if last is not None and now - last < self._cfg.warn_interval_s:
def _drop_invalid(self, points: Sequence[SamplePoint]) -> List[SamplePoint]:
"""丢弃坐标非有限值的框(不可参与匹配,也不发布记录),告警节流。"""
kept: List[SamplePoint] = []
for point in points:
if point.finite:
kept.append(point)
else:
self._warn_emit("invalid_box", "检测框坐标非法(非有限值),已丢弃该框")
return kept
def _warn_emit(self, category: str, message: str, *args: object) -> None:
"""异常日志(§5 节流规范:第 1 次 + 每满 100 次,带累计计数)。"""
if not self._warn.should_emit(category):
return
self._warn_at[category] = now
logger.warning("[%s] %s", category, message)
logger.warning("[%s] " + message + "(累计 %d 次)", category, *args, self._warn.count(category))
def _shared_track_id(box_l: Detection, box_r: Detection) -> Optional[int]:
"""左右目轨道关联一致且有效时返回该 id(目标隔离键优先路径,§4)。"""
if box_l.track_id is not None and box_l.track_id > 0 and box_l.track_id == box_r.track_id:
return box_l.track_id
return None
+119
View File
@@ -0,0 +1,119 @@
"""目标状态隔离(§4):track_id 优先 + 框位置最近邻兜底。
目标隔离键:track_id 可用(左右一致且 > 0)时优先以 (track, 类别, track_id)
为键——上游 target_tracker 已在链路中,规格 §4 已按此冻结;否则回退类别 +
框位置最近邻(isolation_gate_px 门限,见包 README「实现决策」)。
连续 filter_reset_frames 帧未再出现的目标状态整体清理(位置 + 距离滤波 +
一致性),避免最近邻把新目标接到陈旧状态上。
"""
import math
from dataclasses import dataclass
from typing import Dict, Hashable, Optional, Tuple
from .config import StereoConfig
from .consistency import ConsistencyMonitor
from .distance_filter import DistanceFilter
_KIND_TRACK = "track"
_KIND_NEAREST = "nn"
@dataclass
class _TargetState:
class_id: int
track_id: Optional[int]
center_px: Tuple[float, float]
last_seen_frame: int
class TargetStore:
"""每目标状态容器:隔离键解析 + 帧末老化清理(§11.1 状态清单)。"""
def __init__(self, config: StereoConfig) -> None:
self._config = config
self._distance_filter = DistanceFilter(
config.smooth_alpha, config.jump_reset_m, config.filter_reset_frames
)
self._consistency = ConsistencyMonitor(
config.consistency_jump_px, config.consistency_N_frames, config.consistency_M_frames
)
self._states: Dict[Hashable, _TargetState] = {}
self._next_nearest_id = 0
@property
def distance_filter(self) -> DistanceFilter:
return self._distance_filter
@property
def consistency(self) -> ConsistencyMonitor:
return self._consistency
@property
def count(self) -> int:
"""当前存活的目标状态数。"""
return len(self._states)
def resolve(
self,
class_id: int,
track_id: Optional[int],
center_px: Tuple[float, float],
frame_index: int,
) -> Hashable:
"""解析(或新建)目标 key,并刷新该目标最近位置与出现帧。"""
if track_id is not None and track_id > 0:
key: Hashable = (_KIND_TRACK, class_id, track_id)
self._states[key] = _TargetState(class_id, track_id, center_px, frame_index)
return key
key = self._nearest_key(class_id, center_px, frame_index)
if key is None:
self._next_nearest_id += 1
key = (_KIND_NEAREST, class_id, self._next_nearest_id)
self._states[key] = _TargetState(class_id, None, center_px, frame_index)
else:
state = self._states[key]
state.center_px = center_px
state.last_seen_frame = frame_index
return key
def end_frame(self, frame_index: int) -> None:
"""帧末收尾:未出现的目标记滤波失效,到期整体清理三类状态。"""
stale: List[Hashable] = []
for key, state in self._states.items():
if state.last_seen_frame == frame_index:
continue
self._distance_filter.invalidate(key)
if frame_index - state.last_seen_frame >= self._config.filter_reset_frames:
stale.append(key)
for key in stale:
del self._states[key]
self._distance_filter.drop(key)
self._consistency.drop(key)
def clear(self) -> None:
self._states.clear()
self._distance_filter.clear()
self._consistency.clear()
self._next_nearest_id = 0
def _nearest_key(
self,
class_id: int,
center_px: Tuple[float, float],
frame_index: int,
) -> Optional[Hashable]:
"""同类别、isolation_gate_px 内、本帧尚未被占用的最近状态。"""
gate = self._config.isolation_gate_px
best_key: Optional[Hashable] = None
best_distance: Optional[float] = None
for key, state in self._states.items():
if key[0] != _KIND_NEAREST or state.class_id != class_id:
continue
if state.last_seen_frame == frame_index:
continue # 本帧已被其他框接管,一一归属
distance = math.hypot(center_px[0] - state.center_px[0], center_px[1] - state.center_px[1])
if distance <= gate and (best_distance is None or distance < best_distance):
best_key, best_distance = key, distance
return best_key
+68 -32
View File
@@ -1,29 +1,50 @@
"""stereo_ranger 输入输出数据结构:Detection / RangeReport。
"""stereo_ranger 输入输出数据结构(纯值语义)。
规格:docs/双目测距方案.md §10 结果记录。
规格:docs/双目测距技术路线.md —— §2 输入输出接口、§3 符号约定、§6.6 方位源。
约定:所有像素坐标位于**已立体校正**的图像坐标系,检测框已映射回该基准
(§2.1 校正契约 / 坐标基准一致)。本包仅依赖标准库。
"""
from dataclasses import dataclass
from typing import Optional, Tuple
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, Sequence, Tuple
BBoxXYXY = Tuple[float, float, float, float]
class Eye(Enum):
"""物理目(参考目框来源,§2.2)。"""
LEFT = auto() # 左目(解算参考系)
RIGHT = auto() # 右目
class DisparitySource(Enum):
"""视差取点来源(§6.1)。"""
LEFT_EDGE = auto() # 同侧(左)边缘:v1.0 主方案
CENTER_FALLBACK = auto() # 边缘视差越窗后回退框中心
class Degradation(Enum):
"""记录退化标记(§2.2)。描述记录的来源形态,质量由 valid 单独表达。"""
COMPLETE = auto() # 完整:配对成功(距离 + 方位两路都在)
BEARING_ONLY = auto() # 仅方位:单目检出,无距离
@dataclass
class Detection:
"""校正后原生坐标系下的一个检测框(单位 px)。
调用方负责:左右目预处理几何完全一致,且框坐标已映射回原生分辨率
(规格 §4.3)。x1/y1 为左上角,x2/y2 为右下角(x1 即"左边缘")。
"""
"""校正后图像坐标系下的一个检测框(px;x1/y1 左上角,x2/y2 右下角)。"""
x1: float
y1: float
x2: float
y2: float
conf: float = 1.0
cls_id: int = 0
track_id: int = 0 # 未知为 0
confidence: float = 1.0
class_id: int = 0
track_id: Optional[int] = None # 检测侧轨道关联;None = 无(§2.2,v1.0 可无)
@property
def width(self) -> float:
@@ -41,31 +62,46 @@ class Detection:
def cy(self) -> float:
return (self.y1 + self.y2) / 2.0
@property
def bbox(self) -> BBoxXYXY:
return (self.x1, self.y1, self.x2, self.y2)
@dataclass
class RangeReport:
"""单目标单帧测距结果(一个左目检测对应一条记录)。
class DetectionFrame:
"""单目一帧:帧标识 + 检测框列表。
matched=False 表示该左目检测本帧未配对:valid 恒为 False,距离字段为
None,centre_pixel_x/y_mid 为单目自身中心(方位回退,规格 §11.3)。
matched=True 且 valid=False 表示已配对但距离超出有效工作域
(规格 §5.4):距离字段仍给出估计值供诊断,方位照常发布。
同帧契约(§2.1):左右两帧的 frame_id 必须一致,否则整帧拒收、输出空。
"""
frame_sequence: int
frame_id: int
detections: Sequence[Detection] = field(default_factory=tuple)
@dataclass
class RangeRecord:
"""目标距离记录(§2.2,字段与文档表格逐项对应)。
- forward_distance_m(Z):唯一供距离通道消费的量;valid=False 或单目
退化时为 None(不得强行估计,§12.3 红线 3);
- slant_range_m(R):参考值,不进控制回路;无效但仍可解算时保留原始
测量值供诊断;
- disparity_px(D):本次视差(调试与交叉校验用);
- center_mid_px(c_mid):方位源;配对记录为两目框中心等权平均(虚拟
双目中心,§6.6),单目退化为该目框中心;
- reference_box / reference_eye:参考目框(配对记录恒为左目)。
"""
frame_id: int
class_id: int
track_id: int
matched: bool
degradation: Degradation
valid: bool
forward_distance_m: Optional[float] = None # Z,前向分量(主输出)
lateral_m: Optional[float] = None # X,左目系横向偏移
vertical_m: Optional[float] = None # Y,左目系纵向偏移
slant_range_m: Optional[float] = None # R = sqrt(X^2 + Y^2 + Z^2)
disparity_px: Optional[float] = None # D = xL - xR(>0 为前方)
disparity_source: Optional[str] = None # "left_edge" / "center"
track_id: Optional[int] = None
forward_distance_m: Optional[float] = None
slant_range_m: Optional[float] = None
disparity_px: Optional[float] = None
center_mid_px: Tuple[float, float] = (0.0, 0.0)
reference_box: Optional[BBoxXYXY] = None
reference_eye: Eye = Eye.LEFT
confidence: float = 0.0
error_est_m: Optional[float] = None # δZ 估计(δD = 配置 disp_noise_px)
centre_pixel_x_mid: float = 0.0 # 虚拟双目中心 x(方位源,§11.3)
centre_pixel_y_mid: float = 0.0 # y_mid(校正后 yL == yR)
ref_bbox: Optional[BBoxXYXY] = None # 左目(参考目)框,供诊断
timestamp_s: Optional[float] = None
timestamp_ms: int = 0
+30
View File
@@ -0,0 +1,30 @@
"""告警节流器(§4/§5):每类异常"第 1 次 + 每满 100 次"打印,带累计计数。
高频热路径(每帧数学)不打日志;所有异常日志走本通道,且不落在数学热路径
的决策上(§11.3 单周期处理无阻塞、无 IO)。
"""
from typing import Dict
class WarnThrottle:
"""按异常类独立计数与限频。"""
def __init__(self, every: int = 100) -> None:
if every < 1:
raise ValueError("every 必须 ≥ 1")
self._every = every
self._counts: Dict[str, int] = {}
def should_emit(self, category: str) -> bool:
"""推进该类异常计数并返回本次是否应打印(第 1 次 + 每满 every 次)。"""
count = self._counts.get(category, 0) + 1
self._counts[category] = count
return count == 1 or count % self._every == 0
def count(self, category: str) -> int:
"""该类异常的累计次数。"""
return self._counts.get(category, 0)
def clear(self) -> None:
self._counts.clear()
-76
View File
@@ -1,76 +0,0 @@
"""StereoConfig 默认值、派生区间与 JSON 加载测试(规格 §2:参数全部外置)。"""
import json
import pytest
from stereo_ranger.config import StereoConfig, load_from_json
VALID = {
"fx_px": 1663.0, "fy_px": 1663.0, "cx_px": 960.0, "cy_px": 540.0,
"baseline_m": 0.12, "z_min_m": 2.0, "z_max_m": 40.0,
"vertical_tau_px": 3.0, "height_ratio_tau": 0.3,
"disparity_min_px": 4.0, "range_tol_m": 3.0, "disp_noise_px": 1.5,
"use_center_fallback": False, "filter_enabled": True,
"filter_alpha": 0.5, "filter_jump_ratio": 0.1, "warn_interval_s": 1.0,
}
def test_defaults_and_disparity_window():
cfg = StereoConfig()
assert cfg.baseline_m == 0.06 and cfg.fx_px == 960.0
# FB = 57.6,z 窗口 [1, 50] m -> D 窗口 [57.6/50, 57.6/1]
lo, hi = cfg.disparity_bounds_px()
assert lo == pytest.approx(1.152)
assert hi == pytest.approx(57.6)
@pytest.mark.parametrize("kwargs", [
{"baseline_m": 0.0},
{"fx_px": -1.0},
{"fy_px": 0.0},
{"cx_px": -1.0},
{"z_min_m": 0.0},
{"z_max_m": 10.0, "z_min_m": 20.0},
{"vertical_tau_px": 0.0},
{"height_ratio_tau": 0.0},
{"disparity_min_px": 0.0},
{"range_tol_m": 0.0},
{"disp_noise_px": 0.0},
{"filter_alpha": 0.0},
{"filter_alpha": 1.5},
{"filter_jump_ratio": -0.1},
{"warn_interval_s": -1.0},
])
def test_invalid_config_raises(kwargs):
with pytest.raises(ValueError):
StereoConfig(**kwargs)
def test_load_from_json_roundtrip(tmp_path):
p = tmp_path / "stereo.json"
p.write_text(json.dumps(VALID), encoding="utf-8")
cfg = load_from_json(str(p))
for field, value in VALID.items():
assert getattr(cfg, field) == value
lo, hi = cfg.disparity_bounds_px()
assert hi == pytest.approx(1663.0 * 0.12 / 2.0) # FB / z_min
assert lo == pytest.approx(1663.0 * 0.12 / 40.0) # FB / z_max
def test_load_from_json_missing_field_reports_name(tmp_path):
p = tmp_path / "stereo.json"
data = dict(VALID)
del data["baseline_m"]
p.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ValueError, match="baseline_m"):
load_from_json(str(p))
def test_load_from_json_unknown_field_rejected(tmp_path):
p = tmp_path / "stereo.json"
data = dict(VALID)
data["magic_extra"] = 1
p.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ValueError, match="magic_extra"):
load_from_json(str(p))
@@ -1,302 +0,0 @@
"""stereo_ranger 测距核心单元测试。
规格:docs/双目测距方案.md §4(几何)、§5(视差/有效性/误差)、§6(关联)、
§7(滤波)。场景全部由"目标在左目系 (X, Y, Z)"解析投影生成左右框,
再反推验证测距/方位输出。
"""
import math
import pytest
from stereo_ranger.config import StereoConfig
from stereo_ranger.stereo_ranger import (
DISP_SOURCE_CENTER,
DISP_SOURCE_EDGE,
StereoRanger,
focal_from_hfov,
range_error_abs,
range_error_rel,
)
from stereo_ranger.types import Detection
CFG = StereoConfig()
FB = CFG.fx_px * CFG.baseline_m # 57.6 px·m
def box(cx, cy, w=100.0, h=60.0, cls_id=0, conf=1.0, track_id=0):
return Detection(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2,
conf, cls_id, track_id)
def target_pair(x_m, y_m, z_m, w=100.0, h=60.0, cls_id=0, conf=1.0, track_id=0):
"""目标中心在左目系 (x_m, y_m, z_m),解析投影为左右目同尺寸框。"""
u_l = CFG.fx_px * x_m / z_m + CFG.cx_px
v_l = CFG.fy_px * y_m / z_m + CFG.cy_px
u_r = u_l - FB / z_m
return (box(u_l, v_l, w, h, cls_id, conf, track_id),
box(u_r, v_l, w, h, cls_id, conf, track_id))
def one_report(ranger, left, right, **kw):
reports = ranger.update(left, right, **kw)
assert len(reports) == len(left)
return reports
# ---- §4.2 / §5.3 几何与误差公式 ----
def test_focal_from_hfov_matches_doc_examples():
assert focal_from_hfov(1920.0, 90.0) == pytest.approx(960.0)
assert focal_from_hfov(1920.0, 60.0) == pytest.approx(1662.769)
def test_range_error_matches_doc_table():
# 文档 §5.3 示意表:B=0.06, F=960, δD=1 px
expected = {5: 0.4340, 10: 1.7361, 15: 3.9063, 20: 6.9444,
30: 15.625, 50: 43.4028, 100: 173.611}
for z, err in expected.items():
assert range_error_abs(z, 960.0, 0.06, 1.0) == pytest.approx(err, rel=1e-3)
assert range_error_rel(10.0, 960.0, 0.06, 1.0) == pytest.approx(1.7361 / 10.0, rel=1e-3)
def test_on_axis_target_z_and_bearing():
left, right = target_pair(0.0, 0.0, 10.0)
r = one_report(StereoRanger(), [left], [right], frame_sequence=7, timestamp_s=1.5)[0]
assert r.matched and r.valid
assert r.forward_distance_m == pytest.approx(10.0, rel=1e-9)
assert r.disparity_px == pytest.approx(FB / 10.0)
assert r.disparity_source == DISP_SOURCE_EDGE
assert r.lateral_m == pytest.approx(0.0, abs=1e-9)
assert r.vertical_m == pytest.approx(0.0, abs=1e-9)
assert r.slant_range_m == pytest.approx(10.0, rel=1e-9)
assert r.frame_sequence == 7 and r.timestamp_s == 1.5
# 虚拟双目中心:两目中心平均,y 取左目(§11.3)
assert r.centre_pixel_x_mid == pytest.approx((left.cx + right.cx) / 2.0)
assert r.centre_pixel_y_mid == left.cy
def test_off_axis_target_xyz_and_slant_range():
left, right = target_pair(1.2, 0.3, 10.0)
r = one_report(StereoRanger(), [left], [right])[0]
assert r.valid
assert r.lateral_m == pytest.approx(1.2, rel=1e-9)
assert r.vertical_m == pytest.approx(0.3, rel=1e-9)
assert r.slant_range_m == pytest.approx(math.sqrt(1.2**2 + 0.3**2 + 10.0**2), rel=1e-9)
assert r.error_est_m == pytest.approx(range_error_abs(10.0, CFG.fx_px, CFG.baseline_m, CFG.disp_noise_px))
def test_x_uses_left_camera_frame_not_virtual_center():
# X 定义在左目系(§4.2),用左目中心像素;方位 x_mid 是虚拟相机量(§11.3)
left, right = target_pair(0.0, 0.0, 10.0)
r = one_report(StereoRanger(), [left], [right])[0]
assert r.lateral_m == pytest.approx(0.0, abs=1e-9) # 目标在左光轴上
assert r.centre_pixel_x_mid < left.cx # 但虚拟中心相对左光轴偏左 B/2
def test_width_diff_on_right_edge_only_does_not_taint_left_edge():
# §5.1 主方案:同侧(左)边缘对视差免疫"宽度差"。右框仅向 x2 侧扩宽
# 时,左边缘不变、中心被拉偏——边缘取点仍得精确 Z。
left, right = target_pair(0.0, 0.0, 10.0)
right_extended = Detection(right.x1, right.y1, right.x2 + 40.0, right.y2)
r = one_report(StereoRanger(), [left], [right_extended])[0]
assert r.matched and r.valid
assert r.forward_distance_m == pytest.approx(10.0, rel=1e-9)
assert r.disparity_source == DISP_SOURCE_EDGE
assert right_extended.width != left.width # 确有宽度差
def test_center_fallback_when_edge_out_of_window(caplog):
# 左框过宽使左边缘视差越出窗口:回退中心并节流警告(§5.1),同实例内不刷屏
left, right = target_pair(0.0, 0.0, 10.0)
left_wider = box(left.cx, left.cy, w=300.0, h=60.0)
ranger = StereoRanger()
r = one_report(ranger, [left_wider], [right])[0]
r2 = one_report(ranger, [left_wider], [right])[0]
assert r.matched and r.valid
assert r.forward_distance_m == pytest.approx(10.0, rel=1e-9)
assert r.disparity_source == DISP_SOURCE_CENTER
assert r2.disparity_source == DISP_SOURCE_CENTER
assert len(caplog.records) == 1
def test_center_fallback_disabled_leaves_unmatched():
left, right = target_pair(0.0, 0.0, 10.0)
left_wider = box(left.cx, left.cy, w=300.0, h=60.0)
cfg = StereoConfig(use_center_fallback=False)
r = one_report(StereoRanger(cfg), [left_wider], [right])[0]
assert not r.matched and not r.valid
assert r.forward_distance_m is None
# ---- §6 关联硬约束与贪心一一配对 ----
def test_class_mismatch_rejected():
left, _ = target_pair(0.0, 0.0, 10.0)
_, right = target_pair(0.0, 0.0, 10.0, cls_id=1)
r = one_report(StereoRanger(), [left], [right])[0]
assert not r.matched
def test_vertical_residual_beyond_tau_rejected():
left, _ = target_pair(0.0, 0.0, 10.0)
right = box(CFG.cx_px - FB / 10.0, left.cy + 3.0) # |Δy| = 3 > τy = 2
r = one_report(StereoRanger(), [left], [right])[0]
assert not r.matched
def test_height_ratio_beyond_tau_rejected():
left, _ = target_pair(0.0, 0.0, 10.0)
_, right = target_pair(0.0, 0.0, 10.0, h=40.0) # |60-40|/60 > 0.25
r = one_report(StereoRanger(), [left], [right])[0]
assert not r.matched
def test_cost_equal_one_rejected():
# 文档 §6:仅当 cost < 1 时接受(严格小于)
# dy = 1 px -> 0.5,dh 比 = 0.125 -> 0.5,cost 精确 = 1.0
left, _ = target_pair(0.0, 0.0, 10.0)
x_off = FB / 10.0
right = Detection(left.x1 - x_off, left.y1 + 4.75,
left.x1 - x_off + 52.5, left.y1 + 4.75 + 52.5)
# 右框中心 = 左框中心 + 1 px(dy=1),高度 52.5(dh 比 0.125)
assert right.cy == pytest.approx(left.cy + 1.0)
r = one_report(StereoRanger(), [left], [right])[0]
assert not r.matched
def test_disparity_out_of_z_window_rejected():
# 目标过近(D > FB/z_min)与过远(D < FB/z_max)均不可配对(§6)
near_l, near_r = target_pair(0.0, 0.0, 0.5)
far_l, far_r = target_pair(0.0, 0.0, 60.0)
r = one_report(StereoRanger(), [near_l], [near_r])[0]
assert not r.matched
r = one_report(StereoRanger(), [far_l], [far_r])[0]
assert not r.matched
def test_greedy_one_to_one_crossing_indices():
# 目标 A(光轴上, Z=10) 与 B(右偏, Z=6);右目框以下标交叉顺序传入。
# 贪心须按代价配对:L0->R_A、L1->R_B,即使 R_B 在下标 0 处。
l_a, r_a = target_pair(0.0, 0.0, 10.0)
l_b, r_b = target_pair(0.3, 0.0, 6.0)
reports = one_report(StereoRanger(), [l_a, l_b], [r_b, r_a])
assert all(r.matched for r in reports)
assert reports[0].forward_distance_m == pytest.approx(10.0, rel=1e-9)
assert reports[1].forward_distance_m == pytest.approx(6.0, rel=1e-9)
assert reports[0].ref_bbox == (l_a.x1, l_a.y1, l_a.x2, l_a.y2)
def test_track_id_continuity_preference():
# §6:存在轨迹标识时优先匹配相同 track id
l_a, r_a = target_pair(0.0, 0.0, 10.0, track_id=7)
l_b, r_b = target_pair(0.3, 0.0, 8.0, track_id=8)
reports = one_report(StereoRanger(), [l_a, l_b], [r_b, r_a])
by_track = {r.track_id: r.forward_distance_m for r in reports}
assert by_track[7] == pytest.approx(10.0, rel=1e-9)
assert by_track[8] == pytest.approx(8.0, rel=1e-9)
def test_unmatched_left_reports_bearing_only():
left, right = target_pair(0.0, 0.0, 10.0)
stray = box(500.0, 300.0, cls_id=3, conf=0.8)
reports = one_report(StereoRanger(), [left, stray], [right], frame_sequence=42, timestamp_s=1.5)
assert reports[0].matched and reports[0].valid
u = reports[1]
assert not u.matched and not u.valid
assert u.forward_distance_m is None
assert u.centre_pixel_x_mid == stray.cx and u.centre_pixel_y_mid == stray.cy
assert u.confidence == 0.8 and u.class_id == 3
assert u.frame_sequence == 42 and u.timestamp_s == 1.5
def test_empty_sides():
ranger = StereoRanger()
assert ranger.update([], []) == []
r = one_report(ranger, [box(100.0, 100.0)], [])[0]
assert not r.matched
def test_report_order_follows_left_detections():
ranger = StereoRanger()
l1, r1 = target_pair(0.0, 0.0, 10.0, cls_id=0)
l2, r2 = target_pair(0.0, 0.0, 8.0, cls_id=2)
reports = ranger.update([l2, l1], [r1, r2]) # 输出顺序与输入左目一致
assert [r.class_id for r in reports] == [2, 0]
assert reports[0].confidence == min(l2.conf, r2.conf)
# ---- §5.4 有效性判定 ----
def test_beyond_range_tolerance_invalid_but_distance_kept():
left, right = target_pair(0.0, 0.0, 15.0) # δZ ≈ 3.9 m > 2 m
r = one_report(StereoRanger(), [left], [right])[0]
assert r.matched
assert not r.valid
assert r.forward_distance_m == pytest.approx(15.0, rel=1e-9)
assert r.lateral_m is not None # 方位/坐标仍发布(§5.4 域外标记无效)
assert r.error_est_m == pytest.approx(range_error_abs(15.0, CFG.fx_px, CFG.baseline_m, CFG.disp_noise_px))
def test_within_range_tolerance_valid():
left, right = target_pair(0.0, 0.0, 10.0) # δZ ≈ 1.74 m <= 2 m
r = one_report(StereoRanger(), [left], [right])[0]
assert r.valid
def test_invalid_when_disparity_below_dmin():
# 放宽 range_tol 以单独暴露 D_min 判据:D = 2.3 px < 3 px -> 无效
cfg = StereoConfig(range_tol_m=50.0)
left, right = target_pair(0.0, 0.0, 25.0)
r = one_report(StereoRanger(cfg), [left], [right])[0]
assert r.matched and not r.valid
# ---- §7 时间滤波(可选) ----
def moving_pair(z_m, track_id=1, cls_id=0):
"""返回 (左框列表, 右框列表):目标沿光轴移动,track_id 可配。"""
left = box(CFG.cx_px, CFG.cy_px, cls_id=cls_id, track_id=track_id)
right = box(CFG.cx_px - FB / z_m, CFG.cy_px, cls_id=cls_id, track_id=track_id)
return [left], [right]
def test_filter_smooths_and_resets_on_jump():
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3, filter_jump_ratio=0.2,
range_tol_m=50.0) # 放宽有效域,让 10~13 m 均有效以观察滤波
ranger = StereoRanger(cfg)
z1 = one_report(ranger, *moving_pair(10.0), frame_sequence=1)[0]
assert z1.forward_distance_m == pytest.approx(10.0, rel=1e-9) # 首帧置位
z2 = one_report(ranger, *moving_pair(11.0), frame_sequence=2)[0] # 跳变 10% <= 20%:平滑
assert z2.forward_distance_m == pytest.approx(0.7 * 10.0 + 0.3 * 11.0, rel=1e-9)
z3 = one_report(ranger, *moving_pair(13.0), frame_sequence=3)[0] # 跳变 26% > 20%:重置
assert z3.forward_distance_m == pytest.approx(13.0, rel=1e-9)
def test_filter_skipped_without_track_id():
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3)
ranger = StereoRanger(cfg)
one_report(ranger, *moving_pair(10.0, track_id=0), frame_sequence=1)
z2 = one_report(ranger, *moving_pair(11.0, track_id=0), frame_sequence=2)[0]
assert z2.forward_distance_m == pytest.approx(11.0, rel=1e-9)
def test_reset_clears_filter_state():
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3, range_tol_m=50.0)
ranger = StereoRanger(cfg)
one_report(ranger, *moving_pair(10.0), frame_sequence=1) # 状态 = 10.0
ranger.reset()
# 若不重置,10 -> 11 会平滑为 10.3;重置后应直接输出原始 11.0
z = one_report(ranger, *moving_pair(11.0), frame_sequence=2)[0]
assert z.forward_distance_m == pytest.approx(11.0, rel=1e-9)
def test_invalid_measurements_do_not_pollute_filter_state():
cfg = StereoConfig(filter_enabled=True, filter_alpha=0.3, range_tol_m=50.0)
ranger = StereoRanger(cfg)
one_report(ranger, *moving_pair(10.0), frame_sequence=1)
z2 = one_report(ranger, *moving_pair(25.0), frame_sequence=2)[0] # D < D_min:无效
assert not z2.valid
z3 = one_report(ranger, *moving_pair(10.5), frame_sequence=3)[0]
# 无效帧未污染状态:仍按与首帧 10.0 平滑
assert z3.forward_distance_m == pytest.approx(0.7 * 10.0 + 0.3 * 10.5, rel=1e-9)
@@ -3,8 +3,9 @@
移植自 reference-c++/visual_track_control/tests/tracking_control_law_test.cpp。
覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉
超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道三种
无距离降级动作、首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道无距离
降级动作(含 EXIT 门限/grace 重置/零门限立即退出)、距离超龄回落、首拍变化率
限幅(S6)、机体系→NED 输出旋转(A4)。
"""
import math
@@ -26,7 +27,7 @@ def make_config() -> TrackingConfig:
cfg = TrackingConfig()
cfg.control.frequency_hz = 20.0
cfg.control.visual_stale_ms = 200
cfg.control.radar_stale_ms = 500
cfg.control.distance_stale_ms = 500
cfg.control.attitude_stale_ms = 300
cfg.camera.image_width = 1000
cfg.camera.image_height = 1000
@@ -55,7 +56,7 @@ def make_config() -> TrackingConfig:
def make_snapshot(now_ms: int) -> ControlSnapshot:
"""新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜。"""
"""新鲜快照:目标在画面中心,姿态/追踪/距离均新鲜。"""
return ControlSnapshot(
track=TrackState(tracked=True, is_predicted=False,
center=(500.0, 500.0)),
@@ -213,9 +214,9 @@ def test_vertical_velocity_clamped():
def test_distance_approach_when_far():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
@@ -223,17 +224,17 @@ def test_distance_approach_when_far():
def test_distance_retreat_clamped_when_too_close():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.radar_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(-1.0, abs=1e-9)
def test_no_distance_hold_zeros_vx():
law = TrackingControlLaw(make_config()) # 缺省 kHold
law = TrackingControlLaw(make_config()) # 基准配置 HOLD
snap = make_snapshot(1000)
snap.associated_to_target = False
snap.distance_valid = False
out = law.update(snap, 1000)
assert out.mode is ControlMode.VELOCITY_HEADING
assert out.vx_mps == 0.0
@@ -244,29 +245,79 @@ def test_no_distance_slow_approach():
cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.associated_to_target = False
snap.distance_valid = False
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(0.5, abs=1e-9)
def test_no_distance_exit_degrades():
def test_no_distance_exit_waits_grace_then_degrades():
# S1 拍板:EXIT 需持续无距离达门限才降级;grace 期间按 SLOW_APPROACH 行为
cfg = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 200
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.associated_to_target = False
snap.distance_valid = False
out1 = law.update(snap, 1000)
assert out1.mode is ControlMode.VELOCITY_HEADING
assert out1.vx_mps == pytest.approx(0.5, abs=1e-9)
snap.track_time_ms = 1200 # 保持视觉/姿态新鲜,聚焦距离通道
snap.attitude_time_ms = 1200
out2 = law.update(snap, 1200) # 恰达门限 → 降级
assert out2.mode is ControlMode.DEGRADED_HOLD
assert out2.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
assert not out2.valid
def test_no_distance_exit_grace_resets_on_valid_frame():
cfg = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 200
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.distance_valid = False
assert law.update(snap, 1000).mode is ControlMode.VELOCITY_HEADING
snap.track_time_ms = 1100 # 有效距离帧:重置计时
snap.attitude_time_ms = 1100
snap.distance_valid = True
snap.target_distance_m = 30.0
snap.distance_time_ms = 1100
assert law.update(snap, 1100).mode is ControlMode.VELOCITY_HEADING
snap.track_time_ms = 1250 # 再次无效:重新计时
snap.attitude_time_ms = 1250
snap.distance_valid = False
snap.target_distance_m = 0.0
assert law.update(snap, 1250).mode is ControlMode.VELOCITY_HEADING # 0 ms < 200 ms
snap.track_time_ms = 1500
snap.attitude_time_ms = 1500
out = law.update(snap, 1500) # 250 ms ≥ 200 ms → 降级
assert out.mode is ControlMode.DEGRADED_HOLD
assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
def test_no_distance_exit_zero_grace_degrades_immediately():
# grace = 0 保留旧"首个无效拍即退出"行为
cfg = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 0
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.distance_valid = False
out = law.update(snap, 1000)
assert out.mode is ControlMode.DEGRADED_HOLD
assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
assert not out.valid
def test_stale_radar_falls_back_to_no_distance():
law = TrackingControlLaw(make_config()) # kHold
def test_stale_distance_falls_back_to_no_distance():
law = TrackingControlLaw(make_config()) # HOLD
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用
out = law.update(snap, 1000)
assert out.mode is ControlMode.VELOCITY_HEADING
assert out.vx_mps == 0.0
@@ -280,9 +331,9 @@ def test_first_tick_vx_slews_from_zero():
cfg.accel_limit.ax_mps2 = 2.0
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 # 通道输出本应为 3.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0 # 通道输出本应为 3.0
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000) # 首拍:dt 按 1/20 Hz 兜底
assert out.vx_mps == pytest.approx(2.0 * (1.0 / 20.0), abs=1e-9) # 0.1
@@ -293,9 +344,9 @@ def test_output_rotated_to_ned_by_yaw():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.yaw_rad = math.pi / 2.0 # 机头朝东
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
# 机体前向 3.0 → 东向 3.0,北向 ≈ 0
assert out.vx_mps == pytest.approx(0.0, abs=1e-9)
@@ -306,9 +357,9 @@ def test_zero_yaw_keeps_forward_as_north():
law = TrackingControlLaw(make_config())
snap = make_snapshot(1000)
snap.yaw_rad = 0.0
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 1000
out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
assert out.vy_mps == pytest.approx(0.0, abs=1e-9)
@@ -321,17 +372,17 @@ def test_reset_restores_slew_from_zero():
cfg.accel_limit.ax_mps2 = 2.0
law = TrackingControlLaw(cfg)
snap = make_snapshot(1000)
snap.radar_distance_m = 30.0
snap.associated_to_target = True
snap.radar_time_ms = 1000
snap.target_distance_m = 30.0
snap.distance_valid = True
snap.distance_time_ms = 1000
for t in range(1000, 3000, 50):
snap.track_time_ms = t
snap.radar_time_ms = t
snap.distance_time_ms = t
snap.attitude_time_ms = t
law.update(snap, t) # 若干拍后 vx 已爬升到限幅 3.0
law.reset()
snap.track_time_ms = 3000
snap.radar_time_ms = 3000
snap.distance_time_ms = 3000
snap.attitude_time_ms = 3000
out = law.update(snap, 3000)
# Reset 后限幅器归零:又从 0.1 开始渐变
@@ -15,7 +15,7 @@ from pathlib import Path
import pytest
from visual_track_control.config import (HeadingMode, NoDistanceAction,
load_from_json)
TrackingConfig, load_from_json)
_REPO_ROOT = Path(__file__).resolve().parents[3]
REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json"
@@ -27,7 +27,7 @@ def full_config() -> dict:
"control": {
"frequency_hz": 20.0,
"visual_stale_ms": 200,
"radar_stale_ms": 500,
"distance_stale_ms": 500,
"attitude_stale_ms": 300,
},
"camera": {
@@ -57,6 +57,7 @@ def full_config() -> dict:
"retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold",
"no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000,
},
"accel_limit": {
"ax_mps2": 2.0,
@@ -84,7 +85,9 @@ def test_load_real_config_succeeds():
assert cfg.vertical.vz_gain_mps_per_deg == 0.1
assert cfg.vertical.vz_limit_mps == 2.0
assert cfg.distance.d_exp_m == 10.0
assert cfg.distance.no_distance_action is NoDistanceAction.HOLD
assert cfg.distance.no_distance_action is NoDistanceAction.SLOW_APPROACH
assert cfg.distance.no_distance_exit_grace_ms == 3000
assert cfg.control.distance_stale_ms == 500
assert cfg.accel_limit.ax_mps2 == 2.0
@@ -169,3 +172,18 @@ def test_fov_out_of_range_throws(tmp_path):
with pytest.raises(ValueError) as exc_info:
load_from_json(path)
assert "camera.fov_h_deg" in str(exc_info.value)
def test_default_no_distance_action_is_slow_approach():
# S1 拍板:双目有效域外无距离是常态,默认定速接近(纪要 D4/S1)
assert (TrackingConfig().distance.no_distance_action
is NoDistanceAction.SLOW_APPROACH)
def test_negative_exit_grace_throws(tmp_path):
config = full_config()
config["distance"]["no_distance_exit_grace_ms"] = -1
path = write_temp_json(tmp_path, config, "vtc_cfg_badgrace.json")
with pytest.raises(ValueError) as exc_info:
load_from_json(path)
assert "no_distance_exit_grace_ms" in str(exc_info.value)
+1 -1
View File
@@ -9,7 +9,7 @@
| 名称 | 说明 |
| --- | --- |
| `TrackingConfig` / `load_from_json(path)` | 配置加载(启动期校验,错误携带字段名,抛 `ValueError`) |
| `HeadingMode` / `NoDistanceAction` | 配置枚举(rate/position;hold/slow_approach/exit) |
| `HeadingMode` / `NoDistanceAction` | 配置枚举(rate/position;hold/slow_approach/exit,默认 slow_approach;exit 需持续无距离达 `no_distance_exit_grace_ms` 才降级) |
| `TrackingControlLaw(cfg)` | 主控律 |
| `.update(snapshot, now_ms) -> ControlOutput` | 单周期八步流程(姿态/视觉超龄 → 判丢 → Coast → 三通道 → NED 旋转) |
| `.reset()` | 停止闭环时清全部内部状态 |
+14 -6
View File
@@ -23,18 +23,19 @@ class HeadingMode(Enum):
class NoDistanceAction(Enum):
"""雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3)。"""
"""目标距离不可用(无效/超龄)时的距离通道降级动作(§6.3)。"""
HOLD = "hold" # 保持:前向速度指令清零
SLOW_APPROACH = "slow_approach" # 限速接近:按 no_distance_approach_limit_mps 缓速
EXIT = "exit" # 退出近距操作:输出降级保持
EXIT = "exit" # 退出近距操作:持续无距离达 no_distance_exit_grace_ms
# 才降级保持;grace 期间按 SLOW_APPROACH 行为
@dataclass
class ControlGroupConfig:
frequency_hz: float = 20.0
visual_stale_ms: int = 200
radar_stale_ms: int = 500
distance_stale_ms: int = 500
attitude_stale_ms: int = 300
@@ -70,8 +71,11 @@ class DistanceGroupConfig:
derivative_filter_coef: float = 0.0
approach_velocity_limit_mps: float = 3.0
retreat_velocity_limit_mps: float = 1.0
no_distance_action: NoDistanceAction = NoDistanceAction.HOLD
# 默认定速接近(D4 远场策略):双目有效域外无距离是常态,退出仅留给
# 持续无距离(传感器级故障)场景
no_distance_action: NoDistanceAction = NoDistanceAction.SLOW_APPROACH
no_distance_approach_limit_mps: float = 0.5
no_distance_exit_grace_ms: int = 3000 # EXIT 门限:持续无距离达此时长才退出(毫秒,占位)
@dataclass
@@ -155,9 +159,9 @@ def _load_control(root: dict) -> ControlGroupConfig:
c.frequency_hz = _require_positive(
_require_number(s, "control", "frequency_hz"), "control.frequency_hz")
c.visual_stale_ms = _require_integer(s, "control", "visual_stale_ms")
c.radar_stale_ms = _require_integer(s, "control", "radar_stale_ms")
c.distance_stale_ms = _require_integer(s, "control", "distance_stale_ms")
c.attitude_stale_ms = _require_integer(s, "control", "attitude_stale_ms")
if c.visual_stale_ms <= 0 or c.radar_stale_ms <= 0 or c.attitude_stale_ms <= 0:
if c.visual_stale_ms <= 0 or c.distance_stale_ms <= 0 or c.attitude_stale_ms <= 0:
raise ValueError("配置非法: control.*_stale_ms 必须为正")
return c
@@ -240,6 +244,10 @@ def _load_distance(root: dict) -> DistanceGroupConfig:
d.no_distance_approach_limit_mps = _require_non_negative(
_require_number(s, "distance", "no_distance_approach_limit_mps"),
"distance.no_distance_approach_limit_mps")
# 0 容许:等效旧版"首个无效拍即退出"行为
d.no_distance_exit_grace_ms = _require_non_negative(
_require_integer(s, "distance", "no_distance_exit_grace_ms"),
"distance.no_distance_exit_grace_ms")
return d
@@ -10,7 +10,8 @@
④ 像素偏移 → 视线角 β/α(全程针孔模型)
⑤ 水平航向(速率式/位置式,A2:位置式合成绝对 NED 航向)
⑥ 垂直统一速度支路(A1:不切姿态微调模式)
⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit)
⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit,
exit 需持续无距离达 no_distance_exit_grace_ms 门限)
⑧ 机体系 → 局部 NED 旋转(A4)
日志纪律与 C++ 版一致:仅状态切换点打印,热路径零日志。
@@ -78,6 +79,10 @@ class TrackingControlLaw:
self._vz_limiter = SlewRateLimiter(cfg.accel_limit.az_mps2)
self._last_now_ms = 0
self._last_mode = ControlMode.DEGRADED_HOLD
# EXIT 无距离计时(§6.3:持续无距离达 no_distance_exit_grace_ms 才退出,
# 任一有效距离帧重置)
self._no_distance_active = False
self._no_distance_since_ms = 0
def update(self, snapshot: ControlSnapshot, now_ms: int) -> ControlOutput:
# ---- 节拍推算(§7.1 固定周期;异常节拍用配置频率兜底) ----
@@ -107,6 +112,7 @@ class TrackingControlLaw:
# ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 BRAKE_HOVER) ----
if not snapshot.track.tracked:
self._no_distance_active = False # 刹车期间不计 EXIT 无距离时长
if self._last_mode is not ControlMode.BRAKE_HOVER:
# 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅)
self._distance_pid.reset()
@@ -156,14 +162,16 @@ class TrackingControlLaw:
self._cfg.vertical.vz_limit_mps)
# ---- ⑦ 距离通道(§6.2 / §6.3) ----
# 雷达距离不得直接当目标距离:仅关联成立且未超龄才可用(§6.3、FR-041)
distance_available = (snapshot.associated_to_target
and snapshot.radar_distance_m > 0.0
and not is_stale(snapshot.radar_time_ms, now_ms,
self._cfg.control.radar_stale_ms))
# 目标距离来自检测框自身的双目测距:仅 distance_valid 且未超龄才可用
# (§6.3、FR-041)
distance_available = (snapshot.distance_valid
and snapshot.target_distance_m > 0.0
and not is_stale(snapshot.distance_time_ms, now_ms,
self._cfg.control.distance_stale_ms))
if distance_available:
self._no_distance_active = False # 任一有效距离帧重置 EXIT 计时
# Δd = d − d_exp:d > d_exp 前向接近,d < d_exp 减速/后退(§6.2)
delta_d = snapshot.radar_distance_m - self._cfg.distance.d_exp_m
delta_d = snapshot.target_distance_m - self._cfg.distance.d_exp_m
vx_raw = self._distance_pid.update(delta_d, dt_s)
slewed = self._vx_limiter.limit(vx_raw, dt_s) # 加速度限幅(§8)
out.vx_mps = clamp(slewed,
@@ -182,10 +190,20 @@ class TrackingControlLaw:
out.vx_mps = self._vx_limiter.limit(
self._cfg.distance.no_distance_approach_limit_mps, dt_s)
else: # NoDistanceAction.EXIT
# 退出近距操作:整体降级保持
out.mode = ControlMode.DEGRADED_HOLD
out.degraded_reason = DegradedReason.NO_DISTANCE_EXIT
return self._finish_and_log(out, "距离未关联,退出近距操作")
# 退出近距操作:双目有效域外无距离是常态(D4 远场定速接近),
# 首个无效拍不退出;持续无距离达 no_distance_exit_grace_ms
# (疑似传感器级故障)才降级。grace 期间按 SLOW_APPROACH 行为
# 执行(经加速度限幅,vx 平滑过渡);grace=0 保留旧"立即退出"行为
if not self._no_distance_active:
self._no_distance_active = True
self._no_distance_since_ms = now_ms
if (now_ms - self._no_distance_since_ms
>= self._cfg.distance.no_distance_exit_grace_ms):
out.mode = ControlMode.DEGRADED_HOLD
out.degraded_reason = DegradedReason.NO_DISTANCE_EXIT
return self._finish_and_log(out, "持续无距离达门限,退出近距操作")
out.vx_mps = self._vx_limiter.limit(
self._cfg.distance.no_distance_approach_limit_mps, dt_s)
# ---- ⑧ 机体系 → 局部 NED 旋转(§2.3,A4 修复) ----
# 各通道限幅均在机体系完成(旋转为正交变换,合速度不变,限幅约束
@@ -207,6 +225,7 @@ class TrackingControlLaw:
self._yaw_rate_limiter.reset()
self._vx_limiter.reset()
self._vz_limiter.reset()
self._no_distance_active = False
self._last_now_ms = 0
self._last_mode = ControlMode.DEGRADED_HOLD
+4 -3
View File
@@ -55,9 +55,10 @@ class ControlSnapshot:
track: TrackState = field(default_factory=TrackState)
track_time_ms: int = 0 # 取样单调时钟(毫秒),<=0 视为从未取样
radar_distance_m: float = 0.0 # 机头前向单点距离(米)
associated_to_target: bool = False # 相机—雷达空间关联成立标志(未关联不得当目标距离)
radar_time_ms: int = 0
# 目标距离(双目立体测距,§6.3:distance_valid 为 False 时不得当作目标距离)
target_distance_m: float = 0.0 # 目标距离(米,双目前向分量 Z)
distance_valid: bool = False # 距离有效标志(测距来自检测框自身,无跨模态关联)
distance_time_ms: int = 0 # 取样单调时钟(毫秒)
attitude_present: bool = False # 姿态源当前是否可用(过期 → 禁止控制)
attitude_time_ms: int = 0