精简仓库结构并增强任务链路:归档双目可选后端、新增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
+112
View File
@@ -0,0 +1,112 @@
# stereo_ranger(Python 版)
双目稀疏目标测距:对**同一时刻**左右两目已立体校正图像上的目标检测框做立体
几何解算,输出少量目标的**前向距离 Z + 斜距 R(参考)+ 虚拟双目中心方位
c_mid + 有效性/置信度**。不做目标识别、图像采集/立体校正、稠密深度。
规格:`docs/双目测距技术路线.md`(算法与语义权威)。
⚠ **全部参数为占位值**(焦距/标定待厂商数据):标定与地面真值验证完成前,
本模块输出**仅限静态标靶/回放/台架验证,不得作为飞行控制决策依据**(§12.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 次,带累计计数) |
`logger` 名称为 `stereo_ranger`,走标准 `logging`。
## 单帧流程(`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` 帧未出现整体清理。
记录顺序:先按左目框顺序(配对→完整 / 未配对→仅方位),再给未配对右目框的仅方位记录。
## 实现决策(v1.0 冻结,已回填规格)
规格 `docs/双目测距技术路线.md` 已按下列决策修订(§4/§6.1/§6.2/§6.4/§6.7/
§9/§11 均已更新),本节保留决策动机与实现落点,供重写/移植核对。
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 恒等式为准。
## 运行期状态清单(§11.1,漏一个就变行为)
| 状态 | 所属 | 初值 | 改变时机 |
| --- | --- | --- | --- |
| 连续异常计数 ×目标 | `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 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(取点器/匹配器/测距/有效性/滤波器/一致性/全流程),
**全部用合成框对驱动,无需真实图像**(几何是解析可断言的):
- 取点:同侧边缘取值、框宽差不进入视差、中心对照、越窗回退、边界含等号、负视差;
- 匹配:硬约束各一例、`cost ≥ 1` 拒绝、贪心排序、一一配对;
- 测距:`Z = F·B/D` 回收、`D ≤ 0` 违例、`c_mid` 等权平均恒等式、误差平方增长;
- 有效性:三道门与边界归类、预算门通过/拦截两侧;
- 滤波/一致性:低通收敛、跳变重置不拖尾、失效清理、N 帧降级 / M 帧判无效 / 正常帧清零;
- 全流程:单目标、滤波、远场越界仍配对、单目退化、记录顺序、非同帧拒收与节流、
空输入、非有限框、track_id 跨大位移保持状态、最近邻越门限新建状态、匹配器注入、`reset`。
demo 覆盖:① 逼近(三道门依次生效 + 滤波)② 远场域外(方位照常发布)
③ 单目退化 ④ 非同帧拒收与节流 ⑤ 一致性降级/判无效 ⑥ 多目标贪心配对。
+72
View File
@@ -0,0 +1,72 @@
"""stereo_ranger —— 双目稀疏目标测距(规格:docs/双目测距技术路线.md)。
纯算法包:输入校正后坐标系的同帧左右目检测框,输出目标距离记录(Z/R/D、
虚拟双目中心方位、valid/confidence)。不做目标识别、图像采集与立体校正;
仅依赖标准库。⚠ 全部数值参数为占位值,标定与地面验证完成前不得作为飞行
控制决策依据(§12.3 红线)。
"""
from .config import StereoConfig, load_from_json
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 .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",
"DELTA_D_PX",
"ConsistencyMonitor",
"ConsistencyStatus",
"Degradation",
"Detection",
"DetectionFrame",
"Disparity",
"DisparitySource",
"DistanceFilter",
"Eye",
"GreedyMatcher",
"MatchPair",
"Matcher",
"RangeRecord",
"RangeSolution",
"SamplePoint",
"StereoConfig",
"StereoRanger",
"TargetStore",
"ValidityDecision",
"WarnThrottle",
"confidence_from_cost",
"evaluate_validity",
"forward_distance_m",
"load_from_json",
"range_error_abs_m",
"range_error_rel",
"sample_point",
"select_disparity",
"solve_range",
"virtual_center_px",
]
+123
View File
@@ -0,0 +1,123 @@
"""StereoConfig 与 JSON 配置加载(§9 配置参数全量表)。
全部参数配置外置、构造期一次加载并校验:缺项、类型错误、非法取值一律拒绝
进入运行期(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 typing import Any, Dict
# JSON 加载时的整数型字段(其余按数值校验)
_INT_FIELDS = frozenset({"consistency_N_frames", "consistency_M_frames", "filter_reset_frames"})
@dataclass
class StereoConfig:
# ---- 几何组(来自标定 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
# ---- 匹配组(§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
# ---- 有效性组(§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 的视差跳变门限
# ---- 滤波组(§9.4)----
smooth_alpha: float = 0.3
jump_reset_m: float = 5.0
filter_reset_frames: int = 5
# ---- 目标隔离(§4:类别 + 框位置最近邻;⚠补充项)----
isolation_gate_px: float = 50.0
def __post_init__(self) -> None:
_require(self.fx_px > 0.0, "fx_px", "必须为正")
_require(self.fy_px > 0.0, "fy_px", "必须为正")
_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(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 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"未知配置字段: {unknown}")
missing = sorted(known - set(data))
if 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(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()
@@ -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
+227
View File
@@ -0,0 +1,227 @@
"""StereoRanger —— 双目稀疏目标测距(规格 docs/双目测距技术路线.md)。
单周期五步(§5):① 同帧契约检查 → ② 取点 → ③ 左右匹配 →
④ 配对目标:测距 + 方位源 + 有效性 + 滤波 → ⑤ 单目退化发布。
输入为左右目各自、校正后原生坐标系下的检测框;不做目标识别,不做图像
采集/立体校正。纯计算单元(§11.3):单周期处理无阻塞、无 IO;同一实例
不得并发调用 update()。
"""
import logging
from typing import List, Optional, Sequence
from .config import StereoConfig
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")
_CONFIDENCE_DEGRADE_FACTOR = 0.5 # ⚠占位:连续跳变 ≥ N 帧的置信度降级系数(§6.4)
class StereoRanger:
"""双目稀疏目标测距器:每帧调用一次 update(),传入同帧左右目检测结果。"""
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._config
@property
def matcher(self) -> Matcher:
return self._matcher
def reset(self) -> None:
"""清空全部运行期状态(滤波 / 一致性 / 隔离 / 节流计数)。"""
self._store.clear()
self._warn.clear()
self._frame_index = 0
def update(self, left: DetectionFrame, right: DetectionFrame, now_ms: int = 0) -> List[RangeRecord]:
"""单周期处理(§5):返回本帧记录列表。
顺序:先按左目框顺序给出各自记录(配对→完整记录 / 未配对→仅方位),
再给出未配对的右目框的仅方位记录;左目存在时记录数与左目框数一致。
"""
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
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 _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)
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,
)
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:
self._store.distance_filter.invalidate(key)
z_out = None
# 参考斜距与已发布的前向距离同基准: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)
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 _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,
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 _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
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
+107
View File
@@ -0,0 +1,107 @@
"""stereo_ranger 输入输出数据结构(纯值语义)。
规格:docs/双目测距技术路线.md —— §2 输入输出接口、§3 符号约定、§6.6 方位源。
约定:所有像素坐标位于**已立体校正**的图像坐标系,检测框已映射回该基准
(§2.1 校正契约 / 坐标基准一致)。本包仅依赖标准库。
"""
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;x1/y1 左上角,x2/y2 右下角)。"""
x1: float
y1: float
x2: float
y2: float
confidence: float = 1.0
class_id: int = 0
track_id: Optional[int] = None # 检测侧轨道关联;None = 无(§2.2,v1.0 可无)
@property
def width(self) -> float:
return self.x2 - self.x1
@property
def height(self) -> float:
return self.y2 - self.y1
@property
def cx(self) -> float:
return (self.x1 + self.x2) / 2.0
@property
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 DetectionFrame:
"""单目一帧:帧标识 + 检测框列表。
同帧契约(§2.1):左右两帧的 frame_id 必须一致,否则整帧拒收、输出空。
"""
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
degradation: Degradation
valid: bool
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
timestamp_ms: int = 0
@@ -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()