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

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
+754
View File
@@ -0,0 +1,754 @@
# 双目测距技术路线
> 本文档把双目测距(立体目标测距)抽象为**与编程语言、硬件平台无关**的技术路线:
> 只讲几何模型、数据结构、算法流程与质量语义,不依赖任何语言特性、加速器
> 型号或操作系统。可作为任意语言(C++/Rust/Python 仿真/嵌入式 C)在任意
> 算力平台上重新实现或移植的规格说明。
>
> **文档分工**(各司其职,不重复抄录):
>
> | 文档 | 定位 | 与本文的分工 |
> |------|------|--------------|
> | `docs/双目深度估计思路.md` | 平台工程路线 + 原稿对照 | **平台与工程细节权威**:采集/解码链路、加速器预算、线程与文件落点、原稿缺陷对照;本文一律不涉及 |
> | 本文 | **算法路线** | **算法与语义权威**:几何、取点、匹配、有效性、滤波、方位源、误差模型、职责与缺点 |
> | `docs/视觉跟踪控制律技术路线.md` | 距离/方位的消费者 | 其距离通道与方位源如何消费本文输出,见其 §1.2/§2/§6.4 |
> | `docs/双目测距方案讨论纪要.md` | 决策索引 | D2/D4/D5/S1~S3 等拍板结论的唯一索引,本文只落实、不重新讨论 |
>
> 三份技术路线若出现表述分歧:拍板结论以《双目测距方案讨论纪要》为准,
> 算法语义以本文为准,平台工程以《双目深度估计思路》为准。
>
> **参考实现**:`utils/stereo_ranger/`(Python,构件划分与本文逐条对应)。
> 实现时冻结的补充决策与发现的原文矛盾已回填本文(§4/§6.1/§6.2/§6.4/
> §6.7/§9/§11),两文档以本文为准。
>
> ⚠️ **全部数值均为占位值**:焦距/标定参数待厂商数据(P1/P2),各阈值未整定。
> 标定与地面真值验证完成前,本模块输出**仅限静态标靶/回放/台架验证,
> 不得作为飞行控制决策依据**(见 §12.3 红线)。
---
## 目录
1. [概述与系统定位](#1-概述与系统定位)
2. [输入输出接口定义](#2-输入输出接口定义)
3. [坐标系与符号约定](#3-坐标系与符号约定)
4. [核心构件与状态清单](#4-核心构件与状态清单)
5. [单周期处理流程(主流程伪代码)](#5-单周期处理流程主流程伪代码)
6. [各环节算法细节](#6-各环节算法细节)
7. [误差模型与有效距离域](#7-误差模型与有效距离域)
8. [职责、边界与已知缺点](#8-职责边界与已知缺点)
9. [配置参数全量表](#9-配置参数全量表)
10. [设计取舍记录(为什么不是别的做法)](#10-设计取舍记录为什么不是别的做法)
11. [实现与重写指引](#11-实现与重写指引)
12. [验证路线与待定项](#12-验证路线与待定项)
---
## 1. 概述与系统定位
### 1.1 模块职责
本模块是**目标距离与方位的机载唯一来源**(决策 D2:双目替代激光雷达,
与提交 b96111e 一致)。它对**同一时刻**左右两幅已立体校正图像上的目标
检测框做立体几何解算,输出**少量目标的**距离与方位(不是稠密深度图)。
职责四件事:
1. **匹配**:把左右两目的检测框按"同一物理目标"配对(硬约束筛选 +
贪心代价排序);
2. **测距**:同侧边缘视差 D → `Z = F·B / D` 前向距离(斜距 R 仅参考输出);
3. **方位源**:输出**虚拟双目中心**像素 `c_mid`(两目框中心等权平均,
消除单目近场视差偏差,§6.6);
4. **质量语义**:输出有效性标志与置信度(有效域内 + 匹配成功 + 一致性
正常),对距离做轻量时间滤波;域外/失配/异常时**明确输出无效**,
不得强行估计。
### 1.2 在系统中的位置
```text
平台采集/解码/立体校正(平台相关,见《双目深度估计思路》第 3/8 章)
→ 校正后左/右图像(同帧)
→ 左/右目标检测(外部模块,输出原图坐标系的检测框)
→ 本模块:匹配 + 测距 + 方位源 + 有效性 + 滤波 → 目标距离记录
├─ 距离 Z(valid)──▶ 控制律距离通道(Δd → PID → 前向速度,域内闭环)
├─ 方位 c_mid ──────▶ 接入层填入控制律目标中心(虚拟中心相机像素)
└─ valid / 置信度 ──▶ 接入层降级语义:
域外/失配 → "无距离"策略(方位闭环 + 定速接近,D4)
持续无距离 → exit 档降级(S1 门限,消费侧实现)
```
**上游**:同帧校正图像对与左右检测框(检测、采集、解码、校正均为外部
模块);**下游**:跟踪控制律与接入层。"域外定速接近"的远场补偿策略
属消费侧行为,本文只负责提供诚实的有效性标志与持续的方位。
### 1.3 设计特点
| 特点 | 含义 | 带来的约束 |
|------|------|-----------|
| **稀疏输出** | 只算检测到的目标,不做逐像素匹配 | 无检测即无输出;距离精度受框质量制约(§8) |
| **纯值输入/输出** | 每帧传入同帧框对 + 参数,输出纯值记录列表 | 无隐藏输入、可离线回放 |
| **纯函数构件** | 取点、测距、域判定均为无状态纯函数 | 可独立单元测试(合成框对即可,无需真实图像) |
| **有状态部分极小** | 仅滤波器、一致性监控与目标隔离状态,且按目标隔离 | 重写时状态清单见 §11.1,漏一个就变行为 |
| **参数全外置** | 几何、阈值、滤波参数全部来自配置 | 代码内不得写死任何相机参数或阈值 |
| **诚实降级** | 域外/失配输出 `valid = false` 而非勉强给值 | 消费侧的远场策略建立在"不撒谎"的有效性上 |
### 1.4 明确不做的事(边界)
- **不做采集/解码/立体校正**:消费校正后的图像与框;校正与自标定属
平台工程(v1.1 备选),不在本文范围;
- **不做稠密匹配**:不做 SGBM 类逐像素立体匹配,不输出深度图;
- **不做环境感知/避障**:环境深度由避障侧深度模型负责,与本模块并存
互不替代;
- **不做目标跟踪**:不维护轨道与运动滤波;上游 track_id 只用作目标
状态隔离键(§4),不参与匹配打分与连续性先验(后者留 v1.1);
- **不做运动/IMU 补偿**:帧间运动引起的虚假视差靠帧同步契约与一致性
监控兜底,不做解算级补偿;
- **不做多传感器融合**:双目是目标距离机载唯一来源(D2),无交叉校验
基准;数据质量靠内部一致性监控自检(§6.7);
- **不在域外强行估计**:超出有效距离域一律输出 `valid = false`。
---
## 2. 输入输出接口定义
### 2.1 输入(每帧一次,由平台层供给)
| 分组 | 项 | 语义与约束 |
|------|----|-----------|
| 帧对 | 左帧、右帧 | 各含**帧标识**;两帧标识必须一致(同帧契约,见下) |
| 检测 | 检测框数组 ×2 | 每框含:类别、置信度、框(左上/右下角像素);**已映射回校正图像坐标系** |
| 几何 | 基线 `B`、焦距 `F`、主点 `cx, cy` | 校正后内参(`fx = fy = F`);来自标定配置,禁止写死 |
| 参数 | 匹配/有效性/滤波阈值 | 配置外置(§9),启动期加载并校验 |
| 时钟 | 当前单调时刻 | 用于输出打戳 |
**契约要点**:
- **同帧契约**:左右帧必须来自同一曝光时刻(同步与配对由平台层保证,
形态差异见《双目深度估计思路》§3.2)。本文把"左右帧标识一致"作为
输入违约检查:不一致直接拒收本帧、输出空、告警节流——**不同帧的
框配对解算没有物理意义**;
- **校正契约**:检测框位于**已立体校正**的图像上("两目 y 对齐"是校正
的结果而非假设,§3);未校正图像上的框不得进入本模块;
- **坐标基准一致**:检测若在缩放/裁剪图上运行,框必须先映射回校正图像
基准,且几何参数 `F/cx/cy` 使用同一基准的内参——否则视差与几何失配,
距离系统性错误;
- **时间戳纪律**:输出取样时刻以**平台接收时刻的单调时钟**为准,
不伪造采集时刻。
### 2.2 输出:目标距离记录(每帧每目标一条,纯值)
| 字段 | 单位/类型 | 语义 |
|------|-----------|------|
| 帧标识 | — | 继承自输入帧对 |
| 类别 | 枚举 | 目标类别 |
| 轨道关联 | 整数或"无" | 检测侧 track 关联(如有;v1.0 可无) |
| `Z` | m | 前向距离(沿光轴分量,**距离通道唯一消费量**) |
| `R` | m | 斜距(参考值,不进控制回路;与已发布的 `Z` **同基准**——按滤波后 Z 换算,保证 `R ≥ Z`) |
| `D` | px | 本次视差(调试与交叉校验用) |
| `c_mid` | 像素 (x, y) | **虚拟双目中心**框中心:方位控制源(§6.6) |
| 参考目框 | 像素框 | 左目(参考目)检测框,供交叉校验与调试 |
| `confidence` | [0,1] | 匹配质量与一致性综合置信度 |
| `valid` | 布尔 | 是否落在有效距离域且匹配成功且一致性正常(§6.4) |
| 退化标记 | 枚举 | `完整`(距离+方位)/ `仅方位`(单目检出,§5 第 ⑤ 步) |
| 取样时刻 | 单调时钟 | 平台接收时刻打戳(§2.1 时间戳纪律) |
**记录两态**:
- **完整记录**(匹配成功):距离 + 方位 + 有效性;
- **仅方位记录**(仅一目检出,v1.0 允许):发布该目框中心作方位,
`valid = false`,无距离;单目方位存在近场视差偏差(§6.6,v1.0 接受)。
### 2.3 输出语义与消费侧衔接
- `valid == true` 的 `Z` 才可作为控制律距离通道输入;消费侧另有自己的
"有效性 + 正值 + 未超龄"三重门(见《视觉跟踪控制律技术路线》§6.4);
- `valid == false`(域外/失配/一致性异常)→ 消费侧进入"无距离"策略
(方位闭环 + 定速接近,默认档),持续无距离达门限才退出近距操作
(消费侧 exit 门限,S1);
- **`c_mid` 始终随检测发布**(域外仍有方位)——这是远场"方位闭环 +
定速接近"策略得以成立的输入基础;
- 消费侧以 `track_id` 一致性校验本记录与追踪主目标对应(调用方约定
S3,库内不实现)。
---
## 3. 坐标系与符号约定
### 3.1 符号表
| 符号 | 含义 | 单位 |
|------|------|------|
| `B` | 基线长度(左右光心间距) | m |
| `F` | 像素焦距(校正后 `fx = fy`) | px |
| `cx, cy` | x/y 方向主点 | px |
| `xL, yL` | 目标点在**左目(参考目)**图像中的像素坐标 | px |
| `xR` | 同一目标点在右目图像中的像素坐标 | px |
| `D` | 视差 = `xL − xR` | px |
| `Z` | 前向距离(沿光轴分量) | m |
| `X, Y` | 相机(左目)坐标系横向/纵向偏移 | m |
| `X_C` | 目标在**模组中心系**的横向偏移 | m |
| `R` | 斜距 | m |
| `cL, cR` | 同一目标在左/右目中的**检测框中心** | px |
| `c_mid` | 虚拟双目中心框中心(方位源) | px |
**统一约定**:校正后**以左目为参考系**,视差 `D = xL − xR > 0`
(正视差是物理前提;`D ≤ 0` 视为违例,§6.3)。
### 3.2 公式(校正后坐标系)
```text
D = xL − xR (同一物理特征点的 x 坐标差)
Z = F × B / D
X = (xL − cx) × Z / F (左目系)
Y = (yL − cy) × Z / F
R = sqrt(X² + Y² + Z²)
```
- **距离通道使用 Z(前向分量)而非 R**:与机头前向的期望射程语义对齐,
也与地面真值仪(手持激光测距仪)的测量方向一致(决策 D8);
小角度下 `Z ≈ R`,大偏航角目标时二者差异增大,地面验证解读数据须注意;
- **X/Y 以左目系表达**;模组中心系下 `X_C = X − B/2`(左目光心在模组中线
−B/2 侧,符号与 §6.6 一致;原文 `+B/2` 为笔误),`B/2` 对 `Z`、`R` 的
影响可忽略(本机型 3cm)。
---
## 4. 核心构件与状态清单
| 构件 | 职责 | 运行期持久状态 | 备注 |
|------|------|---------------|------|
| 流程编排 | 单周期五步流程(§5) | 无 | 每帧一次 |
| 视差取点器 | 框 → 取点 x(同侧边缘,回退中心) | 无(纯函数) | §6.1 |
| 匹配器 | 左右框配对 | 无(纯函数) | **接口注入**,默认贪心;可替换为匈牙利等而不动调用方 |
| 测距计算 | `(D, 框)` → `(Z, X, Y, R, c_mid)` | 无(纯函数) | §3.2/§6.6 |
| 有效性判定 | 域内 + 误差预算判定 | 无(纯函数) | §6.4 |
| 一致性监控 | 时序层面质量自检 | **每目标**:上次视差 + 连续异常计数 | §6.7 |
| 时间滤波器 | 距离低通 + 跳变重置 | **每目标**:上次滤波值、初始化标志、连续失效计数 | §6.5 |
| 目标状态容器 | 隔离键解析 + 帧末老化清理 | **每目标**:最近框中心、最近出现帧 | 隔离键见下;连续 `filter_reset_frames` 帧未出现则整体清理 |
| 告警节流器 | 异常日志限频 | 每类异常:首见标记 + 累计计数 | 第 1 次 + 每满 100 次 |
**目标隔离键(v1.0 冻结)**:`track_id` 优先——左右目的 track_id 一致且
为正时,用 `(轨道, 类别, id)` 作键(v1.1 语义提前启用,上游追踪库已在
链路中);否则回退**框位置最近邻**(同类别、门限 `isolation_gate_px` 内、
本帧尚未被占用的最近状态,一一归属)。
重写/移植时最容易漏的是**每目标隔离**的一致性计数与滤波状态——按
"全局单份"实现会在多目标下互相污染。
---
## 5. 单周期处理流程(主流程伪代码)
```text
函数 单周期处理(左帧, 右帧, 参数, now_ms) -> 目标距离记录列表:
# ---- ① 输入违约检查 ----
若 左帧.帧标识 ≠ 右帧.帧标识:
告警节流("左右帧非同帧,拒收")
返回 空列表
# ---- ② 取点(每目每框一次,纯函数) ----
左点表 ← 对 左帧.框列表 逐框取点(同侧边缘 x) # §6.1
右点表 ← 对 右帧.框列表 逐框取点(同侧边缘 x)
丢弃 坐标非有限值 的框(不参与匹配,也不发布记录;告警节流)
# ---- ③ 左右匹配(接口注入,默认贪心) ----
配对表 ← 匹配器.匹配(左点表, 右点表, 参数.匹配) # §6.2
# ---- ④ 配对成功的目标:测距 + 方位源 + 有效性 + 滤波 ----
对 配对表 中每个 (左框, 右框, 代价):
D ← 视差选择(左框, 右框) # 同侧边缘;越窗且中心在域内→回退中心(§6.1)
(Z, R) ← 测距(D, 参数.几何) # §3.2
c_mid ← (左框.中心 + 右框.中心) / 2 # §6.6
域内 ← 有效性判定(D, Z, 代价, 参数.有效性) # §6.4 门①②
一致性 ← 一致性监控(目标键).推进(D) # §6.7
置信度 ← 置信度映射(代价, 一致性) # §6.4 映射
有效 ← 域内 且 一致性 正常
若 有效:
Z_out ← 滤波器(目标键).推进(Z, 参数.滤波) # §6.5
否则:
滤波器(目标键).记失效() # §6.5 失效策略
Z_out ← 无
发布 记录(帧标识, 类别, Z_out, R, D, c_mid, 左框,
置信度, 有效, 完整, 取样时刻 = now_ms)
# ---- ⑤ 单目退化(仅一目检出) ----
对 未匹配的左框:
发布 记录(仅方位 = 左框.中心, valid = false, 退化 = 单目)
对 未匹配的右框:
发布 记录(仅方位 = 右框.中心, valid = false, 退化 = 单目)
# ---- ⑥ 空输入 ----
若 两目框列表均为空:
发布 无目标记录(或空列表);不告警(正常空帧)
```
**失败路径语义**:
| 情形 | 行为 | 日志 |
|------|------|------|
| 左右帧标识不一致 | 拒收本帧,输出空 | WARN 节流 |
| 单目检测为空 | 另一目发布仅方位记录 | 不告警(正常退化) |
| 匹配全部失败 | 全部按仅方位发布 | 不告警 |
| 视差违例(`D ≤ 0` 或超界) | 该目标 `valid = false`,仍配对并发布 `D`/`c_mid` | WARN 节流 |
| 边缘视差越窗、中心回退 | 该目标用中心视差解算(框宽差可能混入,§6.1) | WARN 节流 |
| 框坐标非法(非有限值) | 丢弃该框,不参与匹配也不发布 | WARN 节流 |
| 两目均无检测 | 无目标记录 | 不告警 |
高频热路径(每帧执行的数学)不打日志;所有异常日志按节流规范
(第 1 次 + 每满 100 次,带累计计数)。
---
## 6. 各环节算法细节
### 6.1 视差取点:同侧边缘(v1.0 主方案)
同一物理目标在两目中的检测框宽并不严格相等(检测抖动、外观差异),
**框中心**视差会混入框宽差的一半,**同一侧边缘**视差可抵消框宽项:
| 取点方式 | 误差来源 | 结论 |
|----------|----------|------|
| 框中心 | 两目框宽差直接进入视差 | 作对照/回退 |
| **同侧边缘(v1.0)** | 边缘定位噪声 | **v1.0 主方案** |
| 多边缘 + 亚像素精化(灰度重心/抛物线拟合) | 实现复杂度 | v1.1 |
```text
取法: D = 左框.左上角x − 右框.左上角x (同取左侧边缘)
回退: 边缘视差越窗 且 中心视差在域内 → 回退框中心并告警节流
两者均越窗 → 保留边缘值(供诊断与违例告警),由 §6.4 门 ① 判无效
```
> **不做"左右两边缘各算一次取中值"(v1.0 冻结)**:两边缘视差的中值恒
> 等于二者均值,即 `(cL.x − cR.x)`——**框中心视差**,与本表"同侧边缘
> 抵消框宽差"的理由自相矛盾(早期原文笔误),故不实现;亚像素精化留
> v1.1(§8.3)。
### 6.2 左右匹配:硬约束 + 贪心代价
**硬约束**(任一不满足直接拒绝配对,不进代价排序):
- 类别一致;
- `|yL − yR| ≤ τ_y`(校正后 y 应对齐;这是校正质量的运行期体现);
- 框高相近:`|hL − hR| / max(hL, hR) ≤ τ_h`。
**视差域不是配对硬约束(v1.0 冻结)**:越界目标仍应配对、仍发布 `D` 与
`c_mid`(远场"方位闭环 + 定速接近"的输入基础),仅由 §6.4 门 ① 判
`valid = false`。若把域并入硬约束,越界目标会退化为无方位的失配记录,
与 §2.3/§5 失败路径表的语义冲突。
**代价与贪心**:
```text
cost = |Δy| / τ_y + |Δh| / (τ_h × max(hL, hR))
按 cost 升序逐一配对,cost < 1 才接受,一一配对(一个左框至多配一个右框)
```
- 本场景目标数极少(1~2 个),O(n²) 贪心足够(§10.3);
- 失败处理:任一目未匹配 → 该目标本帧无距离(仅方位发布),
不阻塞其他目标。
### 6.3 测距计算与违例处理
```text
Z = F × B / D (D ≤ 0:物理违例 → valid = false,告警节流)
R = sqrt(X² + Y² + Z²) (参考输出)
```
- `D ≤ 0` 在校正正确的正视差几何下不应出现;出现说明匹配错误或校正
残差过大,宁可无效不可给值;
- `D ≤ 0` 时**不出解算结果**(无 `Z`、无 `R`);超界(`0 < D < D_min`
或 `> D_max`)仍解算 `Z`/`R` 供诊断与告警,同样 `valid = false`;
- 边缘取点为像素级整数(取整/插值规则由实现自定,但**左右目必须一致**);
- 所有除法分母(`D`)在使用前校验为正。
### 6.4 有效性判定:三道门(缺一不可)
| 门 | 判据 | 拦截什么 |
|----|------|---------|
| ① 域内 | `D ∈ [D_min, D_max]`(含边界);距离域由 `Z = F·B/D` 派生 | 远场小视差病态 + 近场大视差盲区 |
| ② 误差预算 | `δZ(δD=1px) = Z²/(F·B) ≤ δZ_max` | 有效域边界处的精度下限 |
| ③ 一致性正常 | §6.7 监控未判异常 | 持续视差跳变(标定漂移/同步故障/匹配错误) |
```text
valid = 域内 且 误差预算内 且 匹配成功 且 一致性正常
```
匹配成功由第 ③ 步保证(只有配对目标才进入本节判定)。
**门 ① 以视差域为唯一真源(v1.0 冻结)**:配置只存
`disparity_min_px / disparity_max_px`,`Z_min / Z_max` 由 `Z = F·B/D`
派生为只读量——`D` 是直接观测量,`Z` 是导出量,用 `D` 判域可避免
"距离阈值反推视差阈值"的舍入不一致;两处冗余存储正是该不一致的根源
(原文 §9.2 占位值即不自洽:`D_min = 3px` ⇔ 19.2m ≠ 20m),故不再
"互推并校验自洽"。
**门 ② 的 δD 基准固定为 1px(⚠占位,v1.0 冻结)**:δD 的真实构成
(边缘定位噪声 + 校正残差 + 帧不同步 + 压缩伪影,§7.1)留待地面真值
拟合(§12.2),当前不做估计、不随目标状态变化。
**置信度映射(⚠占位,v1.0 冻结)**:基值 = `clamp(1 − 匹配代价, 0, 1)`;
连续跳变 ≥ N 帧(§6.7)时乘降级系数(⚠0.5);判 invalid(≥ M 帧)时置 0。
### 6.5 时间滤波与跳变重置(每目标独立)
```text
推进(Z_new):
若 未初始化: 状态 ← Z_new; 返回 Z_new
若 |Z_new − 上次| > 跳变门限:
状态 ← Z_new # 直接重置而非滤波,防止拖尾
否则:
状态 ← 上次 + α × (Z_new − 上次) # 一阶低通,α ∈ (0,1]
返回 状态
记失效(): # 无效帧与"目标本帧缺席"均记失效(§6.5 失效策略)
连续失效计数 +1
若 连续失效 ≥ filter_reset_frames: 清空滤波状态(下次有效帧重新初始化)
```
- **单帧毛刺由跳变重置处理,持续性异常由 §6.7 一致性监控处理**,
两者职责不同不得合并;
- `α = 1` 等效不滤波;滤波只在**有效帧**之间进行,无效帧不参与平滑;
- 清理门限 `filter_reset_frames` **独立配置**(不复用 §6.7 的 M,⚠占位
5 帧),防"陈旧状态平滑新测量";
- 参考斜距 `R` 与**滤波后**的 `Z_out` 同基准(`X/Y/Z ∝ Z`,按
`Z_out/Z_raw` 精确换基,保证 `R ≥ Z`,§2.2);
- 滤波引入的群延迟约 `1/α` 帧(⚠占位 α=0.3 → 约 3 帧 ≈ 0.1s),
接近阶段的距离滞后由消费侧 PID 与限幅吸收。
### 6.6 方位源:虚拟双目中心 `c_mid`
**问题**:双目模组两枚相机分列模组中线两侧(各偏 ±B/2),都不在机身
中轴上。校正后严格成立的只有 **y 对齐**(垂直方向不受两目差异影响);
水平方向上直接以某一目(如左目)的像素做方位控制,收敛后对准的是
**该相机的光轴**,相对模组中线存在恒定视差偏差:
```text
β_bias ≈ atan( (B/2) / Z )
```
| 距离 Z | 角度偏差 | 像素等效(F≈960px,⚠占位) |
|-------:|-------:|-------:|
| 3 m | 0.57° | ≈10 px |
| 10 m | 0.17° | ≈2.9 px |
远距离可忽略,**近场不可忽略**;偏差方向恒定、随距离减小。
**精确修正(零额外成本)**:校正后几何下,位于基线中点的**虚拟相机**
对任一物理点 P 的投影恰为该点在两目投影的**等权平均**:
```text
xL = cx + F·(X_C + B/2)/Z (左目光心在模组中线 −B/2 侧)
xR = cx + F·(X_C − B/2)/Z (右目光心在模组中线 +B/2 侧)
(xL + xR) / 2 = cx + F·X_C/Z (= 虚拟相机投影,逐点精确,无近似)
```
检测框的每条边缘都是物理点、逐点满足上式,因此:
```text
虚拟框 = 两目框逐边平均 ⇒ 虚拟框中心 c_mid = (cL + cR) / 2
c_mid.y = (cL.y + cR.y) / 2 (校正后两目 y 天然相等,取平均兼得降噪)
```
即**等权平均等价于把相机搬到模组中线**,方位控制对标的是虚拟中心相机,
与机体中轴的安装偏移(如有)属安装外参,按安装标定处理。
**方位源与视差取点是两个不同的取点,不得混用**:
| 用途 | 取点 | 原因 |
|------|------|------|
| 视差 D(测距) | **同侧边缘** | 抵消两目框宽差(§6.1) |
| 方位 c_mid(控制) | **框中心等权平均** | 瞄点语义 = 虚拟框中心;误用边缘点平均会把瞄点偏到目标边缘(约半个框宽) |
**单目退化**(仅一目检出,v1.0):退化为该目框中心,接受上表偏差;
v1.1 可用最近一次有效 Z 做定值修正 `Δx = F·(B/2)/Z` px。
### 6.7 内部一致性监控(替代原激光交叉校验)
机载激光移除后无外部距离基准,飞行中只能靠自身时序一致性监控数据
质量(**只能降级、不能纠偏**,绝对精度靠地面静态验证,§12.1):
```text
逐目标推进(D):
视差跳变 = |D − 上次 D|(或等效 Z 跳变量;门限 consistency_jump_px)
连续 N 帧跳变超门限 → confidence 降级(⚠×0.5)
连续 M 帧异常 → valid 置 false 并 WARN(节流)
任一正常帧 → 清零连续计数
```
- 匹配层面质量(|Δy| 残差、框高一致性、代价门限)已由 §6.2 硬约束
承担;本节只管**时序层面**;
- 跳变门限 `consistency_jump_px`(⚠占位 3px)、N / M(⚠ 3 / 5 帧)均
为占位,实测后整定(§9.3);
- **推进时机(v1.0 冻结)**:只在配对成功帧推进——仅方位帧没有 `D`
观测量,不推进 D 序列、也不清零;目标缺席/无效帧的**滤波失效**由
§6.5 记,两条状态机职责不混;
- 监控判 invalid 是"质量语义"而非"错误":输出仍正常发布(仅方位),
由消费侧按无距离策略接管。
---
## 7. 误差模型与有效距离域
### 7.1 误差模型
由 `D = F·B/Z` 微分得:
```text
δZ ≈ Z² · δD / (F·B) (绝对误差,随距离平方增长)
δZ / Z ≈ Z · δD / (F·B) (相对误差,随距离线性增长)
```
`δD` 的构成:检测框边缘定位噪声 + 校正残差(|Δy| 失配混入)+ 帧不同步
引入的运动虚假视差 + 有损压缩伪影(平台相关,抽象计入边缘噪声)。
基线与焦距误差(δB/B、δF/F)线性传递、量级小,出厂标定后可忽略。
**这是"双目只能作近场距离源"的定量依据**,也是第 8 章多数缺点的根源。
### 7.2 有效距离域判据
```text
valid 域 ⇔ D ∈ [D_min, D_max](⚠占位 3 ~ 57.6px)
且 δZ(δD = 1px) ≤ δZ_max(⚠占位 2m)
```
- 按占位焦距计算:宽角档(HFOV≈90°)有效域约 **≤15m**、窄角档
(HFOV≈60°)约 **≤20m**(**⚠占位,待厂商实焦距回填后重算**;
数值回填的权威位置在《双目深度估计思路》§5.3 误差表);
- 近界:`D_max`(或 `Z_min`)限定最近可测距离——目标过近时视差超上限、
框占满画面、边缘取点失效(§8 第 3 条);
- 敏感性:误差 ∝ 1/B。更换大基线机型误差按比例缩小、有效域等比扩大
——**换机型(P6)是扩大远场能力的唯一杠杆**。
### 7.3 远场空洞(如实声明)
双目有效域之外(预计 >15~20m,待焦距回填),系统**不存在任何机载
目标距离源**。补偿方式是消费侧的远场策略(方位闭环 + 定速接近,D4),
而非强行使用不可靠的远场估计。本模块的职责边界是:把"测不了"诚实
地表达为 `valid = false` + 持续发布方位。
---
## 8. 职责、边界与已知缺点
### 8.1 职责(一句话版)
> 对同帧左右检测框做立体几何解算:**有效域内给出目标前向距离,
> 全程给出虚拟中心方位,测不了时诚实输出无效**——供控制律做
> 近场距离闭环与远场方位闭环 + 定速接近。
边界(不做什么)见 §1.4;与避障深度模型的分工见《双目深度估计思路》§1.3。
### 8.2 已知缺点与局限(如实声明,不得因文档目的删减)
| # | 缺点 | 根因 | 影响 | 现行对策 / 接受理由 |
|---|------|------|------|---------------------|
| 1 | **误差随距离平方增长** | `δZ ∝ Z²`(§7.1) | 远距离距离值不可用 | 有效域判据硬性拦截(§6.4);域外交给定速接近 |
| 2 | **远场空洞** | 有效域 >15~20m 外无任何机载距离源 | 远场无法距离闭环 | 消费侧 D4 策略(方位闭环 + 定速接近);扩域唯一杠杆是换机型(P6) |
| 3 | **近场盲区** | 视差超 `D_max`、框占满画面、边缘取点失效 | 反制逼近的最后阶段恰是最需要距离时失效 | 消费侧判丢刹车/定速接近兜底;`Z_min` 如实设界 |
| 4 | **检测框是单点依赖** | 全部几何量从框导出 | 无检测即无距离也无方位;框抖动直接进入 δD | 检测置信度门槛在检测侧;追踪库 Coast 补帧兜底 |
| 5 | **框级精度上限** | 取点在框边缘/中心,非特征级匹配 | 精度天花板高于特征级方案 | 接受(几何换算换实时性,§10.1);v1.1 亚像素精化 |
| 6 | **小视差病态** | 远距 D → 1px,量化误差主导 | 远场 δD=1px 假设过于乐观 | 同上由有效域拦截 |
| 7 | **标定与校正依赖** | y 对齐、无畸变是解算前提 | 未标定则系统性偏差,甚至全盘不成立 | P2 出厂标定为 v1.0 首要前置;不达标则 v1.1 自标定提前 |
| 8 | **帧不同步敏感** | 非同瞬曝光 → 运动目标虚假视差 | 快速横移目标距离失真 | 同帧契约(§2.1)+ 13 章快速横移验证;同步形态待 P3 |
| 9 | **固定基线/焦距** | 单一几何配置 | 近盲远废的区间固定 | 参数化配置;换机型只改配置(红线 2) |
| 10 | **贪心匹配局限** | O(n²) 局部最优 | 目标多/邻近/遮挡时错配风险 | 目标数极少(1~2)可接受;匹配器接口注入保留替换空间 |
| 11 | **无飞行中绝对基准** | 激光移除后无外部真值(D2/D8) | 一致性监控只能降级、不能纠偏 | 绝对精度靠地面手持激光测距仪静态验证(§12.1) |
| 12 | **单目退化有瞄偏** | 单目光轴偏离模组中线(§6.6) | 仅方位记录时方位存在 0.5° 量级近场偏差 | v1.0 接受并如实标注退化;v1.1 定值修正 |
**阅读提示**:1/2/3/6/9 是几何固有,只能拦截或换硬件,不能修好;
4/5/8/10/12 是工程折中,有 v1.1/v2.0 改进路径;7/11 是流程前提,
靠标定与地面验证闭环。
### 8.3 缺点对策索引
| 路径 | 覆盖的缺点 |
|------|-----------|
| v1.1(算法) | 亚像素边缘(5)、真值拟合误差修正项(1/6)、track_id 辅助匹配(10)、单目定值修正(12)、自标定(7,视 P2 结论提前) |
| v2.0(远期) | 实例分割替代框(4/5)、端到端立体评估(5)、更大基线/长焦机型(1/2/3/6/9,P6) |
| 消费侧兜底 | 定速接近 + 判丢刹车 + exit 门限(2/3 的安全网) |
| 地面验证 | 7/8/11 的唯一闭环手段 |
---
## 9. 配置参数全量表
全部参数**配置外置、启动期一次加载并校验**:缺项、类型错误、非法取值
一律拒绝进入运行期(fail-fast,语义要求,不绑定具体配置格式)。
所有数值均为占位(⚠),整定后回填。
### 9.1 几何组(来自标定,P1/P2)
| 键 | 单位 | 占位值 | 作用 |
|----|------|-------|------|
| `baseline_m` | m | 0.06 | 基线 B;更换机型只改此项 |
| `fx_px` / `fy_px` | px | 960.0(⚠占位,待 P1) | 像素焦距(校正后 `fx = fy = F`) |
| `cx_px` / `cy_px` | px | 960.0 / 540.0(⚠占位,待 P2) | 主点 |
### 9.2 匹配组
| 键 | 单位 | 占位值 | 作用 |
|----|------|-------|------|
| `tau_y_px` | px | 2.0 | \|Δy\| 硬约束(同时是校正质量的运行期体现) |
| `tau_h_ratio` | — | 0.25 | 框高相对差硬约束 |
| `disparity_min_px` | px | 3.0 | 最小可测视差(远界);**域唯一真源**(§6.4) |
| `disparity_max_px` | px | 57.6 | 最大可测视差(近界);⚠占位:按 F=960/B=0.06 反推 ⇔ 1.0 m |
| `distance_min_m` / `distance_max_m` | m | **不再配置** | v1.0 冻结:距离域由 `Z = F·B/D` 从视差域派生为只读量(§6.4) |
### 9.3 有效性组
| 键 | 单位 | 占位值 | 作用 |
|----|------|-------|------|
| `max_delta_z_m` | m | 2.0 | 误差预算 `δZ(δD=1px)` 上限(δD 基准固定 1px,§6.4) |
| `consistency_N_frames` | 帧 | 3 | 连续跳变降置信度的帧数 |
| `consistency_M_frames` | 帧 | 5 | 连续异常判 invalid 的帧数 |
| `consistency_jump_px` | px | 3.0 | 单帧视差跳变门限(§6.7);**v1.0 补充项**(原文缺项) |
### 9.4 滤波组
| 键 | 单位 | 占位值 | 作用 |
|----|------|-------|------|
| `smooth_alpha` | — | 0.3 | 一阶低通系数(§6.5 定义,1 = 不滤波) |
| `jump_reset_m` | m | 5.0 | 单帧跳变直接重置的门限 |
| `filter_reset_frames` | 帧 | 5(⚠独立配置,不复用 M) | 连续失效多久后清空滤波状态(§6.5) |
### 9.5 目标隔离组
| 键 | 单位 | 占位值 | 作用 |
|----|------|-------|------|
| `isolation_gate_px` | px | 50.0 | 最近邻兜底的匹配门限(§4);**v1.0 补充项**(原文缺项) |
---
## 10. 设计取舍记录(为什么不是别的做法)
### 10.1 两次目标检测代替稠密像素匹配
双目深度最难的是逐像素匹配:稠密立体匹配(SGBM 类)在该分辨率与
嵌入式 CPU 预算下不可行,而系统需求只是**少量目标的距离**。用左右
各一次轻量检测 + 框级几何换算绕开逐像素匹配,以"检测框天然给出 ROI"
换"精度天花板"(§8 第 5 条)。检测算力实测单核 20~30ms,NPU 预算
成立(平台侧见《双目深度估计思路》第 9 章)。
### 10.2 不先上端到端深度网络
需采集标注数据集、可解释性差、误差行为不可解析;先以几何法建立
**可验证的真值基线**,端到端立体/深度网络留 v2.0 评估。
### 10.3 贪心匹配而非匈牙利
目标数 1~2 个,贪心与全局最优几乎必然同解;O(n²) 足够。匹配器做
**接口注入**,保留替换为匈牙利/带连续性先验算法的空间而不动调用方。
### 10.4 同侧边缘而非框中心取视差
框宽差直接污染中心视差;同侧边缘可抵消框宽项(§6.1,原稿缺陷 2 的修正)。
### 10.5 距离通道用 Z 不用 R
与机头前向期望射程语义对齐,也与地面真值仪测量方向一致(决策 D8);
R 仅作参考输出。
### 10.6 方位源取两目框中心等权平均(虚拟中心像素)
两目均偏于模组中线 ±B/2,单目存在近场视差偏差(3m 处约 0.57°);
等权平均是投影几何**精确解**(§6.6,原稿"offset 加权"构想的正确形式,
决策 D5)。注意与视差取点(同侧边缘)分工不同、不可混用。
### 10.7 双目为机载唯一距离源、域外定速接近
替代激光(b96111e / D2);远场误差平方发散,强行估计不可靠——以
"诚实 invalid + 持续方位"支撑消费侧的方位闭环 + 定速接近(D4),
而不是在算法侧掩盖远场空洞。
---
## 11. 实现与重写指引
### 11.1 必须持久化的状态(漏一个就变行为)
| 状态 | 所属 | 初值 | 何时改变 |
|------|------|------|---------|
| 上次视差 + 连续异常计数 ×目标 | 一致性监控 | 无 / 0 | 配对帧写入上次视差;异常 +1 / 正常清零 |
| 上次滤波值 ×目标 | 时间滤波器 | 无(未初始化) | 每有效帧写入 |
| 滤波初始化标志 ×目标 | 时间滤波器 | false | 首个有效帧置 true;清空时回 false |
| 连续失效计数 ×目标 | 时间滤波器 | 0 | 每无效/缺席帧 +1 / 有效帧清零 |
| 最近框中心、最近出现帧 ×目标 | 目标状态容器 | 首帧写入 | 每次解析隔离键时刷新;连续 `filter_reset_frames` 帧未出现则整体清理 |
| 帧序号 | 流程编排 | 0 | 每帧 +1(被拒收的违约帧不加) |
| 告警节流状态 ×异常类 | 节流器 | 首见未打印 + 计数 0 | 每次异常更新 |
构造期预计算量:针孔系数与常量(如 `F·B`)一次算好,热路径只做乘除。
### 11.2 数值与精度注意
| 事项 | 处理方式 | 理由 |
|------|---------|------|
| 除零 | `D ≤ 0` 先判违例再除 | 正视差是物理前提(§6.3) |
| 视差取整 | 左右目规则必须一致 | 不一致会引入系统性 δD |
| 域判定基准 | 用 `D` 判域而非 `Z` | 直接观测量,避免往返舍入(§6.4) |
| 域配置单一来源 | 只配视差域,距离域派生 | 两处冗余存储是互推舍入不一致的根源(§6.4/§9.2) |
| 误差预算基准 | δD 固定 1px(⚠占位),不做在线估计 | 真实 δD 留待地面真值拟合(§12.2) |
| 浮点比较 | 阈值比较用 `≥/≤` 含边界语义 | 边界帧的归类必须确定 |
### 11.3 并发与生命周期
- 本模块是**纯计算单元**:驱动方式(独立线程 / 订阅回调 / 同步调用)
由平台层决定,本文只约定数据契约;
- 同一实例**不得并发调用**单周期处理(含滤波/一致性状态的无锁前提);
- 输出记录为**值拷贝**语义:多消费者各自持有快照,不得共享可变状态;
- 单周期处理应当无阻塞、无 IO(日志走节流通道,不落在数学热路径上)。
### 11.4 可测试性设计(构件边界即测试边界)
| 构件 | 是否纯函数 | 测试要点 |
|------|-----------|---------|
| 取点器 | 是 | 同侧边缘取值;框宽差不进入视差(构造两目等框不同宽断言 D 相同);越窗回退中心;边界含等号 |
| 匹配器 | 是 | 硬约束拦截各一例;贪心排序;一一配对;cost ≥ 1 拒绝 |
| 测距计算 | 是 | 已知 Z 反推 D 构造框对,断言 `Z = F·B/D` 回收;`D ≤ 0` 违例;`c_mid` 等权平均精确性(§6.6 恒等式);`R` 与发布 Z 同基准(`R ≥ Z`) |
| 有效性判定 | 是 | 三道门各一例;边界帧(恰在域界)归类确定;预算门通过/拦截两侧 |
| 时间滤波器 | 否 | 低通收敛;跳变重置不拖尾;失效清理 |
| 一致性监控 | 否 | N 帧降级、M 帧判 invalid、正常帧清零 |
| 目标隔离 | 否 | track_id 一致跨大位移保持状态;最近邻越门限新建;老化清理 |
| 全流程 | 否 | 合成帧对端到端:单目标/多目标/单目退化/非同帧拒收/记录顺序/非有限框/reset |
**无需真实图像**:全部测试用合成框对驱动,几何是解析可断言的。参考实现
的构件级用例已全部通过(§12.1)。
---
## 12. 验证路线与待定项
### 12.1 验证项
| 项 | 方法 | 通过标准 |
|----|------|----------|
| 构件级 | §11.4 合成框对单测 | 全部通过(无硬件依赖);Python 参考实现 `utils/stereo_ranger/` 用例已跑通 |
| 校正质量 | 静态场景统计匹配框 \|Δy\| 分布 | P95 ≤ 1px(⚠占位);不达标触发自标定提前 |
| 测距精度 | 5/10/15/20m 静态标靶,以**地面手持激光测距仪**为真值(非机载,D8),记录误差曲线 | 趋势与 §7 一致;有效域内 δZ ≤ 2m(⚠占位) |
| 帧同步 | 目标横向快速移动,观察视差时序抖动 | 无系统性虚假视差 |
| 控制联动 | 回放/SITL:距离闭环 ↔ 定速接近切换、invalid 持续降级路径 | 《视觉跟踪控制律技术路线》第 7 章场景全覆盖 |
| 日志纪律 | 检查异常路径日志 | 节流规范(第 1 次 + 每 100 次,带累计计数) |
### 12.2 待定项
开放问题(P1 焦距规格、P2 出厂标定、P3 帧同步形态、P6 换机型、
P7 性能实测、P8 避障激光去留)以《双目深度估计思路》第 15 章为权威
清单,本文不另立编号;其中:
- **P1/P2 直接决定本文 §7 结论与 §9 几何组能否从占位变实值**;
- **v1.0 实现补充的占位量**:`consistency_jump_px`(3px)、
`isolation_gate_px`(50px)、置信度降级系数(0.5)、误差预算基准 δD
(固定 1px)——随 P1/P2 回填与实测一并整定(§9.3/§9.5);
- **P6 是扩大有效域的唯一杠杆**(§7.2);
- P8(避障单点激光去留)不影响本链路——目标测距职责已由本模块承担。
### 12.3 红线
1. **占位数值不得用于飞行决策**:§7/§9 中所有 ⚠ 数值未经厂商数据与
整定确认前,本模块输出仅限静态标靶/回放/台架验证;
2. **相机参数与阈值一律配置外置**,禁止写死(后续可能更换机型);
3. **域外/失配必须输出 `valid = false`**,不得强行估计距离(远场策略
的正确性建立在有效性不撒谎之上);
4. **标定与地面真值验证完成前不得接入实飞控制回路**(与消费侧控制律
红线一致,见《视觉跟踪控制律技术路线》§12.3)。
-432
View File
@@ -1,432 +0,0 @@
# 双目目标测距技术规格(基于对偶检测)
> **范围**:平台无关、语言无关的技术规格。通过立体相机对与目标检测器估计被检测目标的距离(及可选三维位置),不依赖稠密立体匹配或端到端深度网络。
>
> **版本**:1.0(由项目专用路线图抽取并泛化而成)。
---
## 1. 目的与定位
### 1.1 动机
单目目标检测只能给出像素位置,无法提供度量距离。立体相机对提供几何约束,可将对应检测结果转换为距离。该方法适用于:
- 只需关心少量离散目标(而非稠密环境地图);
- 稠密立体匹配代价过高或不必要;
- 已有互补测距传感器(如激光雷达),双目估计可作为次要或近场来源。
### 1.2 一句话概述
左右图像各自独立运行轻量目标检测器;从对应检测框的边缘(或中心)提取视差;通过经典立体公式 \(Z = F \cdot B / D\) 将视差转换为度量距离。不生成稠密对应场或深度图。
### 1.3 能力边界
- 仅对被检测器报告的目标(如特定类别)测量距离;
- 有效工作距离由基线、焦距与可接受误差决定(见第 5 章);
- 不输出稠密深度、实例掩码或目标以外的场景几何;
- 不替代主远程测距传感器,定位为近中距离的互补来源。
---
## 2. 硬件与输入假设
所有数值参数均为配置项,禁止硬编码。
| 项目 | 典型/示例值 | 说明 |
|------|-------------|------|
| 基线 \(B\) | 0.06 m(示例) | 可配置;误差与 \(1/B\) 成正比 |
| 图像分辨率 | 如 1920×1080 | 检测流水线支持的任意分辨率 |
| 帧率 | 如 30 fps | 流水线须满足所选帧率的周期 |
| 焦距 / 视场 / 传感器尺寸 | — | 须由标定或厂商数据获得 |
| 内参与外参 | — | 立体校正所必需(见第 3 章) |
| 同步方式 | 优先硬件同步 | 软件同步可行,但须量化残余误差 |
| 检测器输入尺寸 | 如 640×640 | 左右目的 letter-box / 缩放变换必须完全一致 |
---
## 3. 前置条件:标定、立体校正与帧同步
### 3.1 标定与立体校正
本方法假设图像对已完成立体校正,使得:
- 对应极线水平且对齐(同一物理点在两图中 \(y\) 坐标相同);
- 视差纯为水平分量:\(D = x_L - x_R\)。
所需数据:
1. 左右相机各自的内参(\(f_x, f_y, c_x, c_y\))与畸变系数;
2. 两相机之间的相对旋转与平移;
3. 应用于每一帧的校正映射(或等效处理)。
**验证方法**:校正后收集匹配检测框,统计垂直残差 \(|\Delta y|\)。实用接受准则为 P95 \(\le 1\) px(实测后确定)。若残差过大,须进行离线自标定(如棋盘格)并在线重映射。
### 3.2 帧同步
若两路曝光时刻不一致,运动目标会产生虚假视差。
| 采集形态 | 同步质量 | 建议处理方式 |
|----------|----------|--------------|
| 同帧拼接单流(side-by-side) | 硬件保证同曝光 | 解码一次,裁切为左右图 |
| 双独立流 | 取决于硬件触发或软件时间戳 | 按序号 + 接收时间窗配对;监测残余时序抖动 |
无硬件同步时,须在预期目标速度下量化距离误差,必要时限制仅用于低速场景。
### 3.3 时间戳约定
优先使用处理平台上的单调接收时间戳。不得伪造传输层未提供的采集时间戳。
---
## 4. 数学模型
### 4.1 符号约定
| 符号 | 含义 | 单位 |
|------|------|------|
| \(B\) | 基线(左右光心间距) | m |
| \(F\) | 像素焦距(校正后 \(f_x = f_y\)) | px |
| \(c_x, c_y\) | 主点 | px |
| \(x_L, y_L\) | 特征点在**左目(参考目)**图像中的像素坐标 | px |
| \(x_R\) | 同一特征点在右目图像中的像素坐标 | px |
| \(D\) | 视差 \(= x_L - x_R\) | px |
| \(Z\) | 前向距离(沿光轴分量) | m |
| \(X, Y\) | 左目坐标系下的横向 / 纵向偏移 | m |
| \(R\) | 斜距 \(\sqrt{X^2+Y^2+Z^2}\) | m |
约定:校正后以左目为参考系;目标位于相机前方时视差为正。
### 4.2 核心公式(校正后坐标系)
\[
\begin{align*}
D &= x_L - x_R \\
Z &= \frac{F \cdot B}{D} \\
X &= \frac{(x_L - c_x) \cdot Z}{F} \\
Y &= \frac{(y_L - c_y) \cdot Z}{F} \\
R &= \sqrt{X^2 + Y^2 + Z^2}
\end{align*}
\]
- 当主测距传感器同样报告前向距离时,优先使用 \(Z\)(前向分量);仅在需要真实欧氏距离时使用 \(R\)。
- 小角度下 \(Z \approx R\);大偏航角时二者差异增大。
### 4.3 检测器预处理后的坐标映射
检测器通常接收固定尺寸的 letter-box 或缩放图像。必须遵守两条约束:
1. **左右图像使用完全相同的几何变换**(同缩放、同填充)。否则垂直对齐性质被破坏。
2. **视差在原始图像坐标系下计算**。将检测框映射回原生分辨率后再计算 \(D\),可保留传感器的全部像素精度。
内参 \(F, c_x, c_y\) 同样须对应原生分辨率。
### 4.4 有损压缩的影响
帧内压缩(如 MJPEG)会在框边缘引入定位噪声。其对视差不确定度 \(\delta D\) 的贡献应计入第 5.2 节误差预算。若实测影响显著,可提高码流质量档位,或在带宽允许时改用无压缩 / 轻压缩格式。
---
## 5. 视差取点与误差分析
### 5.1 对应点选择
同一目标在两目中的检测框宽度很少严格相等(检测抖动、外观差异)。使用框中心会使宽度差的一半混入视差。
| 方法 | 主要误差来源 | 建议 |
|------|--------------|------|
| 框中心 | 宽度差直接进入视差 | 对照 / 回退方案 |
| **同侧边缘**(如两框左边缘) | 仅边缘定位噪声 | **主方案** |
| 多边缘 + 亚像素精化 | 实现成本更高 | 可选后续精化 |
实用取法:
\[
D = x_L^{\text{左边缘}} - x_R^{\text{左边缘}}
\]
(若检测器直接给出左上角坐标,该值可直接使用。)当边缘测量被判定不可靠时,回退到中心视差并输出诊断警告(需节流)。
### 5.2 误差传播
由 \(D = F B / Z\) 得:
\[
\begin{align*}
\delta Z &\approx \frac{Z^2 \cdot \delta D}{F B} && \text{(绝对误差)} \\
\frac{\delta Z}{Z} &\approx \frac{Z \cdot \delta D}{F B} && \text{(相对误差)}
\end{align*}
\]
\(\delta D\) 的构成:
- 检测框边缘定位噪声;
- 校正后残余垂直失配;
- 运动目标的时序不同步;
- 压缩伪影。
基线与焦距的标定误差线性传递,完成出厂或自标定后通常可忽略。
### 5.3 示例误差表
下表仅为**示意**。假设 \(B = 0.06\,\text{m}\)、\(\delta D = 1\,\text{px}\)、原生宽度 1920 px。给出两种示例水平视场;真实焦距须由标定数据代入。
像素焦距近似:\(F = (W/2) / \tan(\text{HFOV}/2)\)。
| 距离 \(Z\) | \(D\) @ \(F\approx960\,\text{px}\)(HFOV \(\approx90^\circ\)) | \(D\) @ \(F\approx1663\,\text{px}\)(HFOV \(\approx60^\circ\)) | \(\delta Z\) @ 90° | \(\delta Z\) @ 60° |
|-----------:|----------------------------------------------------------:|------------------------------------------------------------:|-------------------:|-------------------:|
| 5 m | 11.5 px | 20.0 px | 0.43 m | 0.25 m |
| 10 m | 5.8 px | 10.0 px | 1.7 m | 1.0 m |
| 15 m | 3.8 px | 6.7 px | 3.9 m | 2.3 m |
| 20 m | 2.9 px | 5.0 px | 6.9 m | 4.0 m |
| 30 m | 1.9 px | 3.3 px | 15.6 m | 9.0 m |
| 50 m | 1.2 px | 2.0 px | 43 m | 25 m |
| 100 m| 0.6 px | 1.0 px | 174 m | 100 m |
\(\delta D\) 加倍则表中所有误差加倍。取得真实焦距后必须重算本表。
### 5.4 有效工作距离
实用有效性判据(可调):
\[
\text{valid} \;\Leftrightarrow\; D \ge D_{\min}\;\text{(如 3 px)} \;\land\; \delta Z(\delta D=1\,\text{px}) \le Z_{\text{tol}}\;\text{(如 2 m)}.
\]
按上表示例,广角镜头有效域约 \(\le 15\,\text{m}\),较窄视场约 \(\le 20\,\text{m}\)。域外估计标记为无效;方位信息仍可发布,距离由其他传感器承担。
敏感性:误差 \(\propto 1/B\)。基线加倍可使误差减半、有效域约扩大一倍——这是提升远距离能力最直接的杠杆。
### 5.5 远距离扩展(最小实现范围之外)
- 亚像素边缘精化(灰度重心、抛物线拟合等);
- 距离估计的时间滤波;
- 更大基线或更长焦距;
- 相对真值传感器拟合的经验修正项;
- 优雅降级为“仅方位 + 外部距离”。
---
## 6. 左右目关联
**输入**:属于同一逻辑帧的两路检测结果(见第 7 章)。
**硬约束**(任一不满足即拒绝配对):
- 类别标识一致;
- 垂直残差 \(|\Delta y| \le \tau_y\)(校正后,如 2 px);
- 视差落在目标距离上下限对应的区间内;
- 相对高度相近:\(|h_L - h_R| / \max(h_L,h_R) \le \tau_h\)(如 0.25)。
**代价与贪心匹配**:
\[
\text{cost} = \frac{|\Delta y|}{\tau_y} + \frac{|\Delta h|}{\tau_h\cdot\max(h_L,h_R)}.
\]
按代价升序排列候选对;仅当 \(\text{cost} < 1\) 时接受;强制一一配对。
多数跟踪场景目标数极少(一两个),\(O(n^2)\) 贪心算法足够。匹配器应通过接口注入,以便后续可替换为匈牙利算法、网络流等而不改动调用方。
**失败处理**:未匹配的检测本帧不产生距离,方位仍可发布;流水线其余部分继续运行。
**可选连续性线索**:若存在轨迹标识,优先匹配相同 track id 的框。
---
## 7. 单周期数据流水线
目标周期为所选帧率的倒数(如 30 fps 时 \(\le 33\,\text{ms}\))。
```
采集(优先硬件同步的立体对)
→ 解码 / 去马赛克 / 色彩转换
→ 几何预处理(校正映射、左右完全一致的 letter-box / 缩放)
→ 左右图像并行推理
→ 检测框映射回原生分辨率
→ 关联(第 6 章)
→ 视差 → (Z, X, Y, R)
→ 有效性判定(第 5.4 节)
→ 可选时间滤波
→ 发布结果
```
- **配对键**:共用帧序号(或序号不可用时采用紧接收时间窗)。
- **时间滤波(轻量)**:对 \(Z\) 做一阶低通,配合跳变重置门限。跨帧关联可用类别 + 最近邻连续性;完整多目标跟踪为可选项。
- **失败路径**:任一阶段失败(解码、推理、关联)导致丢帧或发布 `valid = false` 消息。失败日志须节流。
---
## 8. 图像通路考虑
具体编解码与加速硬件因平台而异,此处仅陈述逻辑要求。
- 优先从相机缓冲到校正、letter-box 直至检测器输入张量的零拷贝通路。
- 校正与 letter-box 对左右两目必须位级一致。
- 若采集格式为有损,须量化其对边缘噪声的贡献(见第 4.4 节)。
---
## 9. 性能预算
具体数值取决于所选检测器、加速器与图像尺寸。关键架构要求是:
> 左右推理**必须并发执行**。串行执行通常使时延翻倍,可能无法满足帧周期预算。
代表性预算骨架(用实测值填充):
| 阶段 | 预算(示意) |
|------|--------------|
| 采集周期 | 1 / 帧率 |
| 解码 + 预处理 | 待实测 |
| 左目推理 | 单设备 / 单核上的检测器时延 |
| 右目推理 | 与左目并发 |
| 关联 + 测距 | 少量目标时 < 1 ms |
| 端到端时延 | 实测;须为控制或融合消费方留有余量 |
---
## 10. 模块分解(语言无关)
以下逻辑模块将算法、I/O 与配置清晰分离。
| 模块 | 职责 |
|------|------|
| StereoRanger | 纯算法:关联、视差提取、距离计算、有效性、可选滤波。接收检测结果 + 相机参数,输出距离消息。可完全单元测试。 |
| 检测前端 | 两个独立检测实例(或同一实例调用两次),输出原生坐标下的框。 |
| 图像采集与预处理 | 采集、解码、校正、左右完全一致的几何变换。 |
| 配置 | 基线、内参、视差上下限、关联阈值、滤波系数——全部外置。 |
建议的结果记录(字段名仅为示意):
```
header // 时间戳、序号、健康状态
frame_sequence
class_id
track_id // 未知时为 0
forward_distance_m // Z
slant_range_m // R(可选)
disparity_px
confidence // [0,1]
valid
centre_pixel_x_mid // 虚拟双目中心(见第 11.2 节)
bbox_* // 参考目框,供诊断
```
---
## 11. 传感器融合与控制接口
### 11.1 距离源优先级(示例策略)
```
主传感器有效 → 使用主传感器
主传感器无效 / 超龄,
双目有效 → 使用双目
双目超出有效距离域 → 标记无效
两者均不可用 → “无距离”降级模式
```
### 11.2 交叉校验
两者同时有效且 \(|Z_{\text{stereo}} - d_{\text{primary}}|\) 超过相对或绝对阈值时,记录节流警告并以主传感器为准。注意 \(Z\) 为前向分量;大偏航角时与纯径向测量存在差异。
### 11.3 方位:虚拟双目中心
每枚物理相机相对模组中线偏移 \(\pm B/2\)。直接使用单目像素偏移会引入恒定角度偏差
\[
\beta_{\text{bias}} \approx \arctan\bigl((B/2)/Z\bigr)
\]
近距离时不可忽略(如 \(B=60\,\text{mm}\) 时 3 m 处约 0.6°)。
校正后,位于基线中点的**虚拟相机**对同一目标的投影恰为两目像素的平均值:
\[
\begin{align*}
x_{\text{mid}} &= \frac{x_L + x_R}{2}, \\
y_{\text{mid}} &= y_L = y_R.
\end{align*}
\]
因此:
- 存在立体匹配时,发布 \(x_{\text{mid}}\)(及 \(y_{\text{mid}}\))作为方位源;
- 仅单目检出时,回退到该目原始坐标(接受偏差),或在存在最近有效距离时施加单侧修正 \(\Delta x = F\cdot(B/2)/Z\)。
模组中线与机体中轴之间的固定安装偏移属于外参标定问题,与双目几何无关。
### 11.4 互补工作域
双目测距通常在近中距离最准确,而窄波束主传感器在此区间可能丢靶;主传感器在远距离(双目视差坍缩处)保持优势。简单的优先级或融合规则即可实现全程覆盖。
---
## 12. 建议实施阶段
| 阶段 | 内容 | 出口条件 |
|------|------|----------|
| **v1.0** | 同步对采集、一致预处理、双路检测、同侧边缘视差、贪心关联、\(Z=F B/D\)、有效性标志、结果发布、可选与主测距传感器融合 | 标定数据可用;实测误差与第 5 章模型一致;基本时延预算满足 |
| **v1.1** | 出厂校正不足时的自标定 / 重映射、亚像素边缘精化、时间滤波、track-id 关联、相对真值的经验偏差修正 | 具备 v1.0 外场数据 |
| **v2.0**(可选) | 用实例分割掩码替代检测框、评估学习式立体或单目深度网络、更大基线或更长焦距硬件、框内局部稠密精化 | 单独可行性研究 |
---
## 13. 验证清单
| 项目 | 方法 | 通过标准(示意) |
|------|------|------------------|
| 校正质量 | 静态场景,匹配框 \(|\Delta y|\) 直方图 | P95 \(\le 1\) px(实测后调整) |
| 测距精度 | 多个已知距离的静态标靶 + 真值传感器 | 误差趋势与第 5.3 节一致;有效域内 \(\delta Z\) 在容限内 |
| 同步性 | 目标快速横向运动 | 无系统性虚假视差 |
| 时延与吞吐 | 并发负载下的端到端计时 | 满足所选帧率预算 |
| 融合 / 降级 | 主传感器丢失、超龄、双目超距的回放或仿真 | 所有降级路径行为符合规格 |
| 日志纪律 | 注入失败 | 仅输出节流后的诊断信息 |
---
## 14. 设计取舍记录
| 决策 | 理由 |
|------|------|
| 不做稠密立体匹配 | 稀疏目标无需;全分辨率对应计算代价高 |
| 几何公式而非学习深度 | 可解释、无需额外训练数据,并为后续网络评估提供干净真值基线 |
| 贪心关联 | 目标基数极小;接口保留后续升级空间 |
| 前向分量 \(Z\) 作为主距离输出 | 与多数单点测距传感器语义对齐 |
| 虚拟双目中心作为方位源 | 消除物理相机偏移引起的恒定近场角度偏差 |
| 同侧边缘视差 | 抵消两目框宽差 |
| 双目作为次要 / 互补源 | 绝对误差随 \(Z^2\) 增长;方法在主传感器最弱处价值最大 |
---
## 15. 开放问题 / 配置检查清单
具体部署前须用真实数值填补以下项:
1. 焦距(或完整内参)与畸变模型。
2. 出厂立体标定是否可用及其格式;否则规划自标定。
3. 采集形态(同帧拼接单流 vs 双流)与同步方式。
4. 有损压缩对边缘噪声的实测贡献。
5. 目标计算设备上所选检测器的实测时延(并确认左右推理可并发)。
6. 可接受的绝对 / 相对距离误差及由此确定的有效工作包络。
7. 未来硬件是否考虑更大基线或不同光学配置。
---
## 附录 A – 公式速查
\[
\begin{align*}
D &= x_L - x_R \\
Z &= \frac{F B}{D} \\
\delta Z &\approx \frac{Z^2\,\delta D}{F B} \\
x_{\text{mid}} &= \frac{x_L + x_R}{2}
\end{align*}
\]
有效性(示例):\(D \ge 3\,\text{px}\) 且 \(\delta Z(\delta D=1\,\text{px}) \le 2\,\text{m}\)。
---
*文档结束。*
+85 -40
View File
@@ -47,7 +47,7 @@
1. **三路控制律计算**(每拍一次,固定周期) 1. **三路控制律计算**(每拍一次,固定周期)
- **水平航向**:目标像素偏移 → 水平视线角 β → 增益 → 偏航指令; - **水平航向**:目标像素偏移 → 水平视线角 β → 增益 → 偏航指令;
- **垂直高度**:目标像素偏移 → 垂直视线角 α → 垂直速度指令; - **垂直高度**:目标像素偏移 → 垂直视线角 α → 垂直速度指令;
- **距离**:雷达目标距离与期望射程之差 Δd → PID → 前向速度指令。 - **距离**:双目目标距离与期望射程之差 Δd → PID → 前向速度指令。
2. **统一限幅**:每个通道内联完成"变化率限幅 + 幅值限幅",任何输出都不允许 2. **统一限幅**:每个通道内联完成"变化率限幅 + 幅值限幅",任何输出都不允许
跳过限幅直达链路(含切换后的**首条**指令)。 跳过限幅直达链路(含切换后的**首条**指令)。
3. **安全降级**:输入超龄、目标判丢、距离不可用时,输出降级意图而不是继续 3. **安全降级**:输入超龄、目标判丢、距离不可用时,输出降级意图而不是继续
@@ -63,8 +63,8 @@
│ (感知线程)│ 每帧 │ (滤波器) │ 主目标+ │ TrackingControlLaw│ │ (感知线程)│ 每帧 │ (滤波器) │ 主目标+ │ TrackingControlLaw│
└─────────────┘ └──────────────┘ Coast标志 └─────────┬─────────┘ └─────────────┘ └──────────────┘ Coast标志 └─────────┬─────────┘
│ │
┌─────────────┐ 前向距离 │ 控制意图 ┌─────────────┐ 目标距离 │ 控制意图
│ 激光测距雷达│──────────────────────┐ ▼ │ 双目立体测距│──────────────────────┐ ▼
└─────────────┘ │ ┌──────────────────────┐ └─────────────┘ │ ┌──────────────────────┐
┌─────────────┐ 姿态/航向/速度 ├─────────────▶│ 打包网关 │ ┌─────────────┐ 姿态/航向/速度 ├─────────────▶│ 打包网关 │
│ PX4 链路 │──────────────────────┘ 控制快照 │ (控制意图→MAVLink) │ │ PX4 链路 │──────────────────────┘ 控制快照 │ (控制意图→MAVLink) │
@@ -78,8 +78,15 @@
``` ```
**上游依赖**:追踪库提供主目标中心与 `tracked` / `is_predicted` 两个行为标志 **上游依赖**:追踪库提供主目标中心与 `tracked` / `is_predicted` 两个行为标志
(追踪库技术路线见 `docs/视觉追踪技术路线.md`);雷达提供机头前向单点距离与 (追踪库技术路线见 `docs/视觉追踪技术路线.md`);双目模块提供目标距离
**关联标志**;飞控链路提供自机姿态(至少航向角)。 (检测框自身的立体测距,前向分量 Z)与有效标志 `distance_valid`;飞控链路
提供自机姿态(至少航向角)。
**方位源(接入层约定)**:双目模组两枚相机分列模组中线两侧(各偏 ±B/2),
单目像素存在近场视差偏差(3m 处约 0.57°)。匹配成功时接入层应以**虚拟双目
中心**像素 `x_mid = (xL + xR) / 2` 填充 `track.center`(推导见
`docs/双目深度估计思路.md` §11.3)。虚拟中心相机与单目共用同一 FOV 配置,
本模块"像素偏移 → 视线角"逻辑与配置**零改动**。
**下游依赖**:只有一个——传输抽象。打包与传输分离,控制律不直接写飞控。 **下游依赖**:只有一个——传输抽象。打包与传输分离,控制律不直接写飞控。
@@ -100,8 +107,9 @@
### 1.4 明确不做的事(边界) ### 1.4 明确不做的事(边界)
- **不做感知**:不跑检测/光流/雷达驱动,只消费调用方填入的快照; - **不做感知**:不跑检测/光流/双目立体匹配,只消费调用方填入的快照;
- **不做目标关联**:相机—雷达空间关联由感知融合层给出布尔标志,本模块只读取; - **不做距离有效性判定**:`distance_valid` 由双目链路自身的一致性校验给出,
track_id 一致性校验为调用方约定(§12.2),本模块只读取标志;
- **不做状态机仲裁**:不判断何时进入/退出视觉跟踪,不实现目标丢失策略; - **不做状态机仲裁**:不判断何时进入/退出视觉跟踪,不实现目标丢失策略;
- **不做链路 IO**:不打开串口、不管理重连、不解析任何入向 MAVLink 消息; - **不做链路 IO**:不打开串口、不管理重连、不解析任何入向 MAVLink 消息;
- **不做配置热更新**:启动期加载一次,调参重启生效; - **不做配置热更新**:启动期加载一次,调参重启生效;
@@ -115,13 +123,13 @@
| 分组 | 字段 | 单位 / 类型 | 语义与约束 | | 分组 | 字段 | 单位 / 类型 | 语义与约束 |
|------|------|------------|-----------| |------|------|------------|-----------|
| 视觉 | `track.center` | 像素 (x, y) | 主目标中心;原点为画面**左上角**,x 右正、y 下正 | | 视觉 | `track.center` | 像素 (x, y) | 主目标中心;原点为画面**左上角**,x 右正、y 下正;双目匹配成功时由接入层填虚拟双目中心 `x_mid`(§1.2) |
| 视觉 | `track.tracked` | 布尔 | 是否存在有效轨道;`false` → 判丢刹车 | | 视觉 | `track.tracked` | 布尔 | 是否存在有效轨道;`false` → 判丢刹车 |
| 视觉 | `track.is_predicted` | 布尔 | 本拍是否为滤波器预测值(Coast);`true` → 清距离积分 | | 视觉 | `track.is_predicted` | 布尔 | 本拍是否为滤波器预测值(Coast);`true` → 清距离积分 |
| 视觉 | `track_time_ms` | 单调时钟 ms | **取样瞬间**打戳;`<= 0` 视为从未取样,直接判超龄 | | 视觉 | `track_time_ms` | 单调时钟 ms | **取样瞬间**打戳;`<= 0` 视为从未取样,直接判超龄 |
| 雷达 | `radar_distance_m` | m | 机头前向单点距离;`<= 0` 视为不可用 | | 距离 | `target_distance_m` | m | 双目目标距离(前向分量 Z);`<= 0` 视为不可用 |
| 雷达 | `associated_to_target` | 布尔 | 相机—雷达关联成立标志;**未关联不得当作目标距离** | | 距离 | `distance_valid` | 布尔 | 本帧测距可用标志:距离来自目标检测框自身的双目测距;**无效帧的距离不得当作目标距离** |
| 雷达 | `radar_time_ms` | 单调时钟 ms | 雷达取样瞬间打戳 | | 距离 | `distance_time_ms` | 单调时钟 ms | 双目距离取样瞬间打戳 |
| 姿态 | `attitude_present` | 布尔 | 姿态源当前是否可用;`false` → 禁止控制 | | 姿态 | `attitude_present` | 布尔 | 姿态源当前是否可用;`false` → 禁止控制 |
| 姿态 | `attitude_time_ms` | 单调时钟 ms | 姿态取样瞬间打戳 | | 姿态 | `attitude_time_ms` | 单调时钟 ms | 姿态取样瞬间打戳 |
| 姿态 | `yaw_rad` | rad (NED) | 自机航向;**参与计算**:位置式航向合成 + 输出端坐标旋转 | | 姿态 | `yaw_rad` | rad (NED) | 自机航向;**参与计算**:位置式航向合成 + 输出端坐标旋转 |
@@ -189,7 +197,7 @@
| 构件 | 职责 | 构造期预计算 | 运行期持久状态 | 复位入口 | | 构件 | 职责 | 构造期预计算 | 运行期持久状态 | 复位入口 |
|------|------|-------------|--------------|---------| |------|------|-------------|--------------|---------|
| 主控律 | 八步流程编排 | 各构件实例、配置值拷贝 | 上拍时刻 `last_now_ms`、上拍模式 `last_mode` | `Reset()` 清全部 | | 主控律 | 八步流程编排 | 各构件实例、配置值拷贝 | 上拍时刻 `last_now_ms`、上拍模式 `last_mode`、无距离计时(`exit` 门限用,§7.5) | `Reset()` 清全部 |
| 视线角转换器 ×2 | 像素偏移 → β / α | 针孔系数 `2·tan(θ/2)/size` | 无(纯函数) | 不需要 | | 视线角转换器 ×2 | 像素偏移 → β / α | 针孔系数 `2·tan(θ/2)/size` | 无(纯函数) | 不需要 |
| 距离 PID | Δd → 前向速度 | PID 参数、输出限幅 | 积分累计、上拍误差、低通后微分值、上次输出、"是否存在有效微分历史"标志 | `ResetIntegral()` 只清积分;`Reset()` 清全部 | | 距离 PID | Δd → 前向速度 | PID 参数、输出限幅 | 积分累计、上拍误差、低通后微分值、上次输出、"是否存在有效微分历史"标志 | `ResetIntegral()` 只清积分;`Reset()` 清全部 |
| 变化率限幅器 ×4 | 限制每拍变化量 | 变化率上限 | 上次输出值 | `Reset(value)` | | 变化率限幅器 ×4 | 限制每拍变化量 | 变化率上限 | 上次输出值 | `Reset(value)` |
@@ -320,6 +328,7 @@ v_down = v_down # 绕下轴旋转不影响垂直分量
# ---- ② 判丢 → 零速刹车 ---- # ---- ② 判丢 → 零速刹车 ----
若 非 快照.track.tracked: 若 非 快照.track.tracked:
无距离计时.清除() # 刹车期间不计 kExit 时长
若 last_mode ≠ kBrakeHover: # 仅在"进入沿"清状态 若 last_mode ≠ kBrakeHover: # 仅在"进入沿"清状态
距离PID.全清(); 四个限幅器.复位(0) 距离PID.全清(); 四个限幅器.复位(0)
输出.mode ← kBrakeHover; 输出.valid ← true # 速度/航向字段保持全零 输出.mode ← kBrakeHover; 输出.valid ← true # 速度/航向字段保持全零
@@ -354,12 +363,13 @@ v_down = v_down # 绕下轴旋转不影响垂直分量
输出.vz_mps ← 钳制(限速后, ±配置.vertical.vz_limit_mps) 输出.vz_mps ← 钳制(限速后, ±配置.vertical.vz_limit_mps)
# ---- ⑦ 距离通道 ---- # ---- ⑦ 距离通道 ----
距离可用 ← 快照.associated_to_target 距离可用 ← 快照.distance_valid
且 快照.radar_distance_m > 0 且 快照.target_distance_m > 0
且 非 已超龄(快照.radar_time_ms, now_ms, 配置.radar_stale_ms) 且 非 已超龄(快照.distance_time_ms, now_ms, 配置.distance_stale_ms)
若 距离可用: 若 距离可用:
Δd ← 快照.radar_distance_m − 配置.distance.d_exp_m # 正=偏远,需接近 无距离计时.清除() # 任一有效帧重置 kExit 计时
Δd ← 快照.target_distance_m − 配置.distance.d_exp_m # 正=偏远,需接近
前向原始 ← 距离PID.更新(Δd, dt) 前向原始 ← 距离PID.更新(Δd, dt)
限速后 ← 前向速度限幅器.推进(前向原始, dt) # 加速度限幅 限速后 ← 前向速度限幅器.推进(前向原始, dt) # 加速度限幅
# 非对称限幅:接近可以快,后退必须慢 # 非对称限幅:接近可以快,后退必须慢
@@ -370,9 +380,14 @@ v_down = v_down # 绕下轴旋转不影响垂直分量
按 配置.distance.no_distance_action 分支: 按 配置.distance.no_distance_action 分支:
保持: 输出.vx_mps ← 0; 前向速度限幅器.复位(0) 保持: 输出.vx_mps ← 0; 前向速度限幅器.复位(0)
限速接近: 输出.vx_mps ← 前向速度限幅器.推进(配置.no_distance_approach_limit_mps, dt) 限速接近: 输出.vx_mps ← 前向速度限幅器.推进(配置.no_distance_approach_limit_mps, dt)
退出近距操作: 输出.mode ← kDegradedHold 退出近距操作:
输出.degraded_reason ← kNoDistanceExit 若 非 无距离计时.进行中: 无距离计时.启动(now_ms) # 首个无效拍起计时
返回 收尾并记录模式切换(输出, "距离未关联,退出近距操作") 若 now_ms − 无距离计时.起始时刻 ≥ 配置.no_distance_exit_grace_ms:
输出.mode ← kDegradedHold
输出.degraded_reason ← kNoDistanceExit
返回 收尾并记录模式切换(输出, "持续无距离达门限,退出近距操作")
# grace 期内按"限速接近"执行(复用加速度限幅,vx 平滑无跳变)
输出.vx_mps ← 前向速度限幅器.推进(配置.no_distance_approach_limit_mps, dt)
# ---- ⑧ 机体系 → 局部 NED 旋转(输出端) ---- # ---- ⑧ 机体系 → 局部 NED 旋转(输出端) ----
旋转机体到NED(输出.vx_mps, 输出.vy_mps, 输出.vz_mps, 快照.yaw_rad) 旋转机体到NED(输出.vx_mps, 输出.vy_mps, 输出.vz_mps, 快照.yaw_rad)
@@ -391,7 +406,7 @@ v_down = v_down # 绕下轴旋转不影响垂直分量
| 姿态超龄降级 | 否 | 否 | 否 | `false` | | 姿态超龄降级 | 否 | 否 | 否 | `false` |
| 视觉超龄降级 | 否 | 否 | 否 | `false` | | 视觉超龄降级 | 否 | 否 | 否 | `false` |
| 判丢刹车 | 是(进入沿) | 是(进入沿) | 否 | `true` | | 判丢刹车 | 是(进入沿) | 是(进入沿) | 否 | `true` |
| 无距离退出 | 是(仅积分) | 否 | 否 | `false` | | 无距离退出(grace 期满) | 是(仅积分) | 否 | 否 | `false` |
| 正常/Coast | 视情况清积分 | 否 | **是** | `true` | | 正常/Coast | 视情况清积分 | 否 | **是** | `true` |
**降级路径不清状态的理由**:降级只是"这一拍不输出",输入恢复后应当接着上次的 **降级路径不清状态的理由**:降级只是"这一拍不输出",输入恢复后应当接着上次的
@@ -505,9 +520,10 @@ vx ← 非对称钳制( 加速度限幅( PID(Δd) ), −后退限速, +接近限
anti-windup 判据,外部再做非对称钳制。这样内部限幅不会先于外部约束生效, anti-windup 判据,外部再做非对称钳制。这样内部限幅不会先于外部约束生效,
保证 anti-windup 判据基于"真实饱和边界"。 保证 anti-windup 判据基于"真实饱和边界"。
**可用性三重门**(缺一不可):关联标志成立 **且** 距离为正 **且** 未超龄。 **可用性三重门**(缺一不可):`distance_valid` 成立 **且** 距离为正 **且** 未超龄。
"未关联的雷达距离"只是机头前方某个障碍物的距离,把它当作目标距离会导致 双目测距取自目标检测框自身,不存在"回波打到别处"的跨模态关联问题;标志为
朝着错误目标调整射程。 `false` 表示本帧测距不可用(有效域外/未匹配/一致性校验失败),不得把任何数值
当作目标距离。
### 6.5 PID 内部算法 ### 6.5 PID 内部算法
@@ -601,12 +617,12 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
|------|-------------|------| |------|-------------|------|
| 视觉追踪结果 | 200 ms | 与推理帧率/追踪输出节拍匹配,约 4 拍(20 Hz) | | 视觉追踪结果 | 200 ms | 与推理帧率/追踪输出节拍匹配,约 4 拍(20 Hz) |
| 自机姿态 | 300 ms | 姿态链路更新较快,但缺失即禁止坐标运算 | | 自机姿态 | 300 ms | 姿态链路更新较快,但缺失即禁止坐标运算 |
| 雷达距离 | 500 ms | 雷达更新较慢,且距离不可用有独立降级动作 | | 双目距离 | 500 ms | 双目随推理帧更新,且距离不可用有独立降级动作 |
### 7.2 降级优先级(顺序即语义) ### 7.2 降级优先级(顺序即语义)
```text ```text
姿态不可用 > 视觉超龄 > 判丢 > 无距离(退出档) 姿态不可用 > 视觉超龄 > 判丢 > 无距离(exit 档,持续达门限)
``` ```
| 优先级 | 检查 | 理由 | | 优先级 | 检查 | 理由 |
@@ -614,7 +630,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
| 1 | 姿态过期/缺失 | 连坐标旋转与绝对航向合成都做不了,任何空间量都不可信(规格:禁止空间转换和控制) | | 1 | 姿态过期/缺失 | 连坐标旋转与绝对航向合成都做不了,任何空间量都不可信(规格:禁止空间转换和控制) |
| 2 | 视觉超龄 | 视觉是"是否判丢"的唯一来源;数据过期时连 `tracked` 标志本身都不可信,因此**必须先于判丢**处理 | | 2 | 视觉超龄 | 视觉是"是否判丢"的唯一来源;数据过期时连 `tracked` 标志本身都不可信,因此**必须先于判丢**处理 |
| 3 | 判丢 | 视觉可信且明确报告无轨道 → 零速刹车 | | 3 | 判丢 | 视觉可信且明确报告无轨道 → 零速刹车 |
| 4 | 无距离 | 只有在"视觉可信且正在跟踪"前提下才谈得上距离降级 | | 4 | 无距离 | 只有在"视觉可信且正在跟踪"前提下才谈得上距离降级;`exit` 档还需持续无距离达门限(§7.5),默认档为 `slow_approach`(§10.8) |
### 7.3 判丢刹车与状态清理 ### 7.3 判丢刹车与状态清理
@@ -642,19 +658,28 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
连续可用,此时停止控制反而会造成机体停顿—恢复的抖动。清积分的理由是:预测期间 连续可用,此时停止控制反而会造成机体停顿—恢复的抖动。清积分的理由是:预测期间
误差不反映真实世界,积分累加会把预测偏差固化成饱和量。 误差不反映真实世界,积分累加会把预测偏差固化成饱和量。
**Coast 期间距离通常不可用**:目标短暂消失往往伴随雷达关联失效,因此第 ⑦ 步会 **Coast 期间距离通常不可用**:目标短暂消失时双目失去匹配(`distance_valid ==
走无距离分支(默认"保持"→ 前向清零)。这是设计上的自然降级,不需要额外分支。 false`),第 ⑦ 步走无距离分支;默认 `slow_approach` 下前向按低速定速推进,
方位闭环继续(§7.5)。这是设计上的自然降级,不需要额外分支。
### 7.5 无距离三档动作 ### 7.5 无距离三档动作
| 配置值 | 行为 | 适用场景 | | 配置值 | 行为 | 适用场景 |
|--------|------|---------| |--------|------|---------|
| `hold`(默认) | 前向速度清零,限幅器复位到 0 | 安全占位语义:不知道距离就不前冲 | | `slow_approach`(默认) | 以配置的低速限幅缓速接近(仍经加速度限幅) | **默认档**:双目是机载唯一距离源,有效域外无距离是常态而非异常;即远场策略"方位闭环 + 定速接近"(D4) |
| `slow_approach` | 以配置的低速限幅缓速接近(仍经加速度限幅) | 需要持续压迫、且低速可接受风险 | | `hold` | 前向速度清零,限幅器复位到 0 | 安全占位语义:不知道距离就不前冲 |
| `exit` | 整体降级保持(`kDegradedHold` / `kNoDistanceExit`),不发送 | 距离是安全前提(如避撞),无距离必须退出近距操作 | | `exit` | 持续无距离达 `no_distance_exit_grace_ms` 才整体降级保持(`kDegradedHold` / `kNoDistanceExit`),不发送 | 距离是安全前提(如避撞)时的可配置安全策略;持续无距离 ≈ 传感器级故障 |
三档中只有 `exit` 会中断整拍输出;`hold` 与 `slow_approach` 仍输出航向与垂直 三档中只有 `exit` 会中断整拍输出,且**延迟到 grace 期满**;其余情况(含 `exit`
通道(继续把目标保持在画面中心)。 的 grace 期)仍输出航向与垂直通道(继续把目标保持在画面中心)。
**`exit` 门限语义**(替代旧版"首个无效拍即退出"):
- `distance_available` 首次为 `false` 起计时,任一有效距离帧**重置**计时;
- grace 期内前向按 `slow_approach` 低速推进,复用既有加速度限幅,`vx` 平滑
过渡无跳变;
- 判丢刹车期间不计时(每拍清计时状态),`Reset()` 同样清除;
- `no_distance_exit_grace_ms = 0` 保留旧版行为(首个无效拍即退出)。
--- ---
@@ -773,7 +798,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
|----|------|-------|------| |----|------|-------|------|
| `frequency_hz` | Hz | 20.0 | 控制频率;同时作为 dt 兜底值 | | `frequency_hz` | Hz | 20.0 | 控制频率;同时作为 dt 兜底值 |
| `visual_stale_ms` | ms | 200 | 视觉结果超龄阈值 | | `visual_stale_ms` | ms | 200 | 视觉结果超龄阈值 |
| `radar_stale_ms` | ms | 500 | 雷达距离超龄阈值 | | `distance_stale_ms` | ms | 500 | 双目距离超龄阈值 |
| `attitude_stale_ms` | ms | 300 | 自机姿态超龄阈值 | | `attitude_stale_ms` | ms | 300 | 自机姿态超龄阈值 |
### 9.2 camera 组:画面与视场 ### 9.2 camera 组:画面与视场
@@ -816,8 +841,9 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
| `derivative_filter_coef` | ∈[0,1) | 0.2 | 微分一阶低通系数(0 = 不滤波) | | `derivative_filter_coef` | ∈[0,1) | 0.2 | 微分一阶低通系数(0 = 不滤波) |
| `approach_velocity_limit_mps` | m/s | 3.0 | 接近速度上限 | | `approach_velocity_limit_mps` | m/s | 3.0 | 接近速度上限 |
| `retreat_velocity_limit_mps` | m/s | 1.0 | 后退速度上限 | | `retreat_velocity_limit_mps` | m/s | 1.0 | 后退速度上限 |
| `no_distance_action` | 枚举 | `hold` | `hold` / `slow_approach` / `exit` | | `no_distance_action` | 枚举 | `slow_approach` | `hold` / `slow_approach` / `exit`(默认档依据见 §10.8) |
| `no_distance_approach_limit_mps` | m/s | 0.5 | `slow_approach` 档的限速值 | | `no_distance_approach_limit_mps` | m/s | 0.5 | `slow_approach` 档(含 `exit` 的 grace 期)的限速值 |
| `no_distance_exit_grace_ms` | ms | 3000 | `exit` 档持续无距离门限(0 = 首个无效拍即退出) |
### 9.6 accel_limit 组:加速度 ### 9.6 accel_limit 组:加速度
@@ -889,6 +915,23 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
见 §5.3:降级是"这一拍不输出",恢复后应平滑续接;刹车是真实控制中断, 见 §5.3:降级是"这一拍不输出",恢复后应平滑续接;刹车是真实控制中断,
恢复后必须从零渐变。两者语义不同,清理策略必须不同。 恢复后必须从零渐变。两者语义不同,清理策略必须不同。
### 10.8 距离输入从雷达切换为双目:语义简化、默认动作与退出门限(2026-09-10)
**背景**:目标距离的机载唯一来源改为双目立体测距(决策 D2,替代激光雷达;
见 `docs/双目深度估计思路.md`)。
1. **关联标志语义简化(S2)**:`associated_to_target`(相机—雷达空间关联)
→ `distance_valid`(本帧测距可用)。双目距离来自目标检测框自身,"回波未
打到目标"的跨模态关联问题消失,标志退化为纯粹的本帧可用性(域外/未匹配/
一致性校验失败);track_id 一致性校验转为调用方约定(§12.2)。
2. **默认无距离动作 `hold` → `slow_approach`(D4)**:单雷达时代无距离多为
瞬时丢关联,`hold` 占位安全;双目时代有效域外无距离是**常态**,`hold` 会让
远场逼近停滞。`slow_approach` 即"方位闭环 + 定速接近"远场策略的库内承载。
3. **`exit` 增加持续门限(S1)**:旧版首个无效拍即降级,会把单帧丢检误判为
传感器级故障;改为持续无距离达 `no_distance_exit_grace_ms` 才退出,grace 期
按 `slow_approach` 行为执行(复用加速度限幅,无跳变),任一有效帧重置。
`exit` 语义收敛为"持续无距离 ≈ 传感器级故障"。
--- ---
## 11. 实现与重写指引 ## 11. 实现与重写指引
@@ -899,6 +942,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
|------|------|------|---------| |------|------|------|---------|
| 上拍时刻 | 主控律 | 0(表示首拍) | 每拍写入当前时刻 | | 上拍时刻 | 主控律 | 0(表示首拍) | 每拍写入当前时刻 |
| 上拍输出模式 | 主控律 | 降级保持 | 仅模式切换时写入(并触发日志) | | 上拍输出模式 | 主控律 | 降级保持 | 仅模式切换时写入(并触发日志) |
| 无距离计时(进行中 + 起始时刻) | 主控律 | 未计时 | 首个无距离拍启动(`exit` 档);有效距离帧 / 判丢刹车 / 复位() 清除 |
| 积分累计 | PID | 0 | 每拍(除冻结);清积分/全清时归零 | | 积分累计 | PID | 0 | 每拍(除冻结);清积分/全清时归零 |
| 上拍误差 | PID | 0 | 每拍 | | 上拍误差 | PID | 0 | 每拍 |
| 低通后微分 | PID | 0 | 每拍 | | 低通后微分 | PID | 0 | 每拍 |
@@ -917,7 +961,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
控制线程(固定周期,配置频率): 控制线程(固定周期,配置频率):
结果 ← 追踪库.取最新结果() # 线程安全拷贝 结果 ← 追踪库.取最新结果() # 线程安全拷贝
快照 ← 采集(结果, 雷达, 姿态) 快照 ← 采集(结果, 双目距离, 姿态)
快照.track_time_ms ← 当前单调时钟() # 就在取结果的那一刻打戳 快照.track_time_ms ← 当前单调时钟() # 就在取结果的那一刻打戳
意图 ← 控制律.更新(快照, 当前单调时钟()) 意图 ← 控制律.更新(快照, 当前单调时钟())
按 意图.mode 分发: 按 意图.mode 分发:
@@ -926,7 +970,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
降级保持 → 不发送(记录降级原因/告警) 降级保持 → 不发送(记录降级原因/告警)
停止视觉闭环(状态机决定): 停止视觉闭环(状态机决定):
控制律.复位() # 清积分、清限幅器、清节拍与模式历史 控制律.复位() # 清积分、清限幅器、清节拍与模式历史、清无距离计时
``` ```
**约束**: **约束**:
@@ -1003,7 +1047,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
| **侧向通道** | `ay_mps2` 预留、机体侧向恒 0 | 若需要侧移则新增通道 | 低 | | **侧向通道** | `ay_mps2` 预留、机体侧向恒 0 | 若需要侧移则新增通道 | 低 |
| **机体补偿** | 快照的滚转/俯仰/角速度字段预留未用 | 接入主工程姿态源后评估 | 低 | | **机体补偿** | 快照的滚转/俯仰/角速度字段预留未用 | 接入主工程姿态源后评估 | 低 |
| **相机标定** | 内参/畸变/安装外参未标定,针孔模型无畸变补偿 | 标定 | 中:视线角系统性偏差 | | **相机标定** | 内参/畸变/安装外参未标定,针孔模型无畸变补偿 | 标定 | 中:视线角系统性偏差 |
| **雷达关联判据** | 由感知融合层给出标志,本模块不实现 | 融合层设计 | 中:关联错误 = 距离通道朝错误目标调射程 | | **track_id 一致性校验** | 调用方约定:装配层校验双目距离与追踪主目标的 `track_id` 一致并告警,库内不实现(S3) | 主程序装配层落地时加一处校验 | 中:错配会把其他目标的距离当作本目标,距离通道朝错误目标调射程 |
### 12.3 红线 ### 12.3 红线
@@ -1014,7 +1058,8 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史
3. **不得用超龄数据生成正常指令**:超龄只能触发降级或告警。 3. **不得用超龄数据生成正常指令**:超龄只能触发降级或告警。
4. **不得绕过限幅**:任何新增通道都必须内联"变化率 + 幅值"双重限幅, 4. **不得绕过限幅**:任何新增通道都必须内联"变化率 + 幅值"双重限幅,
包括切换后的首条指令。 包括切换后的首条指令。
5. **未关联的雷达距离不得当作目标距离**。 5. **`distance_valid == false` 的距离不得当作目标距离**(v1.0 仅单目检出时
不发布距离,方位退化为该目偏移,见 `docs/双目深度估计思路.md` §11.3)。
--- ---
@@ -4,7 +4,7 @@
"control": { "control": {
"frequency_hz": 20.0, "frequency_hz": 20.0,
"visual_stale_ms": 200, "visual_stale_ms": 200,
"radar_stale_ms": 500, "distance_stale_ms": 500,
"attitude_stale_ms": 300 "attitude_stale_ms": 300
}, },
@@ -36,8 +36,9 @@
"derivative_filter_coef": 0.2, "derivative_filter_coef": 0.2,
"approach_velocity_limit_mps": 3.0, "approach_velocity_limit_mps": 3.0,
"retreat_velocity_limit_mps": 1.0, "retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold", "no_distance_action": "slow_approach",
"no_distance_approach_limit_mps": 0.5 "no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000
}, },
"accel_limit": { "accel_limit": {
@@ -179,11 +179,11 @@ int main(int argc, char** argv) {
snapshot.track = tracker.LastResult(); snapshot.track = tracker.LastResult();
snapshot.track_time_ms = now_ms; snapshot.track_time_ms = now_ms;
// 雷达:正常阶段关联成立;Coast/刹车阶段取消关联(演示 §6.3 降级路径) // 双目距离:正常阶段有效;Coast/刹车阶段置无效(演示 §6.3 降级路径)
snapshot.radar_distance_m = 40.0 - 3.0 * elapsed_s; snapshot.target_distance_m = 40.0 - 3.0 * elapsed_s;
snapshot.associated_to_target = (tick < normal_ticks) && snapshot.distance_valid = (tick < normal_ticks) &&
snapshot.radar_distance_m > 0.0; snapshot.target_distance_m > 0.0;
snapshot.radar_time_ms = now_ms; snapshot.distance_time_ms = now_ms;
// 自机姿态:演示中始终新鲜有效(接入主工程时来自 FlightStateSnapshot) // 自机姿态:演示中始终新鲜有效(接入主工程时来自 FlightStateSnapshot)
snapshot.attitude_present = true; snapshot.attitude_present = true;
@@ -209,11 +209,11 @@ int main(int argc, char** argv) {
if (tick % 10 == 0) { if (tick % 10 == 0) {
spdlog::info( spdlog::info(
"[t={:5.1f}s] 阶段={} 模式={}{} tracked={} vx={:+.2f} vy={:+.2f} " "[t={:5.1f}s] 阶段={} 模式={}{} tracked={} vx={:+.2f} vy={:+.2f} "
"vz={:+.2f} yaw_rate={:+.1f}dps d={:5.1f}m 关联={}", "vz={:+.2f} yaw_rate={:+.1f}dps d={:5.1f}m 有效={}",
elapsed_s, phase, ModeName(output.mode), elapsed_s, phase, ModeName(output.mode),
output.coast_active ? "(Coast)" : "", snapshot.track.tracked, output.coast_active ? "(Coast)" : "", snapshot.track.tracked,
output.vx_mps, output.vy_mps, output.vz_mps, output.yaw_rate_dps, output.vx_mps, output.vy_mps, output.vz_mps, output.yaw_rate_dps,
snapshot.radar_distance_m, snapshot.associated_to_target); snapshot.target_distance_m, snapshot.distance_valid);
} }
std::this_thread::sleep_for(std::chrono::milliseconds(period_ms)); std::this_thread::sleep_for(std::chrono::milliseconds(period_ms));
@@ -6,7 +6,7 @@
// 对应规格:docs/物理追踪思路.md // 对应规格:docs/物理追踪思路.md
// §4 水平航向控制(增益 → slew → 角速度限幅,位置式/速率式按配置) // §4 水平航向控制(增益 → slew → 角速度限幅,位置式/速率式按配置)
// §5 垂直/高度控制(统一速度支路:α → 垂直速度,原双支路已合并) // §5 垂直/高度控制(统一速度支路:α → 垂直速度,原双支路已合并)
// §6 距离控制(PID(Δd) → 前向速度;未关联按配置降级,§6.3) // §6 距离控制(PID(Δd) → 前向速度;距离无效按配置降级,§6.3)
// §6.4 Coast 清积分、判丢零速刹车 // §6.4 Coast 清积分、判丢零速刹车
// §7.1 超龄判定(姿态/视觉超龄 → 降级保持) // §7.1 超龄判定(姿态/视觉超龄 → 降级保持)
// §8 统一限幅 // §8 统一限幅
@@ -58,6 +58,11 @@ private:
// ---- 节拍与状态(仅打日志用,不参与数学) ---- // ---- 节拍与状态(仅打日志用,不参与数学) ----
std::int64_t last_now_ms_ = 0; // 上次计算时刻 std::int64_t last_now_ms_ = 0; // 上次计算时刻
ControlMode last_mode_ = ControlMode::kDegradedHold; // 上次输出模式(状态切换日志) ControlMode last_mode_ = ControlMode::kDegradedHold; // 上次输出模式(状态切换日志)
// ---- kExit 无距离计时(§6.3:持续无距离达 no_distance_exit_grace_ms
// 才退出,任一有效距离帧重置) ----
bool no_distance_active_ = false; // 当前是否处于连续无距离状态
std::int64_t no_distance_since_ms_ = 0; // 连续无距离起始时刻(单调时钟)
}; };
} // namespace drone::vtc } // namespace drone::vtc
@@ -33,18 +33,19 @@ enum class HeadingMode : std::uint8_t {
kPosition, // 位置式:β → 修正量,与当前航向合成绝对航向输出 yaw_deg kPosition, // 位置式:β → 修正量,与当前航向合成绝对航向输出 yaw_deg
}; };
/// 雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3,具体动作待定) /// 目标距离不可用(无效/超龄)时的距离通道降级动作(§6.3)
enum class NoDistanceAction : std::uint8_t { enum class NoDistanceAction : std::uint8_t {
kHold, // 保持:前向速度指令清零(安全占位语义) kHold, // 保持:前向速度指令清零(安全占位语义)
kSlowApproach, // 限速接近:按 no_distance_approach_limit_mps 缓速接近 kSlowApproach, // 限速接近:按 no_distance_approach_limit_mps 缓速接近
kExit, // 退出近距操作:输出降级保持(kDegradedHold) kExit, // 退出近距操作:持续无距离达 no_distance_exit_grace_ms 才
// 降级保持(kDegradedHold);grace 期间按 kSlowApproach 行为
}; };
/// control 组:控制周期与各输入超龄阈值(§7) /// control 组:控制周期与各输入超龄阈值(§7)
struct ControlGroupConfig { struct ControlGroupConfig {
double frequency_hz = 20.0; // 控制频率(Hz,§7.1 待定) double frequency_hz = 20.0; // 控制频率(Hz,§7.1 待定)
std::int64_t visual_stale_ms = 200; // 视觉追踪结果超龄阈值(毫秒) std::int64_t visual_stale_ms = 200; // 视觉追踪结果超龄阈值(毫秒)
std::int64_t radar_stale_ms = 500; // 雷达距离超龄阈值(毫秒) std::int64_t distance_stale_ms = 500; // 目标距离超龄阈值(毫秒)
std::int64_t attitude_stale_ms = 300; // 自机姿态超龄阈值(毫秒) std::int64_t attitude_stale_ms = 300; // 自机姿态超龄阈值(毫秒)
}; };
@@ -84,8 +85,12 @@ struct DistanceGroupConfig {
double derivative_filter_coef = 0.0; // 微分一阶低通系数 ∈ [0,1):0=不滤波 double derivative_filter_coef = 0.0; // 微分一阶低通系数 ∈ [0,1):0=不滤波
double approach_velocity_limit_mps = 3.0; // 接近速度限幅(米/秒,§8) double approach_velocity_limit_mps = 3.0; // 接近速度限幅(米/秒,§8)
double retreat_velocity_limit_mps = 1.0; // 后退速度限幅(米/秒,§8) double retreat_velocity_limit_mps = 1.0; // 后退速度限幅(米/秒,§8)
NoDistanceAction no_distance_action = NoDistanceAction::kHold; // 无距离降级(§6.3) // 默认定速接近(D4 远场策略):双目有效域外无距离是常态,退出仅留给
// 持续无距离(传感器级故障)场景
NoDistanceAction no_distance_action = NoDistanceAction::kSlowApproach; // 无距离降级(§6.3)
double no_distance_approach_limit_mps = 0.5; // kSlowApproach 限速值(米/秒) double no_distance_approach_limit_mps = 0.5; // kSlowApproach 限速值(米/秒)
std::int64_t no_distance_exit_grace_ms = 3000; // kExit 门限:持续无距离达
// 此时长才退出(毫秒,占位)
}; };
/// accel_limit 组:各轴加速度限幅(§8,待定) /// accel_limit 组:各轴加速度限幅(§8,待定)
@@ -13,8 +13,8 @@
// - ControlSnapshot / ControlOutput 均为纯值类型(POD 风格), // - ControlSnapshot / ControlOutput 均为纯值类型(POD 风格),
// 控制律热路径零堆分配; // 控制律热路径零堆分配;
// - 本库不依赖主工程 include/ 的任何类型,独立可编译; // - 本库不依赖主工程 include/ 的任何类型,独立可编译;
// - 雷达距离必须带 associated_to_target 关联标志:未关联不得当作 // - 目标距离必须带 distance_valid 有效标志:无效值不得当作目标距离
// 目标距离(§6.3、需求分析.md FR-041)。 // (§6.3、需求分析.md FR-041)。
// ============================================================================= // =============================================================================
#include <cstdint> #include <cstdint>
@@ -54,10 +54,11 @@ struct ControlSnapshot {
drone::tracker::TrackResult track; // 追踪库结果(值拷贝) drone::tracker::TrackResult track; // 追踪库结果(值拷贝)
std::int64_t track_time_ms = 0; // 取样单调时钟(毫秒),<=0 视为从未取样 std::int64_t track_time_ms = 0; // 取样单调时钟(毫秒),<=0 视为从未取样
// ---- 雷达距离(§6.3:未关联仅为前向安全距离,不得当目标距离) ---- // ---- 目标距离(双目立体测距,§6.3:distance_valid == false 时不得
double radar_distance_m = 0.0; // 机头前向单点距离(米) // 当作目标距离) ----
bool associated_to_target = false; // 相机—雷达空间关联成立标志 double target_distance_m = 0.0; // 目标距离(米,双目前向分量 Z)
std::int64_t radar_time_ms = 0; // 取样单调时钟(毫秒) bool distance_valid = false; // 距离有效标志(测距来自检测框自身,无跨模态关联)
std::int64_t distance_time_ms = 0; // 取样单调时钟(毫秒)
// ---- 自机姿态/速度 ---- // ---- 自机姿态/速度 ----
// 姿态过期 → 禁止控制(§9.2)。`yaw_rad` 参与输出端机体系→NED 旋转 // 姿态过期 → 禁止控制(§9.2)。`yaw_rad` 参与输出端机体系→NED 旋转
@@ -9,7 +9,8 @@
// 消息,丢弃同周期距离通道前向速度导致逼近停滞,详见 // 消息,丢弃同周期距离通道前向速度导致逼近停滞,详见
// vtc_types.h ControlMode 注释) // vtc_types.h ControlMode 注释)
// §6.2/§6.3 距离通道:Δd = d − d_exp → PID → 前向速度(限幅); // §6.2/§6.3 距离通道:Δd = d − d_exp → PID → 前向速度(限幅);
// 未关联/超龄 → 按配置降级动作(hold / slow_approach / exit) // 无效/超龄 → 按配置降级动作(hold / slow_approach / exit,
// exit 需持续无距离达 no_distance_exit_grace_ms 门限)
// §6.4 Coast(is_predicted)清航向/距离积分;判丢(!tracked)零速刹车 // §6.4 Coast(is_predicted)清航向/距离积分;判丢(!tracked)零速刹车
// §7.1 超龄数据只触发降级:姿态过期 → 禁止控制;视觉过期 → 降级保持 // §7.1 超龄数据只触发降级:姿态过期 → 禁止控制;视觉过期 → 降级保持
// §8 所有输出统一限幅;首条指令经变化率限幅(S6) // §8 所有输出统一限幅;首条指令经变化率限幅(S6)
@@ -123,6 +124,7 @@ ControlOutput TrackingControlLaw::Update(const ControlSnapshot& snapshot,
// ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 kBrakeHover) ---- // ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 kBrakeHover) ----
if (!snapshot.track.tracked) { if (!snapshot.track.tracked) {
no_distance_active_ = false; // 刹车期间不计 kExit 无距离时长
if (last_mode_ != ControlMode::kBrakeHover) { if (last_mode_ != ControlMode::kBrakeHover) {
// 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅) // 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅)
distance_pid_.Reset(); distance_pid_.Reset();
@@ -181,14 +183,16 @@ ControlOutput TrackingControlLaw::Update(const ControlSnapshot& snapshot,
} }
// ---- ⑦ 距离通道(§6.2 / §6.3) ---- // ---- ⑦ 距离通道(§6.2 / §6.3) ----
// 雷达距离不得直接当目标距离:仅关联成立且未超龄才可用(§6.3、FR-041) // 目标距离来自检测框自身的双目测距:仅 distance_valid 且未超龄才可用
// (§6.3、FR-041)
const bool distance_available = const bool distance_available =
snapshot.associated_to_target && snapshot.radar_distance_m > 0.0 && snapshot.distance_valid && snapshot.target_distance_m > 0.0 &&
!StalenessChecker::IsStale(snapshot.radar_time_ms, now_ms, !StalenessChecker::IsStale(snapshot.distance_time_ms, now_ms,
cfg_.control.radar_stale_ms); cfg_.control.distance_stale_ms);
if (distance_available) { if (distance_available) {
no_distance_active_ = false; // 任一有效距离帧重置 kExit 计时
// Δd = d − d_exp:d > d_exp 前向接近,d < d_exp 减速/后退(§6.2) // Δd = d − d_exp:d > d_exp 前向接近,d < d_exp 减速/后退(§6.2)
const double delta_d = snapshot.radar_distance_m - cfg_.distance.d_exp_m; const double delta_d = snapshot.target_distance_m - cfg_.distance.d_exp_m;
const double vx_raw = distance_pid_.Update(delta_d, dt_s); const double vx_raw = distance_pid_.Update(delta_d, dt_s);
const double slewed = vx_limiter_.Limit(vx_raw, dt_s); // 加速度限幅(§8) const double slewed = vx_limiter_.Limit(vx_raw, dt_s); // 加速度限幅(§8)
out.vx_mps = Clamp(slewed, -cfg_.distance.retreat_velocity_limit_mps, out.vx_mps = Clamp(slewed, -cfg_.distance.retreat_velocity_limit_mps,
@@ -208,10 +212,24 @@ ControlOutput TrackingControlLaw::Update(const ControlSnapshot& snapshot,
cfg_.distance.no_distance_approach_limit_mps, dt_s); cfg_.distance.no_distance_approach_limit_mps, dt_s);
break; break;
case NoDistanceAction::kExit: case NoDistanceAction::kExit:
// 退出近距操作:整体降级保持 // 退出近距操作:双目有效域外无距离是常态(D4 远场定速接近),
out.mode = ControlMode::kDegradedHold; // 首个无效拍不退出;持续无距离达 no_distance_exit_grace_ms
out.degraded_reason = DegradedReason::kNoDistanceExit; // (疑似传感器级故障)才降级。grace 期间按 kSlowApproach 行为
return FinishAndLog("距离未关联,退出近距操作"); // 执行(经加速度限幅,vx 平滑过渡);grace=0 保留旧"立即退出"
// 行为
if (!no_distance_active_) {
no_distance_active_ = true;
no_distance_since_ms_ = now_ms;
}
if (now_ms - no_distance_since_ms_ >=
cfg_.distance.no_distance_exit_grace_ms) {
out.mode = ControlMode::kDegradedHold;
out.degraded_reason = DegradedReason::kNoDistanceExit;
return FinishAndLog("持续无距离达门限,退出近距操作");
}
out.vx_mps = vx_limiter_.Limit(
cfg_.distance.no_distance_approach_limit_mps, dt_s);
break;
} }
} }
@@ -232,6 +250,7 @@ void TrackingControlLaw::Reset() {
yaw_rate_limiter_.Reset(); yaw_rate_limiter_.Reset();
vx_limiter_.Reset(); vx_limiter_.Reset();
vz_limiter_.Reset(); vz_limiter_.Reset();
no_distance_active_ = false;
last_now_ms_ = 0; last_now_ms_ = 0;
last_mode_ = ControlMode::kDegradedHold; last_mode_ = ControlMode::kDegradedHold;
} }
@@ -107,9 +107,9 @@ ControlGroupConfig LoadControl(const Json& root) {
c.frequency_hz = RequirePositive(RequireNumber(s, "control", "frequency_hz"), c.frequency_hz = RequirePositive(RequireNumber(s, "control", "frequency_hz"),
"control.frequency_hz"); "control.frequency_hz");
c.visual_stale_ms = RequireInteger(s, "control", "visual_stale_ms"); c.visual_stale_ms = RequireInteger(s, "control", "visual_stale_ms");
c.radar_stale_ms = RequireInteger(s, "control", "radar_stale_ms"); c.distance_stale_ms = RequireInteger(s, "control", "distance_stale_ms");
c.attitude_stale_ms = RequireInteger(s, "control", "attitude_stale_ms"); c.attitude_stale_ms = RequireInteger(s, "control", "attitude_stale_ms");
if (c.visual_stale_ms <= 0 || c.radar_stale_ms <= 0 || c.attitude_stale_ms <= 0) { if (c.visual_stale_ms <= 0 || c.distance_stale_ms <= 0 || c.attitude_stale_ms <= 0) {
throw std::runtime_error("配置非法: control.*_stale_ms 必须为正"); throw std::runtime_error("配置非法: control.*_stale_ms 必须为正");
} }
return c; return c;
@@ -193,6 +193,13 @@ DistanceGroupConfig LoadDistance(const Json& root) {
d.no_distance_approach_limit_mps = RequireNonNegative( d.no_distance_approach_limit_mps = RequireNonNegative(
RequireNumber(s, "distance", "no_distance_approach_limit_mps"), RequireNumber(s, "distance", "no_distance_approach_limit_mps"),
"distance.no_distance_approach_limit_mps"); "distance.no_distance_approach_limit_mps");
// 0 容许:等效旧版"首个无效拍即退出"行为
d.no_distance_exit_grace_ms = RequireInteger(s, "distance",
"no_distance_exit_grace_ms");
if (d.no_distance_exit_grace_ms < 0) {
throw std::runtime_error(
"配置非法(不得为负): distance.no_distance_exit_grace_ms");
}
return d; return d;
} }
@@ -4,8 +4,9 @@
// 覆盖(物理追踪思路 §2.3 / §4 / §5 / §6 / §7 / §9): // 覆盖(物理追踪思路 §2.3 / §4 / §5 / §6 / §7 / §9):
// 超龄降级(姿态缺失/过期、视觉超龄、降级优先级)、判丢零速刹车、 // 超龄降级(姿态缺失/过期、视觉超龄、降级优先级)、判丢零速刹车、
// Coast 清积分与标志、速率式/位置式航向通道(含绝对航向合成与回绕, // Coast 清积分与标志、速率式/位置式航向通道(含绝对航向合成与回绕,
// A2)、垂直统一速度支路(A1)、距离通道三种无距离降级动作、 // A2)、垂直统一速度支路(A1)、距离通道无距离降级动作(含 kExit
// 首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。 // 门限/grace 重置/零门限立即退出)、距离超龄回落、首拍变化率限幅(S6)、
// 机体系→NED 输出旋转(A4)。
// ============================================================================= // =============================================================================
#include <cmath> #include <cmath>
@@ -31,7 +32,7 @@ TrackingConfig MakeConfig() {
TrackingConfig cfg; TrackingConfig cfg;
cfg.control.frequency_hz = 20.0; cfg.control.frequency_hz = 20.0;
cfg.control.visual_stale_ms = 200; 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.control.attitude_stale_ms = 300;
cfg.camera.image_width = 1000; cfg.camera.image_width = 1000;
cfg.camera.image_height = 1000; cfg.camera.image_height = 1000;
@@ -59,7 +60,7 @@ TrackingConfig MakeConfig() {
return cfg; return cfg;
} }
/// 新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜 /// 新鲜快照:目标在画面中心,姿态/追踪/距离均新鲜
ControlSnapshot MakeSnapshot(std::int64_t now_ms) { ControlSnapshot MakeSnapshot(std::int64_t now_ms) {
ControlSnapshot s; ControlSnapshot s;
s.track.tracked = true; s.track.tracked = true;
@@ -220,9 +221,9 @@ TEST(TrackingControlLawTest, VerticalVelocityClamped) {
TEST(TrackingControlLawTest, DistanceApproachWhenFar) { TEST(TrackingControlLawTest, DistanceApproachWhenFar) {
TrackingControlLaw law(MakeConfig()); TrackingControlLaw law(MakeConfig());
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.radar_distance_m = 30.0; // Δd = +20 → kp 输出 10 → 限幅 3 snap.target_distance_m = 30.0; // Δd = +20 → kp 输出 10 → 限幅 3
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 1000; snap.distance_time_ms = 1000;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_NEAR(out.vx_mps, 3.0, 1e-9); EXPECT_NEAR(out.vx_mps, 3.0, 1e-9);
} }
@@ -230,9 +231,9 @@ TEST(TrackingControlLawTest, DistanceApproachWhenFar) {
TEST(TrackingControlLawTest, DistanceRetreatClampedWhenTooClose) { TEST(TrackingControlLawTest, DistanceRetreatClampedWhenTooClose) {
TrackingControlLaw law(MakeConfig()); TrackingControlLaw law(MakeConfig());
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.radar_distance_m = 5.0; // Δd = -5 → kp 输出 -2.5 → 后退限幅 -1 snap.target_distance_m = 5.0; // Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 1000; snap.distance_time_ms = 1000;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_NEAR(out.vx_mps, -1.0, 1e-9); EXPECT_NEAR(out.vx_mps, -1.0, 1e-9);
} }
@@ -240,7 +241,7 @@ TEST(TrackingControlLawTest, DistanceRetreatClampedWhenTooClose) {
TEST(TrackingControlLawTest, NoDistanceHoldZerosVx) { TEST(TrackingControlLawTest, NoDistanceHoldZerosVx) {
TrackingControlLaw law(MakeConfig()); // 缺省 kHold TrackingControlLaw law(MakeConfig()); // 缺省 kHold
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.associated_to_target = false; snap.distance_valid = false;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_EQ(out.mode, ControlMode::kVelocityHeading); EXPECT_EQ(out.mode, ControlMode::kVelocityHeading);
EXPECT_DOUBLE_EQ(out.vx_mps, 0.0); EXPECT_DOUBLE_EQ(out.vx_mps, 0.0);
@@ -251,29 +252,80 @@ TEST(TrackingControlLawTest, NoDistanceSlowApproach) {
cfg.distance.no_distance_action = NoDistanceAction::kSlowApproach; cfg.distance.no_distance_action = NoDistanceAction::kSlowApproach;
TrackingControlLaw law(cfg); TrackingControlLaw law(cfg);
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.associated_to_target = false; snap.distance_valid = false;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_NEAR(out.vx_mps, 0.5, 1e-9); EXPECT_NEAR(out.vx_mps, 0.5, 1e-9);
} }
TEST(TrackingControlLawTest, NoDistanceExitDegrades) { TEST(TrackingControlLawTest, NoDistanceExitWaitsGraceThenDegrades) {
// S1 拍板:kExit 需持续无距离达门限才降级;grace 期间按 kSlowApproach 行为
auto cfg = MakeConfig(); auto cfg = MakeConfig();
cfg.distance.no_distance_action = NoDistanceAction::kExit; cfg.distance.no_distance_action = NoDistanceAction::kExit;
cfg.distance.no_distance_exit_grace_ms = 200;
TrackingControlLaw law(cfg); TrackingControlLaw law(cfg);
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.associated_to_target = false; snap.distance_valid = false;
const auto out1 = law.Update(snap, 1000);
EXPECT_EQ(out1.mode, ControlMode::kVelocityHeading);
EXPECT_NEAR(out1.vx_mps, 0.5, 1e-9);
snap.track_time_ms = 1200; // 保持视觉/姿态新鲜,聚焦距离通道
snap.attitude_time_ms = 1200;
const auto out2 = law.Update(snap, 1200); // 恰达门限 → 降级
EXPECT_EQ(out2.mode, ControlMode::kDegradedHold);
EXPECT_EQ(out2.degraded_reason, DegradedReason::kNoDistanceExit);
EXPECT_FALSE(out2.valid);
}
TEST(TrackingControlLawTest, NoDistanceExitGraceResetsOnValidFrame) {
auto cfg = MakeConfig();
cfg.distance.no_distance_action = NoDistanceAction::kExit;
cfg.distance.no_distance_exit_grace_ms = 200;
TrackingControlLaw law(cfg);
auto snap = MakeSnapshot(1000);
snap.distance_valid = false;
EXPECT_EQ(law.Update(snap, 1000).mode, ControlMode::kVelocityHeading);
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;
EXPECT_EQ(law.Update(snap, 1100).mode, ControlMode::kVelocityHeading);
snap.track_time_ms = 1250; // 再次无效:重新计时
snap.attitude_time_ms = 1250;
snap.distance_valid = false;
snap.target_distance_m = 0.0;
EXPECT_EQ(law.Update(snap, 1250).mode,
ControlMode::kVelocityHeading); // 0 ms < 200 ms 门限
snap.track_time_ms = 1500;
snap.attitude_time_ms = 1500;
const auto out = law.Update(snap, 1500); // 250 ms ≥ 200 ms → 降级
EXPECT_EQ(out.mode, ControlMode::kDegradedHold);
EXPECT_EQ(out.degraded_reason, DegradedReason::kNoDistanceExit);
}
TEST(TrackingControlLawTest, NoDistanceExitZeroGraceDegradesImmediately) {
// grace = 0 保留旧"首个无效拍即退出"行为
auto cfg = MakeConfig();
cfg.distance.no_distance_action = NoDistanceAction::kExit;
cfg.distance.no_distance_exit_grace_ms = 0;
TrackingControlLaw law(cfg);
auto snap = MakeSnapshot(1000);
snap.distance_valid = false;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_EQ(out.mode, ControlMode::kDegradedHold); EXPECT_EQ(out.mode, ControlMode::kDegradedHold);
EXPECT_EQ(out.degraded_reason, DegradedReason::kNoDistanceExit); EXPECT_EQ(out.degraded_reason, DegradedReason::kNoDistanceExit);
EXPECT_FALSE(out.valid); EXPECT_FALSE(out.valid);
} }
TEST(TrackingControlLawTest, StaleRadarFallsBackToNoDistance) { TEST(TrackingControlLawTest, StaleDistanceFallsBackToNoDistance) {
TrackingControlLaw law(MakeConfig()); // kHold TrackingControlLaw law(MakeConfig()); // kHold
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.radar_distance_m = 30.0; snap.target_distance_m = 30.0;
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 400; // 600 ms 前 > 500 ms 阈值 → 距离不可用 snap.distance_time_ms = 400; // 600 ms 前 > 500 ms 阈值 → 距离不可用
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_EQ(out.mode, ControlMode::kVelocityHeading); EXPECT_EQ(out.mode, ControlMode::kVelocityHeading);
EXPECT_DOUBLE_EQ(out.vx_mps, 0.0); EXPECT_DOUBLE_EQ(out.vx_mps, 0.0);
@@ -287,9 +339,9 @@ TEST(TrackingControlLawTest, FirstTickVxSlewsFromZero) {
cfg.accel_limit.ax_mps2 = 2.0; cfg.accel_limit.ax_mps2 = 2.0;
TrackingControlLaw law(cfg); TrackingControlLaw law(cfg);
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.radar_distance_m = 30.0; // 通道输出本应为 3.0 snap.target_distance_m = 30.0; // 通道输出本应为 3.0
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 1000; snap.distance_time_ms = 1000;
const auto out = law.Update(snap, 1000); // 首拍:dt 按 1/20 Hz 兜底 const auto out = law.Update(snap, 1000); // 首拍:dt 按 1/20 Hz 兜底
EXPECT_NEAR(out.vx_mps, 2.0 * (1.0 / 20.0), 1e-9); // 0.1 EXPECT_NEAR(out.vx_mps, 2.0 * (1.0 / 20.0), 1e-9); // 0.1
} }
@@ -300,9 +352,9 @@ TEST(TrackingControlLawTest, OutputRotatedToNedByYaw) {
TrackingControlLaw law(MakeConfig()); TrackingControlLaw law(MakeConfig());
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.yaw_rad = kPi / 2.0; // 机头朝东 snap.yaw_rad = kPi / 2.0; // 机头朝东
snap.radar_distance_m = 30.0; snap.target_distance_m = 30.0;
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 1000; snap.distance_time_ms = 1000;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
// 机体前向 3.0 → 东向 3.0,北向 ≈ 0 // 机体前向 3.0 → 东向 3.0,北向 ≈ 0
EXPECT_NEAR(out.vx_mps, 0.0, 1e-9); EXPECT_NEAR(out.vx_mps, 0.0, 1e-9);
@@ -313,9 +365,9 @@ TEST(TrackingControlLawTest, ZeroYawKeepsForwardAsNorth) {
TrackingControlLaw law(MakeConfig()); TrackingControlLaw law(MakeConfig());
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.yaw_rad = 0.0; snap.yaw_rad = 0.0;
snap.radar_distance_m = 30.0; snap.target_distance_m = 30.0;
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 1000; snap.distance_time_ms = 1000;
const auto out = law.Update(snap, 1000); const auto out = law.Update(snap, 1000);
EXPECT_NEAR(out.vx_mps, 3.0, 1e-9); EXPECT_NEAR(out.vx_mps, 3.0, 1e-9);
EXPECT_NEAR(out.vy_mps, 0.0, 1e-9); EXPECT_NEAR(out.vy_mps, 0.0, 1e-9);
@@ -328,18 +380,18 @@ TEST(TrackingControlLawTest, ResetRestoresSlewFromZero) {
cfg.accel_limit.ax_mps2 = 2.0; cfg.accel_limit.ax_mps2 = 2.0;
TrackingControlLaw law(cfg); TrackingControlLaw law(cfg);
auto snap = MakeSnapshot(1000); auto snap = MakeSnapshot(1000);
snap.radar_distance_m = 30.0; snap.target_distance_m = 30.0;
snap.associated_to_target = true; snap.distance_valid = true;
snap.radar_time_ms = 1000; snap.distance_time_ms = 1000;
for (std::int64_t t = 1000; t < 3000; t += 50) { for (std::int64_t t = 1000; t < 3000; t += 50) {
snap.track_time_ms = t; snap.track_time_ms = t;
snap.radar_time_ms = t; snap.distance_time_ms = t;
snap.attitude_time_ms = t; snap.attitude_time_ms = t;
law.Update(snap, t); // 若干拍后 vx 已爬升到限幅 3.0 law.Update(snap, t); // 若干拍后 vx 已爬升到限幅 3.0
} }
law.Reset(); law.Reset();
snap.track_time_ms = 3000; snap.track_time_ms = 3000;
snap.radar_time_ms = 3000; snap.distance_time_ms = 3000;
snap.attitude_time_ms = 3000; snap.attitude_time_ms = 3000;
const auto out = law.Update(snap, 3000); const auto out = law.Update(snap, 3000);
// Reset 后限幅器归零:又从 0.1 开始渐变 // Reset 后限幅器归零:又从 0.1 开始渐变
@@ -30,7 +30,7 @@ const char* kFullJson = R"({
"control": { "control": {
"frequency_hz": 20.0, "frequency_hz": 20.0,
"visual_stale_ms": 200, "visual_stale_ms": 200,
"radar_stale_ms": 500, "distance_stale_ms": 500,
"attitude_stale_ms": 300 "attitude_stale_ms": 300
}, },
"camera": { "camera": {
@@ -59,7 +59,8 @@ const char* kFullJson = R"({
"approach_velocity_limit_mps": 3.0, "approach_velocity_limit_mps": 3.0,
"retreat_velocity_limit_mps": 1.0, "retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold", "no_distance_action": "hold",
"no_distance_approach_limit_mps": 0.5 "no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000
}, },
"accel_limit": { "accel_limit": {
"ax_mps2": 2.0, "ax_mps2": 2.0,
@@ -96,7 +97,9 @@ TEST(VtcConfigTest, LoadRealConfigSucceeds) {
EXPECT_DOUBLE_EQ(cfg.vertical.vz_gain_mps_per_deg, 0.1); EXPECT_DOUBLE_EQ(cfg.vertical.vz_gain_mps_per_deg, 0.1);
EXPECT_DOUBLE_EQ(cfg.vertical.vz_limit_mps, 2.0); EXPECT_DOUBLE_EQ(cfg.vertical.vz_limit_mps, 2.0);
EXPECT_DOUBLE_EQ(cfg.distance.d_exp_m, 10.0); EXPECT_DOUBLE_EQ(cfg.distance.d_exp_m, 10.0);
EXPECT_EQ(cfg.distance.no_distance_action, NoDistanceAction::kHold); EXPECT_EQ(cfg.distance.no_distance_action, NoDistanceAction::kSlowApproach);
EXPECT_EQ(cfg.distance.no_distance_exit_grace_ms, 3000);
EXPECT_EQ(cfg.control.distance_stale_ms, 500);
EXPECT_DOUBLE_EQ(cfg.accel_limit.ax_mps2, 2.0); EXPECT_DOUBLE_EQ(cfg.accel_limit.ax_mps2, 2.0);
} }
@@ -221,4 +224,25 @@ TEST(VtcConfigTest, FovOutOfRangeThrows) {
std::remove(path.c_str()); std::remove(path.c_str());
} }
TEST(VtcConfigTest, DefaultNoDistanceActionIsSlowApproach) {
// S1 拍板:双目有效域外无距离是常态,默认定速接近(纪要 D4/S1)
const TrackingConfig cfg;
EXPECT_EQ(cfg.distance.no_distance_action, NoDistanceAction::kSlowApproach);
}
TEST(VtcConfigTest, NegativeExitGraceThrows) {
std::string json = kFullJson;
ReplaceOnce(json, "\"no_distance_exit_grace_ms\": 3000",
"\"no_distance_exit_grace_ms\": -1");
const std::string path = WriteTempJson(json, "vtc_cfg_badgrace.json");
try {
TrackingConfig::LoadFromJson(path);
FAIL() << "应当抛出异常";
} catch (const std::runtime_error& e) {
EXPECT_NE(std::string(e.what()).find("no_distance_exit_grace_ms"),
std::string::npos);
}
std::remove(path.c_str());
}
} // namespace } // namespace
@@ -34,8 +34,9 @@
垂直速度设定值(原"小误差姿态微调/大误差垂直速度"双支路已取消, 垂直速度设定值(原"小误差姿态微调/大误差垂直速度"双支路已取消,
原因见 `vtc_types.h` 的 `ControlMode` 注释与 §3.8); 原因见 `vtc_types.h` 的 `ControlMode` 注释与 §3.8);
- 距离通道(规格 §6):`Δd = d − d_exp` → `PidController` → 前向速度 - 距离通道(规格 §6):`Δd = d − d_exp` → `PidController` → 前向速度
`vx_mps`;雷达未关联/超龄时按 `NoDistanceAction` 配置降级 `vx_mps`;目标距离无效/超龄时按 `NoDistanceAction` 配置降级
(hold / slow_approach / exit)。 (hold / slow_approach / exit,exit 需持续无距离达
`no_distance_exit_grace_ms` 门限,见 §3.8)。
- **统一限幅**(规格 §8):所有输出通道内联完成限幅——`Clamp` 静态限幅 + - **统一限幅**(规格 §8):所有输出通道内联完成限幅——`Clamp` 静态限幅 +
`SlewRateLimiter` 变化率限幅(航向修正/角速度、前向/垂直加速度), `SlewRateLimiter` 变化率限幅(航向修正/角速度、前向/垂直加速度),
全部在机体系完成。 全部在机体系完成。
@@ -96,7 +97,8 @@
(原 `kAttitudeTrim` 姿态微调模式已取消,见 §3.8。) (原 `kAttitudeTrim` 姿态微调模式已取消,见 §3.8。)
`enum class DegradedReason`:`kNone` / `kAttitudeStale`(姿态过期)/ `enum class DegradedReason`:`kNone` / `kAttitudeStale`(姿态过期)/
`kVisionStale`(视觉超龄)/ `kNoDistanceExit`(无距离且配置退出近距操作)。 `kVisionStale`(视觉超龄)/ `kNoDistanceExit`(持续无距离达门限且配置
退出近距操作)。
`ControlSnapshot`(输入,纯值类型): `ControlSnapshot`(输入,纯值类型):
@@ -104,9 +106,9 @@
|------|------| |------|------|
| `track` | `drone::tracker::TrackResult` 值拷贝(追踪库主目标输出,字段定义见 `../target_tracker/target_tracker.md` §二) | | `track` | `drone::tracker::TrackResult` 值拷贝(追踪库主目标输出,字段定义见 `../target_tracker/target_tracker.md` §二) |
| `track_time_ms` | 追踪结果取样单调时钟(毫秒);**<=0 视为从未取样,直接判超龄** | | `track_time_ms` | 追踪结果取样单调时钟(毫秒);**<=0 视为从未取样,直接判超龄** |
| `radar_distance_m` | 机头前向单点距离(米) | | `target_distance_m` | 目标距离(米,双目立体测距前向分量 Z) |
| `associated_to_target` | 相机—雷达空间关联成立标志;**未关联不得当目标距离**(规格 §6.3、需求分析 FR-041) | | `distance_valid` | 距离有效标志;**无效不得当目标距离**(规格 §6.3、需求分析 FR-041)。距离来自目标检测框自身的双目测距,无跨模态关联问题 |
| `radar_time_ms` | 雷达取样单调时钟 | | `distance_time_ms` | 距离取样单调时钟 |
| `attitude_present` / `attitude_time_ms` | 姿态源可用性与取样时刻(过期→禁止控制,规格 §9.2) | | `attitude_present` / `attitude_time_ms` | 姿态源可用性与取样时刻(过期→禁止控制,规格 §9.2) |
| `yaw_rad` | 自机航向(弧度,NED);**参与计算**:输出端机体系→NED 旋转(§2.3)与位置式绝对航向合成 | | `yaw_rad` | 自机航向(弧度,NED);**参与计算**:输出端机体系→NED 旋转(§2.3)与位置式绝对航向合成 |
| `roll_rad` / `pitch_rad` / `yaw_rate_radps` | 自机姿态其余分量(弧度,FRD);预留,机体补偿未实现(规格 §4.1) | | `roll_rad` / `pitch_rad` / `yaw_rate_radps` | 自机姿态其余分量(弧度,FRD);预留,机体补偿未实现(规格 §4.1) |
@@ -124,15 +126,18 @@
| `coast_active` | 本周期处于 Coast(`is_predicted == true`) | | `coast_active` | 本周期处于 Coast(`is_predicted == true`) |
`TrackingConfig` 六分组:`control`(`frequency_hz`、`visual_stale_ms`、 `TrackingConfig` 六分组:`control`(`frequency_hz`、`visual_stale_ms`、
`radar_stale_ms`、`attitude_stale_ms`)、`camera`(`image_width`、 `distance_stale_ms`、`attitude_stale_ms`)、`camera`(`image_width`、
`image_height`、`fov_h_deg`、`fov_v_deg`)、`heading`(`mode`、`gain`、 `image_height`、`fov_h_deg`、`fov_v_deg`)、`heading`(`mode`、`gain`、
`yaw_rate_limit_dps`、`yaw_slew_limit_dps2`)、`vertical` `yaw_rate_limit_dps`、`yaw_slew_limit_dps2`)、`vertical`
(`vz_gain_mps_per_deg`、`vz_limit_mps`)、`distance`(`d_exp_m`、 (`vz_gain_mps_per_deg`、`vz_limit_mps`)、`distance`(`d_exp_m`、
`kp`/`ki`/`kd`、`integral_limit`、`derivative_filter_coef`、 `kp`/`ki`/`kd`、`integral_limit`、`derivative_filter_coef`、
`approach_velocity_limit_mps`、`retreat_velocity_limit_mps`、 `approach_velocity_limit_mps`、`retreat_velocity_limit_mps`、
`no_distance_action`、`no_distance_approach_limit_mps`)、`accel_limit` `no_distance_action`、`no_distance_approach_limit_mps`、
`no_distance_exit_grace_ms`)、`accel_limit`
(`ax_mps2`、`ay_mps2`、`az_mps2`)。JSON 键名与字段一一对应,见 (`ax_mps2`、`ay_mps2`、`az_mps2`)。JSON 键名与字段一一对应,见
`config/track_control.json`。`heading.mode` 默认速率式(`"rate"`); `config/track_control.json`。`heading.mode` 默认速率式(`"rate"`);
`distance.no_distance_action` 默认 `slow_approach`(双目有效域外无距离
是常态,对齐 D4 远场定速接近策略,见《双目测距方案讨论纪要》S1);
相机组原 `linear_ratio` 键已随线性/针孔双路一并移除(§3.5)。 相机组原 `linear_ratio` 键已随线性/针孔双路一并移除(§3.5)。
### 2.3 ICommandTransport 契约 ### 2.3 ICommandTransport 契约
@@ -263,11 +268,11 @@ dt 上限 `kMaxDtS = 0.5` 秒):
→ ② 判丢刹车(清积分与限幅器历史)→ ③ Coast 清距离积分 → ② 判丢刹车(清积分与限幅器历史)→ ③ Coast 清距离积分
→ ④ 像素偏移 → β/α(针孔模型)→ ⑤ 水平航向(按 `HeadingMode` 分支) → ④ 像素偏移 → β/α(针孔模型)→ ⑤ 水平航向(按 `HeadingMode` 分支)
→ ⑥ 垂直统一速度支路(α → `vz_gain·α` → 加速度限幅 → 速度限幅) → ⑥ 垂直统一速度支路(α → `vz_gain·α` → 加速度限幅 → 速度限幅)
→ ⑦ 距离通道(关联成立且未超龄才可用;否则清积分并按 → ⑦ 距离通道(`distance_valid` 且未超龄才可用;否则清积分并按
`no_distance_action` 降级)→ ⑧ 机体系→NED 旋转(`RotateBodyToNed`, `no_distance_action` 降级)→ ⑧ 机体系→NED 旋转(`RotateBodyToNed`,
随 `snapshot.yaw_rad`)→ 置 `valid = true` 输出。 随 `snapshot.yaw_rad`)→ 置 `valid = true` 输出。
两个设计决策记录: 三个设计决策记录:
- **垂直双支路合并(A1)**:规格 §5.2 原设"小误差姿态微调(`kAttitudeTrim` - **垂直双支路合并(A1)**:规格 §5.2 原设"小误差姿态微调(`kAttitudeTrim`
→ ID 82)/ 大误差垂直速度"双支路。姿态微调支路会把整个输出切成姿态 → ID 82)/ 大误差垂直速度"双支路。姿态微调支路会把整个输出切成姿态
@@ -278,6 +283,14 @@ dt 上限 `kMaxDtS = 0.5` 秒):
航向。若把 β 修正量直接填入,飞控会转向"修正角"方向而非目标方向。 航向。若把 β 修正量直接填入,飞控会转向"修正角"方向而非目标方向。
位置式分支输出 `WrapDeg180(当前航向 + 限幅修正量)`;速率式 位置式分支输出 `WrapDeg180(当前航向 + 限幅修正量)`;速率式
(默认)输出角速度指令,天然相对量、无此问题。 (默认)输出角速度指令,天然相对量、无此问题。
- **kExit 持续无距离门限(纪要 S1)**:换双目后有效域外无距离是常态
(纪要 D4 远场定速接近),首个无效拍即退出会频繁降级。`kExit` 现需
连续无距离达 `no_distance_exit_grace_ms` 才降级(语义收敛为"疑似
传感器级故障"),grace 期间按 `kSlowApproach` 行为执行(经加速度
限幅,vx 平滑过渡),任一有效距离帧重置计时;`no_distance_exit_grace_ms
= 0` 保留旧"立即退出"行为。`no_distance_action` 默认值随之
`kHold` → `kSlowApproach`。判丢刹车期间不计无距离时长,`Reset()`
清计时状态。
### 3.9 MAVLink 消息映射(规格 §2.2) ### 3.9 MAVLink 消息映射(规格 §2.2)
@@ -347,12 +360,12 @@ cmake --build build_vtc -j$(nproc)
`LoggingTransport` 字节打印),按配置频率固定节拍依次走三阶段 `LoggingTransport` 字节打印),按配置频率固定节拍依次走三阶段
(对应规格 §6.4): (对应规格 §6.4):
1. **正常跟踪(6 秒)**:持续喂入向画面中心漂移的合成检测框,雷达 1. **正常跟踪(6 秒)**:持续喂入向画面中心漂移的合成检测框,双目
关联成立,输出速率式速度航向意图(vx 按加速度限幅渐变至接近速度上限, 距离有效,输出速率式速度航向意图(vx 按加速度限幅渐变至接近速度上限,
角速度随 β 收敛而减小); 角速度随 β 收敛而减小);
2. **Coast(4 秒)**:停止喂检测 → `is_predicted == true` → 清积分、 2. **Coast(4 秒)**:停止喂检测 → `is_predicted == true` → 清积分、
继续预测跟踪;同时取消雷达关联,演示 §6.3 无距离降级路径(hold: 继续预测跟踪;同时置距离无效,演示 §6.3 无距离降级路径(配置为
前向清零); `slow_approach`:按限速值缓速前向);
3. **判丢刹车(3 秒)**:连续丢帧超过追踪阈值 → `tracked == false` → 3. **判丢刹车(3 秒)**:连续丢帧超过追踪阈值 → `tracked == false` →
零速刹车。 零速刹车。
@@ -372,8 +385,8 @@ cmake --build build_vtc -j$(nproc)
| `frame_transform_test.cpp` | ψ=0/90° 基向量映射、合速度守恒(正交性)、`v_down` 不变 | | `frame_transform_test.cpp` | ψ=0/90° 基向量映射、合速度守恒(正交性)、`v_down` 不变 |
| `line_of_sight_test.cpp` | 零偏移、半幅=半视场、奇对称、单调性、与线性近似的偏离(证明未走线性支路)、构造期非法参数抛异常 | | `line_of_sight_test.cpp` | 零偏移、半幅=半视场、奇对称、单调性、与线性近似的偏离(证明未走线性支路)、构造期非法参数抛异常 |
| `pid_controller_test.cpp` | P/I/D 分量、积分钳位、输出限幅、抗饱和冻结、**误差反号回卷回归**(旧判据钉在 +5,条件积分到 -5)、微分低通、`dt<=0`、`ResetIntegral`/`Reset` | | `pid_controller_test.cpp` | P/I/D 分量、积分钳位、输出限幅、抗饱和冻结、**误差反号回卷回归**(旧判据钉在 +5,条件积分到 -5)、微分低通、`dt<=0`、`ResetIntegral`/`Reset` |
| `tracking_control_law_test.cpp` | 姿态缺失/过期、视觉超龄、从未取样、姿态比视觉更严的优先级、判丢刹车、Coast 标志、速率/位置两种航向模式(含绝对航向回绕)、垂直支路与限幅、距离通道四种降级动作、雷达超龄回落、首拍变化率限幅(S6)、NED 旋转、`Reset` 后从零渐变 | | `tracking_control_law_test.cpp` | 姿态缺失/过期、视觉超龄、从未取样、姿态比视觉更严的优先级、判丢刹车、Coast 标志、速率/位置两种航向模式(含绝对航向回绕)、垂直支路与限幅、距离通道无距离降级动作(含 kExit 门限、grace 有效帧重置、零门限立即退出)、距离超龄回落、首拍变化率限幅(S6)、NED 旋转、`Reset` 后从零渐变 |
| `vtc_config_test.cpp` | 真实配置加载、position 模式可接受、缺文件/缺字段/缺分组/类型错/枚举非法/数值越界均抛异常且消息携带字段名 | | `vtc_config_test.cpp` | 真实配置加载、position 模式可接受、默认降级动作为 slow_approach、缺文件/缺字段/缺分组/类型错/枚举非法/数值越界/grace 为负均抛异常且消息携带字段名 |
| `mavlink_command_gateway_test.cpp` | 手工解码 MAVLink v2 字节流(不含 `mavlink.h`,构建约束见 §3.6):帧头、ID 84、三种掩码分支(含"双零 → 速率掩码"回归)、坐标基准字节、目标系统/组件字节、传输失败传播 | | `mavlink_command_gateway_test.cpp` | 手工解码 MAVLink v2 字节流(不含 `mavlink.h`,构建约束见 §3.6):帧头、ID 84、三种掩码分支(含"双零 → 速率掩码"回归)、坐标基准字节、目标系统/组件字节、传输失败传播 |
运行方式(构建后): 运行方式(构建后):
@@ -430,8 +443,11 @@ CMake config 包);未找到时自动跳过测试构建并打印状态,不
由接线部件完成 `TrackResult` → `TrackedTarget` 转换与控制线程订阅; 由接线部件完成 `TrackResult` → `TrackedTarget` 转换与控制线程订阅;
3. **传输实现替换**:把示例的 `LoggingTransport` 换成真实链路实现 3. **传输实现替换**:把示例的 `LoggingTransport` 换成真实链路实现
(`ICommandTransport` 注入),打包与控制律零改动; (`ICommandTransport` 注入),打包与控制律零改动;
4. **雷达关联标志来源**:`associated_to_target` 必须由感知融合层按 4. **距离有效标志来源**:`distance_valid` 由双目链路(StereoRanger)
规格 §6.3 / 需求分析 FR-041 的判据给出,控制律不自行关联。 给出——距离来自目标检测框自身,控制律不自行判定;多目标场景下
调用方填 `ControlSnapshot` 时应校验 `StereoTargetDistance.track_id`
与 `TrackResult.track_id` 一致(v1.0 单目标为调用方约定,纪要 S3,
库内不加代码)。
### 6.4 红线声明 ### 6.4 红线声明
+11 -17
View File
@@ -9,7 +9,7 @@ PX4 SITL Offboard 视觉跟踪集成测试 (MAVSDK-Python)
适用于 PX4 1.17 适用于 PX4 1.17
YOLO 检测未接入:VirtualTarget 用无人机真实位置/航向做针孔反投影生成 YOLO 检测未接入:VirtualTarget 用无人机真实位置/航向做针孔反投影生成
检测框与激光测距(几何一致闭环),无人机会实际飞向该虚拟目标。 检测框与双目目标距离(几何一致闭环),无人机会实际飞向该虚拟目标。
""" """
import asyncio import asyncio
@@ -116,7 +116,7 @@ TRACK_NORMAL_SECS = 20.0 # 虚拟目标保持可见时长(之后
TRACK_BRAKE_SECS = 5.0 # 判丢后零速刹车时长 TRACK_BRAKE_SECS = 5.0 # 判丢后零速刹车时长
TRACK_TIMEOUT_SECS = 120.0 # 跟踪段整体安全超时 TRACK_TIMEOUT_SECS = 120.0 # 跟踪段整体安全超时
STATUS_EVERY_TICKS = 10 # 每 0.5 s 打印一行状态 STATUS_EVERY_TICKS = 10 # 每 0.5 s 打印一行状态
PLOT_TRAJECTORY = False # 跟踪结束后绘制无人机与虚拟目标轨迹 PLOT_TRAJECTORY = True # 跟踪结束后绘制无人机与虚拟目标轨迹
TRAJECTORY_SAVE_PATH = Path(__file__).resolve().parent / "trajectory_plot.png" TRAJECTORY_SAVE_PATH = Path(__file__).resolve().parent / "trajectory_plot.png"
# 虚拟目标:相对悬停锚点、按锚点机头朝向放置的机体系偏移 # 虚拟目标:相对悬停锚点、按锚点机头朝向放置的机体系偏移
@@ -219,7 +219,7 @@ def plot_trajectories(
class VirtualTarget: class VirtualTarget:
"""几何一致虚拟目标 —— YOLO 检测与激光雷达的替身。 """几何一致虚拟目标 —— YOLO 检测与双目测距的替身。
目标初始位置: 目标初始位置:
以起飞后的悬停锚点为参考, 以起飞后的悬停锚点为参考,
@@ -387,7 +387,7 @@ class VirtualTarget:
): ):
"""返回: """返回:
(detections, radar_distance_m, associated) (detections, target_distance_m, distance_valid)
不可见时: 不可见时:
@@ -487,16 +487,10 @@ class VirtualTarget:
) )
# ============================================================ # ============================================================
# 9. 计算模拟激光雷达距离 # 9. 模拟双目目标距离(前向分量 Z,与检测框几何一致)
# ============================================================ # ============================================================
slant = math.sqrt( return [box], fwd, True
fwd * fwd
+ right * right
+ rel_d * rel_d
)
return [box], slant, True
class TelemetryHub: class TelemetryHub:
@@ -616,7 +610,7 @@ async def run_tracking(drone, anchor_ned: tuple[float, float, float],
target.set_visible(False) target.set_visible(False)
print("-- 模拟目标消失,追踪器进入 Coast 预测") print("-- 模拟目标消失,追踪器进入 Coast 预测")
detections, radar_m, associated = target.observe( detections, distance_m, distance_valid = target.observe(
hub.north_m, hub.east_m, hub.down_m, hub.yaw_rad) hub.north_m, hub.east_m, hub.down_m, hub.yaw_rad)
# 记录本拍位置(目标位置在 observe 内已 update) # 记录本拍位置(目标位置在 observe 内已 update)
@@ -628,9 +622,9 @@ async def run_tracking(drone, anchor_ned: tuple[float, float, float],
snap = ControlSnapshot( snap = ControlSnapshot(
track=track_result_to_state(result), track=track_result_to_state(result),
track_time_ms=now, # 每拍取样即打戳(视觉始终新鲜) track_time_ms=now, # 每拍取样即打戳(视觉始终新鲜)
radar_distance_m=radar_m, target_distance_m=distance_m,
associated_to_target=associated, distance_valid=distance_valid,
radar_time_ms=now if associated else 0, distance_time_ms=now,
attitude_present=True, attitude_present=True,
attitude_time_ms=hub.attitude_time_ms, attitude_time_ms=hub.attitude_time_ms,
yaw_rad=hub.yaw_rad, yaw_rad=hub.yaw_rad,
@@ -644,7 +638,7 @@ async def run_tracking(drone, anchor_ned: tuple[float, float, float],
print(f"[跟踪 t={t_s:5.1f}s] " print(f"[跟踪 t={t_s:5.1f}s] "
f"检测={len(detections)} " f"检测={len(detections)} "
f"追踪[trk={result.tracked} pred={result.is_predicted} " f"追踪[trk={result.tracked} pred={result.is_predicted} "
f"lost={result.lost_frames}] 雷达={radar_m:5.1f}m | " f"lost={result.lost_frames}] 距离={distance_m:5.1f}m | "
f"[{out.mode.name}] vx_body={out.vx_body_mps:+.2f} " f"[{out.mode.name}] vx_body={out.vx_body_mps:+.2f} "
f"vz={out.vz_mps:+.2f} yaw_rate={out.yaw_rate_dps:+.2f}°/s " f"vz={out.vz_mps:+.2f} yaw_rate={out.yaw_rate_dps:+.2f}°/s "
f"coast={out.coast_active}") f"coast={out.coast_active}")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 109 KiB

+27 -9
View File
@@ -1,9 +1,12 @@
# utils —— reference-c++ 两个子项目的 Python 移植 # utils —— reference-c++ 的 Python 移植 + 双目测距新实现
将 `reference-c++/` 下的 `target_tracker`(多目标追踪过滤器)与 将 `reference-c++/` 下的 `target_tracker`(多目标追踪过滤器)与
`visual_track_control`(视觉跟踪控制律)移植为 Python,**两包互不依赖** `visual_track_control`(视觉跟踪控制律)移植为 Python,**两包互不依赖**
(唯一的数据转换点在胶水层 demo)。 (唯一的数据转换点在胶水层 demo)。
另有 `stereo_ranger`(双目稀疏目标测距):**按规格新实现**(非 C++ 移植),
规格 `docs/双目测距技术路线.md`;同样与既有两包互不依赖。
## 目录结构 ## 目录结构
``` ```
@@ -12,19 +15,24 @@ utils/
│ └── README.md # 接口清单 + C++ 差异 │ └── README.md # 接口清单 + C++ 差异
├── visual_track_control/ # 包 2:三通道控制律 + MAVSDK offboard 网关 ├── visual_track_control/ # 包 2:三通道控制律 + MAVSDK offboard 网关
│ └── README.md # 接口清单 + C++ 差异 │ └── README.md # 接口清单 + C++ 差异
├── stereo_ranger/ # 包 3:双目稀疏目标测距(纯标准库,无第三方依赖)
│ └── README.md # 接口清单 + 与规格的偏差
├── demo_track_control.py # 胶水层集成演示(模拟检测 → 追踪 → 控制律 → 发送) ├── demo_track_control.py # 胶水层集成演示(模拟检测 → 追踪 → 控制律 → 发送)
├── demo_stereo_ranger.py # 双目测距演示(合成框对,六场景,无硬件)
├── config/track_control.json # 控制律配置(拷贝自 reference-c++) ├── config/track_control.json # 控制律配置(拷贝自 reference-c++)
├── tests/ # pytest 测试(对应 C++ 全部 13 个测试文件) ├── config/stereo_ranger.json # 双目测距配置(占位值,待标定回填)
│ ├── target_tracker/ # 27 条 ├── tests/ # pytest 测试
│ └── visual_track_control/ # 78 条 │ ├── target_tracker/ # 28 条(对应 C++ 测试文件)
├── conftest.py # 保证可从任意工作目录导入两包 │ ├── visual_track_control/ # 81 条(对应 C++ 测试文件)
│ └── stereo_ranger/ # 111 条(规格 §11.4 构件级 + 全流程)
├── conftest.py # 保证可从任意工作目录导入三包
└── pytest.ini # 收集 *_test.py 命名(与 C++ 测试文件名对齐) └── pytest.ini # 收集 *_test.py 命名(与 C++ 测试文件名对齐)
``` ```
## 环境 ## 环境
conda env `pytorch`(`D:\program file\conda\envs\pytorch`),Python 3.12, 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 "D:/program file/conda/envs/pytorch/python.exe" -m pytest -q
``` ```
105 条测试全部通过(含 C++ 验收用例的逐条移植:§9.4 六条硬验收、A1/A2/A4 220 条测试全部通过:109 条 C++ 移植用例(含 §9.4 六条硬验收、A1/A2/A4
回归、PID anti-windup C1、超龄 C4、首拍限幅 S6)。 回归、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) # MAVSDK 实发(需飞控/SITL 在 udp://:14540)
python utils/demo_track_control.py --transport mavsdk 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。 目标消失 4 s(Coast 预测,60 帧后轨道判亡)→ 判零速刹车 3 s。
双目演示六场景:逼近(三道有效性门 + 滤波)→ 远场域外(方位仍发布)→
单目退化 → 非同帧拒收 → 一致性降级/判无效 → 多目标贪心配对。
## 移植约定 ## 移植约定
- MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支 - MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支
@@ -56,3 +71,6 @@ python utils/demo_track_control.py --transport mavsdk
- 测试逐条移植 C++ gtest 断言(`EXPECT_DOUBLE_EQ` → 精确 `==`, - 测试逐条移植 C++ gtest 断言(`EXPECT_DOUBLE_EQ` → 精确 `==`,
`EXPECT_NEAR` → `pytest.approx`);MAVSDK 部分因无 SITL/实机,仅 mock 验证。 `EXPECT_NEAR` → `pytest.approx`);MAVSDK 部分因无 SITL/实机,仅 mock 验证。
- 行为修复项(C1/C2/C4、A1/A2/A4、S6)在测试文件头注释中标注对应章节。 - 行为修复项(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": { "control": {
"frequency_hz": 20.0, "frequency_hz": 20.0,
"visual_stale_ms": 200, "visual_stale_ms": 200,
"radar_stale_ms": 500, "distance_stale_ms": 500,
"attitude_stale_ms": 300 "attitude_stale_ms": 300
}, },
@@ -36,8 +36,9 @@
"derivative_filter_coef": 0.2, "derivative_filter_coef": 0.2,
"approach_velocity_limit_mps": 3.0, "approach_velocity_limit_mps": 3.0,
"retreat_velocity_limit_mps": 1.0, "retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold", "no_distance_action": "slow_approach",
"no_distance_approach_limit_mps": 0.5 "no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000
}, },
"accel_limit": { "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 实发) → CommandSender(mock 记录 / MAVSDK offboard 实发)
场景时间线(20 Hz,--speedup 可加速): 场景时间线(20 Hz,--speedup 可加速):
0~ 6s 正常跟踪:目标匀速横移 + 雷达距离 30→10 m 前向逼近 0~ 6s 正常跟踪:目标匀速横移 + 双目距离 30→10 m 前向逼近
6~10s 目标消失:追踪器 Coast 预测(60 帧 = 3 s 后轨道判丢, 6~10s 目标消失:追踪器 Coast 预测(60 帧 = 3 s 后轨道判丢,
控制律自动切换零速刹车) 控制律自动切换零速刹车)
10~13s 持续判丢:维持零速刹车 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)] 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): def simulate_distance(tick: int, normal_ticks: int, period_s: float):
"""场景雷达源:正常阶段关联成立、距离 30→10 m;之后未关联。""" """场景双目距离源:正常阶段有效、距离 30→10 m;之后无效。"""
if tick >= normal_ticks: if tick >= normal_ticks:
return 0.0, False return 0.0, False
distance = max(10.0, 30.0 - tick * period_s * 3.0) 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): for tick in range(total_ticks):
now_ms = 1000 + tick * period_ms now_ms = 1000 + tick * period_ms
detections = simulate_detections(tick, normal_ticks, period_s) 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) result = tracker.update(detections)
snap = ControlSnapshot( snap = ControlSnapshot(
track=track_result_to_state(result), track=track_result_to_state(result),
track_time_ms=now_ms, # 每拍取样即打戳(视觉始终新鲜) track_time_ms=now_ms, # 每拍取样即打戳(视觉始终新鲜)
radar_distance_m=radar_m, target_distance_m=distance_m,
associated_to_target=associated, distance_valid=distance_valid,
radar_time_ms=now_ms if associated else 0, distance_time_ms=now_ms,
attitude_present=True, attitude_present=True,
attitude_time_ms=now_ms, # 模拟姿态源持续可用 attitude_time_ms=now_ms, # 模拟姿态源持续可用
yaw_rad=0.0, # 机头朝北(NED 旋转恒等) 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,左上/右下角) | | `StereoRanger(config=None, matcher=None)` | 主入口;`matcher` 可注入自定义匹配器 |
| `StereoConfig(...)` | 配置(默认值 = 规格示例值,见下) | | `.update(left, right, now_ms=0) -> List[RangeRecord]` | 单周期处理(§5 五步),纯计算、无 IO |
| `load_from_json(path)` | 从 JSON 构建配置(缺字段/未知字段/非法值抛 `ValueError`) | | `.reset()` | 清空全部运行期状态(滤波/一致性/隔离/节流计数) |
| `StereoRanger(config=None)` | 测距器主类 | | `.config` / `.matcher` | 只读访问(配置与匹配器) |
| `.update(left_dets, right_dets, frame_sequence=0, timestamp_s=None) -> list[RangeReport]` | 单帧主流程(返回记录数与左目检测数一致、顺序一致) | | `StereoConfig` / `load_from_json(path)` | 17 项配置;缺项/未知字段/类型错误/非法取值一律 `ValueError`(fail-fast) |
| `.reset()` / `.config` | 清空滤波状态 / 当前配置 | | `.focal_baseline_px_m` / `.z_min_m` / `.z_max_m` / `.z_error_budget_max_m` | 派生只读量(F·B 常量、视差域互推的距离域、误差预算远界) |
| `RangeReport` | 输出记录(字段见 `types.py`) | | `Detection(x1,y1,x2,y2, confidence, class_id, track_id)` | 单框(校正后坐标系;含 `cx/cy/width/height/bbox` 属性) |
| `focal_from_hfov(w_px, hfov_deg)` / `range_error_abs(z, fx, B, δD=1)` / `range_error_rel(...)` | 工具函数(§5.2/§5.3) | | `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、同缩放),由检测前端保证; ## 单帧流程(`update` 五步)
2. 传入本模块的框必须已映射回**原生分辨率**(视差在原生坐标计算),内参 `fx/fy/cx/cy` 亦对应原生分辨率;
3. 图像对须已完成立体校正(同一目标在两目 y 坐标一致);校正残留由 `vertical_tau_px` 容错;
4. YOLO 推理及左右目并行执行由调用方负责,本模块只收检测结果。
## 单帧流程(对照规格) 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`:有效测距; 规格 `docs/双目测距技术路线.md` 已按下列决策修订(§4/§6.1/§6.2/§6.4/§6.7/
- `matched=True, valid=False`:已配对但超出有效工作域(§5.4),距离/坐标仍发布供诊断,方位可用; §9/§11 均已更新),本节保留决策动机与实现落点,供重写/移植核对。
- `matched=False, valid=False`:左目检测本帧未配对(§6 失败处理),仅单目方位回退。
方位源 `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 | 校正后内参(原生分辨率) | | 连续异常计数 ×目标 | `ConsistencyMonitor` | 0 | 每帧异常 +1 / 正常帧清零 |
| `baseline_m` | 0.06 | 基线 B | | 上次滤波值、初始化标志、连续失效计数 ×目标 | `DistanceFilter` | 无 / false / 0 | 有效帧写入 / 首有效帧置位 / 无效帧 +1 |
| `z_min_m / z_max_m` | 1.0 / 50.0 | 距离窗口(派生视差关联区间) | | 最近位置、最近出现帧 ×目标 | `TargetStore` | 首帧写入 | 每次 `resolve` |
| `vertical_tau_px` / `height_ratio_tau` | 2.0 / 0.25 | 关联阈值 τy / τh | | 帧序号 | `StereoRanger` | 0 | 每帧 +1(拒收帧不加) |
| `disparity_min_px` / `range_tol_m` | 3.0 / 2.0 | 有效性判据 D_min / Z_tol | | 告警计数 ×异常类 | `WarnThrottle` | 0 | 每次异常 |
| `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 | 诊断日志节流间隔 |
## 运行 ## 测试与演示
```bash ```bash
cd utils cd utils
"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q # 全部测试(149 条) "D:/program file/conda/envs/pytorch/python.exe" -m pytest tests/stereo_ranger -q # 111 条
"D:/program file/conda/envs/pytorch/python.exe" demo_stereo_ranger.py # 合成场景 demo "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/检测器及其预处理(由调用方按上文约定接入); - 匹配:硬约束各一例、`cost ≥ 1` 拒绝、贪心排序、一一配对;
- 亚像素边缘精化、跨帧跟踪(`track_id` 由外部追踪器提供后,时间滤波即可用)、与主测距传感器融合(§5.5/§11)。 - 测距:`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 .config import StereoConfig, load_from_json
from .stereo_ranger import ( from .consistency import ConsistencyMonitor, ConsistencyStatus
DISP_SOURCE_CENTER, from .distance_filter import DistanceFilter
DISP_SOURCE_EDGE, from .geometry import (
StereoRanger, DELTA_D_PX,
focal_from_hfov, RangeSolution,
range_error_abs, ValidityDecision,
confidence_from_cost,
evaluate_validity,
forward_distance_m,
range_error_abs_m,
range_error_rel, 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__ = [ __all__ = [
"BBoxXYXY", "BBoxXYXY",
"DISP_SOURCE_CENTER", "DELTA_D_PX",
"DISP_SOURCE_EDGE", "ConsistencyMonitor",
"ConsistencyStatus",
"Degradation",
"Detection", "Detection",
"RangeReport", "DetectionFrame",
"Disparity",
"DisparitySource",
"DistanceFilter",
"Eye",
"GreedyMatcher",
"MatchPair",
"Matcher",
"RangeRecord",
"RangeSolution",
"SamplePoint",
"StereoConfig", "StereoConfig",
"StereoRanger", "StereoRanger",
"focal_from_hfov", "TargetStore",
"ValidityDecision",
"WarnThrottle",
"confidence_from_cost",
"evaluate_validity",
"forward_distance_m",
"load_from_json", "load_from_json",
"range_error_abs", "range_error_abs_m",
"range_error_rel", "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 json
import math
from dataclasses import dataclass, fields from dataclasses import dataclass, fields
from pathlib import Path from typing import Any, Dict
from typing import Any, Dict, Tuple
# JSON 加载时的整数型字段(其余按数值校验)
_INT_FIELDS = frozenset({"consistency_N_frames", "consistency_M_frames", "filter_reset_frames"})
@dataclass @dataclass
class StereoConfig: class StereoConfig:
# ---- 相机参数(校正后、对应原生分辨率,§3.1 / §4.3)---- # ---- 几何组(来自标定 P1/P2,§9.1;⚠全部为占位值)----
fx_px: float = 960.0 fx_px: float = 960.0
fy_px: float = 960.0 fy_px: float = 960.0
cx_px: float = 960.0 cx_px: float = 960.0
cy_px: float = 540.0 cy_px: float = 540.0
baseline_m: float = 0.06 # 基线 B baseline_m: float = 0.06
# ---- 目标距离窗口:关联硬约束(§6),派生视差区间 [FB/z_max, FB/z_min] ---- # ---- 匹配组(§9.2)----
z_min_m: float = 1.0 tau_y_px: float = 2.0
z_max_m: float = 50.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)---- # ---- 有效性组(§9.3)----
vertical_tau_px: float = 2.0 # 垂直残差 tau_y max_delta_z_m: float = 2.0 # 误差预算 δZ(δD = 1px) 上限
height_ratio_tau: float = 0.25 # 相对高度差 tau_h consistency_N_frames: int = 3 # 连续跳变降置信度的帧数
consistency_M_frames: int = 5 # 连续异常判 invalid 的帧数
consistency_jump_px: float = 3.0 # ⚠补充项:§9 未列,§6.7 的视差跳变门限
# ---- 视差取点与有效性(§5)---- # ---- 滤波组(§9.4)----
disparity_min_px: float = 3.0 # D_min:D 低于此值直接无效 smooth_alpha: float = 0.3
range_tol_m: float = 2.0 # Z_tol:δZ <= Z_tol 才算有效 jump_reset_m: float = 5.0
disp_noise_px: float = 1.0 # δD(误差模型中的边缘定位噪声) filter_reset_frames: int = 5
use_center_fallback: bool = True # 左边缘视差越窗时回退中心(§5.1)
# ---- 可选时间滤波(§7,一阶低通 + 跳变重置)---- # ---- 目标隔离(§4:类别 + 框位置最近邻;⚠补充项)----
filter_enabled: bool = False isolation_gate_px: float = 50.0
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
def __post_init__(self) -> None: def __post_init__(self) -> None:
_require(self.fx_px > 0.0, "fx_px", "必须为正") _require(self.fx_px > 0.0, "fx_px", "必须为正")
@@ -52,31 +53,71 @@ class StereoConfig:
_require(self.cx_px >= 0.0, "cx_px", "必须非负") _require(self.cx_px >= 0.0, "cx_px", "必须非负")
_require(self.cy_px >= 0.0, "cy_px", "必须非负") _require(self.cy_px >= 0.0, "cy_px", "必须非负")
_require(self.baseline_m > 0.0, "baseline_m", "必须为正") _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.tau_y_px > 0.0, "tau_y_px", "必须为正")
_require(self.vertical_tau_px > 0.0, "vertical_tau_px", "必须为正") _require(self.tau_h_ratio > 0.0, "tau_h_ratio", "必须为正")
_require(self.height_ratio_tau > 0.0, "height_ratio_tau", "必须为正") _require(
_require(self.disparity_min_px > 0.0, "disparity_min_px", "必须为正") 0.0 < self.disparity_min_px < self.disparity_max_px,
_require(self.range_tol_m > 0.0, "range_tol_m", "必须为正") "disparity_min_px/disparity_max_px",
_require(self.disp_noise_px > 0.0, "disp_noise_px", "必须为正") "须满足 0 < 下限 < 上限",
_require(0.0 < self.filter_alpha <= 1.0, "filter_alpha", "须落在 (0, 1]") )
_require(self.filter_jump_ratio >= 0.0, "filter_jump_ratio", "必须非负") _require(self.max_delta_z_m > 0.0, "max_delta_z_m", "必须为正")
_require(self.warn_interval_s >= 0.0, "warn_interval_s", "必须非负") _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: def load_from_json(path: str) -> StereoConfig:
"""从 JSON 文件构建配置。字段缺失或非法取值抛 ValueError(携带字段名)。""" """从 JSON 文件构建配置;缺项/未知字段/类型错误/非法取值一律抛 ValueError。"""
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as handle:
data: Dict[str, Any] = json.load(f) data: Dict[str, Any] = json.load(handle)
known = {field.name for field in fields(StereoConfig)} if not isinstance(data, dict):
unknown = set(data) - known raise ValueError("配置非法: 顶层须为 JSON 对象")
known = {item.name for item in fields(StereoConfig)}
unknown = sorted(set(data) - known)
if unknown: if unknown:
raise ValueError(f"未知配置字段: {sorted(unknown)}") raise ValueError(f"未知配置字段: {unknown}")
missing = known - set(data) missing = sorted(known - set(data))
if missing: 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) return StereoConfig(**data)
def _require(ok: bool, field_name: str, message: str) -> None: def _require(condition: bool, field_name: str, message: str) -> None:
if not ok: if not condition:
raise ValueError(f"配置非法: {field_name} {message}") 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)。
输入为左右目各自、校正后原生坐标系下的检测框列表;不做目标识别,不做 单周期五步(§5):① 同帧契约检查 → ② 取点 → ③ 左右匹配 →
图像采集/立体校正。单帧主流程:关联(§6)→ 视差取点(§5.1)→ ④ 配对目标:测距 + 方位源 + 有效性 + 滤波 → ⑤ 单目退化发布。
Z=F·B/D 与 (X, Y, R)(§4.2)→ 有效性(§5.4)→ 可选时间滤波(§7)。
依赖仅为标准库。 输入为左右目各自、校正后原生坐标系下的检测框;不做目标识别,不做图像
采集/立体校正。纯计算单元(§11.3):单周期处理无阻塞、无 IO;同一实例
不得并发调用 update()。
""" """
import logging import logging
import math from typing import List, Optional, Sequence
import time
from typing import Dict, List, Optional, Sequence, Tuple
from .config import StereoConfig 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") logger = logging.getLogger("stereo_ranger")
DISP_SOURCE_EDGE = "left_edge" _CONFIDENCE_DEGRADE_FACTOR = 0.5 # ⚠占位:连续跳变 ≥ N 帧的置信度降级系数(§6.4)
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)
class StereoRanger: class StereoRanger:
"""双目稀疏目标测距器。 """双目稀疏目标测距器:每帧调用一次 update(),传入同帧左右目检测结果。"""
用法:每帧调用一次 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()
def __init__(self, config: Optional[StereoConfig] = None): self._store = TargetStore(self._config)
self._cfg = config if config is not None else StereoConfig() self._warn = WarnThrottle()
self._z_state: Dict[Tuple[int, int], float] = {} # (cls_id, track_id) -> 滤波 Z self._frame_index = 0
self._warn_at: Dict[str, float] = {}
@property @property
def config(self) -> StereoConfig: def config(self) -> StereoConfig:
return self._cfg return self._config
@property
def matcher(self) -> Matcher:
return self._matcher
def reset(self) -> None: def reset(self) -> None:
self._z_state.clear() """清空全部运行期状态(滤波 / 一致性 / 隔离 / 节流计数)。"""
self._warn_at.clear() self._store.clear()
self._warn.clear()
self._frame_index = 0
def update( def update(self, left: DetectionFrame, right: DetectionFrame, now_ms: int = 0) -> List[RangeRecord]:
self, """单周期处理(§5):返回本帧记录列表。
left: Sequence[Detection],
right: Sequence[Detection],
frame_sequence: int = 0,
timestamp_s: Optional[float] = None,
) -> List[RangeReport]:
"""单帧主流程:返回报告列表(每个左目检测一条,保持左目输入顺序)。
配对成功且测距有效的报告 valid=True;配对成功但超出有效工作域的 顺序:先按左目框顺序给出各自记录(配对→完整记录 / 未配对→仅方位),
valid=False(距离仍给出,§5.4);未配对的左目检测 matched=False, 再给出未配对的右目框的仅方位记录;左目存在时记录数与左目框数一致。
仅发布单目方位(§6 失败处理)。
""" """
pairs = self._match(left, right) config = self._config
matched_r: Dict[int, int] = {l_idx: r_idx for l_idx, r_idx, _ in pairs} if left.frame_id != right.frame_id:
reports: List[RangeReport] = [] self._warn_emit(
for l_idx, det_l in enumerate(left): "frame_mismatch",
r_idx = matched_r.get(l_idx) "左右帧标识不一致(L=%s, R=%s),拒收本帧",
if r_idx is None: left.frame_id,
reports.append(self._unmatched_report(det_l, frame_sequence, timestamp_s)) 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 continue
report = self._build_report(det_l, right[r_idx], frame_sequence, timestamp_s) records.append(
reports.append(report) self._complete_record(
return reports 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]]: def _complete_record(
"""硬约束过滤 + 代价升序贪心一一配对(§6)。返回 (左下标, 右下标, 左框)。""" self,
cfg = self._cfg point_l: SamplePoint,
d_lo, d_hi = cfg.disparity_bounds_px() point_r: SamplePoint,
candidates: List[Tuple[int, int, Detection, float]] = [] pair: MatchPair,
for i, det_l in enumerate(left): frame_id: int,
for j, det_r in enumerate(right): now_ms: int,
if det_l.cls_id != det_r.cls_id: frame_index: int,
continue ) -> RangeRecord:
dy = abs(det_l.cy - det_r.cy) """配对成功的记录:测距 + 方位源 + 有效性(门①②)+ 一致性(门③)+ 滤波。"""
if dy > cfg.vertical_tau_px: config = self._config
continue box_l = point_l.box
h_max = max(det_l.height, det_r.height) box_r = point_r.box
if h_max <= 0.0: disparity = select_disparity(point_l, point_r, config)
continue center_mid = virtual_center_px(box_l, box_r)
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 _build_report(self, det_l: Detection, det_r: Detection, seq: int, ts: Optional[float]) -> RangeReport: if disparity.fallback_used:
cfg = self._cfg self._warn_emit("center_fallback", "边缘视差越窗,回退框中心(框宽差可能混入视差)")
d_edge = det_l.x1 - det_r.x1 if not disparity.positive:
d_lo, d_hi = cfg.disparity_bounds_px() self._warn_emit("disparity_violation", "视差违例 D=%.2f px(须为正),该目标判无效", disparity.d_px)
if d_lo <= d_edge <= d_hi: elif not disparity.in_domain:
d = d_edge self._warn_emit(
source = DISP_SOURCE_EDGE "disparity_violation",
else: "视差超界 D=%.2f px(域 [%.2f, %.2f]),该目标判无效",
d = det_l.cx - det_r.cx disparity.d_px,
source = DISP_SOURCE_CENTER config.disparity_min_px,
z = cfg.fx_px * cfg.baseline_m / d config.disparity_max_px,
report = RangeReport( )
frame_sequence=seq,
timestamp_s=ts, key = self._store.resolve(
class_id=det_l.cls_id, box_l.class_id,
track_id=det_l.track_id if det_l.track_id != 0 else det_r.track_id, _shared_track_id(box_l, box_r),
matched=True, center_mid,
valid=False, # §5.4 判定见下 frame_index,
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),
) )
err = range_error_abs(z, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px) status = self._store.consistency.advance(key, disparity.d_px)
if d >= cfg.disparity_min_px and err <= cfg.range_tol_m: if status.invalid:
report.valid = True self._warn_emit(
z_out = self._filter_z(report.class_id, report.track_id, z) "consistency_invalid",
report.forward_distance_m = z_out "连续 %d 帧视差跳变超门限(%.1f px),质量判无效",
report.error_est_m = range_error_abs(z_out, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_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: else:
report.error_est_m = err self._store.distance_filter.invalidate(key)
self._fill_xyz(report, det_l.cx) z_out = None
return report
def _fill_xyz(self, report: RangeReport, x_l_px: float) -> None: # 参考斜距与已发布的前向距离同基准:X/Y/Z 均 ∝ Z,按 z_out/z_raw 缩放即精确换基
"""由 Z 与左目(参考目)框中心像素回算左目系 X, Y, R(§4.2)。 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), return RangeRecord(
而 X 定义在左目坐标系,二者相差 B/2,不得混用。 frame_id=frame_id,
""" class_id=box_l.class_id,
cfg = self._cfg degradation=Degradation.COMPLETE,
z = report.forward_distance_m valid=valid,
x = (x_l_px - cfg.cx_px) * z / cfg.fx_px track_id=box_l.track_id if box_l.track_id is not None else box_r.track_id,
y = (report.centre_pixel_y_mid - cfg.cy_px) * z / cfg.fy_px forward_distance_m=z_out,
report.lateral_m = x slant_range_m=slant_range_m,
report.vertical_m = y disparity_px=disparity.d_px,
report.slant_range_m = math.sqrt(x * x + y * y + z * z) 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: def _bearing_only_record(
"""可选一阶低通 + 跳变重置(§7)。track_id 为 0(无轨迹)时不过滤。""" self,
cfg = self._cfg point: SamplePoint,
if not cfg.filter_enabled or track_id == 0: frame_id: int,
return z now_ms: int,
key = (cls_id, track_id) eye: Eye,
prev = self._z_state.get(key) ) -> RangeRecord:
if prev is None: """单目退化记录(§5 第 ⑤ 步):仅方位,valid = false,无距离。"""
self._z_state[key] = z detection = point.box
return z return RangeRecord(
if abs(z - prev) / prev > cfg.filter_jump_ratio: frame_id=frame_id,
self._z_state[key] = z # 跳变:重置 class_id=detection.class_id,
return z degradation=Degradation.BEARING_ONLY,
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,
valid=False, valid=False,
confidence=det.conf, track_id=detection.track_id,
centre_pixel_x_mid=det.cx, center_mid_px=(detection.cx, detection.cy),
centre_pixel_y_mid=det.cy, reference_box=detection.bbox,
ref_bbox=(det.x1, det.y1, det.x2, det.y2), reference_eye=eye,
confidence=detection.confidence,
timestamp_ms=now_ms,
) )
def _warn(self, category: str, message: str) -> None: def _drop_invalid(self, points: Sequence[SamplePoint]) -> List[SamplePoint]:
"""节流诊断(§7:失败日志须节流)。""" """丢弃坐标非有限值的框(不可参与匹配,也不发布记录),告警节流。"""
now = time.monotonic() kept: List[SamplePoint] = []
last = self._warn_at.get(category) for point in points:
if last is not None and now - last < self._cfg.warn_interval_s: 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 return
self._warn_at[category] = now logger.warning("[%s] " + message + "(累计 %d 次)", category, *args, self._warn.count(category))
logger.warning("[%s] %s", category, message)
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 dataclasses import dataclass, field
from typing import Optional, Tuple from enum import Enum, auto
from typing import Optional, Sequence, Tuple
BBoxXYXY = Tuple[float, float, float, float] 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 @dataclass
class Detection: class Detection:
"""校正后原生坐标系下的一个检测框(单位 px)。 """校正后图像坐标系下的一个检测框(px;x1/y1 左上角,x2/y2 右下角)。"""
调用方负责:左右目预处理几何完全一致,且框坐标已映射回原生分辨率
(规格 §4.3)。x1/y1 为左上角,x2/y2 为右下角(x1 即"左边缘")。
"""
x1: float x1: float
y1: float y1: float
x2: float x2: float
y2: float y2: float
conf: float = 1.0 confidence: float = 1.0
cls_id: int = 0 class_id: int = 0
track_id: int = 0 # 未知为 0 track_id: Optional[int] = None # 检测侧轨道关联;None = 无(§2.2,v1.0 可无)
@property @property
def width(self) -> float: def width(self) -> float:
@@ -41,31 +62,46 @@ class Detection:
def cy(self) -> float: def cy(self) -> float:
return (self.y1 + self.y2) / 2.0 return (self.y1 + self.y2) / 2.0
@property
def bbox(self) -> BBoxXYXY:
return (self.x1, self.y1, self.x2, self.y2)
@dataclass @dataclass
class RangeReport: class DetectionFrame:
"""单目标单帧测距结果(一个左目检测对应一条记录)。 """单目一帧:帧标识 + 检测框列表。
matched=False 表示该左目检测本帧未配对:valid 恒为 False,距离字段为 同帧契约(§2.1):左右两帧的 frame_id 必须一致,否则整帧拒收、输出空。
None,centre_pixel_x/y_mid 为单目自身中心(方位回退,规格 §11.3)。
matched=True 且 valid=False 表示已配对但距离超出有效工作域
(规格 §5.4):距离字段仍给出估计值供诊断,方位照常发布。
""" """
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 class_id: int
track_id: int degradation: Degradation
matched: bool
valid: bool valid: bool
forward_distance_m: Optional[float] = None # Z,前向分量(主输出) track_id: Optional[int] = None
lateral_m: Optional[float] = None # X,左目系横向偏移 forward_distance_m: Optional[float] = None
vertical_m: Optional[float] = None # Y,左目系纵向偏移 slant_range_m: Optional[float] = None
slant_range_m: Optional[float] = None # R = sqrt(X^2 + Y^2 + Z^2) disparity_px: Optional[float] = None
disparity_px: Optional[float] = None # D = xL - xR(>0 为前方) center_mid_px: Tuple[float, float] = (0.0, 0.0)
disparity_source: Optional[str] = None # "left_edge" / "center" reference_box: Optional[BBoxXYXY] = None
reference_eye: Eye = Eye.LEFT
confidence: float = 0.0 confidence: float = 0.0
error_est_m: Optional[float] = None # δZ 估计(δD = 配置 disp_noise_px) timestamp_ms: int = 0
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
+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。 移植自 reference-c++/visual_track_control/tests/tracking_control_law_test.cpp。
覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉 覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉
超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向 超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道三种 通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道无距离
无距离降级动作、首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。 降级动作(含 EXIT 门限/grace 重置/零门限立即退出)、距离超龄回落、首拍变化率
限幅(S6)、机体系→NED 输出旋转(A4)。
""" """
import math import math
@@ -26,7 +27,7 @@ def make_config() -> TrackingConfig:
cfg = TrackingConfig() cfg = TrackingConfig()
cfg.control.frequency_hz = 20.0 cfg.control.frequency_hz = 20.0
cfg.control.visual_stale_ms = 200 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.control.attitude_stale_ms = 300
cfg.camera.image_width = 1000 cfg.camera.image_width = 1000
cfg.camera.image_height = 1000 cfg.camera.image_height = 1000
@@ -55,7 +56,7 @@ def make_config() -> TrackingConfig:
def make_snapshot(now_ms: int) -> ControlSnapshot: def make_snapshot(now_ms: int) -> ControlSnapshot:
"""新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜。""" """新鲜快照:目标在画面中心,姿态/追踪/距离均新鲜。"""
return ControlSnapshot( return ControlSnapshot(
track=TrackState(tracked=True, is_predicted=False, track=TrackState(tracked=True, is_predicted=False,
center=(500.0, 500.0)), center=(500.0, 500.0)),
@@ -213,9 +214,9 @@ def test_vertical_velocity_clamped():
def test_distance_approach_when_far(): def test_distance_approach_when_far():
law = TrackingControlLaw(make_config()) law = TrackingControlLaw(make_config())
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3 snap.target_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 1000 snap.distance_time_ms = 1000
out = law.update(snap, 1000) out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(3.0, abs=1e-9) 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(): def test_distance_retreat_clamped_when_too_close():
law = TrackingControlLaw(make_config()) law = TrackingControlLaw(make_config())
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.radar_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1 snap.target_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 1000 snap.distance_time_ms = 1000
out = law.update(snap, 1000) out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(-1.0, abs=1e-9) assert out.vx_mps == pytest.approx(-1.0, abs=1e-9)
def test_no_distance_hold_zeros_vx(): def test_no_distance_hold_zeros_vx():
law = TrackingControlLaw(make_config()) # 缺省 kHold law = TrackingControlLaw(make_config()) # 基准配置 HOLD
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.associated_to_target = False snap.distance_valid = False
out = law.update(snap, 1000) out = law.update(snap, 1000)
assert out.mode is ControlMode.VELOCITY_HEADING assert out.mode is ControlMode.VELOCITY_HEADING
assert out.vx_mps == 0.0 assert out.vx_mps == 0.0
@@ -244,29 +245,79 @@ def test_no_distance_slow_approach():
cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH
law = TrackingControlLaw(cfg) law = TrackingControlLaw(cfg)
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.associated_to_target = False snap.distance_valid = False
out = law.update(snap, 1000) out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(0.5, abs=1e-9) 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 = make_config()
cfg.distance.no_distance_action = NoDistanceAction.EXIT cfg.distance.no_distance_action = NoDistanceAction.EXIT
cfg.distance.no_distance_exit_grace_ms = 200
law = TrackingControlLaw(cfg) law = TrackingControlLaw(cfg)
snap = make_snapshot(1000) 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) out = law.update(snap, 1000)
assert out.mode is ControlMode.DEGRADED_HOLD assert out.mode is ControlMode.DEGRADED_HOLD
assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
assert not out.valid assert not out.valid
def test_stale_radar_falls_back_to_no_distance(): def test_stale_distance_falls_back_to_no_distance():
law = TrackingControlLaw(make_config()) # kHold law = TrackingControlLaw(make_config()) # HOLD
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 snap.target_distance_m = 30.0
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用 snap.distance_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用
out = law.update(snap, 1000) out = law.update(snap, 1000)
assert out.mode is ControlMode.VELOCITY_HEADING assert out.mode is ControlMode.VELOCITY_HEADING
assert out.vx_mps == 0.0 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 cfg.accel_limit.ax_mps2 = 2.0
law = TrackingControlLaw(cfg) law = TrackingControlLaw(cfg)
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 # 通道输出本应为 3.0 snap.target_distance_m = 30.0 # 通道输出本应为 3.0
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 1000 snap.distance_time_ms = 1000
out = law.update(snap, 1000) # 首拍:dt 按 1/20 Hz 兜底 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 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()) law = TrackingControlLaw(make_config())
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.yaw_rad = math.pi / 2.0 # 机头朝东 snap.yaw_rad = math.pi / 2.0 # 机头朝东
snap.radar_distance_m = 30.0 snap.target_distance_m = 30.0
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 1000 snap.distance_time_ms = 1000
out = law.update(snap, 1000) out = law.update(snap, 1000)
# 机体前向 3.0 → 东向 3.0,北向 ≈ 0 # 机体前向 3.0 → 东向 3.0,北向 ≈ 0
assert out.vx_mps == pytest.approx(0.0, abs=1e-9) 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()) law = TrackingControlLaw(make_config())
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.yaw_rad = 0.0 snap.yaw_rad = 0.0
snap.radar_distance_m = 30.0 snap.target_distance_m = 30.0
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 1000 snap.distance_time_ms = 1000
out = law.update(snap, 1000) out = law.update(snap, 1000)
assert out.vx_mps == pytest.approx(3.0, abs=1e-9) assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
assert out.vy_mps == pytest.approx(0.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 cfg.accel_limit.ax_mps2 = 2.0
law = TrackingControlLaw(cfg) law = TrackingControlLaw(cfg)
snap = make_snapshot(1000) snap = make_snapshot(1000)
snap.radar_distance_m = 30.0 snap.target_distance_m = 30.0
snap.associated_to_target = True snap.distance_valid = True
snap.radar_time_ms = 1000 snap.distance_time_ms = 1000
for t in range(1000, 3000, 50): for t in range(1000, 3000, 50):
snap.track_time_ms = t snap.track_time_ms = t
snap.radar_time_ms = t snap.distance_time_ms = t
snap.attitude_time_ms = t snap.attitude_time_ms = t
law.update(snap, t) # 若干拍后 vx 已爬升到限幅 3.0 law.update(snap, t) # 若干拍后 vx 已爬升到限幅 3.0
law.reset() law.reset()
snap.track_time_ms = 3000 snap.track_time_ms = 3000
snap.radar_time_ms = 3000 snap.distance_time_ms = 3000
snap.attitude_time_ms = 3000 snap.attitude_time_ms = 3000
out = law.update(snap, 3000) out = law.update(snap, 3000)
# Reset 后限幅器归零:又从 0.1 开始渐变 # Reset 后限幅器归零:又从 0.1 开始渐变
@@ -15,7 +15,7 @@ from pathlib import Path
import pytest import pytest
from visual_track_control.config import (HeadingMode, NoDistanceAction, from visual_track_control.config import (HeadingMode, NoDistanceAction,
load_from_json) TrackingConfig, load_from_json)
_REPO_ROOT = Path(__file__).resolve().parents[3] _REPO_ROOT = Path(__file__).resolve().parents[3]
REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json" REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json"
@@ -27,7 +27,7 @@ def full_config() -> dict:
"control": { "control": {
"frequency_hz": 20.0, "frequency_hz": 20.0,
"visual_stale_ms": 200, "visual_stale_ms": 200,
"radar_stale_ms": 500, "distance_stale_ms": 500,
"attitude_stale_ms": 300, "attitude_stale_ms": 300,
}, },
"camera": { "camera": {
@@ -57,6 +57,7 @@ def full_config() -> dict:
"retreat_velocity_limit_mps": 1.0, "retreat_velocity_limit_mps": 1.0,
"no_distance_action": "hold", "no_distance_action": "hold",
"no_distance_approach_limit_mps": 0.5, "no_distance_approach_limit_mps": 0.5,
"no_distance_exit_grace_ms": 3000,
}, },
"accel_limit": { "accel_limit": {
"ax_mps2": 2.0, "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_gain_mps_per_deg == 0.1
assert cfg.vertical.vz_limit_mps == 2.0 assert cfg.vertical.vz_limit_mps == 2.0
assert cfg.distance.d_exp_m == 10.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 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: with pytest.raises(ValueError) as exc_info:
load_from_json(path) load_from_json(path)
assert "camera.fov_h_deg" in str(exc_info.value) 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`) | | `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)` | 主控律 | | `TrackingControlLaw(cfg)` | 主控律 |
| `.update(snapshot, now_ms) -> ControlOutput` | 单周期八步流程(姿态/视觉超龄 → 判丢 → Coast → 三通道 → NED 旋转) | | `.update(snapshot, now_ms) -> ControlOutput` | 单周期八步流程(姿态/视觉超龄 → 判丢 → Coast → 三通道 → NED 旋转) |
| `.reset()` | 停止闭环时清全部内部状态 | | `.reset()` | 停止闭环时清全部内部状态 |
+14 -6
View File
@@ -23,18 +23,19 @@ class HeadingMode(Enum):
class NoDistanceAction(Enum): class NoDistanceAction(Enum):
"""雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3)。""" """目标距离不可用(无效/超龄)时的距离通道降级动作(§6.3)。"""
HOLD = "hold" # 保持:前向速度指令清零 HOLD = "hold" # 保持:前向速度指令清零
SLOW_APPROACH = "slow_approach" # 限速接近:按 no_distance_approach_limit_mps 缓速 SLOW_APPROACH = "slow_approach" # 限速接近:按 no_distance_approach_limit_mps 缓速
EXIT = "exit" # 退出近距操作:输出降级保持 EXIT = "exit" # 退出近距操作:持续无距离达 no_distance_exit_grace_ms
# 才降级保持;grace 期间按 SLOW_APPROACH 行为
@dataclass @dataclass
class ControlGroupConfig: class ControlGroupConfig:
frequency_hz: float = 20.0 frequency_hz: float = 20.0
visual_stale_ms: int = 200 visual_stale_ms: int = 200
radar_stale_ms: int = 500 distance_stale_ms: int = 500
attitude_stale_ms: int = 300 attitude_stale_ms: int = 300
@@ -70,8 +71,11 @@ class DistanceGroupConfig:
derivative_filter_coef: float = 0.0 derivative_filter_coef: float = 0.0
approach_velocity_limit_mps: float = 3.0 approach_velocity_limit_mps: float = 3.0
retreat_velocity_limit_mps: float = 1.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_approach_limit_mps: float = 0.5
no_distance_exit_grace_ms: int = 3000 # EXIT 门限:持续无距离达此时长才退出(毫秒,占位)
@dataclass @dataclass
@@ -155,9 +159,9 @@ def _load_control(root: dict) -> ControlGroupConfig:
c.frequency_hz = _require_positive( c.frequency_hz = _require_positive(
_require_number(s, "control", "frequency_hz"), "control.frequency_hz") _require_number(s, "control", "frequency_hz"), "control.frequency_hz")
c.visual_stale_ms = _require_integer(s, "control", "visual_stale_ms") 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") 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 必须为正") raise ValueError("配置非法: control.*_stale_ms 必须为正")
return c return c
@@ -240,6 +244,10 @@ def _load_distance(root: dict) -> DistanceGroupConfig:
d.no_distance_approach_limit_mps = _require_non_negative( d.no_distance_approach_limit_mps = _require_non_negative(
_require_number(s, "distance", "no_distance_approach_limit_mps"), _require_number(s, "distance", "no_distance_approach_limit_mps"),
"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 return d
@@ -10,7 +10,8 @@
④ 像素偏移 → 视线角 β/α(全程针孔模型) ④ 像素偏移 → 视线角 β/α(全程针孔模型)
⑤ 水平航向(速率式/位置式,A2:位置式合成绝对 NED 航向) ⑤ 水平航向(速率式/位置式,A2:位置式合成绝对 NED 航向)
⑥ 垂直统一速度支路(A1:不切姿态微调模式) ⑥ 垂直统一速度支路(A1:不切姿态微调模式)
⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit) ⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit,
exit 需持续无距离达 no_distance_exit_grace_ms 门限)
⑧ 机体系 → 局部 NED 旋转(A4) ⑧ 机体系 → 局部 NED 旋转(A4)
日志纪律与 C++ 版一致:仅状态切换点打印,热路径零日志。 日志纪律与 C++ 版一致:仅状态切换点打印,热路径零日志。
@@ -78,6 +79,10 @@ class TrackingControlLaw:
self._vz_limiter = SlewRateLimiter(cfg.accel_limit.az_mps2) self._vz_limiter = SlewRateLimiter(cfg.accel_limit.az_mps2)
self._last_now_ms = 0 self._last_now_ms = 0
self._last_mode = ControlMode.DEGRADED_HOLD 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: def update(self, snapshot: ControlSnapshot, now_ms: int) -> ControlOutput:
# ---- 节拍推算(§7.1 固定周期;异常节拍用配置频率兜底) ---- # ---- 节拍推算(§7.1 固定周期;异常节拍用配置频率兜底) ----
@@ -107,6 +112,7 @@ class TrackingControlLaw:
# ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 BRAKE_HOVER) ---- # ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 BRAKE_HOVER) ----
if not snapshot.track.tracked: if not snapshot.track.tracked:
self._no_distance_active = False # 刹车期间不计 EXIT 无距离时长
if self._last_mode is not ControlMode.BRAKE_HOVER: if self._last_mode is not ControlMode.BRAKE_HOVER:
# 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅) # 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅)
self._distance_pid.reset() self._distance_pid.reset()
@@ -156,14 +162,16 @@ class TrackingControlLaw:
self._cfg.vertical.vz_limit_mps) self._cfg.vertical.vz_limit_mps)
# ---- ⑦ 距离通道(§6.2 / §6.3) ---- # ---- ⑦ 距离通道(§6.2 / §6.3) ----
# 雷达距离不得直接当目标距离:仅关联成立且未超龄才可用(§6.3、FR-041) # 目标距离来自检测框自身的双目测距:仅 distance_valid 且未超龄才可用
distance_available = (snapshot.associated_to_target # (§6.3、FR-041)
and snapshot.radar_distance_m > 0.0 distance_available = (snapshot.distance_valid
and not is_stale(snapshot.radar_time_ms, now_ms, and snapshot.target_distance_m > 0.0
self._cfg.control.radar_stale_ms)) and not is_stale(snapshot.distance_time_ms, now_ms,
self._cfg.control.distance_stale_ms))
if distance_available: if distance_available:
self._no_distance_active = False # 任一有效距离帧重置 EXIT 计时
# Δd = d − d_exp:d > d_exp 前向接近,d < d_exp 减速/后退(§6.2) # Δ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) vx_raw = self._distance_pid.update(delta_d, dt_s)
slewed = self._vx_limiter.limit(vx_raw, dt_s) # 加速度限幅(§8) slewed = self._vx_limiter.limit(vx_raw, dt_s) # 加速度限幅(§8)
out.vx_mps = clamp(slewed, out.vx_mps = clamp(slewed,
@@ -182,10 +190,20 @@ class TrackingControlLaw:
out.vx_mps = self._vx_limiter.limit( out.vx_mps = self._vx_limiter.limit(
self._cfg.distance.no_distance_approach_limit_mps, dt_s) self._cfg.distance.no_distance_approach_limit_mps, dt_s)
else: # NoDistanceAction.EXIT else: # NoDistanceAction.EXIT
# 退出近距操作:整体降级保持 # 退出近距操作:双目有效域外无距离是常态(D4 远场定速接近),
out.mode = ControlMode.DEGRADED_HOLD # 首个无效拍不退出;持续无距离达 no_distance_exit_grace_ms
out.degraded_reason = DegradedReason.NO_DISTANCE_EXIT # (疑似传感器级故障)才降级。grace 期间按 SLOW_APPROACH 行为
return self._finish_and_log(out, "距离未关联,退出近距操作") # 执行(经加速度限幅,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 修复) ---- # ---- ⑧ 机体系 → 局部 NED 旋转(§2.3,A4 修复) ----
# 各通道限幅均在机体系完成(旋转为正交变换,合速度不变,限幅约束 # 各通道限幅均在机体系完成(旋转为正交变换,合速度不变,限幅约束
@@ -207,6 +225,7 @@ class TrackingControlLaw:
self._yaw_rate_limiter.reset() self._yaw_rate_limiter.reset()
self._vx_limiter.reset() self._vx_limiter.reset()
self._vz_limiter.reset() self._vz_limiter.reset()
self._no_distance_active = False
self._last_now_ms = 0 self._last_now_ms = 0
self._last_mode = ControlMode.DEGRADED_HOLD self._last_mode = ControlMode.DEGRADED_HOLD
+4 -3
View File
@@ -55,9 +55,10 @@ class ControlSnapshot:
track: TrackState = field(default_factory=TrackState) track: TrackState = field(default_factory=TrackState)
track_time_ms: int = 0 # 取样单调时钟(毫秒),<=0 视为从未取样 track_time_ms: int = 0 # 取样单调时钟(毫秒),<=0 视为从未取样
radar_distance_m: float = 0.0 # 机头前向单点距离(米) # 目标距离(双目立体测距,§6.3:distance_valid 为 False 时不得当作目标距离)
associated_to_target: bool = False # 相机—雷达空间关联成立标志(未关联不得当目标距离) target_distance_m: float = 0.0 # 目标距离(米,双目前向分量 Z)
radar_time_ms: int = 0 distance_valid: bool = False # 距离有效标志(测距来自检测框自身,无跨模态关联)
distance_time_ms: int = 0 # 取样单调时钟(毫秒)
attitude_present: bool = False # 姿态源当前是否可用(过期 → 禁止控制) attitude_present: bool = False # 姿态源当前是否可用(过期 → 禁止控制)
attitude_time_ms: int = 0 attitude_time_ms: int = 0