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

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
+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()