From 538ae4c6c734973bf26abdb83362b98247bc0db3 Mon Sep 17 00:00:00 2001 From: keita <16924591+keita_sakura@user.noreply.gitee.com> Date: Thu, 10 Sep 2026 16:16:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=AE=E6=B5=8B=E8=B7=9D=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E9=87=8D=E6=9E=84=E4=B8=8E=E5=85=B6=E4=BB=96=E5=B0=8F?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/双目测距技术路线.md | 754 ++++++++++++++++++ docs/双目测距方案.md | 432 ---------- docs/视觉跟踪控制律技术路线.md | 125 ++- .../config/track_control.json | 7 +- .../examples/track_control_demo.cpp | 14 +- .../tracking_control_law.h | 7 +- .../include/visual_track_control/vtc_config.h | 13 +- .../include/visual_track_control/vtc_types.h | 13 +- .../src/tracking_control_law.cpp | 39 +- .../visual_track_control/src/vtc_config.cpp | 11 +- .../tests/tracking_control_law_test.cpp | 116 ++- .../tests/vtc_config_test.cpp | 30 +- .../视觉跟踪控制律实现文档.md | 52 +- test.py | 28 +- trajectory_plot.png | Bin 116006 -> 111909 bytes utils/README.md | 36 +- utils/config/track_control.json | 7 +- utils/demo_stereo_ranger.py | 96 --- utils/demo_track_control.py | 14 +- utils/stereo_ranger/README.md | 131 ++- utils/stereo_ranger/__init__.py | 72 +- utils/stereo_ranger/config.py | 139 ++-- utils/stereo_ranger/consistency.py | 74 ++ utils/stereo_ranger/distance_filter.py | 68 ++ utils/stereo_ranger/geometry.py | 84 ++ utils/stereo_ranger/matching.py | 82 ++ utils/stereo_ranger/sampling.py | 73 ++ utils/stereo_ranger/stereo_ranger.py | 372 ++++----- utils/stereo_ranger/target_store.py | 119 +++ utils/stereo_ranger/types.py | 100 ++- utils/stereo_ranger/warn_throttle.py | 30 + utils/tests/stereo_ranger/config_test.py | 76 -- .../tests/stereo_ranger/stereo_ranger_test.py | 302 ------- .../tracking_control_law_test.py | 119 ++- .../visual_track_control/vtc_config_test.py | 24 +- utils/visual_track_control/README.md | 2 +- utils/visual_track_control/config.py | 20 +- .../tracking_control_law.py | 41 +- utils/visual_track_control/types.py | 7 +- 39 files changed, 2285 insertions(+), 1444 deletions(-) create mode 100644 docs/双目测距技术路线.md delete mode 100644 docs/双目测距方案.md delete mode 100644 utils/demo_stereo_ranger.py create mode 100644 utils/stereo_ranger/consistency.py create mode 100644 utils/stereo_ranger/distance_filter.py create mode 100644 utils/stereo_ranger/geometry.py create mode 100644 utils/stereo_ranger/matching.py create mode 100644 utils/stereo_ranger/sampling.py create mode 100644 utils/stereo_ranger/target_store.py create mode 100644 utils/stereo_ranger/warn_throttle.py delete mode 100644 utils/tests/stereo_ranger/config_test.py delete mode 100644 utils/tests/stereo_ranger/stereo_ranger_test.py diff --git a/docs/双目测距技术路线.md b/docs/双目测距技术路线.md new file mode 100644 index 0000000..831105d --- /dev/null +++ b/docs/双目测距技术路线.md @@ -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)。 diff --git a/docs/双目测距方案.md b/docs/双目测距方案.md deleted file mode 100644 index 5ace1fb..0000000 --- a/docs/双目测距方案.md +++ /dev/null @@ -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}\)。 - ---- - -*文档结束。* diff --git a/docs/视觉跟踪控制律技术路线.md b/docs/视觉跟踪控制律技术路线.md index 3307769..29af140 100644 --- a/docs/视觉跟踪控制律技术路线.md +++ b/docs/视觉跟踪控制律技术路线.md @@ -47,7 +47,7 @@ 1. **三路控制律计算**(每拍一次,固定周期) - **水平航向**:目标像素偏移 → 水平视线角 β → 增益 → 偏航指令; - **垂直高度**:目标像素偏移 → 垂直视线角 α → 垂直速度指令; - - **距离**:雷达目标距离与期望射程之差 Δd → PID → 前向速度指令。 + - **距离**:双目目标距离与期望射程之差 Δd → PID → 前向速度指令。 2. **统一限幅**:每个通道内联完成"变化率限幅 + 幅值限幅",任何输出都不允许 跳过限幅直达链路(含切换后的**首条**指令)。 3. **安全降级**:输入超龄、目标判丢、距离不可用时,输出降级意图而不是继续 @@ -63,8 +63,8 @@ │ (感知线程)│ 每帧 │ (滤波器) │ 主目标+ │ TrackingControlLaw│ └─────────────┘ └──────────────┘ Coast标志 └─────────┬─────────┘ │ -┌─────────────┐ 前向距离 │ 控制意图 -│ 激光测距雷达│──────────────────────┐ ▼ +┌─────────────┐ 目标距离 │ 控制意图 +│ 双目立体测距│──────────────────────┐ ▼ └─────────────┘ │ ┌──────────────────────┐ ┌─────────────┐ 姿态/航向/速度 ├─────────────▶│ 打包网关 │ │ PX4 链路 │──────────────────────┘ 控制快照 │ (控制意图→MAVLink) │ @@ -78,8 +78,15 @@ ``` **上游依赖**:追踪库提供主目标中心与 `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 明确不做的事(边界) -- **不做感知**:不跑检测/光流/雷达驱动,只消费调用方填入的快照; -- **不做目标关联**:相机—雷达空间关联由感知融合层给出布尔标志,本模块只读取; +- **不做感知**:不跑检测/光流/双目立体匹配,只消费调用方填入的快照; +- **不做距离有效性判定**:`distance_valid` 由双目链路自身的一致性校验给出, + track_id 一致性校验为调用方约定(§12.2),本模块只读取标志; - **不做状态机仲裁**:不判断何时进入/退出视觉跟踪,不实现目标丢失策略; - **不做链路 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.is_predicted` | 布尔 | 本拍是否为滤波器预测值(Coast);`true` → 清距离积分 | | 视觉 | `track_time_ms` | 单调时钟 ms | **取样瞬间**打戳;`<= 0` 视为从未取样,直接判超龄 | -| 雷达 | `radar_distance_m` | m | 机头前向单点距离;`<= 0` 视为不可用 | -| 雷达 | `associated_to_target` | 布尔 | 相机—雷达关联成立标志;**未关联不得当作目标距离** | -| 雷达 | `radar_time_ms` | 单调时钟 ms | 雷达取样瞬间打戳 | +| 距离 | `target_distance_m` | m | 双目目标距离(前向分量 Z);`<= 0` 视为不可用 | +| 距离 | `distance_valid` | 布尔 | 本帧测距可用标志:距离来自目标检测框自身的双目测距;**无效帧的距离不得当作目标距离** | +| 距离 | `distance_time_ms` | 单调时钟 ms | 双目距离取样瞬间打戳 | | 姿态 | `attitude_present` | 布尔 | 姿态源当前是否可用;`false` → 禁止控制 | | 姿态 | `attitude_time_ms` | 单调时钟 ms | 姿态取样瞬间打戳 | | 姿态 | `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` | 无(纯函数) | 不需要 | | 距离 PID | Δd → 前向速度 | PID 参数、输出限幅 | 积分累计、上拍误差、低通后微分值、上次输出、"是否存在有效微分历史"标志 | `ResetIntegral()` 只清积分;`Reset()` 清全部 | | 变化率限幅器 ×4 | 限制每拍变化量 | 变化率上限 | 上次输出值 | `Reset(value)` | @@ -320,6 +328,7 @@ v_down = v_down # 绕下轴旋转不影响垂直分量 # ---- ② 判丢 → 零速刹车 ---- 若 非 快照.track.tracked: + 无距离计时.清除() # 刹车期间不计 kExit 时长 若 last_mode ≠ kBrakeHover: # 仅在"进入沿"清状态 距离PID.全清(); 四个限幅器.复位(0) 输出.mode ← kBrakeHover; 输出.valid ← true # 速度/航向字段保持全零 @@ -354,12 +363,13 @@ v_down = v_down # 绕下轴旋转不影响垂直分量 输出.vz_mps ← 钳制(限速后, ±配置.vertical.vz_limit_mps) # ---- ⑦ 距离通道 ---- - 距离可用 ← 快照.associated_to_target - 且 快照.radar_distance_m > 0 - 且 非 已超龄(快照.radar_time_ms, now_ms, 配置.radar_stale_ms) + 距离可用 ← 快照.distance_valid + 且 快照.target_distance_m > 0 + 且 非 已超龄(快照.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) 限速后 ← 前向速度限幅器.推进(前向原始, dt) # 加速度限幅 # 非对称限幅:接近可以快,后退必须慢 @@ -370,9 +380,14 @@ v_down = v_down # 绕下轴旋转不影响垂直分量 按 配置.distance.no_distance_action 分支: 保持: 输出.vx_mps ← 0; 前向速度限幅器.复位(0) 限速接近: 输出.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(输出.vx_mps, 输出.vy_mps, 输出.vz_mps, 快照.yaw_rad) @@ -391,7 +406,7 @@ v_down = v_down # 绕下轴旋转不影响垂直分量 | 姿态超龄降级 | 否 | 否 | 否 | `false` | | 视觉超龄降级 | 否 | 否 | 否 | `false` | | 判丢刹车 | 是(进入沿) | 是(进入沿) | 否 | `true` | -| 无距离退出 | 是(仅积分) | 否 | 否 | `false` | +| 无距离退出(grace 期满) | 是(仅积分) | 否 | 否 | `false` | | 正常/Coast | 视情况清积分 | 否 | **是** | `true` | **降级路径不清状态的理由**:降级只是"这一拍不输出",输入恢复后应当接着上次的 @@ -505,9 +520,10 @@ vx ← 非对称钳制( 加速度限幅( PID(Δd) ), −后退限速, +接近限 anti-windup 判据,外部再做非对称钳制。这样内部限幅不会先于外部约束生效, 保证 anti-windup 判据基于"真实饱和边界"。 -**可用性三重门**(缺一不可):关联标志成立 **且** 距离为正 **且** 未超龄。 -"未关联的雷达距离"只是机头前方某个障碍物的距离,把它当作目标距离会导致 -朝着错误目标调整射程。 +**可用性三重门**(缺一不可):`distance_valid` 成立 **且** 距离为正 **且** 未超龄。 +双目测距取自目标检测框自身,不存在"回波打到别处"的跨模态关联问题;标志为 +`false` 表示本帧测距不可用(有效域外/未匹配/一致性校验失败),不得把任何数值 +当作目标距离。 ### 6.5 PID 内部算法 @@ -601,12 +617,12 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 |------|-------------|------| | 视觉追踪结果 | 200 ms | 与推理帧率/追踪输出节拍匹配,约 4 拍(20 Hz) | | 自机姿态 | 300 ms | 姿态链路更新较快,但缺失即禁止坐标运算 | -| 雷达距离 | 500 ms | 雷达更新较慢,且距离不可用有独立降级动作 | +| 双目距离 | 500 ms | 双目随推理帧更新,且距离不可用有独立降级动作 | ### 7.2 降级优先级(顺序即语义) ```text -姿态不可用 > 视觉超龄 > 判丢 > 无距离(退出档) +姿态不可用 > 视觉超龄 > 判丢 > 无距离(exit 档,持续达门限) ``` | 优先级 | 检查 | 理由 | @@ -614,7 +630,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 | 1 | 姿态过期/缺失 | 连坐标旋转与绝对航向合成都做不了,任何空间量都不可信(规格:禁止空间转换和控制) | | 2 | 视觉超龄 | 视觉是"是否判丢"的唯一来源;数据过期时连 `tracked` 标志本身都不可信,因此**必须先于判丢**处理 | | 3 | 判丢 | 视觉可信且明确报告无轨道 → 零速刹车 | -| 4 | 无距离 | 只有在"视觉可信且正在跟踪"前提下才谈得上距离降级 | +| 4 | 无距离 | 只有在"视觉可信且正在跟踪"前提下才谈得上距离降级;`exit` 档还需持续无距离达门限(§7.5),默认档为 `slow_approach`(§10.8) | ### 7.3 判丢刹车与状态清理 @@ -642,19 +658,28 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 连续可用,此时停止控制反而会造成机体停顿—恢复的抖动。清积分的理由是:预测期间 误差不反映真实世界,积分累加会把预测偏差固化成饱和量。 -**Coast 期间距离通常不可用**:目标短暂消失往往伴随雷达关联失效,因此第 ⑦ 步会 -走无距离分支(默认"保持"→ 前向清零)。这是设计上的自然降级,不需要额外分支。 +**Coast 期间距离通常不可用**:目标短暂消失时双目失去匹配(`distance_valid == +false`),第 ⑦ 步走无距离分支;默认 `slow_approach` 下前向按低速定速推进, +方位闭环继续(§7.5)。这是设计上的自然降级,不需要额外分支。 ### 7.5 无距离三档动作 | 配置值 | 行为 | 适用场景 | |--------|------|---------| -| `hold`(默认) | 前向速度清零,限幅器复位到 0 | 安全占位语义:不知道距离就不前冲 | -| `slow_approach` | 以配置的低速限幅缓速接近(仍经加速度限幅) | 需要持续压迫、且低速可接受风险 | -| `exit` | 整体降级保持(`kDegradedHold` / `kNoDistanceExit`),不发送 | 距离是安全前提(如避撞),无距离必须退出近距操作 | +| `slow_approach`(默认) | 以配置的低速限幅缓速接近(仍经加速度限幅) | **默认档**:双目是机载唯一距离源,有效域外无距离是常态而非异常;即远场策略"方位闭环 + 定速接近"(D4) | +| `hold` | 前向速度清零,限幅器复位到 0 | 安全占位语义:不知道距离就不前冲 | +| `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 兜底值 | | `visual_stale_ms` | ms | 200 | 视觉结果超龄阈值 | -| `radar_stale_ms` | ms | 500 | 雷达距离超龄阈值 | +| `distance_stale_ms` | ms | 500 | 双目距离超龄阈值 | | `attitude_stale_ms` | ms | 300 | 自机姿态超龄阈值 | ### 9.2 camera 组:画面与视场 @@ -816,8 +841,9 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 | `derivative_filter_coef` | ∈[0,1) | 0.2 | 微分一阶低通系数(0 = 不滤波) | | `approach_velocity_limit_mps` | m/s | 3.0 | 接近速度上限 | | `retreat_velocity_limit_mps` | m/s | 1.0 | 后退速度上限 | -| `no_distance_action` | 枚举 | `hold` | `hold` / `slow_approach` / `exit` | -| `no_distance_approach_limit_mps` | m/s | 0.5 | `slow_approach` 档的限速值 | +| `no_distance_action` | 枚举 | `slow_approach` | `hold` / `slow_approach` / `exit`(默认档依据见 §10.8) | +| `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 组:加速度 @@ -889,6 +915,23 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 见 §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. 实现与重写指引 @@ -899,6 +942,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 |------|------|------|---------| | 上拍时刻 | 主控律 | 0(表示首拍) | 每拍写入当前时刻 | | 上拍输出模式 | 主控律 | 降级保持 | 仅模式切换时写入(并触发日志) | +| 无距离计时(进行中 + 起始时刻) | 主控律 | 未计时 | 首个无距离拍启动(`exit` 档);有效距离帧 / 判丢刹车 / 复位() 清除 | | 积分累计 | PID | 0 | 每拍(除冻结);清积分/全清时归零 | | 上拍误差 | PID | 0 | 每拍 | | 低通后微分 | PID | 0 | 每拍 | @@ -917,7 +961,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 控制线程(固定周期,配置频率): 结果 ← 追踪库.取最新结果() # 线程安全拷贝 - 快照 ← 采集(结果, 雷达, 姿态) + 快照 ← 采集(结果, 双目距离, 姿态) 快照.track_time_ms ← 当前单调时钟() # 就在取结果的那一刻打戳 意图 ← 控制律.更新(快照, 当前单调时钟()) 按 意图.mode 分发: @@ -926,7 +970,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 降级保持 → 不发送(记录降级原因/告警) 停止视觉闭环(状态机决定): - 控制律.复位() # 清积分、清限幅器、清节拍与模式历史 + 控制律.复位() # 清积分、清限幅器、清节拍与模式历史、清无距离计时 ``` **约束**: @@ -1003,7 +1047,7 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 | **侧向通道** | `ay_mps2` 预留、机体侧向恒 0 | 若需要侧移则新增通道 | 低 | | **机体补偿** | 快照的滚转/俯仰/角速度字段预留未用 | 接入主工程姿态源后评估 | 低 | | **相机标定** | 内参/畸变/安装外参未标定,针孔模型无畸变补偿 | 标定 | 中:视线角系统性偏差 | -| **雷达关联判据** | 由感知融合层给出标志,本模块不实现 | 融合层设计 | 中:关联错误 = 距离通道朝错误目标调射程 | +| **track_id 一致性校验** | 调用方约定:装配层校验双目距离与追踪主目标的 `track_id` 一致并告警,库内不实现(S3) | 主程序装配层落地时加一处校验 | 中:错配会把其他目标的距离当作本目标,距离通道朝错误目标调射程 | ### 12.3 红线 @@ -1014,7 +1058,8 @@ Coast 只清积分而不清微分:预测值仍在连续演化,微分历史 3. **不得用超龄数据生成正常指令**:超龄只能触发降级或告警。 4. **不得绕过限幅**:任何新增通道都必须内联"变化率 + 幅值"双重限幅, 包括切换后的首条指令。 -5. **未关联的雷达距离不得当作目标距离**。 +5. **`distance_valid == false` 的距离不得当作目标距离**(v1.0 仅单目检出时 + 不发布距离,方位退化为该目偏移,见 `docs/双目深度估计思路.md` §11.3)。 --- diff --git a/reference-c++/visual_track_control/config/track_control.json b/reference-c++/visual_track_control/config/track_control.json index 7dcd26b..c9d9c8f 100644 --- a/reference-c++/visual_track_control/config/track_control.json +++ b/reference-c++/visual_track_control/config/track_control.json @@ -4,7 +4,7 @@ "control": { "frequency_hz": 20.0, "visual_stale_ms": 200, - "radar_stale_ms": 500, + "distance_stale_ms": 500, "attitude_stale_ms": 300 }, @@ -36,8 +36,9 @@ "derivative_filter_coef": 0.2, "approach_velocity_limit_mps": 3.0, "retreat_velocity_limit_mps": 1.0, - "no_distance_action": "hold", - "no_distance_approach_limit_mps": 0.5 + "no_distance_action": "slow_approach", + "no_distance_approach_limit_mps": 0.5, + "no_distance_exit_grace_ms": 3000 }, "accel_limit": { diff --git a/reference-c++/visual_track_control/examples/track_control_demo.cpp b/reference-c++/visual_track_control/examples/track_control_demo.cpp index 3e1a4b6..fd00eb3 100644 --- a/reference-c++/visual_track_control/examples/track_control_demo.cpp +++ b/reference-c++/visual_track_control/examples/track_control_demo.cpp @@ -179,11 +179,11 @@ int main(int argc, char** argv) { snapshot.track = tracker.LastResult(); snapshot.track_time_ms = now_ms; - // 雷达:正常阶段关联成立;Coast/刹车阶段取消关联(演示 §6.3 降级路径) - snapshot.radar_distance_m = 40.0 - 3.0 * elapsed_s; - snapshot.associated_to_target = (tick < normal_ticks) && - snapshot.radar_distance_m > 0.0; - snapshot.radar_time_ms = now_ms; + // 双目距离:正常阶段有效;Coast/刹车阶段置无效(演示 §6.3 降级路径) + snapshot.target_distance_m = 40.0 - 3.0 * elapsed_s; + snapshot.distance_valid = (tick < normal_ticks) && + snapshot.target_distance_m > 0.0; + snapshot.distance_time_ms = now_ms; // 自机姿态:演示中始终新鲜有效(接入主工程时来自 FlightStateSnapshot) snapshot.attitude_present = true; @@ -209,11 +209,11 @@ int main(int argc, char** argv) { if (tick % 10 == 0) { spdlog::info( "[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), output.coast_active ? "(Coast)" : "", snapshot.track.tracked, 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)); diff --git a/reference-c++/visual_track_control/include/visual_track_control/tracking_control_law.h b/reference-c++/visual_track_control/include/visual_track_control/tracking_control_law.h index 1ccd940..894490d 100644 --- a/reference-c++/visual_track_control/include/visual_track_control/tracking_control_law.h +++ b/reference-c++/visual_track_control/include/visual_track_control/tracking_control_law.h @@ -6,7 +6,7 @@ // 对应规格:docs/物理追踪思路.md // §4 水平航向控制(增益 → slew → 角速度限幅,位置式/速率式按配置) // §5 垂直/高度控制(统一速度支路:α → 垂直速度,原双支路已合并) -// §6 距离控制(PID(Δd) → 前向速度;未关联按配置降级,§6.3) +// §6 距离控制(PID(Δd) → 前向速度;距离无效按配置降级,§6.3) // §6.4 Coast 清积分、判丢零速刹车 // §7.1 超龄判定(姿态/视觉超龄 → 降级保持) // §8 统一限幅 @@ -58,6 +58,11 @@ private: // ---- 节拍与状态(仅打日志用,不参与数学) ---- std::int64_t last_now_ms_ = 0; // 上次计算时刻 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 diff --git a/reference-c++/visual_track_control/include/visual_track_control/vtc_config.h b/reference-c++/visual_track_control/include/visual_track_control/vtc_config.h index 820a24c..df67408 100644 --- a/reference-c++/visual_track_control/include/visual_track_control/vtc_config.h +++ b/reference-c++/visual_track_control/include/visual_track_control/vtc_config.h @@ -33,18 +33,19 @@ enum class HeadingMode : std::uint8_t { kPosition, // 位置式:β → 修正量,与当前航向合成绝对航向输出 yaw_deg }; -/// 雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3,具体动作待定) +/// 目标距离不可用(无效/超龄)时的距离通道降级动作(§6.3) enum class NoDistanceAction : std::uint8_t { kHold, // 保持:前向速度指令清零(安全占位语义) kSlowApproach, // 限速接近:按 no_distance_approach_limit_mps 缓速接近 - kExit, // 退出近距操作:输出降级保持(kDegradedHold) + kExit, // 退出近距操作:持续无距离达 no_distance_exit_grace_ms 才 + // 降级保持(kDegradedHold);grace 期间按 kSlowApproach 行为 }; /// control 组:控制周期与各输入超龄阈值(§7) struct ControlGroupConfig { double frequency_hz = 20.0; // 控制频率(Hz,§7.1 待定) 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; // 自机姿态超龄阈值(毫秒) }; @@ -84,8 +85,12 @@ struct DistanceGroupConfig { double derivative_filter_coef = 0.0; // 微分一阶低通系数 ∈ [0,1):0=不滤波 double approach_velocity_limit_mps = 3.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 限速值(米/秒) + std::int64_t no_distance_exit_grace_ms = 3000; // kExit 门限:持续无距离达 + // 此时长才退出(毫秒,占位) }; /// accel_limit 组:各轴加速度限幅(§8,待定) diff --git a/reference-c++/visual_track_control/include/visual_track_control/vtc_types.h b/reference-c++/visual_track_control/include/visual_track_control/vtc_types.h index a156d75..790f8da 100644 --- a/reference-c++/visual_track_control/include/visual_track_control/vtc_types.h +++ b/reference-c++/visual_track_control/include/visual_track_control/vtc_types.h @@ -13,8 +13,8 @@ // - ControlSnapshot / ControlOutput 均为纯值类型(POD 风格), // 控制律热路径零堆分配; // - 本库不依赖主工程 include/ 的任何类型,独立可编译; -// - 雷达距离必须带 associated_to_target 关联标志:未关联不得当作 -// 目标距离(§6.3、需求分析.md FR-041)。 +// - 目标距离必须带 distance_valid 有效标志:无效值不得当作目标距离 +// (§6.3、需求分析.md FR-041)。 // ============================================================================= #include @@ -54,10 +54,11 @@ struct ControlSnapshot { drone::tracker::TrackResult track; // 追踪库结果(值拷贝) std::int64_t track_time_ms = 0; // 取样单调时钟(毫秒),<=0 视为从未取样 - // ---- 雷达距离(§6.3:未关联仅为前向安全距离,不得当目标距离) ---- - double radar_distance_m = 0.0; // 机头前向单点距离(米) - bool associated_to_target = false; // 相机—雷达空间关联成立标志 - std::int64_t radar_time_ms = 0; // 取样单调时钟(毫秒) + // ---- 目标距离(双目立体测距,§6.3:distance_valid == false 时不得 + // 当作目标距离) ---- + double target_distance_m = 0.0; // 目标距离(米,双目前向分量 Z) + bool distance_valid = false; // 距离有效标志(测距来自检测框自身,无跨模态关联) + std::int64_t distance_time_ms = 0; // 取样单调时钟(毫秒) // ---- 自机姿态/速度 ---- // 姿态过期 → 禁止控制(§9.2)。`yaw_rad` 参与输出端机体系→NED 旋转 diff --git a/reference-c++/visual_track_control/src/tracking_control_law.cpp b/reference-c++/visual_track_control/src/tracking_control_law.cpp index c5890c9..fda6544 100644 --- a/reference-c++/visual_track_control/src/tracking_control_law.cpp +++ b/reference-c++/visual_track_control/src/tracking_control_law.cpp @@ -9,7 +9,8 @@ // 消息,丢弃同周期距离通道前向速度导致逼近停滞,详见 // vtc_types.h ControlMode 注释) // §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)零速刹车 // §7.1 超龄数据只触发降级:姿态过期 → 禁止控制;视觉过期 → 降级保持 // §8 所有输出统一限幅;首条指令经变化率限幅(S6) @@ -123,6 +124,7 @@ ControlOutput TrackingControlLaw::Update(const ControlSnapshot& snapshot, // ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 kBrakeHover) ---- if (!snapshot.track.tracked) { + no_distance_active_ = false; // 刹车期间不计 kExit 无距离时长 if (last_mode_ != ControlMode::kBrakeHover) { // 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅) distance_pid_.Reset(); @@ -181,14 +183,16 @@ ControlOutput TrackingControlLaw::Update(const ControlSnapshot& snapshot, } // ---- ⑦ 距离通道(§6.2 / §6.3) ---- - // 雷达距离不得直接当目标距离:仅关联成立且未超龄才可用(§6.3、FR-041) + // 目标距离来自检测框自身的双目测距:仅 distance_valid 且未超龄才可用 + // (§6.3、FR-041) const bool distance_available = - snapshot.associated_to_target && snapshot.radar_distance_m > 0.0 && - !StalenessChecker::IsStale(snapshot.radar_time_ms, now_ms, - cfg_.control.radar_stale_ms); + snapshot.distance_valid && snapshot.target_distance_m > 0.0 && + !StalenessChecker::IsStale(snapshot.distance_time_ms, now_ms, + cfg_.control.distance_stale_ms); if (distance_available) { + no_distance_active_ = false; // 任一有效距离帧重置 kExit 计时 // Δ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 slewed = vx_limiter_.Limit(vx_raw, dt_s); // 加速度限幅(§8) 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); break; case NoDistanceAction::kExit: - // 退出近距操作:整体降级保持 - out.mode = ControlMode::kDegradedHold; - out.degraded_reason = DegradedReason::kNoDistanceExit; - return FinishAndLog("距离未关联,退出近距操作"); + // 退出近距操作:双目有效域外无距离是常态(D4 远场定速接近), + // 首个无效拍不退出;持续无距离达 no_distance_exit_grace_ms + // (疑似传感器级故障)才降级。grace 期间按 kSlowApproach 行为 + // 执行(经加速度限幅,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(); vx_limiter_.Reset(); vz_limiter_.Reset(); + no_distance_active_ = false; last_now_ms_ = 0; last_mode_ = ControlMode::kDegradedHold; } diff --git a/reference-c++/visual_track_control/src/vtc_config.cpp b/reference-c++/visual_track_control/src/vtc_config.cpp index 629bc64..960b2f7 100644 --- a/reference-c++/visual_track_control/src/vtc_config.cpp +++ b/reference-c++/visual_track_control/src/vtc_config.cpp @@ -107,9 +107,9 @@ ControlGroupConfig LoadControl(const Json& root) { c.frequency_hz = RequirePositive(RequireNumber(s, "control", "frequency_hz"), "control.frequency_hz"); 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"); - 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 必须为正"); } return c; @@ -193,6 +193,13 @@ DistanceGroupConfig LoadDistance(const Json& root) { d.no_distance_approach_limit_mps = RequireNonNegative( RequireNumber(s, "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; } diff --git a/reference-c++/visual_track_control/tests/tracking_control_law_test.cpp b/reference-c++/visual_track_control/tests/tracking_control_law_test.cpp index 4ec1bce..b6a5d4a 100644 --- a/reference-c++/visual_track_control/tests/tracking_control_law_test.cpp +++ b/reference-c++/visual_track_control/tests/tracking_control_law_test.cpp @@ -4,8 +4,9 @@ // 覆盖(物理追踪思路 §2.3 / §4 / §5 / §6 / §7 / §9): // 超龄降级(姿态缺失/过期、视觉超龄、降级优先级)、判丢零速刹车、 // Coast 清积分与标志、速率式/位置式航向通道(含绝对航向合成与回绕, -// A2)、垂直统一速度支路(A1)、距离通道三种无距离降级动作、 -// 首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。 +// A2)、垂直统一速度支路(A1)、距离通道无距离降级动作(含 kExit +// 门限/grace 重置/零门限立即退出)、距离超龄回落、首拍变化率限幅(S6)、 +// 机体系→NED 输出旋转(A4)。 // ============================================================================= #include @@ -31,7 +32,7 @@ TrackingConfig MakeConfig() { TrackingConfig cfg; cfg.control.frequency_hz = 20.0; cfg.control.visual_stale_ms = 200; - cfg.control.radar_stale_ms = 500; + cfg.control.distance_stale_ms = 500; cfg.control.attitude_stale_ms = 300; cfg.camera.image_width = 1000; cfg.camera.image_height = 1000; @@ -59,7 +60,7 @@ TrackingConfig MakeConfig() { return cfg; } -/// 新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜 +/// 新鲜快照:目标在画面中心,姿态/追踪/距离均新鲜 ControlSnapshot MakeSnapshot(std::int64_t now_ms) { ControlSnapshot s; s.track.tracked = true; @@ -220,9 +221,9 @@ TEST(TrackingControlLawTest, VerticalVelocityClamped) { TEST(TrackingControlLawTest, DistanceApproachWhenFar) { TrackingControlLaw law(MakeConfig()); auto snap = MakeSnapshot(1000); - snap.radar_distance_m = 30.0; // Δd = +20 → kp 输出 10 → 限幅 3 - snap.associated_to_target = true; - snap.radar_time_ms = 1000; + snap.target_distance_m = 30.0; // Δd = +20 → kp 输出 10 → 限幅 3 + snap.distance_valid = true; + snap.distance_time_ms = 1000; const auto out = law.Update(snap, 1000); EXPECT_NEAR(out.vx_mps, 3.0, 1e-9); } @@ -230,9 +231,9 @@ TEST(TrackingControlLawTest, DistanceApproachWhenFar) { TEST(TrackingControlLawTest, DistanceRetreatClampedWhenTooClose) { TrackingControlLaw law(MakeConfig()); auto snap = MakeSnapshot(1000); - snap.radar_distance_m = 5.0; // Δd = -5 → kp 输出 -2.5 → 后退限幅 -1 - snap.associated_to_target = true; - snap.radar_time_ms = 1000; + snap.target_distance_m = 5.0; // Δd = -5 → kp 输出 -2.5 → 后退限幅 -1 + snap.distance_valid = true; + snap.distance_time_ms = 1000; const auto out = law.Update(snap, 1000); EXPECT_NEAR(out.vx_mps, -1.0, 1e-9); } @@ -240,7 +241,7 @@ TEST(TrackingControlLawTest, DistanceRetreatClampedWhenTooClose) { TEST(TrackingControlLawTest, NoDistanceHoldZerosVx) { TrackingControlLaw law(MakeConfig()); // 缺省 kHold auto snap = MakeSnapshot(1000); - snap.associated_to_target = false; + snap.distance_valid = false; const auto out = law.Update(snap, 1000); EXPECT_EQ(out.mode, ControlMode::kVelocityHeading); EXPECT_DOUBLE_EQ(out.vx_mps, 0.0); @@ -251,29 +252,80 @@ TEST(TrackingControlLawTest, NoDistanceSlowApproach) { cfg.distance.no_distance_action = NoDistanceAction::kSlowApproach; TrackingControlLaw law(cfg); auto snap = MakeSnapshot(1000); - snap.associated_to_target = false; + snap.distance_valid = false; const auto out = law.Update(snap, 1000); EXPECT_NEAR(out.vx_mps, 0.5, 1e-9); } -TEST(TrackingControlLawTest, NoDistanceExitDegrades) { +TEST(TrackingControlLawTest, NoDistanceExitWaitsGraceThenDegrades) { + // S1 拍板:kExit 需持续无距离达门限才降级;grace 期间按 kSlowApproach 行为 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.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); EXPECT_EQ(out.mode, ControlMode::kDegradedHold); EXPECT_EQ(out.degraded_reason, DegradedReason::kNoDistanceExit); EXPECT_FALSE(out.valid); } -TEST(TrackingControlLawTest, StaleRadarFallsBackToNoDistance) { +TEST(TrackingControlLawTest, StaleDistanceFallsBackToNoDistance) { TrackingControlLaw law(MakeConfig()); // kHold auto snap = MakeSnapshot(1000); - snap.radar_distance_m = 30.0; - snap.associated_to_target = true; - snap.radar_time_ms = 400; // 600 ms 前 > 500 ms 阈值 → 距离不可用 + snap.target_distance_m = 30.0; + snap.distance_valid = true; + snap.distance_time_ms = 400; // 600 ms 前 > 500 ms 阈值 → 距离不可用 const auto out = law.Update(snap, 1000); EXPECT_EQ(out.mode, ControlMode::kVelocityHeading); EXPECT_DOUBLE_EQ(out.vx_mps, 0.0); @@ -287,9 +339,9 @@ TEST(TrackingControlLawTest, FirstTickVxSlewsFromZero) { cfg.accel_limit.ax_mps2 = 2.0; TrackingControlLaw law(cfg); auto snap = MakeSnapshot(1000); - snap.radar_distance_m = 30.0; // 通道输出本应为 3.0 - snap.associated_to_target = true; - snap.radar_time_ms = 1000; + snap.target_distance_m = 30.0; // 通道输出本应为 3.0 + snap.distance_valid = true; + snap.distance_time_ms = 1000; 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 } @@ -300,9 +352,9 @@ TEST(TrackingControlLawTest, OutputRotatedToNedByYaw) { TrackingControlLaw law(MakeConfig()); auto snap = MakeSnapshot(1000); snap.yaw_rad = kPi / 2.0; // 机头朝东 - snap.radar_distance_m = 30.0; - snap.associated_to_target = true; - snap.radar_time_ms = 1000; + snap.target_distance_m = 30.0; + snap.distance_valid = true; + snap.distance_time_ms = 1000; const auto out = law.Update(snap, 1000); // 机体前向 3.0 → 东向 3.0,北向 ≈ 0 EXPECT_NEAR(out.vx_mps, 0.0, 1e-9); @@ -313,9 +365,9 @@ TEST(TrackingControlLawTest, ZeroYawKeepsForwardAsNorth) { TrackingControlLaw law(MakeConfig()); auto snap = MakeSnapshot(1000); snap.yaw_rad = 0.0; - snap.radar_distance_m = 30.0; - snap.associated_to_target = true; - snap.radar_time_ms = 1000; + snap.target_distance_m = 30.0; + snap.distance_valid = true; + snap.distance_time_ms = 1000; const auto out = law.Update(snap, 1000); EXPECT_NEAR(out.vx_mps, 3.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; TrackingControlLaw law(cfg); auto snap = MakeSnapshot(1000); - snap.radar_distance_m = 30.0; - snap.associated_to_target = true; - snap.radar_time_ms = 1000; + snap.target_distance_m = 30.0; + snap.distance_valid = true; + snap.distance_time_ms = 1000; for (std::int64_t t = 1000; t < 3000; t += 50) { snap.track_time_ms = t; - snap.radar_time_ms = t; + snap.distance_time_ms = t; snap.attitude_time_ms = t; law.Update(snap, t); // 若干拍后 vx 已爬升到限幅 3.0 } law.Reset(); snap.track_time_ms = 3000; - snap.radar_time_ms = 3000; + snap.distance_time_ms = 3000; snap.attitude_time_ms = 3000; const auto out = law.Update(snap, 3000); // Reset 后限幅器归零:又从 0.1 开始渐变 diff --git a/reference-c++/visual_track_control/tests/vtc_config_test.cpp b/reference-c++/visual_track_control/tests/vtc_config_test.cpp index 8a749c6..6307f9a 100644 --- a/reference-c++/visual_track_control/tests/vtc_config_test.cpp +++ b/reference-c++/visual_track_control/tests/vtc_config_test.cpp @@ -30,7 +30,7 @@ const char* kFullJson = R"({ "control": { "frequency_hz": 20.0, "visual_stale_ms": 200, - "radar_stale_ms": 500, + "distance_stale_ms": 500, "attitude_stale_ms": 300 }, "camera": { @@ -59,7 +59,8 @@ const char* kFullJson = R"({ "approach_velocity_limit_mps": 3.0, "retreat_velocity_limit_mps": 1.0, "no_distance_action": "hold", - "no_distance_approach_limit_mps": 0.5 + "no_distance_approach_limit_mps": 0.5, + "no_distance_exit_grace_ms": 3000 }, "accel_limit": { "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_limit_mps, 2.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); } @@ -221,4 +224,25 @@ TEST(VtcConfigTest, FovOutOfRangeThrows) { 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 diff --git a/reference-c++/visual_track_control/视觉跟踪控制律实现文档.md b/reference-c++/visual_track_control/视觉跟踪控制律实现文档.md index a114a33..3164316 100644 --- a/reference-c++/visual_track_control/视觉跟踪控制律实现文档.md +++ b/reference-c++/visual_track_control/视觉跟踪控制律实现文档.md @@ -34,8 +34,9 @@ 垂直速度设定值(原"小误差姿态微调/大误差垂直速度"双支路已取消, 原因见 `vtc_types.h` 的 `ControlMode` 注释与 §3.8); - 距离通道(规格 §6):`Δd = d − d_exp` → `PidController` → 前向速度 - `vx_mps`;雷达未关联/超龄时按 `NoDistanceAction` 配置降级 - (hold / slow_approach / exit)。 + `vx_mps`;目标距离无效/超龄时按 `NoDistanceAction` 配置降级 + (hold / slow_approach / exit,exit 需持续无距离达 + `no_distance_exit_grace_ms` 门限,见 §3.8)。 - **统一限幅**(规格 §8):所有输出通道内联完成限幅——`Clamp` 静态限幅 + `SlewRateLimiter` 变化率限幅(航向修正/角速度、前向/垂直加速度), 全部在机体系完成。 @@ -96,7 +97,8 @@ (原 `kAttitudeTrim` 姿态微调模式已取消,见 §3.8。) `enum class DegradedReason`:`kNone` / `kAttitudeStale`(姿态过期)/ -`kVisionStale`(视觉超龄)/ `kNoDistanceExit`(无距离且配置退出近距操作)。 +`kVisionStale`(视觉超龄)/ `kNoDistanceExit`(持续无距离达门限且配置 +退出近距操作)。 `ControlSnapshot`(输入,纯值类型): @@ -104,9 +106,9 @@ |------|------| | `track` | `drone::tracker::TrackResult` 值拷贝(追踪库主目标输出,字段定义见 `../target_tracker/target_tracker.md` §二) | | `track_time_ms` | 追踪结果取样单调时钟(毫秒);**<=0 视为从未取样,直接判超龄** | -| `radar_distance_m` | 机头前向单点距离(米) | -| `associated_to_target` | 相机—雷达空间关联成立标志;**未关联不得当目标距离**(规格 §6.3、需求分析 FR-041) | -| `radar_time_ms` | 雷达取样单调时钟 | +| `target_distance_m` | 目标距离(米,双目立体测距前向分量 Z) | +| `distance_valid` | 距离有效标志;**无效不得当目标距离**(规格 §6.3、需求分析 FR-041)。距离来自目标检测框自身的双目测距,无跨模态关联问题 | +| `distance_time_ms` | 距离取样单调时钟 | | `attitude_present` / `attitude_time_ms` | 姿态源可用性与取样时刻(过期→禁止控制,规格 §9.2) | | `yaw_rad` | 自机航向(弧度,NED);**参与计算**:输出端机体系→NED 旋转(§2.3)与位置式绝对航向合成 | | `roll_rad` / `pitch_rad` / `yaw_rate_radps` | 自机姿态其余分量(弧度,FRD);预留,机体补偿未实现(规格 §4.1) | @@ -124,15 +126,18 @@ | `coast_active` | 本周期处于 Coast(`is_predicted == true`) | `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`、 `yaw_rate_limit_dps`、`yaw_slew_limit_dps2`)、`vertical` (`vz_gain_mps_per_deg`、`vz_limit_mps`)、`distance`(`d_exp_m`、 `kp`/`ki`/`kd`、`integral_limit`、`derivative_filter_coef`、 `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 键名与字段一一对应,见 `config/track_control.json`。`heading.mode` 默认速率式(`"rate"`); +`distance.no_distance_action` 默认 `slow_approach`(双目有效域外无距离 +是常态,对齐 D4 远场定速接近策略,见《双目测距方案讨论纪要》S1); 相机组原 `linear_ratio` 键已随线性/针孔双路一并移除(§3.5)。 ### 2.3 ICommandTransport 契约 @@ -263,11 +268,11 @@ dt 上限 `kMaxDtS = 0.5` 秒): → ② 判丢刹车(清积分与限幅器历史)→ ③ Coast 清距离积分 → ④ 像素偏移 → β/α(针孔模型)→ ⑤ 水平航向(按 `HeadingMode` 分支) → ⑥ 垂直统一速度支路(α → `vz_gain·α` → 加速度限幅 → 速度限幅) -→ ⑦ 距离通道(关联成立且未超龄才可用;否则清积分并按 +→ ⑦ 距离通道(`distance_valid` 且未超龄才可用;否则清积分并按 `no_distance_action` 降级)→ ⑧ 机体系→NED 旋转(`RotateBodyToNed`, 随 `snapshot.yaw_rad`)→ 置 `valid = true` 输出。 -两个设计决策记录: +三个设计决策记录: - **垂直双支路合并(A1)**:规格 §5.2 原设"小误差姿态微调(`kAttitudeTrim` → ID 82)/ 大误差垂直速度"双支路。姿态微调支路会把整个输出切成姿态 @@ -278,6 +283,14 @@ dt 上限 `kMaxDtS = 0.5` 秒): 航向。若把 β 修正量直接填入,飞控会转向"修正角"方向而非目标方向。 位置式分支输出 `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) @@ -347,12 +360,12 @@ cmake --build build_vtc -j$(nproc) `LoggingTransport` 字节打印),按配置频率固定节拍依次走三阶段 (对应规格 §6.4): -1. **正常跟踪(6 秒)**:持续喂入向画面中心漂移的合成检测框,雷达 - 关联成立,输出速率式速度航向意图(vx 按加速度限幅渐变至接近速度上限, +1. **正常跟踪(6 秒)**:持续喂入向画面中心漂移的合成检测框,双目 + 距离有效,输出速率式速度航向意图(vx 按加速度限幅渐变至接近速度上限, 角速度随 β 收敛而减小); 2. **Coast(4 秒)**:停止喂检测 → `is_predicted == true` → 清积分、 - 继续预测跟踪;同时取消雷达关联,演示 §6.3 无距离降级路径(hold: - 前向清零); + 继续预测跟踪;同时置距离无效,演示 §6.3 无距离降级路径(配置为 + `slow_approach`:按限速值缓速前向); 3. **判丢刹车(3 秒)**:连续丢帧超过追踪阈值 → `tracked == false` → 零速刹车。 @@ -372,8 +385,8 @@ cmake --build build_vtc -j$(nproc) | `frame_transform_test.cpp` | ψ=0/90° 基向量映射、合速度守恒(正交性)、`v_down` 不变 | | `line_of_sight_test.cpp` | 零偏移、半幅=半视场、奇对称、单调性、与线性近似的偏离(证明未走线性支路)、构造期非法参数抛异常 | | `pid_controller_test.cpp` | P/I/D 分量、积分钳位、输出限幅、抗饱和冻结、**误差反号回卷回归**(旧判据钉在 +5,条件积分到 -5)、微分低通、`dt<=0`、`ResetIntegral`/`Reset` | -| `tracking_control_law_test.cpp` | 姿态缺失/过期、视觉超龄、从未取样、姿态比视觉更严的优先级、判丢刹车、Coast 标志、速率/位置两种航向模式(含绝对航向回绕)、垂直支路与限幅、距离通道四种降级动作、雷达超龄回落、首拍变化率限幅(S6)、NED 旋转、`Reset` 后从零渐变 | -| `vtc_config_test.cpp` | 真实配置加载、position 模式可接受、缺文件/缺字段/缺分组/类型错/枚举非法/数值越界均抛异常且消息携带字段名 | +| `tracking_control_law_test.cpp` | 姿态缺失/过期、视觉超龄、从未取样、姿态比视觉更严的优先级、判丢刹车、Coast 标志、速率/位置两种航向模式(含绝对航向回绕)、垂直支路与限幅、距离通道无距离降级动作(含 kExit 门限、grace 有效帧重置、零门限立即退出)、距离超龄回落、首拍变化率限幅(S6)、NED 旋转、`Reset` 后从零渐变 | +| `vtc_config_test.cpp` | 真实配置加载、position 模式可接受、默认降级动作为 slow_approach、缺文件/缺字段/缺分组/类型错/枚举非法/数值越界/grace 为负均抛异常且消息携带字段名 | | `mavlink_command_gateway_test.cpp` | 手工解码 MAVLink v2 字节流(不含 `mavlink.h`,构建约束见 §3.6):帧头、ID 84、三种掩码分支(含"双零 → 速率掩码"回归)、坐标基准字节、目标系统/组件字节、传输失败传播 | 运行方式(构建后): @@ -430,8 +443,11 @@ CMake config 包);未找到时自动跳过测试构建并打印状态,不 由接线部件完成 `TrackResult` → `TrackedTarget` 转换与控制线程订阅; 3. **传输实现替换**:把示例的 `LoggingTransport` 换成真实链路实现 (`ICommandTransport` 注入),打包与控制律零改动; -4. **雷达关联标志来源**:`associated_to_target` 必须由感知融合层按 - 规格 §6.3 / 需求分析 FR-041 的判据给出,控制律不自行关联。 +4. **距离有效标志来源**:`distance_valid` 由双目链路(StereoRanger) + 给出——距离来自目标检测框自身,控制律不自行判定;多目标场景下 + 调用方填 `ControlSnapshot` 时应校验 `StereoTargetDistance.track_id` + 与 `TrackResult.track_id` 一致(v1.0 单目标为调用方约定,纪要 S3, + 库内不加代码)。 ### 6.4 红线声明 diff --git a/test.py b/test.py index 0eef58f..a59e18b 100644 --- a/test.py +++ b/test.py @@ -9,7 +9,7 @@ PX4 SITL Offboard 视觉跟踪集成测试 (MAVSDK-Python) 适用于 PX4 1.17 YOLO 检测未接入:VirtualTarget 用无人机真实位置/航向做针孔反投影生成 -检测框与激光测距(几何一致闭环),无人机会实际飞向该虚拟目标。 +检测框与双目目标距离(几何一致闭环),无人机会实际飞向该虚拟目标。 """ import asyncio @@ -116,7 +116,7 @@ TRACK_NORMAL_SECS = 20.0 # 虚拟目标保持可见时长(之后 TRACK_BRAKE_SECS = 5.0 # 判丢后零速刹车时长 TRACK_TIMEOUT_SECS = 120.0 # 跟踪段整体安全超时 STATUS_EVERY_TICKS = 10 # 每 0.5 s 打印一行状态 -PLOT_TRAJECTORY = False # 跟踪结束后绘制无人机与虚拟目标轨迹 +PLOT_TRAJECTORY = True # 跟踪结束后绘制无人机与虚拟目标轨迹 TRAJECTORY_SAVE_PATH = Path(__file__).resolve().parent / "trajectory_plot.png" # 虚拟目标:相对悬停锚点、按锚点机头朝向放置的机体系偏移 @@ -219,7 +219,7 @@ def plot_trajectories( 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( - fwd * fwd - + right * right - + rel_d * rel_d - ) - - return [box], slant, True + return [box], fwd, True class TelemetryHub: @@ -616,7 +610,7 @@ async def run_tracking(drone, anchor_ned: tuple[float, float, float], target.set_visible(False) 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) # 记录本拍位置(目标位置在 observe 内已 update) @@ -628,9 +622,9 @@ async def run_tracking(drone, anchor_ned: tuple[float, float, float], snap = ControlSnapshot( track=track_result_to_state(result), track_time_ms=now, # 每拍取样即打戳(视觉始终新鲜) - radar_distance_m=radar_m, - associated_to_target=associated, - radar_time_ms=now if associated else 0, + target_distance_m=distance_m, + distance_valid=distance_valid, + distance_time_ms=now, attitude_present=True, attitude_time_ms=hub.attitude_time_ms, 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] " f"检测={len(detections)} " 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"vz={out.vz_mps:+.2f} yaw_rate={out.yaw_rate_dps:+.2f}°/s " f"coast={out.coast_active}") diff --git a/trajectory_plot.png b/trajectory_plot.png index d6af64622dc490af8d585604fa16cf424217ac8c..d26d6506595d91063a104997de9a2eb070a2b709 100644 GIT binary patch literal 111909 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⁣9YgiN!Qiz zu;S6&XV)F>LP_qz5CB+}?28r&dxx`A-c%ZfEzl`D1qB6vX2EmaMx~OVK%!fe%BDCs z{zLcTWc52${yf0r!OTmnmfO)y)iYMZ>BB)EPWlubq z`Hv&J=%`P%{_lhE;t=Z&4R&IeCg?BF7&mS`Io^9R1M>vIzkXUYv<6a&!=$Bw24qLy zbeA-Otg}#4&FOPgdmR6ZT~~Z}b@K20z`w9K8ESbt)PI+ew%S~V9jn&2g*FQ1fhep^y1ofw z`PQeWIOQjYq40SCxDB}`@|KIJ)p#NVfKb0gG?4@{*+_qi;j0qcY0%>N4YFyTMxFQF zYps>uIVFa_bX(S|w6pKY5|a-~O5)h#WTcU1$qTx>6KvR3KsN{&@FkQ_u!2yJgE)v^Lsu zjJ~9a)!ID-InnPu<3{nc>5dhM!2i=Fo!^|;%td6IN%NSAcnT0o`2=~;U;(k@1@qQQjZH{aWDS)?`f>kfAcUr z*5rX9_3#JI@zeowkC9?aE+O9fAt<4P*dkMXYKHChyxry#k@I$d=M+7g@aoUE?l)!X zR{f#xc#heVi@~Z3{lt$fNmFsUXNel09?R9ed8|=V_3or!;LcN?;yWxmAIwq@YYAF) zC`?>W(C!71*!#=eCrEs|7!vlj1MINqBdQ;g&*ZBjwX6lBGWsgMeEnbWNaSvO4G<*~ z4>`7ihJf2*y9;RokGS)3f-7|D%9Rmf8LO*M;pHoDxmn%nsd$L5r3QV%X8@x*&1emm zk=P?x6o^F#jNJbGHuwzOFv+{YDhek?A`cfyPP=O`nSva7)R7^$90+9CG{Bn{+ukH2 z5t*v~>5ul}HLL>1mFSF{=!Ub(E(iXXx;l)NZR@nPn|Y{vz2=v_0-74Y@xs;h^1Cz3 zuzgNv+7j(9G&#vG57=amf$dTX(3=z7$*p&N4a3&9;?e-$A@xa(`I%!~Wd&1K&;Pi3 zN#k$1&GkETxZ&5?viFp75WX3iJE{@|kS8KP%Ff*z=YdLt`OGkGoF~p9G76p3&$J&k z{J82kh`qt=#f5X;J0?4Oog3dlWJ4B2`#6=rF;<)HER9Y|dE5@f{|;Dn2!Sj97?W!X zjSq*Vu+1!_(p?<6)*Ju>|w)Sm16a&@*|AeaKSr!mIG6Dv^kY^NtiYV$L z=?5^MVb`|@E@c=oP;o@Ly4EMuzC69H&9-l{>Aqcv5LP+e_$7EJ@wcD?x8B9kE}7E6 z0uBcG%@a8_3svd`9-a4A1#HzKiL>)fxIT05v@&NJ7iHuFku%L*n>+@zX9dZB#o2*K zM!9jj1gri}a^Y{)#w8 zmlc&Bp%mLO$;F_gyN}XOzS<;i zSigQJc0v5SgVa-Cz$vRmkcJM#`iGH$O; z50#t_M5pD$!Sww~T+yacgNcI=A3fq@3oY67fp$<*g+u)K{0w&LS#fPU)K0Vg$W}gO z*i2rt3ARXJnJ+Kf+sdlA9%b90=o-5=uJXpEAJMt=Bc|vmwACvI!=h2guvFwRkD0*meuxr?Cr@;) zdCF|*D=f2C*K%%TyD_Sjxo`x@!!6zCS?hyJE|6!ieponnQnqnsM?9>P|Bfi<&Q`}BUj3%btHquJLPAKJC`eM(FZ@z0 zux*vJO2i>Tehi<%ssaop;swZx_b~XNsVvZ&Pe4K>HwRJlVXVCl;nqD9EI|O*`3U$l zxIlz{uSNF%>2W6_^FjBPG4g#6avp#$o7w}RAe(CrH`(nxrjiA7^lsB@zoy`L|B4iH zRY_aSl4`d-4Pr@$sQ1-%WqqjAh&-&b!R>8GNm2$bSLrl|yCw)>!`OrF3qFnv!x5p5 ztJbku9VzsB>DbFWXUlar^8YX*7vH8rzm}3JF6|3T919J^NH4CzRw#{6dKCemBqjrp zkI^~E1z((d%e?o>I>Z*3Wv)8q#a1sPgGhWrPfkeA#lO*YcNHz4o2GTXlFfS9l+E^7 z#>`tQJq4^yH+!qij^t*aYxTfxPl55NNwwICecd{EGaJOr$+k0+Q6WiX3l#xi1)(e& z$NOmZk=)&mppy>N&^7F%okUgmA$ofbu(kjLC)9Z5O26hBv9%{8LUwJQ%*cS4S6^EC z?cb%jOlJ0ILr_h)r}3f7KC}8M5M>Kj>M>Qbu&|uOl0wK}J=Db0#8o6$IWKO|FTmmh zuYvmHV*_;s)N0oK{;b+3&n#(S?P6-Q!$Yizz}YX5%K<$nA!aa+452d@H2Ag=dlg+9 zP07*1!^g*$+lC$;p_}RrpYaVKKLRPiZqWyR2v^`Cl%5fybiIoUlhPA7AcR=?D27Y- zio(egY)gedn_|xUU*xO#L>Ed!>hd*h!20O$*1W_?AF$Og^#f$*z$r@TWi*59iW)~J zaeO|^ISP?!AvjONlDi`vx+4azo+1J#ja2{sMmMX2t%4V`N{bbL6j%h6J8qgH%DLV`!%=2RH=o18{Z zlYe1%eP*12p>XH!-Me#>(XN6G^Y7J;h|WlJo!gZ*xlv{RV5II16~!O_?#tCI1VRDn zaNNb>h9`J$3r(_tYA((;FG5ci0oG7Cz~G9ktg3oOs!&}+-KULe)$}xhQ;DMs zc@LE2D^Sv#-Z7>@Rs-;pV`vuRYmLdBL-YWzXc-I}WHAF+OvU)ys0k!ryGr`D7|Xk% zBnnzJudBex2-Kir^hRt_wGx;sRNX~*fXmU`%cGl0rDn~FLM4Q$hM@lzR!*pbo%R&k zn)GUaE9(4Y+mSc;W0C92%O%%r;$s^nU+nQn$CW-&{?vN0Ny2SlJ|LN=_Xc2`q^{y(K}3n}u*7U;sE z+tw2u)(NzPP!6+$>E1+m29jJhZv%WjokQafQ<@J&z38poL{ahnZ2jhEqS=)BkE9rG z${{T$q513>qjLIzdhuPHyBv2cq<&xA0^|*MF&u-Uyi_z&y2#1`1iI1?5i0h6k;Ux| zb2Y=Ku~`}eDo^(@JHSUPUz-z56u_6|K(_9gKA0+O=kV?lbsGlj%_&kqKVj7N$?{;e zSP^=m;hk1vLWb3@2tms@!ri#m<0+(7k(%Z~bg(cqxGi|=utH-5gZfiw2mx#`CS=@O z_1Jzg*$f#05YjY5=uX6bsXzR~K>I{?d|wqJ9!+Hv$TVQzbss_YQAgAp6V849&W?Zt z0$t)1$KE=&qc|^C*E`!96XqBS+!3g8tw0Rp7Z&32BqVDyUMQbJKwP_toqBc;{Lc60 z(S_}TX5rV{^XrKeR0vySiK>x!EHqV)1ArvXdV=Ja*6yOn_uG{38LWz>O?W+3@HJ;~ zJ}hl`>3bURKuYXGk5CCBKsq|Wqc1VG(YSUw2HKa$%qLDN`UYd zg-JU%v=IgpRS~n%dcw+qB%VVvD}kH_lI{isUN!s9Z9*ldzS4;W7xwD)YY5UIGSDPN z8-+6-r(Wu!H*S3)kw*}DE$|S`v#kL54Ke+Y=mlbNm-po(K`5OFNd9n>#bT9wri-lX zQR^iA#!c>yT3SOT!W$jAa#W;`9SC`_Pv+l9J`|l$7Fls_c^VNDV!$3$+C5hBb~Cg@ zLR2k-hy=5?lWp{a)!Hr$Qg@xEOCBHO{rlovc*Eqhm?K>2;}KQWOO;SLVP`K@_x*`Gtd!5kU(zXaQiGBqbQvCAmQq`v^EASqn-K zPY61SfK`zEBHe^d@qGGtgF)a_Kwb^LmoX(1rIW;F_b;9ohCdEF{DjFFrx}dZa-2XK za!pfq{juFWH7KG0IAOYrki&);Z^M_8;$lKWnvne$Vgu4)=ZC z*YH@$P!7_j0w={1V7r%kR;!6*TqZ(^-Ox1(EvY@i#HGV7X;&c1@LWq3|F;E6L z5{#yRTm_^roz;5FXo#iMD^p23^s;BgOUZ?YKu27HMA(SbPDId7%>ol=|CO0l#mZxp-?(zbK z(Dnt3{Ko!TW9+eGNfOx)4aXv~$8MbT(L1Cs{o6{X7u#SQh zv5@Eb^A}n9BOTk-XNM^e!w8FxUlV<_kf3Kl;ZKG4mI?oK&2;8%(pUo3OgFj1Wbc5smmsFbZ%(JAD zn`Fo!&)*JQCU`XvEPlWbpk;1X>(3wS__%~|Q*O3f#mgrIvx6s8gGY|&ZPUYtyYgeG z^?cAv;&fAAU*9JppI^W~#@$wIH&AC$k@eQtt%Nll9+i$Ie-JpKnp4`tBy@tvAntKp zPv8BDvF6y5wbSDq+fJG^baM4VXRY`Ej!yNj{Q~8vT^p|%k~1N#ibnD2lA4}MGTU&| zC;nT?qBr(lK0Yok?()}$r@5peI6{3KrPaAZ7}p!fI#Ny*{er?#+g- z1Jp7hs~JyvS%O*-x+V>(sq3g8Vjca^{j?1Z@}$k?%*_OnVJS!s1(qi6F3Za@56Lj@(tB~Yzms&U+)Y3y0AIYOXhNj z5SZeMrb?J^s}TX>Z$uP*rT>M9?xrEyA>cEFcJt#$( zF-01nBBYxjN>_#@!blqsLG>|K-hmHauqKCwuY9~?2#<@I!JOyXf1d<~l4ux$TiX{w zE=b$?^P73$v}+2o{M?R3k_`L%r|M*s+b4nT-*$c zNBg%j1v&Tc-`~k)4!w*`)`DA8zCwKukp0?UOAw6lZ!Y*?J9EzB(rc%m(?R;rXuP*(7CqTYqJCWCz;8%v8 z8=B|kD_1^*2!sFxME4AK@!LHF=qKI)5FqX(LXl|KkrimNjM3Dzz)V{j50D6CcprEMak(eOHUeoM#Z<7uk|K^H*nH!MceymLa26u6Et*!h6g% z_f1ZmX;7R3bIizhuei-t;FTa%Goj(?0<%GS=I0kca$5@|2iiu*#)w%II2A9@M6TxC z4c~V+d18HlN*@ND#7m7h4wK>$XLtpYfCyO`ohc(%63QbmBt?tuHeNdQH~f@MIMv-m z?i3|JU%aBwUspU_vucWBQ2VktOuKIDzwJHcWm%UGop>DS7#AUlAk*(nTrj$fcHi;3 zC_lIA31B%9=bd0ZH8pMFQK6oEMhpH{Ju^A?fpuo0$K;SWjq*Hn*cM*IX*M*lBLXkrlD`+U& zVYEqpO<=10P9>=1P`)M~Er(e|`%xagHnjm)`Wt(<#mBQyR<64y5PhxLUF>Vl?1=)I zl%@Zr?xG3=gI9Nc%KLa{2-H^F1Yefcv6=^%0=_5!(?8#zf0w+e>!#HOJ-xjusMoco z1yLJ5>;@F*z=STtpF@IZ0WZtj)gMFTBe>7}wf5kY6G1+9%K<>GKtx*!Uky_yBH_gB z0fmVULZis?14Kw%w?M8tt!~ngh59w#nGWqsNd@l{A3+}wqi%KA`&OsM8#@gfEng)j zypFnWO*IKe66DV^Q1^b{O1q?IUWzt174gUga1w2&+{~|v46((_1ZMQ+kAE*h2h(=X)|^cT9w3LhN#&pD#@`&!NDCEy zw(|mCTLzMk;y?p51Q;K813&Yx7k3!Y!>GQL#lV%YLh=11FcoTp&@~=+2gz>6zX?L8 zF9wDzfGKsHEF=>bc6N4G+YK^oy>fGNkIsSrF4obkk-yC$+gry>M@?kl#^Yn3QnKyh zol?$-|G`2oB{JQ%GY4C9VzZ|>Rh=xHMOq2^{SyIn+ z4ErPu&y~H6v>SvHnlyxjhYPB7fy^eHwX|R~fzH11Ui4r_ca$;sRYYF++w6}MLKLU-1bTkUz*h#EZLt!qL2|k8tAungU*h;Pnc4 zG&n4@Fs=OvS|K`EjtR`3pXg`p5f7)sT|?SjY)wVTDIuI(4|QG}{?dub$sp{obmJT# zEq#q|S@0(@F9JAqzG2!Ods6-C7|{+8@U~%ZoKJCrIF@KZmdUu((nf52rh z)zU$pHK6JkkPxLuKJeivJDpaK1|GVE&&h^zL|q6J9D^8vty_t*cGGS+qDgWO~C5eIVk!DcvfRodzF$eG$K z4tDX27D#nV$|+ME?Ro^gY@hv?156BJu}5otxtzb0m;YQyn4C}TsxdC!R$%Gb2}F@!>OI|hV6%Qh%FO#E22ef!sBtLyEU;E3 ziI01$kXM$D+2*0i$;pjdEhJU&%E75RG(1=)`Evk@P*KE9?guhW$f~tqG2s*^@NY+; zguM-JV_oo|$>ai{%TBY(YoKX;VcMqi7w&Y)NdwJ%PI8{u z=%|I${iwD}IC=J$>K|tb(JW)|O9~`36)do-aClMY*WA8nk>SLJA&NuaR69L1QI7q) z(l5NrWG#60{Zjs-MpZ-M<6D==0usw?51i?h$CLSm-`bv$Q|0^yZ)dntMYt8BWuP#Q zu-``zv?LCN-6-pQUGxxUiYd+t!heUJp#vup?6tgL~wTIO%P=b)vcU#H>q^GaOAOg16Gz_bkLykB_=M#up*MbKI^&`%~qVHGhLJ z+GVFM?wt6w=lX)Z&P*Rec1gsQtYuhGa8uGM>fRn^z$y)4WC!NIshrB%FGGGI`vA5e zOfQwrzt3C5CbQ?SW6z-2C>j^kzVBfDSeNl@l7x`7%>rkx;eFOwXO9lG4y<(fD}9;4 zK*P}2lSFudgX66HORE?J%xpKPWh}NBZk7Y`Q#NRRN0O53*wfcgmy&({4z8PZ|F%TH z+LQkhhpPT?#PVUqV@P?7a$E?Wl3L^rVOa*7htQ^0KlA5yaL}stWj@)`TUoHXERWvw za0G9gCKQV4QG%gc48yvJJ5acU++jbb6e?62>rJ&E*q&d*(&WxDJGtzQgL=C4XWqv; zpFtyNi?oxM&da0cK$5tnp>Rla^sp=QxIDjq8w&;4kY`-aMP1O>)4M)}8aB)RrXpr& zQxD?w`%?q()w}ZiB!Civt01)Ef$dqH*V|evPVfe2fZ%~!;4+Y(TQLj{YwPvf(l!K@ ztjS4dk=l`br=w&Ay-bHrbNc5@9r8oSSISZEESBSM#gT@M{6@_o-%D+ar701jnN{yR zO%4heS>o@@nR9LgVGx6g{h(*f{tRt{Deq$N8fkE?pjWy3@dvmbOX)a2afpYlqee0* zy_pE(f+Wi)w&c?)APrxi8&+)&+quF>YQ=NiMUaS{v}Tj}mGDb0(npub@KNLwQ3_5O zv0Wiyti~gog@qe)x>p0I-ZFoX6+BK~M#lkPyuy(<)xt~{=Q_w40V~H4Tm=S4-}=_% z(~+fA`AxyL^QyY+l_;49>O4BHKGt$`(M7v01yCI(3ZB-lppRd$+b<#1!wdRHVsnFU zWAA@Q@QF|Vur9{3=pu~lcN;ChVmjL3`D@I-Tv=4~LQk-0yv@`&nsm`Wwiy9PmU(!1 z%wrL}PcrJokEHNUH_yHMc&8#wm~*d{Mb3qHo2OO7m%>@I@eSLWgs=lie2GG!5rOKk z^lf8eTU6|!(LQAp-#PAwZQP3&v&x0TcQ%2ZZ6QQh9x>ZonEqJ7jWxo-tLLlO@OeZ@ zxisq~LpWl4uPs{El1IPcq<7_H6Q#Z=Cm7`7)wDACRXk}s|Lb!2Vx zUy4vy^Z=1&^YU_c3>O)8tHC4mLwHTf%#h)^Ce9PDz#$~%7laj9G;$fX0i95zk6Zqh zv}By5ZZStzs4S{ikp$)>?M#O$X2JWmXYoCHYf62=(3IZ+Z;{nV+K#?U(`R4dbDThA zZEA$B%rLA0%Ndyo2&Rj;?JNR)E(gk9y~*tVK)T<4)Cj9JhyU)37W1n_uQJ}UJuw#f75h;{=JSfp!X0M}Eh%#)vw&?ATj+($<#fODwT8%W|y zi=J9;#zhA1D>^@M$T=w}hO@-rL3SJH=)8ZBUTsIO?^K{!B6Vpcd?No=0zm%H&qesk zt5%5-9UE~(6Z;0k4HC@Jo22pJs_|6kC+Go&D&&&QwBoMwZ>jjtN*RB(VHPU61qmGasLV z$;th5vlk{rF81HM&^$e)7+cPE4q8hu|`?rk-v$qxaV{#8>Q=f}Yk`y!&OM6>i()6o9=M=2L{yL6$ea zsto1GI?S-2Xx%fQ-kN&hIAVy62mI_F|E{pB(ob>}htMhbiQIH8m+7vxO3U+OgDshY zaQ5TPUk?1-J@8c_pY!*6gEmG5vLS#v0b|U9CB6l^KePWdAdB_X-?vey3f^)9-xLnr zxw=sW;uFDRwiY?IOW=XBj0ZW?hgX*6rJF-D^T?i;SNVpUrAB#H)hml0ohaGHnQo@9 z|FTVRnDn4bg##8`sisqg26G;0@*%9QENU0^Oogo0>~pGs;|xWxIK{Y-?l3vucoUVV zg@Uq#fq~%*pqJkZx;vpbeiBUF)w~Q@{pL4`0PH_ z|81x{TEvpQqqx0xmeM>_Yh3Z1BVw=h2}gWrNKBNAnO07=@#IglcCs<#^Q-?e)QPup zxpS111k$hk9bNL^liiwEk>&E<4Phm;)sqD*Yq%`cTnF;C>V0;H{$8US+-=l_nTrLF zECx9-VQ$#l>_KdJ6U%=a`24GIi#ON_o{eDY)V`Hy9bd+?^QP;iA7PB4a6zebSW4-< znUPxmu4oy25XSfv@9Rn08QY#kmNmQvMowD4>nQFjgc&qA!U9>%98lTgtL!M?3(O7R z5-~gSzBSjr|Elv?OSN|E$l1|0a@ib}u->!M<-Vb$*68<`fKG^$+8*PD;p3(%@B z$9Z+zCe$i)3{XD@^o7C_aUL zjJ9G8cXN?i-?cJj=BtqL6ufOn15MDUfkXpJz{#(!E@q=xXRwBud7idYg6dzLzM6Vb z=Wm41e2JNu!*(*G%(mviAwT_i-8vH@(BmEWQRsh7hQfbIoa1nK7wh|<%^v>Y0c2gu z>s_IKe%lTUapSTqIq&g0ETi&0lMT(XI&3cqngk`&`o(DVET?}T9FT?7Tq&d$R4SN2^tM&ZP!5E|M5$Uy0(hX{9) zNE3+k4U&TRsXcnim?@>yan7mz$Z&A@Me&{-H5}A8&NA25NiEzwow4VS6Pg^e_gOTQ zSCMPro6-ok2Pfk50{3439J7JkBRpmzTsg#vZ- zmH%jRDsQN@u)&3nefMjFoYii>_GLLMEWlOUP)~T&P>jI?P_4#>NC1&%1Yp?+c>juV zOF#$K*^}2n|Ammda(B`#i2s#TV^wGvSJgQIPx!CQ(xMRnYf_+gslb`f&vP z`07=@S3(%!W*CZ-L5U7qVu}y1FKa{#6P9 zUeaaUw&6`q4mMuO2D#J2M&Pa8=Am?j4}DfFbbV*?M zVH9i>rJ9UW5bG^Yvnt4c{;azx%e;QwY94Xt{!9HU%lFdDID@4OjzGJOoHyKUyI3av zIEcQcA0u6W^r0&YMO?%$_AE@wS)C&BluKr%y7U4W7o@(4xh8<6e}{|0y|QD#-TBja zpi_aKi>S)a831S+rceaH?Z;@8KiVM0=pfA>lE8Dpql+=n6|+iw2Wz23eLA0dkb*ZZ zTvSeyz|swskck!5K`)2V)Q0uBj#fwhM-=!W?4NzQ5w`?p zXwbi-vku}bg?hkl>C&YqcBmiEj?;;{2mxTcUwYicTr(7a(EUZYsSb}0Y^h<2PTVqS zezQ}#rsIawp@DmFeH~UWN@kxoLHsKgDIHreF#qMwO$ri`uTYH)OlHKr^=s*R0FHT~?nZ9i^+pOm=^z z^j#6o77^kkC(f`2D2rDCVoE=H*Op^mS z%?-N;L*`#(`YrH)kpy)Kh4*2A`;2{3Fp4EPgr{+B98J=X#9Zf*VI|M%Eiq&N}d{L@T%+ z`zv8A>?4)&t9jKZ1Y*0_O1ZakVHj2p!3vD3?4K6F$#?}ImY3(NS2mx~Z!q@GBH=~2 z^TSI+PS0V$vjF3Ck9$dZT$p^UVeZt?F3&1~-zfm~hV%sw9)LzWR4j0p!}Fi@r)r)p z5o9Wkj9i6bZ^ecf0fPik7IrM=c4r0eh7|IGzCy_x$0)NNjPHR@ufn(olcFOs`hD;$ zrb}ac1|zwn&rjR|Z6XEWsr(dNm`!Ow)b^mTbhMF+=0`@TnepE$6MbgJBn^Qb$(mXw>0`6Rie&Ajp>H^u4Y8c|G=I% z3UWj@GU7p^(1y?1z$X!(d%4sk2KF6WxZ|yxvuQw;xC~N2p(QaV2N=Ey^Ms3oO^q4v z!ncWt{Pii+x&SA(eiz%$4`CFEY)ry@7xSLGXVjUxh0~Yb|0bxK3<5g}riJ@jtK=kbJ z=%_ImecTe;1op{aGhTjVtl^V%U20zN#zK#G>36?pbg$=q-;hqKt^64(eSI22pbV=o|Z5z;f-((z_K82$u{t` zPPMETM?r3ez;N>O+{|-+lR8l#gOAp`vz2X$EDw({NVkm|R4!akJhflaQ3F%RK^R7o zO-%v)@V8*H|lIbehkZE>e9-CM|#U>b3Tv`@P^a>jfWF_cw z)qDI}@$~2w-P9_A*fqeA@a|ww{037sE118{! zwCgqc;1&2G4Xh$dYMVZ-n905%-mZM2HO8XkRo((0qoAMw(Z|(x<(#^y?9mvxiGS@b z_wJ4^ueJN(_75W>s`nWvy2CYT#>zPT2{rT*WQfm>zbm=>mtd&zlyDAf)&%~Jc$Vn- zbIpgppt*VPyX1%;8KzMz1{b%@A*_f|Q=#%->!JU~a#8k}r_{j`$Z*cx@y8bGSVV=T zJ#osedxylP`D&Lz`D1B#tNcO9uVYhzPWN_R$#nAX9J(+uT;wMc{V%%%zKknZB4O~u z{}hCJS}8`r;tDTBq}X-PBG5)5e8G1^tI{2Jf=J%Q2b!Oq(VVL9BRRk zxe*XR4KqbZGsgX-?t5{jt^XycVZTY7Es;-J$(uTN4=Pccq6X}a7zp!XUf#8|YbT zwrH)#JYsywD!+^gP=mQJ1){)`hgm94eN6Ix5IZKkox<#HcAzca5rHm=0EU~ASrO5mAI=HLFQ`;udu`S zcHa2#;e!j^Mpfi4??>%k45nLfp1FdeBIBAh`(VfcMolLn2Z3>-m+2UUH#`hNhZYL| zi$x4Bet`C4r*u%L8YSW`TbA+86C;bF@dI9`e_j00!tmi>zFd!#=alWM2Fu2kmwMSh zEm`$GXYL+HAZwher+$`SXJ6fh-K@5qI5O~4FceF}i1@F>L^I;04LwDVm?!yxrX_1pa3BvZq3ZsUY9yYBmERlqG?DrRy7IKRRR=Dr z;z6K96L8WGddo0Z$T-$u#|5+5zcyT$+|iJ3=(iAJ4iiOc@jt+O%5LCMS~5#BAFQ_^ z_IOrnD1{)y67af+2A7!R;hSa-7jnQu2Yp50nVbgSF=FgNOiRElfHb-c3^+0d({Xf6 z*H@9=SNB4+z#*hHd>j2%WBd1<_^foXS~0?tg|4CzGn{t zOrG5pF+A|FA1cg8;E>#S9WTDE8)gmPp=LtqmKaN{`Nt(-a|N3x5k!y><9?Tb`s(Xu zK`tyV@eX~b>UotlrKtYxQGljSO27gnrPRbT+s*Lj_qT(R^L1RC?$5|~ZW$*AV4+28 zovBHC8I@JD?5#z?yZIE~`!E!az$E7;bD2!`Kdzp}@F#h;O+8?Y?X+%+z!AeCveK>C zM!`lHj7XA5{TT~-W#gA&s4@fmu+t45zUSVDX>c8qfKh|w#J7_xVAs*7i%-7JMFuv# zgQn)`w7mG}_trV@A|y3puGeXzBxb(mUEdah+x!(NKqUF7Ais}Kx9xeGBovvXqQNtK zLASQ`=;M9R_C`A0TnrVm0?xz;`%JvDg>WQ($4_AqG->D$t7_mTE#mOxL~gPnBK0zV z27eBxZ^=b$bN%LDbnJ_pnz{MH_Xyfs7E$oFLQ4ZgR=g*2PIAzpd~|K^AVJR@jh1HTYOR>x~$F%t1Mp;^!1c4RaRRB}vC4(7VM)4UvHL6=47GkvrTc^l@$z1U z(S?}P$Qg7c^s%yQRdD>kbOA8OPhuo;m-%VKMr@8#UaL4fOv~AOrR?{u^;}Mr7C!uK zoh6~H#nkvxOj5YlNIxZMGeL+GJEk=}#Fjgq8|i1R?`Ku?^4tDQu|zz5<3B=qEg z2@e+4w~4`Ze!d+?WGH&N&rbw&@|$px6OBPRSj%NV2JZsXzulvM-A%+VjaURv!n%Z# z`v^$2G$cy|5hcY4uSH;lgybO`=Km(Sa1$@S<*z&rk05@H9Zdu3Jw*{5k!&?t z^v|@ljlS(%zFf(bb-w7zV1Z?#5$Q`|mroK-(7U!oAEqP%qtQecdcNxMe$>+_Oib{H zNQf#?>w!Hv3e$iyBg{(YA3k(#C(Cq9dTJXgu5H4)Mn6X=ww0E(f6T$dWgev*AE*R5 zPYts7?bXpd!MRf&)>)XAZqzg)*KMY=rYQiKPfQC8i}f zt~D#8dDksP?|zuE>&Ys(5#NddO2$AlgUgq=}p9 z2P9+@zZsM*d4gQbULb0W0G+Z$t1h6>9i`Vl)%5*yA!jlTm(=@)49qZAcwJ{7?K`N* z(Hk!Q&N%Qc$1Sp+&Sb(m=)(E(Ec9EWfIchXG}KidU9P1ra}WyewWX()6>`#n-4rFJ zKFBoR28mtzag@{(SRo1^OrJv=gts6I8sknWdHn7+myoXKz1qw@eJ{hdmV@7TycKyY z%N2ProGp86XCTiHP_nO;=oBb6L}`3a!g}DEPkx8(S+`EJW2d(s1PyW z#D9)-SG6^vyqv@hEa@>TO+yt_dda+Pv^r#ybo9xQEov)yKEX}-w<`9q&!Glg3;GJ| z%W%#c={x2jBX3E^HE%9v#K*gdhHH;?_c!6rIU(XyVbB+8QiHmS8cZ(|MQ1*Oj@ z#FT{PC^cF^&dbEXAIHMP1WtK>?~)03h$&jDu)QqWJ5Cjuy(7cl%vmCtVttfG5dUQc zhCUi#l}lB(Vin97Y8V+>KindpyNsJAoUYG4-DXh$=arSrB>^b9QJde@@+GWo&~#P3 z2-!9zZgSM$ptEv=`A<6czU0d$Dh3;+8zvrDk8~{TYl^&c_7lWR0tH)ngRemiZUZk& z6d#9h))PBl82{{&+NSJ1bU~3t1jnFiU8-EcO^D4>vdu%OqZ;1e9r5cZC}-7`lzhbK ziHl8}EY$fQ4cUKL8P-2#IX7x+6~H6$+OjFa^WH8)Fub&iw#4H^Is<$U;-5&QxOw8( z3FivfeFfjk``#|bb(B39MdtYtHkN9v1&xMazY|U=@czQP-lWdAp|44l?A{Fnn6af! zwPNNH&re8-N6XSZ|6y?E2b0tjzYqalTXi7HPAylK9jzT*k65oC8QpKVT`N*DUDBLv z$d2=IA=?f2eZ}*W5^$)zM9x%_{(?IHR-`Y&^`@f5)P;k4s2K+E*CeCn^L*U-#Tg~K z&zU}jyc=rOW)s_V`TIEz5BX|dk3o&q9vYE5`nd}SKcCQew(hpzGJUlyYn28+eU*GR zqL(H@Ym79SNW=l3+8VBYj*G0CHEo=?@9Mg?;^K9QFHe>*O+U;7aWAK`GDXqvZek=+G z8$&gr0XM|4lu1YUd|HKg1#&6FGBojUKlKXR4{*LL(x2Ov^5apcpTR`i4Rbz!^EGb6 zD-9@8xwC`zBO+3d3+%V9^pbQ?_F5n%D5s?A?dQdt=`ZZkc~)hTRu~hCc>7kDjGFbbMg=7%Dt?dmqMz z%Ehtnm)F`DqTCmgT5Pju9v7y^t^QxK@XuWnmibcF+x4f$8QiTX%b|7-6_!(b|G3yH zO7M+;6tl!Fw{8<-*QBL%Yom=PE==Uic^>d;c@lsB;Hq;?4c|pKTd5{(Y@;R0JG3?g zazHhv3G%FMvrXQW^w5)OoDv?7yQ9o|tBP^PreaB4o>MS?Bk<1~_w_|)3U2Us7$Z30 zZGU0ThkYd*vd!SEraJGgd06|ubldLi(xbEpGZoIHsZU#$d}FD_N~>HSjzzj6!uJGx zxehnj4Sp3W%2$Emze6*d2Y{v}+1=3+t2x(PprrWvC{AGHP5?%*sA1yvrSC%i3SM5$ zte2#YJ(jQ>?&p~~i>7~>|C&FQ+A=aLM>|j?k}PrHlZBY`+(`G8s)Cb9*j^7=jO63+ ztVA6yL*wq5wTi4KP7hYMBOd>TOV2_1+>dt(8?MACHftS*M=L_?AGW$l zA9hT~STN%YyRrDQXvAg9a(LZ{IH(q$fMg`!kros55?MsRrl7_mCdcy)tL% zE|2cU<%5It7KZe&F4_kU`8lu(N!`(9)_Ca7+|ts$7g{bC2Prwj3vJ9@35A&%=(v&BeA7j*W}FsS0jae_zp7RXnEC9J<$hyx~RKkQ%5mHv$)V_+bq zsi`mvzGZrvyxz&-whLW;DVY{*FRk3Hi$4FO3UX0_h98nP{!5ADXUS14{mGxB;*qbY zzkUKdHa?4+*QmAc?;Fpz8+~p7wPdS~us)~08?*EHwF6$2TxmYL!^R@t!I=$?Whb&X z;ZfiiA+}*FrR`8N1$nPco;D+~HXhg0Ut@pnsqz#}8mM~mo0wA;+B8V_rkeCS`FUr` z;oteL;z6O$eVXoaxxDMBrz)NvV_zLywWb$Bp$h=!9S3DtB`aBtF1GA@tS^(*3VwZ) z$DA8D!U8Ml)zcFtDGhR8;sV;55_AjeEwWv^3aLPT;z1e8;g&-VwugS9EZ`LB`in_9 zfC@ItAGsAJKGL5y^igmP^Vx)Yi=jHxinkP*#YSlydG_wZUZQF`ef!&8MwDYd)TG-~ zE;&#v;w&`0jmrGo;t;xhl{YU5HfZnE|cpo}6vw=4_0x26<^MA8`6(BvsGgUt4m>v&rFVcI<_3P)AY&ZYb zZv!_=O)k!zOkblGc@=giIw{uzbzgn_ZmW^hBonc(QZ8wutuO|>9WRZcy(%>;THCEr zE*Bi1={_|iaL3HFNZ(vl<4G~(=t^2D_>|9ORcdiTS$Y(XogRTMb z!6>l?l8&y(%o^NvhM}0B=rQ1!(S^w@#nlfy6keukx!%okzC0l9#`Trd~jmY)VTiZ^PvxXV7`dv zl*^>n`8kcVONcv<-q{{`A*XRQM%2~Mz}GE&;eK|VXd_%*$Y}@$#eeU_O z0LJl?;|=P?!G+Dmw+erKeNLrq`}k~-^L3dgv~PO57w`s~lXDO)aasGDluxp1cfa+Y zn8@vp=Ge=cJ(ZZ!U$y02Meg@^+7GU3sha}x`u@Z;jiVQV27wqB|0?vm0yDst*sl+f z?vjKE$4V&gz%Z!Ic9X(I(T28l!^DYClIfy7sybKrX|Lt zlkZ(`a^jD4sazEWtsFWy?%b8JL+n`@Z;|c}f&aREGo7-UU^ik>|in zDAK_%%GEmC%wpAQd!~t}M{Pz{G`o~f92{wMFzh^3G)}!#;Dx{{xpt((X5_|m?6i54zvk&ra2the2N47@TUoWNZUt%R(TI5#5>8It_E z9N0seMv-$+e&8L4b?(oefS$qG85Z_kS95b|ck6b3Og37QU_eip%^(D8#j&1RYFfI` zO^u1cW+fUe4-(1d(07?>sUme=vdRXHBoYi^?xF!ZACSyG0YrlsNqJm7zY!s^Yr{6= zxZf0*$0wD}?|9>%IdW4|YxLLPtyZs96|wN=KU=YaLwx5Op6Q`Qx3_lIy0T?hl_khm)P1qf zpFLn~@2rRrRiB$(z^KRw&B;-GwjoD4;pv@;<&6AK^4l=`B2!tvyZW*G9+4$NUT9P!L0yD??cpvnIRq)zD)VyylUa6>{U41}4KNY30>G!%XpHpXB z_J>K7H&B^^`L1|Oe>Sn}Ktd~#KMdTet3;0c)k>S3K>Qt&EBXazlsMJ*-0Rs2>!n(q z&%}z-H}}$NT-c-M_?L>2g@7oCd1RZ;MaI@-KoByKP95B`G077TqW(pF-~R zU^X=SUROt;pM?)H)4 zXwJ%Otj01qc;P(A6I+-dj(c}W)?ki<1;@@0nnRzMK7ST*+Cj^;GR;czHOm%=<(8@5 zJ7Xl0h|VNxBWfbzbcPL{3a4R`paHWf@L5h(PM_*W36HxkgrI8(A3bv}Vc`9p>_Ix7 z)sSz%uas8I6ce3T^(6g(44Y4PJoyxlxM%(oV?XJfBY`NkEO>Fx=|^vE6aD#q%WqgH zs4A>l!9qMtbkTtWM66t&Zlm#VapthXrX{WYbZ%1zK7|y<%28*E2kx6`VR$b4OR2pS zqlyd%ierudAzN+Qv17-8_YxZ+Kn=P}X)iEQ0=T_YLf1pW&+`X1L=7;y=x~jYq%bCz zKVodcF+A#tb19|BFM~n@2D?$g{bSD#)5cop>bzaINo{8#t$(D&_b=-HAKNpXhMG@V zAAf|8*GA4zby32}w19ap%jHpyZ8y^U@jQhlsc=gj^P|-q2V)`<(ce$_-#Q3UmJWmw#XJ*rP(1Xc6)(4JF|^3%=Iz4y8E|u1hf0XKJm3{XmX~F zn10e28Qqs#R{Lhxtwl%-oSiYO7k{i*m*Une`}h=x`?Tn-MYcjFX^b`ouq#J^5{98$ z$7i$)3~;I=jMoUE)|feXfdPlktwJ{o5-3|7CFXw7ewttXh=XP7bO`)b{Ib)8U4ttjJF zrIV3S^Y(UFk4i=tiGhCi{x)r8D;t(p0x&Xl&P4 zIXV9)L+JQ7geB-2eq@^H5%_RxS)6*>baDi54*@>Nb$@@qC)^SVMHn|9`(=#R78Uy6 z146=wo9bfkSAPhuNf0<#e#R!>gRf5H**=~9!FK*D%-WkQ=YznE5?!L~=-0y)S~F+> zQ)f)4w-gsOW0U|lgH+?9)x`TCiu#g#R^W!xO}%etYCHpWP|rr4H&v{=#JQlWzKKz;F<~viLhn#>;c~ z;fQ4c_}?V45qarnG_D2fV`Dl&kYQ2R(xvJXa2?DS!xz*~I8k}5!38Pago6lwkfocS&p~8v&ES zj4u=QIXv0F4mBQ^Z<`!0;&B3hIs@%S$%o3kR?76NT=@cG%<*@RFAw+H^o zu#PURIcz=fozy?v+a*ZmjhHm(D_q~&L$=+MQ&NgH)|0k;<;u*~|6o&ETdGB}p#<8# z)AC!@U2p1L&T>aERFvN|QLpeZ^NEgx@KJ}y|lem#)f{5$hpbUb~NLX?HInrbHJV?8WbY*WcL|oz>(}}Nq~uMK4CUMBI+?^ zTzeTthAEE2ut6~z8nOM671OqO@66&WSLSHgUl)I=N~A-C%eEq(8+&B`IBCCRyE!>I z5sd}NsC@}?)a;9J)IROrg*uRi38;mw3-Bymn&p!B;%tCu2`_I;0So+XO8r&yy^bd8 z-6b|8pc1lO#{aWpV6ar8d9k129=NDD0DESnWP%Vxd^=&WYIvxD_c-&Mj6=P+lF7wW zq$nKLP|JEng6|28Nr3P9nE(SzclR==LsKMZuY2vUNmMYE3 z1%U1>1~J561bp;OlaRtdi4+4UZA7K$SDY$$Sa>+sAIN-JTtq2 z{qIvwVZ#3j{EAy{38f}{Tep6^8&8UDhFh1u)9jsHzKVst7x7nG>iaXdye&EQVBz#+ zyr!2cAe0yen-#ciF|p$I@R&=tOp&v8c5%7Ir??tSp${1HVt?%=C-wtx?uQ^J(U1gq zX@qygkSrbzS)ZH{8@I+7Wk& zKxUg`|1~S-ePRJi9I*G&cc_ToUpU66*c(W6RM>J9|!b4Y7HKaqSc zC?(U)rEMlLY)U1xWwLvNyJ>TwzhS}34C*m!UHUt`ZLL*5z9nK=WFz;F3Rqk6#gA4l3<+tgLN#&u@s>OI1ktmD^ z3?NQ1Oq(?>gKIyGDBpWrQ7RXz8*X&hr8BUL?+i&j>sg_JGpg#ZBh^puXnuIB708WZ z`YsQZ8LUg4lc1+0r2q{yUrAYAI&?Imu$%ArgXI+ggq?=KhAe7Y*r8k6WZW@0w?Q+L*GPXutr( z1jO9FZ?Am87B=a4B7d0Szcra)XrFI*i1n~n`#b|^HwiuB1qc63H}b@^o4dTV`S8D8GW_t6b9+Mlw{aCbJ{yX`=Rq(X8xUC@_F0%?5&6TvDNaU<(U zGx_xXA77uNo}Uh8h-VV@`di^i++ZCn`6O*wx!R24lcj7IFQwZd5yxL=Az~f%*;x21 zaHm~6_I%4sM@r9YM$sDB6@LdU=4K!)f=q%vKtD0TeC00FlLo;oamUxD(U-O*>Gh}^nV*PG^>a08%Rr=}jX4~px$EAc+zrCr>)(PmoYnm`_7O#7-n8W&|)!c|m z52ZPeg9Q)t@+N)PJD9h{f-eoXC1*?zx~q^QlQBE_NYt`F3f~q+Ndq+D@y*fW$cueWQ)w>wYhVS&q&akZHe{x_+Pqp^* zEk67FzGb;MTyej<f*)>AFTZz*{{C9H0~<_jA>AzBFq}@n)yU$khA!97XcA|>_q=a)A$HpZ@@pV(Xw|e?)#D&??ccVe=iIH4k&*GHGvwsL zJ!)!d0RV>+fd_p@V%e4v8zdZ52t>O+1Z`>YMcG^{)q6Vq{v7?jd7-7@rSz`S+)na) z#)CgU^6i)rpZ@vfg8Wh%nl+7APc%r`cKAHiP8y)t;Kuuci^S9EeC|4BW#vPM4!w|~ zmET>sV%pFzH(G~{FYmf?cckgqvehq5*yST)`FlpoOPLl0GVsObd0Vb3#vc(#)`mK5 zXJ7OdF$rhu>l@9`pFRaEYc?7*xgUTRj-hs3Bs&PGmO5a>B zUok)NFO!#LIBXgj(CcWbyZUCEDS-M{=vUii3;XTOfkhuuQ(N8jWWh;J>l-||o;C|T zSowwoju$H3-BH<5d?Z}F@||a8l+2O2*|_WLg6iuw3aHq1AL@=0Jod!sTj!^Gu46$) zXCv0X$Y``ujo7V878j^Aad2BQ137I0mza={Dxzh}gkQ?Qhw_o^@#DusNOH!;jT_CN z%RDG+RdIhm_GcOyVBKdD;A5+~UaNi7rhK5GJW^0K?DQCqtH#zJ{r10lOro>;EP6gL zq)J%VzcySUwy7@CrPjMaiqXkoyX~OGexZR=&OMK?3I-0III*mxI+z!9Qmp92xbX) z?%jKesJX){R;+l+kn6%HA)yUyq!KotI}!15d6gxGnVKg~+=I;UDMrS>TuTs%g`Q?7 z9Tp|mo{JUOcq*3?Q8Z6xxM$D0nH*VUrRZvhRT-R6(lZm?@Mx4zby%(!o_)Ndcv41Q^KD&UA#P5taWLVYwZqnE4G0?(~wU&|5E$Z!V zES>H}^wResZ950Y+c&M(IbNQAw{Ql%!M7A+Z*Zs*kmz$B|LTyEQU&b(SWC1Py;f3D z(ZVZgG+h5)RIP>Hc=2rhH&!;0+`goCsBtRGBF78bwVxf=)U5S=WplLx0=36r$e(!H z{pRYRvIxu(v2-@p6$J+J4yUgw;D zI%mG~ozHSz@9lCbm3g~+6SQlca4}ii*c_vLnewcRe*5+l2;eh;vi>UTsJpmXBgVs1 zH+l2WdCiF-ox0tAcN4#Ge&4hCvQDJHDyX9%wa7VTnmo;g*~rZ~6WwfqQpv+!kXwAl z!rC#^6m2XJdW4s8)%H0IrqC!Ia0@AM8oUtOlsYhAT31)cCn2E+kP^8UCLG!K7Nokl zyRXvY&`Q;S5;2;=V=ARRe!qj+>gVP00s>-|-}bydC8Z67dWU1_p#Iz#M}treyjooIfl|xiS8><>)V5V)xJILHMboc{`Lc9`5+kh1p0z#%BuJEzA3rf1Np+j znwf^b1Z??Ks;M?H(5nqSvB9-#Pw1BvC#guOZy%BZ3`jR^b<%k zq(`0|5r;6p%^LI5V7EM~f`g&c`v$#MeruV*EMenH&m6q(Y|ds4$05eiQS@X>PQau7 zJG;go@6N8l@8!KYN$;Jgevx76#Cc4mV%_x0ZWIQS5zdN@uo1>jh;daheFZk zlxpyQNrN}w1r^C}DBcZ#_38w7^EGlc zjH@y(b9oQ-<2#s|bRX`%buzoWpJ7B1SQ|E{z*Ab82}+PFdCYs#h0M@U7Ht%^p^?M` zsQt5DDnlse)pU*MnJz)v@@#jkP}R>18X8IV0ctxCh(|SNsaZ$hi+%efYK`tTkCg3y zkYmb+p;gFP+>H5=;2eKPPR(}z@zY7ZnMOMfyf~YhBuJkCx`~maM>CuGuXggte}@Z_ zM+L5WKw@Y8*j_m zgTiTJkJ)o_8`*ZK<<~`C&TCe0Wqa*Y;1iPz(N5Uj3-js;=U%fn;S%9NvDd%vIs^Z9 zLA$p_J5>+PC@elJvJLZJWKo-Fgi(sxui4U2<1ajF4|MH%a)o6XFG=l@>)c3c-!f|E7IMiJFE+iOkOH+x}HPaB_WAR^vnoSl@d~=|R!B^5F{Mj-1YIfL>XZ5g}mK3J^0oaqCMn^6Y9RSD;IMe(QLObui zbbz9j?Xt_e+-jQdAfzEO%r`YOfEhYu+^rlEbkje7JEC58>9S&+sCIX*O}RsZK#R}% z#7GnM)d^e*D&Etts-x0tI#j1HEh-Y5JF<(2oH|Qxy$(m>fQ-qNI*dnK zb&2ZN?mQUjTFbyv_x18)g;&gLM`o?i`hwH%gV4$xB2)wO@cD9O`=4L%Wt$aqqITc* zhz;J3kf-YUjxbEqHXQfzcGd5#p-*RCCxCoikL;Thd6JVe4=lBP%rS7ir2|B04__!S z7}*pn-2HgR?v4*ijC2q*X6@{W?p;&AyIL;H`>&@)MMY(Q`?ip{QOBA?QkPVDI1@!P zY>79j1EprOyx;i~cyg;=-@w$QY6{AUCS_8R?K8Cc`)=P0zpAN!eIqMWv5zbuRK74h za^?F;=iiXA3H9p3)9FwVUfF*_*3frV&u+t$k6+DqbMZ+NQYpKhBQi!Pda_vrtYDWM zJ}#jQnOqO3nL`ze4@fp0yVxFQV@-pDhn`ZYr5z19{Xj~;Rh5x0vlqOpd*Bv-kIZ~Q*?d+PZkoB4HjFo?*3z}k1YMKU@eAq$2g8aiWE zW8yD4vNg7EzZ;p9+sV0dBY^mrzdOLC*wn&+HJ~V?I(Tz#p{p@UOA_)?OeJ3yN4EX1(G*H92aIYng3Cx+f2I`g< z^eVS6S0=%KCH*jhvdZZ8c@GN)z+L6SeP#zSv@=jYf6{=9 z=h6AX2XVb6PDAp`wiFM$mHT3|S41}$1WoT_Z!Pf1^!uF=aZg(pLt$StU<=z~l6@N= zNW-h^C@)lhjNoYpO~vY>&I|g*P|I@Q(G@edm!0@B5v@wE`Yff8pNS4c^XWc^ob5ox z*bG%)@!8_=qxvpC7I5zZT_hRl{+jZg!jT=9oKDgsbiZykKQz zCEebJP})P`k%HnLydT{MBnRFV6^TQ>iw@Kz??2POz;?*4V_D_aNvvGFvFtE%kUhKp z72Cu{IX+OX;Z!}TL5VyhoQl5Pqo^wdwTQUu==I84#3An2C)HlF8BsX{J{^i!9-maj zAWg&qOoa-)oG!)?mXpg%QPoCNwks}!e(qmu%psZKW0)~DQu9(FSvGK0!T~Le-Q)ZU z@o0?E*tHEBM=)dRLtic7K6y*x{?99b>U6;h)`b{}J4vv$5h9kGYfq{dQl#4Fa>U%w z5BqBZZHRKt^~Xru?umt8VJM#kHtFuje5?f76VPKF}s*9iOtE~2#w0E z61`lXr=^&wT{n|wd^B-v`^B!@=*iY%3Y8KU$Qzn;2D#MG70ebPZgb19W(R^r=XNj> zDxKQ^v`Ii61dYN?81<$FJ5+i(%Nibu<;9@a;5dhAL9-xC>Nc`XN(FQ+19&S6R3zds zz+Uguq4-qJM>n;h(df)E>7KuMXyl)B!_wy!i{9`uZDOG#0~gA!W!ST^tT~;Tbt~1a zCH_r{P)z;aN4T-RVOcz~bG}FG(tV5XZ7IS@uFqG-!nz#OlP{aeV29RPqqhwKsdD|JgV1PyzJP5r!+!b6mvjJI>RG zKcpwR1s`OW*3DM9Qj}#{UI2X11Fz#RT>f<)$lvfgx)!jmbitstD2zj*aQ#E(=h#@= zONTa>)O4cfy4xPyX1SIxuYrg<2scXxguuowx=-%(gjx*!ct?wc1AYT0uG*63R8{xQ ztzo@c$@;lbC60_VDa#Xoo_nKoD5fi?kOFhBHpj|Mae}3W)b-^Z`wyM@io}=yz!Wm* z4uI4xgxhY5MrD)tD}#n%L?abU3XoQZ`h={*bOc7|=yK_FT z8hy7mo3`4mc7SaSl}y&IZiod_FZRB zjqRjeyfGZ6^ZiBM_LLxU>>g%|p5@IT1}20+ z#?+^~)dF7i7N#}{$CfykI?1fxvzykQF6rNX@K;`wP+^~1%rlx_{xdShon4qLW^;3c zoZuOK!N&U4=w+%$#si~vdAI8nD*4OUb(h8DKYjeRqhxrv?G&g^5#BQ9k2OVC3qGBn z^8u%zZef}or@R34?-Hxq5RVQ`|GcWID%y0_)>;K3)3?e|RGtIu_B5f^)=Q;4+&4ce zC-8`BD#)!ZQrzat^^w!Kg~TH-tS$Vli-87HTRWy)?q*Z1&P4Q=+=d|SU5crPkGyi{ z@L}{+^0NPsx}=iUUCoGy$kc9gzzI-}nUM&@v)jD|+V!Ekmo^li-y~vZw|qGLxh0&1 zFl!%*^&V(P#8Jt^Pe-USJ(XppHh+rQ)&t+-t7MnuNoC^^5pvc3$Si6t)7O`K5r>Wk zBjE#IobT<$?O{?IVAHuet`kir6gp+;sl`ibN)xMoexFe*w3(C?mkY@v!=5Z$buDCcWf8Y?V}S$NWWft2W8bFwt3NpE3b zu5x!2-ZsUEP}nMpoR9v-6Kg2uFl&1BlzcLZ$tL z5}7^ecB^frI(&S5mz1Aev9`8Wi8w4*{cX43oxd%+Yt3=K%+Hek73WUH9qa1}b{M%f zJsP53sm){8Ed)3;(kggY6)~yFVBW$Ly+1I}&#q_? zEas3NZA;~!Q_)_X0q9E~wZ4R7-~F}z@6n|-uw(n1=;-Jz#=Y;(|La7Ry`d_~C46q5 zpMiy-pyBBxADf>{>`h}kuS~l-y5w59Ta*{9OeWyk6x0-h;^m3}nq-rV$*oYO4DS$g zXqzYyp0t9nV$$9f??oj(;Wq5whmpQS-XN(c(C!ZorEA`)cOLAs&ogK5$v-6fw^lz{ zCwCeFpc(F58_xI&^S`d5hp(b+GFslAUkLnd(r(Dki`ZUU{Ixu|(ZA9Y_f`aFU=bJn zLDESaIR{n4J#bp=Vk#LmuGBX!`qAa`VLQA8_*}+gh761G5~-IQx61@H#pokf?O(Pe zpWQ+9p*KG_{w&;*;}_p~Ab%g#8$NmQw7Bc@#C}0`IeRY_1mZgSocf<#TVzM7+kQQv z+&zb69joHHE#SzU*?^4q?Y;_$&tuyVs$qXN-vie+IUxMf%m!+b>+>0~nR+dYA6hjP zRM=%yxs?0Z0cv2*venF@{!-pNx9j{t`mIP@YTBV-GtsC`rRb-mN^Sch&q9XHbSK1< zfL>p2!E^HP&G~pkDjMbOSdjy3{AI;S)K+9)X)Ko7_26s~vUHGn8{$xSZut$>TjB(` z*z&4NBsosAZ7v2CS%-91&lL=xD%Io9N{Tp~Xtf=o8v6H1+jEmv<|}!G=X|)i-%cr; zL8i6CD<0ki(tAamdt2{G*wDkIU9daR*^9>J<_-u2h>nc>2wLvhe&=dEc*+nz8nmkP z;2}6jGN32{Tvh9fNHN8h=DW|m8*cVQ!tm7$l_L%+t!EU``BsG2dzl}%?f(@rubW8i zFJmw>(7QE;_f}-Pd~s#*wA)Tf`I$-Ec|r3kLm2z{3*hcm=>6?LfMlF+83=kdzj%il4%k{Rs}dyEBK0Qou4Z) zD-VlJH4_x=75jKMZcJ-D+9Mu42>;;Q{0MdfiW z5vSoR8F1M^S>Q|R)nO{SWz(!<vMI*)db>dnG&p}r&`1|7CN)- z<`sN(E2cQa)cj|=M%hyU+Gu7aw*ncKQd)Zd*R==_7@Lvuha!a0BJ_?ORQsRb)CK)6 zfh01=A?)CPKX>jmhUL9&R-0?u{%Q@Ew+%BwAsJjZP+zCgCdXTD+U2-BrIsXZ#Ba)V z$vtee7wccCt?g;obZoW+VrWsx0A@8ti4b<|Mt5dCMIB_}B(DD`vV;kq-TJ6G!wkp% zCt*v zr_)KE@ttJzT`Z48Tt9TmZ#*cROA}_7zUk0)lS(lv^`}V@`PW#Ty%3&1nwr|d{A!_s z0}Y$OxQw0Js_01-MyKq2*Zj z!`gP;1)RRE(GNgjV42UVsQwQUgia7gprOmO_VxU_8j+ZoJwAR<1^2&*kYq!Yx|Ebu zU#W9STfNe(LvWpJFEt+5-mV7&Q8xnws?PC|mhMeCEPb2xuey&?&wtA+W1-{gTMb_$?WNwTdC_-)is@g&vhk7rH zPrurtdaRmtwo{C;Ou+E8BVJ%)BJM;1Y@`EhEK4rTsi!2}g#mkq#fM{3*n9^h(eEx3 zXF(Sfw2JefwJU~23OhcZPZfUS%Pn|wOq9WViZG0|3HM`p0_Wq1n~J7_;OkR%gQPlE zo2|278UnTkFU-SL{kwGxK;|IMYhG~vw$aD(4ECpzIMaagt%X^5fO(M^6v<@31j4{`*!_{HM#rh zGQ>2h=9sA2CTfkQi)vUhrkwa-fSw3-c&zM!kVj!@#uX#G?1tpqQ>2T3kDe|Mb9x-$^kT+ZsvUhMY4|FmCJ__jZ zR9J^*u60L!LG(xm6E-&h+{=U)U=BZcCum-l8IfNWK|$NYzWoE@Z)3$|_3bK!B)F<` zf*0X8(l>0X(ST{PwvmR3f0Ws-xD!fhv8uc&51b!(x~k4=^~?_*U1CH=0K4*DrkgMQ zW=lfChva@iD1$LH-`}l4AK-KoV3LD!W^^odH^=$jW#RJcGkCLj@rIQlq~^@8T5>@$ z91$OUC(T_!yZ|9~2eg4`rc>%OFmOG5_N`Yf;Ao#94=NLqVk(B8Fzy@hf>4tmdUtC&6v&0DgHM5{5Y)Y zRTZD_30!I3m|)FfZ!|)3l;Z7!S>g6l4t?wY z9l$uM&*PiX5tc_u_FYjN{zV@3esTL9NGUFa9MxFWxxy zgAj8}Fv6)KqbFt0zZXEW{}xTVJuNLM)^?x&Zl=DIk?e_Yk;fINQAtBi{g+mz_zha; z4oogir@vR=-3RMdiniHqFX?cO=UmMEW$VV9?P)5>gY^Xker*VDa_h17I!qY24C3PA zAYmb8MyKn>9+z!OuK#(8--BK)ni6ou<9*dGe9vTlbp(M0a8rnP5_{{!Gb6LgA@1KSdvQR-Y#`yXt0lnFK3mgx%!X@NJJ@W1PNENo7jUJoMF#m||1AvEbpH9U zQIr~^!r_3X1TiJ}f5mR$LDh15S!h1V^@evFf!YZ~pth>2cN^37;5%&tk16d40k~T1 zgB(;e!kh-74Y?&FX}~5eUNyD*?AIZb+R00X)H_GX+{;g+eOI$-3YFnsVcht(Z8ud_ zfr^dzAJjao(#DAh3aOY9$*bn$qa>gBPwk~NpZfJ_s!?G!4VQ`oSVAMzUnFn`s7hpP z=XuVlg5+;?swJ{3d$03;4OknZRjV;kNsfKsIp0jOc?ZYSF~MwX`iq!4?op!R^m*1e$uCCX&NSfEFy{1g`2LtXa4ZC1!MPz}%oetj*y)hxn7=Z^6D{b_C*u`AA;s)l zHAN_OdEbd$x5?1Y8BVD+a19%f`O^Ty{)Tp>=s!9n&hIa3&HRgXu~l}&Az?wo;pMyk z+SzUvw*&X#Ac~_`DIzN3Eb#Rhk-&ZBUNJyQ9OMuuT+nlS_)DqMF@#6TXr`-W2Fc3K zEDaw$B^R0NzwtA13>}$G0tP_F^9?fnfg>4$zlt z{ACgGsjDmR{{HVP3@oB)8x|RgyZJ&<0GZ!524H3H-ZYG30x;#I?>lzd4@zf&nhds3 zYf@_PQFR$H9SX$(IkxQ@Vc~8A()t#0LhNbAtrHn2RF*F*>C3Z{dVnhEZOE@`qD%y%(tef#rDAyA3P~(OenBU zJ)*gPu$;157%*GzQ&<$>f9Gu6|k(9gR2&_O%{>HF){ zwY8r~S>|#X)@oVRo{r@|llC(UKT|yL!oQ)R0T`+CU;d&&wa2fXF)4X_fYmg2GvW9@ zb;UULKC2lw&$;*-1DmuJDtWTxekr^n*W)&r$Cu9(tT^2SA7H8)>} z_8Z2HJb{*K1*4` zzt5Bq{r68gG9o)s>l43|%S&88dbF!grtF52l}a)(qAn;J<(Qk5eKv@CwO_23xJ7GY zc}ZE}D~mmOHquM$y;0#0pP^C*+hXz@U)h5*PlUCX=5^t$qpvI!wks!xRPpfW56n{l zui&@&@VJRb&|HAdVHMi;(=iLJx$h@)NW3XG;qy;KmD$g8=Z zjma^!E3OrBZ>ubu@^`w-M8~XvdF{9n5U^9>-$&k$?$nS@+EUYYFA)vIS`FY;Kd6hV z1b#EmxK0$z0GxR!4l))UG;DMyho0M%?rM_#_zw@Db5=HpHu`t~wYp6)6-GD$sA723 z&CtSH@JSWY$F+@!4q#t?kcQX6Q>9c%9jmTsnK{NYR z8IL_wDNG2mzf`R$#_=Fkg*lvlru}Gb)%Wx)`LCzy(o6b@hr3nU&U6>OJ~X1hKfJT^w0lRakH_qy*b&DBof^` z#Ov7qoR;`-=B9+bA$6ju=@|{8ul^qJ_kWe-^zKm>O*uQ&QYZ;1_Xk6_0&$mjondLC zc!xYAi}Sp^Y6?{DLEkJs<6R%Lc?yT{E7LYgE)6V)VL0^Xv9oJ#ZfFJCXG(garG)_2 z6q(~ucT^v>aA$h>>c;4{YaW@uwDGq( zl_VJxDqw5E0JB(rX^}J`=w+d;o904N%K)GTaqA2dr%;lgn@iiT^1_a+*v_rBv0)C* z$0pV0OSif%=$u=#It@S<>{so;eQ7DlPBB3YmM_Tx5mQO5*kuyYxp=BgQ`^{ht@ZVf ztA7rk7v}(2a3rP#Lvw%Fq3ae%rLu<7!#su`fhwkcgQx~0&OB39ZG5R9_P&;Qm5Ih)Ry8faXDd95>6_!&zC#{C2UYm^^oNis4Yz0eI_L5)s7Of(Vb*Q_ z=SQ-d-7D_SjrN@TEC>~s<<}qK`p6cx>Gdf>NF8UXiSNYGEL6XvO*MSx=)Md~rFMCj zM>V(5i%|dgX8OCXjbW@;=6Y@R@5mo69m<{v=hu%iF4{9xJ~ozGNi3ldu_v+aTyD1k zd%jI4A6MtF2Us5em8S-YJlReMl*K<&wwm!)iHL}5pC{WE;|mF;|>d$n9x+2z8awEDi{k+5x~4`sV|o`!N7@EOmb$9wAkbIZh<+jlW-EGSNg z2y@d9tnc&#%fG?QE2%SJ-5O|)4a>y|AT1}ZX-cIIo7>=@ByCjA7xbl|geE3;5q_Re zg>AKTyCi#+0fojAp;z@?RLW9#X#dqnbI&dGq+ARE?}!iL@N6E4L`y%+F20Omq`B<+ z!;oDmvg^4Gx}m~f*T%kkD(^sCx%InUIa9X*8j0}d`|~9^oU`;*h}F)koB=(}eS3n0 z%x-N{iNLkPxme?F&eDQZX@wGW^LoM6r|3(L*|dk<`L9lYh$SCxfMY~Gq3?)t?Q`Yj zeim|K&>zs7UL!#0wU|un7(nQvVuX}iX zeOSq7{h|H_30=Nb4z0uya4@)FNp(A;zCYf9gOi7foZmYXCK%fJ`L1U`!f5g`ENu7` z`0HNGyKLjmB6oK#tC&8(C`aq`w^C!#YikSN!OPHTQ)#i0iZ&$Z=gftCL8wdH>jQYh`{Jnti?fR z+4tjeit|fNgb+pGYu)t<(~5~4Vt11Q>HP*^Q*>tGzZUdkP3xxkmPgnvy7=kip)L2 zS*BJAPU09M{E`|9gEL{rzq;k6T6^J9G^2RQwi(U0r_J17!84UxPiR2b)X%P1;w&<8bnBFmrDYvX)dB~we$cSUO!>pF(^->6|T{^=v!&|8+z zx6sQ6qWaD(+v&7~?EWer=Idr)^qZj%$p@c@3%9*?B5k^~90&vi1pOP=0XP?7bVdwV{wl2|Udw+rsj<&d^5O10Nrfpu@2|Snv(xJU|KD%GzcV63URwm& ze41HqB52_msxOv4B{yTDSYmD$)p`vX5x}Vx5lIN)F`Tbzw?{2J2KLB80Ds9zTrJ3R z{z1&7W4A`d!nKS5{|xn$(@z+o|7ym8fyPtWfs>eeNmH(P0sHPKzv_P@nL z*UZqvSlx6D?^b_V$38=b?{dhu7~7y|C?R`o`&pGQ4!S%o7ViK0l)<*S?~I`O3{J_u zPnS(X75J@q1q;dqtIjZ)WsqmNvO>fPz1+(0jVv3V=ry@#QEg6HP|Vu;!Nov+w6fM+ zt$RnaES&3O>1j3ydp@FB)Q8$~#H+pV|EC~~j?x{DG_8pdytE6hjk#%DoxNuKJ?uCW zPpQdFcbl;Yrp_3Kw5krxbvyiLrLqr$Z|k2l6jcCtGMF{v3D(KJz2%Tf1a zLGp3A%Gx^aa&VU0L`}PtsAzC?qDODRIZk{ihhT5VGu7zkvIWoKM<-b8e-Wf%_(@BZQx4mT7DC&24C(_S6eoE1YEpnI>0p@^NHq zxH40So~)VA#;5QG$xYE>s48|T?YRRCGAUew7UoJ3Y9Ytss72hauNU@=NR0m0;uEZ6 zJDDyS5v8}rHX`kYzDIOzE0S`)i@hOxF}nco&7}cyqF9ByPdtVmEtqo!+{!@DBEPZr zC=*{r9{im)>lT&xfF?=kwKDIUh)u9;tfuj_BwBM+-my86W1ggY$GP#21s6<255xq9 zG@I#hPt1#Szw&91Txmd4<1eMFery?9`u^B;0<22CE{y#XKJUewGsPE7nXT7 z1dR4qQ}x%5JNB_`t-~0<0J!*hLPjDr_i>KJI|OfSK0m;yfND>RsDp5fz%UVSmxj<0 zHI-xqZNU%T(0n_c5i4-_ZBALZtB*{fmQ)%n4a+_+h)8=1kd|XB`^LSHD{E8h z@G%9)&uIvpQXIn8p#q{W1g2DbIX75Bz)Vd&{NphD$6!(gPidv6W2uZVF~!Q|^~DF+ z8`<>7C+C#L%pJ!@jjvy=xB<~{ojDdm1bO4?yNfBD&m68#xiK;htPHq2d71CcWvH0<@NEVm5vE4KlE`%Mm!eR$e+8B6tH9NmooUpM7C>W5i685 zJji}weW?^UT1ou**BtCsm+x$@eH>&)F#H2e^xCDXOr;1zPwLTY4b|;Vusv}>hD5lj zP%M~ZJqGH{J$@;H@it&fz_{qsmt`!t)yy7t>Vb1lpKhNKzyA&l7!F|IIfFDqe6Waa z?7HB(`{2!wH+oNoC22TX7l)O9yHPCRFcMVJyX>-ay`yYW0QjU!>Pg@CUpU)Sxzlab9yaA7t~~bfaMD zpr=WAm_MW~gNH>mg$yt{(8QWhX!L!)`xKNGyee#RzC|1XYj^AF>$89$4h{=50P6GL zsmt%c;R(N!k~BC9claI%kEwk1YCjkQ_2fO7QpWDK$#FH32b#8!Uy+S*$^vuIN7CfT zOVNBEWr%7p9HvX%qNSyi$9A`^LJp{4Ks_UeY-u6mxnO#eNSiQpZ}n8ktB;WLD!A&D zPak5jWgkupV5lWNK=hq!QBeWd z+1z;=mCFq7N+vnFpSEuH(}m0hiP1z7MC4886x|Q^m`cLUSEF}iqD|`@TG;qdzoIP7 zx#+agZ><|BTV54WMu%(@-n#M-yu0&;=QL>FRBQ|V*u&yE zFP{d1Bs*m)hfG7_IwcFHlF=^)-GyOKvXCDfvg0J(_JxKs;+wYJWud>$81T*oKXpB9 zlDHOk7lhK%kTdgeVfZR6l4?l*iIaXvk2p*Phce&|DQ(plaMRNLFmyR^XRet^L=mJP zuFemdfKgKmZ1^jnh+$ZfJ}c<;2a4ZqVUyN@#Yoi7)-bAJ@0n}OV+;VVfPSeV@nZlL z$IjnTz6B-7ixoqxseSu7?(}^yeXe=?o_h0wBS%t&tHs6A&%(fLT%uH(KTZXgsTVl= zO?$42Xc1tZj#;W&(T>c6GF5U&z&jCm9GUqyz%|E<)K*|T9jC`5 zbGFATmpY~O=(B|!Q;9XT>|_(d_I&Fovin+37;?60$E;Z{I-CSa*Y>IWYR!d()(vPb z!Q`SAOHpvDeU1ZEJw4gD>T*|hc&l7DMeeI(DTsoP5zFf-YgW_GPu`N@Q=!b}S+X=e z04f#Q(sf04X8V;vK$G`?@eeMgfUy$nG-kaQWo0uGOv7!#+x zzb}KD;*VlkH{*jchY$LTf1B*`D`=`>EqWr+uv2FVsI;8|Mg`a7l)zqxh=@S%J(5iu ztpnM@&Z!4*h`B>W-Z>qSYPmW0MxEe34YxSK8*+Fz-6{`Z1?i$DeAR zTI}z-b9DG>q3p+cuCTXH!qSI7JE!!bTAc73eiDxMHnDXTYuvLVEHBA$J@Suya@H+#oU>r~E+xs>T z<)}r)H6xQPHl{u?@~ab&-7{y}_ONk6RE$J3TCLJ;jEw~xY(_K1oMIKISj0l^D=F@C z=#F{7<8qzzAD9QYdY+c#9DCp&mFBMQx}FI#3;HX|F)qeI-q&{N|I0mI7k~%KH!3bJ z18m8kfSlh}hd51=TX@t-8$SY3Y+pi4Edx7qjrEp_)h*HGzt3jDu4^c<2xfsO)s|%N zY_QxBD!ala*XP@eR@s{4yU#_83v}#@s`_~(=YsB=@1fCvsf)dVffgpw);6lZ%vlcf z+9nJD>zQMcaIM#CC@xhxY%rN$NW*tdM5h@}5uM)~b7FHa_jjty%!_OH9Bh}+s2GZI#h^*g|Ej50F5)yGJAAIVovMJAJJ4A+@ z5L&1I%d37X8bYbwjj6|pD%siDvB7lfOy7W9i~XQkh~|C>X5gN-#kp|TY|saOf|(BM z$amiVTbk7t1$Qi{-Y`F@V2EEYJlWpf9zAa*n1e=hcWz8gt(-V!rt;Cg6{h#{W0C+ zW&>GfGG-YWT~=ZK>UbhD>?TTqy{Wq=%kbAgsZ-Kwe6sh40{DBup|7}UQwr};v4Z=F zITy>XGgzTwV8%nGA*Fz#QUX(k1e{#h=XsEL29C6J4S6ZG{t~15z1*63AK`fi-?h7T z^`VNO$XjY`5YhWaqk_itkCQXIG z)HHQX-17sjZk4J1&I*}%*uLBMJ*~MnsYN#GVm_*T$)o1nRVlJn7VQnZl0VxN66`*O zuyX!nVAWk(kC(L^VPnq8qvIhjw>`Mj&_+IR444Wu3(*@jeV7tP5xbRnuw>qwpt4SMr76yB(otaNnRZ zYtA2v_{t2HWW{+!ZrBDRfrJYd@|qFg<28Lx#Rkv*V2-7ct25WBpsWrPKDFiPQI0Ng zTwdD9(vN1GUVlP>KLB$&jE5G3`Ll&h0RQi{h&2_`Y3?uJxw=Ko^DnFbimD~vnw^|q zMdVV3V2bH<&R<_1qh^CRNnj%26I2JyKn@fMV^jI#Qx|;jW1}#s?x5Xdv%0EAYeO}) zMKV(2-c5<}K^x90O@-pF3+$rf!hg^<2;l8sV!P5;6VD2 z#n*xj_%*y(pPHU5N|+yPIh^{-a`IMTecU~g%f_s5#(>CbLl9?4%x{4ik@qo4f_mBK zCpLAE>r}7l4xQSQJ1mFdCFrO%%xB%KcO1Ns2we$cUwuz z(F-?sAG}UlDkEZeSEsG|(v$6TPrkSXa=AN`f6yU9RV?SdZjbAa|9{T>O?gtA!j>+C zl}Y%n+=tM@TOcVkyS)#INSw&6%(ldbvNMEu)QLFgf1TQY;;ZHIudQZPF2x0iRhpIgP}gACl3KQlhNef;Q^|XM4Kl z{e8#?z5B7v$xp*=W^}XI4{Vs1I64#?RHUc5 zx5N~EhGcXuE+t2CQ;8^?g2~efVC%sp6fe&Au9hk( z!gH;W+Q6GI8_PX00c?HGq2o35w}PPkdN%rxJqA*5gxe$?4vUcw9VKn)JTr&W5<+)P zhTFhz%&h`4Y1}}$`zj2SUYEneH_wLAS~KwuxmxsmL_bq#;Dj(M+U&C4x1nb5gv1m; zty0f&ax$*Td;>zsTXrM6WeTb_QxB(S5GVYaXE~L!n{%ggL%quc7TgAO z{gVYs@Zsz^b~&~so!#FEF#+p!Gbuhczrrv;9aE>%ZFX5)Zb@-2Zj)}>8l%6D&r9Z6 zG2*tN9q*s=aa#nG_j(Zvny$&1%7Pt zsIP}eStF{?a?L50n&*EpPy*0rS_6tKJs@j^@~urlbF=Vq=eL(fKrIW(+_Tm7%X9gV zu4TYUc7-%8x`$yEay_#kF=SiyLBuC8ZHd;+2%A5M(H02qcf_6|`57g?xfAmq@<`r1 zKOySBp(=_0qpLCx=*4;mTaDeA-Ky4g*;{F4_ApvvhYiu(Zm#FebNwsk$$UlL9@B@h z?zQC_a(a|`88JpstUk%w3W9ETGU#B0(TjV(e=a0;r@TCpNQ5E0|UCT4T%{f3*HTM>Mda#Nv)AXWQeg-%PG3E~X+3BtY{AwR`^ zr(Z~WyfH2YU{g+SWhn}VecH_X_$@s4NK`ZU=3aiwwH^7q{{^!RQ_al@4EK}@FMGAO z7TAJ_C==jSV?i&bcaH%YT|%EV=<{uk9X*s4FA)hg))^G7obOY=AWxmWayzl63Pmh= zV06zSQp@)rR2MWiB2F;l3B<*Av}}3je*ywLz*&$5$rDN(R)Ek%paC>E+A~J%QGknC z|A+bRM?K?ibdAd@uVzf^<=W;$%Q2B$nDw+y1;`em%+?;UIv*{_|N11aUY-*L0F=5O zq0UQSMk>a{=JGc_Wm1)}e@lA2Lwl+fAo%w$Tl!-yj0xZ@*{Z&hT>&yJ8})X1-;QSc z5*?@9am;9{?cT}zFx0u@^ZQP6GQ+E$f7p7@moN2YWcsm}!NAy+laQ&OlWF2qp8C@t z#!5zU7cV7W8dId}8tF>@K+^g$t?-Q@DE8TzO`@eRvxKtq}(YCq}=Zdmg1#j1f0CW|O%bt=F1uMB)dJQ-#v z;+K7yqj5MKw+g)ILaD>oMgQHmZyJ|s*y9I^#>N!49FJTJ1+xo3&aM8>QCI6cBtM;B zzfVZeAnJl1`Y8Xt*6cmUD&0~N9BjB~l&t9#j|g%1GzfYyti7B|td4C}7y_aNT_g_a zOu^&Fvncds^ZBi1&gpRFTN@xbIU!ba;zTePz!1%JazD=;^L8!d$oqec zc90`r0fC&(I1tTmZhM6K%VaHN8S6n5MB$1}oG6y_2&=8ldXDkY_?qN<7V}L}B6&kr zgVH*0e7C5dgS64$tMg3Ps1u%3u_0nioQo;K)emCs90*)r&Z#laG1l%csY7xQk+#99 zRTeIfSFz0v;qufDtRaokWZ#{6)F)7Gdac}fpvT_1k0tHVA^m?=KeSty$Wp5vKPW!3 z2*g2;ZZjOH4f>tcki7$kX}EiIPBUyQ!wultSCj68LXI4ep44q!>t~gq@|$Z}p@LggzdKlYn|r zyD8?qLg`>Rr~E*3Nrrw)1>t>Ab3u})@#im;0_+2$>55s^fLBWsEjZ6)>+Q=+Xx`*E z$pO;duDKQ#pWnH5>eZog2g`%xECq>@gZGMA1ui0ode5*0BkP+mK!Ls5UpOFa$w(*r z{vYy4I-2y?M+Dgm{D<98EfU}d>Js(LGEn36yW?<^PAUzis(l@`QW3Z3Zi&-b2w4Wz z7tBSwEM|4%b-}*(>hV$8drs^vS6-_##>;rTvF{Qm444)gq_KZ^U*=Uc0Lr513r5t} zCu>>$bxA;J_~;#^D-Gjf0n%f?d`DBj$?I#XkXi-zRDNG^WQ<^ByhNc><%W~p@D)Cp z%9Z*-z%!UV)?CDz4!J~>U7p@t&XKRf;9Sdlyo9^{!HWmy2ki$^L)g3s_LrXQ9XH{D zPdV+gq{4URYf-5))BI92&*D@HLoBIsH26!H5vHP7?Xw2^Yzd%H0Dz3-iGu`b?!bB2 zxhfdsZVuiiR^M-0px9}bVVBsacw0`#j?3zY97}uCjm13d2Lrop63^=IGdX4^ zn^qH(s?HeeRY-aY4r9_tyizp%^Q-;VW}(OUP4Bkq`{Ap!hvm)MwZDEKC-Ib3091bd zBM7bhsGL*M^v64V{Z$ZW3d}Gm$gs0y6R52Ks*fP zDvlM%7e4rG7kw3h&=LUllK&~P-LttQG@mcRh!EanAB8-<^0R_uD2F)%?$h{HV1pSs z?q+%}{QUMYGPlmN|I^%;K;^u??Y?H)7)quFluFYkA&n?{MUe*06s1{nX*8yy^h#+Y z8Z;s-$pHs61L-&tp^v(7rL_5Z$2dHW5|b3gZe-Pe6xw{{*0 z63XuoZuJc|_HP%%^b}u=APCBCGcX2 zu#)TGL$f$74b0q?gi0>A@_Fx7mMw`U%p^#x&F{X4*u7c)twH&LdP$k8drNlYSHYn} z!9kH%#Nj;cd~p6x0H<%VEvv-oU(k#~P?_ zY3ID-3xtxFnNDuaI}EKxTRS)2mY39yczJkztYBoa$Gyy~My$VOp|pi%w^o&2ZBF?f@`}Kp ze3YDwHqt+YgPFO5-RTuKc|r4ayE2{ohw{>_bkgE6D>28Q7`Ro6qg`u}h+RUNM9aG5 z0M(u1qxT&OTS8mLCNIuT7Od4Ur+RptBmang6`0F-dWov2xW`q#yI%XT%c8^TFW*eW zM5}ie20u?+|xI3#+GRznF zkw5CGK_rDiroGe`)77JiIh$B^F^>0jRH~j)$l^_yKPln~kPNf$LcFa|s8n$_-mIik6(DX^m?po!0WjqrjZmntG1%b@w=#LIsa0`k(^Me9yGe;psH1-JC6 zWd3Izxr~e`t>h@{SF4{&?l)A5=jP<33ItU9QhWtNUW#{fitsAa+CMGs9C`M%Y4c7; z^MaLQy1UQ(2%}mB{%?WKv+SC%M&|&!k5J)AY%hy+nTVf!;epR5e5T%l!VVG2hnETU z+I`JQN8O;Ju*FKy_C+NNg`u$h)wALq43v&SmVVLpbryuFlkgPb9T#P#2rhYI*);DN9G~hj_bBve)2olZ)*Vo zBCh(;snbQv4!$gecqE7?+8uR6o24jFHi>`hbZ+iHL}KnGul}OwK#G7l!!F_UTTHW- zKy7!zu6A)v5}UmgP$#HGMt)0NT9dRvpws_lZ9qwvX zRmpq?CSU+!_u?yWB?hKa^RMy@H=Q-)*W`8{3@l?4R|l2gd&A86k^yqfKaOC@i~Ga_;Hs zUBfN&)9RbUPKoHPm}a;Iu}JV;V@o`+kU6$0R#{Oyj4;h8j2hD_Pfpow=GB|U`w#cr zc5S_$a^k|svnjCCCpCk&s}1?E80$|`u4o?$BJze4YRTtamvpN=Hwfe5IJl^AjWC|vdh4fiN;di&o7hoveRB9-Yd(ru z3~E(p1=k->)`zBzY_Yh3|EIpkQgu(p_#3~Xdp=}Xu)-|8zjkWWMN>P>daP5iI80wBXXE|_ zl2P(3LTkTgwCq_a;P!VGCYy3}WxgBS22WAfFjt)F>2(7gg?1k{!KTJ?=B4K^Ovi~r zBmx89Dp$>i3Q8L%$E`qHRt$J_Rw#ep0!DCp4J3Oy##ZsG-#TA|gmdwZivlW?ub;Cs z)WSaM4R+MAIDQr|?9-29GZq@+NHz~Q7-=xAj1bIC*}*z{T1d~= zn!(`lb9Y*G72LcLX1@pQwu7!;_Xr)$Z=$7{@eh}K3DES5{Ajd;xqB`>yu8NuuE-F# zI9JmIx<#tmTRI*3B4*7yfApQCZLW9#?{~e+d#P5n4}z6THJ*qK_poQ*$^elT+#>X| z|KJ@ywE(%RK6pp^76yv%l^Tb|JmuhGI!{xGpad*6DCu;Q04l@gZ&&uBM!$ zT$myGDB)Dzk+pi!hs;`Ss+H?#8(~}5I{I%oODNKi=ZK7t=gWF7b@5kPa7Wg`0r2ri zb;mJH$+=QeKX2KwOH06T++Iki`&fGQCR7UB^si!A{P9LFrw?I{e%?2F!)|fYr}DP* zf7fenJ^`=ANff2 zp}+pZ&>H)O9>N23Z1KEQr5exeJdp~G?rpjkx1BgYBL4A(M@dX{1&dZAzI(4R#D55F zxC%%D)-@cCfs?!ckRMR!2%+Ld9rj<9N~&P7@qNwPl8_JA#EJfe+v%G$TJ5_8RRvHE zO&1}Iv)d!ZkP{SkrR3ey+n>x~t{a8|%{-D23syhX<;Cm8lyFGev8Vl#OM6n<=WY?R$+~H%tv5MO=~p zaYCg!Sh)yIb6qL(9yhOvo9N2IxJ{&J5t}RHa8ATVPe*3$)2T-lBLcJ^VPpS5ksj1I z9j!=}Q-3Hm7W$n^CKufrY`O)6Pam&vZ~zJzby!u=s`*&wrw3>TTv)Cx4Hz$TKqYRf zT#pWfF8!e=!cd5Ton1DHwl_A}?x*)~*Q+MoMJbI5i#E2M$32xeQ%`!7J%Pr@iI$pnBQDpeB;&CEuF#U+ouGVY5#(P$%jA-T-T*)3+ZW~9wq zWp!b#MuhR_A~IW~vyNNb(@}~73FK+@-e1C9I8;;2Lao0acM15XeuD}nj=D`;E#hc5 z)c+MX1ymS~ZRg=}waikZ9jDpL;GH?VhyQxjZ5#hG?R0td)XlQH*PB2rVF>z&#&vn; zh>k8uBshDF!2u8~B}(&5sTU3~B zbq9qM4&Gaw0})UFgvt6&kWV5X;7(rz!L4ZAey5?DEo zk?>E5Roas4QI+=*F1o?oIhjLtBM}giXr-hF8@g zyDAHJ@OO0LTc9s12iPO_aQJy67+KYGFXM-6vETLU$NwmZ7|(!3+*hnJb-SMR*I+8y zOw&ruB`Glw=PS8?M6v0ujAR$#Wu$(3@SItLdp7S7CXyVdg{70oD)ZI!a2M1cZZrQvkUc8miU0g*@^m z*95;O+6wWxQTS2VN*t14V@4NrWgjC?;+u{B+DG}C-*|s+6!97AxjP~JQa5bFS7C=2 zB}66U(x|ZVu}r1K-mC+&6Z%I7Zg$xS6g%8&6Q(8wL`(gnh0e_VSMjQgeb?!txPX&9 zvYC~V?tXDKuakmKr(wOcVKJ+f&I9$RD>ly;558K(_w=2!Q}&4K$IMi}U|!R5_Eb|| zl$Suh-pgNLw|wy~hf~hKm8j;x@ze(T3($r)LSyXh>(|_$`rzae&#Y~~8$0{+Js*iR z^{26t-0s3Tnk(*O_=OQhqVPg_9T36?AvP4NDMmLT+?wL-*uglsC;XJ0;cJGtT!&=z zGCFdZV>Hd|`lIwG$4BT0f2Ckn$5v3IoadNXWmV(%z4v6|?FU_ms0x$t;xaLyN)&fn zdaNuh`-dBRjQd+ZY}R>_Eiu}?V75X#Y~y4jGc~zvRmPh$=1C~89Ayf+nNMWdG z`uk33?((u6HNqF6c`CoMGj1<3G-o>Fd!9>Hq#5E*t8(JZPasrq|Hk`Y*09 zWSD&~>YLIlwOWfKs5izX(sgoOXowSI3GbcZw0DmLW%5eBMZ4Z3nD3)Ynh$DZ zma4CaG}GEHNnRLbcD3;5W1Su=A`1j=IGGqMSu0~>A;{1()p5uY?e>}J(Gd}?h{k2; z8wn%S!n=I;&CSY>HcI2)-noN8f27ubt#~KUgMyb-I2gfu2htRkOKrmZiSH(bG(lFz z=JNU)_~~*|k)QFLafF5jA<;R*H@y8zax_ag`DZe5stTD6OWH7B+a*grhO*_pk+R{J ztwhkP5_Ky7x?u+BN?zovf}`9Enm9@j`zwF|0qm{ucr@@BqA|~%DTvcg&AQ9V=9E(RFTQAA)xjxFv5vu9L3T7L0sZ6ULA3^Yeka`*+ z7p~uX`98iohqXKKYdVaJ$SQo@kcNg>gvG|QF#Rp=Vqlo_YYy;wC4XTUzfC)b^8LEL z^}iLlfC*pc2>IHzEZC34JDK+dM5rdeCCXK2$pvVw>^=H?X+UMCZ6(Xak14=iFr!b? zwc@~2yRsm6VpFQA@d8KqyZ}glncv^tzs~1pO2+Pg@)Q-|D)RR>yLJx#W5>-8Cb47F zY{_Y1WiSHK=YX7C@*kNBbT3A_!}ltx2Ngg2xA33w?B>{u7w6?HT%ZqYW$;>Iq2zFe zAOodXF(I$#9}SVBPJg70?K-YG#qPgGm&v34Arj6Tk|84t#kxSW*G$59IpF3|>xxl-M3u1JVeAUkBZ2(1p+hO{pe6NKV$!muTR{Ru6f4<7%zVlcF zijdLtnzL2zLjU$*2~|VixUQ|??5yHBb&9H&zx6`#60p~C#nN|)Yy;c&&Nadjyoo5R z$!)kb+~nn|cFiqwZCDJ}$`kt#PeNsY=8qBNnEog~E$-tVuH-*BHdqnK)-n|41m@(e^YPFf#nGp)JgD{J;CSC%Q6* zt%vDK48g{7+}_hFpjVUx0!owaq1z9{j(lCm3Cbf;lVaCN^XJ1*XLR^aEDHCi8>g++ zUizlpbv(&AY3H(OT!hxxSk6L!>l7DKv4-tZu1G61M?L3NfGTaL){?u!d7?!Gdd;}wxZPj*=0EvWP zulUV>J28B(kEy3dFl%4$%K6-X^k^jd+xvXdb_j5xg)#dMwBY{`ZLpZ1Ug@;C_i>ZI zE-0`l8fyP28eV5v$sFkF&e&g(t+qz=Q-AKrt50XvT{tCzTXmED+rS%mNEzGK7eDq7 zcND95{Gcb32`AeUMrbggPme^s?2m$c$yagXL}S$$8L#Eyq5paVeq$fyZx9K(=kA5Q zAwOsVZq@#LzAO12jt*5D=pP$r7FVyf1g>4`DzA?AXopaY<*AR7*0Mp8wKcbN)o>P(`|<{5r3C%_iLcKC zQ|IkpE2MuF0(y$^`C*tjBp+8i2AT&PBcfltkPfvjp^l`p?K~qzk`R}%&K)>J_I6gL zs{-#NhGrsG&8^Y5T}nK-!dCfxCeLH%q0S0YQ=wTjDr9h=I;(Px;yW^KeTP1p`kSJM z#|a4gjJ6>gRLrtf3=oKvU~HKE!3;EbG7y0T2-i`YFF90YB~!BlsR0?Az#n|T#13$U zdw^s-O2$K4kgwd&<{SZHY%AtTGLg;qLk!)h^?i7dg!9?M+6obM&>*9qPCt*ym<(vj zZtf}!;zXMar|A<#F)#G^e@+B2gCM=?JtyjJJ z>+YBT9T&SG*BQk$sH>k&1!zTqJ;G!!59VkY{N|dPnhc+wpEDy=vV@3a(JT5Nhq-5I zmFk1d1099~Y-eRRH@_X@o^Jk&7VGoG>z-IppwU`1Jcb z=Zag1{WtAhjqA$~xVYQt=H(A}BN4+=HVPjDiVAZLX8Yx`&+Ov!jl^JNi)PhFy(bldwaB zFw;bk*oFb&fz~5?6Q3Auc4BjNlJ*f(Eb1l|9MeG1qeqWss;k_b#`1RN^wX^0@{}Mi zE(*nDRXGGy{&AE-aybJ-uL0VMFb3Ra|GjR{(5aIrWfdpqP3RHeg=URJ+^vBGhmfwj zUP~IGcCFy&Q^7p5&N8doEbGFVHN}ErM->%Kyl0>5Rhkr6cFJS^NrRDH^yP`xu91^% zd6I0FmltjMc60WO29C6yUxl7y*sTiTG-B{HRi;%o^W0{%S7)Wyz$H;JH$k?5 z1*)TvE1s^dV2qX^TbxtLk7=sT@)JbX3q2k4gT7=Oj@}TYJZpM74`dW{M;d&fi*x+O z|3un!s?iDGGlvRb*WIb6J20sw=fyHS9XIe3BH|JrjTTGrl750hJVvna_w4I1Uf&zG zxc{AyA`BW|&MLqEpB6}?1GI=Odaqg$x0K)@Gypq1mLYq`!Hy4br%J#oj)SzD1RKbL zEL8o(g*syCJHY&sP9zzjoB4&gRP$U@{hr7O*@jnvl?*${Cfs22R%pYo`7@5WaFHOw zYQ;PEW;S@=2|I6b{`4c)DHm!99%G7G^{(4KYM2CvPa8yNyodjTwdgF-TzXXDCdUK{ zV~Oses+u%YjocQmP4rQsf54NByX%JxLe_(!3Dw~!us0QRT@*7c2VXHQJmyavmgc9z z6DND&g2e0%H+l4Q#M}zEo7eC`lf18Z1&%{%&`yiH2<_1MY#bsMLz*xWUF6;TDhO4-)oC4*n6lO@#cm<4^f?QntvBhz~D#9+(Q|uL$D>MSk zR%29-M9bBiReUpbm(wuPf$l`Y>jceA(6U)4rT)L3B>WkP8mefF#Yvt`QM&q4q;4Le zR1kAO#H2r(;>%xFnI(tkJ-rZhJA`k9TYmPehS^Cb{|&b<=(aOoEwlp=MM2y7evXVj z!f7xDgJ418j%fk}&cIFxKl@i25!n3Wa66RYS~p{wBeP@d`})79f&0TFwNo&Kc`)fE z7&Md3ZTY_u@{G#fc3Z&wDMAMh5}*Me(bf)wOINo2&cdr=b>t;Jp?a3yayJOiMeJgAFBb%ekhgMub6euQ+s;ch(?GA=L*r7}%-#cWP{K^Rw0s3ojKM{eJ_v>rPHBY4kBs|xuyJB)1;&GP z)+M=}5;;fVu(zEt$NPs1r2CfTv+DOu1@HQ&zx)H$CJCxuSFvQRQKC9$XEm>KoW73O z2T%;0jXOc(x^Qd?yfqr#7BrC)MN6Vi*0 zdXMDp++O=*r}$emyxTMr6>^sC7na53=PApclfGYfr8&e6Du@`*b(}rc#l?%<{t?-{ z`vP>~>+7S$t)eiid`zMEmx$nR0r7Mp)F#Z4w!B_8rt6_k5@zXda~Qw=qWyWyoVWDA zj%p4T36s$`Z=i90NqF|ZJ^^E5ZX{MY$E!Id)H&+7L76rVH-f_zyF zHah+KrSFUm(7{}I=nU5HeKTFY&g|*Rg4+|QrJJn5!@I!Y}R1#6uJQ~I5{k1ixe*TKb_!aQknY_9>T)g(v1O3EY1qok) zT+NCc(Ug{j^QK$YhL9YD2&Q%G^)}T+0q5axrL_1Q3wjRB@HHW;$Vef0Q)eY}P@hSS z$pVjms@in&PN$rg*TGdJv1|CVM~dsANQL)!I!MPWKKatGB> z2!5=2xEozwf_*#m)rGZ8jzyYQ#!p^ZR^mJ~XK0=6nT%=^Xnu%IEGFORHdBs=EZ5z|0w>g7A!Xb%iP4gUO7R12DVm%xAt2bZpeIfcf;E} z$L9EIQ5*4gnVPt*6m_sJ{~BWlX%!0zef$GXS{5(IoVD?F{=co@V{a#fie;^uZ>ibB z@I=cc1CYLA!>dS93{Akj0dc48^8;hg%SkULZ?7WH$5&wEUMPoSYMXqqH>a5K9|}5I z7(X^$cropk@mW5Da>PQ28xwYJC5$fl~IlYt~N(AQ_9S z$lVv-6_0bYd2mIveS{i5`5d=Z)&=|}9~GFZKDm6^8Y8n1O0*FjCbh*H8H(+n4x#K% z5ywZu;OzY?6XxkX7$1N5$4&WKh zaow|O_rI>@f(-NbQbgjobwk^^c@dEO=uFHNm>9k{Vjt#vV&#`F8Oe24#S$84M^42! zk3Do}Q4vTFKD77Q0|4r@2zZwUZ^Wi^Q|Qj#SZ(26CfUSj*rUsHSyF8MBjPUND~kJJ zw_n1t)v?*M!g@R(F;?QOj@7kB%S&8oQbhHCtE=rI{E#24%=IU(-Cfx*WYAuE{!$JW z{zxBB$MlxgXWD$LdaF@cue0lyM^Iynl*6>^y?YOt7ZE*#<4DkH(2NbjCfk5TObmdQ z>P5^3GgTn#AVyHf10}Q$$Z}8i85Gja4yBC>8_DW8bC$(1cgS9Vk`7&({|_i%{_O<% znUNv^f&nY}uBVy!(rEAY4+NWS&iu{{w|a;R3Upqj>|F5A@0#`rGi*NC_G+z{PTkn3 zai5T6xAHe|Y=gEhmWkN?Z<|0Mx``+jzY{8Cd&tLyr=a_0xJO@n$x?-L#kKBX=pDXW z1@JMm^ZdetCm8U3Jk0a-m@CyGEBp7H3>xF`qvXN~%z;+hPLAk{6w&zF=!=oUFqfVh zFlZc5tm|VO)Gtag`}V1j?*o;N;{$_Zr?0>(;xBo=6Z_LIr!FmYd|7>OI(p~L{dX)W z!cT0DM0cP=*i4;#Zv;9fCnrDV!GBv>c${>ZTma&gzoStc6d9)BY=dvqBbd7;{}AR{ zf226%AJQ=*hDs;?4{e?GUiY35D>i(EfZ@a7_8(qPD3|9iUmn48)TtKoq+5=Z|I*U? zt>i>|aQpNfJAe72*j@9a`&)Ho)%4vm%%N{=_Ow$5%vDz1$Ak=m#(2KdSDqWU6kVJ! zseo?w!y}Yj$Lhp5Ourl{KRUD5tz1dzg!!m-j3Q^B&H^X;x7gqQcuOMMCj{3&?O(B+ z`^V?qUjeHDscmF5^^g5};Qr1_;^e)L^@Pp5otVG>?|(b3(FxFN#xp=M3jpFbzbmrb z4=9%RUGZJ*^lz~JwE4??DX3hbxcoywN^$0_xqk5vTaGB5GtYB-|6(=#2dDM4e}@Ww zd_W%br_hkv^an@BFHd|(Ryi)n)?%R}YyQe%Xb|OlE&uO71&=7{o|Y(Pykit@mvjcU z@O;M%p7+NDt<&`+SA-I8B#CPHIv=Yg3e-|=YW(8Gys#gyYtMAnNWKqo%-Qs0V)_s4 z2OgvNsO(}7*~G&`g<-2QIxd|{=FEW20S#iZn<{>f5YwU_u(yR9)~TC;frfV6+~Klg z+4)2j@HbrCtBTkd^+5}DE(%K}{4kRL-49JGx1)bu_y8Y9(&2kxAkRpLS8<-YVk zeH;k3m^9;nN+oe|l701apmjHTd_Tlg|F{a#wL*j|H2vkqJw1ps{iiF-r@==0pZ>>h zeVs1*e|!r5Ume^3g-`uIxE41>^$g2Mv+SOIqyQovVe~y?QGOhOb*ZiU+~AJdTpf57 ze}t2ta--hlJ>=nT)4IY!{rV)3C6dC3WDWoT-X7lbiI zVf5^q8XFnUis`)wd@?oZNOUxSF{)l#z=YgMoY)=&?Jy53&KHtD)A)KBMDGE@%H7y> zXtVW{KfeZei0N_g>L8dYL^(JJ2B@Z{_I2q>H`C#F^ex>FnxzCj5(ONEe~Tc!r_&L3kVwfi?tqIurAkl}Xu6Gb0xQ(ph_V&;LI1G2;MjY_*X7W6q5I*VZsKb1yS-imrp@tT@K5fWG^^hi6BZ ze`8lo4j3nZ`ql7>%E;IEo_x3$@3R`K`;6pRM{*aM3QQwQVHZLkgTl_Qvxm^XURX5* z&I^7co}qf3pow&D?$l)E1jb&6v%A5^!gB_yJ7AU`yyg5eaJs6{!nc&O1j72X(^g3S zxOHbmnbs302S{JlsJD8JeMvUJrx)*RxH=ABFq{^63n+sYSiPV;yN3HK!(+n*V!?GR znX^8xMWJQ0Sy#1Ly5sn8j~TUV2NcoX9obMmEdLbu?U)`slyfwxh4t_rEO;76wPgYS zrLHVUBXUp|G7Bh~nVB7HI4=$%muOJC+A+Pnvun9Qn1$8z#XIf;m!H!miW`SuDQRpJ zx6syH(Z<~mWvn~TL9A!!{z%lT%NT7D1b`36U|fAy&x;kDTjbe^)C7JfFh$7(B$tet zwL<&O4s~grHjgxh{}^-#AQ(lgs-7n!mOl=-Hah| z92o)jwW3Tf#qV4sj1+3K;43{6b`S+W0*g$+o<8F~cdaINE$J%M=Chbr$zrb- zwZF8_TsaMB@dgKYQ-_!9&7Kn(9UbR7HMt85nbo=f!}!wgS63Lw{Q=OwYti%Gu@CM< z1hB_lvVZEqRl-FGga;)s$$l;`&tS(ln)5hDs_a%IUBI<|E3cA+(;tw4!!sc~R04St zlzFT1;EG%H#FBa8^~+lHp5ZGR1sqf(0>6)~-ce);)(>IQ(ISi!b5wANpni&5zu^qH zk6Vk?NKa6SKNb$WdOzGfPEYEs;wmC0$Oy8AwNIP@(QEqVmg`@o)CqYdPrxq~=M)jk za&pQx`ke3pmtZv5@x|8t^56Bfvx=GnpIY_2Wab4G5S55BE)sNzL>?}#{N`4MMPzq8 zu@vliVi!xVj;Lf1HAeTPr>9q+*4jkSNjcgOBTslO^c4igNF(-Ww}*txLK@IyYX5N0 z9hhc3>B|M6xytcsK1}8&cTKT1(nv%DD^CVT0TfRCUhK4ZM5GXU>R-Z8JKv_pp@nH= z=d1Y?iR7Icr2TSd_bkYksASo7e*)XEAK~&F*gc;SX^$?Sr#xG2|W%lZC(Yv>_uhOY}zlmfR%*s@Vp>&(+R zBMlcU>JO5&j*0gN3n3we|$I=q!ZaDAZ3lUoM9&H#QZUYu4CWUh!!EI33eu)=vHD($ztmS^YZp6NTO zDU|xl-sNju&WW}pv0OK_U>3+7ys{)L$*T4Jdh$+mYp<>r!f=d1E1>jG2r;pc_h2qx zcxlVbH%{+iK6yC-(%glCLi>?IB)LqEw2YtHfwh&4vk_yxa$sDYk5aguFg)BKnEiCs zj68#t_HkGNmzT07KndXhE$U#Pu9fusd6VK4a|;U#kctc@4HhKK`rv_`P@vQ0{MEU8 z5dgO3_tjHlNd^LZo$=VNLJf8GAP#c!eOX9=YhjE_%jC)yADFnwlYIS)JRd&8ox88(w%2%IB_-S#i)WR!8jfImxMUAyb9O)pQT~j(`+VE>nCJt&;%3?A=7=%nx_j)!pihPj zz^`a$!=efJ0}*;vasHNlbzTPk{w=EhS68%r$#u=;FvQ9Z616isCyCVgItN1g0dzOY z6p(>cZJ~vR5^R8H{rcS`=GS^r7*w3}&k2SJ_JihTPQUiT`Q+LkohlgBT0GO21f00X z8Ir6uLqwYO@E+W^KZo=1SNBU@eO0#bvcD|6Nm`=#aiD~ACK|H~s~|49)fD}`=&-Y~ zI7RgL+t2q!Y%)e)jA*Q-p82{5f%X3D{H^=y3Vu?@e6d;YA_FYwv>hAod$B@6mWDY~I?$X<8XX0_}%F;l+HTdnyg^I<5T&IMkw@+tz zllJzt`6w=>{;CG03ykacXzXvp78|crMZ_bi6V8fEQ0q*tGWfP4{W^mDFBLr-@!r=P z5$0%^Rv&EacI|n+(WiX%hCRo78{fQz8C~wwgt>BzV$)-vQ^+`ojty2Dv~YN*($JQ^ z#v-bM6a}%XiO{>LnBcO5v2q-Cz52uB=d>L=Q^zausP3Y#K&250!mMYr7&ehyjF4)O z$IY2MWPzA14t*B71sL%oMPF;0TG^n_*$vb!kWfrNAuD<)p&7Ds}y4zqw?w zQKz?WKxLo&yu&6W9qxKEW=7TDE`tmkX{{s=T;IC0`{XBCsb~E0h+~Ml-OprpNKhl~ zAd9GNDGt6;oXzAMM7|RY?mWr7zkxHCwBG0<47;m|1X-xXtJV2hgYC$NRgt2@X-8Q1 z#r$jQL=;K7k5iF}nK>G`068`Yu@hTb@wn}rhS{uPNkSI&FJ?{+-V!$UPskZmCxUQ| zHx!WRqyli1{Dy$PW{a-$gU7w3Qh|Htoi4B* zST>!%h`hB)5X#6ZXtKMPX4)8&@`D(sAtL~bO1%I2WZ&(ni=rp#@lKU$RXkP`c%Fqerv0Ry{guODLVtv^Ong2Rv1&o?b{TEsBIyc( zMXB42SshE3uD>1_2vIyx=Do)rL(~eVdo&t@qSgqTBp4!P_<-wD%5`zpLh?pdUnx@Z znLY*~dAG@7iZhEeYh8xF&V!g0tQphRY(06!4Au%+&!?A37>+Dt^)Bk@;v%jZP)YbG zy|;fUTL1Cyb9oplhj-kwTD*FzG@En3FIxTgz+^zZ5$8D6l{waxWz!ZJP{G1_z>RG1 zh>R9U8~jj} zpZ#FRGL235ADmM6+j>vtgE}qz6ZgmEEJ@qdOZ_UMVxt!ERLFANJZ9{XniioSv7BRt zkq7_C#EGtnjM`vn%Z-nFoZeNf9g`>(srTXFuv1)R#S zohlPGGkVu0&!FV3`Gs%TKZB>7&DUp_#45>x2t2rnbaRVEcqtTCVrpa`xoXknnK%>r z6g*l`BhTur$~d~iye7qX9E(ONK~p3YfhMpSyM+s1$Zwkhix!fr5`2a*$+U8%e+w5c zZ$X!XM#|}Mu&85^UxGw^gTt_;C{#hqTw)|p#7f^+S00(Myu7@pmlv7oZM=NDnfz9v89NGUn?_3wujJ1u3VW9{*wV}V!iNJ z`1rkK9A$LiTQq(#DQIOp|DxDutj6JDrDMIELJpe& za&pROYinO(4G0b8hpQ#_{vj~i{ce0ru61+$_KDx&?Acf(0*D=FoBIfUmDg9_)Wpfg z#)b`D&})J{0@~8((a(CQO^GpJ{Hp^c2*!TMgNYg}#BVXJFQ9a!n2(?T5GyNd&=$RY z;db4^rWHy7f;yjY`%3Wt+>=j>z!u&tA)yWEsQ3GK?vi7ayaF#hQ0vYxAOtC+p5L=~ zuUy1KE-DHn1h|XdY?2ABt*y&F=&+_>1vI-Bh;0`_F1TgO7XR+OmlyeR7c z5*pg{^=mkS4>HZ_C|8ila4pIRL^lO>v=K~|kQE}}@lbtya<&OB=fq1>Tj_ae+3MAB zv5Pgt!c{4DU$lC|g{OB20Ub#c$wYv}@Y&hfk;KTZyXGbbCkF>NH@7TmQCLtbm*E7Q zz*y9F3+PyFy!YY0`IC_-Syw@_xX&<>AdNG*cS7m@;oV_jVgB9XXZv5Bb%u^?(Xj6 z6BA5p*T&6T%K93OonhC5`L}8ITb-N$?BUy z$rd9K?)p-^7cjXtb8tw*&Dp>I@#{}_Wfq1?IIC-Ll@d=2SDX5_H=%feXFepfjNYgO8of`vTDiW}O z11S&y3mfXj)8p?^rZBErbsyOhH&!$mO)Jx6*=0>Gk(`%vX3u8qT1p>5S+Z&K<^$&0 zJ(uC!s~GQq#4j2)z~y3sMHaV6C5$^Y`E(AmKp#<+a8Oa$so<|UsHk*H5NS-C+uny? z*sx&(z%>Ol4i5a~-Gq2ZBWSRd(gEOn4@RB7Kb?nteK``F6DU^NIxh#zGZa|$_4tSG zvU!xbwD|L9RaDIUwomqZuk+;q`nR=K0@YeF<6uO-xj~xoXL(vm}Oq|I2`vq1Y9Q#dlNg^q**9rPSSIT({1|hB1<3WNb{s*|k?78!I*xtBzSf^F3Y$Clw=F z{)0%Mpa{t_+FKyl)YqUZ~gg=%T$28 zQcD0hn!XBb(9E(e>qk(wNvUt_sQY4z0n8YK*cX%g5 z54j+QUm??>FRI$AIem$>on642ldzxkB-I=?SnR%QYetHWVwFJdH|fbNH^qxcE9!2h zIP~d*PRLZ=f_1%M>C(@@i)^~8M1lvANEhVh`>-Wmyf~Zu;u$k$ESNw4Jt6@do+X1f z+!aSo$`O+{r*f+1DLWO%mT$lPhtb@3Llffm-}9mh*X6HcCi`feZ$Lo6vuDrZ!veh! zDo?#-1Rf=?iU6}+7kK~Lc6E8_a`{HGchtoV1m+! z425F)waK)g-oC%pmxJjTx07Y`i6a%gx-Jc+(-(T+9w7Al8tG<0FiP<_I>TUUF7mdp zFm=TK?AZ!&gWccqY&$F3kP+ZXsbb-!LQoAyP-)b4LltbrPj-~Uo}|CUz7$lh!@9b6 z;TIo{mei$-7fDFobb|_fWW@U$Ru;KfKR*svS65iw9z#Ikx0OJt7T`CuEQs@|lctx5 z0C{+RDv)tiM!;&SukXEkG^sbm<~ZV~FrFe3N@E20&FHD<7l%E-Yl?HhtHhkwap!rI z$octDd%o_D7%EF6wP5l${!N>jV;UyL#~E>;0RAunx};*-tYv{?gWp=BS#)KBPHtGs zvf$z%hn{2ax7;BYaCnV%TZs&*Ja62X7-JXEe0hT6(VTgGVPrBBd4m=<35_+ka}_Hq$?te5h#Seo;ynjBGxfjBZT@t5`esyOn z&9E{ER=~kdU-<`t^MOP6KzmgoIKPN*9A6T4tJThsYI|NJPRZjXC%8k6#^0ykvoD4| zI?_HR1Q(3uY%n)BN20_dBBF-OP)Szy%1XXt$A!%hDHkkWd^0gI5rw00Bt-H$z9d`P z*pwm8ndlOdINnehxQ5nEZ<)YnVZ3Ed4}3sOMnPBi8u@lx0AL*c2dSt-9{GeV?nVRj z2yg4xon(X(80CJ4v@cnD2?$x178XYUhLrVnbeLe-5TA9>sXdT8*wnxH>y*BJEK)NF zkPST;4bBh#e3L@SCl;0854`>Mn2}17l!$yg<`=JC5fU~jx$y|dX(J;oU0$WbP6e;e zAAui5r*%)gOj*oPcw;U8NQ4>@g5sWphlgj{b_S9Cho6ka>8nuqIX{1YO-&6#egLb4 z(`k{4WR8>i`t868jnE$ec%h?fL6*v$2=uAi!1PRUQUHIrZ<{|?^%G|eEO|UGex<9g zU(4b^xsO?|m(;R-BfU*bIC`*b$xUI?(>RdD910Ut!ww{>*(6;;0DbuI?`x~J>^W87 zpBiLb`q-@7}c@={+?8fl3V7^&QP`X>7cYIvb1DAHz_lwoT%PaOsRpOp(Z7G&}>bsPd=E zXHe?ctE#F}fEtk)1r36KN5PdTIV+52XMz`DfKHq(Mp?2jQE70y$8SeLtK|;T53NHA&N%4CIj`CuRsq zr;nr2|Fm;Hof<*^Z;IQ6X~DATKmGsttzk*S)zZlaUI{!TS4owTmrmGo{QUm{tW+3z literal 116006 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 Kr+=Qj^nU=HktPZN diff --git a/utils/README.md b/utils/README.md index 074af5b..3829630 100644 --- a/utils/README.md +++ b/utils/README.md @@ -1,9 +1,12 @@ -# utils —— reference-c++ 两个子项目的 Python 移植 +# utils —— reference-c++ 的 Python 移植 + 双目测距新实现 将 `reference-c++/` 下的 `target_tracker`(多目标追踪过滤器)与 `visual_track_control`(视觉跟踪控制律)移植为 Python,**两包互不依赖** (唯一的数据转换点在胶水层 demo)。 +另有 `stereo_ranger`(双目稀疏目标测距):**按规格新实现**(非 C++ 移植), +规格 `docs/双目测距技术路线.md`;同样与既有两包互不依赖。 + ## 目录结构 ``` @@ -12,19 +15,24 @@ utils/ │ └── README.md # 接口清单 + C++ 差异 ├── visual_track_control/ # 包 2:三通道控制律 + MAVSDK offboard 网关 │ └── README.md # 接口清单 + C++ 差异 +├── stereo_ranger/ # 包 3:双目稀疏目标测距(纯标准库,无第三方依赖) +│ └── README.md # 接口清单 + 与规格的偏差 ├── demo_track_control.py # 胶水层集成演示(模拟检测 → 追踪 → 控制律 → 发送) +├── demo_stereo_ranger.py # 双目测距演示(合成框对,六场景,无硬件) ├── config/track_control.json # 控制律配置(拷贝自 reference-c++) -├── tests/ # pytest 测试(对应 C++ 全部 13 个测试文件) -│ ├── target_tracker/ # 27 条 -│ └── visual_track_control/ # 78 条 -├── conftest.py # 保证可从任意工作目录导入两包 +├── config/stereo_ranger.json # 双目测距配置(占位值,待标定回填) +├── tests/ # pytest 测试 +│ ├── target_tracker/ # 28 条(对应 C++ 测试文件) +│ ├── visual_track_control/ # 81 条(对应 C++ 测试文件) +│ └── stereo_ranger/ # 111 条(规格 §11.4 构件级 + 全流程) +├── conftest.py # 保证可从任意工作目录导入三包 └── pytest.ini # 收集 *_test.py 命名(与 C++ 测试文件名对齐) ``` ## 环境 conda env `pytorch`(`D:\program file\conda\envs\pytorch`),Python 3.12, -依赖:numpy、mavsdk、pytest。 +依赖:numpy、mavsdk、pytest(stereo_ranger 仅标准库)。 ## 运行测试 @@ -33,8 +41,9 @@ cd utils "D:/program file/conda/envs/pytorch/python.exe" -m pytest -q ``` -105 条测试全部通过(含 C++ 验收用例的逐条移植:§9.4 六条硬验收、A1/A2/A4 -回归、PID anti-windup C1、超龄 C4、首拍限幅 S6)。 +220 条测试全部通过:109 条 C++ 移植用例(含 §9.4 六条硬验收、A1/A2/A4 +回归、PID anti-windup C1、超龄 C4、首拍限幅 S6)+ 111 条 stereo_ranger +(合成框对驱动,无需真实图像/硬件)。 ## 运行演示 @@ -44,11 +53,17 @@ python utils/demo_track_control.py --speedup 10 # MAVSDK 实发(需飞控/SITL 在 udp://:14540) python utils/demo_track_control.py --transport mavsdk + +# 双目测距(合成框对,离线,无相机/飞控) +python utils/demo_stereo_ranger.py ``` -时间线 13 s(20 Hz):正常跟踪 6 s(vx 从零渐变 → 逼近限速 3 m/s)→ +控制链路时间线 13 s(20 Hz):正常跟踪 6 s(vx 从零渐变 → 逼近限速 3 m/s)→ 目标消失 4 s(Coast 预测,60 帧后轨道判亡)→ 判零速刹车 3 s。 +双目演示六场景:逼近(三道有效性门 + 滤波)→ 远场域外(方位仍发布)→ +单目退化 → 非同帧拒收 → 一致性降级/判无效 → 多目标贪心配对。 + ## 移植约定 - MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支 @@ -56,3 +71,6 @@ python utils/demo_track_control.py --transport mavsdk - 测试逐条移植 C++ gtest 断言(`EXPECT_DOUBLE_EQ` → 精确 `==`, `EXPECT_NEAR` → `pytest.approx`);MAVSDK 部分因无 SITL/实机,仅 mock 验证。 - 行为修复项(C1/C2/C4、A1/A2/A4、S6)在测试文件头注释中标注对应章节。 +- `stereo_ranger` 不是移植:按规格五步流程新实现,与规格的偏差(越界=有效性门、 + 视差域权威、补充 consistency_jump_px/isolation_gate_px 等)见其包 README; + ⚠ 全部数值为占位值,标定完成前不得作为飞行控制决策依据。 diff --git a/utils/config/track_control.json b/utils/config/track_control.json index 7dcd26b..c9d9c8f 100644 --- a/utils/config/track_control.json +++ b/utils/config/track_control.json @@ -4,7 +4,7 @@ "control": { "frequency_hz": 20.0, "visual_stale_ms": 200, - "radar_stale_ms": 500, + "distance_stale_ms": 500, "attitude_stale_ms": 300 }, @@ -36,8 +36,9 @@ "derivative_filter_coef": 0.2, "approach_velocity_limit_mps": 3.0, "retreat_velocity_limit_mps": 1.0, - "no_distance_action": "hold", - "no_distance_approach_limit_mps": 0.5 + "no_distance_action": "slow_approach", + "no_distance_approach_limit_mps": 0.5, + "no_distance_exit_grace_ms": 3000 }, "accel_limit": { diff --git a/utils/demo_stereo_ranger.py b/utils/demo_stereo_ranger.py deleted file mode 100644 index a7741e6..0000000 --- a/utils/demo_stereo_ranger.py +++ /dev/null @@ -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() diff --git a/utils/demo_track_control.py b/utils/demo_track_control.py index 213e0e6..7cd451f 100644 --- a/utils/demo_track_control.py +++ b/utils/demo_track_control.py @@ -10,7 +10,7 @@ → CommandSender(mock 记录 / MAVSDK offboard 实发) 场景时间线(20 Hz,--speedup 可加速): - 0~ 6s 正常跟踪:目标匀速横移 + 雷达距离 30→10 m 前向逼近 + 0~ 6s 正常跟踪:目标匀速横移 + 双目距离 30→10 m 前向逼近 6~10s 目标消失:追踪器 Coast 预测(60 帧 = 3 s 后轨道判丢, 控制律自动切换零速刹车) 10~13s 持续判丢:维持零速刹车 @@ -73,8 +73,8 @@ def simulate_detections(tick: int, normal_ticks: int, period_s: float): return [Detection(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0, 0.9)] -def simulate_radar(tick: int, normal_ticks: int, period_s: float): - """场景雷达源:正常阶段关联成立、距离 30→10 m;之后未关联。""" +def simulate_distance(tick: int, normal_ticks: int, period_s: float): + """场景双目距离源:正常阶段有效、距离 30→10 m;之后无效。""" if tick >= normal_ticks: return 0.0, False distance = max(10.0, 30.0 - tick * period_s * 3.0) @@ -155,15 +155,15 @@ async def run_demo(args) -> None: for tick in range(total_ticks): now_ms = 1000 + tick * period_ms detections = simulate_detections(tick, normal_ticks, period_s) - radar_m, associated = simulate_radar(tick, normal_ticks, period_s) + distance_m, distance_valid = simulate_distance(tick, normal_ticks, period_s) result = tracker.update(detections) snap = ControlSnapshot( track=track_result_to_state(result), track_time_ms=now_ms, # 每拍取样即打戳(视觉始终新鲜) - radar_distance_m=radar_m, - associated_to_target=associated, - radar_time_ms=now_ms if associated else 0, + target_distance_m=distance_m, + distance_valid=distance_valid, + distance_time_ms=now_ms, attitude_present=True, attitude_time_ms=now_ms, # 模拟姿态源持续可用 yaw_rad=0.0, # 机头朝北(NED 旋转恒等) diff --git a/utils/stereo_ranger/README.md b/utils/stereo_ranger/README.md index 9cd2c55..df5cd97 100644 --- a/utils/stereo_ranger/README.md +++ b/utils/stereo_ranger/README.md @@ -1,65 +1,112 @@ -# stereo_ranger(Python) +# stereo_ranger(Python 版) -双目稀疏目标测距(规格:`docs/双目测距方案.md`)。输入左右目各自、**校正后原生坐标系**下的检测框,输出目标的 \(Z, X, Y, R\) 与方位。不做目标识别(YOLO)、不做图像采集与立体校正——对应规格 §10 中可独立替换的检测前端与采集预处理模块。 +双目稀疏目标测距:对**同一时刻**左右两目已立体校正图像上的目标检测框做立体 +几何解算,输出少量目标的**前向距离 Z + 斜距 R(参考)+ 虚拟双目中心方位 +c_mid + 有效性/置信度**。不做目标识别、图像采集/立体校正、稠密深度。 -仅依赖标准库(`math`/`logging`),不 import 本仓库任何其他模块。 +规格:`docs/双目测距技术路线.md`(算法与语义权威)。 + +⚠ **全部参数为占位值**(焦距/标定待厂商数据):标定与地面真值验证完成前, +本模块输出**仅限静态标靶/回放/台架验证,不得作为飞行控制决策依据**(§12.3)。 ## 接口清单 | 名称 | 说明 | | --- | --- | -| `Detection(x1, y1, x2, y2, conf=1.0, cls_id=0, track_id=0)` | 输入检测框(px,左上/右下角) | -| `StereoConfig(...)` | 配置(默认值 = 规格示例值,见下) | -| `load_from_json(path)` | 从 JSON 构建配置(缺字段/未知字段/非法值抛 `ValueError`) | -| `StereoRanger(config=None)` | 测距器主类 | -| `.update(left_dets, right_dets, frame_sequence=0, timestamp_s=None) -> list[RangeReport]` | 单帧主流程(返回记录数与左目检测数一致、顺序一致) | -| `.reset()` / `.config` | 清空滤波状态 / 当前配置 | -| `RangeReport` | 输出记录(字段见 `types.py`) | -| `focal_from_hfov(w_px, hfov_deg)` / `range_error_abs(z, fx, B, δD=1)` / `range_error_rel(...)` | 工具函数(§5.2/§5.3) | +| `StereoRanger(config=None, matcher=None)` | 主入口;`matcher` 可注入自定义匹配器 | +| `.update(left, right, now_ms=0) -> List[RangeRecord]` | 单周期处理(§5 五步),纯计算、无 IO | +| `.reset()` | 清空全部运行期状态(滤波/一致性/隔离/节流计数) | +| `.config` / `.matcher` | 只读访问(配置与匹配器) | +| `StereoConfig` / `load_from_json(path)` | 17 项配置;缺项/未知字段/类型错误/非法取值一律 `ValueError`(fail-fast) | +| `.focal_baseline_px_m` / `.z_min_m` / `.z_max_m` / `.z_error_budget_max_m` | 派生只读量(F·B 常量、视差域互推的距离域、误差预算远界) | +| `Detection(x1,y1,x2,y2, confidence, class_id, track_id)` | 单框(校正后坐标系;含 `cx/cy/width/height/bbox` 属性) | +| `DetectionFrame(frame_id, detections)` | 单目单帧 | +| `RangeRecord` | 输出记录:`frame_id / class_id / degradation / valid / track_id / forward_distance_m(Z) / slant_range_m(R) / disparity_px(D) / center_mid_px(c_mid) / reference_box / reference_eye / confidence / timestamp_ms` | +| `Degradation` / `Eye` / `DisparitySource` | 枚举:完整/仅方位;左/右目;同侧边缘/中心回退 | +| `sample_point` / `SamplePoint` / `select_disparity` / `Disparity` | 取点器(§6.1,纯函数) | +| `Matcher`(Protocol)/ `GreedyMatcher` / `MatchPair` | 匹配器(§6.2,接口注入,可换匈牙利等) | +| `solve_range` / `RangeSolution` / `virtual_center_px` / `forward_distance_m` | 测距与方位源(§3.2/§6.6,纯函数) | +| `evaluate_validity` / `ValidityDecision` / `range_error_abs_m` / `range_error_rel` / `confidence_from_cost` / `DELTA_D_PX` | 有效性三道门与误差模型(§6.4/§7.1,纯函数) | +| `ConsistencyMonitor` / `ConsistencyStatus` | 一致性监控(§6.7,每目标状态) | +| `DistanceFilter` | 距离低通 + 跳变重置(§6.5,每目标状态) | +| `TargetStore` | 目标隔离与老化清理(§4,track_id 优先 + 最近邻兜底) | +| `WarnThrottle` | 告警节流(第 1 次 + 每满 100 次,带累计计数) | -## 与检测器的约定(规格 §4.3) +`logger` 名称为 `stereo_ranger`,走标准 `logging`。 -1. 左右图像必须使用**完全一致**的预处理几何(同 letter-box、同缩放),由检测前端保证; -2. 传入本模块的框必须已映射回**原生分辨率**(视差在原生坐标计算),内参 `fx/fy/cx/cy` 亦对应原生分辨率; -3. 图像对须已完成立体校正(同一目标在两目 y 坐标一致);校正残留由 `vertical_tau_px` 容错; -4. YOLO 推理及左右目并行执行由调用方负责,本模块只收检测结果。 +## 单帧流程(`update` 五步) -## 单帧流程(对照规格) +1. **同帧契约**:`left.frame_id != right.frame_id` → 拒收整帧、返回空列表、WARN 节流(§2.1); +2. **取点**:逐框取同侧(左)边缘 x 与框中心;坐标非有限值的框丢弃并 WARN(不参与解算也不发布); +3. **匹配**:硬约束(类别一致、`|Δy| ≤ tau_y_px`、框高相对差 ≤ `tau_h_ratio`)+ 代价升序贪心一一配对,`cost < 1` 才接受; +4. **配对目标**:`D → Z = F·B/D`(`D ≤ 0` 判违例)→ `R` 参考 → `c_mid = (cL + cR)/2` → 门① 域内 / 门② 误差预算 / 门③ 一致性 → 有效帧过 `DistanceFilter`,记 `Z_out`; +5. **未配对框**:发布仅方位记录(`BEARING_ONLY`,`valid = false`,右目未匹配同样发布);帧末对未出现目标记失效、连续 `filter_reset_frames` 帧未出现整体清理。 -硬约束过滤(类别一致、`|Δy| ≤ τy`、高度比 `≤ τh`、视差落在 `FB/z_max ~ FB/z_min` 区间)→ 代价 `|Δy|/τy + |Δh|/(τh·max(hL,hR))` 升序贪心一一配对(严格 `cost < 1`;同 `track_id` 优先)→ 主取**同侧左边缘**视差,越出关联窗口时按配置回退中心视差(节流警告)→ `Z=FB/D`、`X=(xL−cx)Z/F`、`Y=(yL−cy)Z/F`、`R=√(X²+Y²+Z²)` → 有效性 `D ≥ D_min 且 δZ ≤ Z_tol` → 可选一阶低通滤波(按 (cls, track) 分键,跳变超门限重置;`track_id=0` 不过滤)。 +记录顺序:先按左目框顺序(配对→完整 / 未配对→仅方位),再给未配对右目框的仅方位记录。 -## 输出语义 +## 实现决策(v1.0 冻结,已回填规格) -- `matched=True, valid=True`:有效测距; -- `matched=True, valid=False`:已配对但超出有效工作域(§5.4),距离/坐标仍发布供诊断,方位可用; -- `matched=False, valid=False`:左目检测本帧未配对(§6 失败处理),仅单目方位回退。 +规格 `docs/双目测距技术路线.md` 已按下列决策修订(§4/§6.1/§6.2/§6.4/§6.7/ +§9/§11 均已更新),本节保留决策动机与实现落点,供重写/移植核对。 -方位源 `centre_pixel_x_mid` 为基线中点虚拟相机投影(§11.3);注意 `X` 属**左目坐标系**(§4.2),二者相差 `B/2`,勿混用。 +1. **越界 = 有效性门,不是配对硬约束**:规格 §6.2 原把"视差在域内"列入 + 硬约束,与 §5/§6.3/§6.4/§2.3 的表述冲突;按 §5 主流程实现并回填—— + 越界目标仍配对、仍发布 `D` 与 `c_mid`(远场方位闭环的输入基础),仅 + `valid = false`。 +2. **视差域权威**:`disparity_min_px / disparity_max_px` 为真源,`z_min_m / z_max_m` + 为派生只读属性;原 §9.2 占位值不自洽(`D_min = 3 px ⇔ 19.2 m ≠ 20 m`), + 距离域配置项已从规格删除(§6.4/§9.2)。 +3. **不实现 §6.1 的"左右边缘各算一次取中值"精化**:两支的 median 恒等于均值, + 即框中心视差,与"同侧边缘"的理由矛盾,规格已删除该项(早期笔误),留 + v1.1 亚像素精化。 +4. **补充配置项**(原文 §9 未列、算法需要,规格 §9.3/§9.5 已回填): + `consistency_jump_px`(§6.7 视差跳变门限)、`isolation_gate_px`(§4 最近邻 + 兜底的门限)。 +5. **δD 固定 1 px**:`DELTA_D_PX = 1.0`(§7.1 ⚠占位);标定回填后可参数化。 +6. **R 与已发布 Z 同基准**:`R = R_raw · z_out / z_raw`(X/Y/Z 均 ∝ Z,精确换基, + 保证 `R ≥ Z`);无效帧无 `Z_out` 时 `R` 取本帧原始值(诊断参考)。规格 + §2.2/§6.5 已注明。 +7. **置信度映射**(§6.4 ⚠占位):`clamp(1 − cost, 0, 1)`;连续跳变 ≥ N 帧乘 0.5 + (`_CONFIDENCE_DEGRADE_FACTOR`);判无效置 0。 +8. **一致性只在配对成功帧推进**:仅方位帧没有 D 观测量,不推进 D 序列;但目标 + 未出现的帧会记一次滤波失效(§6.5 失效清理),两条状态机职责不混。 +9. **取点回退规则**:边缘视差越窗且中心视差在域内 → 回退中心 + (`CENTER_FALLBACK` + WARN);两者均越窗 → 保留边缘值供诊断与违例告警。 +10. **track_id 优先路径提前启用**(v1.1 语义,规格 §4 已按此冻结):左右 + `track_id` 一致且 > 0 时用 `(track, 类别, id)` 作隔离键;最近邻只在无 + track_id 的状态间匹配,一一归属。 +11. **§3.2 勘误**:原文 `X_C = X + B/2` 与 §6.6 的符号相反(应为 `X_C = X − B/2`), + 规格已修正;代码不输出 `X_C`,以 §6.6 恒等式为准。 -## StereoConfig 关键项 +## 运行期状态清单(§11.1,漏一个就变行为) -| 字段 | 默认 | 说明 | -| --- | --- | --- | -| `fx_px/fy_px/cx_px/cy_px` | 960/960/960/540 | 校正后内参(原生分辨率) | -| `baseline_m` | 0.06 | 基线 B | -| `z_min_m / z_max_m` | 1.0 / 50.0 | 距离窗口(派生视差关联区间) | -| `vertical_tau_px` / `height_ratio_tau` | 2.0 / 0.25 | 关联阈值 τy / τh | -| `disparity_min_px` / `range_tol_m` | 3.0 / 2.0 | 有效性判据 D_min / Z_tol | -| `disp_noise_px` | 1.0 | 误差模型 δD(用于 δZ 估计与有效性) | -| `use_center_fallback` | true | 边缘视差越窗时回退中心 | -| `filter_enabled` / `filter_alpha` / `filter_jump_ratio` | false / 0.3 / 0.2 | 时间滤波 | -| `warn_interval_s` | 5.0 | 诊断日志节流间隔 | +| 状态 | 所属 | 初值 | 改变时机 | +| --- | --- | --- | --- | +| 连续异常计数 ×目标 | `ConsistencyMonitor` | 0 | 每帧异常 +1 / 正常帧清零 | +| 上次滤波值、初始化标志、连续失效计数 ×目标 | `DistanceFilter` | 无 / false / 0 | 有效帧写入 / 首有效帧置位 / 无效帧 +1 | +| 最近位置、最近出现帧 ×目标 | `TargetStore` | 首帧写入 | 每次 `resolve` | +| 帧序号 | `StereoRanger` | 0 | 每帧 +1(拒收帧不加) | +| 告警计数 ×异常类 | `WarnThrottle` | 0 | 每次异常 | -## 运行 +## 测试与演示 ```bash cd utils -"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q # 全部测试(149 条) -"D:/program file/conda/envs/pytorch/python.exe" demo_stereo_ranger.py # 合成场景 demo +"D:/program file/conda/envs/pytorch/python.exe" -m pytest tests/stereo_ranger -q # 111 条 +"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q # 全量 220 条 +python utils/demo_stereo_ranger.py # 六场景合成框对演示 ``` -## 未覆盖边界(属其他模块/后续版本) +构件级测试逐条对应 §11.4(取点器/匹配器/测距/有效性/滤波器/一致性/全流程), +**全部用合成框对驱动,无需真实图像**(几何是解析可断言的): -- 图像采集、立体校正 remap、帧同步、side-by-side 裁切(规格 §3); -- YOLO/检测器及其预处理(由调用方按上文约定接入); -- 亚像素边缘精化、跨帧跟踪(`track_id` 由外部追踪器提供后,时间滤波即可用)、与主测距传感器融合(§5.5/§11)。 +- 取点:同侧边缘取值、框宽差不进入视差、中心对照、越窗回退、边界含等号、负视差; +- 匹配:硬约束各一例、`cost ≥ 1` 拒绝、贪心排序、一一配对; +- 测距:`Z = F·B/D` 回收、`D ≤ 0` 违例、`c_mid` 等权平均恒等式、误差平方增长; +- 有效性:三道门与边界归类、预算门通过/拦截两侧; +- 滤波/一致性:低通收敛、跳变重置不拖尾、失效清理、N 帧降级 / M 帧判无效 / 正常帧清零; +- 全流程:单目标、滤波、远场越界仍配对、单目退化、记录顺序、非同帧拒收与节流、 + 空输入、非有限框、track_id 跨大位移保持状态、最近邻越门限新建状态、匹配器注入、`reset`。 + +demo 覆盖:① 逼近(三道门依次生效 + 滤波)② 远场域外(方位照常发布) +③ 单目退化 ④ 非同帧拒收与节流 ⑤ 一致性降级/判无效 ⑥ 多目标贪心配对。 diff --git a/utils/stereo_ranger/__init__.py b/utils/stereo_ranger/__init__.py index 8a11b35..2dd4a9a 100644 --- a/utils/stereo_ranger/__init__.py +++ b/utils/stereo_ranger/__init__.py @@ -1,30 +1,72 @@ -"""stereo_ranger —— 双目稀疏目标测距(规格:docs/双目测距方案.md)。 +"""stereo_ranger —— 双目稀疏目标测距(规格:docs/双目测距技术路线.md)。 -纯算法包:输入校正后原生坐标系的左右目检测框,输出目标的 (Z, X, Y, R) -与方位。不做目标识别、图像采集与立体校正;仅依赖标准库。 +纯算法包:输入校正后坐标系的同帧左右目检测框,输出目标距离记录(Z/R/D、 +虚拟双目中心方位、valid/confidence)。不做目标识别、图像采集与立体校正; +仅依赖标准库。⚠ 全部数值参数为占位值,标定与地面验证完成前不得作为飞行 +控制决策依据(§12.3 红线)。 """ from .config import StereoConfig, load_from_json -from .stereo_ranger import ( - DISP_SOURCE_CENTER, - DISP_SOURCE_EDGE, - StereoRanger, - focal_from_hfov, - range_error_abs, +from .consistency import ConsistencyMonitor, ConsistencyStatus +from .distance_filter import DistanceFilter +from .geometry import ( + DELTA_D_PX, + RangeSolution, + ValidityDecision, + confidence_from_cost, + evaluate_validity, + forward_distance_m, + range_error_abs_m, range_error_rel, + solve_range, + virtual_center_px, ) -from .types import BBoxXYXY, Detection, RangeReport +from .matching import GreedyMatcher, MatchPair, Matcher +from .sampling import Disparity, SamplePoint, sample_point, select_disparity +from .stereo_ranger import StereoRanger +from .target_store import TargetStore +from .types import ( + BBoxXYXY, + Degradation, + Detection, + DetectionFrame, + DisparitySource, + Eye, + RangeRecord, +) +from .warn_throttle import WarnThrottle __all__ = [ "BBoxXYXY", - "DISP_SOURCE_CENTER", - "DISP_SOURCE_EDGE", + "DELTA_D_PX", + "ConsistencyMonitor", + "ConsistencyStatus", + "Degradation", "Detection", - "RangeReport", + "DetectionFrame", + "Disparity", + "DisparitySource", + "DistanceFilter", + "Eye", + "GreedyMatcher", + "MatchPair", + "Matcher", + "RangeRecord", + "RangeSolution", + "SamplePoint", "StereoConfig", "StereoRanger", - "focal_from_hfov", + "TargetStore", + "ValidityDecision", + "WarnThrottle", + "confidence_from_cost", + "evaluate_validity", + "forward_distance_m", "load_from_json", - "range_error_abs", + "range_error_abs_m", "range_error_rel", + "sample_point", + "select_disparity", + "solve_range", + "virtual_center_px", ] diff --git a/utils/stereo_ranger/config.py b/utils/stereo_ranger/config.py index ba8ef07..4307702 100644 --- a/utils/stereo_ranger/config.py +++ b/utils/stereo_ranger/config.py @@ -1,50 +1,51 @@ -"""StereoConfig 与 JSON 配置加载。 +"""StereoConfig 与 JSON 配置加载(§9 配置参数全量表)。 -规格 docs/双目测距方案.md:全部数值参数均为配置项(§2),默认值取文档 -示例值。配置校验在构造期一次性完成,热路径不读 JSON。 +全部参数配置外置、构造期一次加载并校验:缺项、类型错误、非法取值一律拒绝 +进入运行期(fail-fast)。所有默认值均为 ⚠占位值,未整定前不得用于飞行决策 +(§12.3 红线 1/2)。 + +域权威(实现决策):视差域 disparity_min_px / disparity_max_px 为权威, +距离域 z_min_m / z_max_m 由 Z = F·B/D 派生为只读属性——域判定使用视差 +(§11.2 直接观测量),两套阈值不再冗余存储、避免互推舍入不一致(§6.4)。 """ import json +import math from dataclasses import dataclass, fields -from pathlib import Path -from typing import Any, Dict, Tuple +from typing import Any, Dict + +# JSON 加载时的整数型字段(其余按数值校验) +_INT_FIELDS = frozenset({"consistency_N_frames", "consistency_M_frames", "filter_reset_frames"}) @dataclass class StereoConfig: - # ---- 相机参数(校正后、对应原生分辨率,§3.1 / §4.3)---- + # ---- 几何组(来自标定 P1/P2,§9.1;⚠全部为占位值)---- fx_px: float = 960.0 fy_px: float = 960.0 cx_px: float = 960.0 cy_px: float = 540.0 - baseline_m: float = 0.06 # 基线 B + baseline_m: float = 0.06 - # ---- 目标距离窗口:关联硬约束(§6),派生视差区间 [FB/z_max, FB/z_min] ---- - z_min_m: float = 1.0 - z_max_m: float = 50.0 + # ---- 匹配组(§9.2)---- + tau_y_px: float = 2.0 + tau_h_ratio: float = 0.25 + disparity_min_px: float = 3.0 + disparity_max_px: float = 57.6 # ⚠占位:F=960/B=0.06 下 ⇔ 近界 1.0 m - # ---- 关联阈值(§6)---- - vertical_tau_px: float = 2.0 # 垂直残差 tau_y - height_ratio_tau: float = 0.25 # 相对高度差 tau_h + # ---- 有效性组(§9.3)---- + max_delta_z_m: float = 2.0 # 误差预算 δZ(δD = 1px) 上限 + consistency_N_frames: int = 3 # 连续跳变降置信度的帧数 + consistency_M_frames: int = 5 # 连续异常判 invalid 的帧数 + consistency_jump_px: float = 3.0 # ⚠补充项:§9 未列,§6.7 的视差跳变门限 - # ---- 视差取点与有效性(§5)---- - disparity_min_px: float = 3.0 # D_min:D 低于此值直接无效 - range_tol_m: float = 2.0 # Z_tol:δZ <= Z_tol 才算有效 - disp_noise_px: float = 1.0 # δD(误差模型中的边缘定位噪声) - use_center_fallback: bool = True # 左边缘视差越窗时回退中心(§5.1) + # ---- 滤波组(§9.4)---- + smooth_alpha: float = 0.3 + jump_reset_m: float = 5.0 + filter_reset_frames: int = 5 - # ---- 可选时间滤波(§7,一阶低通 + 跳变重置)---- - filter_enabled: bool = False - filter_alpha: float = 0.3 - filter_jump_ratio: float = 0.2 # 相对跳变超此门限则重置滤波器 - - # ---- 诊断日志节流 ---- - warn_interval_s: float = 5.0 - - def disparity_bounds_px(self) -> Tuple[float, float]: - """关联用的视差区间 (lo, hi):由 z_min/z_max 派生(§6)。""" - fb = self.fx_px * self.baseline_m - return fb / self.z_max_m, fb / self.z_min_m + # ---- 目标隔离(§4:类别 + 框位置最近邻;⚠补充项)---- + isolation_gate_px: float = 50.0 def __post_init__(self) -> None: _require(self.fx_px > 0.0, "fx_px", "必须为正") @@ -52,31 +53,71 @@ class StereoConfig: _require(self.cx_px >= 0.0, "cx_px", "必须非负") _require(self.cy_px >= 0.0, "cy_px", "必须非负") _require(self.baseline_m > 0.0, "baseline_m", "必须为正") - _require(0.0 < self.z_min_m < self.z_max_m, "z_min_m/z_max_m", "须满足 0 < z_min_m < z_max_m") - _require(self.vertical_tau_px > 0.0, "vertical_tau_px", "必须为正") - _require(self.height_ratio_tau > 0.0, "height_ratio_tau", "必须为正") - _require(self.disparity_min_px > 0.0, "disparity_min_px", "必须为正") - _require(self.range_tol_m > 0.0, "range_tol_m", "必须为正") - _require(self.disp_noise_px > 0.0, "disp_noise_px", "必须为正") - _require(0.0 < self.filter_alpha <= 1.0, "filter_alpha", "须落在 (0, 1]") - _require(self.filter_jump_ratio >= 0.0, "filter_jump_ratio", "必须非负") - _require(self.warn_interval_s >= 0.0, "warn_interval_s", "必须非负") + _require(self.tau_y_px > 0.0, "tau_y_px", "必须为正") + _require(self.tau_h_ratio > 0.0, "tau_h_ratio", "必须为正") + _require( + 0.0 < self.disparity_min_px < self.disparity_max_px, + "disparity_min_px/disparity_max_px", + "须满足 0 < 下限 < 上限", + ) + _require(self.max_delta_z_m > 0.0, "max_delta_z_m", "必须为正") + _require(self.consistency_N_frames >= 1, "consistency_N_frames", "必须 ≥ 1") + _require( + self.consistency_M_frames >= self.consistency_N_frames, + "consistency_M_frames", + "必须 ≥ consistency_N_frames", + ) + _require(self.consistency_jump_px > 0.0, "consistency_jump_px", "必须为正") + _require(0.0 < self.smooth_alpha <= 1.0, "smooth_alpha", "须落在 (0, 1]") + _require(self.jump_reset_m > 0.0, "jump_reset_m", "必须为正") + _require(self.filter_reset_frames >= 1, "filter_reset_frames", "必须 ≥ 1") + _require(self.isolation_gate_px > 0.0, "isolation_gate_px", "必须为正") + + @property + def focal_baseline_px_m(self) -> float: + """F·B:构造期一次算好的常量,热路径只做乘除(§11.1)。""" + return self.fx_px * self.baseline_m + + @property + def z_min_m(self) -> float: + """近界(由 disparity_max_px 派生)。""" + return self.focal_baseline_px_m / self.disparity_max_px + + @property + def z_max_m(self) -> float: + """远界(由 disparity_min_px 派生)。""" + return self.focal_baseline_px_m / self.disparity_min_px + + @property + def z_error_budget_max_m(self) -> float: + """误差预算门限对应的最远距离 sqrt(δZ_max·F·B)(δD = 1px,§7.2)。""" + return math.sqrt(self.max_delta_z_m * self.focal_baseline_px_m) def load_from_json(path: str) -> StereoConfig: - """从 JSON 文件构建配置。字段缺失或非法取值抛 ValueError(携带字段名)。""" - with open(path, encoding="utf-8") as f: - data: Dict[str, Any] = json.load(f) - known = {field.name for field in fields(StereoConfig)} - unknown = set(data) - known + """从 JSON 文件构建配置;缺项/未知字段/类型错误/非法取值一律抛 ValueError。""" + with open(path, encoding="utf-8") as handle: + data: Dict[str, Any] = json.load(handle) + if not isinstance(data, dict): + raise ValueError("配置非法: 顶层须为 JSON 对象") + known = {item.name for item in fields(StereoConfig)} + unknown = sorted(set(data) - known) if unknown: - raise ValueError(f"未知配置字段: {sorted(unknown)}") - missing = known - set(data) + raise ValueError(f"未知配置字段: {unknown}") + missing = sorted(known - set(data)) if missing: - raise ValueError(f"缺少配置字段: {sorted(missing)}") + raise ValueError(f"缺少配置字段: {missing}") + for name, value in data.items(): + if isinstance(value, bool): + raise ValueError(f"配置类型非法: {name} 须为数值") + if name in _INT_FIELDS: + if not isinstance(value, int): + raise ValueError(f"配置类型非法: {name} 须为整数") + elif not isinstance(value, (int, float)): + raise ValueError(f"配置类型非法: {name} 须为数值") return StereoConfig(**data) -def _require(ok: bool, field_name: str, message: str) -> None: - if not ok: +def _require(condition: bool, field_name: str, message: str) -> None: + if not condition: raise ValueError(f"配置非法: {field_name} {message}") diff --git a/utils/stereo_ranger/consistency.py b/utils/stereo_ranger/consistency.py new file mode 100644 index 0000000..7ff9100 --- /dev/null +++ b/utils/stereo_ranger/consistency.py @@ -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() diff --git a/utils/stereo_ranger/distance_filter.py b/utils/stereo_ranger/distance_filter.py new file mode 100644 index 0000000..c30ca50 --- /dev/null +++ b/utils/stereo_ranger/distance_filter.py @@ -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() diff --git a/utils/stereo_ranger/geometry.py b/utils/stereo_ranger/geometry.py new file mode 100644 index 0000000..32156b3 --- /dev/null +++ b/utils/stereo_ranger/geometry.py @@ -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)) diff --git a/utils/stereo_ranger/matching.py b/utils/stereo_ranger/matching.py new file mode 100644 index 0000000..74aafee --- /dev/null +++ b/utils/stereo_ranger/matching.py @@ -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 diff --git a/utils/stereo_ranger/sampling.py b/utils/stereo_ranger/sampling.py new file mode 100644 index 0000000..c006e46 --- /dev/null +++ b/utils/stereo_ranger/sampling.py @@ -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 diff --git a/utils/stereo_ranger/stereo_ranger.py b/utils/stereo_ranger/stereo_ranger.py index 12c6b0f..36b32c5 100644 --- a/utils/stereo_ranger/stereo_ranger.py +++ b/utils/stereo_ranger/stereo_ranger.py @@ -1,215 +1,227 @@ -"""StereoRanger —— 双目稀疏目标测距(纯算法,规格 docs/双目测距方案.md)。 +"""StereoRanger —— 双目稀疏目标测距(规格 docs/双目测距技术路线.md)。 -输入为左右目各自、校正后原生坐标系下的检测框列表;不做目标识别,不做 -图像采集/立体校正。单帧主流程:关联(§6)→ 视差取点(§5.1)→ -Z=F·B/D 与 (X, Y, R)(§4.2)→ 有效性(§5.4)→ 可选时间滤波(§7)。 -依赖仅为标准库。 +单周期五步(§5):① 同帧契约检查 → ② 取点 → ③ 左右匹配 → +④ 配对目标:测距 + 方位源 + 有效性 + 滤波 → ⑤ 单目退化发布。 + +输入为左右目各自、校正后原生坐标系下的检测框;不做目标识别,不做图像 +采集/立体校正。纯计算单元(§11.3):单周期处理无阻塞、无 IO;同一实例 +不得并发调用 update()。 """ import logging -import math -import time -from typing import Dict, List, Optional, Sequence, Tuple +from typing import List, Optional, Sequence from .config import StereoConfig -from .types import BBoxXYXY, Detection, RangeReport +from .geometry import confidence_from_cost, evaluate_validity, solve_range, virtual_center_px +from .matching import GreedyMatcher, Matcher, MatchPair +from .sampling import SamplePoint, sample_point, select_disparity +from .target_store import TargetStore +from .types import Degradation, Detection, DetectionFrame, Eye, RangeRecord +from .warn_throttle import WarnThrottle logger = logging.getLogger("stereo_ranger") -DISP_SOURCE_EDGE = "left_edge" -DISP_SOURCE_CENTER = "center" - - -def focal_from_hfov(image_width_px: float, hfov_deg: float) -> float: - """由水平视场与图像宽近似像素焦距:F = (W/2) / tan(HFOV/2)(§5.3)。""" - return (image_width_px / 2.0) / math.tan(math.radians(hfov_deg) / 2.0) - - -def range_error_abs(z_m: float, fx_px: float, baseline_m: float, delta_d_px: float = 1.0) -> float: - """绝对距离误差估计 δZ ≈ Z^2·δD/(F·B)(§5.2)。""" - return z_m * z_m * delta_d_px / (fx_px * baseline_m) - - -def range_error_rel(z_m: float, fx_px: float, baseline_m: float, delta_d_px: float = 1.0) -> float: - """相对距离误差 δZ/Z ≈ Z·δD/(F·B)(§5.2)。""" - return z_m * delta_d_px / (fx_px * baseline_m) +_CONFIDENCE_DEGRADE_FACTOR = 0.5 # ⚠占位:连续跳变 ≥ N 帧的置信度降级系数(§6.4) class StereoRanger: - """双目稀疏目标测距器。 + """双目稀疏目标测距器:每帧调用一次 update(),传入同帧左右目检测结果。""" - 用法:每帧调用一次 update(),传入同一逻辑帧的左右目检测结果。 - """ - - def __init__(self, config: Optional[StereoConfig] = None): - self._cfg = config if config is not None else StereoConfig() - self._z_state: Dict[Tuple[int, int], float] = {} # (cls_id, track_id) -> 滤波 Z - self._warn_at: Dict[str, float] = {} + def __init__(self, config: Optional[StereoConfig] = None, matcher: Optional[Matcher] = None) -> None: + self._config = config if config is not None else StereoConfig() + self._matcher = matcher if matcher is not None else GreedyMatcher() + self._store = TargetStore(self._config) + self._warn = WarnThrottle() + self._frame_index = 0 @property def config(self) -> StereoConfig: - return self._cfg + return self._config + + @property + def matcher(self) -> Matcher: + return self._matcher def reset(self) -> None: - self._z_state.clear() - self._warn_at.clear() + """清空全部运行期状态(滤波 / 一致性 / 隔离 / 节流计数)。""" + self._store.clear() + self._warn.clear() + self._frame_index = 0 - def update( - self, - left: Sequence[Detection], - right: Sequence[Detection], - frame_sequence: int = 0, - timestamp_s: Optional[float] = None, - ) -> List[RangeReport]: - """单帧主流程:返回报告列表(每个左目检测一条,保持左目输入顺序)。 + def update(self, left: DetectionFrame, right: DetectionFrame, now_ms: int = 0) -> List[RangeRecord]: + """单周期处理(§5):返回本帧记录列表。 - 配对成功且测距有效的报告 valid=True;配对成功但超出有效工作域的 - valid=False(距离仍给出,§5.4);未配对的左目检测 matched=False, - 仅发布单目方位(§6 失败处理)。 + 顺序:先按左目框顺序给出各自记录(配对→完整记录 / 未配对→仅方位), + 再给出未配对的右目框的仅方位记录;左目存在时记录数与左目框数一致。 """ - pairs = self._match(left, right) - matched_r: Dict[int, int] = {l_idx: r_idx for l_idx, r_idx, _ in pairs} - reports: List[RangeReport] = [] - for l_idx, det_l in enumerate(left): - r_idx = matched_r.get(l_idx) - if r_idx is None: - reports.append(self._unmatched_report(det_l, frame_sequence, timestamp_s)) + config = self._config + if left.frame_id != right.frame_id: + self._warn_emit( + "frame_mismatch", + "左右帧标识不一致(L=%s, R=%s),拒收本帧", + left.frame_id, + right.frame_id, + ) + return [] + + self._frame_index += 1 + frame_index = self._frame_index + left_points = self._drop_invalid([sample_point(det) for det in left.detections]) + right_points = self._drop_invalid([sample_point(det) for det in right.detections]) + + pairs = self._matcher.match(left_points, right_points, config) + pair_of_left = {pair.left_index: pair for pair in pairs} + paired_right = {pair.right_index for pair in pairs} + + records: List[RangeRecord] = [] + for index, point_l in enumerate(left_points): + pair = pair_of_left.get(index) + if pair is None: + records.append(self._bearing_only_record(point_l, left.frame_id, now_ms, Eye.LEFT)) continue - report = self._build_report(det_l, right[r_idx], frame_sequence, timestamp_s) - reports.append(report) - return reports + records.append( + self._complete_record( + point_l, + right_points[pair.right_index], + pair, + left.frame_id, + now_ms, + frame_index, + ) + ) + for index, point_r in enumerate(right_points): + if index in paired_right: + continue + records.append(self._bearing_only_record(point_r, left.frame_id, now_ms, Eye.RIGHT)) + + self._store.end_frame(frame_index) + return records # ---- 内部实现 ---- - def _match(self, left: Sequence[Detection], right: Sequence[Detection]) -> List[Tuple[int, int, Detection]]: - """硬约束过滤 + 代价升序贪心一一配对(§6)。返回 (左下标, 右下标, 左框)。""" - cfg = self._cfg - d_lo, d_hi = cfg.disparity_bounds_px() - candidates: List[Tuple[int, int, Detection, float]] = [] - for i, det_l in enumerate(left): - for j, det_r in enumerate(right): - if det_l.cls_id != det_r.cls_id: - continue - dy = abs(det_l.cy - det_r.cy) - if dy > cfg.vertical_tau_px: - continue - h_max = max(det_l.height, det_r.height) - if h_max <= 0.0: - continue - dh_ratio = abs(det_l.height - det_r.height) / h_max - if dh_ratio > cfg.height_ratio_tau: - continue - cost = dy / cfg.vertical_tau_px + dh_ratio / cfg.height_ratio_tau - if cost >= 1.0: - continue - d_edge = det_l.x1 - det_r.x1 - if not (d_lo <= d_edge <= d_hi): - d_center = det_l.cx - det_r.cx - if cfg.use_center_fallback and (d_lo <= d_center <= d_hi): - self._warn("center_fallback", "左边缘视差越出窗口,回退中心视差(宽度差可能混入)") - else: - continue - same_track = det_l.track_id > 0 and det_l.track_id == det_r.track_id - candidates.append((0 if same_track else 1, cost, i, j, det_l)) - candidates.sort(key=lambda c: (c[0], c[1])) - matched_l: set = set() - used_r: set = set() - pairs: List[Tuple[int, int, Detection]] = [] - for _, _, i, j, det_l in candidates: - if i in matched_l or j in used_r: - continue - pairs.append((i, j, det_l)) - matched_l.add(i) - used_r.add(j) - return pairs + def _complete_record( + self, + point_l: SamplePoint, + point_r: SamplePoint, + pair: MatchPair, + frame_id: int, + now_ms: int, + frame_index: int, + ) -> RangeRecord: + """配对成功的记录:测距 + 方位源 + 有效性(门①②)+ 一致性(门③)+ 滤波。""" + config = self._config + box_l = point_l.box + box_r = point_r.box + disparity = select_disparity(point_l, point_r, config) + center_mid = virtual_center_px(box_l, box_r) - def _build_report(self, det_l: Detection, det_r: Detection, seq: int, ts: Optional[float]) -> RangeReport: - cfg = self._cfg - d_edge = det_l.x1 - det_r.x1 - d_lo, d_hi = cfg.disparity_bounds_px() - if d_lo <= d_edge <= d_hi: - d = d_edge - source = DISP_SOURCE_EDGE - else: - d = det_l.cx - det_r.cx - source = DISP_SOURCE_CENTER - z = cfg.fx_px * cfg.baseline_m / d - report = RangeReport( - frame_sequence=seq, - timestamp_s=ts, - class_id=det_l.cls_id, - track_id=det_l.track_id if det_l.track_id != 0 else det_r.track_id, - matched=True, - valid=False, # §5.4 判定见下 - forward_distance_m=z, - disparity_px=d, - disparity_source=source, - confidence=min(det_l.conf, det_r.conf), - centre_pixel_x_mid=(det_l.cx + det_r.cx) / 2.0, - centre_pixel_y_mid=det_l.cy, - ref_bbox=(det_l.x1, det_l.y1, det_l.x2, det_l.y2), + if disparity.fallback_used: + self._warn_emit("center_fallback", "边缘视差越窗,回退框中心(框宽差可能混入视差)") + if not disparity.positive: + self._warn_emit("disparity_violation", "视差违例 D=%.2f px(须为正),该目标判无效", disparity.d_px) + elif not disparity.in_domain: + self._warn_emit( + "disparity_violation", + "视差超界 D=%.2f px(域 [%.2f, %.2f]),该目标判无效", + disparity.d_px, + config.disparity_min_px, + config.disparity_max_px, + ) + + key = self._store.resolve( + box_l.class_id, + _shared_track_id(box_l, box_r), + center_mid, + frame_index, ) - err = range_error_abs(z, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px) - if d >= cfg.disparity_min_px and err <= cfg.range_tol_m: - report.valid = True - z_out = self._filter_z(report.class_id, report.track_id, z) - report.forward_distance_m = z_out - report.error_est_m = range_error_abs(z_out, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px) + status = self._store.consistency.advance(key, disparity.d_px) + if status.invalid: + self._warn_emit( + "consistency_invalid", + "连续 %d 帧视差跳变超门限(%.1f px),质量判无效", + status.streak, + config.consistency_jump_px, + ) + + z_raw = config.focal_baseline_px_m / disparity.d_px if disparity.positive else None + solution = solve_range(box_l, box_r, disparity.d_px, config) if z_raw is not None else None + decision = evaluate_validity(disparity.d_px, z_raw, config) + valid = decision.valid and not status.invalid + + confidence = confidence_from_cost(pair.cost) + if status.confidence_degraded: + confidence *= _CONFIDENCE_DEGRADE_FACTOR + if status.invalid: + confidence = 0.0 + + if valid: + z_out = self._store.distance_filter.advance(key, z_raw) else: - report.error_est_m = err - self._fill_xyz(report, det_l.cx) - return report + self._store.distance_filter.invalidate(key) + z_out = None - def _fill_xyz(self, report: RangeReport, x_l_px: float) -> None: - """由 Z 与左目(参考目)框中心像素回算左目系 X, Y, R(§4.2)。 + # 参考斜距与已发布的前向距离同基准:X/Y/Z 均 ∝ Z,按 z_out/z_raw 缩放即精确换基 + slant_range_m = None + if solution is not None: + slant_range_m = solution.r_m if z_out is None else solution.r_m * (z_out / z_raw) - 注意:方位源 centre_pixel_x_mid 属于基线中点虚拟相机(§11.3), - 而 X 定义在左目坐标系,二者相差 B/2,不得混用。 - """ - cfg = self._cfg - z = report.forward_distance_m - x = (x_l_px - cfg.cx_px) * z / cfg.fx_px - y = (report.centre_pixel_y_mid - cfg.cy_px) * z / cfg.fy_px - report.lateral_m = x - report.vertical_m = y - report.slant_range_m = math.sqrt(x * x + y * y + z * z) + return RangeRecord( + frame_id=frame_id, + class_id=box_l.class_id, + degradation=Degradation.COMPLETE, + valid=valid, + track_id=box_l.track_id if box_l.track_id is not None else box_r.track_id, + forward_distance_m=z_out, + slant_range_m=slant_range_m, + disparity_px=disparity.d_px, + center_mid_px=center_mid, + reference_box=box_l.bbox, + reference_eye=Eye.LEFT, + confidence=confidence, + timestamp_ms=now_ms, + ) - def _filter_z(self, cls_id: int, track_id: int, z: float) -> float: - """可选一阶低通 + 跳变重置(§7)。track_id 为 0(无轨迹)时不过滤。""" - cfg = self._cfg - if not cfg.filter_enabled or track_id == 0: - return z - key = (cls_id, track_id) - prev = self._z_state.get(key) - if prev is None: - self._z_state[key] = z - return z - if abs(z - prev) / prev > cfg.filter_jump_ratio: - self._z_state[key] = z # 跳变:重置 - return z - z_out = (1.0 - cfg.filter_alpha) * prev + cfg.filter_alpha * z - self._z_state[key] = z_out - return z_out - - def _unmatched_report(self, det: Detection, seq: int, ts: Optional[float]) -> RangeReport: - return RangeReport( - frame_sequence=seq, - timestamp_s=ts, - class_id=det.cls_id, - track_id=det.track_id, - matched=False, + def _bearing_only_record( + self, + point: SamplePoint, + frame_id: int, + now_ms: int, + eye: Eye, + ) -> RangeRecord: + """单目退化记录(§5 第 ⑤ 步):仅方位,valid = false,无距离。""" + detection = point.box + return RangeRecord( + frame_id=frame_id, + class_id=detection.class_id, + degradation=Degradation.BEARING_ONLY, valid=False, - confidence=det.conf, - centre_pixel_x_mid=det.cx, - centre_pixel_y_mid=det.cy, - ref_bbox=(det.x1, det.y1, det.x2, det.y2), + track_id=detection.track_id, + center_mid_px=(detection.cx, detection.cy), + reference_box=detection.bbox, + reference_eye=eye, + confidence=detection.confidence, + timestamp_ms=now_ms, ) - def _warn(self, category: str, message: str) -> None: - """节流诊断(§7:失败日志须节流)。""" - now = time.monotonic() - last = self._warn_at.get(category) - if last is not None and now - last < self._cfg.warn_interval_s: + def _drop_invalid(self, points: Sequence[SamplePoint]) -> List[SamplePoint]: + """丢弃坐标非有限值的框(不可参与匹配,也不发布记录),告警节流。""" + kept: List[SamplePoint] = [] + for point in points: + if point.finite: + kept.append(point) + else: + self._warn_emit("invalid_box", "检测框坐标非法(非有限值),已丢弃该框") + return kept + + def _warn_emit(self, category: str, message: str, *args: object) -> None: + """异常日志(§5 节流规范:第 1 次 + 每满 100 次,带累计计数)。""" + if not self._warn.should_emit(category): return - self._warn_at[category] = now - logger.warning("[%s] %s", category, message) + logger.warning("[%s] " + message + "(累计 %d 次)", category, *args, self._warn.count(category)) + + +def _shared_track_id(box_l: Detection, box_r: Detection) -> Optional[int]: + """左右目轨道关联一致且有效时返回该 id(目标隔离键优先路径,§4)。""" + if box_l.track_id is not None and box_l.track_id > 0 and box_l.track_id == box_r.track_id: + return box_l.track_id + return None diff --git a/utils/stereo_ranger/target_store.py b/utils/stereo_ranger/target_store.py new file mode 100644 index 0000000..6ed3c8c --- /dev/null +++ b/utils/stereo_ranger/target_store.py @@ -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 diff --git a/utils/stereo_ranger/types.py b/utils/stereo_ranger/types.py index fc964b2..e3b9b54 100644 --- a/utils/stereo_ranger/types.py +++ b/utils/stereo_ranger/types.py @@ -1,29 +1,50 @@ -"""stereo_ranger 输入输出数据结构:Detection / RangeReport。 +"""stereo_ranger 输入输出数据结构(纯值语义)。 -规格:docs/双目测距方案.md §10 结果记录。 +规格:docs/双目测距技术路线.md —— §2 输入输出接口、§3 符号约定、§6.6 方位源。 + +约定:所有像素坐标位于**已立体校正**的图像坐标系,检测框已映射回该基准 +(§2.1 校正契约 / 坐标基准一致)。本包仅依赖标准库。 """ -from dataclasses import dataclass -from typing import Optional, Tuple +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Optional, Sequence, Tuple BBoxXYXY = Tuple[float, float, float, float] +class Eye(Enum): + """物理目(参考目框来源,§2.2)。""" + + LEFT = auto() # 左目(解算参考系) + RIGHT = auto() # 右目 + + +class DisparitySource(Enum): + """视差取点来源(§6.1)。""" + + LEFT_EDGE = auto() # 同侧(左)边缘:v1.0 主方案 + CENTER_FALLBACK = auto() # 边缘视差越窗后回退框中心 + + +class Degradation(Enum): + """记录退化标记(§2.2)。描述记录的来源形态,质量由 valid 单独表达。""" + + COMPLETE = auto() # 完整:配对成功(距离 + 方位两路都在) + BEARING_ONLY = auto() # 仅方位:单目检出,无距离 + + @dataclass class Detection: - """校正后原生坐标系下的一个检测框(单位 px)。 - - 调用方负责:左右目预处理几何完全一致,且框坐标已映射回原生分辨率 - (规格 §4.3)。x1/y1 为左上角,x2/y2 为右下角(x1 即"左边缘")。 - """ + """校正后图像坐标系下的一个检测框(px;x1/y1 左上角,x2/y2 右下角)。""" x1: float y1: float x2: float y2: float - conf: float = 1.0 - cls_id: int = 0 - track_id: int = 0 # 未知为 0 + confidence: float = 1.0 + class_id: int = 0 + track_id: Optional[int] = None # 检测侧轨道关联;None = 无(§2.2,v1.0 可无) @property def width(self) -> float: @@ -41,31 +62,46 @@ class Detection: def cy(self) -> float: return (self.y1 + self.y2) / 2.0 + @property + def bbox(self) -> BBoxXYXY: + return (self.x1, self.y1, self.x2, self.y2) + @dataclass -class RangeReport: - """单目标单帧测距结果(一个左目检测对应一条记录)。 +class DetectionFrame: + """单目一帧:帧标识 + 检测框列表。 - matched=False 表示该左目检测本帧未配对:valid 恒为 False,距离字段为 - None,centre_pixel_x/y_mid 为单目自身中心(方位回退,规格 §11.3)。 - matched=True 且 valid=False 表示已配对但距离超出有效工作域 - (规格 §5.4):距离字段仍给出估计值供诊断,方位照常发布。 + 同帧契约(§2.1):左右两帧的 frame_id 必须一致,否则整帧拒收、输出空。 """ - frame_sequence: int + frame_id: int + detections: Sequence[Detection] = field(default_factory=tuple) + + +@dataclass +class RangeRecord: + """目标距离记录(§2.2,字段与文档表格逐项对应)。 + + - forward_distance_m(Z):唯一供距离通道消费的量;valid=False 或单目 + 退化时为 None(不得强行估计,§12.3 红线 3); + - slant_range_m(R):参考值,不进控制回路;无效但仍可解算时保留原始 + 测量值供诊断; + - disparity_px(D):本次视差(调试与交叉校验用); + - center_mid_px(c_mid):方位源;配对记录为两目框中心等权平均(虚拟 + 双目中心,§6.6),单目退化为该目框中心; + - reference_box / reference_eye:参考目框(配对记录恒为左目)。 + """ + + frame_id: int class_id: int - track_id: int - matched: bool + degradation: Degradation valid: bool - forward_distance_m: Optional[float] = None # Z,前向分量(主输出) - lateral_m: Optional[float] = None # X,左目系横向偏移 - vertical_m: Optional[float] = None # Y,左目系纵向偏移 - slant_range_m: Optional[float] = None # R = sqrt(X^2 + Y^2 + Z^2) - disparity_px: Optional[float] = None # D = xL - xR(>0 为前方) - disparity_source: Optional[str] = None # "left_edge" / "center" + track_id: Optional[int] = None + forward_distance_m: Optional[float] = None + slant_range_m: Optional[float] = None + disparity_px: Optional[float] = None + center_mid_px: Tuple[float, float] = (0.0, 0.0) + reference_box: Optional[BBoxXYXY] = None + reference_eye: Eye = Eye.LEFT confidence: float = 0.0 - error_est_m: Optional[float] = None # δZ 估计(δD = 配置 disp_noise_px) - centre_pixel_x_mid: float = 0.0 # 虚拟双目中心 x(方位源,§11.3) - centre_pixel_y_mid: float = 0.0 # y_mid(校正后 yL == yR) - ref_bbox: Optional[BBoxXYXY] = None # 左目(参考目)框,供诊断 - timestamp_s: Optional[float] = None + timestamp_ms: int = 0 diff --git a/utils/stereo_ranger/warn_throttle.py b/utils/stereo_ranger/warn_throttle.py new file mode 100644 index 0000000..3845a79 --- /dev/null +++ b/utils/stereo_ranger/warn_throttle.py @@ -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() diff --git a/utils/tests/stereo_ranger/config_test.py b/utils/tests/stereo_ranger/config_test.py deleted file mode 100644 index 612dd4a..0000000 --- a/utils/tests/stereo_ranger/config_test.py +++ /dev/null @@ -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)) diff --git a/utils/tests/stereo_ranger/stereo_ranger_test.py b/utils/tests/stereo_ranger/stereo_ranger_test.py deleted file mode 100644 index 6d69a97..0000000 --- a/utils/tests/stereo_ranger/stereo_ranger_test.py +++ /dev/null @@ -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) diff --git a/utils/tests/visual_track_control/tracking_control_law_test.py b/utils/tests/visual_track_control/tracking_control_law_test.py index 55704f2..a4b1531 100644 --- a/utils/tests/visual_track_control/tracking_control_law_test.py +++ b/utils/tests/visual_track_control/tracking_control_law_test.py @@ -3,8 +3,9 @@ 移植自 reference-c++/visual_track_control/tests/tracking_control_law_test.cpp。 覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉 超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向 -通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道三种 -无距离降级动作、首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。 +通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道无距离 +降级动作(含 EXIT 门限/grace 重置/零门限立即退出)、距离超龄回落、首拍变化率 +限幅(S6)、机体系→NED 输出旋转(A4)。 """ import math @@ -26,7 +27,7 @@ def make_config() -> TrackingConfig: cfg = TrackingConfig() cfg.control.frequency_hz = 20.0 cfg.control.visual_stale_ms = 200 - cfg.control.radar_stale_ms = 500 + cfg.control.distance_stale_ms = 500 cfg.control.attitude_stale_ms = 300 cfg.camera.image_width = 1000 cfg.camera.image_height = 1000 @@ -55,7 +56,7 @@ def make_config() -> TrackingConfig: def make_snapshot(now_ms: int) -> ControlSnapshot: - """新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜。""" + """新鲜快照:目标在画面中心,姿态/追踪/距离均新鲜。""" return ControlSnapshot( track=TrackState(tracked=True, is_predicted=False, center=(500.0, 500.0)), @@ -213,9 +214,9 @@ def test_vertical_velocity_clamped(): def test_distance_approach_when_far(): law = TrackingControlLaw(make_config()) snap = make_snapshot(1000) - snap.radar_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3 - snap.associated_to_target = True - snap.radar_time_ms = 1000 + snap.target_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3 + snap.distance_valid = True + snap.distance_time_ms = 1000 out = law.update(snap, 1000) assert out.vx_mps == pytest.approx(3.0, abs=1e-9) @@ -223,17 +224,17 @@ def test_distance_approach_when_far(): def test_distance_retreat_clamped_when_too_close(): law = TrackingControlLaw(make_config()) snap = make_snapshot(1000) - snap.radar_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1 - snap.associated_to_target = True - snap.radar_time_ms = 1000 + snap.target_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1 + snap.distance_valid = True + snap.distance_time_ms = 1000 out = law.update(snap, 1000) assert out.vx_mps == pytest.approx(-1.0, abs=1e-9) def test_no_distance_hold_zeros_vx(): - law = TrackingControlLaw(make_config()) # 缺省 kHold + law = TrackingControlLaw(make_config()) # 基准配置 HOLD snap = make_snapshot(1000) - snap.associated_to_target = False + snap.distance_valid = False out = law.update(snap, 1000) assert out.mode is ControlMode.VELOCITY_HEADING assert out.vx_mps == 0.0 @@ -244,29 +245,79 @@ def test_no_distance_slow_approach(): cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH law = TrackingControlLaw(cfg) snap = make_snapshot(1000) - snap.associated_to_target = False + snap.distance_valid = False out = law.update(snap, 1000) assert out.vx_mps == pytest.approx(0.5, abs=1e-9) -def test_no_distance_exit_degrades(): +def test_no_distance_exit_waits_grace_then_degrades(): + # S1 拍板:EXIT 需持续无距离达门限才降级;grace 期间按 SLOW_APPROACH 行为 cfg = make_config() cfg.distance.no_distance_action = NoDistanceAction.EXIT + cfg.distance.no_distance_exit_grace_ms = 200 law = TrackingControlLaw(cfg) snap = make_snapshot(1000) - snap.associated_to_target = False + snap.distance_valid = False + out1 = law.update(snap, 1000) + assert out1.mode is ControlMode.VELOCITY_HEADING + assert out1.vx_mps == pytest.approx(0.5, abs=1e-9) + snap.track_time_ms = 1200 # 保持视觉/姿态新鲜,聚焦距离通道 + snap.attitude_time_ms = 1200 + out2 = law.update(snap, 1200) # 恰达门限 → 降级 + assert out2.mode is ControlMode.DEGRADED_HOLD + assert out2.degraded_reason is DegradedReason.NO_DISTANCE_EXIT + assert not out2.valid + + +def test_no_distance_exit_grace_resets_on_valid_frame(): + cfg = make_config() + cfg.distance.no_distance_action = NoDistanceAction.EXIT + cfg.distance.no_distance_exit_grace_ms = 200 + law = TrackingControlLaw(cfg) + snap = make_snapshot(1000) + snap.distance_valid = False + assert law.update(snap, 1000).mode is ControlMode.VELOCITY_HEADING + + snap.track_time_ms = 1100 # 有效距离帧:重置计时 + snap.attitude_time_ms = 1100 + snap.distance_valid = True + snap.target_distance_m = 30.0 + snap.distance_time_ms = 1100 + assert law.update(snap, 1100).mode is ControlMode.VELOCITY_HEADING + + snap.track_time_ms = 1250 # 再次无效:重新计时 + snap.attitude_time_ms = 1250 + snap.distance_valid = False + snap.target_distance_m = 0.0 + assert law.update(snap, 1250).mode is ControlMode.VELOCITY_HEADING # 0 ms < 200 ms + + snap.track_time_ms = 1500 + snap.attitude_time_ms = 1500 + out = law.update(snap, 1500) # 250 ms ≥ 200 ms → 降级 + assert out.mode is ControlMode.DEGRADED_HOLD + assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT + + +def test_no_distance_exit_zero_grace_degrades_immediately(): + # grace = 0 保留旧"首个无效拍即退出"行为 + cfg = make_config() + cfg.distance.no_distance_action = NoDistanceAction.EXIT + cfg.distance.no_distance_exit_grace_ms = 0 + law = TrackingControlLaw(cfg) + snap = make_snapshot(1000) + snap.distance_valid = False out = law.update(snap, 1000) assert out.mode is ControlMode.DEGRADED_HOLD assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT assert not out.valid -def test_stale_radar_falls_back_to_no_distance(): - law = TrackingControlLaw(make_config()) # kHold +def test_stale_distance_falls_back_to_no_distance(): + law = TrackingControlLaw(make_config()) # HOLD snap = make_snapshot(1000) - snap.radar_distance_m = 30.0 - snap.associated_to_target = True - snap.radar_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用 + snap.target_distance_m = 30.0 + snap.distance_valid = True + snap.distance_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用 out = law.update(snap, 1000) assert out.mode is ControlMode.VELOCITY_HEADING assert out.vx_mps == 0.0 @@ -280,9 +331,9 @@ def test_first_tick_vx_slews_from_zero(): cfg.accel_limit.ax_mps2 = 2.0 law = TrackingControlLaw(cfg) snap = make_snapshot(1000) - snap.radar_distance_m = 30.0 # 通道输出本应为 3.0 - snap.associated_to_target = True - snap.radar_time_ms = 1000 + snap.target_distance_m = 30.0 # 通道输出本应为 3.0 + snap.distance_valid = True + snap.distance_time_ms = 1000 out = law.update(snap, 1000) # 首拍:dt 按 1/20 Hz 兜底 assert out.vx_mps == pytest.approx(2.0 * (1.0 / 20.0), abs=1e-9) # 0.1 @@ -293,9 +344,9 @@ def test_output_rotated_to_ned_by_yaw(): law = TrackingControlLaw(make_config()) snap = make_snapshot(1000) snap.yaw_rad = math.pi / 2.0 # 机头朝东 - snap.radar_distance_m = 30.0 - snap.associated_to_target = True - snap.radar_time_ms = 1000 + snap.target_distance_m = 30.0 + snap.distance_valid = True + snap.distance_time_ms = 1000 out = law.update(snap, 1000) # 机体前向 3.0 → 东向 3.0,北向 ≈ 0 assert out.vx_mps == pytest.approx(0.0, abs=1e-9) @@ -306,9 +357,9 @@ def test_zero_yaw_keeps_forward_as_north(): law = TrackingControlLaw(make_config()) snap = make_snapshot(1000) snap.yaw_rad = 0.0 - snap.radar_distance_m = 30.0 - snap.associated_to_target = True - snap.radar_time_ms = 1000 + snap.target_distance_m = 30.0 + snap.distance_valid = True + snap.distance_time_ms = 1000 out = law.update(snap, 1000) assert out.vx_mps == pytest.approx(3.0, abs=1e-9) assert out.vy_mps == pytest.approx(0.0, abs=1e-9) @@ -321,17 +372,17 @@ def test_reset_restores_slew_from_zero(): cfg.accel_limit.ax_mps2 = 2.0 law = TrackingControlLaw(cfg) snap = make_snapshot(1000) - snap.radar_distance_m = 30.0 - snap.associated_to_target = True - snap.radar_time_ms = 1000 + snap.target_distance_m = 30.0 + snap.distance_valid = True + snap.distance_time_ms = 1000 for t in range(1000, 3000, 50): snap.track_time_ms = t - snap.radar_time_ms = t + snap.distance_time_ms = t snap.attitude_time_ms = t law.update(snap, t) # 若干拍后 vx 已爬升到限幅 3.0 law.reset() snap.track_time_ms = 3000 - snap.radar_time_ms = 3000 + snap.distance_time_ms = 3000 snap.attitude_time_ms = 3000 out = law.update(snap, 3000) # Reset 后限幅器归零:又从 0.1 开始渐变 diff --git a/utils/tests/visual_track_control/vtc_config_test.py b/utils/tests/visual_track_control/vtc_config_test.py index 4335252..2719a04 100644 --- a/utils/tests/visual_track_control/vtc_config_test.py +++ b/utils/tests/visual_track_control/vtc_config_test.py @@ -15,7 +15,7 @@ from pathlib import Path import pytest from visual_track_control.config import (HeadingMode, NoDistanceAction, - load_from_json) + TrackingConfig, load_from_json) _REPO_ROOT = Path(__file__).resolve().parents[3] REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json" @@ -27,7 +27,7 @@ def full_config() -> dict: "control": { "frequency_hz": 20.0, "visual_stale_ms": 200, - "radar_stale_ms": 500, + "distance_stale_ms": 500, "attitude_stale_ms": 300, }, "camera": { @@ -57,6 +57,7 @@ def full_config() -> dict: "retreat_velocity_limit_mps": 1.0, "no_distance_action": "hold", "no_distance_approach_limit_mps": 0.5, + "no_distance_exit_grace_ms": 3000, }, "accel_limit": { "ax_mps2": 2.0, @@ -84,7 +85,9 @@ def test_load_real_config_succeeds(): assert cfg.vertical.vz_gain_mps_per_deg == 0.1 assert cfg.vertical.vz_limit_mps == 2.0 assert cfg.distance.d_exp_m == 10.0 - assert cfg.distance.no_distance_action is NoDistanceAction.HOLD + assert cfg.distance.no_distance_action is NoDistanceAction.SLOW_APPROACH + assert cfg.distance.no_distance_exit_grace_ms == 3000 + assert cfg.control.distance_stale_ms == 500 assert cfg.accel_limit.ax_mps2 == 2.0 @@ -169,3 +172,18 @@ def test_fov_out_of_range_throws(tmp_path): with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "camera.fov_h_deg" in str(exc_info.value) + + +def test_default_no_distance_action_is_slow_approach(): + # S1 拍板:双目有效域外无距离是常态,默认定速接近(纪要 D4/S1) + assert (TrackingConfig().distance.no_distance_action + is NoDistanceAction.SLOW_APPROACH) + + +def test_negative_exit_grace_throws(tmp_path): + config = full_config() + config["distance"]["no_distance_exit_grace_ms"] = -1 + path = write_temp_json(tmp_path, config, "vtc_cfg_badgrace.json") + with pytest.raises(ValueError) as exc_info: + load_from_json(path) + assert "no_distance_exit_grace_ms" in str(exc_info.value) diff --git a/utils/visual_track_control/README.md b/utils/visual_track_control/README.md index 25979a3..2b52d84 100644 --- a/utils/visual_track_control/README.md +++ b/utils/visual_track_control/README.md @@ -9,7 +9,7 @@ | 名称 | 说明 | | --- | --- | | `TrackingConfig` / `load_from_json(path)` | 配置加载(启动期校验,错误携带字段名,抛 `ValueError`) | -| `HeadingMode` / `NoDistanceAction` | 配置枚举(rate/position;hold/slow_approach/exit) | +| `HeadingMode` / `NoDistanceAction` | 配置枚举(rate/position;hold/slow_approach/exit,默认 slow_approach;exit 需持续无距离达 `no_distance_exit_grace_ms` 才降级) | | `TrackingControlLaw(cfg)` | 主控律 | | `.update(snapshot, now_ms) -> ControlOutput` | 单周期八步流程(姿态/视觉超龄 → 判丢 → Coast → 三通道 → NED 旋转) | | `.reset()` | 停止闭环时清全部内部状态 | diff --git a/utils/visual_track_control/config.py b/utils/visual_track_control/config.py index 95117cc..ff7694a 100644 --- a/utils/visual_track_control/config.py +++ b/utils/visual_track_control/config.py @@ -23,18 +23,19 @@ class HeadingMode(Enum): class NoDistanceAction(Enum): - """雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3)。""" + """目标距离不可用(无效/超龄)时的距离通道降级动作(§6.3)。""" HOLD = "hold" # 保持:前向速度指令清零 SLOW_APPROACH = "slow_approach" # 限速接近:按 no_distance_approach_limit_mps 缓速 - EXIT = "exit" # 退出近距操作:输出降级保持 + EXIT = "exit" # 退出近距操作:持续无距离达 no_distance_exit_grace_ms + # 才降级保持;grace 期间按 SLOW_APPROACH 行为 @dataclass class ControlGroupConfig: frequency_hz: float = 20.0 visual_stale_ms: int = 200 - radar_stale_ms: int = 500 + distance_stale_ms: int = 500 attitude_stale_ms: int = 300 @@ -70,8 +71,11 @@ class DistanceGroupConfig: derivative_filter_coef: float = 0.0 approach_velocity_limit_mps: float = 3.0 retreat_velocity_limit_mps: float = 1.0 - no_distance_action: NoDistanceAction = NoDistanceAction.HOLD + # 默认定速接近(D4 远场策略):双目有效域外无距离是常态,退出仅留给 + # 持续无距离(传感器级故障)场景 + no_distance_action: NoDistanceAction = NoDistanceAction.SLOW_APPROACH no_distance_approach_limit_mps: float = 0.5 + no_distance_exit_grace_ms: int = 3000 # EXIT 门限:持续无距离达此时长才退出(毫秒,占位) @dataclass @@ -155,9 +159,9 @@ def _load_control(root: dict) -> ControlGroupConfig: c.frequency_hz = _require_positive( _require_number(s, "control", "frequency_hz"), "control.frequency_hz") c.visual_stale_ms = _require_integer(s, "control", "visual_stale_ms") - c.radar_stale_ms = _require_integer(s, "control", "radar_stale_ms") + c.distance_stale_ms = _require_integer(s, "control", "distance_stale_ms") c.attitude_stale_ms = _require_integer(s, "control", "attitude_stale_ms") - if c.visual_stale_ms <= 0 or c.radar_stale_ms <= 0 or c.attitude_stale_ms <= 0: + if c.visual_stale_ms <= 0 or c.distance_stale_ms <= 0 or c.attitude_stale_ms <= 0: raise ValueError("配置非法: control.*_stale_ms 必须为正") return c @@ -240,6 +244,10 @@ def _load_distance(root: dict) -> DistanceGroupConfig: d.no_distance_approach_limit_mps = _require_non_negative( _require_number(s, "distance", "no_distance_approach_limit_mps"), "distance.no_distance_approach_limit_mps") + # 0 容许:等效旧版"首个无效拍即退出"行为 + d.no_distance_exit_grace_ms = _require_non_negative( + _require_integer(s, "distance", "no_distance_exit_grace_ms"), + "distance.no_distance_exit_grace_ms") return d diff --git a/utils/visual_track_control/tracking_control_law.py b/utils/visual_track_control/tracking_control_law.py index fc9dff7..cab5c48 100644 --- a/utils/visual_track_control/tracking_control_law.py +++ b/utils/visual_track_control/tracking_control_law.py @@ -10,7 +10,8 @@ ④ 像素偏移 → 视线角 β/α(全程针孔模型) ⑤ 水平航向(速率式/位置式,A2:位置式合成绝对 NED 航向) ⑥ 垂直统一速度支路(A1:不切姿态微调模式) - ⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit) + ⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit, + exit 需持续无距离达 no_distance_exit_grace_ms 门限) ⑧ 机体系 → 局部 NED 旋转(A4) 日志纪律与 C++ 版一致:仅状态切换点打印,热路径零日志。 @@ -78,6 +79,10 @@ class TrackingControlLaw: self._vz_limiter = SlewRateLimiter(cfg.accel_limit.az_mps2) self._last_now_ms = 0 self._last_mode = ControlMode.DEGRADED_HOLD + # EXIT 无距离计时(§6.3:持续无距离达 no_distance_exit_grace_ms 才退出, + # 任一有效距离帧重置) + self._no_distance_active = False + self._no_distance_since_ms = 0 def update(self, snapshot: ControlSnapshot, now_ms: int) -> ControlOutput: # ---- 节拍推算(§7.1 固定周期;异常节拍用配置频率兜底) ---- @@ -107,6 +112,7 @@ class TrackingControlLaw: # ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 BRAKE_HOVER) ---- if not snapshot.track.tracked: + self._no_distance_active = False # 刹车期间不计 EXIT 无距离时长 if self._last_mode is not ControlMode.BRAKE_HOVER: # 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅) self._distance_pid.reset() @@ -156,14 +162,16 @@ class TrackingControlLaw: self._cfg.vertical.vz_limit_mps) # ---- ⑦ 距离通道(§6.2 / §6.3) ---- - # 雷达距离不得直接当目标距离:仅关联成立且未超龄才可用(§6.3、FR-041) - distance_available = (snapshot.associated_to_target - and snapshot.radar_distance_m > 0.0 - and not is_stale(snapshot.radar_time_ms, now_ms, - self._cfg.control.radar_stale_ms)) + # 目标距离来自检测框自身的双目测距:仅 distance_valid 且未超龄才可用 + # (§6.3、FR-041) + distance_available = (snapshot.distance_valid + and snapshot.target_distance_m > 0.0 + and not is_stale(snapshot.distance_time_ms, now_ms, + self._cfg.control.distance_stale_ms)) if distance_available: + self._no_distance_active = False # 任一有效距离帧重置 EXIT 计时 # Δd = d − d_exp:d > d_exp 前向接近,d < d_exp 减速/后退(§6.2) - delta_d = snapshot.radar_distance_m - self._cfg.distance.d_exp_m + delta_d = snapshot.target_distance_m - self._cfg.distance.d_exp_m vx_raw = self._distance_pid.update(delta_d, dt_s) slewed = self._vx_limiter.limit(vx_raw, dt_s) # 加速度限幅(§8) out.vx_mps = clamp(slewed, @@ -182,10 +190,20 @@ class TrackingControlLaw: out.vx_mps = self._vx_limiter.limit( self._cfg.distance.no_distance_approach_limit_mps, dt_s) else: # NoDistanceAction.EXIT - # 退出近距操作:整体降级保持 - out.mode = ControlMode.DEGRADED_HOLD - out.degraded_reason = DegradedReason.NO_DISTANCE_EXIT - return self._finish_and_log(out, "距离未关联,退出近距操作") + # 退出近距操作:双目有效域外无距离是常态(D4 远场定速接近), + # 首个无效拍不退出;持续无距离达 no_distance_exit_grace_ms + # (疑似传感器级故障)才降级。grace 期间按 SLOW_APPROACH 行为 + # 执行(经加速度限幅,vx 平滑过渡);grace=0 保留旧"立即退出"行为 + if not self._no_distance_active: + self._no_distance_active = True + self._no_distance_since_ms = now_ms + if (now_ms - self._no_distance_since_ms + >= self._cfg.distance.no_distance_exit_grace_ms): + out.mode = ControlMode.DEGRADED_HOLD + out.degraded_reason = DegradedReason.NO_DISTANCE_EXIT + return self._finish_and_log(out, "持续无距离达门限,退出近距操作") + out.vx_mps = self._vx_limiter.limit( + self._cfg.distance.no_distance_approach_limit_mps, dt_s) # ---- ⑧ 机体系 → 局部 NED 旋转(§2.3,A4 修复) ---- # 各通道限幅均在机体系完成(旋转为正交变换,合速度不变,限幅约束 @@ -207,6 +225,7 @@ class TrackingControlLaw: self._yaw_rate_limiter.reset() self._vx_limiter.reset() self._vz_limiter.reset() + self._no_distance_active = False self._last_now_ms = 0 self._last_mode = ControlMode.DEGRADED_HOLD diff --git a/utils/visual_track_control/types.py b/utils/visual_track_control/types.py index 01353f3..a6a7a0d 100644 --- a/utils/visual_track_control/types.py +++ b/utils/visual_track_control/types.py @@ -55,9 +55,10 @@ class ControlSnapshot: track: TrackState = field(default_factory=TrackState) track_time_ms: int = 0 # 取样单调时钟(毫秒),<=0 视为从未取样 - radar_distance_m: float = 0.0 # 机头前向单点距离(米) - associated_to_target: bool = False # 相机—雷达空间关联成立标志(未关联不得当目标距离) - radar_time_ms: int = 0 + # 目标距离(双目立体测距,§6.3:distance_valid 为 False 时不得当作目标距离) + target_distance_m: float = 0.0 # 目标距离(米,双目前向分量 Z) + distance_valid: bool = False # 距离有效标志(测距来自检测框自身,无跨模态关联) + distance_time_ms: int = 0 # 取样单调时钟(毫秒) attitude_present: bool = False # 姿态源当前是否可用(过期 → 禁止控制) attitude_time_ms: int = 0