first commit
This commit is contained in:
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N801" />
|
||||||
|
<option value="N806" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyStubPackagesAdvertiser" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<list>
|
||||||
|
<option value="scipy" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredIdentifiers">
|
||||||
|
<list>
|
||||||
|
<option value="moviepy.*" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/code_drone-test.iml" filepath="$PROJECT_DIR$/.idea/code_drone-test.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# C++ → Python 移植计划(reference-c++ → utils)
|
||||||
|
|
||||||
|
## 范围与环境
|
||||||
|
- 移植 `reference-c++/target_tracker` 与 `reference-c++/visual_track_control` 两个子项目为 Python,放入 `utils/`;**不改动 reference-c++ 任何文件**。
|
||||||
|
- Python 解释器:`D:\program file\conda\envs\pytorch\python.exe`(3.12.13,mavsdk 3.15.3 / numpy 2.4.3 已装)。需向该环境 `pip install pytest`。
|
||||||
|
- 行为保真优先:单位、符号约定、限幅顺序、超龄/判丢/Coast 语义、异常消息文本全部与 C++ 版及规格文档一致。
|
||||||
|
|
||||||
|
## 目录布局
|
||||||
|
```
|
||||||
|
utils/
|
||||||
|
├── README.md # 唯一文档(结构、运行、MAVSDK 映射决策、实机接线片段)
|
||||||
|
├── conftest.py # pytest sys.path 注入,使两包可顶层导入
|
||||||
|
├── target_tracker/ # 包1:追踪库(仅依赖 stdlib + numpy)
|
||||||
|
│ ├── __init__.py # 导出 TargetTracker/TrackResult/Detection/FrameShape 等
|
||||||
|
│ ├── kalman_box_filter.py # 8 维恒速卡尔曼(numpy 矩阵;Q 用状态值、R 用 abs×2 的规格差异保留)
|
||||||
|
│ ├── tracklet.py # 轨道 + ToCenterSize/ToXYXY/ComputeIoU
|
||||||
|
│ ├── target_tracker.py # 六步主流程 + 贪婪关联 + 主目标锁定;LastResult 用 threading.Lock
|
||||||
|
│ └── tests/ # pytest 移植 27 条(kalman 5 / tracklet 12 / tracker 10,含 §9.4 六条验收)
|
||||||
|
├── visual_track_control/ # 包2:控制律(不 import target_tracker)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── vtc_types.py # TrackObservation/ControlSnapshot/ControlOutput/枚举(frozen dataclass)
|
||||||
|
│ ├── vtc_config.py # stdlib json 加载,校验异常消息与 C++ 一致(含字段路径)
|
||||||
|
│ ├── line_of_sight.py / pid_controller.py / rate_limiter.py / staleness_checker.py / frame_transform.py
|
||||||
|
│ ├── tracking_control_law.py # 八步单周期主流程,logging 仅模式切换打点
|
||||||
|
│ ├── offboard_sender.py # OffboardSender 协议 + YawIntegrator + LoopbackSender(演示/测试)
|
||||||
|
│ ├── mavsdk_sender.py # MAVSDK 实现(mavsdk 懒加载,其余模块零感知)
|
||||||
|
│ ├── config/track_control.json # 从 C++ 原样拷贝
|
||||||
|
│ └── tests/ # pytest 移植 8 套
|
||||||
|
└── examples/
|
||||||
|
└── track_control_demo.py # 唯一同时 import 两包的胶水点
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键设计
|
||||||
|
1. **解耦边界**:C++ 版 ControlSnapshot 内嵌 tracker::TrackResult;Python 改为控制律自有的最小结构 `TrackObservation(tracked, is_predicted, center)`。visual_track_control 全程不出现 target_tracker 导入,demo 负责字段拷贝。两包内部一律相对导入、可独立搬移;mavsdk 只在 mavsdk_sender.py 内懒加载。
|
||||||
|
2. **MAVSDK 映射(已确认方案)**:统一 `set_velocity_ned(VelocityNedYaw)`。
|
||||||
|
- 速率式:`YawIntegrator` 把 yaw_rate 积分为绝对航向(wrap180,dt 取发送侧单调时钟差)→ VelocityNedYaw(vn, ve, vd, 积分航向);
|
||||||
|
- 位置式:直接用 out.yaw_deg;刹车:零速 + 保持积分航向;降级:不发送。
|
||||||
|
- 控制律输出仍与 C++ 一致(机体系计算限幅→输出端旋转为 NED),NED 分量直接对应 MAVSDK 字段;OffboardError 捕获后返回 False(对齐 C++ Send 布尔语义)。offboard.start()/stop() 生命周期不属于 sender,README 给接线片段。
|
||||||
|
3. **数值**:全程 Python float(双精度),卡尔曼用 numpy(4×4 求逆 + 1e-9 正则化);保留检测入口 NaN/Inf 过滤、置信度 NaN→−∞ 排序等防御逻辑;时间戳 `time.monotonic_ns()//10**6`。
|
||||||
|
4. **MAVSDK sender 测试**:用 fake offboard 对象(鸭子类型)验证分发与积分行为,不 import mavsdk;gateway 相关 C++ 字节级用例改为验证 sender 收到的 VelocityNedYaw 内容。
|
||||||
|
|
||||||
|
## 验证与交付
|
||||||
|
1. `pip install pytest`(pytorch 环境)→ 两包测试全绿;
|
||||||
|
2. 运行 demo(asyncio,20Hz,三阶段:正常 6s → Coast 4s → 判丢 3s,演示判丢阈值 60 帧),输出语义与 C++ 版一致;
|
||||||
|
3. mavsdk_sender 冒烟测试(fake offboard,不连实机);
|
||||||
|
4. 撰写 utils/README.md。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
pip 安装 pytest → target_tracker 包+测试 → visual_track_control 包+测试 → sender 层+测试 → demo → README → 汇总报告。
|
||||||
+371
@@ -0,0 +1,371 @@
|
|||||||
|
# 视觉跟踪控制律设计(FlightController 实现期规格)
|
||||||
|
|
||||||
|
## 1. 文档状态与范围
|
||||||
|
|
||||||
|
### 1.1 文档状态
|
||||||
|
|
||||||
|
- 本文档为**实现期规格文档**,由早期草稿《物理追踪实现路线 v1.0》重写而来:
|
||||||
|
原文档中与既有设计体系冲突的表述(摄像头型号、飞控直连、控制时机、
|
||||||
|
拦截设备绑定、雷达距离语义等)已全部修正,合理的控制思路
|
||||||
|
(水平角控制、垂直控制、距离控制)保留并纳入本规格。
|
||||||
|
- 当前相关代码均为骨架占位实现,与 `docs/开发进度.md`(第 6 步未开始)一致:
|
||||||
|
- `src/control/flight_controller.cpp`(IFlightController Stub)
|
||||||
|
- `src/state_machine/mission_state_machine.cpp`(IMissionStateMachine Stub)
|
||||||
|
- `src/perception/target_estimator.cpp`(ITargetEstimator Stub)
|
||||||
|
- 本文档中所有阈值、限幅值、PID 参数**只列占位,不填具体数值**,
|
||||||
|
一律标注"待定/配置化"(需求分析.md NFR-007)。
|
||||||
|
|
||||||
|
### 1.2 范围与状态机对应
|
||||||
|
|
||||||
|
本文档定义视觉跟踪阶段(位置→速度控制切换之后)的控制律:
|
||||||
|
|
||||||
|
| 状态机阶段 | 本文档覆盖内容 |
|
||||||
|
|------------|----------------|
|
||||||
|
| S5 GPS_APPROACH | 仅提供上下文(位置引导阶段,控制律不在本文档范围) |
|
||||||
|
| S6 VISUAL_HANDOVER | 切换后首条限幅指令的产生要求(切换条件本身见 状态机设计.md S6) |
|
||||||
|
| S7 VISUAL_TRACKING | **本文档核心**:水平航向、垂直/高度、距离三路控制律 |
|
||||||
|
| S8 OBSTACLE_HOLD | 控制律输出被障碍策略接管(限速/刹停),见第 9 章 |
|
||||||
|
|
||||||
|
### 1.3 文档定位与引用关系
|
||||||
|
|
||||||
|
本文档定位为**视觉跟踪控制律设计(FlightController 实现期规格)**。
|
||||||
|
与以下内容重叠的部分一律引用、不复述:
|
||||||
|
|
||||||
|
| 引用文档 | 引用章节 | 本文档不重复的内容 |
|
||||||
|
|----------|----------|--------------------|
|
||||||
|
| `docs/状态机设计.md` | S6、S7、§4.2、§7 | 切换守卫条件、目标丢失转移、可替换策略接口 |
|
||||||
|
| `docs/感知与避障设计.md` | §3、§5、§6、§9 | 标定流程、稳定锁定、雷达关联条件、降级原则 |
|
||||||
|
| `docs/视觉追踪技术路线.md` | §1.2、§2 | TargetTracker 算法细节、Coast/判丢行为约定 |
|
||||||
|
| `docs/系统架构设计.md` | §6、§8 | 数据流、控制单出口、时间与控制周期 |
|
||||||
|
| `docs/数据接口文档.md` | §2.2、§3、§4.9~§4.12、§5 | 消息字段定义、接口签名、Topic 一览 |
|
||||||
|
| `docs/需求分析.md` | FR-033、FR-041、FR-051、NFR-007、NFR-008、§7 | 需求条文本身 |
|
||||||
|
| `include/common/types.h` | 全文 | 消息类型与 Topic 常量的权威定义 |
|
||||||
|
| `target_tracker/target_tracker.md` | §2、§7 | 追踪库接口与未来接线指引 |
|
||||||
|
|
||||||
|
## 2. 输入输出与数据流
|
||||||
|
|
||||||
|
### 2.1 输入
|
||||||
|
|
||||||
|
控制律在状态机与控制线程中按固定周期消费以下快照(Topic 名称常量见
|
||||||
|
`include/common/types.h` 的 `drone::common::topics` 命名空间):
|
||||||
|
|
||||||
|
| Topic 常量 | 消息类型 | 用途 |
|
||||||
|
|------------|----------|------|
|
||||||
|
| `kEstimatedTarget` | `TargetState` | 目标估计状态(位置/速度/置信度/最近观测时间),控制律主输入 |
|
||||||
|
| `kFlightState` | `FlightStateSnapshot` | 本机姿态、速度、局部位置,用于坐标转换与反馈 |
|
||||||
|
| `kLaserRange` | `LaserRangeSample` | 机头前向单点距离;**仅在相机—雷达标定完成且空间关联成立时**才可作为目标距离,否则仅为前向安全距离(需求分析.md FR-041) |
|
||||||
|
| `kGroundTarget` | `GroundStationTarget` | 地面站目标状态,用于丢失校验与降级回退(非视觉控制主输入) |
|
||||||
|
|
||||||
|
### 2.2 输出与控制链路
|
||||||
|
|
||||||
|
控制律不直接写飞控。链路严格遵循 系统架构设计.md §6 的控制单出口约束:
|
||||||
|
|
||||||
|
```text
|
||||||
|
状态机与控制线程(控制律,本文档范围)
|
||||||
|
-> ControlIntent(控制意图,kControlIntent)
|
||||||
|
-> IFlightController(意图 -> 设定值转换与限幅)
|
||||||
|
-> Px4Setpoint(kPx4Setpoint)
|
||||||
|
-> IPx4Link(PX4 通信线程)
|
||||||
|
-> 飞控串口
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只有 PX4 通信线程可以写飞控串口;控制律只提交意图
|
||||||
|
(系统架构设计.md §6、数据接口文档.md §4.9)。
|
||||||
|
- 视觉跟踪阶段主要使用:
|
||||||
|
- `ControlIntentType::kVelocityHeading`(速度与航向意图):
|
||||||
|
`target_x/target_y/target_z` 为速度分量(坐标系由 `header.frame_id` 给出),
|
||||||
|
`yaw_deg` 为目标航向,`max_speed_mps` 携带速度限制;
|
||||||
|
- `ControlIntentType::kBrakeHover`(刹停/悬停意图):目标丢失判丢、
|
||||||
|
障碍制动等场景;
|
||||||
|
- `ControlIntentType::kManualOverride`(禁止自动输出/人工接管意图):
|
||||||
|
遥控器接管时停止任务控制输出。
|
||||||
|
- `Px4Setpoint` 使用 `SetpointType::kVelocity`(速度设定值,分量写入
|
||||||
|
`x`、`y`、`z`)或 `SetpointType::kAttitude`(姿态微调,仅限垂直小误差
|
||||||
|
支路,见第 5 章);航向写入 `yaw_deg` 或 `yaw_rate_dps`;
|
||||||
|
无效设定值(如刹停完成)也需发送,避免过期指令(`valid` 标志语义见
|
||||||
|
`include/common/types.h`)。
|
||||||
|
|
||||||
|
### 2.3 坐标系编码
|
||||||
|
|
||||||
|
所有空间数据消息显式使用 `MessageHeader.frame_id` 编码
|
||||||
|
(数据接口文档.md §2.2、`include/common/types.h` 文件头约定):
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 = 未知
|
||||||
|
1 = WGS84 经纬度
|
||||||
|
2 = 机体(前右下)
|
||||||
|
3 = 局部NED
|
||||||
|
4 = 局部ENU
|
||||||
|
```
|
||||||
|
|
||||||
|
控制律内部计算统一在**机体坐标系(frame_id = 2)**下进行
|
||||||
|
(视线角→机体前/右/下分量),输出的速度意图若采用局部系
|
||||||
|
(frame_id = 3),必须随 `FlightStateSnapshot` 的姿态/航向完成旋转,
|
||||||
|
并在 `header.frame_id` 中显式标注;禁止混用未标注坐标系的数据。
|
||||||
|
|
||||||
|
## 3. 坐标与标定前提
|
||||||
|
|
||||||
|
### 3.1 转换链路
|
||||||
|
|
||||||
|
```text
|
||||||
|
目标像素位置 (u, v)
|
||||||
|
->(相机内参 + 畸变校正)-> 归一化像平面方向
|
||||||
|
->(相机→机体固定外参)-> 机体视线方向
|
||||||
|
->(结合当前机体姿态)-> 水平偏航误差 / 垂直俯仰误差
|
||||||
|
->(雷达关联成立时叠加距离)-> 机体系相对位置 (Δ前, Δ右, Δ下)
|
||||||
|
```
|
||||||
|
|
||||||
|
视觉像素只有结合相机模型和对应时刻的机体姿态,才能转换为目标相对机体
|
||||||
|
的视线方向(感知与避障设计.md §3)。
|
||||||
|
|
||||||
|
### 3.2 标定前提与待定项
|
||||||
|
|
||||||
|
实现视觉控制前必须完成(感知与避障设计.md §3、需求分析.md §7):
|
||||||
|
|
||||||
|
- 相机内参与畸变标定(**待定**:相机内参、畸变参数未标定);
|
||||||
|
- 相机坐标系到机体坐标系的固定外参(**待定**:外参未标定);
|
||||||
|
- 激光波束在相机图像中的投影位置或区域(**待定**:相机—雷达标定未完成);
|
||||||
|
- 图像、检测、光流、雷达和 PX4 姿态的时间对齐;
|
||||||
|
- 目标和本机高度基准(**待定**,需求分析.md §7)。
|
||||||
|
|
||||||
|
摄像头为 SIYI A8mini,经网口以 RTSP 输出 H.265、1080p、25 FPS 视频
|
||||||
|
(需求分析.md §3、开发思路.md);画面宽度 `W`、高度 `H` 与帧率以实际
|
||||||
|
解码配置为准。标定完成前,控制律只允许在 SITL/台架中以录制数据回放
|
||||||
|
验证数学链路,不得上实飞。
|
||||||
|
|
||||||
|
## 4. 水平航向控制
|
||||||
|
|
||||||
|
### 4.1 目标与原理
|
||||||
|
|
||||||
|
目标:让机头在水平方向对准目标。由目标像素水平偏移反求偏航修正量,
|
||||||
|
经限幅后作为航向指令。
|
||||||
|
|
||||||
|
设目标中心像素横坐标偏移为 `x_offset`(目标中心相对画面中心,右正),
|
||||||
|
图像宽度为 `W`,相机**水平视场角**为 `θ_h`(注意:`θ_h` 仅表示水平
|
||||||
|
视场角,与俯仰姿态角无关,不得复用同一符号)。
|
||||||
|
|
||||||
|
针孔模型下的精确表述:
|
||||||
|
|
||||||
|
```
|
||||||
|
β = atan( x_offset * 2 * tan(θ_h / 2) / W )
|
||||||
|
```
|
||||||
|
|
||||||
|
当目标靠近画面中心(|x_offset| 相对 W 较小)时,可用线性近似:
|
||||||
|
|
||||||
|
```
|
||||||
|
β ≈ x_offset * ( θ_h / W )
|
||||||
|
```
|
||||||
|
|
||||||
|
线性近似的适用条件:
|
||||||
|
|
||||||
|
- 偏移量处于画面中心附近(具体适用范围**待定**,由标定与实测确定);
|
||||||
|
- 已完成畸变校正(畸变在画面边缘放大近似误差);
|
||||||
|
- 大偏移场景应使用针孔模型表述,或直接采用归一化像平面方向。
|
||||||
|
|
||||||
|
机体姿态变化时,像素偏移同时包含本机转动与目标运动两部分,
|
||||||
|
控制律需结合 `FlightStateSnapshot` 的姿态与角速度做补偿
|
||||||
|
(实现期细化,补偿结构**待定**)。
|
||||||
|
|
||||||
|
### 4.2 输出映射
|
||||||
|
|
||||||
|
- 航向指令映射到 `ControlIntentType::kVelocityHeading` 的 `yaw_deg`,
|
||||||
|
或由 `Px4Setpoint` 的 `yaw_deg` / `yaw_rate_dps` 表达;
|
||||||
|
- 采用位置式(β→目标航向)还是速率式(β→偏航角速度)由实现期
|
||||||
|
按 PX4 1.17.0 Offboard 行为确定(**待定**);
|
||||||
|
- 偏航角速度与方向变化率必须限幅(需求分析.md FR-033),
|
||||||
|
限幅值见第 8 章。
|
||||||
|
|
||||||
|
## 5. 垂直 / 高度控制
|
||||||
|
|
||||||
|
### 5.1 误差来源
|
||||||
|
|
||||||
|
垂直误差由两个来源合成:
|
||||||
|
|
||||||
|
1. 像素垂直偏移:目标中心相对画面中心的纵坐标偏移 `y_offset`(下正),
|
||||||
|
图像高度 `H`,相机**垂直视场角** `θ_v`。与第 4 章同理,垂直视线角:
|
||||||
|
|
||||||
|
```
|
||||||
|
α = atan( y_offset * 2 * tan(θ_v / 2) / H ) (针孔模型)
|
||||||
|
α ≈ y_offset * ( θ_v / H ) (中心附近线性近似)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 距离信息:只有雷达与目标完成空间关联(见 3.2 与第 6.3 节)时,
|
||||||
|
才可由测距距离 `d` 换算高度差:
|
||||||
|
|
||||||
|
```
|
||||||
|
Δh = d * sin(α)
|
||||||
|
```
|
||||||
|
|
||||||
|
(等价于早期草稿中的 `Δh = d × cos(90° − α)`;此处 α 为垂直视线角,
|
||||||
|
与水平视场角 θ_h、垂直视场角 θ_v 明确区分命名。)
|
||||||
|
|
||||||
|
雷达未关联时,垂直通道只能使用角度误差做姿态/速率级控制,
|
||||||
|
不得假设已知高度差。
|
||||||
|
|
||||||
|
### 5.2 控制策略
|
||||||
|
|
||||||
|
垂直控制按角度误差大小分两支(沿用原草稿思路,阈值待定):
|
||||||
|
|
||||||
|
- **角度误差较小**:直接微调姿态角(`SetpointType::kAttitude`),
|
||||||
|
仅作辅助,必须有严格姿态角与姿态变化率限幅(需求分析.md FR-033);
|
||||||
|
- **角度误差较大**:调整高度,使用速度设定值(`SetpointType::kVelocity`
|
||||||
|
的 `z` 分量,局部 NED 下为垂直速度)趋近目标高度。
|
||||||
|
|
||||||
|
角度误差切换阈值、姿态角限幅、垂直速度限幅均**待定**(第 8 章)。
|
||||||
|
|
||||||
|
## 6. 距离控制
|
||||||
|
|
||||||
|
### 6.1 目标
|
||||||
|
|
||||||
|
距离控制使目标进入拦截射程。**射程进入条件由拦截设备接口
|
||||||
|
(`IInterceptionDevice`)与配置给出**,本文档不假设任何具体拦截方式;
|
||||||
|
具体捕网或其他拦截方式不得进入通用逻辑(需求分析.md FR-051、
|
||||||
|
状态机设计.md §7)。射程 `d_exp` 为配置项,数值**待定**。
|
||||||
|
|
||||||
|
### 6.2 控制律
|
||||||
|
|
||||||
|
设目标距离为 `d`(来源见 6.3),射程为 `d_exp`:
|
||||||
|
|
||||||
|
```
|
||||||
|
距离误差:Δd = d - d_exp
|
||||||
|
前向速度指令 = PID(Δd)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 对 `Δd` 做 PID 得到机体前向速度指令,采用速度指令实现
|
||||||
|
(`ControlIntentType::kVelocityHeading` 的速度分量 →
|
||||||
|
`SetpointType::kVelocity`);
|
||||||
|
- `d > d_exp` 时前向接近,`d < d_exp` 时减速/后退,接近末端的速度
|
||||||
|
必须按限幅约束收敛;
|
||||||
|
- PID 参数(Kp/Ki/Kd、积分限幅、微分滤波)全部**待定/配置化**。
|
||||||
|
|
||||||
|
### 6.3 距离来源与降级
|
||||||
|
|
||||||
|
- 雷达距离**不得直接当作目标距离**:`LaserRangeSample` 表达机头前向
|
||||||
|
单点距离,不得自动解释为任意视觉目标的距离
|
||||||
|
(`include/common/types.h` 注释、通信与数据定义.md §7)。
|
||||||
|
仅当相机—雷达标定完成、视觉区域覆盖雷达投影点、时间足够接近且
|
||||||
|
测量质量有效时,才可关联为目标距离(需求分析.md FR-041、
|
||||||
|
感知与避障设计.md §3);
|
||||||
|
- 关联失败时,雷达距离仅作为**前向安全距离**,供障碍策略
|
||||||
|
(第 9 章)使用;距离通道此时按配置降级:保持当前速度、
|
||||||
|
限速接近或退出近距操作(具体降级动作**待定**);
|
||||||
|
- 视觉估计的距离趋势(目标估计器输出)可作为辅助输入,
|
||||||
|
融合权重**待定**。
|
||||||
|
|
||||||
|
### 6.4 与追踪库的行为衔接
|
||||||
|
|
||||||
|
沿用 视觉追踪技术路线.md §1.2 的上游行为约定:
|
||||||
|
|
||||||
|
- 追踪库输出 `is_predicted == true`(Coast 预测中)时,距离与航向
|
||||||
|
PID 的**积分项清空**,防止积分饱和;
|
||||||
|
- 追踪库输出 `tracked == false`(判丢)时,执行**零速刹车**
|
||||||
|
(`ControlIntentType::kBrakeHover`),并按 状态机设计.md §4.2
|
||||||
|
的目标丢失转移处理;
|
||||||
|
- 停止视觉闭环时调用追踪库 `Reset()` 清空状态。
|
||||||
|
|
||||||
|
## 7. 控制周期与时序
|
||||||
|
|
||||||
|
### 7.1 固定控制周期
|
||||||
|
|
||||||
|
- 控制线程按**固定周期持续运行**,不等待任意传感器消息后被动执行;
|
||||||
|
视觉跟踪控制律在每个控制周期内持续执行,而非"检测完成后才进行"
|
||||||
|
(系统架构设计.md §8);
|
||||||
|
- 每次控制计算使用同一时刻附近的 PX4、视觉、雷达和目标数据快照;
|
||||||
|
- 超龄数据只能触发降级或告警,不能继续生成正常控制指令
|
||||||
|
(超龄判定使用 `MessageHeader.receive_time_ms` + `valid_for_ms`);
|
||||||
|
- 控制周期频率与各输入超龄阈值**待定/配置化**(系统架构设计.md §8)。
|
||||||
|
|
||||||
|
### 7.2 与 target_tracker 的衔接
|
||||||
|
|
||||||
|
目标追踪库(`target_tracker/`,已实现,见 `target_tracker/target_tracker.md`)
|
||||||
|
承担像素级平滑、Coast 与主目标锁定:
|
||||||
|
|
||||||
|
- 推理侧每帧调用 `TargetTracker::Update()`,控制线程(约 20Hz 速度闭环)
|
||||||
|
读取 `TrackResult`(`LastResult()` 线程安全拷贝);
|
||||||
|
- 控制线程消费 `TrackResult` 的平滑中心点计算第 4/5 章角度误差,
|
||||||
|
并执行 6.4 的 Coast/判丢约定;
|
||||||
|
- `TrackResult` 属于追踪库自有类型,**尚未进入
|
||||||
|
`include/common/types.h`**;`target_tracker/target_tracker.md` §7
|
||||||
|
提议新增 `TrackedTarget` 消息与 `kTrackedTarget` 主题,
|
||||||
|
该提议当前**未实现**,接入主工程时需先在数据接口文档与
|
||||||
|
types.h 中正式定义。
|
||||||
|
|
||||||
|
## 8. 限幅、阈值与配置化
|
||||||
|
|
||||||
|
所有阈值集中于此并进入配置文件,不得散落在代码中
|
||||||
|
(需求分析.md NFR-007)。视觉跟踪的速度、加速度和方向变化
|
||||||
|
必须限幅(需求分析.md FR-033)。
|
||||||
|
|
||||||
|
| 项目 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 水平/垂直角度误差切换阈值 | 姿态微调与高度/速率控制的分支条件 | 待定 |
|
||||||
|
| 射程 `d_exp` | 由拦截设备接口与配置给出 | 待定 |
|
||||||
|
| 距离通道 PID 参数 | Kp / Ki / Kd、积分限幅 | 待定 |
|
||||||
|
| 航向通道增益与限幅 | 偏航角速度上限、方向变化率上限 | 待定 |
|
||||||
|
| 垂直速度限幅 | 上升/下降速度上限 | 待定 |
|
||||||
|
| 姿态角限幅 | 姿态微调支路的角与角速率上限 | 待定 |
|
||||||
|
| 前向速度限幅 | 接近/后退速度上限,随距离收敛 | 待定 |
|
||||||
|
| 加速度限幅 | 各轴加速度上限 | 待定 |
|
||||||
|
| 控制周期频率 | 控制线程固定周期 | 待定 |
|
||||||
|
| 各输入超龄阈值 | 视觉/雷达/姿态/目标估计 | 待定 |
|
||||||
|
| 线性近似适用范围 | 画面中心偏移比例 | 待定 |
|
||||||
|
|
||||||
|
首条视觉速度设定值必须经过限幅,避免位置→速度切换产生突变
|
||||||
|
(状态机设计.md S6)。
|
||||||
|
|
||||||
|
## 9. 异常与降级
|
||||||
|
|
||||||
|
### 9.1 目标丢失
|
||||||
|
|
||||||
|
- 判丢后执行零速刹车并调用 `ITargetLostStrategy`:短时预测
|
||||||
|
(预测时长与不确定度有上限)、最新有效 `kGroundTarget` 校验、
|
||||||
|
整机转向搜索或退回 `GPS_APPROACH`(状态机设计.md §4.2、
|
||||||
|
感知与避障设计.md §5);
|
||||||
|
- 卡尔曼预测不得无限期生成控制指令(需求分析.md FR-034)。
|
||||||
|
|
||||||
|
### 9.2 传感器降级
|
||||||
|
|
||||||
|
按 感知与避障设计.md §9 的降级原则,与本文档控制律相关者:
|
||||||
|
|
||||||
|
| 故障 | 视觉跟踪阶段动作 |
|
||||||
|
|------|------------------|
|
||||||
|
| 摄像头失效 | 退出视觉跟踪 |
|
||||||
|
| 光流失效 | 降低跟踪可信度和速度 |
|
||||||
|
| 雷达失效 | 降低速度或退出近距操作;距离通道仅保留角度级控制 |
|
||||||
|
| 地面站目标过期 | 可按策略短时视觉跟踪 |
|
||||||
|
| PX4 姿态过期 | 禁止空间转换和控制,立即退出正常控制 |
|
||||||
|
|
||||||
|
### 9.3 障碍物规避
|
||||||
|
|
||||||
|
前向危险时控制律输出被障碍策略接管:清除视觉速度意图,限速、刹停并
|
||||||
|
悬停(S8 OBSTACLE_HOLD,状态机设计.md S8、感知与避障设计.md §7)。
|
||||||
|
雷达未关联时的前向安全距离即该策略的主输入。危险解除后必须重新
|
||||||
|
验证入口条件才可恢复视觉控制(需求分析.md FR-044)。
|
||||||
|
|
||||||
|
## 10. 待定项与验证计划
|
||||||
|
|
||||||
|
### 10.1 待定项清单
|
||||||
|
|
||||||
|
| 待定项 | 依据 |
|
||||||
|
|--------|------|
|
||||||
|
| 相机内参、畸变参数、相机—机体—雷达外参 | 需求分析.md §7 |
|
||||||
|
| 目标和本机高度基准 | 需求分析.md §7 |
|
||||||
|
| 第 8 章全部阈值、限幅与 PID 参数 | 需求分析.md §7、NFR-007 |
|
||||||
|
| 雷达—目标空间关联的具体判据参数 | 感知与避障设计.md §3 |
|
||||||
|
| 航向控制位置式/速率式选择 | PX4 1.17.0 Offboard 实测 |
|
||||||
|
| 机体姿态补偿结构 | 实现期细化 |
|
||||||
|
| `TrackedTarget` / `kTrackedTarget` 消息定义 | target_tracker.md §7 提议,未入库 |
|
||||||
|
|
||||||
|
### 10.2 验证路径
|
||||||
|
|
||||||
|
按 需求分析.md NFR-008 与 系统架构设计.md §11 逐级验证:
|
||||||
|
|
||||||
|
1. **开发机单元测试**:控制律纯函数(角度换算、PID、限幅)用
|
||||||
|
Google Test 覆盖;录制数据回放验证像素→视线角数学链路;
|
||||||
|
2. **PX4 SITL**:在仿真中验证设定值流、限幅行为、丢失/降级分支;
|
||||||
|
3. **串口台架**:真实飞控 + 模拟数据,验证时序、超龄判定与
|
||||||
|
控制权仲裁;
|
||||||
|
4. **受控实飞**:按状态机设计验证 S6→S7 切换与跟踪效果。
|
||||||
|
|
||||||
|
必测故障项包含旧目标位置、旧视频帧、雷达异常、遥控器接管、
|
||||||
|
算力板控制输出中断(系统架构设计.md §11)。
|
||||||
+1029
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,685 @@
|
|||||||
|
# 多目标追踪过滤器(TargetTracker)技术路线
|
||||||
|
|
||||||
|
> 本文档将 `drone_controller/target_tracker.py` 的实现抽象为与编程语言无关的技术路线,
|
||||||
|
> 可作为 C++(或任意语言)重新实现的规格说明。所有算法细节、参数默认值均以现有代码
|
||||||
|
> 实际实现为准,未包含代码中不存在的功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [概述与系统定位](#1-概述与系统定位)
|
||||||
|
2. [输入输出接口定义](#2-输入输出接口定义)
|
||||||
|
3. [核心数据结构](#3-核心数据结构)
|
||||||
|
4. [卡尔曼滤波算法细节](#4-卡尔曼滤波算法细节)
|
||||||
|
5. [检测-轨迹关联流程](#5-检测-轨迹关联流程)
|
||||||
|
6. [轨迹生命周期与 ID 管理](#6-轨迹生命周期与-id-管理)
|
||||||
|
7. [主流程伪代码(每帧处理)](#7-主流程伪代码每帧处理)
|
||||||
|
8. [关键参数表](#8-关键参数表)
|
||||||
|
9. [C++ 移植要点与模块划分建议](#9-c-移植要点与模块划分建议)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述与系统定位
|
||||||
|
|
||||||
|
### 1.1 模块职责
|
||||||
|
|
||||||
|
本模块是一个**轻量级多目标追踪过滤器**,位于无人机视觉闭环控制链路中
|
||||||
|
"目标检测"与"飞控闭环"之间,承担三类职责:
|
||||||
|
|
||||||
|
1. **平滑**:检测器(YOLO 推理)输出的边界框逐帧抖动,通过恒定速度卡尔曼滤波
|
||||||
|
输出平滑后的目标中心点与边界框,供下游速度闭环(PID)消费。
|
||||||
|
2. **抗短暂丢失(Coast 预测)**:目标短暂消失(如 2~5 帧)时,不立即判丢,
|
||||||
|
而是用滤波器预测值继续输出目标位置(`is_predicted = true`),
|
||||||
|
避免控制输出突变导致机体抽搐。
|
||||||
|
3. **ID 稳定与主目标锁定**:多目标场景下通过"中心距离 + IoU 加权代价"的贪婪
|
||||||
|
关联维持稳定 ID;选定主目标(primary)后持续锁定,直到该轨道真正死亡,
|
||||||
|
避免被新出现的高置信度目标抢走导致"甩头"。
|
||||||
|
|
||||||
|
### 1.2 在系统中的位置
|
||||||
|
|
||||||
|
```
|
||||||
|
摄像头/RTSP ──► NPU 推理(YOLO) ──► 后处理检测框
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ TargetTracker │ ◄── 每帧调用 update()
|
||||||
|
│ (本模块) │
|
||||||
|
└──────────────────┘
|
||||||
|
│ 平滑中心点 / 框 / 状态标志
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
20Hz 控制线程 Coast 期间 tracked=false
|
||||||
|
PID → 速度指令 清空 PID 积分 触发零速刹车
|
||||||
|
```
|
||||||
|
|
||||||
|
- **调用时机**:由推理主循环在每一帧检测完成后调用一次 `update()`;
|
||||||
|
结果由独立的控制线程(约 20Hz 速度闭环)读取消费。
|
||||||
|
- **上游行为约定**(来自调用方,供移植时对齐):
|
||||||
|
- 输出 `is_predicted == true`(Coast 中)时,调用方会清空 PID 积分项,
|
||||||
|
防止积分饱和(windup);
|
||||||
|
- 输出 `tracked == false`(彻底丢失)时,调用方执行零速刹车;
|
||||||
|
- 停止视觉闭环时调用方会调用 `reset()` 清空全部追踪状态。
|
||||||
|
|
||||||
|
### 1.3 设计特点
|
||||||
|
|
||||||
|
- 恒定速度模型,8 维状态向量,纯线性卡尔曼滤波(无 UKF/EKF)。
|
||||||
|
- 关联采用**贪婪最邻近**(按轨道置信度降序逐一匹配),不使用匈牙利算法;
|
||||||
|
代价函数为中心距离与 (1−IoU) 的加权和。
|
||||||
|
- 所有坐标使用**原始像素坐标**(与检测后处理输出空间一致),不做归一化。
|
||||||
|
- 时间步长以"帧"为单位(dt = 1),与帧率无关,参数按约 30fps、640×480 画面调优。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 输入输出接口定义
|
||||||
|
|
||||||
|
### 2.1 主要接口
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `构造(max_lost_frames, max_association_dist, min_hits, dist_weight, iou_weight)` | 创建追踪器,参数见[第 8 节](#8-关键参数表) |
|
||||||
|
| `update(detections, frame_shape) → TrackResult` | 每帧调用一次,输入检测列表,输出主目标追踪结果 |
|
||||||
|
| `get_last_result() → TrackResult` | 获取上一帧结果快照(线程安全读取场景使用) |
|
||||||
|
| `reset()` | 清空所有轨道、ID 计数器、锁定状态与缓存结果 |
|
||||||
|
| `active_count() → int` | 当前存活轨道数量 |
|
||||||
|
| `active_tracks() → {track_id: Tracklet}` | 当前所有轨道的只读快照(浅复制) |
|
||||||
|
|
||||||
|
### 2.2 输入:detections
|
||||||
|
|
||||||
|
每帧检测列表,每个元素为 6 元组:
|
||||||
|
|
||||||
|
```
|
||||||
|
[x1, y1, x2, y2, conf, cls_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| x1, y1 | 浮点 | 边界框左上角(像素坐标) |
|
||||||
|
| x2, y2 | 浮点 | 边界框右下角(像素坐标) |
|
||||||
|
| conf | 浮点 | 检测置信度 ∈ [0,1] |
|
||||||
|
| cls_id | 整数 | 类别编号 |
|
||||||
|
|
||||||
|
- 允许传入空列表(表示本帧无任何检测),此时所有轨道进入 Coast 计数。
|
||||||
|
- `frame_shape = (height, width)`,可选。仅在"无实测轨道、只有 Coast 轨道"
|
||||||
|
时用于选取主目标的兜底排序(选最接近画面中心的轨道);未提供时按
|
||||||
|
640×480 的画面中心 (320, 240) 计算。
|
||||||
|
|
||||||
|
### 2.3 输出:TrackResult
|
||||||
|
|
||||||
|
`update()` 每帧返回一个结构体(代码中以字典表示),字段如下:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| tracked | 布尔 | 是否存在有效主目标。false 时调用方应刹车 |
|
||||||
|
| primary_id | 整数或空 | 主目标轨道 ID;无目标时为空 |
|
||||||
|
| center | (cx, cy) 或空 | 平滑后的目标中心点(像素坐标) |
|
||||||
|
| box | [x1,y1,x2,y2] 或空 | 平滑后的边界框 |
|
||||||
|
| confidence | 浮点 | 当前置信度(Coast 期间继承最近一次实测的置信度) |
|
||||||
|
| is_predicted | 布尔 | true = 当前帧处于 Coast(仅有预测、无实测) |
|
||||||
|
| lost_frames | 整数 | 主目标连续未命中帧数 |
|
||||||
|
| n_active | 整数 | 当前活跃轨道总数 |
|
||||||
|
| raw | [x1,y1,x2,y2] 或空 | 关联到的原始检测框;Coast 时为空 |
|
||||||
|
|
||||||
|
丢失(无主目标)时的返回值:`tracked=false`,其余定位字段为空,`lost_frames=0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心数据结构
|
||||||
|
|
||||||
|
模块由三层结构组成:
|
||||||
|
|
||||||
|
```
|
||||||
|
TargetTracker(多目标管理器)
|
||||||
|
└── Tracklet × N(单条轨道:生命周期 + 辅助状态)
|
||||||
|
└── KalmanBoxFilter(单个 8 维恒定速度卡尔曼滤波器)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 KalmanBoxFilter(单目标滤波器)
|
||||||
|
|
||||||
|
| 成员 | 类型 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| x | 数组[8] | 状态向量 [cx, cy, w, h, vx, vy, vw, vh] |
|
||||||
|
| P | 矩阵 8×8 | 状态协方差 |
|
||||||
|
| F | 矩阵 8×8 | 状态转移矩阵(常量) |
|
||||||
|
| H | 矩阵 4×8 | 观测矩阵(常量) |
|
||||||
|
| initialized | 布尔 | 是否已用首次观测初始化 |
|
||||||
|
|
||||||
|
### 3.2 Tracklet(轨道)
|
||||||
|
|
||||||
|
| 成员 | 类型 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| track_id | 整数 | 全局唯一轨道 ID |
|
||||||
|
| kf | KalmanBoxFilter | 本轨道的滤波器 |
|
||||||
|
| lost_count | 整数 | 当前连续未匹配帧数 |
|
||||||
|
| max_lost | 整数 | 最大允许连续丢失帧数(构造时注入) |
|
||||||
|
| confidence | 浮点 | 最近一次匹配的置信度 |
|
||||||
|
| cls_id | 整数 | 最近一次匹配的类别 ID |
|
||||||
|
| age | 整数 | 轨道已存在的总帧数(创建时为 1) |
|
||||||
|
| hits | 整数 | 累计成功匹配次数(创建时为 1) |
|
||||||
|
| last_center | (cx, cy) | 最近一次中心点(实测或预测) |
|
||||||
|
| last_box | [x1,y1,x2,y2] 或空 | 最近一次边界框(实测或预测) |
|
||||||
|
|
||||||
|
派生状态(只读属性):
|
||||||
|
|
||||||
|
- `is_dead := lost_count > max_lost`(可回收)
|
||||||
|
- `is_coasting := lost_count > 0`(处于纯预测状态)
|
||||||
|
|
||||||
|
### 3.3 TargetTracker(管理器)
|
||||||
|
|
||||||
|
| 成员 | 类型 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| max_lost_frames 等 5 项 | 浮点/整数 | 配置参数,见第 8 节 |
|
||||||
|
| next_id | 整数 | 下一个可分配的轨道 ID(从 0 递增,不复用) |
|
||||||
|
| tracks | 哈希表 {track_id → Tracklet} | 所有存活轨道 |
|
||||||
|
| frame_count | 整数 | 已处理帧数(仅统计用) |
|
||||||
|
| locked_primary_id | 整数或空 | 当前锁定的主目标轨道 ID |
|
||||||
|
| last_result | TrackResult | 上一帧结果缓存(供跨线程读取) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 卡尔曼滤波算法细节
|
||||||
|
|
||||||
|
### 4.1 状态空间模型
|
||||||
|
|
||||||
|
状态向量(8 维)与观测向量(4 维):
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathbf{x} = [c_x,\ c_y,\ w,\ h,\ v_x,\ v_y,\ v_w,\ v_h]^T,
|
||||||
|
\qquad
|
||||||
|
\mathbf{z} = [c_x,\ c_y,\ w,\ h]^T
|
||||||
|
\]
|
||||||
|
|
||||||
|
其中 \(c_x, c_y\) 为框中心像素坐标,\(w, h\) 为框宽高,后四项为对应速度
|
||||||
|
(单位:像素/帧)。
|
||||||
|
|
||||||
|
**状态转移**(恒定速度模型,dt = 1 帧):
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathbf{x}_k = F\,\mathbf{x}_{k-1},\quad
|
||||||
|
F = \begin{bmatrix} I_4 & I_4 \\ 0 & I_4 \end{bmatrix}
|
||||||
|
\]
|
||||||
|
|
||||||
|
即前 4 维(位置/尺寸)各自加上对应速度:`F[i][i+4] = 1 (i=0..3)`,其余为单位阵。
|
||||||
|
|
||||||
|
**观测模型**(直接观测位置与尺寸,不观测速度):
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathbf{z}_k = H\,\mathbf{x}_k,\quad
|
||||||
|
H = [\,I_4 \mid 0\,] \in \mathbb{R}^{4\times 8}
|
||||||
|
\]
|
||||||
|
|
||||||
|
### 4.2 噪声参数(经验值,针对 640×480 画面调优)
|
||||||
|
|
||||||
|
| 常量 | 值 | 用途 |
|
||||||
|
|------|----|------|
|
||||||
|
| STD_POS | 1/50 = 0.02 | 位置过程噪声比例系数 |
|
||||||
|
| STD_VEL | 1/200 = 0.005 | 速度过程噪声比例系数 |
|
||||||
|
| INI_POS | 2.0 | 初始位置协方差(对角) |
|
||||||
|
| INI_VEL | 100.0 | 初始速度协方差(对角) |
|
||||||
|
|
||||||
|
**关键设计:噪声与目标尺寸成比例(动态 Q/R)。**
|
||||||
|
预测时过程噪声 Q 按当前状态中的目标宽高缩放:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\sigma_{pos} = [\tfrac{w}{50},\ \tfrac{h}{50},\ \tfrac{w}{50},\ \tfrac{h}{50}],
|
||||||
|
\quad
|
||||||
|
\sigma_{vel} = [\tfrac{w}{200},\ \tfrac{h}{200},\ \tfrac{w}{200},\ \tfrac{h}{200}]
|
||||||
|
\]
|
||||||
|
|
||||||
|
\[
|
||||||
|
Q = \mathrm{diag}\big(\sigma_{pos}^2,\ \sigma_{vel}^2\big)
|
||||||
|
\]
|
||||||
|
|
||||||
|
(Q 为 8×8 对角阵,前 4 个对角元来自 \(\sigma_{pos}\),后 4 个来自 \(\sigma_{vel}\);
|
||||||
|
每个分量先平方再上对角。)
|
||||||
|
|
||||||
|
更新时观测噪声 R 同样按当前状态的宽高缩放,并额外乘 2 增强平滑:
|
||||||
|
|
||||||
|
\[
|
||||||
|
R = \mathrm{diag}\big(2\,(\tfrac{|w|}{50})^2,\ 2\,(\tfrac{|h|}{50})^2,\ 2\,(\tfrac{|w|}{50})^2,\ 2\,(\tfrac{|h|}{50})^2\big)
|
||||||
|
\]
|
||||||
|
|
||||||
|
> 实现注意:R 中使用 `abs(w)`、`abs(h)` 防御负尺寸;Q 中直接使用状态值。
|
||||||
|
> 该差异与代码保持一致即可,正常情形下尺寸为正,二者等价。
|
||||||
|
|
||||||
|
### 4.3 初始化(initiate)
|
||||||
|
|
||||||
|
用首次观测 \([c_x, c_y, w, h]\) 初始化:
|
||||||
|
|
||||||
|
- 状态:\(\mathbf{x} = [c_x, c_y, w, h, 0, 0, 0, 0]^T\)(速度置零)
|
||||||
|
- 协方差:\(P = \mathrm{diag}(2, 2, 2, 2, 100, 100, 100, 100)\)
|
||||||
|
(位置置信度中等,速度置信度低,允许前几帧快速收敛速度估计)
|
||||||
|
|
||||||
|
### 4.4 预测(predict)
|
||||||
|
|
||||||
|
未初始化时返回空。否则:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathbf{x} \leftarrow F\,\mathbf{x}, \qquad
|
||||||
|
P \leftarrow F\,P\,F^T + Q
|
||||||
|
\]
|
||||||
|
|
||||||
|
返回预测的位置/尺寸 \([c_x, c_y, w, h]\)(状态向量前 4 维的副本)。
|
||||||
|
|
||||||
|
### 4.5 更新(update)
|
||||||
|
|
||||||
|
未初始化时直接返回。给定观测 \(\mathbf{z} = [c_x, c_y, w, h]\):
|
||||||
|
|
||||||
|
\[
|
||||||
|
S = H\,P\,H^T + R
|
||||||
|
\]
|
||||||
|
|
||||||
|
\[
|
||||||
|
K = P\,H^T\,S^{-1}
|
||||||
|
\]
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathbf{y} = \mathbf{z} - H\,\mathbf{x} \quad \text{(残差 / innovation)}
|
||||||
|
\]
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathbf{x} \leftarrow \mathbf{x} + K\,\mathbf{y}, \qquad
|
||||||
|
P \leftarrow (I_8 - K\,H)\,P
|
||||||
|
\]
|
||||||
|
|
||||||
|
> 实现注意:S 为 4×4 矩阵,代码中使用通用矩阵求逆。C++ 中可用
|
||||||
|
> 4×4 专用求逆(如高斯消元或 LDL 分解),无需引入大型线性代数库。
|
||||||
|
> 代码未对位置/尺寸做画面边界钳制,也未做负尺寸约束,移植时保持一致
|
||||||
|
> (若需增强鲁棒性可另行添加,但属于行为变更)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 检测-轨迹关联流程
|
||||||
|
|
||||||
|
### 5.1 代价函数
|
||||||
|
|
||||||
|
对每条轨道(预测后的中心 \((p_x, p_y)\)、预测框 \(B_t\))与每个检测
|
||||||
|
(中心 \((d_x, d_y)\)、检测框 \(B_d\)),定义加权代价:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathrm{cost} = w_{dist}\cdot \frac{\lVert (p_x,p_y)-(d_x,d_y) \rVert_2}{D_{max}}
|
||||||
|
\;+\; w_{iou}\cdot (1 - \mathrm{IoU}(B_t, B_d))
|
||||||
|
\]
|
||||||
|
|
||||||
|
- \(\lVert\cdot\rVert_2\) 为中心点欧氏距离(像素);
|
||||||
|
- \(D_{max}\) 为距离软阈值(`max_association_dist`,默认 200 px),
|
||||||
|
用于把距离项归一化到与 IoU 项同量级;实际使用时取
|
||||||
|
\(\max(D_{max},\ 10^{-3})\) 防止除零;
|
||||||
|
- 默认 \(w_{dist} = w_{iou} = 0.5\)。
|
||||||
|
|
||||||
|
**匹配有效性判据**:仅当 `cost < 1.0` 时视为有效匹配
|
||||||
|
(实现上:每条轨道的最优代价初始化为 1.0,只有严格小于 1.0 的候选才会被记录)。
|
||||||
|
|
||||||
|
### 5.2 IoU 计算
|
||||||
|
|
||||||
|
轴对齐框 \([x_1,y_1,x_2,y_2]\) 的标准交并比:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\mathrm{IoU}(A,B) = \frac{\mathrm{area}(A \cap B)}{\mathrm{area}(A) + \mathrm{area}(B) - \mathrm{area}(A \cap B)}
|
||||||
|
\]
|
||||||
|
|
||||||
|
交集宽高需与 0 取最大值;任一框为空、交集面积 ≤ 0 或并集 ≤ 0 时返回 0。
|
||||||
|
|
||||||
|
### 5.3 贪婪关联算法(非匈牙利)
|
||||||
|
|
||||||
|
按**轨道置信度从高到低**排序后逐一贪心匹配(高置信轨道优先抢占检测):
|
||||||
|
|
||||||
|
```
|
||||||
|
ASSOCIATE(tracks, detections):
|
||||||
|
若 tracks 或 detections 为空:
|
||||||
|
返回 matches=[], unmatched_tracks=全部轨道, unmatched_dets=全部检测
|
||||||
|
|
||||||
|
预计算每个检测的中心与框
|
||||||
|
|
||||||
|
sorted_tracks ← 按 track.confidence 降序排序的轨道列表
|
||||||
|
assigned_dets ← 空集合; assigned_tracks ← 空集合; matches ← []
|
||||||
|
|
||||||
|
对 sorted_tracks 中每条轨道 t:
|
||||||
|
若 t.id ∈ assigned_tracks:跳过
|
||||||
|
best_cost ← 1.0; best_det ← 空
|
||||||
|
对每个未分配的检测 d:
|
||||||
|
dist ← 欧氏距离(t.last_center, d.center)
|
||||||
|
norm_dist ← dist / max(Dmax, 1e-3)
|
||||||
|
iou ← IoU(t.last_box, d.box)
|
||||||
|
cost ← w_dist × norm_dist + w_iou × (1 − iou)
|
||||||
|
若 cost < best_cost:
|
||||||
|
best_cost ← cost; best_det ← d
|
||||||
|
若 best_det 非空:
|
||||||
|
matches.append((t.id, best_det.idx))
|
||||||
|
assigned_dets.add(best_det.idx); assigned_tracks.add(t.id)
|
||||||
|
|
||||||
|
unmatched_tracks ← tracks 中不在 assigned_tracks 的轨道
|
||||||
|
unmatched_dets ← detections 中不在 assigned_dets 的检测
|
||||||
|
返回 (matches, unmatched_tracks, unmatched_dets)
|
||||||
|
```
|
||||||
|
|
||||||
|
复杂度:O(M×N)(M 条轨道 × N 个检测),另有 O(M log M) 排序。
|
||||||
|
在机载小目标数量(通常个位数)下开销可忽略。
|
||||||
|
|
||||||
|
> 说明:代码明确选择贪心而非匈牙利最优匹配——贪心实现简单、无第三方依赖,
|
||||||
|
> 配合"置信度优先 + 距离/IoU 加权"在无人机跟随单主目标的场景下已足够稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 轨迹生命周期与 ID 管理
|
||||||
|
|
||||||
|
### 6.1 生命周期状态
|
||||||
|
|
||||||
|
代码中没有显式状态枚举,但由字段组合可归纳出四个阶段:
|
||||||
|
|
||||||
|
| 阶段 | 判定条件 | 行为 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 新建(未确认) | `hits < min_hits` | 正常参与关联与预测,但主目标选举时优先级低于已确认轨道 |
|
||||||
|
| 已确认 | `hits ≥ min_hits` | 主目标选举的优先候选池 |
|
||||||
|
| Coast(滑行预测) | `0 < lost_count ≤ max_lost` | 无实测,输出滤波器预测值,`is_predicted=true` |
|
||||||
|
| 死亡 | `lost_count > max_lost` | 当帧即从轨道表中删除 |
|
||||||
|
|
||||||
|
注意两点(与代码一致):
|
||||||
|
|
||||||
|
1. **min_hits 不阻止轨道参与关联或对外输出**,它只影响"重新选择主目标"时
|
||||||
|
的候选池优先级(见 6.3)。这与 SORT 中 tentative 轨道不输出的做法不同。
|
||||||
|
2. **死亡判定使用严格大于**:`lost_count > max_lost`,即默认配置下允许
|
||||||
|
连续 8 帧 Coast,第 9 帧未命中才回收。
|
||||||
|
|
||||||
|
### 6.2 ID 分配策略
|
||||||
|
|
||||||
|
- 全局计数器 `next_id` 从 0 开始,每创建一个新轨道自增 1;
|
||||||
|
- **ID 永不复用**;`reset()` 时计数器归零;
|
||||||
|
- 未匹配到任何现有轨道的检测,每个都会创建一个新轨道(无预过滤,
|
||||||
|
不要求检测置信度下限——上游检测器已完成阈值过滤)。
|
||||||
|
|
||||||
|
### 6.3 主目标(primary)锁定与选举
|
||||||
|
|
||||||
|
对外每帧只输出**一个**主目标,供控制回路使用。选择规则:
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT_PRIMARY:
|
||||||
|
若轨道表为空:清除锁定,返回 tracked=false
|
||||||
|
|
||||||
|
① 锁定延续:若 locked_primary_id 非空且该轨道仍存活:
|
||||||
|
直接以该轨道输出(Coast 中照样输出,is_predicted=true)
|
||||||
|
—— 不被其他更高置信度目标抢占
|
||||||
|
|
||||||
|
② 否则(无锁定 / 锁定目标已死亡被回收)→ 重新选举并锁定:
|
||||||
|
measured ← 所有 lost_count == 0 的轨道(本帧有实测)
|
||||||
|
coasting ← 其余存活轨道
|
||||||
|
|
||||||
|
若 measured 非空:
|
||||||
|
confirmed ← measured 中 hits ≥ min_hits 者
|
||||||
|
pool ← confirmed(若为空则退回 measured)
|
||||||
|
chosen ← pool 中 confidence 最大者
|
||||||
|
否则若 coasting 非空:
|
||||||
|
chosen ← coasting 中离画面中心最近者(欧氏距离平方排序)
|
||||||
|
is_predicted ← true
|
||||||
|
否则:
|
||||||
|
清除锁定,返回 tracked=false
|
||||||
|
|
||||||
|
locked_primary_id ← chosen.id
|
||||||
|
以 chosen 输出结果
|
||||||
|
```
|
||||||
|
|
||||||
|
锁定的**唯一解除途径**:锁定轨道死亡被回收时清除 `locked_primary_id`
|
||||||
|
(在轨道删除的同一处处理),或调用 `reset()`。
|
||||||
|
|
||||||
|
### 6.4 生命周期状态图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[未匹配检测] --> B[新建轨道 hits=1 lost=0]
|
||||||
|
B -->|每帧匹配成功| C{hits >= min_hits?}
|
||||||
|
C -->|否, 选举低优先级| B
|
||||||
|
C -->|是, 已确认| D[正常跟踪 lost=0]
|
||||||
|
D -->|某帧未匹配| E[Coast lost_count+1 输出预测值]
|
||||||
|
E -->|重新匹配| D
|
||||||
|
E -->|lost_count > max_lost| F[死亡回收 删除轨道]
|
||||||
|
B -->|连续未匹配| E
|
||||||
|
F -->|若是锁定主目标| G[清除锁定 下帧重新选举]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 主流程伪代码(每帧处理)
|
||||||
|
|
||||||
|
`update(detections, frame_shape)` 的完整单帧流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
UPDATE(detections, frame_shape):
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
# ---- 步骤 1:所有存活轨道先行预测(推进一帧)----
|
||||||
|
对 tracks 中每条轨道 t:
|
||||||
|
t.predict() # 卡尔曼预测;更新 t.last_center / t.last_box;age += 1
|
||||||
|
|
||||||
|
# ---- 步骤 2:构造检测中心表并关联 ----
|
||||||
|
若 detections 非空:
|
||||||
|
det_centers ← []
|
||||||
|
对每个检测 i = [x1,y1,x2,y2,conf,cls]:
|
||||||
|
cx ← (x1+x2)/2; cy ← (y1+y2)/2
|
||||||
|
w ← x2−x1; h ← y2−y1
|
||||||
|
det_centers.append((i, cx, cy, w, h, conf, cls))
|
||||||
|
(matches, unmatched_tracks, unmatched_dets) ← ASSOCIATE(tracks, det_centers)
|
||||||
|
否则:
|
||||||
|
matches ← []; unmatched_tracks ← 全部轨道; unmatched_dets ← []
|
||||||
|
|
||||||
|
# ---- 步骤 3:用匹配到的检测更新轨道 ----
|
||||||
|
对每个匹配 (tid, did):
|
||||||
|
由 det_centers[did] 重建原始框 raw_box = [cx−w/2, cy−h/2, cx+w/2, cy+h/2]
|
||||||
|
tracks[tid].update(raw_box, conf, cls)
|
||||||
|
# 内部:框→[cx,cy,w,h] → kf.update;lost_count←0;hits+1;age+1
|
||||||
|
# 更新 last_center / last_box / confidence / cls_id
|
||||||
|
|
||||||
|
# ---- 步骤 4:未匹配轨道计丢失,死亡者回收 ----
|
||||||
|
对每个 tid ∈ unmatched_tracks:
|
||||||
|
tracks[tid].mark_miss() # lost_count+1, age+1
|
||||||
|
若 tracks[tid].is_dead: # lost_count > max_lost
|
||||||
|
若 tid == locked_primary_id:locked_primary_id ← 空
|
||||||
|
从 tracks 中删除 tid
|
||||||
|
|
||||||
|
# ---- 步骤 5:未匹配检测 → 创建新轨道 ----
|
||||||
|
对每个 did ∈ unmatched_dets:
|
||||||
|
以 (next_id, [cx,cy,w,h], conf, cls, max_lost=max_lost_frames) 创建 Tracklet
|
||||||
|
new_track.last_box ← 由中心/宽高重建的框 # 保证创建当帧即可参与输出
|
||||||
|
tracks[next_id] ← new_track
|
||||||
|
next_id += 1
|
||||||
|
|
||||||
|
# ---- 步骤 6:选取/延续主目标,组装输出 ----
|
||||||
|
result ← SELECT_PRIMARY(frame_shape)
|
||||||
|
last_result ← result # 缓存供跨线程读取
|
||||||
|
返回 result
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `Tracklet.predict()` 与 `Tracklet.update()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
TRACKLET_PREDICT():
|
||||||
|
pred ← kf.predict()
|
||||||
|
若 pred 非空:
|
||||||
|
last_center ← (pred.cx, pred.cy)
|
||||||
|
last_box ← center_size → [x1,y1,x2,y2]
|
||||||
|
age += 1
|
||||||
|
返回 (last_center, last_box)
|
||||||
|
|
||||||
|
TRACKLET_UPDATE(box=[x1,y1,x2,y2], conf, cls):
|
||||||
|
cx ← (x1+x2)/2; cy ← (y1+y2)/2
|
||||||
|
w ← x2−x1; h ← y2−y1
|
||||||
|
kf.update([cx, cy, w, h])
|
||||||
|
last_center ← (cx, cy); last_box ← box
|
||||||
|
confidence ← conf; cls_id ← cls
|
||||||
|
lost_count ← 0; hits += 1; age += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
每帧处理的时序图:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[接收本帧检测] --> B[全部轨道卡尔曼预测]
|
||||||
|
B --> C{有检测?}
|
||||||
|
C -->|是| D[中心距离+IoU 贪婪关联]
|
||||||
|
C -->|否| E[全部轨道标记 miss]
|
||||||
|
D --> F[匹配轨道: 卡尔曼更新]
|
||||||
|
D --> G[未匹配轨道: miss 计数]
|
||||||
|
D --> H[未匹配检测: 新建轨道]
|
||||||
|
G --> I{lost > max_lost?}
|
||||||
|
I -->|是| J[回收轨道 必要时解除锁定]
|
||||||
|
I -->|否| K[主目标选择/锁定延续]
|
||||||
|
E --> I
|
||||||
|
F --> K
|
||||||
|
H --> K
|
||||||
|
J --> K
|
||||||
|
K --> L[输出 TrackResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 关键参数表
|
||||||
|
|
||||||
|
以下默认值全部取自代码实际实现。
|
||||||
|
|
||||||
|
### 8.1 追踪器配置参数
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 含义与调优建议 |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| max_lost_frames | 8 | 最大连续丢失帧数(约 250~300ms @ 30fps)。越大越能容忍遮挡,但 Coast 漂移时间越长;判丢后下游刹车 |
|
||||||
|
| max_association_dist | 200.0(像素) | 中心距离软阈值,用于归一化距离代价。目标运动快/帧率低时调大 |
|
||||||
|
| min_hits | 3 | 轨道"确认"所需累计匹配次数,仅影响主目标选举优先级 |
|
||||||
|
| dist_weight | 0.5 | 关联代价中归一化距离项权重 |
|
||||||
|
| iou_weight | 0.5 | 关联代价中 (1−IoU) 项权重。两项建议之和保持 1.0,使 cost<1.0 判据语义稳定 |
|
||||||
|
|
||||||
|
> 工程部署时以上参数可由外部配置文件注入(项目中即通过配置文件的
|
||||||
|
> `target_tracker` 段传入,未配置项回落到代码默认值)。
|
||||||
|
|
||||||
|
### 8.2 卡尔曼滤波内部常量
|
||||||
|
|
||||||
|
| 常量 | 值 | 含义 |
|
||||||
|
|------|----|------|
|
||||||
|
| dt | 1.0 | 状态转移步长,以"帧"为单位 |
|
||||||
|
| STD_POS | 1/50 | 位置过程/观测噪声比例系数(乘以目标宽高) |
|
||||||
|
| STD_VEL | 1/200 | 速度过程噪声比例系数(乘以目标宽高) |
|
||||||
|
| INI_POS | 2.0 | 初始位置协方差 |
|
||||||
|
| INI_VEL | 100.0 | 初始速度协方差 |
|
||||||
|
| R 缩放因子 | 2.0 | 观测噪声额外加倍,增强输出平滑 |
|
||||||
|
| 匹配代价上限 | 1.0 | cost ≥ 1.0 判为不可匹配 |
|
||||||
|
| Coast 兜底画面中心 | (320, 240) | frame_shape 缺省时按 640×480 计算 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. C++ 移植要点与模块划分建议
|
||||||
|
|
||||||
|
### 9.1 模块 / 类划分草案
|
||||||
|
|
||||||
|
与代码三层结构一一对应:
|
||||||
|
|
||||||
|
```
|
||||||
|
target_tracker/
|
||||||
|
├── kalman_box_filter.h/.cpp // class KalmanBoxFilter
|
||||||
|
├── tracklet.h/.cpp // struct/class Tracklet
|
||||||
|
└── target_tracker.h/.cpp // class TargetTracker + struct TrackResult
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// ---- 基础几何量 ----
|
||||||
|
struct BoxXYXY { double x1, y1, x2, y2; };
|
||||||
|
struct BoxCXCYWH{ double cx, cy, w, h; };
|
||||||
|
|
||||||
|
// ---- 单目标滤波器 ----
|
||||||
|
class KalmanBoxFilter {
|
||||||
|
double x[8]; // 状态向量
|
||||||
|
double P[8][8]; // 协方差
|
||||||
|
static constexpr double F[8][8] = /* I4 右上 I4 */;
|
||||||
|
static constexpr double H[4][8] = /* [I4 | 0] */;
|
||||||
|
bool initialized = false;
|
||||||
|
|
||||||
|
void initiate(const BoxCXCYWH& z);
|
||||||
|
bool predict(BoxCXCYWH& out); // 未初始化返回 false
|
||||||
|
void update(const BoxCXCYWH& z);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 轨道 ----
|
||||||
|
struct Tracklet {
|
||||||
|
int track_id;
|
||||||
|
KalmanBoxFilter kf;
|
||||||
|
int lost_count = 0;
|
||||||
|
int max_lost;
|
||||||
|
double confidence;
|
||||||
|
int cls_id;
|
||||||
|
int age = 1;
|
||||||
|
int hits = 1;
|
||||||
|
std::pair<double,double> last_center;
|
||||||
|
std::optional<BoxXYXY> last_box; // 首帧预测前可能为空(创建时即赋实测框)
|
||||||
|
|
||||||
|
bool is_dead() const { return lost_count > max_lost; }
|
||||||
|
bool is_coasting() const { return lost_count > 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 输出结构 ----
|
||||||
|
struct TrackResult {
|
||||||
|
bool tracked = false;
|
||||||
|
std::optional<int> primary_id;
|
||||||
|
std::pair<double,double> center; // tracked=false 时无效
|
||||||
|
std::optional<BoxXYXY> box;
|
||||||
|
double confidence = 0.0;
|
||||||
|
bool is_predicted = false;
|
||||||
|
int lost_frames = 0;
|
||||||
|
int n_active = 0;
|
||||||
|
std::optional<BoxXYXY> raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 管理器 ----
|
||||||
|
class TargetTracker {
|
||||||
|
public:
|
||||||
|
struct Config {
|
||||||
|
int max_lost_frames = 8;
|
||||||
|
double max_association_dist = 200.0;
|
||||||
|
int min_hits = 3;
|
||||||
|
double dist_weight = 0.5;
|
||||||
|
double iou_weight = 0.5;
|
||||||
|
};
|
||||||
|
explicit TargetTracker(const Config& cfg);
|
||||||
|
TrackResult update(const std::vector<Detection>& dets,
|
||||||
|
std::optional<FrameShape> frame_shape);
|
||||||
|
TrackResult last_result() const; // 需要跨线程时加锁/原子快照
|
||||||
|
void reset();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 STL 容器选择
|
||||||
|
|
||||||
|
| 代码中的结构 | C++ 建议 |
|
||||||
|
|--------------|----------|
|
||||||
|
| 轨道哈希表 `{id: Tracklet}` | `std::unordered_map<int, Tracklet>`;若需按 ID 有序遍历可用 `std::map` |
|
||||||
|
| 关联中的已分配集合 | `std::unordered_set<int>`,或目标数量少时直接用 `std::vector<bool>` 位标记 |
|
||||||
|
| 按置信度排序的轨道视图 | 复制 ID/指针到 `std::vector` 后 `std::sort`(不直接排哈希表) |
|
||||||
|
| 检测列表 | `std::vector<Detection>`,`Detection` 为 POD 结构体 |
|
||||||
|
| 结果缓存 | `TrackResult` 值类型;跨线程用互斥锁保护的拷贝,或双缓冲原子交换 |
|
||||||
|
|
||||||
|
### 9.3 数值计算
|
||||||
|
|
||||||
|
- 矩阵规模极小(8×8、4×8、4×4),**无需引入 Eigen/OpenCV**:
|
||||||
|
用固定大小二维数组或 `std::array<double, 64>` 手写矩阵乘法即可。
|
||||||
|
- 唯一需要通用实现的是 4×4 矩阵求逆(S 矩阵)。可用高斯-约当消元或
|
||||||
|
利用 S 接近对称正定的特性做 Cholesky/LDL 分解;建议加极小量
|
||||||
|
正则化(对角加 1e-9)防奇异。
|
||||||
|
- 距离计算用 `std::hypot`;IoU 为纯标量运算。
|
||||||
|
- 全程 `double` 精度,与代码一致(代码为 float64)。
|
||||||
|
|
||||||
|
### 9.4 行为一致性检查清单
|
||||||
|
|
||||||
|
移植后建议用以下用例对齐行为:
|
||||||
|
|
||||||
|
1. **空检测帧序列**:连续输入空检测,主目标应输出 `is_predicted=true`
|
||||||
|
共 `max_lost_frames` 帧,第 `max_lost_frames + 1` 帧 `tracked=false`
|
||||||
|
(因死亡判定为 `lost_count > max_lost`)。
|
||||||
|
2. **ID 单调性**:多次进出画面的目标应获得严格递增、不复用的 ID。
|
||||||
|
3. **锁定不被抢占**:锁定目标 Coast 期间出现更高置信度新目标,
|
||||||
|
`primary_id` 不变;锁定目标死亡后切换到新目标。
|
||||||
|
4. **min_hits 语义**:新轨道(hits 未达 min_hits)在无其他已确认实测轨道时
|
||||||
|
也可被选为主目标(候选池回退逻辑)。
|
||||||
|
5. **cost 边界**:恰好 `cost = 1.0` 不构成匹配(严格小于)。
|
||||||
|
6. **置信度优先**:两轨道竞争同一检测时,置信度高者获胜。
|
||||||
|
|
||||||
|
### 9.5 线程模型注意
|
||||||
|
|
||||||
|
原系统中追踪器在推理主线程更新,结果由 20Hz 控制线程读取。C++ 移植若
|
||||||
|
保持该模型,仅需保护 `last_result` 的读写(互斥锁或无锁双缓冲),
|
||||||
|
追踪器内部状态不必加锁(单线程写)。若改为多线程调用 `update()`,
|
||||||
|
则需整体加锁。
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# target_tracker —— 视觉追踪库(独立动态库工程)
|
||||||
|
#
|
||||||
|
# 轻量级多目标追踪过滤器:恒定速度卡尔曼滤波 + 贪婪关联 + 主目标锁定。
|
||||||
|
# 规格见 docs/视觉追踪技术路线.md;实现文档见 target_tracker.md。
|
||||||
|
# 零第三方依赖(不引入 spdlog / Eigen / OpenCV),仅链接 C++ 标准库。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
project(target_tracker VERSION 1.0.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
# ---- 动态库目标 ----
|
||||||
|
add_library(target_tracker SHARED
|
||||||
|
src/kalman_box_filter.cpp
|
||||||
|
src/tracklet.cpp
|
||||||
|
src/target_tracker.cpp)
|
||||||
|
target_include_directories(target_tracker PUBLIC
|
||||||
|
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>)
|
||||||
|
target_compile_features(target_tracker PUBLIC cxx_std_17)
|
||||||
|
|
||||||
|
# 版本与符号:VERSION 1.0.0 / SOVERSION 1;默认符号可见性(不加
|
||||||
|
# visibility=hidden 与导出宏),全部公共类直接对外导出。
|
||||||
|
set_target_properties(target_tracker PROPERTIES
|
||||||
|
VERSION 1.0.0
|
||||||
|
SOVERSION 1)
|
||||||
|
|
||||||
|
# 编译告警:与主工程一致(非 MSVC:-Wall -Wextra -Wpedantic)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(target_tracker PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(target_tracker PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ---- 单元测试(GTest;缺失时仅提示不失败,与主工程策略一致) ----
|
||||||
|
include(CTest)
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
find_package(GTest QUIET)
|
||||||
|
if(GTest_FOUND)
|
||||||
|
include(GoogleTest)
|
||||||
|
|
||||||
|
# 测试可执行文件直接链接共享库 target_tracker;
|
||||||
|
# BUILD_RPATH 指向库输出目录,保证 ctest 无需 LD_LIBRARY_PATH 即可运行
|
||||||
|
set(CMAKE_BUILD_RPATH "${CMAKE_BINARY_DIR};$ORIGIN")
|
||||||
|
|
||||||
|
# 卡尔曼滤波器测试:初始化状态、静止收敛、匀速速度收敛与预测超前、未初始化行为
|
||||||
|
add_executable(kalman_box_filter_test tests/kalman_box_filter_test.cpp)
|
||||||
|
target_link_libraries(kalman_box_filter_test PRIVATE
|
||||||
|
target_tracker GTest::gtest_main)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(kalman_box_filter_test PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(kalman_box_filter_test PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
gtest_discover_tests(kalman_box_filter_test)
|
||||||
|
|
||||||
|
# 轨道与几何工具测试:IoU 边界、IsDead/IsCoasting、Predict/Update/MarkMiss 计数
|
||||||
|
add_executable(tracklet_test tests/tracklet_test.cpp)
|
||||||
|
target_link_libraries(tracklet_test PRIVATE
|
||||||
|
target_tracker GTest::gtest_main)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(tracklet_test PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(tracklet_test PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
gtest_discover_tests(tracklet_test)
|
||||||
|
|
||||||
|
# 追踪器测试:逐条覆盖文档 §9.4 六条行为一致性验收用例
|
||||||
|
add_executable(target_tracker_test tests/target_tracker_test.cpp)
|
||||||
|
target_link_libraries(target_tracker_test PRIVATE
|
||||||
|
target_tracker GTest::gtest_main)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(target_tracker_test PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(target_tracker_test PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
gtest_discover_tests(target_tracker_test)
|
||||||
|
else()
|
||||||
|
message(STATUS "GoogleTest not found; target_tracker tests will not be built")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// kalman_box_filter.h —— 单目标 8 维恒定速度卡尔曼滤波器
|
||||||
|
//
|
||||||
|
// 对应规格:docs/视觉追踪技术路线.md §4(卡尔曼滤波算法细节)、§8.2(内部常量)
|
||||||
|
// 状态向量 x[8] = [cx, cy, w, h, vx, vy, vw, vh](§4.1)
|
||||||
|
// 观测向量 z[4] = [cx, cy, w, h](§4.1)
|
||||||
|
// 时间步长 dt = 1 帧,与帧率无关(§1.3)
|
||||||
|
//
|
||||||
|
// 实现约束(§9.3):矩阵规模极小(8×8 / 4×8 / 4×4),手写定长矩阵运算,
|
||||||
|
// 全程 double 精度,不引入 Eigen/OpenCV 等线性代数库。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace drone {
|
||||||
|
namespace tracker {
|
||||||
|
|
||||||
|
/// 轴对齐边界框(中心点 + 宽高表示,卡尔曼观测空间)
|
||||||
|
struct BoxCXCYWH {
|
||||||
|
double cx = 0.0; // 框中心 x(像素)
|
||||||
|
double cy = 0.0; // 框中心 y(像素)
|
||||||
|
double w = 0.0; // 框宽(像素)
|
||||||
|
double h = 0.0; // 框高(像素)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 单目标恒定速度卡尔曼滤波器(§3.1 / §4)
|
||||||
|
class KalmanBoxFilter {
|
||||||
|
public:
|
||||||
|
KalmanBoxFilter() = default;
|
||||||
|
|
||||||
|
/// 是否已用首次观测初始化(§3.1 initialized 字段)
|
||||||
|
bool Initialized() const { return initialized_; }
|
||||||
|
|
||||||
|
/// 用首次观测 [cx, cy, w, h] 初始化(§4.3):
|
||||||
|
/// 状态速度置零;P = diag(2,2,2,2,100,100,100,100)。
|
||||||
|
void Initiate(const BoxCXCYWH& z);
|
||||||
|
|
||||||
|
/// 预测推进一帧(§4.4):未初始化返回 std::nullopt;
|
||||||
|
/// 否则 x ← F·x,P ← F·P·Fᵀ + Q,返回状态前 4 维 [cx,cy,w,h] 副本。
|
||||||
|
std::optional<BoxCXCYWH> Predict();
|
||||||
|
|
||||||
|
/// 观测更新(§4.5):未初始化时直接返回;
|
||||||
|
/// S = H·P·Hᵀ + R,K = P·Hᵀ·S⁻¹,x ← x + K(z−Hx),P ← (I−KH)P。
|
||||||
|
void Update(const BoxCXCYWH& z);
|
||||||
|
|
||||||
|
/// 状态向量只读访问([cx, cy, w, h, vx, vy, vw, vh],测试与排查用)
|
||||||
|
const std::array<double, 8>& State() const { return x_; }
|
||||||
|
|
||||||
|
// ---- 内部常量(§4.2 / §8.2,经验值,针对 640×480 画面调优) ----
|
||||||
|
static constexpr double kStdPos = 1.0 / 50.0; // STD_POS:位置噪声比例系数
|
||||||
|
static constexpr double kStdVel = 1.0 / 200.0; // STD_VEL:速度噪声比例系数
|
||||||
|
static constexpr double kIniPos = 2.0; // INI_POS:初始位置协方差
|
||||||
|
static constexpr double kIniVel = 100.0; // INI_VEL:初始速度协方差
|
||||||
|
static constexpr double kRScale = 2.0; // R 额外缩放因子(增强平滑,§8.2)
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::array<double, 8> x_{}; // 状态向量(§3.1)
|
||||||
|
std::array<std::array<double, 8>, 8> P_{}; // 状态协方差 8×8(§3.1)
|
||||||
|
bool initialized_ = false; // 是否已初始化(§3.1)
|
||||||
|
|
||||||
|
// 状态转移矩阵 F(常量,§4.1):I4 + 右上 I4,即 F[i][i]=1、F[i][i+4]=1
|
||||||
|
static constexpr std::array<std::array<double, 8>, 8> kF = {{
|
||||||
|
{{1, 0, 0, 0, 1, 0, 0, 0}},
|
||||||
|
{{0, 1, 0, 0, 0, 1, 0, 0}},
|
||||||
|
{{0, 0, 1, 0, 0, 0, 1, 0}},
|
||||||
|
{{0, 0, 0, 1, 0, 0, 0, 1}},
|
||||||
|
{{0, 0, 0, 0, 1, 0, 0, 0}},
|
||||||
|
{{0, 0, 0, 0, 0, 1, 0, 0}},
|
||||||
|
{{0, 0, 0, 0, 0, 0, 1, 0}},
|
||||||
|
{{0, 0, 0, 0, 0, 0, 0, 1}},
|
||||||
|
}};
|
||||||
|
|
||||||
|
// 观测矩阵 H(常量,§4.1):[I4 | 0](4 行 × 8 列),
|
||||||
|
// 直接观测位置与尺寸、不观测速度
|
||||||
|
static constexpr std::array<std::array<double, 8>, 4> kH = {{
|
||||||
|
{{1, 0, 0, 0, 0, 0, 0, 0}},
|
||||||
|
{{0, 1, 0, 0, 0, 0, 0, 0}},
|
||||||
|
{{0, 0, 1, 0, 0, 0, 0, 0}},
|
||||||
|
{{0, 0, 0, 1, 0, 0, 0, 0}},
|
||||||
|
}};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace tracker
|
||||||
|
} // namespace drone
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// target_tracker.h —— 多目标追踪过滤器(TargetTracker)对外接口
|
||||||
|
//
|
||||||
|
// 对应规格:docs/视觉追踪技术路线.md
|
||||||
|
// §2 输入输出接口定义(Detection / TrackResult)
|
||||||
|
// §3.3 TargetTracker(管理器)
|
||||||
|
// §5 检测-轨迹关联(贪婪最邻近,§5.3)
|
||||||
|
// §6 轨迹生命周期与 ID 管理(主目标锁定与选举,§6.3)
|
||||||
|
// §7 主流程(每帧 Update 六步)
|
||||||
|
//
|
||||||
|
// 线程模型(§9.5):Update() 由单线程(推理主循环)调用;库内仅对
|
||||||
|
// last_result 缓存加一把 std::mutex,LastResult() 返回受保护拷贝。
|
||||||
|
// 本库零第三方依赖(不依赖 spdlog / Eigen / OpenCV)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "tracklet.h"
|
||||||
|
|
||||||
|
namespace drone {
|
||||||
|
namespace tracker {
|
||||||
|
|
||||||
|
/// 单个检测框(§2.2 输入 detections 元素:[x1,y1,x2,y2,conf,cls_id])
|
||||||
|
struct Detection {
|
||||||
|
double x1 = 0.0; // 边界框左上角 x(像素坐标)
|
||||||
|
double y1 = 0.0; // 边界框左上角 y
|
||||||
|
double x2 = 0.0; // 边界框右下角 x
|
||||||
|
double y2 = 0.0; // 边界框右下角 y
|
||||||
|
double conf = 0.0; // 检测置信度 ∈ [0,1]
|
||||||
|
int cls_id = 0; // 类别编号
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 画面尺寸(height, width,§2.2):仅在"无实测轨道、只有 Coast 轨道"
|
||||||
|
/// 时用于选取离画面中心最近的轨道;缺省按 640×480 中心 (320,240)。
|
||||||
|
struct FrameShape {
|
||||||
|
int height = 0;
|
||||||
|
int width = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 每帧追踪输出(§2.3)
|
||||||
|
struct TrackResult {
|
||||||
|
bool tracked = false; // 是否存在有效主目标;false 时调用方应刹车
|
||||||
|
std::optional<int> primary_id; // 主目标轨道 ID;无目标时为空
|
||||||
|
std::pair<double, double> center{0.0, 0.0}; // 平滑后的目标中心点(tracked=false 时无效)
|
||||||
|
std::optional<BoxXYXY> box; // 平滑后的边界框
|
||||||
|
double confidence = 0.0; // 当前置信度(Coast 期间继承最近实测值)
|
||||||
|
bool is_predicted = false; // true = Coast(仅预测、无实测)
|
||||||
|
int lost_frames = 0; // 主目标连续未命中帧数
|
||||||
|
int n_active = 0; // 当前活跃轨道总数
|
||||||
|
std::optional<BoxXYXY> raw; // 关联到的原始检测框;Coast 时为空(§2.3)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 多目标追踪过滤器(§1.1 / §3.3)
|
||||||
|
class TargetTracker {
|
||||||
|
public:
|
||||||
|
/// 追踪器配置参数(§8.1,默认值取自规格)
|
||||||
|
struct Config {
|
||||||
|
int max_lost_frames = 8; // 最大连续丢失帧数(严格大于才判死,§6.1)
|
||||||
|
double max_association_dist = 200.0; // 中心距离软阈值 Dmax(像素,§5.1)
|
||||||
|
int min_hits = 3; // 轨道"确认"所需累计匹配次数(§6.1)
|
||||||
|
double dist_weight = 0.5; // 关联代价距离项权重(§5.1)
|
||||||
|
double iou_weight = 0.5; // 关联代价 (1−IoU) 项权重(§5.1)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 默认配置构造(等价于 Config{},§8.1 默认值)
|
||||||
|
TargetTracker();
|
||||||
|
|
||||||
|
/// 指定配置构造
|
||||||
|
explicit TargetTracker(const Config& cfg);
|
||||||
|
|
||||||
|
/// 每帧调用一次(§7 六步主流程):输入检测列表与可选画面尺寸,
|
||||||
|
/// 输出主目标追踪结果并缓存(供 LastResult 跨线程读取)。
|
||||||
|
TrackResult Update(const std::vector<Detection>& detections,
|
||||||
|
std::optional<FrameShape> frame_shape = std::nullopt);
|
||||||
|
|
||||||
|
/// 上一帧结果快照(线程安全:返回受互斥锁保护的拷贝,§9.5)
|
||||||
|
TrackResult LastResult() const;
|
||||||
|
|
||||||
|
/// 清空所有轨道、ID 计数器、锁定状态与缓存结果(§2.1);next_id 归零(§6.2)
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
/// 当前存活轨道数量(§2.1)
|
||||||
|
int ActiveCount() const;
|
||||||
|
|
||||||
|
/// 当前所有轨道的只读快照(§2.1;值拷贝)
|
||||||
|
std::unordered_map<int, Tracklet> ActiveTracks() const;
|
||||||
|
|
||||||
|
/// 配置只读访问
|
||||||
|
const Config& GetConfig() const { return config_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// 贪婪关联结果(§5.3):matches 元素为 (track_id, 检测索引)
|
||||||
|
struct AssociationResult {
|
||||||
|
std::vector<std::pair<int, int>> matches;
|
||||||
|
std::vector<int> unmatched_track_ids;
|
||||||
|
std::vector<int> unmatched_det_indices;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 贪婪最邻近关联(§5.3):按轨道 confidence 降序逐一匹配,
|
||||||
|
/// cost = w_dist·dist/max(Dmax,1e-3) + w_iou·(1−IoU),仅 cost 严格小于
|
||||||
|
/// best_cost(初始 1.0)才匹配,即 cost ≥ 1.0 判为不可匹配(§5.1)。
|
||||||
|
AssociationResult AssociateDetections(const std::vector<Detection>& detections);
|
||||||
|
|
||||||
|
/// 主目标选择/锁定延续(§6.3 SELECT_PRIMARY)
|
||||||
|
TrackResult SelectPrimary(std::optional<FrameShape> frame_shape);
|
||||||
|
|
||||||
|
Config config_; // 配置参数(§3.3)
|
||||||
|
int next_id_ = 0; // 下一个可分配 ID,从 0 递增不复用(§6.2)
|
||||||
|
std::unordered_map<int, Tracklet> tracks_; // 所有存活轨道(§3.3)
|
||||||
|
long long frame_count_ = 0; // 已处理帧数(仅统计用,§3.3)
|
||||||
|
std::optional<int> locked_primary_id_; // 当前锁定的主目标 ID(§3.3)
|
||||||
|
TrackResult last_result_; // 上一帧结果缓存(§3.3)
|
||||||
|
mutable std::mutex last_result_mutex_; // 仅保护 last_result_(§9.5)
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace tracker
|
||||||
|
} // namespace drone
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// tracklet.h —— 轨道(Tracklet)与基础几何工具
|
||||||
|
//
|
||||||
|
// 对应规格:docs/视觉追踪技术路线.md
|
||||||
|
// §3.2 Tracklet(轨道)字段定义与派生状态
|
||||||
|
// §5.2 IoU 计算
|
||||||
|
// §7 主流程伪代码末尾 TRACKLET_PREDICT / TRACKLET_UPDATE
|
||||||
|
//
|
||||||
|
// 几何量说明:BoxCXCYWH(卡尔曼观测空间 [cx,cy,w,h])定义于
|
||||||
|
// kalman_box_filter.h,本文件通过 include 复用并同时提供 BoxXYXY
|
||||||
|
// (像素坐标 [x1,y1,x2,y2])与两种表示的互转函数。
|
||||||
|
// 本库只依赖 C++ 标准库,无任何第三方依赖。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "kalman_box_filter.h"
|
||||||
|
|
||||||
|
namespace drone {
|
||||||
|
namespace tracker {
|
||||||
|
|
||||||
|
// ---- 基础几何量(§9.1 模块/类划分草案) ----
|
||||||
|
|
||||||
|
/// 轴对齐边界框(像素坐标,左上/右下角表示)
|
||||||
|
struct BoxXYXY {
|
||||||
|
double x1 = 0.0;
|
||||||
|
double y1 = 0.0;
|
||||||
|
double x2 = 0.0;
|
||||||
|
double y2 = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [x1,y1,x2,y2] → [cx,cy,w,h](§7 TRACKLET_UPDATE 中的转换)
|
||||||
|
BoxCXCYWH ToCenterSize(const BoxXYXY& box);
|
||||||
|
|
||||||
|
/// [cx,cy,w,h] → [x1,y1,x2,y2](§7 TRACKLET_PREDICT 中的 center_size 逆变换)
|
||||||
|
BoxXYXY ToXYXY(const BoxCXCYWH& box);
|
||||||
|
|
||||||
|
/// 标准轴对齐 IoU(§5.2):
|
||||||
|
/// 任一框为空、交集面积 ≤ 0 或并集 ≤ 0 时返回 0。
|
||||||
|
double ComputeIoU(const BoxXYXY& a, const BoxXYXY& b);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tracklet —— 单条轨道:生命周期计数 + 辅助状态 + 卡尔曼滤波器(§3.2)
|
||||||
|
// =============================================================================
|
||||||
|
struct Tracklet {
|
||||||
|
/// 构造即创建轨道(§7 步骤 5 / §6.1 新建阶段):
|
||||||
|
/// 用首次观测初始化滤波器,age=1、hits=1、lost_count=0,
|
||||||
|
/// last_center / last_box 立即由观测重建(保证创建当帧即可参与输出)。
|
||||||
|
Tracklet(int id, const BoxCXCYWH& measurement, double conf, int cls,
|
||||||
|
int max_lost_frames);
|
||||||
|
|
||||||
|
int track_id = 0; // 全局唯一轨道 ID(§3.2)
|
||||||
|
KalmanBoxFilter kf; // 本轨道的滤波器(§3.2)
|
||||||
|
int lost_count = 0; // 当前连续未匹配帧数(§3.2)
|
||||||
|
int max_lost = 0; // 最大允许连续丢失帧数(构造时注入,§3.2)
|
||||||
|
double confidence = 0.0; // 最近一次匹配的置信度(§3.2)
|
||||||
|
int cls_id = 0; // 最近一次匹配的类别 ID(§3.2)
|
||||||
|
int age = 1; // 轨道已存在的总帧数(创建时为 1,§3.2)
|
||||||
|
int hits = 1; // 累计成功匹配次数(创建时为 1,§3.2)
|
||||||
|
std::pair<double, double> last_center{0.0, 0.0}; // 最近一次中心点(实测或预测)
|
||||||
|
std::optional<BoxXYXY> last_box; // 最近一次边界框(实测或预测,§3.2)
|
||||||
|
std::optional<BoxXYXY> last_raw_box; // 最近一次实测关联到的原始检测框;
|
||||||
|
// Coast 期间保留不更新,仅内部参考;
|
||||||
|
// 对外输出语义按 §2.3:Coast 时 raw 置空
|
||||||
|
|
||||||
|
// ---- 派生状态(§3.2 只读属性) ----
|
||||||
|
|
||||||
|
/// is_dead := lost_count > max_lost(严格大于,§6.1 注记 2)
|
||||||
|
bool IsDead() const { return lost_count > max_lost; }
|
||||||
|
|
||||||
|
/// is_coasting := lost_count > 0(处于纯预测状态,§3.2)
|
||||||
|
bool IsCoasting() const { return lost_count > 0; }
|
||||||
|
|
||||||
|
// ---- 生命周期方法(§7 末尾伪代码) ----
|
||||||
|
|
||||||
|
/// TRACKLET_PREDICT:卡尔曼预测推进一帧。
|
||||||
|
/// 预测成功时更新 last_center / last_box 且 age+1。
|
||||||
|
void Predict();
|
||||||
|
|
||||||
|
/// TRACKLET_UPDATE:框 [x1,y1,x2,y2] → [cx,cy,w,h] → kf.update;
|
||||||
|
/// lost_count 清零、hits+1、age+1,更新
|
||||||
|
/// last_center / last_box / last_raw_box / confidence / cls_id。
|
||||||
|
void Update(const BoxXYXY& box, double conf, int cls);
|
||||||
|
|
||||||
|
/// 未匹配计丢失:lost_count+1、age+1(§7 步骤 4)。
|
||||||
|
void MarkMiss();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace tracker
|
||||||
|
} // namespace drone
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// kalman_box_filter.cpp —— 单目标 8 维恒定速度卡尔曼滤波器实现
|
||||||
|
//
|
||||||
|
// 对应规格:docs/视觉追踪技术路线.md §4(算法细节)、§8.2(内部常量)、
|
||||||
|
// §9.3(数值计算:定长数组手写矩阵乘法,4×4 高斯-约当求逆 + 1e-9 正则化)。
|
||||||
|
// 关键公式逐行注释并标注对照文档小节号。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "target_tracker/kalman_box_filter.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace drone {
|
||||||
|
namespace tracker {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ---- 定长矩阵基础运算(§9.3:无需引入 Eigen/OpenCV) ----
|
||||||
|
|
||||||
|
/// 8×8 矩阵乘法:C = A·B
|
||||||
|
std::array<std::array<double, 8>, 8> MatMul8(const std::array<std::array<double, 8>, 8>& a,
|
||||||
|
const std::array<std::array<double, 8>, 8>& b) {
|
||||||
|
std::array<std::array<double, 8>, 8> c{};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
for (int k = 0; k < 8; ++k) {
|
||||||
|
if (a[i][k] == 0.0) continue; // 稀疏常量矩阵加速,结果不变
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
c[i][j] += a[i][k] * b[k][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8×8 矩阵转置:R = Aᵀ
|
||||||
|
std::array<std::array<double, 8>, 8> MatTranspose8(
|
||||||
|
const std::array<std::array<double, 8>, 8>& a) {
|
||||||
|
std::array<std::array<double, 8>, 8> r{};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
r[i][j] = a[j][i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4×4 矩阵求逆:高斯-约当消元 + 部分主元(§9.3)。
|
||||||
|
/// 输入矩阵先对角加 1e-9 正则化防奇异(§9.3 建议)。
|
||||||
|
/// 返回的 bool 表示是否求逆成功(主元退化时返回 false)。
|
||||||
|
bool Invert4x4(const std::array<std::array<double, 4>, 4>& m,
|
||||||
|
std::array<std::array<double, 4>, 4>& out) {
|
||||||
|
constexpr double kRegularization = 1e-9; // 正则化极小量(§9.3)
|
||||||
|
|
||||||
|
// 增广矩阵 [M + εI | I],消元后左半变为单位阵、右半即为逆矩阵
|
||||||
|
std::array<std::array<double, 8>, 4> aug{};
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
for (int j = 0; j < 4; ++j) {
|
||||||
|
aug[i][j] = m[i][j] + (i == j ? kRegularization : 0.0);
|
||||||
|
aug[i][j + 4] = (i == j) ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int col = 0; col < 4; ++col) {
|
||||||
|
// 部分主元:选取当前列绝对值最大行交换,提高数值稳定性
|
||||||
|
int pivot = col;
|
||||||
|
for (int r = col + 1; r < 4; ++r) {
|
||||||
|
if (std::abs(aug[r][col]) > std::abs(aug[pivot][col])) {
|
||||||
|
pivot = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (std::abs(aug[pivot][col]) < 1e-12) {
|
||||||
|
return false; // 主元退化(正则化后理论上不可达)
|
||||||
|
}
|
||||||
|
if (pivot != col) {
|
||||||
|
std::swap(aug[pivot], aug[col]);
|
||||||
|
}
|
||||||
|
// 主元行归一化
|
||||||
|
const double d = aug[col][col];
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
aug[col][j] /= d;
|
||||||
|
}
|
||||||
|
// 消去其余行(高斯-约当:一次消成对角单位阵)
|
||||||
|
for (int r = 0; r < 4; ++r) {
|
||||||
|
if (r == col) continue;
|
||||||
|
const double f = aug[r][col];
|
||||||
|
if (f == 0.0) continue;
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
aug[r][j] -= f * aug[col][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
for (int j = 0; j < 4; ++j) {
|
||||||
|
out[i][j] = aug[i][j + 4];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void KalmanBoxFilter::Initiate(const BoxCXCYWH& z) {
|
||||||
|
// §4.3 初始化:状态 = [cx, cy, w, h, 0, 0, 0, 0](速度置零)
|
||||||
|
x_ = {{z.cx, z.cy, z.w, z.h, 0.0, 0.0, 0.0, 0.0}};
|
||||||
|
|
||||||
|
// §4.3 协方差:P = diag(2,2,2,2,100,100,100,100)
|
||||||
|
// (位置置信度中等,速度置信度低,允许前几帧快速收敛速度估计)
|
||||||
|
for (auto& row : P_) row.fill(0.0);
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
P_[i][i] = kIniPos; // INI_POS = 2.0(§4.2)
|
||||||
|
P_[i + 4][i + 4] = kIniVel; // INI_VEL = 100.0(§4.2)
|
||||||
|
}
|
||||||
|
initialized_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<BoxCXCYWH> KalmanBoxFilter::Predict() {
|
||||||
|
// §4.4:未初始化返回空
|
||||||
|
if (!initialized_) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.2 动态过程噪声:Q = diag(σ_pos², σ_vel²),σ 按状态中 w/h 缩放
|
||||||
|
// σ_pos = [w/50, h/50, w/50, h/50]
|
||||||
|
// σ_vel = [w/200, h/200, w/200, h/200]
|
||||||
|
// (Q 直接使用状态值,不做 abs,与规格 §4.2 注记保持一致)
|
||||||
|
const double w = x_[2];
|
||||||
|
const double h = x_[3];
|
||||||
|
const double std_pos[4] = {w * kStdPos, h * kStdPos, w * kStdPos, h * kStdPos};
|
||||||
|
const double std_vel[4] = {w * kStdVel, h * kStdVel, w * kStdVel, h * kStdVel};
|
||||||
|
std::array<std::array<double, 8>, 8> q{};
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
q[i][i] = std_pos[i] * std_pos[i]; // 前 4 对角元来自 σ_pos(各分量先平方)
|
||||||
|
q[i + 4][i + 4] = std_vel[i] * std_vel[i]; // 后 4 对角元来自 σ_vel
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.4 状态转移:x ← F·x(恒定速度模型:位置/尺寸各自加上对应速度)
|
||||||
|
std::array<double, 8> x_new{};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
double sum = 0.0;
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
sum += kF[i][j] * x_[j];
|
||||||
|
}
|
||||||
|
x_new[i] = sum;
|
||||||
|
}
|
||||||
|
x_ = x_new;
|
||||||
|
|
||||||
|
// §4.4 协方差传播:P ← F·P·Fᵀ + Q
|
||||||
|
P_ = MatMul8(MatMul8(kF, P_), MatTranspose8(kF));
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
P_[i][j] += q[i][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.4 返回预测的位置/尺寸(状态向量前 4 维的副本)
|
||||||
|
return BoxCXCYWH{x_[0], x_[1], x_[2], x_[3]};
|
||||||
|
}
|
||||||
|
|
||||||
|
void KalmanBoxFilter::Update(const BoxCXCYWH& z) {
|
||||||
|
// §4.5:未初始化直接返回
|
||||||
|
if (!initialized_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.2 动态观测噪声:R = diag(2(w/50)², 2(h/50)², 2(w/50)², 2(h/50)²)
|
||||||
|
// (R 使用 abs(w)/abs(h) 防御负尺寸,并额外乘 2 增强平滑;
|
||||||
|
// 该差异与规格 §4.2 注记保持一致)
|
||||||
|
const double w = std::abs(x_[2]);
|
||||||
|
const double h = std::abs(x_[3]);
|
||||||
|
std::array<std::array<double, 4>, 4> r{};
|
||||||
|
const double rw = kRScale * (w * kStdPos) * (w * kStdPos);
|
||||||
|
const double rh = kRScale * (h * kStdPos) * (h * kStdPos);
|
||||||
|
r[0][0] = rw;
|
||||||
|
r[1][1] = rh;
|
||||||
|
r[2][2] = rw;
|
||||||
|
r[3][3] = rh;
|
||||||
|
|
||||||
|
// §4.5 新息协方差:S = H·P·Hᵀ + R(4×4)
|
||||||
|
// H = [I4 | 0],故 H·P·Hᵀ 即 P 的左上 4×4 子块
|
||||||
|
std::array<std::array<double, 4>, 4> s{};
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
for (int j = 0; j < 4; ++j) {
|
||||||
|
s[i][j] = P_[i][j] + r[i][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.5 / §9.3:S⁻¹(4×4 高斯-约当求逆 + 对角加 1e-9 正则化防奇异)
|
||||||
|
std::array<std::array<double, 4>, 4> s_inv{};
|
||||||
|
if (!Invert4x4(s, s_inv)) {
|
||||||
|
return; // 正则化后理论不可达;防御性跳过本次更新
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.5 卡尔曼增益:K = P·Hᵀ·S⁻¹(8×4)
|
||||||
|
// H = [I4 | 0],故 P·Hᵀ 即 P 的前 4 列(8×4)
|
||||||
|
std::array<std::array<double, 4>, 8> k{};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
for (int j = 0; j < 4; ++j) {
|
||||||
|
double sum = 0.0;
|
||||||
|
for (int l = 0; l < 4; ++l) {
|
||||||
|
sum += P_[i][l] * s_inv[l][j];
|
||||||
|
}
|
||||||
|
k[i][j] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.5 残差(innovation):y = z − H·x(H·x 即状态前 4 维)
|
||||||
|
const double z_vec[4] = {z.cx, z.cy, z.w, z.h};
|
||||||
|
double y[4];
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
y[i] = z_vec[i] - x_[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.5 状态更新:x ← x + K·y
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
double sum = 0.0;
|
||||||
|
for (int j = 0; j < 4; ++j) {
|
||||||
|
sum += k[i][j] * y[j];
|
||||||
|
}
|
||||||
|
x_[i] += sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4.5 协方差更新:P ← (I8 − K·H)·P
|
||||||
|
// K·H(8×8)= K 的前 4 列铺到前 4 列上(H 选通前 4 维)
|
||||||
|
std::array<std::array<double, 8>, 8> ikh{};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
ikh[i][j] = (i == j ? 1.0 : 0.0) - (j < 4 ? k[i][j] : 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
P_ = MatMul8(ikh, P_);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace tracker
|
||||||
|
} // namespace drone
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// target_tracker.cpp —— 多目标追踪过滤器(TargetTracker)实现
|
||||||
|
//
|
||||||
|
// 对应规格:docs/视觉追踪技术路线.md
|
||||||
|
// §5 检测-轨迹关联(贪婪最邻近,§5.3)
|
||||||
|
// §6 轨迹生命周期与 ID 管理(主目标锁定与选举,§6.3)
|
||||||
|
// §7 主流程六步(UPDATE 伪代码)
|
||||||
|
// §9.5 线程模型(单线程写 Update,仅 last_result 缓存加锁)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "target_tracker/target_tracker.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace drone {
|
||||||
|
namespace tracker {
|
||||||
|
|
||||||
|
TargetTracker::TargetTracker() : TargetTracker(Config{}) {}
|
||||||
|
|
||||||
|
TargetTracker::TargetTracker(const Config& cfg) : config_(cfg) {}
|
||||||
|
|
||||||
|
TrackResult TargetTracker::Update(const std::vector<Detection>& detections,
|
||||||
|
std::optional<FrameShape> frame_shape) {
|
||||||
|
// §7:frame_count += 1(仅统计用)
|
||||||
|
++frame_count_;
|
||||||
|
|
||||||
|
// ---- 入口防御:过滤坐标或置信度含 NaN/Inf 的检测 ----
|
||||||
|
// 防御性增强:上游检测器理论上已过滤,但库自身必须免疫——
|
||||||
|
// 非有限值一旦进入卡尔曼状态将永久污染轨道并传导到主目标输出
|
||||||
|
std::vector<Detection> valid_detections;
|
||||||
|
valid_detections.reserve(detections.size());
|
||||||
|
for (const Detection& det : detections) {
|
||||||
|
if (std::isfinite(det.x1) && std::isfinite(det.y1) &&
|
||||||
|
std::isfinite(det.x2) && std::isfinite(det.y2) &&
|
||||||
|
std::isfinite(det.conf)) {
|
||||||
|
valid_detections.push_back(det);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 1:所有存活轨道先行预测(推进一帧)----
|
||||||
|
// (§7 步骤 1:t.predict() 更新 last_center / last_box、age += 1)
|
||||||
|
for (auto& kv : tracks_) {
|
||||||
|
kv.second.Predict();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 2:构造检测中心表并贪婪关联(§7 步骤 2 / §5.3) ----
|
||||||
|
AssociationResult assoc;
|
||||||
|
if (!valid_detections.empty()) {
|
||||||
|
assoc = AssociateDetections(valid_detections);
|
||||||
|
} else {
|
||||||
|
// 无检测:全部轨道视为未匹配,全部进入 Coast 计数(§7 步骤 2 否则分支)
|
||||||
|
for (const auto& kv : tracks_) {
|
||||||
|
assoc.unmatched_track_ids.push_back(kv.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 3:用匹配到的检测更新轨道(§7 步骤 3) ----
|
||||||
|
for (const auto& match : assoc.matches) {
|
||||||
|
const int track_id = match.first;
|
||||||
|
const Detection& det = valid_detections[static_cast<size_t>(match.second)];
|
||||||
|
// §7 步骤 3:由检测重建原始框 raw_box = [cx−w/2, cy−h/2, cx+w/2, cy+h/2]
|
||||||
|
// (等价于检测框本身:cx=(x1+x2)/2、w=x2−x1 的逆运算,浮点上精确还原)
|
||||||
|
const BoxXYXY raw_box{det.x1, det.y1, det.x2, det.y2};
|
||||||
|
tracks_.at(track_id).Update(raw_box, det.conf, det.cls_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 4:未匹配轨道计丢失,死亡者回收(§7 步骤 4) ----
|
||||||
|
for (const int track_id : assoc.unmatched_track_ids) {
|
||||||
|
auto it = tracks_.find(track_id);
|
||||||
|
if (it == tracks_.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
it->second.MarkMiss(); // lost_count+1、age+1
|
||||||
|
if (it->second.IsDead()) { // lost_count > max_lost(严格大于,§6.1)
|
||||||
|
// 锁定的唯一解除途径之一:锁定轨道死亡被回收时清锁(§6.3)
|
||||||
|
if (locked_primary_id_.has_value() && *locked_primary_id_ == track_id) {
|
||||||
|
locked_primary_id_.reset();
|
||||||
|
}
|
||||||
|
tracks_.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 5:未匹配检测 → 创建新轨道(§7 步骤 5 / §6.2) ----
|
||||||
|
for (const int det_idx : assoc.unmatched_det_indices) {
|
||||||
|
const Detection& det = valid_detections[static_cast<size_t>(det_idx)];
|
||||||
|
// §7 步骤 5:以 [cx,cy,w,h] 创建;构造函数内重建 last_box,
|
||||||
|
// 保证创建当帧即可参与输出;next_id 从 0 递增永不复用(§6.2)
|
||||||
|
tracks_.emplace(next_id_,
|
||||||
|
Tracklet(next_id_, ToCenterSize({det.x1, det.y1, det.x2, det.y2}),
|
||||||
|
det.conf, det.cls_id, config_.max_lost_frames));
|
||||||
|
++next_id_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 步骤 6:选取/延续主目标,组装输出并缓存(§7 步骤 6 / §6.3) ----
|
||||||
|
TrackResult result = SelectPrimary(frame_shape);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(last_result_mutex_);
|
||||||
|
last_result_ = result; // 缓存供跨线程读取(§9.5)
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackResult TargetTracker::LastResult() const {
|
||||||
|
// §9.5:LastResult() 返回受互斥锁保护的拷贝
|
||||||
|
std::lock_guard<std::mutex> lock(last_result_mutex_);
|
||||||
|
return last_result_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TargetTracker::Reset() {
|
||||||
|
// §2.1:清空所有轨道、ID 计数器、锁定状态与缓存结果
|
||||||
|
tracks_.clear();
|
||||||
|
next_id_ = 0; // §6.2:reset() 时计数器归零
|
||||||
|
frame_count_ = 0;
|
||||||
|
locked_primary_id_.reset(); // 锁定的唯一解除途径之二(§6.3)
|
||||||
|
std::lock_guard<std::mutex> lock(last_result_mutex_);
|
||||||
|
last_result_ = TrackResult{};
|
||||||
|
}
|
||||||
|
|
||||||
|
int TargetTracker::ActiveCount() const {
|
||||||
|
return static_cast<int>(tracks_.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_map<int, Tracklet> TargetTracker::ActiveTracks() const {
|
||||||
|
return tracks_; // 值拷贝只读快照(§2.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
TargetTracker::AssociationResult TargetTracker::AssociateDetections(
|
||||||
|
const std::vector<Detection>& detections) {
|
||||||
|
AssociationResult result;
|
||||||
|
|
||||||
|
// §5.3:按轨道 confidence 降序排序(复制 ID 到 vector 后 sort,
|
||||||
|
// 不直接排哈希表,§9.2)
|
||||||
|
std::vector<int> sorted_ids;
|
||||||
|
sorted_ids.reserve(tracks_.size());
|
||||||
|
for (const auto& kv : tracks_) {
|
||||||
|
sorted_ids.push_back(kv.first);
|
||||||
|
}
|
||||||
|
std::sort(sorted_ids.begin(), sorted_ids.end(), [this](int a, int b) {
|
||||||
|
const double raw_a = tracks_.at(a).confidence;
|
||||||
|
const double raw_b = tracks_.at(b).confidence;
|
||||||
|
// NaN 安全:非有限置信度按 −∞ 处理(排最后),避免 NaN 参与比较
|
||||||
|
// 破坏 std::sort 要求的严格弱序(未定义行为);映射后为全序,
|
||||||
|
// 等价传递性恒成立
|
||||||
|
const double ca = std::isfinite(raw_a)
|
||||||
|
? raw_a
|
||||||
|
: -std::numeric_limits<double>::infinity();
|
||||||
|
const double cb = std::isfinite(raw_b)
|
||||||
|
? raw_b
|
||||||
|
: -std::numeric_limits<double>::infinity();
|
||||||
|
if (ca != cb) return ca > cb; // 置信度从高到低
|
||||||
|
return a < b; // 平局按 ID 升序作次级键,保证确定性
|
||||||
|
});
|
||||||
|
|
||||||
|
std::vector<bool> det_assigned(detections.size(), false);
|
||||||
|
|
||||||
|
for (const int track_id : sorted_ids) {
|
||||||
|
const Tracklet& track = tracks_.at(track_id);
|
||||||
|
// §5.3:每条轨道最优代价初始化为 1.0(仅 cost 严格小于 1.0 才记录,
|
||||||
|
// 即 cost ≥ 1.0 判为不可匹配,§5.1 匹配有效性判据)
|
||||||
|
double best_cost = 1.0;
|
||||||
|
int best_det = -1;
|
||||||
|
for (size_t d = 0; d < detections.size(); ++d) {
|
||||||
|
if (det_assigned[d]) continue; // 跳过已分配检测
|
||||||
|
const Detection& det = detections[d];
|
||||||
|
// §5.1:中心点欧氏距离(像素)
|
||||||
|
const double det_cx = (det.x1 + det.x2) / 2.0;
|
||||||
|
const double det_cy = (det.y1 + det.y2) / 2.0;
|
||||||
|
const double dist =
|
||||||
|
std::hypot(track.last_center.first - det_cx,
|
||||||
|
track.last_center.second - det_cy);
|
||||||
|
// §5.1:归一化距离,取 max(Dmax, 1e-3) 防除零
|
||||||
|
const double norm_dist =
|
||||||
|
dist / std::max(config_.max_association_dist, 1e-3);
|
||||||
|
// §5.2:预测框与检测框的 IoU
|
||||||
|
const double iou = track.last_box.has_value()
|
||||||
|
? ComputeIoU(*track.last_box,
|
||||||
|
{det.x1, det.y1, det.x2, det.y2})
|
||||||
|
: 0.0;
|
||||||
|
// §5.1:cost = w_dist·norm_dist + w_iou·(1−IoU)
|
||||||
|
const double cost = config_.dist_weight * norm_dist +
|
||||||
|
config_.iou_weight * (1.0 - iou);
|
||||||
|
if (cost < best_cost) { // 严格小于才更新(§5.1 / §5.3)
|
||||||
|
best_cost = cost;
|
||||||
|
best_det = static_cast<int>(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best_det >= 0) {
|
||||||
|
result.matches.emplace_back(track_id, best_det);
|
||||||
|
det_assigned[static_cast<size_t>(best_det)] = true;
|
||||||
|
} else {
|
||||||
|
result.unmatched_track_ids.push_back(track_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5.3:未分配检测收集
|
||||||
|
for (size_t d = 0; d < detections.size(); ++d) {
|
||||||
|
if (!det_assigned[d]) {
|
||||||
|
result.unmatched_det_indices.push_back(static_cast<int>(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackResult TargetTracker::SelectPrimary(std::optional<FrameShape> frame_shape) {
|
||||||
|
TrackResult result;
|
||||||
|
result.n_active = static_cast<int>(tracks_.size());
|
||||||
|
|
||||||
|
// §6.3:轨道表为空 → 清除锁定,返回 tracked=false
|
||||||
|
if (tracks_.empty()) {
|
||||||
|
locked_primary_id_.reset();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §6.3 ① 锁定延续:锁定轨道仍存活则直接输出(Coast 中照样输出,
|
||||||
|
// is_predicted=true;不被其他更高置信度目标抢占)
|
||||||
|
Tracklet* chosen = nullptr;
|
||||||
|
bool is_predicted = false;
|
||||||
|
if (locked_primary_id_.has_value()) {
|
||||||
|
auto it = tracks_.find(*locked_primary_id_);
|
||||||
|
if (it != tracks_.end()) {
|
||||||
|
chosen = &it->second;
|
||||||
|
is_predicted = it->second.IsCoasting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §6.3 ② 重新选举(无锁定 / 锁定目标已死亡被回收)
|
||||||
|
if (chosen == nullptr) {
|
||||||
|
// measured ← 所有 lost_count == 0 的轨道;coasting ← 其余存活轨道
|
||||||
|
std::vector<Tracklet*> measured;
|
||||||
|
std::vector<Tracklet*> coasting;
|
||||||
|
for (auto& kv : tracks_) {
|
||||||
|
if (kv.second.IsCoasting()) {
|
||||||
|
coasting.push_back(&kv.second);
|
||||||
|
} else {
|
||||||
|
measured.push_back(&kv.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!measured.empty()) {
|
||||||
|
// confirmed ← measured 中 hits ≥ min_hits 者;为空则退回 measured
|
||||||
|
// (min_hits 只影响候选池优先级,不阻止输出,§6.1 注记 1)
|
||||||
|
std::vector<Tracklet*> pool;
|
||||||
|
for (Tracklet* t : measured) {
|
||||||
|
if (t->hits >= config_.min_hits) {
|
||||||
|
pool.push_back(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pool.empty()) {
|
||||||
|
pool = measured;
|
||||||
|
}
|
||||||
|
// chosen ← pool 中 confidence 最大者;平局取 unordered_map 遍历序
|
||||||
|
// 中首个最大者,不做确定性承诺(规格 §6.3 未规定平局规则)
|
||||||
|
chosen = pool.front();
|
||||||
|
for (Tracklet* t : pool) {
|
||||||
|
if (t->confidence > chosen->confidence) {
|
||||||
|
chosen = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_predicted = false;
|
||||||
|
} else if (!coasting.empty()) {
|
||||||
|
// 无实测轨道:选 coasting 中离画面中心最近者(欧氏距离平方排序)
|
||||||
|
// §2.2:frame_shape 缺省按 640×480 画面中心 (320, 240)
|
||||||
|
const double center_x =
|
||||||
|
(frame_shape.has_value() && frame_shape->width > 0)
|
||||||
|
? frame_shape->width / 2.0
|
||||||
|
: 320.0;
|
||||||
|
const double center_y =
|
||||||
|
(frame_shape.has_value() && frame_shape->height > 0)
|
||||||
|
? frame_shape->height / 2.0
|
||||||
|
: 240.0;
|
||||||
|
double best_dist_sq = std::numeric_limits<double>::infinity();
|
||||||
|
for (Tracklet* t : coasting) {
|
||||||
|
const double dx = t->last_center.first - center_x;
|
||||||
|
const double dy = t->last_center.second - center_y;
|
||||||
|
const double dist_sq = dx * dx + dy * dy;
|
||||||
|
if (dist_sq < best_dist_sq) {
|
||||||
|
best_dist_sq = dist_sq;
|
||||||
|
chosen = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen == nullptr) {
|
||||||
|
// NaN 残余的防御兜底:若所有 coasting 轨道中心均为 NaN,
|
||||||
|
// dist_sq 恒为 NaN 无人当选,取首个避免空指针解引用;
|
||||||
|
// 正常输入已在 Update 入口经有限性过滤,本分支理论上不可达
|
||||||
|
chosen = coasting.front();
|
||||||
|
}
|
||||||
|
is_predicted = true; // Coast 兜底:仅预测、无实测
|
||||||
|
} else {
|
||||||
|
// 理论上不可达(轨道非空则 measured/coasting 必居其一);防御清锁
|
||||||
|
locked_primary_id_.reset();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
locked_primary_id_ = chosen->track_id; // 选举后锁定(§6.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 组装输出(§2.3 九字段) ----
|
||||||
|
result.tracked = true;
|
||||||
|
result.primary_id = chosen->track_id;
|
||||||
|
result.center = chosen->last_center;
|
||||||
|
result.box = chosen->last_box;
|
||||||
|
result.confidence = chosen->confidence; // Coast 期间继承最近实测置信度
|
||||||
|
result.is_predicted = is_predicted;
|
||||||
|
result.lost_frames = chosen->lost_count;
|
||||||
|
// §2.3:raw = 关联到的原始检测框;Coast 时为空(锁定延续分支同样遵守)
|
||||||
|
result.raw = is_predicted ? std::nullopt : chosen->last_raw_box;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace tracker
|
||||||
|
} // namespace drone
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// tracklet.cpp —— 轨道(Tracklet)与基础几何工具实现
|
||||||
|
//
|
||||||
|
// 对应规格:docs/视觉追踪技术路线.md
|
||||||
|
// §5.2 IoU 计算
|
||||||
|
// §7 TRACKLET_PREDICT / TRACKLET_UPDATE 伪代码
|
||||||
|
// §3.2 Tracklet 字段语义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "target_tracker/tracklet.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace drone {
|
||||||
|
namespace tracker {
|
||||||
|
|
||||||
|
BoxCXCYWH ToCenterSize(const BoxXYXY& box) {
|
||||||
|
// §7 TRACKLET_UPDATE:cx=(x1+x2)/2;cy=(y1+y2)/2;w=x2−x1;h=y2−y1
|
||||||
|
return BoxCXCYWH{(box.x1 + box.x2) / 2.0, (box.y1 + box.y2) / 2.0,
|
||||||
|
box.x2 - box.x1, box.y2 - box.y1};
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxXYXY ToXYXY(const BoxCXCYWH& box) {
|
||||||
|
// §7 TRACKLET_PREDICT:center_size → [cx−w/2, cy−h/2, cx+w/2, cy+h/2]
|
||||||
|
return BoxXYXY{box.cx - box.w / 2.0, box.cy - box.h / 2.0,
|
||||||
|
box.cx + box.w / 2.0, box.cy + box.h / 2.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
double ComputeIoU(const BoxXYXY& a, const BoxXYXY& b) {
|
||||||
|
// §5.2:任一框为空(宽或高 ≤ 0)返回 0
|
||||||
|
const double area_a = (a.x2 - a.x1) * (a.y2 - a.y1);
|
||||||
|
const double area_b = (b.x2 - b.x1) * (b.y2 - b.y1);
|
||||||
|
if (area_a <= 0.0 || area_b <= 0.0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交集宽高与 0 取最大值(§5.2)
|
||||||
|
const double inter_w = std::min(a.x2, b.x2) - std::max(a.x1, b.x1);
|
||||||
|
const double inter_h = std::min(a.y2, b.y2) - std::max(a.y1, b.y1);
|
||||||
|
if (inter_w <= 0.0 || inter_h <= 0.0) {
|
||||||
|
return 0.0; // 交集面积 ≤ 0 返回 0(§5.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const double inter_area = inter_w * inter_h;
|
||||||
|
const double union_area = area_a + area_b - inter_area;
|
||||||
|
if (union_area <= 0.0) {
|
||||||
|
return 0.0; // 并集 ≤ 0 返回 0(§5.2)
|
||||||
|
}
|
||||||
|
// IoU = area(A∩B) / (area(A)+area(B)−area(A∩B))(§5.2)
|
||||||
|
return inter_area / union_area;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tracklet::Tracklet(int id, const BoxCXCYWH& measurement, double conf, int cls,
|
||||||
|
int max_lost_frames)
|
||||||
|
: track_id(id),
|
||||||
|
max_lost(max_lost_frames),
|
||||||
|
confidence(conf),
|
||||||
|
cls_id(cls) {
|
||||||
|
// §4.3:用首次观测初始化滤波器(速度置零、P 取默认对角)
|
||||||
|
kf.Initiate(measurement);
|
||||||
|
// §7 步骤 5:创建当帧即赋 last_center / last_box(保证当帧可参与输出)
|
||||||
|
last_center = {measurement.cx, measurement.cy};
|
||||||
|
last_box = ToXYXY(measurement);
|
||||||
|
// 创建即源于实测检测:同步记录原始检测框(Coast 期间保留,
|
||||||
|
// 对外输出按 §2.3 在 Coast 时置空)
|
||||||
|
last_raw_box = last_box;
|
||||||
|
// age / hits / lost_count 由类内默认值保证(1 / 1 / 0,§3.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tracklet::Predict() {
|
||||||
|
// §7 TRACKLET_PREDICT:卡尔曼预测推进一帧
|
||||||
|
const auto pred = kf.Predict();
|
||||||
|
if (pred.has_value()) {
|
||||||
|
// 预测成功:更新 last_center / last_box,age += 1
|
||||||
|
last_center = {pred->cx, pred->cy};
|
||||||
|
last_box = ToXYXY(*pred);
|
||||||
|
++age;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tracklet::Update(const BoxXYXY& box, double conf, int cls) {
|
||||||
|
// §7 TRACKLET_UPDATE:框 → [cx,cy,w,h] → kf.update
|
||||||
|
kf.Update(ToCenterSize(box));
|
||||||
|
last_center = {(box.x1 + box.x2) / 2.0, (box.y1 + box.y2) / 2.0};
|
||||||
|
last_box = box;
|
||||||
|
last_raw_box = box; // 实测关联到的原始检测框(Coast 期间保留不更新)
|
||||||
|
confidence = conf;
|
||||||
|
cls_id = cls;
|
||||||
|
lost_count = 0; // 匹配成功:连续丢失计数清零
|
||||||
|
++hits; // 累计匹配次数 +1
|
||||||
|
++age; // 帧龄 +1
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tracklet::MarkMiss() {
|
||||||
|
// §7 步骤 4:未匹配计丢失 lost_count+1、age+1
|
||||||
|
++lost_count;
|
||||||
|
++age;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace tracker
|
||||||
|
} // namespace drone
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# target_tracker 视觉追踪库实现文档
|
||||||
|
|
||||||
|
> 独立动态库(`libtarget_tracker.so`),实现轻量级多目标追踪过滤器:
|
||||||
|
> 恒定速度卡尔曼滤波平滑 + Coast 抗短暂丢失 + ID 稳定与主目标锁定。
|
||||||
|
> 算法行为的唯一权威规格为 `docs/视觉追踪技术路线.md`(下称"技术路线"),
|
||||||
|
> 本文档不重复抄录算法全文,仅描述实现结构与对照关系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、功能职责与边界
|
||||||
|
|
||||||
|
**做什么**:
|
||||||
|
|
||||||
|
- 每帧接收 YOLO 检测框列表,输出**一个主目标**的平滑中心点/边界框/状态标志
|
||||||
|
(`TrackResult`),供下游速度闭环(PID)消费。
|
||||||
|
- 平滑:8 维恒定速度卡尔曼滤波(位置/尺寸 + 速度),抑制检测框逐帧抖动。
|
||||||
|
- Coast 预测:目标短暂消失时用滤波器预测值继续输出(`is_predicted=true`),
|
||||||
|
避免控制输出突变;连续丢失超过 `max_lost_frames`(严格大于)才判丢。
|
||||||
|
- ID 稳定:贪婪最邻近关联(中心距离 + IoU 加权代价)维持轨道 ID;
|
||||||
|
主目标锁定后不被高置信度新目标抢占,仅在轨道死亡或 `Reset()` 时解除。
|
||||||
|
|
||||||
|
**不做什么**(边界):
|
||||||
|
|
||||||
|
- 不做光流估计、不做激光/惯导等多源融合(属 perception 模块职责);
|
||||||
|
- 不做 Topic 订阅/发布接线、不依赖 spdlog/Eigen/OpenCV,零第三方依赖;
|
||||||
|
- 不做画面边界钳制与负尺寸约束(与技术路线 §4.5 注记保持一致);
|
||||||
|
- 不要求检测置信度下限过滤(上游检测器已完成阈值过滤)。
|
||||||
|
|
||||||
|
## 二、接口与数据流
|
||||||
|
|
||||||
|
命名空间 `drone::tracker`,头文件保护 `#pragma once`。
|
||||||
|
|
||||||
|
| 头文件 | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| `include/target_tracker/kalman_box_filter.h` | `BoxCXCYWH`、`KalmanBoxFilter` |
|
||||||
|
| `include/target_tracker/tracklet.h` | `BoxXYXY`、`ToCenterSize`/`ToXYXY`/`ComputeIoU`、`Tracklet` |
|
||||||
|
| `include/target_tracker/target_tracker.h` | `Detection`、`FrameShape`、`TrackResult`、`TargetTracker` |
|
||||||
|
|
||||||
|
主要接口签名:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class KalmanBoxFilter {
|
||||||
|
void Initiate(const BoxCXCYWH& z); // 首次观测初始化(§4.3)
|
||||||
|
std::optional<BoxCXCYWH> Predict(); // 未初始化返回 nullopt(§4.4)
|
||||||
|
void Update(const BoxCXCYWH& z); // 观测更新(§4.5)
|
||||||
|
bool Initialized() const;
|
||||||
|
const std::array<double, 8>& State() const; // [cx,cy,w,h,vx,vy,vw,vh]
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Tracklet {
|
||||||
|
Tracklet(int id, const BoxCXCYWH& measurement, double conf, int cls,
|
||||||
|
int max_lost_frames);
|
||||||
|
bool IsDead() const; // lost_count > max_lost(严格大于)
|
||||||
|
bool IsCoasting() const; // lost_count > 0
|
||||||
|
void Predict(); // kf.predict 非空时更新 last_center/last_box、age+1
|
||||||
|
void Update(const BoxXYXY& box, double conf, int cls); // 匹配更新
|
||||||
|
void MarkMiss(); // lost+1、age+1
|
||||||
|
// 字段:track_id / kf / lost_count / max_lost / confidence / cls_id /
|
||||||
|
// age / hits / last_center / last_box / last_raw_box
|
||||||
|
};
|
||||||
|
|
||||||
|
class TargetTracker {
|
||||||
|
struct Config { // §8.1 默认值
|
||||||
|
int max_lost_frames = 8;
|
||||||
|
double max_association_dist = 200.0;
|
||||||
|
int min_hits = 3;
|
||||||
|
double dist_weight = 0.5;
|
||||||
|
double iou_weight = 0.5;
|
||||||
|
};
|
||||||
|
TargetTracker(); // 默认配置
|
||||||
|
explicit TargetTracker(const Config& cfg);
|
||||||
|
TrackResult Update(const std::vector<Detection>& detections,
|
||||||
|
std::optional<FrameShape> frame_shape = std::nullopt);
|
||||||
|
TrackResult LastResult() const; // 线程安全拷贝
|
||||||
|
void Reset(); // 清全部状态且 next_id 归零
|
||||||
|
int ActiveCount() const;
|
||||||
|
std::unordered_map<int, Tracklet> ActiveTracks() const; // 只读快照
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
数据流:调用方每帧将检测列表(像素坐标 `[x1,y1,x2,y2,conf,cls_id]`)传入
|
||||||
|
`Update()`,得到 `TrackResult`(九字段:tracked / primary_id / center / box /
|
||||||
|
confidence / is_predicted / lost_frames / n_active / raw)。丢失时
|
||||||
|
`tracked=false`、定位字段空、`lost_frames=0`(技术路线 §2.3)。
|
||||||
|
|
||||||
|
## 三、关键实现点
|
||||||
|
|
||||||
|
### 3.1 卡尔曼滤波(对照技术路线 §4)
|
||||||
|
|
||||||
|
- 状态 `x[8]=[cx,cy,w,h,vx,vy,vw,vh]`,观测 `z[4]=[cx,cy,w,h]`,dt=1 帧。
|
||||||
|
- 常量矩阵:`F`(8×8,I4 + 右上 I4)、`H`(4×8,[I4|0])为 `constexpr`;
|
||||||
|
矩阵乘法/转置用 `std::array` 定长手写,全程 `double`(§9.3)。
|
||||||
|
- 常量:`kStdPos=1/50`、`kStdVel=1/200`、`kIniPos=2.0`、`kIniVel=100.0`、
|
||||||
|
`kRScale=2.0`(§4.2/§8.2)。
|
||||||
|
- **动态噪声**:`Predict()` 时 Q=diag(σ_pos²,σ_vel²),σ 直接用状态中的 w/h
|
||||||
|
缩放;`Update()` 时 R 用 `abs(w)/abs(h)` 缩放并乘 2——两处差异与规格
|
||||||
|
§4.2 注记严格保持一致。
|
||||||
|
- `Initiate()`:速度置零,P=diag(2,2,2,2,100,100,100,100)(§4.3)。
|
||||||
|
- `Update()` 中 S=HPHᵀ+R 为 4×4,用**高斯-约当消元 + 部分主元**求逆,
|
||||||
|
消元前对角加 1e-9 正则化防奇异(§9.3);随后 K=PHᵀS⁻¹、
|
||||||
|
x←x+K(z−Hx)、P←(I−KH)P(§4.5)。
|
||||||
|
|
||||||
|
### 3.2 贪婪关联(对照 §5)
|
||||||
|
|
||||||
|
- `cost = dist_weight·dist/max(Dmax,1e-3) + iou_weight·(1−IoU)`,
|
||||||
|
距离用 `std::hypot`;每条轨道 `best_cost` 初始化为 1.0,**仅 cost 严格
|
||||||
|
小于才匹配**(cost=1.0 恰好不匹配,§5.1)。
|
||||||
|
- 按轨道 confidence 降序(平局按 ID 升序保证确定性)逐一贪心匹配;
|
||||||
|
排序时复制 ID 到 `std::vector` 后 `std::sort`,不直接排哈希表(§9.2)。
|
||||||
|
|
||||||
|
### 3.3 主目标选举三级回退(对照 §6.3)
|
||||||
|
|
||||||
|
1. **锁定延续优先**:`locked_primary_id` 存活则直接输出(Coast 中照样输出
|
||||||
|
`is_predicted=true`),不被高置信度目标抢占;
|
||||||
|
2. **重新选举**(无锁定/锁定已死亡):measured(lost==0)中 hits≥min_hits
|
||||||
|
的已确认池取 confidence 最大;池空则退回全部 measured;
|
||||||
|
3. 无 measured 时从 coasting 中选离画面中心最近者(欧氏距离平方,
|
||||||
|
`frame_shape` 缺省按 640×480 中心 (320,240)),`is_predicted=true`;
|
||||||
|
都没有则 `tracked=false`。
|
||||||
|
|
||||||
|
锁定仅在轨道死亡删除处或 `Reset()` 时解除。
|
||||||
|
|
||||||
|
### 3.4 主流程六步(对照 §7)
|
||||||
|
|
||||||
|
`Update()` 严格按:①全体轨道 predict ②检测中心表 + 贪婪关联 ③匹配轨道
|
||||||
|
update(raw_box 由检测重建)④未匹配轨道 mark_miss、死亡即删、若为锁定
|
||||||
|
目标同时清锁 ⑤未匹配检测新建轨道(创建当帧赋 last_box/last_raw_box,
|
||||||
|
保证当帧可参与输出;next_id 从 0 递增永不复用)⑥主目标选举组装输出并
|
||||||
|
缓存 last_result。
|
||||||
|
|
||||||
|
### 3.5 线程模型(对照 §9.5)
|
||||||
|
|
||||||
|
单线程写 `Update()`(推理主循环);库内仅对 `last_result` 缓存加一把
|
||||||
|
`std::mutex`,`LastResult()` 返回受保护拷贝。内部轨道状态不加锁;
|
||||||
|
若未来改为多线程调用 `Update()` 需整体加锁。
|
||||||
|
|
||||||
|
### 3.6 符号导出说明
|
||||||
|
|
||||||
|
当前不加 `-fvisibility=hidden`、不使用导出宏,共享库默认导出全部公共符号。
|
||||||
|
日后若启用隐藏可见性(如 `CXX_VISIBILITY_PRESET hidden`),需补
|
||||||
|
`TARGET_TRACKER_API` 宏(导出/导入双态)并标注所有公共类与函数,
|
||||||
|
否则下游链接将缺失符号。
|
||||||
|
|
||||||
|
## 四、日志行为
|
||||||
|
|
||||||
|
本库为**纯算法层,不打任何日志**(不依赖 spdlog)。若未来接入主工程,
|
||||||
|
接线层(订阅/发布适配部件)按主工程日志纪律负责关键路径日志与节流,
|
||||||
|
本库内部不新增日志。
|
||||||
|
|
||||||
|
## 五、测试方式
|
||||||
|
|
||||||
|
构建环境:WSL2 Ubuntu(AGENTS.md 规定的开发机构建环境)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/d/ProgramData/drone_test/drone_test
|
||||||
|
cmake -S target_tracker -B target_tracker/build
|
||||||
|
cmake --build target_tracker/build -j$(nproc)
|
||||||
|
ctest --test-dir target_tracker/build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
测试共 27 条(kalman_box_filter_test 5 条 / tracklet_test 12 条 /
|
||||||
|
target_tracker_test 10 条),其中 `target_tracker_test.cpp` 逐条覆盖
|
||||||
|
技术路线 §9.4 六条验收用例:
|
||||||
|
|
||||||
|
1. `EmptyFramesCoastThenLost`:空检测帧 Coast 持续 max_lost_frames 帧
|
||||||
|
(is_predicted=true),第 max_lost_frames+1 帧 tracked=false;
|
||||||
|
2. `IdStrictlyIncreasingNoReuse`:ID 严格递增不复用,Reset 后归零;
|
||||||
|
3. `LockNotStolenAndSwitchAfterDeath`:Coast 期间高置信度新目标不抢占锁定,
|
||||||
|
锁定目标死亡后切换;
|
||||||
|
4. `MinHitsFallbackAllowsNewTrack`:无已确认实测轨道时新轨道(hits<min_hits)
|
||||||
|
可当选主目标;
|
||||||
|
5. `CostExactlyOneDoesNotMatch`:纯距离项构造 cost=1.0 恰好不匹配,
|
||||||
|
并附 cost<1.0 对照帧;
|
||||||
|
6. `HigherConfidenceTrackWinsDetection`:两轨道竞争同一检测,置信度高者获胜。
|
||||||
|
|
||||||
|
其余为补充行为测试(丢失输出字段约定、Coast 兜底选举、锁定延续、
|
||||||
|
默认画面中心、IoU 边界、滤波器收敛性等)。
|
||||||
|
|
||||||
|
## 六、排查 / 修改要点
|
||||||
|
|
||||||
|
- **主目标"甩头"**:确认是否误改锁定解除逻辑——锁定只能在轨道死亡删除处
|
||||||
|
或 `Reset()` 解除;选举分支不得覆盖存活锁定。
|
||||||
|
- **Coast 时间不对**:死亡判定为 `lost_count > max_lost`(严格大于),
|
||||||
|
默认允许 Coast 8 帧、第 9 帧判丢;改成 `>=` 属于行为变更。
|
||||||
|
- **关联不上 / 乱匹配**:检查 `max_association_dist` 与目标帧间位移是否
|
||||||
|
匹配;cost 判据为严格小于 1.0;dist/iou 权重之和建议保持 1.0。
|
||||||
|
- **滤波发散或跳变**:检查 Q/R 动态噪声是否按状态 w/h 缩放(R 用 abs 且
|
||||||
|
乘 2、Q 不用 abs,该差异是规格要求);4×4 求逆已加 1e-9 正则化。
|
||||||
|
- **测试链接失败(找不到 so)**:测试可执行文件通过 `CMAKE_BUILD_RPATH`
|
||||||
|
定位库;若把测试挪到别处运行需设置 `LD_LIBRARY_PATH`。
|
||||||
|
- 修改任何行为后必须同步更新本文档与 `docs/视觉追踪技术路线.md` 的对照注释。
|
||||||
|
|
||||||
|
## 七、未来接线指引(接入主工程视频链路)
|
||||||
|
|
||||||
|
本库以独立动态库交付,主工程接入建议采用:
|
||||||
|
|
||||||
|
1. **订阅解码帧主题作为每帧节拍**:接线部件订阅解码帧 Topic,以解码帧
|
||||||
|
到达为帧节拍驱动追踪(保证与推理节奏一致);
|
||||||
|
2. **按 frame_sequence 聚合 DetectionResult**:YOLO 每目标发布一条
|
||||||
|
DetectionResult(同帧共享 frame_sequence),接线部件按 frame_sequence
|
||||||
|
聚合成 `std::vector<Detection>` 后调用 `Update()`;
|
||||||
|
3. **主工程 `include/common/types.h` 新增 `TrackedTarget` 消息与
|
||||||
|
`kTrackedTarget` 主题**:由接线部件把 `TrackResult` 转换为
|
||||||
|
`TrackedTarget` 发布,供控制线程(20Hz PID)订阅消费;Coast
|
||||||
|
(is_predicted=true)时调用方清 PID 积分、tracked=false 时零速刹车
|
||||||
|
(技术路线 §1.2 上游行为约定)。
|
||||||
|
|
||||||
|
主工程链接该 so 的 CMake 片段示例:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
# 主工程 CMakeLists.txt 中引入视觉追踪库(独立子工程)
|
||||||
|
add_subdirectory(target_tracker)
|
||||||
|
|
||||||
|
target_link_libraries(drone_control PRIVATE target_tracker)
|
||||||
|
# 部署时需将 libtarget_tracker.so.1 一并打包到可执行文件目录
|
||||||
|
# (或设置 INSTALL_RPATH=$ORIGIN,与 docs/部署与打包.md 目录式部署约定一致)
|
||||||
|
```
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// kalman_box_filter_test.cpp —— KalmanBoxFilter 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖 docs/视觉追踪技术路线.md §4 行为:
|
||||||
|
// - 初始化状态正确(§4.3)
|
||||||
|
// - 静止目标多帧收敛到观测(§4.5)
|
||||||
|
// - 匀速目标速度收敛且预测超前一个 dt(§4.4)
|
||||||
|
// - 未初始化 predict 返回 nullopt(§4.4)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "target_tracker/kalman_box_filter.h"
|
||||||
|
|
||||||
|
using drone::tracker::BoxCXCYWH;
|
||||||
|
using drone::tracker::KalmanBoxFilter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 未初始化时 predict 返回 nullopt(§4.4);update 直接返回不改变状态(§4.5)
|
||||||
|
TEST(KalmanBoxFilterTest, UninitializedPredictReturnsNullopt) {
|
||||||
|
KalmanBoxFilter kf;
|
||||||
|
EXPECT_FALSE(kf.Initialized());
|
||||||
|
EXPECT_FALSE(kf.Predict().has_value());
|
||||||
|
|
||||||
|
// 未初始化时 update 无副作用
|
||||||
|
kf.Update({100.0, 100.0, 20.0, 20.0});
|
||||||
|
EXPECT_FALSE(kf.Initialized());
|
||||||
|
EXPECT_FALSE(kf.Predict().has_value());
|
||||||
|
for (const double v : kf.State()) {
|
||||||
|
EXPECT_DOUBLE_EQ(v, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化状态正确(§4.3):速度置零、位置/尺寸取首次观测
|
||||||
|
TEST(KalmanBoxFilterTest, InitiateSetsStateCorrectly) {
|
||||||
|
KalmanBoxFilter kf;
|
||||||
|
kf.Initiate({320.0, 240.0, 40.0, 30.0});
|
||||||
|
EXPECT_TRUE(kf.Initialized());
|
||||||
|
|
||||||
|
const std::array<double, 8> expected = {
|
||||||
|
{320.0, 240.0, 40.0, 30.0, 0.0, 0.0, 0.0, 0.0}};
|
||||||
|
for (size_t i = 0; i < expected.size(); ++i) {
|
||||||
|
EXPECT_DOUBLE_EQ(kf.State()[i], expected[i]) << "index=" << i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化后 predict 返回状态前 4 维副本(速度为零 → 位置不变,§4.4)
|
||||||
|
const auto pred = kf.Predict();
|
||||||
|
ASSERT_TRUE(pred.has_value());
|
||||||
|
EXPECT_NEAR(pred->cx, 320.0, 1e-9);
|
||||||
|
EXPECT_NEAR(pred->cy, 240.0, 1e-9);
|
||||||
|
EXPECT_NEAR(pred->w, 40.0, 1e-9);
|
||||||
|
EXPECT_NEAR(pred->h, 30.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静止目标:多帧重复观测同一框,滤波输出应收敛到观测值(§4.5)
|
||||||
|
TEST(KalmanBoxFilterTest, StaticTargetConvergesToObservation) {
|
||||||
|
KalmanBoxFilter kf;
|
||||||
|
const BoxCXCYWH z{100.0, 150.0, 40.0, 60.0};
|
||||||
|
kf.Initiate({104.0, 156.0, 42.0, 62.0}); // 带初始偏差,验证收敛能力
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; ++i) {
|
||||||
|
kf.Predict();
|
||||||
|
kf.Update(z);
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_NEAR(kf.State()[0], z.cx, 0.5); // cx
|
||||||
|
EXPECT_NEAR(kf.State()[1], z.cy, 0.5); // cy
|
||||||
|
EXPECT_NEAR(kf.State()[2], z.w, 0.5); // w
|
||||||
|
EXPECT_NEAR(kf.State()[3], z.h, 0.5); // h
|
||||||
|
// 静止目标速度应收敛到约 0
|
||||||
|
EXPECT_NEAR(kf.State()[4], 0.0, 0.1);
|
||||||
|
EXPECT_NEAR(kf.State()[5], 0.0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匀速目标:速度估计收敛到真实速度,且 predict 超前一个 dt(§4.4)
|
||||||
|
TEST(KalmanBoxFilterTest, ConstantVelocityConvergesAndPredictLeadsOneDt) {
|
||||||
|
KalmanBoxFilter kf;
|
||||||
|
const double vx = 3.0; // 像素/帧
|
||||||
|
const double vy = 2.0; // 像素/帧
|
||||||
|
|
||||||
|
// 目标从 (100, 100) 出发匀速运动,每帧观测一次(predict → update)
|
||||||
|
BoxCXCYWH z{100.0, 100.0, 30.0, 30.0};
|
||||||
|
kf.Initiate(z);
|
||||||
|
for (int i = 0; i < 60; ++i) {
|
||||||
|
kf.Predict();
|
||||||
|
z.cx += vx;
|
||||||
|
z.cy += vy;
|
||||||
|
kf.Update(z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 速度收敛到真实速度(像素/帧)
|
||||||
|
EXPECT_NEAR(kf.State()[4], vx, 0.3) << "vx 未收敛";
|
||||||
|
EXPECT_NEAR(kf.State()[5], vy, 0.3) << "vy 未收敛";
|
||||||
|
// 位置贴合当前观测
|
||||||
|
EXPECT_NEAR(kf.State()[0], z.cx, 1.5);
|
||||||
|
EXPECT_NEAR(kf.State()[1], z.cy, 1.5);
|
||||||
|
|
||||||
|
// 再预测一帧:应超前当前观测一个 dt(≈ 速度 × 1 帧)
|
||||||
|
const auto pred = kf.Predict();
|
||||||
|
ASSERT_TRUE(pred.has_value());
|
||||||
|
EXPECT_NEAR(pred->cx, z.cx + vx, 1.0) << "预测未超前一个 dt";
|
||||||
|
EXPECT_NEAR(pred->cy, z.cy + vy, 1.0) << "预测未超前一个 dt";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态噪声不退化:小尺寸目标多帧观测仍稳定收敛(§4.2 噪声与尺寸成比例)
|
||||||
|
TEST(KalmanBoxFilterTest, SmallTargetNoiseScalingStable) {
|
||||||
|
KalmanBoxFilter kf;
|
||||||
|
const BoxCXCYWH z{50.0, 60.0, 4.0, 4.0}; // 小目标(4×4 像素)
|
||||||
|
kf.Initiate(z);
|
||||||
|
for (int i = 0; i < 30; ++i) {
|
||||||
|
kf.Predict();
|
||||||
|
kf.Update(z);
|
||||||
|
}
|
||||||
|
EXPECT_NEAR(kf.State()[0], z.cx, 0.5);
|
||||||
|
EXPECT_NEAR(kf.State()[1], z.cy, 0.5);
|
||||||
|
EXPECT_TRUE(std::isfinite(kf.State()[2]));
|
||||||
|
EXPECT_TRUE(std::isfinite(kf.State()[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// target_tracker_test.cpp —— TargetTracker 单元测试
|
||||||
|
//
|
||||||
|
// 逐条覆盖 docs/视觉追踪技术路线.md §9.4 行为一致性验收用例(硬验收标准):
|
||||||
|
// 1. 空检测帧序列:Coast 持续 max_lost_frames 帧,第 max_lost_frames+1 帧判丢
|
||||||
|
// 2. ID 严格递增不复用(含 Reset 后归零)
|
||||||
|
// 3. 锁定不被抢占;锁定目标死亡后切换到新目标
|
||||||
|
// 4. min_hits 回退:无已确认实测轨道时新轨道也可当选主目标
|
||||||
|
// 5. cost=1.0 恰好不构成匹配(严格小于)
|
||||||
|
// 6. 置信度优先:两轨道竞争同一检测,置信度高者获胜
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "target_tracker/target_tracker.h"
|
||||||
|
|
||||||
|
using drone::tracker::Detection;
|
||||||
|
using drone::tracker::FrameShape;
|
||||||
|
using drone::tracker::TargetTracker;
|
||||||
|
using drone::tracker::TrackResult;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Detection MakeDet(double x1, double y1, double x2, double y2, double conf,
|
||||||
|
int cls = 0) {
|
||||||
|
return Detection{x1, y1, x2, y2, conf, cls};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 验收用例 1(§9.4-1):空检测帧序列
|
||||||
|
// 连续输入空检测,主目标输出 is_predicted=true 共 max_lost_frames 帧,
|
||||||
|
// 第 max_lost_frames + 1 帧 tracked=false(死亡判定 lost_count > max_lost)。
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, EmptyFramesCoastThenLost) {
|
||||||
|
TargetTracker tracker; // 默认配置 max_lost_frames = 8
|
||||||
|
ASSERT_EQ(tracker.GetConfig().max_lost_frames, 8);
|
||||||
|
|
||||||
|
// 第 1 帧:给出一个检测,建立轨道并锁定为主目标
|
||||||
|
TrackResult r = tracker.Update({MakeDet(100.0, 100.0, 140.0, 140.0, 0.9)});
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_FALSE(r.is_predicted); // 当帧有实测
|
||||||
|
EXPECT_EQ(r.lost_frames, 0);
|
||||||
|
EXPECT_EQ(r.n_active, 1);
|
||||||
|
ASSERT_TRUE(r.box.has_value());
|
||||||
|
ASSERT_TRUE(r.raw.has_value()); // 当帧关联到原始检测框
|
||||||
|
|
||||||
|
// 后续空检测帧:应持续 Coast 共 max_lost_frames 帧(is_predicted=true)
|
||||||
|
for (int i = 1; i <= 8; ++i) {
|
||||||
|
r = tracker.Update({});
|
||||||
|
ASSERT_TRUE(r.tracked) << "第 " << i << " 帧 Coast 期间不应判丢";
|
||||||
|
EXPECT_TRUE(r.is_predicted) << "第 " << i << " 帧应为纯预测输出";
|
||||||
|
EXPECT_EQ(r.lost_frames, i);
|
||||||
|
EXPECT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_TRUE(r.box.has_value()); // Coast 期间继续输出预测框
|
||||||
|
EXPECT_FALSE(r.raw.has_value()); // Coast 时 raw 为空(§2.3)
|
||||||
|
EXPECT_DOUBLE_EQ(r.confidence, 0.9); // 继承最近实测置信度(§2.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 max_lost_frames + 1 帧:轨道死亡回收 → tracked=false
|
||||||
|
r = tracker.Update({});
|
||||||
|
EXPECT_FALSE(r.tracked);
|
||||||
|
EXPECT_FALSE(r.primary_id.has_value());
|
||||||
|
EXPECT_FALSE(r.box.has_value());
|
||||||
|
EXPECT_EQ(r.lost_frames, 0); // 丢失时 lost_frames=0(§2.3)
|
||||||
|
EXPECT_EQ(r.n_active, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 验收用例 2(§9.4-2):ID 单调性
|
||||||
|
// 多次进出画面的目标获得严格递增、不复用的 ID;Reset 后计数器归零。
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, IdStrictlyIncreasingNoReuse) {
|
||||||
|
TargetTracker::Config cfg;
|
||||||
|
cfg.max_lost_frames = 1; // 缩短 Coast 窗口,加速轨道死亡
|
||||||
|
TargetTracker tracker(cfg);
|
||||||
|
|
||||||
|
// 目标 A 出现 → id 0
|
||||||
|
TrackResult r = tracker.Update({MakeDet(100.0, 100.0, 120.0, 120.0, 0.9)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
const int id_a = *r.primary_id;
|
||||||
|
EXPECT_EQ(id_a, 0);
|
||||||
|
|
||||||
|
// 目标 A 消失至死亡(max_lost=1:第 2 帧 Coast、第 3 帧死亡回收)
|
||||||
|
tracker.Update({});
|
||||||
|
r = tracker.Update({});
|
||||||
|
EXPECT_FALSE(r.tracked);
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 0);
|
||||||
|
|
||||||
|
// 目标 B 出现(同一位置):ID 不复用,严格递增 → id 1
|
||||||
|
r = tracker.Update({MakeDet(100.0, 100.0, 120.0, 120.0, 0.9)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
const int id_b = *r.primary_id;
|
||||||
|
EXPECT_EQ(id_b, 1);
|
||||||
|
EXPECT_GT(id_b, id_a);
|
||||||
|
|
||||||
|
// 目标 C 同帧新建 → id 2(同帧多目标亦严格递增)
|
||||||
|
r = tracker.Update({MakeDet(100.0, 100.0, 120.0, 120.0, 0.9),
|
||||||
|
MakeDet(400.0, 300.0, 430.0, 330.0, 0.8)});
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 2);
|
||||||
|
int max_id = 0;
|
||||||
|
for (const auto& kv : tracker.ActiveTracks()) {
|
||||||
|
max_id = std::max(max_id, kv.first);
|
||||||
|
}
|
||||||
|
EXPECT_EQ(max_id, 2);
|
||||||
|
|
||||||
|
// Reset 后计数器归零(§6.2):新轨道重新从 id 0 开始
|
||||||
|
tracker.Reset();
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 0);
|
||||||
|
EXPECT_FALSE(tracker.LastResult().tracked);
|
||||||
|
r = tracker.Update({MakeDet(50.0, 50.0, 70.0, 70.0, 0.9)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_EQ(*r.primary_id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 验收用例 3(§9.4-3):锁定不被抢占
|
||||||
|
// 锁定目标 Coast 期间出现更高置信度新目标,primary_id 不变;
|
||||||
|
// 锁定目标死亡后切换到新目标。
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, LockNotStolenAndSwitchAfterDeath) {
|
||||||
|
TargetTracker::Config cfg;
|
||||||
|
cfg.max_lost_frames = 3;
|
||||||
|
TargetTracker tracker(cfg);
|
||||||
|
|
||||||
|
// 低置信度目标 A 建立并锁定
|
||||||
|
TrackResult r = tracker.Update({MakeDet(100.0, 100.0, 140.0, 140.0, 0.4)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
const int id_a = *r.primary_id;
|
||||||
|
|
||||||
|
// A 消失进入 Coast;期间出现更高置信度新目标 B(远离 A,不会被误关联)
|
||||||
|
r = tracker.Update({MakeDet(500.0, 300.0, 540.0, 340.0, 0.95)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_EQ(*r.primary_id, id_a) << "Coast 期间锁定不应被高置信度目标抢占";
|
||||||
|
EXPECT_TRUE(r.is_predicted);
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 2); // A(Coast)与 B 并存
|
||||||
|
|
||||||
|
// 继续 Coast,锁定依旧延续
|
||||||
|
r = tracker.Update({MakeDet(500.0, 300.0, 540.0, 340.0, 0.95)});
|
||||||
|
EXPECT_EQ(*r.primary_id, id_a);
|
||||||
|
EXPECT_TRUE(r.is_predicted);
|
||||||
|
|
||||||
|
// A 继续丢失直至死亡(max_lost=3:lost 达 4 帧时回收)
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
r = tracker.Update({MakeDet(500.0, 300.0, 540.0, 340.0, 0.95)});
|
||||||
|
}
|
||||||
|
// A 已死亡删除 → 锁定解除,重新选举切换到 B(此时 B 已确认且实测)
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
EXPECT_NE(*r.primary_id, id_a) << "锁定目标死亡后应切换到新目标";
|
||||||
|
EXPECT_FALSE(r.is_predicted);
|
||||||
|
|
||||||
|
// 后续 B 持续作为主目标
|
||||||
|
r = tracker.Update({MakeDet(500.0, 300.0, 540.0, 340.0, 0.95)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_NE(*r.primary_id, id_a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 验收用例 4(§9.4-4):min_hits 回退
|
||||||
|
// 新轨道(hits 未达 min_hits)在无其他已确认实测轨道时也可当选主目标。
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, MinHitsFallbackAllowsNewTrack) {
|
||||||
|
TargetTracker::Config cfg;
|
||||||
|
cfg.min_hits = 5; // 抬高确认门槛,保证测试期间轨道始终未确认
|
||||||
|
TargetTracker tracker(cfg);
|
||||||
|
|
||||||
|
// 首帧新建轨道 hits=1 < min_hits:候选池 confirmed 为空 → 退回 measured
|
||||||
|
TrackResult r = tracker.Update({MakeDet(200.0, 150.0, 240.0, 190.0, 0.6)});
|
||||||
|
ASSERT_TRUE(r.tracked) << "无已确认轨道时新轨道应能当选主目标";
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_EQ(*r.primary_id, 0);
|
||||||
|
EXPECT_FALSE(r.is_predicted);
|
||||||
|
|
||||||
|
// 再跟几帧(hits 增长但仍未达 min_hits=5),依旧当选
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
r = tracker.Update({MakeDet(200.0, 150.0, 240.0, 190.0, 0.6)});
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
EXPECT_EQ(*r.primary_id, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 验收用例 5(§9.4-5):cost 边界
|
||||||
|
// 恰好 cost = 1.0 不构成匹配(仅严格小于才匹配)。
|
||||||
|
// 构造:w_dist=1、w_iou=0(纯距离项),中心距离恰等于 Dmax → cost=1.0;
|
||||||
|
// 同时给出略小于阈值的对照帧证明边界两侧行为正确。
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, CostExactlyOneDoesNotMatch) {
|
||||||
|
TargetTracker::Config cfg;
|
||||||
|
cfg.max_lost_frames = 1;
|
||||||
|
cfg.max_association_dist = 200.0;
|
||||||
|
cfg.dist_weight = 1.0; // 纯距离项,IoU 项权重为 0 且两框零交集
|
||||||
|
cfg.iou_weight = 0.0;
|
||||||
|
TargetTracker tracker(cfg);
|
||||||
|
|
||||||
|
// 建立轨道:中心 (100,100)
|
||||||
|
TrackResult r = tracker.Update({MakeDet(90.0, 90.0, 110.0, 110.0, 0.9)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
const int id_a = *r.primary_id;
|
||||||
|
|
||||||
|
// 下一帧检测中心 (100,300):距离 = 200 = Dmax → cost = 1.0,恰好不匹配
|
||||||
|
// (预测后轨道中心速度为 0,仍位于 (100,100);两框零交集 IoU=0 无影响)
|
||||||
|
r = tracker.Update({MakeDet(90.0, 290.0, 110.0, 310.0, 0.9)});
|
||||||
|
// 未匹配 → 原轨道 Coast、检测新建轨道;两个目标并存
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 2) << "cost=1.0 不应匹配,应新建轨道";
|
||||||
|
// 原轨道(锁定)进入 Coast:lost_frames=1
|
||||||
|
EXPECT_TRUE(r.tracked);
|
||||||
|
EXPECT_TRUE(r.is_predicted);
|
||||||
|
EXPECT_EQ(*r.primary_id, id_a);
|
||||||
|
EXPECT_EQ(r.lost_frames, 1);
|
||||||
|
|
||||||
|
// 对照:距离略小于阈值(199px → cost=0.995 < 1.0)应匹配成功
|
||||||
|
TargetTracker tracker2(cfg);
|
||||||
|
r = tracker2.Update({MakeDet(90.0, 90.0, 110.0, 110.0, 0.9)});
|
||||||
|
const int id_b = *r.primary_id;
|
||||||
|
r = tracker2.Update({MakeDet(90.0, 289.0, 110.0, 309.0, 0.9)});
|
||||||
|
EXPECT_EQ(tracker2.ActiveCount(), 1) << "cost<1.0 应匹配成功,不应新建轨道";
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_EQ(*r.primary_id, id_b);
|
||||||
|
EXPECT_FALSE(r.is_predicted); // 实测命中
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 验收用例 6(§9.4-6):置信度优先
|
||||||
|
// 两轨道竞争同一检测时,置信度高者获胜。
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, HigherConfidenceTrackWinsDetection) {
|
||||||
|
TargetTracker tracker;
|
||||||
|
|
||||||
|
// 同帧建立两条轨道:A 置信度 0.5、B 置信度 0.9,相距 80px(不互相重叠)
|
||||||
|
TrackResult r = tracker.Update({MakeDet(100.0, 100.0, 120.0, 120.0, 0.5),
|
||||||
|
MakeDet(180.0, 100.0, 200.0, 120.0, 0.9)});
|
||||||
|
ASSERT_EQ(tracker.ActiveCount(), 2);
|
||||||
|
|
||||||
|
// 找出两条轨道 ID(按置信度区分)
|
||||||
|
int id_low = -1;
|
||||||
|
int id_high = -1;
|
||||||
|
for (const auto& kv : tracker.ActiveTracks()) {
|
||||||
|
if (kv.second.confidence > 0.8) {
|
||||||
|
id_high = kv.first;
|
||||||
|
} else {
|
||||||
|
id_low = kv.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ASSERT_NE(id_low, -1);
|
||||||
|
ASSERT_NE(id_high, -1);
|
||||||
|
|
||||||
|
// 下一帧只给一个检测,放在两轨道预测中心之间(各距 40px,对称):
|
||||||
|
// 距离项对两者相同,贪婪按置信度降序 → 高置信度轨道 B 优先匹配
|
||||||
|
r = tracker.Update({MakeDet(140.0, 100.0, 160.0, 120.0, 0.7)});
|
||||||
|
|
||||||
|
const auto tracks = tracker.ActiveTracks();
|
||||||
|
ASSERT_EQ(tracks.size(), 2u);
|
||||||
|
// 获胜者:命中(lost=0、hits=2、置信度更新)
|
||||||
|
EXPECT_EQ(tracks.at(id_high).lost_count, 0) << "高置信度轨道应获胜";
|
||||||
|
EXPECT_EQ(tracks.at(id_high).hits, 2);
|
||||||
|
EXPECT_DOUBLE_EQ(tracks.at(id_high).confidence, 0.7);
|
||||||
|
// 失败者:未匹配进入 Coast(lost=1、hits 不变)
|
||||||
|
EXPECT_EQ(tracks.at(id_low).lost_count, 1) << "低置信度轨道应落选";
|
||||||
|
EXPECT_EQ(tracks.at(id_low).hits, 1);
|
||||||
|
EXPECT_DOUBLE_EQ(tracks.at(id_low).confidence, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NaN/Inf 防御(评审修复项):坐标或置信度含非有限值的检测被丢弃,
|
||||||
|
// 不崩溃、不新建轨道、不污染主目标输出
|
||||||
|
// ============================================================================
|
||||||
|
TEST(TargetTrackerAcceptanceTest, NonFiniteDetectionsAreDiscarded) {
|
||||||
|
TargetTracker tracker;
|
||||||
|
const double nan = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
const double inf = std::numeric_limits<double>::infinity();
|
||||||
|
|
||||||
|
// 第 1 帧:建立正常轨道
|
||||||
|
TrackResult r = tracker.Update({MakeDet(100.0, 100.0, 140.0, 140.0, 0.9)});
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
|
||||||
|
// 第 2 帧:混入 NaN 坐标 / Inf 坐标 / NaN 置信度检测与一个正常检测:
|
||||||
|
// 非有限检测全部被丢弃,仅正常检测参与关联,不新建轨道
|
||||||
|
r = tracker.Update({MakeDet(nan, 100.0, 140.0, 140.0, 0.9),
|
||||||
|
MakeDet(100.0, -inf, 140.0, 140.0, 0.9),
|
||||||
|
MakeDet(100.0, 100.0, nan, 140.0, 0.9),
|
||||||
|
MakeDet(102.0, 102.0, 142.0, 142.0, nan),
|
||||||
|
MakeDet(101.0, 101.0, 141.0, 141.0, 0.9)});
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 1) << "非有限检测不应新建轨道";
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
EXPECT_FALSE(r.is_predicted); // 正常检测命中,实测输出
|
||||||
|
// 主目标输出未被 NaN 污染
|
||||||
|
EXPECT_TRUE(std::isfinite(r.center.first));
|
||||||
|
EXPECT_TRUE(std::isfinite(r.center.second));
|
||||||
|
ASSERT_TRUE(r.box.has_value());
|
||||||
|
EXPECT_TRUE(std::isfinite(r.box->x1) && std::isfinite(r.box->y1));
|
||||||
|
EXPECT_TRUE(std::isfinite(r.box->x2) && std::isfinite(r.box->y2));
|
||||||
|
EXPECT_TRUE(std::isfinite(r.confidence));
|
||||||
|
ASSERT_TRUE(r.raw.has_value()); // 实测帧 raw 有值
|
||||||
|
|
||||||
|
// 第 3 帧:仅含 NaN 检测 → 等价空帧,轨道 Coast 不崩溃
|
||||||
|
r = tracker.Update({MakeDet(nan, nan, nan, nan, nan)});
|
||||||
|
EXPECT_TRUE(r.tracked);
|
||||||
|
EXPECT_TRUE(r.is_predicted);
|
||||||
|
EXPECT_EQ(r.lost_frames, 1);
|
||||||
|
EXPECT_FALSE(r.raw.has_value()); // Coast 时 raw 为空(§2.3)
|
||||||
|
EXPECT_TRUE(std::isfinite(r.center.first));
|
||||||
|
|
||||||
|
// 持续 NaN 检测直至轨道死亡回收,全程不崩溃
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
r = tracker.Update({MakeDet(nan, 0.0, 10.0, 10.0, 0.9)});
|
||||||
|
}
|
||||||
|
EXPECT_FALSE(r.tracked);
|
||||||
|
EXPECT_EQ(tracker.ActiveCount(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 补充行为测试(§2.3 / §6.3 / §9.5)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 丢失时返回值约定:tracked=false、定位字段空、lost_frames=0(§2.3)
|
||||||
|
TEST(TargetTrackerBehaviorTest, LostResultFieldsAreEmpty) {
|
||||||
|
TargetTracker tracker;
|
||||||
|
TrackResult r = tracker.Update({}); // 空帧序列起点:无任何轨道
|
||||||
|
EXPECT_FALSE(r.tracked);
|
||||||
|
EXPECT_FALSE(r.primary_id.has_value());
|
||||||
|
EXPECT_FALSE(r.box.has_value());
|
||||||
|
EXPECT_FALSE(r.raw.has_value());
|
||||||
|
EXPECT_EQ(r.lost_frames, 0);
|
||||||
|
EXPECT_EQ(r.n_active, 0);
|
||||||
|
EXPECT_DOUBLE_EQ(r.confidence, 0.0);
|
||||||
|
|
||||||
|
// LastResult 返回同一快照(§9.5 跨线程读取入口)
|
||||||
|
const TrackResult cached = tracker.LastResult();
|
||||||
|
EXPECT_FALSE(cached.tracked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coast 兜底选举:无实测轨道时选离画面中心最近的 Coast 轨道(§6.3)。
|
||||||
|
// 注意锁定延续优先:需先让锁定轨道死亡解除锁定,才会进入重新选举分支,
|
||||||
|
// 故采用错帧创建 + 缩短 max_lost 的方式构造场景。
|
||||||
|
TEST(TargetTrackerBehaviorTest, CoastingFallbackChoosesNearestToFrameCenter) {
|
||||||
|
TargetTracker::Config cfg;
|
||||||
|
cfg.max_lost_frames = 2;
|
||||||
|
TargetTracker tracker(cfg);
|
||||||
|
|
||||||
|
// F1:角落目标(高置信度)先建立并被锁定
|
||||||
|
tracker.Update({MakeDet(10.0, 10.0, 30.0, 30.0, 0.95)});
|
||||||
|
// F2:画面中心附近目标建立;角落目标丢失(1)
|
||||||
|
tracker.Update({MakeDet(300.0, 220.0, 340.0, 260.0, 0.9)});
|
||||||
|
// F3:远处目标建立;角落丢失(2)、中心目标丢失(1)
|
||||||
|
tracker.Update({MakeDet(600.0, 450.0, 620.0, 470.0, 0.85)});
|
||||||
|
// F4:全部丢失:角落丢失(3) > max_lost 死亡解除锁定;
|
||||||
|
// 其余两条 Coast 存活 → 重新选举选离画面中心最近者
|
||||||
|
TrackResult r = tracker.Update({}, FrameShape{480, 640});
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
EXPECT_TRUE(r.is_predicted);
|
||||||
|
EXPECT_NEAR(r.center.first, 320.0, 25.0); // 靠近画面中心 (320,240) 者当选
|
||||||
|
EXPECT_NEAR(r.center.second, 240.0, 25.0);
|
||||||
|
EXPECT_EQ(r.n_active, 2); // 角落轨道已回收
|
||||||
|
}
|
||||||
|
|
||||||
|
// 锁定延续优先于置信度选举:锁定轨道实测时即使置信度较低也保持主目标(§6.3 ①)
|
||||||
|
TEST(TargetTrackerBehaviorTest, LockedPrimaryKeptWhileMeasured) {
|
||||||
|
TargetTracker tracker;
|
||||||
|
TrackResult r = tracker.Update({MakeDet(100.0, 100.0, 120.0, 120.0, 0.4)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
const int locked_id = *r.primary_id;
|
||||||
|
|
||||||
|
// 高置信度新目标出现并持续,锁定目标也持续实测 → primary_id 不变
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
r = tracker.Update({MakeDet(100.0 + i, 100.0, 120.0 + i, 120.0, 0.4),
|
||||||
|
MakeDet(400.0, 300.0, 430.0, 330.0, 0.95)});
|
||||||
|
ASSERT_TRUE(r.primary_id.has_value());
|
||||||
|
EXPECT_EQ(*r.primary_id, locked_id);
|
||||||
|
EXPECT_FALSE(r.is_predicted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frame_shape 缺省按 640×480 画面中心 (320,240) 计算(§8.2 / §2.2):
|
||||||
|
// 两条 Coast 轨道分别位于默认中心两侧,当选者应为更近的一侧
|
||||||
|
TEST(TargetTrackerBehaviorTest, DefaultFrameCenterIs320x240) {
|
||||||
|
TargetTracker::Config cfg;
|
||||||
|
cfg.max_lost_frames = 2;
|
||||||
|
TargetTracker tracker(cfg);
|
||||||
|
|
||||||
|
// F1:角落目标先建立并被锁定(高置信度)
|
||||||
|
tracker.Update({MakeDet(0.0, 0.0, 20.0, 20.0, 0.95)});
|
||||||
|
// F2:左侧目标(中心 (280,240),距默认中心 40px)建立
|
||||||
|
tracker.Update({MakeDet(270.0, 230.0, 290.0, 250.0, 0.9)});
|
||||||
|
// F3:右侧目标(中心 (500,240),距默认中心 180px)建立
|
||||||
|
tracker.Update({MakeDet(490.0, 230.0, 510.0, 250.0, 0.85)});
|
||||||
|
// F4:全部丢失:锁定轨道死亡解除锁定,两条 Coast 轨道兜底选举
|
||||||
|
// (不传 frame_shape → 按默认画面中心 (320,240);两候选间距足够大,
|
||||||
|
// 速度估计漂移不影响最近者判定)
|
||||||
|
TrackResult r = tracker.Update({});
|
||||||
|
ASSERT_TRUE(r.tracked);
|
||||||
|
EXPECT_TRUE(r.is_predicted);
|
||||||
|
EXPECT_NEAR(r.center.first, 280.0, 30.0); // 左侧更近,当选
|
||||||
|
EXPECT_NEAR(r.center.second, 240.0, 30.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// tracklet_test.cpp —— Tracklet 与几何工具单元测试
|
||||||
|
//
|
||||||
|
// 覆盖 docs/视觉追踪技术路线.md:
|
||||||
|
// - IoU 边界(空框 / 零交集 / 完全重合=1,§5.2)
|
||||||
|
// - IsDead / IsCoasting 判定(§3.2 派生状态,§6.1 严格大于)
|
||||||
|
// - Predict / Update / MarkMiss 计数语义(§7 末尾伪代码)
|
||||||
|
// - BoxXYXY ↔ BoxCXCYWH 互转(§7)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "target_tracker/tracklet.h"
|
||||||
|
|
||||||
|
using drone::tracker::BoxCXCYWH;
|
||||||
|
using drone::tracker::BoxXYXY;
|
||||||
|
using drone::tracker::ComputeIoU;
|
||||||
|
using drone::tracker::ToCenterSize;
|
||||||
|
using drone::tracker::ToXYXY;
|
||||||
|
using drone::tracker::Tracklet;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ---- 框表示互转(§7) ----
|
||||||
|
|
||||||
|
TEST(BoxConversionTest, RoundTripPreservesValues) {
|
||||||
|
const BoxXYXY box{10.0, 20.0, 50.0, 80.0};
|
||||||
|
const BoxCXCYWH c = ToCenterSize(box);
|
||||||
|
EXPECT_DOUBLE_EQ(c.cx, 30.0); // (10+50)/2
|
||||||
|
EXPECT_DOUBLE_EQ(c.cy, 50.0); // (20+80)/2
|
||||||
|
EXPECT_DOUBLE_EQ(c.w, 40.0); // 50−10
|
||||||
|
EXPECT_DOUBLE_EQ(c.h, 60.0); // 80−20
|
||||||
|
|
||||||
|
const BoxXYXY back = ToXYXY(c);
|
||||||
|
EXPECT_DOUBLE_EQ(back.x1, box.x1);
|
||||||
|
EXPECT_DOUBLE_EQ(back.y1, box.y1);
|
||||||
|
EXPECT_DOUBLE_EQ(back.x2, box.x2);
|
||||||
|
EXPECT_DOUBLE_EQ(back.y2, box.y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IoU 边界(§5.2) ----
|
||||||
|
|
||||||
|
TEST(IoUTest, IdenticalBoxesReturnOne) {
|
||||||
|
const BoxXYXY a{10.0, 10.0, 30.0, 30.0};
|
||||||
|
EXPECT_NEAR(ComputeIoU(a, a), 1.0, 1e-12); // 完全重合 = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IoUTest, DisjointBoxesReturnZero) {
|
||||||
|
const BoxXYXY a{0.0, 0.0, 10.0, 10.0};
|
||||||
|
const BoxXYXY b{20.0, 20.0, 30.0, 30.0};
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(a, b), 0.0); // 零交集
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IoUTest, TouchingEdgesReturnZero) {
|
||||||
|
// 仅边/角相接:交集宽高为 0 → 交集面积 ≤ 0 返回 0(§5.2)
|
||||||
|
const BoxXYXY a{0.0, 0.0, 10.0, 10.0};
|
||||||
|
const BoxXYXY b{10.0, 0.0, 20.0, 10.0}; // 右边缘相接
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(a, b), 0.0);
|
||||||
|
const BoxXYXY c{10.0, 10.0, 20.0, 20.0}; // 角点相接
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(a, c), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IoUTest, EmptyBoxReturnsZero) {
|
||||||
|
const BoxXYXY normal{0.0, 0.0, 10.0, 10.0};
|
||||||
|
const BoxXYXY zero_area{5.0, 5.0, 5.0, 5.0}; // 零面积
|
||||||
|
const BoxXYXY inverted{10.0, 10.0, 0.0, 0.0}; // 反向(负宽高)
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(normal, zero_area), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(zero_area, normal), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(normal, inverted), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(ComputeIoU(zero_area, zero_area), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IoUTest, PartialOverlapIsCorrect) {
|
||||||
|
// A = [0,0,10,10](面积 100),B = [5,5,15,15](面积 100),交集 25
|
||||||
|
const BoxXYXY a{0.0, 0.0, 10.0, 10.0};
|
||||||
|
const BoxXYXY b{5.0, 5.0, 15.0, 15.0};
|
||||||
|
EXPECT_NEAR(ComputeIoU(a, b), 25.0 / 175.0, 1e-12);
|
||||||
|
EXPECT_NEAR(ComputeIoU(b, a), 25.0 / 175.0, 1e-12); // 对称性
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 构造与派生状态(§3.2 / §6.1) ----
|
||||||
|
|
||||||
|
TEST(TrackletTest, ConstructionInitialFields) {
|
||||||
|
const BoxCXCYWH meas{100.0, 100.0, 20.0, 20.0};
|
||||||
|
Tracklet t(7, meas, 0.9, 1, 8);
|
||||||
|
|
||||||
|
EXPECT_EQ(t.track_id, 7);
|
||||||
|
EXPECT_EQ(t.lost_count, 0);
|
||||||
|
EXPECT_EQ(t.max_lost, 8);
|
||||||
|
EXPECT_DOUBLE_EQ(t.confidence, 0.9);
|
||||||
|
EXPECT_EQ(t.cls_id, 1);
|
||||||
|
EXPECT_EQ(t.age, 1); // 创建时为 1(§3.2)
|
||||||
|
EXPECT_EQ(t.hits, 1); // 创建时为 1(§3.2)
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_center.first, 100.0);
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_center.second, 100.0);
|
||||||
|
ASSERT_TRUE(t.last_box.has_value()); // 创建当帧即赋 last_box(§7 步骤 5)
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_box->x1, 90.0);
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_box->y1, 90.0);
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_box->x2, 110.0);
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_box->y2, 110.0);
|
||||||
|
EXPECT_FALSE(t.IsDead());
|
||||||
|
EXPECT_FALSE(t.IsCoasting());
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDead 使用严格大于:lost_count == max_lost 仍存活(§6.1 注记 2)
|
||||||
|
TEST(TrackletTest, IsDeadUsesStrictlyGreater) {
|
||||||
|
Tracklet t(0, {100.0, 100.0, 20.0, 20.0}, 0.9, 0, 8);
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
t.MarkMiss();
|
||||||
|
EXPECT_FALSE(t.IsDead()) << "lost_count=" << t.lost_count
|
||||||
|
<< " 未超过 max_lost 不应判死";
|
||||||
|
}
|
||||||
|
EXPECT_EQ(t.lost_count, 8);
|
||||||
|
t.MarkMiss(); // lost_count = 9 > 8
|
||||||
|
EXPECT_TRUE(t.IsDead());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackletTest, IsCoastingWhenLostPositive) {
|
||||||
|
Tracklet t(0, {100.0, 100.0, 20.0, 20.0}, 0.9, 0, 8);
|
||||||
|
EXPECT_FALSE(t.IsCoasting());
|
||||||
|
t.MarkMiss();
|
||||||
|
EXPECT_TRUE(t.IsCoasting()); // lost_count > 0 即 Coast(§3.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 生命周期方法计数语义(§7) ----
|
||||||
|
|
||||||
|
TEST(TrackletTest, PredictAdvancesAgeAndUpdatesLastState) {
|
||||||
|
Tracklet t(0, {100.0, 100.0, 20.0, 20.0}, 0.9, 0, 8);
|
||||||
|
const int age_before = t.age;
|
||||||
|
t.Predict(); // 速度为零:位置不变,但 age+1、last_box 更新为预测框
|
||||||
|
EXPECT_EQ(t.age, age_before + 1);
|
||||||
|
ASSERT_TRUE(t.last_box.has_value());
|
||||||
|
EXPECT_NEAR(t.last_center.first, 100.0, 1e-9);
|
||||||
|
EXPECT_NEAR(t.last_center.second, 100.0, 1e-9);
|
||||||
|
// hits / lost_count / confidence 不受预测影响
|
||||||
|
EXPECT_EQ(t.hits, 1);
|
||||||
|
EXPECT_EQ(t.lost_count, 0);
|
||||||
|
EXPECT_DOUBLE_EQ(t.confidence, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackletTest, UpdateResetsLostAndIncrementsHits) {
|
||||||
|
Tracklet t(0, {100.0, 100.0, 20.0, 20.0}, 0.9, 0, 8);
|
||||||
|
t.MarkMiss();
|
||||||
|
t.MarkMiss();
|
||||||
|
ASSERT_EQ(t.lost_count, 2);
|
||||||
|
ASSERT_TRUE(t.IsCoasting());
|
||||||
|
|
||||||
|
const int age_before = t.age;
|
||||||
|
const BoxXYXY box{150.0, 160.0, 170.0, 180.0};
|
||||||
|
t.Update(box, 0.75, 2);
|
||||||
|
|
||||||
|
EXPECT_EQ(t.lost_count, 0); // 匹配成功清零(§7)
|
||||||
|
EXPECT_EQ(t.hits, 2); // hits+1
|
||||||
|
EXPECT_EQ(t.age, age_before + 1); // age+1
|
||||||
|
EXPECT_DOUBLE_EQ(t.confidence, 0.75);
|
||||||
|
EXPECT_EQ(t.cls_id, 2);
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_center.first, 160.0); // (150+170)/2
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_center.second, 170.0); // (160+180)/2
|
||||||
|
ASSERT_TRUE(t.last_box.has_value());
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_box->x1, box.x1);
|
||||||
|
ASSERT_TRUE(t.last_raw_box.has_value()); // 实测框同步记录
|
||||||
|
EXPECT_DOUBLE_EQ(t.last_raw_box->x2, box.x2);
|
||||||
|
EXPECT_FALSE(t.IsCoasting());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackletTest, MarkMissIncrementsLostAndAge) {
|
||||||
|
Tracklet t(0, {100.0, 100.0, 20.0, 20.0}, 0.9, 0, 8);
|
||||||
|
const int age_before = t.age;
|
||||||
|
t.MarkMiss();
|
||||||
|
EXPECT_EQ(t.lost_count, 1);
|
||||||
|
EXPECT_EQ(t.age, age_before + 1);
|
||||||
|
// MarkMiss 不影响 hits / confidence
|
||||||
|
EXPECT_EQ(t.hits, 1);
|
||||||
|
EXPECT_DOUBLE_EQ(t.confidence, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# visual_track_control —— 视觉跟踪控制律静态库(独立子工程)
|
||||||
|
#
|
||||||
|
# 实现 docs/物理追踪思路.md 的水平航向控制(§4)、垂直/高度双支路控制(§5)、
|
||||||
|
# 距离 PID(§6)、全量限幅(§8)、Coast 清积分与判丢刹车(§6.4)、
|
||||||
|
# 超龄降级(§7.1、§9.2)。
|
||||||
|
#
|
||||||
|
# 构建约束:
|
||||||
|
# - 与主工程完全解耦:根 CMakeLists.txt 零改动,可独立构建;
|
||||||
|
# - 仅依赖 third_party:nlohmann(单头文件)、spdlog(源码)、
|
||||||
|
# c_library_v2-master(MAVLink 纯头文件);
|
||||||
|
# - target_tracker 为 SHARED 库且零源码修改,原样 add_subdirectory 复用,
|
||||||
|
# TrackResult 出现在本库公共头(vtc_types.h),故以 PUBLIC 链接;
|
||||||
|
# - MAVLink include 目录设为 SYSTEM PRIVATE:压告警噪音且不泄漏给下游,
|
||||||
|
# 全工程仅 src/mavlink_command_gateway.cpp 一个 TU 包含 mavlink.h
|
||||||
|
# (MAVLINK_HELPER 默认空宏,多 TU 包含会多重定义)。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
project(visual_track_control LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
# target_tracker 是 SHARED 库:BUILD_RPATH 指向构建目录,示例无需
|
||||||
|
# LD_LIBRARY_PATH 即可直接运行(部署时需随带 libtarget_tracker.so.1)。
|
||||||
|
set(CMAKE_BUILD_RPATH "${CMAKE_BINARY_DIR};${CMAKE_BINARY_DIR}/target_tracker;$ORIGIN")
|
||||||
|
|
||||||
|
# ---- 依赖:target_tracker(零改动复用;其测试块由 -DBUILD_TESTING=OFF 压制) ----
|
||||||
|
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../target_tracker
|
||||||
|
${CMAKE_BINARY_DIR}/target_tracker)
|
||||||
|
|
||||||
|
# ---- 依赖:spdlog(守卫引入,避免挂载进根构建时重复定义;关闭示例/测试/动态库/安装) ----
|
||||||
|
if(NOT TARGET spdlog::spdlog)
|
||||||
|
set(SPDLOG_BUILD_EXAMPLE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(SPDLOG_BUILD_EXAMPLE_HO OFF CACHE BOOL "" FORCE)
|
||||||
|
set(SPDLOG_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(SPDLOG_BUILD_TESTS_HO OFF CACHE BOOL "" FORCE)
|
||||||
|
set(SPDLOG_BUILD_SHARED OFF CACHE BOOL "" FORCE)
|
||||||
|
set(SPDLOG_INSTALL OFF CACHE BOOL "" FORCE)
|
||||||
|
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../third_party/spdlog
|
||||||
|
${CMAKE_BINARY_DIR}/spdlog)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ---- 静态库本体 ----
|
||||||
|
add_library(visual_track_control STATIC
|
||||||
|
src/vtc_config.cpp
|
||||||
|
src/pid_controller.cpp
|
||||||
|
src/line_of_sight.cpp
|
||||||
|
src/tracking_control_law.cpp
|
||||||
|
src/mavlink_command_gateway.cpp)
|
||||||
|
|
||||||
|
target_include_directories(visual_track_control PUBLIC
|
||||||
|
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>)
|
||||||
|
|
||||||
|
# nlohmann JSON:单头文件,仅配置加载器(启动期)使用,不进入热路径
|
||||||
|
target_include_directories(visual_track_control PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../third_party/nlohmann)
|
||||||
|
|
||||||
|
# MAVLink 纯头文件库:SYSTEM 压告警、PRIVATE 不泄漏(公共头无任何 mavlink_* 类型)
|
||||||
|
target_include_directories(visual_track_control SYSTEM PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../third_party/c_library_v2-master)
|
||||||
|
|
||||||
|
target_link_libraries(visual_track_control
|
||||||
|
PUBLIC target_tracker
|
||||||
|
PRIVATE spdlog::spdlog)
|
||||||
|
target_compile_features(visual_track_control PUBLIC cxx_std_17)
|
||||||
|
|
||||||
|
# 编译告警:与主工程一致
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(visual_track_control PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(visual_track_control PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ---- 示例可执行文件(合成检测 → 追踪 → 控制律 → MAVLink 字节流打印) ----
|
||||||
|
add_executable(track_control_demo examples/track_control_demo.cpp)
|
||||||
|
target_link_libraries(track_control_demo PRIVATE visual_track_control spdlog::spdlog)
|
||||||
|
target_compile_definitions(track_control_demo PRIVATE
|
||||||
|
VTC_DEMO_DEFAULT_CONFIG="${CMAKE_CURRENT_SOURCE_DIR}/config/track_control.json")
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(track_control_demo PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(track_control_demo PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ---- 单元测试(Google Test) ----
|
||||||
|
# 独立选项:构建本子工程时需 -DBUILD_TESTING=OFF 压制 target_tracker 的测试,
|
||||||
|
# 故本库测试开关不复用 BUILD_TESTING,二者互不干扰。
|
||||||
|
# 开发机未安装 GTest 时自动跳过(不影响库与示例构建)。
|
||||||
|
option(VTC_BUILD_TESTS "构建 visual_track_control 单元测试" ON)
|
||||||
|
if(VTC_BUILD_TESTS)
|
||||||
|
find_package(GTest QUIET)
|
||||||
|
if(GTest_FOUND)
|
||||||
|
enable_testing()
|
||||||
|
include(GoogleTest)
|
||||||
|
add_executable(vtc_unit_tests
|
||||||
|
tests/rate_limiter_test.cpp
|
||||||
|
tests/staleness_checker_test.cpp
|
||||||
|
tests/frame_transform_test.cpp
|
||||||
|
tests/line_of_sight_test.cpp
|
||||||
|
tests/pid_controller_test.cpp
|
||||||
|
tests/tracking_control_law_test.cpp
|
||||||
|
tests/vtc_config_test.cpp
|
||||||
|
tests/mavlink_command_gateway_test.cpp)
|
||||||
|
target_link_libraries(vtc_unit_tests PRIVATE
|
||||||
|
visual_track_control GTest::gtest GTest::gtest_main)
|
||||||
|
target_compile_definitions(vtc_unit_tests PRIVATE
|
||||||
|
VTC_TEST_CONFIG="${CMAKE_CURRENT_SOURCE_DIR}/config/track_control.json"
|
||||||
|
VTC_TEST_TMP_DIR="${CMAKE_CURRENT_BINARY_DIR}")
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(vtc_unit_tests PRIVATE /W4 /permissive-)
|
||||||
|
else()
|
||||||
|
target_compile_options(vtc_unit_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
gtest_discover_tests(vtc_unit_tests)
|
||||||
|
else()
|
||||||
|
message(STATUS "未找到 GTest:跳过 vtc_unit_tests 单元测试")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"_comment": "视觉跟踪控制律配置。全部数值为占位示例(对应 docs/物理追踪思路.md 第 8 章待定项),键名后缀标注单位;标定完成前仅限回放验证,不得用于实飞。",
|
||||||
|
|
||||||
|
"control": {
|
||||||
|
"frequency_hz": 20.0,
|
||||||
|
"visual_stale_ms": 200,
|
||||||
|
"radar_stale_ms": 500,
|
||||||
|
"attitude_stale_ms": 300
|
||||||
|
},
|
||||||
|
|
||||||
|
"camera": {
|
||||||
|
"image_width": 1920,
|
||||||
|
"image_height": 1080,
|
||||||
|
"fov_h_deg": 60.0,
|
||||||
|
"fov_v_deg": 35.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"heading": {
|
||||||
|
"mode": "rate",
|
||||||
|
"gain": 1.0,
|
||||||
|
"yaw_rate_limit_dps": 30.0,
|
||||||
|
"yaw_slew_limit_dps2": 60.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"vertical": {
|
||||||
|
"vz_gain_mps_per_deg": 0.1,
|
||||||
|
"vz_limit_mps": 2.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"distance": {
|
||||||
|
"d_exp_m": 10.0,
|
||||||
|
"kp": 0.5,
|
||||||
|
"ki": 0.05,
|
||||||
|
"kd": 0.1,
|
||||||
|
"integral_limit": 2.0,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
|
||||||
|
"accel_limit": {
|
||||||
|
"ax_mps2": 2.0,
|
||||||
|
"ay_mps2": 2.0,
|
||||||
|
"az_mps2": 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// track_control_demo.cpp —— 视觉跟踪控制律演示程序
|
||||||
|
//
|
||||||
|
// 数据流(模拟单线程整合的完整链路):
|
||||||
|
// 合成运动检测框 → TargetTracker::Update()(模拟推理线程每帧调用)
|
||||||
|
// → LastResult() + 打戳 → ControlSnapshot → TrackingControlLaw::Update()
|
||||||
|
// → MavlinkCommandGateway → LoggingTransport(字节数 + 十六进制摘要)
|
||||||
|
//
|
||||||
|
// 依次演示三种分支(对应物理追踪思路 §6.4):
|
||||||
|
// 1. 正常跟踪:持续喂入合成检测框,输出速度航向意图(垂直/距离通道
|
||||||
|
// 统一为速度分量);
|
||||||
|
// 2. Coast:停止喂检测 → is_predicted==true → 清积分,继续预测跟踪;
|
||||||
|
// 3. 判丢刹车:连续丢帧超过追踪库阈值 → tracked==false → 零速刹车。
|
||||||
|
//
|
||||||
|
// 运行方式:
|
||||||
|
// track_control_demo [配置文件路径] # 缺省使用编译期内置路径
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
#include <target_tracker/target_tracker.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/i_command_transport.h"
|
||||||
|
#include "visual_track_control/mavlink_command_gateway.h"
|
||||||
|
#include "visual_track_control/tracking_control_law.h"
|
||||||
|
#include "visual_track_control/vtc_config.h"
|
||||||
|
#include "visual_track_control/vtc_types.h"
|
||||||
|
|
||||||
|
#ifndef VTC_DEMO_DEFAULT_CONFIG
|
||||||
|
#define VTC_DEMO_DEFAULT_CONFIG "config/track_control.json"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ---- 演示时长(秒):总时长有限,自动退出 ----
|
||||||
|
constexpr int kNormalPhaseSeconds = 6; // 阶段1:正常跟踪
|
||||||
|
constexpr int kCoastPhaseSeconds = 4; // 阶段2:Coast(停止喂检测)
|
||||||
|
constexpr int kLostPhaseSeconds = 3; // 阶段3:判丢刹车
|
||||||
|
|
||||||
|
// 追踪库判丢阈值放大:默认 8 帧在 20Hz 下 Coast 仅 0.4 秒,不便于观察;
|
||||||
|
// 演示专用放宽到 60 帧(约 3 秒),正式参数以追踪库规格为准。
|
||||||
|
constexpr int kDemoMaxLostFrames = 60;
|
||||||
|
|
||||||
|
const char* ModeName(drone::vtc::ControlMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case drone::vtc::ControlMode::kVelocityHeading: return "速度航向";
|
||||||
|
case drone::vtc::ControlMode::kBrakeHover: return "零速刹车";
|
||||||
|
case drone::vtc::ControlMode::kDegradedHold: return "降级保持";
|
||||||
|
}
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 演示用传输实现:统计字节数并打印十六进制摘要(按秒级节流,防止刷屏)
|
||||||
|
class LoggingTransport : public drone::vtc::ICommandTransport {
|
||||||
|
public:
|
||||||
|
bool Send(const std::uint8_t* data, std::size_t len) override {
|
||||||
|
++msg_count_;
|
||||||
|
total_bytes_ += len;
|
||||||
|
|
||||||
|
const auto now_ms =
|
||||||
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
if (now_ms - last_print_ms_ >= 1000) {
|
||||||
|
// 十六进制摘要:取包头前 12 字节
|
||||||
|
std::string hex;
|
||||||
|
char buf[4];
|
||||||
|
const std::size_t n = len < 12 ? len : 12;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%02X ", data[i]);
|
||||||
|
hex += buf;
|
||||||
|
}
|
||||||
|
spdlog::info("[Transport] 累计 {} 帧 / {} 字节; 本帧 {} 字节, 包头: {}",
|
||||||
|
msg_count_, total_bytes_, len, hex);
|
||||||
|
last_print_ms_ = now_ms;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::int64_t last_print_ms_ = 0;
|
||||||
|
std::uint64_t msg_count_ = 0;
|
||||||
|
std::uint64_t total_bytes_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 合成运动检测框:从画面右下向中心缓慢漂移(模拟目标被持续锁定)
|
||||||
|
std::vector<drone::tracker::Detection> SynthesizeDetections(double elapsed_s,
|
||||||
|
int width, int height) {
|
||||||
|
const double center_x = width * 0.5 + 160.0 - 20.0 * elapsed_s;
|
||||||
|
const double center_y = height * 0.5 + 80.0 - 10.0 * elapsed_s;
|
||||||
|
const double half_w = 70.0;
|
||||||
|
const double half_h = 50.0;
|
||||||
|
|
||||||
|
drone::tracker::Detection det;
|
||||||
|
det.x1 = center_x - half_w;
|
||||||
|
det.y1 = center_y - half_h;
|
||||||
|
det.x2 = center_x + half_w;
|
||||||
|
det.y2 = center_y + half_h;
|
||||||
|
det.conf = 0.9;
|
||||||
|
det.cls_id = 0;
|
||||||
|
return {det};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
// ---- 启动期:一次性加载配置(缺字段/非法值直接抛异常终止) ----
|
||||||
|
const std::string config_path = argc > 1 ? argv[1] : VTC_DEMO_DEFAULT_CONFIG;
|
||||||
|
drone::vtc::TrackingConfig cfg;
|
||||||
|
try {
|
||||||
|
cfg = drone::vtc::TrackingConfig::LoadFromJson(config_path);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
spdlog::error("配置加载失败: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
spdlog::info("配置加载成功: {} (控制频率 {} Hz)", config_path,
|
||||||
|
cfg.control.frequency_hz);
|
||||||
|
|
||||||
|
// ---- 构造:追踪库 / 控制律 / 传输 / 网关 ----
|
||||||
|
drone::tracker::TargetTracker::Config tracker_cfg;
|
||||||
|
tracker_cfg.max_lost_frames = kDemoMaxLostFrames; // 演示专用判丢阈值
|
||||||
|
drone::tracker::TargetTracker tracker(tracker_cfg);
|
||||||
|
|
||||||
|
drone::vtc::TrackingControlLaw control_law(cfg);
|
||||||
|
LoggingTransport transport;
|
||||||
|
drone::vtc::MavlinkCommandGateway gateway(transport, /*sys=*/1, /*comp=*/1,
|
||||||
|
/*target_sys=*/1, /*target_comp=*/1);
|
||||||
|
|
||||||
|
const int width = cfg.camera.image_width;
|
||||||
|
const int height = cfg.camera.image_height;
|
||||||
|
const int period_ms = static_cast<int>(1000.0 / cfg.control.frequency_hz);
|
||||||
|
const int normal_ticks = kNormalPhaseSeconds * static_cast<int>(cfg.control.frequency_hz);
|
||||||
|
const int coast_ticks = kCoastPhaseSeconds * static_cast<int>(cfg.control.frequency_hz);
|
||||||
|
const int lost_ticks = kLostPhaseSeconds * static_cast<int>(cfg.control.frequency_hz);
|
||||||
|
const int total_ticks = normal_ticks + coast_ticks + lost_ticks;
|
||||||
|
|
||||||
|
spdlog::info("演示开始: 正常跟踪 {}s → Coast {}s → 判丢刹车 {}s (共 {} 拍, {} ms/拍)",
|
||||||
|
kNormalPhaseSeconds, kCoastPhaseSeconds, kLostPhaseSeconds,
|
||||||
|
total_ticks, period_ms);
|
||||||
|
|
||||||
|
drone::tracker::FrameShape frame_shape;
|
||||||
|
frame_shape.height = height;
|
||||||
|
frame_shape.width = width;
|
||||||
|
|
||||||
|
const char* current_phase = nullptr;
|
||||||
|
|
||||||
|
// ---- 主循环:按配置频率固定节拍(§7.1) ----
|
||||||
|
for (int tick = 0; tick < total_ticks; ++tick) {
|
||||||
|
const double elapsed_s = static_cast<double>(tick) / cfg.control.frequency_hz;
|
||||||
|
// 模拟单调时钟:1 秒偏移保证取样时间戳有效
|
||||||
|
const std::int64_t now_ms = 1000 + static_cast<std::int64_t>(tick) * period_ms;
|
||||||
|
|
||||||
|
// ---- 阶段划分 ----
|
||||||
|
const char* phase = tick < normal_ticks ? "正常跟踪"
|
||||||
|
: tick < normal_ticks + coast_ticks ? "Coast"
|
||||||
|
: "判丢刹车";
|
||||||
|
if (phase != current_phase) {
|
||||||
|
spdlog::info("===== 阶段切换: {} =====", phase);
|
||||||
|
current_phase = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模拟推理线程:仅正常跟踪阶段喂入检测框 ----
|
||||||
|
// 阶段2/3 停止喂检测:先 Coast(is_predicted),后判丢(tracked==false)
|
||||||
|
std::vector<drone::tracker::Detection> detections;
|
||||||
|
if (tick < normal_ticks) {
|
||||||
|
detections = SynthesizeDetections(elapsed_s, width, height);
|
||||||
|
}
|
||||||
|
tracker.Update(detections, frame_shape);
|
||||||
|
|
||||||
|
// ---- 控制线程:LastResult() + 打戳(TrackResult 无时间戳,§7.2) ----
|
||||||
|
drone::vtc::ControlSnapshot snapshot;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 自机姿态:演示中始终新鲜有效(接入主工程时来自 FlightStateSnapshot)
|
||||||
|
snapshot.attitude_present = true;
|
||||||
|
snapshot.attitude_time_ms = now_ms;
|
||||||
|
|
||||||
|
// ---- 控制律单周期计算 ----
|
||||||
|
const drone::vtc::ControlOutput output = control_law.Update(snapshot, now_ms);
|
||||||
|
|
||||||
|
// ---- 按模式经网关发送 ----
|
||||||
|
const auto time_boot_ms = static_cast<std::uint32_t>(now_ms);
|
||||||
|
switch (output.mode) {
|
||||||
|
case drone::vtc::ControlMode::kVelocityHeading:
|
||||||
|
gateway.SendVelocityHeading(output, time_boot_ms);
|
||||||
|
break;
|
||||||
|
case drone::vtc::ControlMode::kBrakeHover:
|
||||||
|
gateway.SendBrakeHover(time_boot_ms);
|
||||||
|
break;
|
||||||
|
case drone::vtc::ControlMode::kDegradedHold:
|
||||||
|
break; // 降级保持:不发送指令(仅告警,§7.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 半秒一次的摘要打印(演示观测用,非生产热路径) ----
|
||||||
|
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 关联={}",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(period_ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
control_law.Reset(); // 停止视觉闭环:清全部状态(§6.4)
|
||||||
|
spdlog::info("演示结束");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// frame_transform.h —— 机体系(前/右/下)→ 局部 NED(北/东/地)坐标旋转
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §2.3 控制律内部计算统一在机体坐标系;输出的速度意图若采用局部系,
|
||||||
|
// 必须随姿态完成旋转并显式标注坐标系,禁止混用未标注坐标系的数据。
|
||||||
|
//
|
||||||
|
// 实现要点:
|
||||||
|
// - 速度设定值只随**航向角 ψ**(绕下轴旋转)旋转:水平速度分量的
|
||||||
|
// 姿态耦合项由飞控内环处理,这是 PX4 offboard 速度设定值的标准做法;
|
||||||
|
// 滚转/俯仰不参与(对速度指令为二阶小量);
|
||||||
|
// - 旋转为正交变换:合速度大小不变,因此"限幅在机体系完成、旋转在
|
||||||
|
// 输出端执行"不破坏 §8 限幅约束;
|
||||||
|
// - 无状态纯函数(零分配、无虚函数、无锁),可独立单元测试。
|
||||||
|
//
|
||||||
|
// 正方向验证(NED:x=北,y=东,z=地;航向 ψ 自北顺时针为正):
|
||||||
|
// - 机体前向 (1,0) 在 ψ=90°(机头朝东)时 → (0, 1) = 东 ✓
|
||||||
|
// - 机体右向 (0,1) 在 ψ=0°(机头朝北)时 → (0, 1) = 东 ✓
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 机体系速度(前/右/下)→ 局部 NED(北/东/地),绕下轴按航向角 ψ 旋转:
|
||||||
|
/// v_north = v_forward·cosψ − v_right·sinψ
|
||||||
|
/// v_east = v_forward·sinψ + v_right·cosψ
|
||||||
|
/// v_down = v_down(不变)
|
||||||
|
/// 参数以引用传入并就地写出旋转结果。
|
||||||
|
inline void RotateBodyToNed(double& v_forward, double& v_right, double& v_down,
|
||||||
|
double yaw_rad) {
|
||||||
|
(void)v_down; // 垂直分量不受绕下轴旋转影响;保留参数以保持接口对称
|
||||||
|
const double c = std::cos(yaw_rad);
|
||||||
|
const double s = std::sin(yaw_rad);
|
||||||
|
const double north = v_forward * c - v_right * s;
|
||||||
|
const double east = v_forward * s + v_right * c;
|
||||||
|
v_forward = north;
|
||||||
|
v_right = east;
|
||||||
|
// v_down 不变
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// i_command_transport.h —— 命令传输层抽象接口
|
||||||
|
//
|
||||||
|
// 设计目标(双重解耦):
|
||||||
|
// - 传输层只吃字节流:串口 / 电台 / 回环打印均可注入实现,
|
||||||
|
// 后续重写传输只换实现,不动打包与控制律;
|
||||||
|
// - 打包封装(MavlinkCommandGateway)与本接口组合,不直接依赖具体链路。
|
||||||
|
//
|
||||||
|
// 控制律不直接写飞控(物理追踪思路 §2.2 控制单出口约束的独立库映射):
|
||||||
|
// 网关打包后交由实现方决定如何送达。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 命令传输接口:只吃字节流
|
||||||
|
class ICommandTransport {
|
||||||
|
public:
|
||||||
|
virtual ~ICommandTransport() = default;
|
||||||
|
|
||||||
|
/// 发送一帧已打包的命令字节流。
|
||||||
|
/// 返回 true = 已交付底层链路;false = 发送失败(调用方可计数/告警)。
|
||||||
|
/// 实现方保证:不长期持有 data 指针(同步拷贝或同步写出)。
|
||||||
|
virtual bool Send(const std::uint8_t* data, std::size_t len) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// line_of_sight.h —— 像素偏移 → 视线角(针孔模型)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §4.1 水平视线角 β:
|
||||||
|
// 针孔模型 β = atan( x_offset · 2·tan(θ_h/2) / W )
|
||||||
|
// (线性近似 β ≈ x_offset · ( θ_h / W ) 仅在 |x_offset| 很小时成立)
|
||||||
|
// §5.1 垂直视线角 α 同理(θ_v、H)
|
||||||
|
//
|
||||||
|
// 实现要点:
|
||||||
|
// - 全程使用精确针孔模型:规格中的线性近似与针孔式在切换边界两侧
|
||||||
|
// 函数值不连续(θ=60°、边界比例 0.2 处约 8% 角度台阶),会向控制
|
||||||
|
// 环路注入突变;atan 计算代价可忽略,故直接全程采用精确式,
|
||||||
|
// linear_ratio 配置项随之移除(§8 待定项收敛);
|
||||||
|
// - 构造期预计算针孔系数,热路径仅一次乘加 + atan
|
||||||
|
// (零分配、无虚函数、无锁)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 像素偏移 → 视线角(弧度)转换器;水平/垂直各构造一个实例
|
||||||
|
class PixelToAngle {
|
||||||
|
public:
|
||||||
|
/// size_px:画面尺寸(W 或 H,像素,必须 > 0);
|
||||||
|
/// fov_rad:视场角(θ_h 或 θ_v,弧度,必须 ∈ (0, π))。
|
||||||
|
/// 非法参数抛 std::invalid_argument(构造期校验)。
|
||||||
|
PixelToAngle(double size_px, double fov_rad);
|
||||||
|
|
||||||
|
/// 偏移(像素,水平右正 / 垂直下正)→ 视线角(弧度,同向为正)
|
||||||
|
double Convert(double offset_px) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
double pinhole_scale_; // 2·tan(θ/2) / size:针孔模型系数
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// mavlink_command_gateway.h —— MAVLink 命令打包网关(对外声明)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md §2.2
|
||||||
|
// - kVelocityHeading → SET_POSITION_TARGET_LOCAL_NED(ID 84,速度模式)
|
||||||
|
// - kBrakeHover → ID 84 全零速度
|
||||||
|
// (原 kAttitudeTrim → ID 82 已随姿态微调支路一并移除,见 vtc_types.h)
|
||||||
|
//
|
||||||
|
// 关键构建约束(务必遵守):
|
||||||
|
// - MAVLink 头(common/mavlink.h)只允许被本库的
|
||||||
|
// src/mavlink_command_gateway.cpp 一个 TU 包含:
|
||||||
|
// MAVLINK_HELPER 默认空宏,多 TU 包含会多重定义;
|
||||||
|
// - 因此本头文件不出现任何 mavlink_* 类型,打包/发送缓冲用
|
||||||
|
// 固定尺寸字节存储承载(尺寸在 .cpp 中 static_assert 校验)。
|
||||||
|
//
|
||||||
|
// 线程与资源:无线程、无 IO、公共方法非虚;成员固定缓冲,热路径零堆分配。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "visual_track_control/i_command_transport.h"
|
||||||
|
#include "visual_track_control/vtc_types.h"
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// MAVLink 命令打包网关:控制意图 → MAVLink 消息字节流 → 传输接口
|
||||||
|
class MavlinkCommandGateway {
|
||||||
|
public:
|
||||||
|
/// transport:字节流传输实现(注入,生命周期由调用方保证);
|
||||||
|
/// system_id/component_id:本机 MAVLink 身份;
|
||||||
|
/// target_system/target_component:飞控端身份。
|
||||||
|
MavlinkCommandGateway(ICommandTransport& transport, std::uint8_t system_id,
|
||||||
|
std::uint8_t component_id, std::uint8_t target_system,
|
||||||
|
std::uint8_t target_component);
|
||||||
|
|
||||||
|
/// 速度与航向意图 → SET_POSITION_TARGET_LOCAL_NED(ID 84,速度模式 type_mask)。
|
||||||
|
/// 掩码选择:`yaw_rate_dps` 非零 → 角速率掩码;`yaw_deg` 非零 → 航向角掩码;
|
||||||
|
/// 两者均为零 → 角速率掩码配零角速率(保持当前航向,避免误发绝对航向 0)。
|
||||||
|
bool SendVelocityHeading(const ControlOutput& output, std::uint32_t time_boot_ms);
|
||||||
|
|
||||||
|
/// 零速刹车 → ID 84 全零速度(判丢等场景,§6.4)。
|
||||||
|
bool SendBrakeHover(std::uint32_t time_boot_ms);
|
||||||
|
|
||||||
|
// 固定缓冲尺寸(.cpp 中与实际类型大小做 static_assert 校验):
|
||||||
|
static constexpr std::size_t kMsgBufferSize = 512; // ≥ sizeof(mavlink_message_t)
|
||||||
|
static constexpr std::size_t kTxBufferSize = 512; // ≥ MAVLINK_MAX_PACKET_LEN
|
||||||
|
|
||||||
|
private:
|
||||||
|
ICommandTransport& transport_; // 字节流传输实现(注入)
|
||||||
|
|
||||||
|
// MAVLink 身份
|
||||||
|
std::uint8_t system_id_;
|
||||||
|
std::uint8_t component_id_;
|
||||||
|
std::uint8_t target_system_;
|
||||||
|
std::uint8_t target_component_;
|
||||||
|
|
||||||
|
// 固定缓冲:热路径零堆分配(对齐 8 满足 mavlink_message_t 对齐要求)
|
||||||
|
alignas(8) std::uint8_t msg_storage_[kMsgBufferSize]; // mavlink_message_t 承载
|
||||||
|
std::uint8_t tx_buffer_[kTxBufferSize]; // 发送字节缓冲
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// pid_controller.h —— 位置式 PID 控制器(纯算法构件)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §6.2 距离控制律:前向速度指令 = PID(Δd),Δd = d − d_exp
|
||||||
|
// §8 PID 参数(Kp/Ki/Kd、积分限幅、微分滤波)全部待定/配置化
|
||||||
|
//
|
||||||
|
// 特性(热路径零分配、无虚函数、无锁):
|
||||||
|
// - 位置式输出:out = Kp·e + Ki·∫e + Kd·d(e)/dt;
|
||||||
|
// - 积分钳位:积分项限幅在 ±integral_limit;
|
||||||
|
// - anti-windup(条件积分):输出饱和且本拍积分增量仍推深饱和时冻结积分,
|
||||||
|
// 误差反号后允许积分回卷,防止积分饱和与残留;
|
||||||
|
// - 微分一阶低通:derivative_filter_coef ∈ [0,1),0 = 不滤波;
|
||||||
|
// - ResetIntegral() / Reset() 均 O(1)(Coast 清积分,§6.4)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// PID 参数(全部来自配置,代码不写死任何数值)
|
||||||
|
struct PidParams {
|
||||||
|
double kp = 0.0; // 比例增益
|
||||||
|
double ki = 0.0; // 积分增益
|
||||||
|
double kd = 0.0; // 微分增益
|
||||||
|
double integral_limit = 0.0; // 积分项限幅(<=0 表示不限幅)
|
||||||
|
double derivative_filter_coef = 0.0; // 微分一阶低通系数 ∈ [0,1):越大滤波越强
|
||||||
|
double output_limit = 0.0; // 输出限幅(<=0 表示不限幅)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 位置式 PID 控制器
|
||||||
|
class PidController {
|
||||||
|
public:
|
||||||
|
explicit PidController(const PidParams& params);
|
||||||
|
|
||||||
|
/// 单步更新:输入误差与采样间隔(秒),返回限幅后的输出。
|
||||||
|
/// dt_s <= 0 时保持上次输出(异常节拍保护)。
|
||||||
|
double Update(double error, double dt_s);
|
||||||
|
|
||||||
|
/// 清空积分项(§6.4:Coast 时防止积分饱和);保留微分历史与上次输出
|
||||||
|
void ResetIntegral();
|
||||||
|
|
||||||
|
/// 清空全部内部状态(积分、微分历史、上次输出)
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
/// 上次输出(只读)
|
||||||
|
double LastOutput() const { return last_output_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
PidParams params_;
|
||||||
|
|
||||||
|
double integral_ = 0.0; // 积分累计(已钳位)
|
||||||
|
double prev_error_ = 0.0; // 上次误差(微分用)
|
||||||
|
double filtered_derivative_ = 0.0; // 低通后的微分值
|
||||||
|
double last_output_ = 0.0; // 上次输出
|
||||||
|
bool has_prev_error_ = false; // 是否存在有效微分历史
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// rate_limiter.h —— 限幅与变化率限制(纯算法构件,header-only)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §8 所有阈值集中配置:速度/加速度/方向变化率必须限幅(FR-033)
|
||||||
|
// S6 首条视觉速度设定值必须经过限幅,避免位置→速度切换产生突变
|
||||||
|
// (状态机设计.md S6)
|
||||||
|
//
|
||||||
|
// 特性(热路径零分配、无虚函数、无锁):
|
||||||
|
// - Clamp():静态限幅;
|
||||||
|
// - SlewRateLimiter:保存上次输出,按配置变化率限幅;初值置 0 时
|
||||||
|
// 天然实现"首条指令突变抑制"(S6)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 限幅:将 v 钳制在 [lo, hi](调用方保证 lo <= hi)
|
||||||
|
inline double Clamp(double v, double lo, double hi) {
|
||||||
|
return std::min(std::max(v, lo), hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 变化率限幅器:限制输出每秒最大变化量(单位由调用方决定,
|
||||||
|
/// 如 度/秒^2 用于航向、米/秒^2 用于速度)
|
||||||
|
class SlewRateLimiter {
|
||||||
|
public:
|
||||||
|
/// rate_limit:每秒最大变化量(<=0 表示冻结输出);initial:初始输出值
|
||||||
|
explicit SlewRateLimiter(double rate_limit, double initial = 0.0)
|
||||||
|
: rate_limit_(rate_limit), output_(initial) {}
|
||||||
|
|
||||||
|
/// 朝 target 推进一个节拍:单步变化不超过 rate_limit·dt_s。
|
||||||
|
/// dt_s <= 0 或 rate_limit <= 0 时保持当前输出(异常节拍保护)。
|
||||||
|
double Limit(double target, double dt_s) {
|
||||||
|
if (dt_s <= 0.0 || rate_limit_ <= 0.0) {
|
||||||
|
return output_;
|
||||||
|
}
|
||||||
|
const double max_step = rate_limit_ * dt_s;
|
||||||
|
output_ = Clamp(target, output_ - max_step, output_ + max_step);
|
||||||
|
return output_;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 复位输出值(刹车/重置时归零,保证恢复跟踪后指令从零渐变)
|
||||||
|
void Reset(double value = 0.0) { output_ = value; }
|
||||||
|
|
||||||
|
/// 上次输出(只读)
|
||||||
|
double LastOutput() const { return output_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
double rate_limit_; // 每秒最大变化量
|
||||||
|
double output_; // 当前输出
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// staleness_checker.h —— 输入超龄判定(单调毫秒时钟)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §7.1 超龄数据只能触发降级或告警,不能继续生成正常控制指令;
|
||||||
|
// 超龄判定使用取样时间 + valid_for_ms(MessageHeader 语义)。
|
||||||
|
//
|
||||||
|
// 特性:无状态纯函数封装(零分配、无虚函数、无锁)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 输入超龄判定器(无状态,仅提供静态判定)
|
||||||
|
class StalenessChecker {
|
||||||
|
public:
|
||||||
|
/// sample_time_ms:取样单调时钟毫秒(<=0 视为从未取样,直接判超龄);
|
||||||
|
/// now_ms:当前单调时钟毫秒;
|
||||||
|
/// valid_for_ms:有效期(毫秒,配置给定)。
|
||||||
|
/// 返回 true = 已超龄。
|
||||||
|
static bool IsStale(std::int64_t sample_time_ms, std::int64_t now_ms,
|
||||||
|
std::int64_t valid_for_ms) {
|
||||||
|
if (sample_time_ms <= 0) {
|
||||||
|
return true; // 从未取样:视为超龄(触发降级)
|
||||||
|
}
|
||||||
|
if (now_ms < sample_time_ms) {
|
||||||
|
// 时间戳不一致(时钟源错配/打戳错误):年龄不可信。
|
||||||
|
// 单一单调时钟下不应发生;宁可降级也不信任不可核实的取样。
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (now_ms - sample_time_ms) > valid_for_ms;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// tracking_control_law.h —— 视觉跟踪主控律(TrackingControlLaw)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §4 水平航向控制(增益 → slew → 角速度限幅,位置式/速率式按配置)
|
||||||
|
// §5 垂直/高度控制(统一速度支路:α → 垂直速度,原双支路已合并)
|
||||||
|
// §6 距离控制(PID(Δd) → 前向速度;未关联按配置降级,§6.3)
|
||||||
|
// §6.4 Coast 清积分、判丢零速刹车
|
||||||
|
// §7.1 超龄判定(姿态/视觉超龄 → 降级保持)
|
||||||
|
// §8 统一限幅
|
||||||
|
// §2.3 输出端随机体航向把机体系速度旋转为局部 NED
|
||||||
|
//
|
||||||
|
// 线程模型:单线程调用(状态机与控制线程),成员无锁、无堆对象;
|
||||||
|
// 日志仅状态切换点(刹车进出、降级切换),高频热路径不打日志。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "visual_track_control/line_of_sight.h"
|
||||||
|
#include "visual_track_control/pid_controller.h"
|
||||||
|
#include "visual_track_control/rate_limiter.h"
|
||||||
|
#include "visual_track_control/vtc_config.h"
|
||||||
|
#include "visual_track_control/vtc_types.h"
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 视觉跟踪主控律:消费控制快照,输出限幅后的控制意图
|
||||||
|
class TrackingControlLaw {
|
||||||
|
public:
|
||||||
|
/// cfg:启动期加载的全量配置(值拷贝持有,运行期不再触碰配置源)
|
||||||
|
explicit TrackingControlLaw(const TrackingConfig& cfg);
|
||||||
|
|
||||||
|
/// 单周期控制计算(§7.1 固定周期持续运行)。
|
||||||
|
/// now_ms:当前单调时钟毫秒;内部据此推算 dt 并做超龄判定。
|
||||||
|
/// 流程:①超龄判定 → ②判丢刹车 → ③Coast 清积分 → ④像素→视线角
|
||||||
|
/// → ⑤水平航向 → ⑥垂直速度 → ⑦距离通道 → ⑧机体系→NED 旋转输出
|
||||||
|
ControlOutput Update(const ControlSnapshot& snapshot, std::int64_t now_ms);
|
||||||
|
|
||||||
|
/// 停止视觉闭环时清全部状态(积分、限幅器历史、节拍时钟,§6.4)
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
TrackingConfig cfg_; // 配置值拷贝(运行期只读)
|
||||||
|
|
||||||
|
// ---- 预计算构件(构造期完成,热路径零分配) ----
|
||||||
|
PixelToAngle pixel_to_beta_; // 水平:x_offset → β(§4.1)
|
||||||
|
PixelToAngle pixel_to_alpha_; // 垂直:y_offset → α(§5.1)
|
||||||
|
PidController distance_pid_; // 距离通道 PID(§6.2)
|
||||||
|
|
||||||
|
// ---- 变化率限幅器(§8 / S6 首条指令限幅) ----
|
||||||
|
SlewRateLimiter yaw_deg_limiter_; // 位置式航向变化率(度/秒^2)
|
||||||
|
SlewRateLimiter yaw_rate_limiter_; // 速率式角速度变化率(度/秒^2)
|
||||||
|
SlewRateLimiter vx_limiter_; // 前向加速度限幅(米/秒^2)
|
||||||
|
SlewRateLimiter vz_limiter_; // 垂直加速度限幅(米/秒^2)
|
||||||
|
|
||||||
|
// ---- 节拍与状态(仅打日志用,不参与数学) ----
|
||||||
|
std::int64_t last_now_ms_ = 0; // 上次计算时刻
|
||||||
|
ControlMode last_mode_ = ControlMode::kDegradedHold; // 上次输出模式(状态切换日志)
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// vtc_config.h —— 视觉跟踪控制律配置(TrackingConfig + LoadFromJson)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §8 限幅、阈值与配置化(所有阈值集中配置,不得散落在代码中,
|
||||||
|
// 需求分析.md NFR-007)
|
||||||
|
// §4 水平航向控制(模式、增益、限幅)
|
||||||
|
// §5 垂直/高度控制(垂直速度增益与限幅;原双支路已统一为速度支路)
|
||||||
|
// §6 距离控制(射程、PID、限幅、无距离降级动作)
|
||||||
|
// §7 控制周期与时序(频率、各输入超龄阈值)
|
||||||
|
//
|
||||||
|
// 设计约束:
|
||||||
|
// - 全部数值待定:本结构默认值与示例配置均为占位,标定前仅限回放验证;
|
||||||
|
// - LoadFromJson 启动期一次性解析:缺字段/类型错/非法值抛
|
||||||
|
// std::runtime_error 并携带字段名(初始化抛异常规范);
|
||||||
|
// - 热路径绝不触碰 JSON 对象,不做热更新(调参重启即可)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 航向通道模式(§4.2:位置式/速率式按 PX4 1.11.3 Offboard 实测选择)
|
||||||
|
///
|
||||||
|
/// 默认速率式:yaw_rate 指令天然是相对量,无坐标系歧义。位置式输出为
|
||||||
|
/// 绝对 NED 航向(当前航向 + 限幅修正量合成),依赖新鲜自机姿态;
|
||||||
|
/// 姿态接线未完成前不得用于实飞。
|
||||||
|
enum class HeadingMode : std::uint8_t {
|
||||||
|
kRate, // 速率式(默认):β → 偏航角速度,输出 yaw_rate_dps
|
||||||
|
kPosition, // 位置式:β → 修正量,与当前航向合成绝对航向输出 yaw_deg
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3,具体动作待定)
|
||||||
|
enum class NoDistanceAction : std::uint8_t {
|
||||||
|
kHold, // 保持:前向速度指令清零(安全占位语义)
|
||||||
|
kSlowApproach, // 限速接近:按 no_distance_approach_limit_mps 缓速接近
|
||||||
|
kExit, // 退出近距操作:输出降级保持(kDegradedHold)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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 attitude_stale_ms = 300; // 自机姿态超龄阈值(毫秒)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// camera 组:画面与视场角(§3.2、§4.1、§5.1;内参/畸变未标定,占位)
|
||||||
|
struct CameraGroupConfig {
|
||||||
|
int image_width = 1920; // 画面宽度 W(像素)
|
||||||
|
int image_height = 1080; // 画面高度 H(像素)
|
||||||
|
double fov_h_deg = 60.0; // 水平视场角 θ_h(度,仅水平,§4.1)
|
||||||
|
double fov_v_deg = 35.0; // 垂直视场角 θ_v(度,§5.1)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// heading 组:水平航向通道(§4)
|
||||||
|
struct HeadingGroupConfig {
|
||||||
|
HeadingMode mode = HeadingMode::kRate; // 默认速率式(§4.2,见 HeadingMode 注释)
|
||||||
|
double gain = 1.0; // 增益:视线角(度) → 航向指令(无量纲占位)
|
||||||
|
double yaw_rate_limit_dps = 30.0; // 偏航角速度上限(度/秒,FR-033)
|
||||||
|
double yaw_slew_limit_dps2 = 60.0; // 方向变化率上限(度/秒^2,FR-033、S6 首条限幅)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// vertical 组:垂直/高度控制(§5)
|
||||||
|
///
|
||||||
|
/// 规格 §5.2 原设"小误差姿态微调 / 大误差垂直速度"双支路,实测评审后
|
||||||
|
/// 统一为速度支路(见 ControlMode 注释):切换阈值与姿态微调参数随之移除。
|
||||||
|
/// 小误差经 vz_gain 线性映射为小垂直速度,行为连续且无模式翻转。
|
||||||
|
struct VerticalGroupConfig {
|
||||||
|
double vz_gain_mps_per_deg = 0.1; // 垂直速度增益:α(度) → 垂直速度(米/秒)
|
||||||
|
double vz_limit_mps = 2.0; // 垂直速度限幅(米/秒)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// distance 组:距离通道(§6)
|
||||||
|
struct DistanceGroupConfig {
|
||||||
|
double d_exp_m = 10.0; // 射程 d_exp(米,由拦截设备接口给出,§6.1 待定)
|
||||||
|
double kp = 0.0; // PID 比例(§6.2 待定)
|
||||||
|
double ki = 0.0; // PID 积分
|
||||||
|
double kd = 0.0; // PID 微分
|
||||||
|
double integral_limit = 0.0; // 积分项限幅
|
||||||
|
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)
|
||||||
|
double no_distance_approach_limit_mps = 0.5; // kSlowApproach 限速值(米/秒)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// accel_limit 组:各轴加速度限幅(§8,待定)
|
||||||
|
struct AccelLimitConfig {
|
||||||
|
double ax_mps2 = 2.0; // 前向加速度限幅(米/秒^2)
|
||||||
|
double ay_mps2 = 2.0; // 侧向加速度限幅(米/秒^2,预留)
|
||||||
|
double az_mps2 = 1.5; // 垂直加速度限幅(米/秒^2)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 视觉跟踪控制律全量配置(§8:所有阈值集中于此)
|
||||||
|
struct TrackingConfig {
|
||||||
|
ControlGroupConfig control;
|
||||||
|
CameraGroupConfig camera;
|
||||||
|
HeadingGroupConfig heading;
|
||||||
|
VerticalGroupConfig vertical;
|
||||||
|
DistanceGroupConfig distance;
|
||||||
|
AccelLimitConfig accel_limit;
|
||||||
|
|
||||||
|
/// 从 JSON 文件一次性加载配置(启动期调用)。
|
||||||
|
/// 缺字段 / 类型错误 / 非法取值均抛 std::runtime_error,
|
||||||
|
/// 异常消息携带字段名(如 "heading.gain")。
|
||||||
|
static TrackingConfig LoadFromJson(const std::string& path);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// vtc_types.h —— 视觉跟踪控制律的中立输入/输出结构
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §2 输入输出与数据流(控制意图枚举语义)
|
||||||
|
// §6.4 与追踪库的行为衔接(tracked / is_predicted 语义)
|
||||||
|
// §7.2 与 target_tracker 的衔接(TrackResult 无时间戳,由调用方在
|
||||||
|
// LastResult() 瞬间打戳)
|
||||||
|
//
|
||||||
|
// 设计约束:
|
||||||
|
// - ControlSnapshot / ControlOutput 均为纯值类型(POD 风格),
|
||||||
|
// 控制律热路径零堆分配;
|
||||||
|
// - 本库不依赖主工程 include/ 的任何类型,独立可编译;
|
||||||
|
// - 雷达距离必须带 associated_to_target 关联标志:未关联不得当作
|
||||||
|
// 目标距离(§6.3、需求分析.md FR-041)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include <target_tracker/target_tracker.h>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
/// 控制输出模式(对应物理追踪思路 §2.2 的控制意图语义)
|
||||||
|
///
|
||||||
|
/// 注意:规格 §5.2 原设"垂直小误差姿态微调支路"(kAttitudeTrim →
|
||||||
|
/// SET_ATTITUDE_TARGET),实测评审后取消——姿态微调支路会把整个输出切成
|
||||||
|
/// 姿态消息,导致同周期的距离通道前向速度被丢弃(逼近停滞),且双消息
|
||||||
|
/// 类型逐拍翻转会扰动 PX4 offboard。现垂直通道一律走速度支路(小误差
|
||||||
|
/// 对应小 vz),全周期单一 offboard 消息。
|
||||||
|
enum class ControlMode : std::uint8_t {
|
||||||
|
kVelocityHeading, // 速度与航向意图:速度分量 + yaw(§4.2、§5、§6.2 主输出)
|
||||||
|
kBrakeHover, // 零速刹车/悬停:判丢等场景(§6.4、§9.1)
|
||||||
|
kDegradedHold, // 降级保持:超龄/退出近距操作,禁止正常控制(§7.1、§9.2)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 降级原因(仅 kDegradedHold 模式有意义)
|
||||||
|
enum class DegradedReason : std::uint8_t {
|
||||||
|
kNone, // 未降级
|
||||||
|
kAttitudeStale, // PX4 姿态过期:禁止空间转换和控制(§9.2)
|
||||||
|
kVisionStale, // 视觉追踪结果超龄:不得继续生成正常指令(§7.1)
|
||||||
|
kNoDistanceExit, // 无距离关联且配置为退出近距操作(§6.3)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 控制律输入快照:调用方在同一控制周期内采集并打戳
|
||||||
|
///
|
||||||
|
/// 注意:TrackResult 本身不含时间戳(§7.2),调用方必须在读取
|
||||||
|
/// LastResult() 的瞬间用单调时钟打戳填入 track_time_ms;
|
||||||
|
/// 超龄判定一律使用"取样时间 + valid_for_ms"(§7.1)。
|
||||||
|
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; // 取样单调时钟(毫秒)
|
||||||
|
|
||||||
|
// ---- 自机姿态/速度 ----
|
||||||
|
// 姿态过期 → 禁止控制(§9.2)。`yaw_rad` 参与输出端机体系→NED 旋转
|
||||||
|
// (§2.3)与位置式绝对航向合成;其余字段预留,接入主工程时由
|
||||||
|
// FlightStateSnapshot 填充(机体补偿见 §4.1)。
|
||||||
|
bool attitude_present = false; // 姿态源当前是否可用
|
||||||
|
std::int64_t attitude_time_ms = 0; // 姿态取样单调时钟(毫秒)
|
||||||
|
double roll_rad = 0.0; // 横滚(弧度,FRD)
|
||||||
|
double pitch_rad = 0.0; // 俯仰(弧度,FRD)
|
||||||
|
double yaw_rad = 0.0; // 航向(弧度,NED)
|
||||||
|
double yaw_rate_radps = 0.0; // 偏航角速度(弧度/秒,机体补偿预留,§4.1)
|
||||||
|
double velocity_x_mps = 0.0; // 自机速度分量(预留)
|
||||||
|
double velocity_y_mps = 0.0;
|
||||||
|
double velocity_z_mps = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 控制律输出:限幅后的控制意图(§8:所有输出必须经过限幅)
|
||||||
|
///
|
||||||
|
/// 坐标语义:速度分量在机体系(前/右/下,§2.3)内计算与限幅,输出前
|
||||||
|
/// 已随自机航向(ControlSnapshot.yaw_rad)旋转为局部 NED(北/东/地),
|
||||||
|
/// 与网关使用的 MAV_FRAME_LOCAL_NED 一致;因此旋转后 vy_mps 可能非零
|
||||||
|
/// (机体系侧向恒为 0,旋转不改变合速度大小,限幅在旋转前完成)。
|
||||||
|
struct ControlOutput {
|
||||||
|
ControlMode mode = ControlMode::kDegradedHold; // 输出模式
|
||||||
|
bool valid = false; // 指令是否可用于发送
|
||||||
|
|
||||||
|
// ---- 速度分量(局部 NED:北/东/地,米/秒;由机体系随航向旋转得到) ----
|
||||||
|
double vx_mps = 0.0; // 北向(距离通道输出经旋转,§6.2)
|
||||||
|
double vy_mps = 0.0; // 东向(机体系恒 0,航向旋转后可非零)
|
||||||
|
double vz_mps = 0.0; // 垂直(NED 下正,垂直速度支路输出,§5.2)
|
||||||
|
|
||||||
|
// ---- 航向通道(§4.2:位置式输出 yaw_deg,速率式输出 yaw_rate_dps) ----
|
||||||
|
double yaw_deg = 0.0; // 目标绝对航向(度,NED,右转为正;仅位置式,
|
||||||
|
// 由当前航向 + 限幅修正量合成并回绕 [-180,180))
|
||||||
|
double yaw_rate_dps = 0.0; // 偏航角速度指令(度/秒;速率式为默认,天然相对量)
|
||||||
|
|
||||||
|
// ---- 状态标注 ----
|
||||||
|
DegradedReason degraded_reason = DegradedReason::kNone; // 降级原因
|
||||||
|
bool coast_active = false; // 本周期处于 Coast(is_predicted==true,§6.4)
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// line_of_sight.cpp —— 像素偏移 → 视线角实现(全程针孔模型)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md §4.1(水平 β)、§5.1(垂直 α):
|
||||||
|
// 针孔模型:angle = atan( offset · 2·tan(θ/2) / size )
|
||||||
|
//
|
||||||
|
// 原设计的线性近似双路切换已取消:线性式与针孔式在切换边界两侧不连续
|
||||||
|
// (θ=60°、边界比例 0.2 处约 8% 角度台阶),会向控制环路注入突变;
|
||||||
|
// atan 代价可忽略,全程采用精确针孔式。
|
||||||
|
//
|
||||||
|
// 构造期预计算针孔系数,Convert 热路径仅一次乘加 + atan。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "visual_track_control/line_of_sight.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
}
|
||||||
|
|
||||||
|
PixelToAngle::PixelToAngle(double size_px, double fov_rad) {
|
||||||
|
// 构造期校验:非法参数直接抛异常(初始化抛异常规范)
|
||||||
|
if (!(size_px > 0.0)) {
|
||||||
|
throw std::invalid_argument("PixelToAngle: size_px 必须为正");
|
||||||
|
}
|
||||||
|
if (!(fov_rad > 0.0 && fov_rad < kPi)) {
|
||||||
|
throw std::invalid_argument("PixelToAngle: fov_rad 必须在 (0, π) 内");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预计算针孔系数(§4.1 公式)
|
||||||
|
pinhole_scale_ = 2.0 * std::tan(fov_rad / 2.0) / size_px; // 2·tan(θ/2) / size
|
||||||
|
}
|
||||||
|
|
||||||
|
double PixelToAngle::Convert(double offset_px) const {
|
||||||
|
return std::atan(offset_px * pinhole_scale_); // 针孔模型,全偏移范围精确
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// mavlink_command_gateway.cpp —— MAVLink 命令打包网关实现
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md §2.2
|
||||||
|
// kVelocityHeading → SET_POSITION_TARGET_LOCAL_NED(ID 84,速度模式)
|
||||||
|
// kBrakeHover → ID 84 全零速度
|
||||||
|
// (原 kAttitudeTrim → ID 82 已随姿态微调支路一并移除,见 vtc_types.h)
|
||||||
|
//
|
||||||
|
// 关键构建约束:本文件是全工程唯一包含 common/mavlink.h 的 TU。
|
||||||
|
// MAVLINK_HELPER 默认空宏(mavlink_helpers.h),多 TU 包含会多重定义。
|
||||||
|
//
|
||||||
|
// 打包策略:
|
||||||
|
// - 使用 mavlink_msg_*_pack(无通道状态、纯静态内联、可重入),
|
||||||
|
// 不使用 _pack_chan 通道 API;
|
||||||
|
// - mavlink_msg_to_send_buffer 输出字节流 → ICommandTransport::Send;
|
||||||
|
// - 消息/发送缓冲为成员固定持有,热路径零堆分配。
|
||||||
|
//
|
||||||
|
// 坐标系与语义说明(待实测确认):
|
||||||
|
// - coordinate_frame 取 MAV_FRAME_LOCAL_NED;速度分量由主控律在输出端
|
||||||
|
// 随机体航向旋转为局部 NED(北/东/地,物理追踪思路 §2.3),本文件
|
||||||
|
// 直接填充不再做坐标变换;
|
||||||
|
// - 速度模式 type_mask 需以 PX4 1.11.3 Offboard 实测为准
|
||||||
|
// (物理追踪思路 §10.1)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "visual_track_control/mavlink_command_gateway.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "common/mavlink.h" // 全工程唯一包含点(见文件头构建约束)
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
constexpr double kDegToRad = kPi / 180.0;
|
||||||
|
|
||||||
|
// ---- SET_POSITION_TARGET_LOCAL_NED(ID 84)type_mask 常量 ----
|
||||||
|
// 掩码位语义:置位 = 忽略该字段。
|
||||||
|
// 基础位:忽略位置 x/y/z(bit0-2)与加速度 afx/afy/afz(bit6-8)= 0x01C7。
|
||||||
|
constexpr std::uint16_t kMaskVelocityBase = 0x01C7;
|
||||||
|
// 速度 + 航向角模式:忽略 yaw_rate(bit11)
|
||||||
|
constexpr std::uint16_t kMaskVelocityYaw = kMaskVelocityBase | 0x0800;
|
||||||
|
// 速度 + 偏航角速率模式:忽略 yaw(bit10)
|
||||||
|
constexpr std::uint16_t kMaskVelocityYawRate = kMaskVelocityBase | 0x0400;
|
||||||
|
// 刹车模式:速度全零,yaw 与 yaw_rate 均忽略(bit10、bit11)
|
||||||
|
constexpr std::uint16_t kMaskBrake = kMaskVelocityBase | 0x0C00;
|
||||||
|
|
||||||
|
/// 固定存储解释为消息结构(打包函数只写入字节,不读取旧值)
|
||||||
|
inline mavlink_message_t* AsMessage(std::uint8_t* storage) {
|
||||||
|
return reinterpret_cast<mavlink_message_t*>(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// 固定缓冲尺寸校验:编译期保证存储足够(头文件不暴露 mavlink 类型)
|
||||||
|
static_assert(sizeof(mavlink_message_t) <= MavlinkCommandGateway::kMsgBufferSize,
|
||||||
|
"msg_storage_ 不足以容纳 mavlink_message_t");
|
||||||
|
static_assert(MAVLINK_MAX_PACKET_LEN <= MavlinkCommandGateway::kTxBufferSize,
|
||||||
|
"tx_buffer_ 不足以容纳最大 MAVLink 数据包");
|
||||||
|
|
||||||
|
MavlinkCommandGateway::MavlinkCommandGateway(ICommandTransport& transport,
|
||||||
|
std::uint8_t system_id,
|
||||||
|
std::uint8_t component_id,
|
||||||
|
std::uint8_t target_system,
|
||||||
|
std::uint8_t target_component)
|
||||||
|
: transport_(transport),
|
||||||
|
system_id_(system_id),
|
||||||
|
component_id_(component_id),
|
||||||
|
target_system_(target_system),
|
||||||
|
target_component_(target_component) {}
|
||||||
|
|
||||||
|
bool MavlinkCommandGateway::SendVelocityHeading(const ControlOutput& output,
|
||||||
|
std::uint32_t time_boot_ms) {
|
||||||
|
auto* msg = AsMessage(msg_storage_);
|
||||||
|
|
||||||
|
// 掩码三分支(A2 修复):
|
||||||
|
// 角速率非零 → 速率掩码(速率式控制律常态,§4.2);
|
||||||
|
// 航向角非零 → 航向掩码(位置式控制律);
|
||||||
|
// 两者均零 → 速率掩码配零角速率:保持当前航向。
|
||||||
|
// 旧实现把"零角速率"落到航向掩码,等于误发绝对航向 0(指向正北)。
|
||||||
|
const bool has_yaw_rate = std::fabs(output.yaw_rate_dps) > 1.0e-9;
|
||||||
|
const bool has_yaw = std::fabs(output.yaw_deg) > 1.0e-9;
|
||||||
|
const std::uint16_t type_mask =
|
||||||
|
(!has_yaw_rate && has_yaw) ? kMaskVelocityYaw : kMaskVelocityYawRate;
|
||||||
|
|
||||||
|
mavlink_msg_set_position_target_local_ned_pack(
|
||||||
|
system_id_, component_id_, msg,
|
||||||
|
time_boot_ms, target_system_, target_component_,
|
||||||
|
MAV_FRAME_LOCAL_NED, type_mask,
|
||||||
|
0.0F, 0.0F, 0.0F, // 位置字段:忽略
|
||||||
|
static_cast<float>(output.vx_mps), static_cast<float>(output.vy_mps),
|
||||||
|
static_cast<float>(output.vz_mps),
|
||||||
|
0.0F, 0.0F, 0.0F, // 加速度字段:忽略
|
||||||
|
static_cast<float>(output.yaw_deg * kDegToRad),
|
||||||
|
static_cast<float>(output.yaw_rate_dps * kDegToRad));
|
||||||
|
|
||||||
|
const std::uint16_t len = mavlink_msg_to_send_buffer(tx_buffer_, msg);
|
||||||
|
return transport_.Send(tx_buffer_, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MavlinkCommandGateway::SendBrakeHover(std::uint32_t time_boot_ms) {
|
||||||
|
auto* msg = AsMessage(msg_storage_);
|
||||||
|
|
||||||
|
// 判丢零速刹车(§6.4):速度全零,位置/加速度/航向字段全部忽略
|
||||||
|
mavlink_msg_set_position_target_local_ned_pack(
|
||||||
|
system_id_, component_id_, msg,
|
||||||
|
time_boot_ms, target_system_, target_component_,
|
||||||
|
MAV_FRAME_LOCAL_NED, kMaskBrake,
|
||||||
|
0.0F, 0.0F, 0.0F,
|
||||||
|
0.0F, 0.0F, 0.0F,
|
||||||
|
0.0F, 0.0F, 0.0F,
|
||||||
|
0.0F, 0.0F);
|
||||||
|
|
||||||
|
const std::uint16_t len = mavlink_msg_to_send_buffer(tx_buffer_, msg);
|
||||||
|
return transport_.Send(tx_buffer_, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// pid_controller.cpp —— 位置式 PID 控制器实现
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §6.2 前向速度指令 = PID(Δd)
|
||||||
|
// §6.4 Coast 时积分项清空(ResetIntegral)
|
||||||
|
//
|
||||||
|
// anti-windup 策略(条件积分):输出达到饱和限幅且本拍积分增量仍在
|
||||||
|
// 推深饱和时,冻结本步积分累加(不采纳候选积分值),防止积分饱和;
|
||||||
|
// 误差反号后增量反向、允许积分回卷,避免饱和残留。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "visual_track_control/pid_controller.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "visual_track_control/rate_limiter.h"
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
|
||||||
|
PidController::PidController(const PidParams& params) : params_(params) {}
|
||||||
|
|
||||||
|
double PidController::Update(double error, double dt_s) {
|
||||||
|
if (dt_s <= 0.0) {
|
||||||
|
return last_output_; // 异常节拍保护:保持上次输出
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 积分:候选值先钳位(积分限幅) ----
|
||||||
|
double integral_candidate = integral_ + error * dt_s;
|
||||||
|
if (params_.integral_limit > 0.0) {
|
||||||
|
integral_candidate =
|
||||||
|
Clamp(integral_candidate, -params_.integral_limit, params_.integral_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 微分:一阶低通滤波(系数 ∈ [0,1),0 = 不滤波) ----
|
||||||
|
double derivative = 0.0;
|
||||||
|
if (has_prev_error_) {
|
||||||
|
derivative = (error - prev_error_) / dt_s;
|
||||||
|
const double coef = params_.derivative_filter_coef;
|
||||||
|
derivative = coef * filtered_derivative_ + (1.0 - coef) * derivative;
|
||||||
|
}
|
||||||
|
filtered_derivative_ = derivative;
|
||||||
|
prev_error_ = error;
|
||||||
|
has_prev_error_ = true;
|
||||||
|
|
||||||
|
// ---- 位置式合成 ----
|
||||||
|
const double integral_term = params_.ki * integral_candidate;
|
||||||
|
const double raw = params_.kp * error + integral_term + params_.kd * derivative;
|
||||||
|
|
||||||
|
// ---- anti-windup(条件积分,C1 修复) ----
|
||||||
|
// 冻结判据基于"本拍积分增量方向"(error·dt):输出饱和且本拍增量仍
|
||||||
|
// 在推深饱和时才冻结。不采用旧实现"积分项与输出同向"判据(误差反号
|
||||||
|
// 后无法回卷),也不用 raw·error(大 kp 项会掩盖饱和方向、拖延回卷)。
|
||||||
|
const bool saturated = params_.output_limit > 0.0 && std::fabs(raw) > params_.output_limit;
|
||||||
|
const bool increment_pushes_deeper = (error * dt_s) * raw > 0.0;
|
||||||
|
if (!(saturated && increment_pushes_deeper)) {
|
||||||
|
integral_ = integral_candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 输出限幅 ----
|
||||||
|
if (params_.output_limit > 0.0) {
|
||||||
|
last_output_ = Clamp(raw, -params_.output_limit, params_.output_limit);
|
||||||
|
} else {
|
||||||
|
last_output_ = raw;
|
||||||
|
}
|
||||||
|
return last_output_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PidController::ResetIntegral() {
|
||||||
|
integral_ = 0.0; // §6.4:Coast 清积分,防积分饱和
|
||||||
|
}
|
||||||
|
|
||||||
|
void PidController::Reset() {
|
||||||
|
integral_ = 0.0;
|
||||||
|
prev_error_ = 0.0;
|
||||||
|
filtered_derivative_ = 0.0;
|
||||||
|
last_output_ = 0.0;
|
||||||
|
has_prev_error_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// tracking_control_law.cpp —— 视觉跟踪主控律实现
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md
|
||||||
|
// §4.1/§4.2 水平航向控制:像素偏移 → β → 增益 → slew → 角速度限幅,
|
||||||
|
// 位置式输出 yaw_deg / 速率式输出 yaw_rate_dps(按配置)
|
||||||
|
// §5.1/§5.2 垂直控制:统一速度支路 α → 垂直速度设定值(原"小误差姿态
|
||||||
|
// 微调/大误差垂直速度"双支路已合并:姿态微调会把输出切成姿态
|
||||||
|
// 消息,丢弃同周期距离通道前向速度导致逼近停滞,详见
|
||||||
|
// vtc_types.h ControlMode 注释)
|
||||||
|
// §6.2/§6.3 距离通道:Δd = d − d_exp → PID → 前向速度(限幅);
|
||||||
|
// 未关联/超龄 → 按配置降级动作(hold / slow_approach / exit)
|
||||||
|
// §6.4 Coast(is_predicted)清航向/距离积分;判丢(!tracked)零速刹车
|
||||||
|
// §7.1 超龄数据只触发降级:姿态过期 → 禁止控制;视觉过期 → 降级保持
|
||||||
|
// §8 所有输出统一限幅;首条指令经变化率限幅(S6)
|
||||||
|
// §2.3 输出端随机体航向把机体系速度旋转为局部 NED
|
||||||
|
//
|
||||||
|
// 日志纪律:仅状态切换点(刹车进出、降级切换)打印;热路径零日志、零分配。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "visual_track_control/tracking_control_law.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/frame_transform.h"
|
||||||
|
#include "visual_track_control/staleness_checker.h"
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
constexpr double kRadToDeg = 180.0 / kPi;
|
||||||
|
constexpr double kDegToRad = kPi / 180.0;
|
||||||
|
constexpr double kMaxDtS = 0.5; // 单步 dt 上限(秒):异常节拍保护
|
||||||
|
|
||||||
|
/// 角度回绕到 [-180, 180)(位置式绝对航向合成用)
|
||||||
|
double WrapDeg180(double deg) {
|
||||||
|
double d = std::fmod(deg + 180.0, 360.0);
|
||||||
|
if (d < 0.0) {
|
||||||
|
d += 360.0;
|
||||||
|
}
|
||||||
|
return d - 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 控制模式可读名(仅状态切换日志使用)
|
||||||
|
const char* ModeName(ControlMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case ControlMode::kVelocityHeading: return "速度航向";
|
||||||
|
case ControlMode::kBrakeHover: return "零速刹车";
|
||||||
|
case ControlMode::kDegradedHold: return "降级保持";
|
||||||
|
}
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 由距离配置构造 PID 参数(§6.2);输出限幅取接近/后退限幅的较大者
|
||||||
|
PidParams BuildDistancePidParams(const TrackingConfig& cfg) {
|
||||||
|
PidParams p;
|
||||||
|
p.kp = cfg.distance.kp;
|
||||||
|
p.ki = cfg.distance.ki;
|
||||||
|
p.kd = cfg.distance.kd;
|
||||||
|
p.integral_limit = cfg.distance.integral_limit;
|
||||||
|
p.derivative_filter_coef = cfg.distance.derivative_filter_coef;
|
||||||
|
p.output_limit = std::max(cfg.distance.approach_velocity_limit_mps,
|
||||||
|
cfg.distance.retreat_velocity_limit_mps);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TrackingControlLaw::TrackingControlLaw(const TrackingConfig& cfg)
|
||||||
|
: cfg_(cfg),
|
||||||
|
pixel_to_beta_(cfg.camera.image_width, cfg.camera.fov_h_deg * kDegToRad),
|
||||||
|
pixel_to_alpha_(cfg.camera.image_height, cfg.camera.fov_v_deg * kDegToRad),
|
||||||
|
distance_pid_(BuildDistancePidParams(cfg)),
|
||||||
|
yaw_deg_limiter_(cfg.heading.yaw_slew_limit_dps2),
|
||||||
|
yaw_rate_limiter_(cfg.heading.yaw_slew_limit_dps2),
|
||||||
|
vx_limiter_(cfg.accel_limit.ax_mps2),
|
||||||
|
vz_limiter_(cfg.accel_limit.az_mps2) {}
|
||||||
|
|
||||||
|
ControlOutput TrackingControlLaw::Update(const ControlSnapshot& snapshot,
|
||||||
|
std::int64_t now_ms) {
|
||||||
|
// ---- 节拍推算(§7.1 固定周期;异常节拍用配置频率兜底) ----
|
||||||
|
// 时钟回拨或 now_ms 未前进(同值)时不信任差值,回退配置频率:
|
||||||
|
// 单拍误差由限幅器与超龄判定兜底,不引入额外状态。
|
||||||
|
double dt_s = 1.0 / cfg_.control.frequency_hz;
|
||||||
|
if (last_now_ms_ > 0 && now_ms > last_now_ms_) {
|
||||||
|
dt_s = std::min(static_cast<double>(now_ms - last_now_ms_) / 1000.0, kMaxDtS);
|
||||||
|
}
|
||||||
|
last_now_ms_ = now_ms;
|
||||||
|
|
||||||
|
ControlOutput out;
|
||||||
|
out.mode = ControlMode::kVelocityHeading; // 正常路径默认模式,分支可覆盖
|
||||||
|
|
||||||
|
// 状态切换日志辅助:仅模式变化时打印(刹车进出、降级切换等)
|
||||||
|
auto FinishAndLog = [&](const char* reason) {
|
||||||
|
if (out.mode != last_mode_) {
|
||||||
|
spdlog::info("[ControlLaw] 状态切换: {} -> {} ({})",
|
||||||
|
ModeName(last_mode_), ModeName(out.mode), reason);
|
||||||
|
last_mode_ = out.mode;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- ① 超龄判定(§7.1 / §9.2) ----
|
||||||
|
// 姿态过期或缺失:禁止空间转换和控制,立即退出正常控制(§9.2)
|
||||||
|
if (!snapshot.attitude_present ||
|
||||||
|
StalenessChecker::IsStale(snapshot.attitude_time_ms, now_ms,
|
||||||
|
cfg_.control.attitude_stale_ms)) {
|
||||||
|
out.mode = ControlMode::kDegradedHold;
|
||||||
|
out.degraded_reason = DegradedReason::kAttitudeStale;
|
||||||
|
return FinishAndLog("姿态过期/缺失,禁止控制");
|
||||||
|
}
|
||||||
|
// 视觉追踪结果超龄:不得继续生成正常指令(§7.1)
|
||||||
|
if (StalenessChecker::IsStale(snapshot.track_time_ms, now_ms,
|
||||||
|
cfg_.control.visual_stale_ms)) {
|
||||||
|
out.mode = ControlMode::kDegradedHold;
|
||||||
|
out.degraded_reason = DegradedReason::kVisionStale;
|
||||||
|
return FinishAndLog("视觉追踪结果超龄");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 kBrakeHover) ----
|
||||||
|
if (!snapshot.track.tracked) {
|
||||||
|
if (last_mode_ != ControlMode::kBrakeHover) {
|
||||||
|
// 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅)
|
||||||
|
distance_pid_.Reset();
|
||||||
|
yaw_deg_limiter_.Reset();
|
||||||
|
yaw_rate_limiter_.Reset();
|
||||||
|
vx_limiter_.Reset();
|
||||||
|
vz_limiter_.Reset();
|
||||||
|
}
|
||||||
|
out.mode = ControlMode::kBrakeHover; // 速度分量保持默认全零
|
||||||
|
out.valid = true;
|
||||||
|
return FinishAndLog("判丢,零速刹车");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ③ Coast:is_predicted == true → 清积分防饱和(§6.4) ----
|
||||||
|
if (snapshot.track.is_predicted) {
|
||||||
|
distance_pid_.ResetIntegral();
|
||||||
|
// 航向通道为纯比例增益映射(§4.2),无积分项,无需清理
|
||||||
|
out.coast_active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ④ 像素偏移 → 视线角(§4.1 水平 β / §5.1 垂直 α) ----
|
||||||
|
// x_offset = center.first − W/2(右正),y_offset = center.second − H/2(下正)
|
||||||
|
const double x_offset =
|
||||||
|
snapshot.track.center.first - cfg_.camera.image_width * 0.5;
|
||||||
|
const double y_offset =
|
||||||
|
snapshot.track.center.second - cfg_.camera.image_height * 0.5;
|
||||||
|
const double beta_deg = pixel_to_beta_.Convert(x_offset) * kRadToDeg;
|
||||||
|
const double alpha_deg = pixel_to_alpha_.Convert(y_offset) * kRadToDeg;
|
||||||
|
|
||||||
|
// ---- ⑤ 水平航向通道(§4.2:增益 → slew → 角速度限幅) ----
|
||||||
|
if (cfg_.heading.mode == HeadingMode::kRate) {
|
||||||
|
// 速率式:β → 偏航角速度指令,先变化率限幅再角速度限幅(FR-033)
|
||||||
|
const double target_rate = cfg_.heading.gain * beta_deg;
|
||||||
|
const double slewed = yaw_rate_limiter_.Limit(target_rate, dt_s);
|
||||||
|
out.yaw_rate_dps = Clamp(slewed, -cfg_.heading.yaw_rate_limit_dps,
|
||||||
|
cfg_.heading.yaw_rate_limit_dps);
|
||||||
|
} else {
|
||||||
|
// 位置式:β → 航向修正量,变化率限幅(方向变化率上限,FR-033)后
|
||||||
|
// 与当前航向合成为绝对 NED 航向——ID 84 的 yaw 字段是绝对值,
|
||||||
|
// 直接发修正量会让机体转向"修正角"方向(A2 修复)。姿态已在 ①
|
||||||
|
// 通过超龄校验,yaw_rad 可用。
|
||||||
|
const double target_yaw = cfg_.heading.gain * beta_deg;
|
||||||
|
const double correction_deg = yaw_deg_limiter_.Limit(target_yaw, dt_s);
|
||||||
|
out.yaw_deg = WrapDeg180(snapshot.yaw_rad * kRadToDeg + correction_deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ⑥ 垂直速度通道(§5:统一速度支路) ----
|
||||||
|
// 符号约定:α 下正(目标偏低)→ NED 下正 → vz 为正(下降趋近目标高度)。
|
||||||
|
// 小误差自然映射为小垂直速度:无需单独的切换阈值与姿态微调支路,
|
||||||
|
// 输出模式保持 kVelocityHeading,距离通道前向速度不会被丢弃(A1 修复)。
|
||||||
|
{
|
||||||
|
const double vz_target = cfg_.vertical.vz_gain_mps_per_deg * alpha_deg;
|
||||||
|
const double slewed = vz_limiter_.Limit(vz_target, dt_s); // 加速度限幅(§8)
|
||||||
|
out.vz_mps = Clamp(slewed, -cfg_.vertical.vz_limit_mps,
|
||||||
|
cfg_.vertical.vz_limit_mps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ⑦ 距离通道(§6.2 / §6.3) ----
|
||||||
|
// 雷达距离不得直接当目标距离:仅关联成立且未超龄才可用(§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);
|
||||||
|
if (distance_available) {
|
||||||
|
// Δ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 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,
|
||||||
|
cfg_.distance.approach_velocity_limit_mps);
|
||||||
|
} else {
|
||||||
|
// 无可用目标距离:清积分防饱和,按配置降级动作(§6.3)
|
||||||
|
distance_pid_.ResetIntegral();
|
||||||
|
switch (cfg_.distance.no_distance_action) {
|
||||||
|
case NoDistanceAction::kHold:
|
||||||
|
// 保持:前向指令清零(安全占位语义;接入主工程后可改为保持当前速度)
|
||||||
|
out.vx_mps = 0.0;
|
||||||
|
vx_limiter_.Reset(0.0);
|
||||||
|
break;
|
||||||
|
case NoDistanceAction::kSlowApproach:
|
||||||
|
// 限速接近:缓速前向,同样经加速度限幅
|
||||||
|
out.vx_mps = vx_limiter_.Limit(
|
||||||
|
cfg_.distance.no_distance_approach_limit_mps, dt_s);
|
||||||
|
break;
|
||||||
|
case NoDistanceAction::kExit:
|
||||||
|
// 退出近距操作:整体降级保持
|
||||||
|
out.mode = ControlMode::kDegradedHold;
|
||||||
|
out.degraded_reason = DegradedReason::kNoDistanceExit;
|
||||||
|
return FinishAndLog("距离未关联,退出近距操作");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ⑧ 机体系 → 局部 NED 旋转(§2.3,A4 修复) ----
|
||||||
|
// 到此为止各通道限幅均在机体系完成(旋转为正交变换,合速度不变,
|
||||||
|
// 限幅约束不被破坏);输出端随快照航向旋转为北/东/地,与网关的
|
||||||
|
// MAV_FRAME_LOCAL_NED 一致。姿态已在 ① 通过超龄校验,yaw_rad 可用。
|
||||||
|
RotateBodyToNed(out.vx_mps, out.vy_mps, out.vz_mps, snapshot.yaw_rad);
|
||||||
|
|
||||||
|
out.valid = true;
|
||||||
|
return FinishAndLog(out.coast_active ? "Coast 预测跟踪" : "正常跟踪");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrackingControlLaw::Reset() {
|
||||||
|
// 停止视觉闭环时清全部状态(§6.4)
|
||||||
|
distance_pid_.Reset();
|
||||||
|
yaw_deg_limiter_.Reset();
|
||||||
|
yaw_rate_limiter_.Reset();
|
||||||
|
vx_limiter_.Reset();
|
||||||
|
vz_limiter_.Reset();
|
||||||
|
last_now_ms_ = 0;
|
||||||
|
last_mode_ = ControlMode::kDegradedHold;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// vtc_config.cpp —— 配置加载器实现(启动期一次性解析)
|
||||||
|
//
|
||||||
|
// 对应规格:docs/物理追踪思路.md §8(阈值集中配置化,NFR-007)。
|
||||||
|
//
|
||||||
|
// 实现要点:
|
||||||
|
// - 仅在启动期调用一次:缺字段 / 类型错误 / 非法取值抛
|
||||||
|
// std::runtime_error,异常消息携带完整字段名(如 "heading.gain");
|
||||||
|
// - 热路径绝不触碰 JSON:解析结果拷贝进 TrackingConfig 后即释放;
|
||||||
|
// - 本文件是全库唯一包含 nlohmann/json 的 TU。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include "visual_track_control/vtc_config.h"
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
namespace drone::vtc {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
|
||||||
|
/// 读取并解析 JSON 文件;失败抛异常并携带路径
|
||||||
|
Json ParseConfigFile(const std::string& path) {
|
||||||
|
std::ifstream ifs(path);
|
||||||
|
if (!ifs) {
|
||||||
|
throw std::runtime_error("配置文件打开失败: " + path);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Json::parse(ifs);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
throw std::runtime_error("配置文件解析失败: " + path + " (" + e.what() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取必需的对象分组;缺失或类型错抛异常
|
||||||
|
const Json& RequireSection(const Json& root, const char* section) {
|
||||||
|
const auto it = root.find(section);
|
||||||
|
if (it == root.end() || !it->is_object()) {
|
||||||
|
throw std::runtime_error(std::string("配置缺失分组或类型错误: ") + section);
|
||||||
|
}
|
||||||
|
return *it;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取必需的数值字段;缺失/类型错抛异常并携带字段名
|
||||||
|
double RequireNumber(const Json& section, const char* section_name, const char* key) {
|
||||||
|
const auto it = section.find(key);
|
||||||
|
if (it == section.end() || !it->is_number()) {
|
||||||
|
throw std::runtime_error(std::string("配置缺失字段或类型错误: ") +
|
||||||
|
section_name + "." + key);
|
||||||
|
}
|
||||||
|
return it->get<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取必需的整数字段
|
||||||
|
std::int64_t RequireInteger(const Json& section, const char* section_name,
|
||||||
|
const char* key) {
|
||||||
|
const auto it = section.find(key);
|
||||||
|
if (it == section.end() || !it->is_number_integer()) {
|
||||||
|
throw std::runtime_error(std::string("配置缺失字段或类型错误(需整数): ") +
|
||||||
|
section_name + "." + key);
|
||||||
|
}
|
||||||
|
return it->get<std::int64_t>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取必需的字符串字段
|
||||||
|
std::string RequireString(const Json& section, const char* section_name,
|
||||||
|
const char* key) {
|
||||||
|
const auto it = section.find(key);
|
||||||
|
if (it == section.end() || !it->is_string()) {
|
||||||
|
throw std::runtime_error(std::string("配置缺失字段或类型错误(需字符串): ") +
|
||||||
|
section_name + "." + key);
|
||||||
|
}
|
||||||
|
return it->get<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验字段为正数;非法抛异常并携带字段名
|
||||||
|
double RequirePositive(double value, const char* field_name) {
|
||||||
|
if (!(value > 0.0)) {
|
||||||
|
throw std::runtime_error(std::string("配置非法(必须为正): ") + field_name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验字段为非负数
|
||||||
|
double RequireNonNegative(double value, const char* field_name) {
|
||||||
|
if (value < 0.0) {
|
||||||
|
throw std::runtime_error(std::string("配置非法(不得为负): ") + field_name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验字段在开区间 (lo, hi)
|
||||||
|
double RequireRange(double value, double lo, double hi, const char* field_name) {
|
||||||
|
if (!(value > lo && value < hi)) {
|
||||||
|
throw std::runtime_error(std::string("配置非法(超出范围): ") + field_name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlGroupConfig LoadControl(const Json& root) {
|
||||||
|
const Json& s = RequireSection(root, "control");
|
||||||
|
ControlGroupConfig c;
|
||||||
|
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.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) {
|
||||||
|
throw std::runtime_error("配置非法: control.*_stale_ms 必须为正");
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraGroupConfig LoadCamera(const Json& root) {
|
||||||
|
const Json& s = RequireSection(root, "camera");
|
||||||
|
CameraGroupConfig c;
|
||||||
|
c.image_width = static_cast<int>(
|
||||||
|
RequirePositive(RequireInteger(s, "camera", "image_width"), "camera.image_width"));
|
||||||
|
c.image_height = static_cast<int>(
|
||||||
|
RequirePositive(RequireInteger(s, "camera", "image_height"), "camera.image_height"));
|
||||||
|
c.fov_h_deg = RequireRange(RequireNumber(s, "camera", "fov_h_deg"), 0.0, 180.0,
|
||||||
|
"camera.fov_h_deg");
|
||||||
|
c.fov_v_deg = RequireRange(RequireNumber(s, "camera", "fov_v_deg"), 0.0, 180.0,
|
||||||
|
"camera.fov_v_deg");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
HeadingGroupConfig LoadHeading(const Json& root) {
|
||||||
|
const Json& s = RequireSection(root, "heading");
|
||||||
|
HeadingGroupConfig h;
|
||||||
|
const std::string mode = RequireString(s, "heading", "mode");
|
||||||
|
if (mode == "position") {
|
||||||
|
h.mode = HeadingMode::kPosition;
|
||||||
|
} else if (mode == "rate") {
|
||||||
|
h.mode = HeadingMode::kRate;
|
||||||
|
} else {
|
||||||
|
throw std::runtime_error("配置非法: heading.mode 仅支持 position / rate");
|
||||||
|
}
|
||||||
|
h.gain = RequireNonNegative(RequireNumber(s, "heading", "gain"), "heading.gain");
|
||||||
|
h.yaw_rate_limit_dps = RequirePositive(
|
||||||
|
RequireNumber(s, "heading", "yaw_rate_limit_dps"), "heading.yaw_rate_limit_dps");
|
||||||
|
h.yaw_slew_limit_dps2 = RequirePositive(
|
||||||
|
RequireNumber(s, "heading", "yaw_slew_limit_dps2"), "heading.yaw_slew_limit_dps2");
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalGroupConfig LoadVertical(const Json& root) {
|
||||||
|
const Json& s = RequireSection(root, "vertical");
|
||||||
|
VerticalGroupConfig v;
|
||||||
|
v.vz_gain_mps_per_deg = RequireNonNegative(
|
||||||
|
RequireNumber(s, "vertical", "vz_gain_mps_per_deg"), "vertical.vz_gain_mps_per_deg");
|
||||||
|
v.vz_limit_mps = RequirePositive(RequireNumber(s, "vertical", "vz_limit_mps"),
|
||||||
|
"vertical.vz_limit_mps");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
DistanceGroupConfig LoadDistance(const Json& root) {
|
||||||
|
const Json& s = RequireSection(root, "distance");
|
||||||
|
DistanceGroupConfig d;
|
||||||
|
d.d_exp_m = RequirePositive(RequireNumber(s, "distance", "d_exp_m"), "distance.d_exp_m");
|
||||||
|
d.kp = RequireNonNegative(RequireNumber(s, "distance", "kp"), "distance.kp");
|
||||||
|
d.ki = RequireNonNegative(RequireNumber(s, "distance", "ki"), "distance.ki");
|
||||||
|
d.kd = RequireNonNegative(RequireNumber(s, "distance", "kd"), "distance.kd");
|
||||||
|
d.integral_limit = RequireNonNegative(RequireNumber(s, "distance", "integral_limit"),
|
||||||
|
"distance.integral_limit");
|
||||||
|
d.derivative_filter_coef = RequireRange(
|
||||||
|
RequireNumber(s, "distance", "derivative_filter_coef"), -0.000001, 1.0,
|
||||||
|
"distance.derivative_filter_coef");
|
||||||
|
if (d.derivative_filter_coef < 0.0) {
|
||||||
|
d.derivative_filter_coef = 0.0; // 容许 -0.0
|
||||||
|
}
|
||||||
|
d.approach_velocity_limit_mps = RequirePositive(
|
||||||
|
RequireNumber(s, "distance", "approach_velocity_limit_mps"),
|
||||||
|
"distance.approach_velocity_limit_mps");
|
||||||
|
d.retreat_velocity_limit_mps = RequirePositive(
|
||||||
|
RequireNumber(s, "distance", "retreat_velocity_limit_mps"),
|
||||||
|
"distance.retreat_velocity_limit_mps");
|
||||||
|
const std::string action = RequireString(s, "distance", "no_distance_action");
|
||||||
|
if (action == "hold") {
|
||||||
|
d.no_distance_action = NoDistanceAction::kHold;
|
||||||
|
} else if (action == "slow_approach") {
|
||||||
|
d.no_distance_action = NoDistanceAction::kSlowApproach;
|
||||||
|
} else if (action == "exit") {
|
||||||
|
d.no_distance_action = NoDistanceAction::kExit;
|
||||||
|
} else {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"配置非法: distance.no_distance_action 仅支持 hold / slow_approach / exit");
|
||||||
|
}
|
||||||
|
d.no_distance_approach_limit_mps = RequireNonNegative(
|
||||||
|
RequireNumber(s, "distance", "no_distance_approach_limit_mps"),
|
||||||
|
"distance.no_distance_approach_limit_mps");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccelLimitConfig LoadAccelLimit(const Json& root) {
|
||||||
|
const Json& s = RequireSection(root, "accel_limit");
|
||||||
|
AccelLimitConfig a;
|
||||||
|
a.ax_mps2 = RequirePositive(RequireNumber(s, "accel_limit", "ax_mps2"),
|
||||||
|
"accel_limit.ax_mps2");
|
||||||
|
a.ay_mps2 = RequirePositive(RequireNumber(s, "accel_limit", "ay_mps2"),
|
||||||
|
"accel_limit.ay_mps2");
|
||||||
|
a.az_mps2 = RequirePositive(RequireNumber(s, "accel_limit", "az_mps2"),
|
||||||
|
"accel_limit.az_mps2");
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TrackingConfig TrackingConfig::LoadFromJson(const std::string& path) {
|
||||||
|
const Json root = ParseConfigFile(path);
|
||||||
|
TrackingConfig cfg;
|
||||||
|
cfg.control = LoadControl(root);
|
||||||
|
cfg.camera = LoadCamera(root);
|
||||||
|
cfg.heading = LoadHeading(root);
|
||||||
|
cfg.vertical = LoadVertical(root);
|
||||||
|
cfg.distance = LoadDistance(root);
|
||||||
|
cfg.accel_limit = LoadAccelLimit(root);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace drone::vtc
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// frame_transform_test.cpp —— RotateBodyToNed 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §2.3,A4):
|
||||||
|
// 零航向恒等、朝东时机体前向 → 东向、垂直分量不变、模长守恒。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/frame_transform.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::RotateBodyToNed;
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
|
||||||
|
TEST(FrameTransformTest, ZeroYawIsIdentity) {
|
||||||
|
double x = 1.0, y = 0.5, z = -2.0;
|
||||||
|
RotateBodyToNed(x, y, z, 0.0);
|
||||||
|
EXPECT_NEAR(x, 1.0, 1e-12); // 北 = 前
|
||||||
|
EXPECT_NEAR(y, 0.5, 1e-12); // 东 = 右
|
||||||
|
EXPECT_NEAR(z, -2.0, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameTransformTest, YawEast90MapsForwardToEast) {
|
||||||
|
double x = 2.0, y = 0.0, z = 0.0;
|
||||||
|
RotateBodyToNed(x, y, z, kPi / 2.0); // 机头朝东
|
||||||
|
EXPECT_NEAR(x, 0.0, 1e-9); // 北向 ≈ 0
|
||||||
|
EXPECT_NEAR(y, 2.0, 1e-9); // 东向 = 前向速度
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameTransformTest, RightIsEastWhenHeadingNorth) {
|
||||||
|
double x = 0.0, y = 1.0, z = 0.0;
|
||||||
|
RotateBodyToNed(x, y, z, 0.0);
|
||||||
|
EXPECT_NEAR(x, 0.0, 1e-12);
|
||||||
|
EXPECT_NEAR(y, 1.0, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameTransformTest, DownComponentUnchanged) {
|
||||||
|
double x = 1.0, y = 1.0, z = 3.5;
|
||||||
|
RotateBodyToNed(x, y, z, kPi / 3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(z, 3.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameTransformTest, PreservesHorizontalNorm) {
|
||||||
|
// 旋转为正交变换:水平合速度不变(限幅约束不被破坏的前提)
|
||||||
|
double x = 3.0, y = -4.0, z = 1.0;
|
||||||
|
const double norm_before = std::hypot(x, y);
|
||||||
|
RotateBodyToNed(x, y, z, 0.7);
|
||||||
|
EXPECT_NEAR(std::hypot(x, y), norm_before, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// line_of_sight_test.cpp —— PixelToAngle 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §4.1 / §5.1,C2):
|
||||||
|
// 零偏移、画面边缘偏移 = 半视场角、奇对称、单调性、
|
||||||
|
// 与线性近似的偏离(全程针孔模型)、构造期非法参数抛异常。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/line_of_sight.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::PixelToAngle;
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
constexpr double kRadToDeg = 180.0 / kPi;
|
||||||
|
|
||||||
|
TEST(PixelToAngleTest, ZeroOffsetGivesZeroAngle) {
|
||||||
|
PixelToAngle conv(1000.0, 90.0 * kPi / 180.0);
|
||||||
|
EXPECT_DOUBLE_EQ(conv.Convert(0.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PixelToAngleTest, HalfSizeOffsetGivesHalfFov) {
|
||||||
|
// 针孔模型:偏移 = size/2 时 angle = atan(tan(θ/2)) = θ/2
|
||||||
|
PixelToAngle conv(1000.0, 90.0 * kPi / 180.0);
|
||||||
|
EXPECT_NEAR(conv.Convert(500.0), 45.0 * kPi / 180.0, 1e-12);
|
||||||
|
EXPECT_NEAR(conv.Convert(-500.0), -45.0 * kPi / 180.0, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PixelToAngleTest, OddSymmetry) {
|
||||||
|
PixelToAngle conv(1920.0, 60.0 * kPi / 180.0);
|
||||||
|
for (const double offset : {1.0, 100.0, 730.5}) {
|
||||||
|
EXPECT_NEAR(conv.Convert(-offset), -conv.Convert(offset), 1e-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PixelToAngleTest, MonotonicIncreasing) {
|
||||||
|
PixelToAngle conv(1080.0, 35.0 * kPi / 180.0);
|
||||||
|
double prev = conv.Convert(-600.0);
|
||||||
|
for (double x = -500.0; x <= 600.0; x += 100.0) {
|
||||||
|
const double cur = conv.Convert(x);
|
||||||
|
EXPECT_GT(cur, prev);
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PixelToAngleTest, DeviatesFromLinearApproximation) {
|
||||||
|
// C2:全程针孔模型。θ=90°、偏移比例 0.25 处线性近似误差约 4°,
|
||||||
|
// 针孔结果必须与之明显偏离(证明未走线性支路)。
|
||||||
|
PixelToAngle conv(1000.0, 90.0 * kPi / 180.0);
|
||||||
|
const double pinhole_deg = conv.Convert(250.0) * kRadToDeg; // atan(250·2·tan45°/1000)
|
||||||
|
const double linear_deg = 250.0 * (90.0 / 1000.0); // θ·offset/size = 22.5°
|
||||||
|
EXPECT_NEAR(pinhole_deg, std::atan(0.5) * kRadToDeg, 1e-9); // 26.565°
|
||||||
|
EXPECT_GT(std::fabs(pinhole_deg - linear_deg), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PixelToAngleTest, ConstructorRejectsInvalidArgs) {
|
||||||
|
EXPECT_THROW(PixelToAngle(0.0, 1.0), std::invalid_argument);
|
||||||
|
EXPECT_THROW(PixelToAngle(-10.0, 1.0), std::invalid_argument);
|
||||||
|
EXPECT_THROW(PixelToAngle(1000.0, 0.0), std::invalid_argument);
|
||||||
|
EXPECT_THROW(PixelToAngle(1000.0, kPi), std::invalid_argument);
|
||||||
|
EXPECT_THROW(PixelToAngle(1000.0, kPi + 0.1), std::invalid_argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// mavlink_command_gateway_test.cpp —— MavlinkCommandGateway 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §2.2、§4.2,A2 掩码三分支):
|
||||||
|
// ID 84 速度模式的 type_mask 选择(角速率非零 → 速率掩码;航向角非零 →
|
||||||
|
// 航向掩码;两者均零 → 速率掩码配零角速率,保持航向)、
|
||||||
|
// 速度/航向字段编码、刹车消息、坐标基准、传输失败传播。
|
||||||
|
//
|
||||||
|
// 构建约束:本文件**不得**包含 common/mavlink.h(MAVLINK_HELPER 默认空宏,
|
||||||
|
// 多 TU 包含会多重定义),故按 MAVLink v2 线格式手工解码:
|
||||||
|
// 帧头 10 字节(STX=0xFD, len, flags×2, seq, sys, comp, msgid×3 LE),
|
||||||
|
// 载荷按字段尺寸降序排列(ID 84:time,x,y,z,vx,vy,vz,afx,afy,afz,
|
||||||
|
// yaw,yaw_rate 均 4 字节在前,随后 u16 type_mask,再 target_system、
|
||||||
|
// target_component、coordinate_frame 三个 u8)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/i_command_transport.h"
|
||||||
|
#include "visual_track_control/mavlink_command_gateway.h"
|
||||||
|
#include "visual_track_control/vtc_types.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::ControlOutput;
|
||||||
|
using drone::vtc::ICommandTransport;
|
||||||
|
using drone::vtc::MavlinkCommandGateway;
|
||||||
|
|
||||||
|
// ---- MAVLink v2 帧布局常量(见文件头说明) ----
|
||||||
|
constexpr std::uint8_t kMavlinkV2Stx = 0xFD;
|
||||||
|
constexpr std::size_t kHeaderLen = 10;
|
||||||
|
constexpr std::size_t kMsgId84PayloadLen = 53;
|
||||||
|
|
||||||
|
// ---- ID 84 载荷字段偏移(相对载荷起点) ----
|
||||||
|
constexpr std::size_t kOffVx = 16;
|
||||||
|
constexpr std::size_t kOffVy = 20;
|
||||||
|
constexpr std::size_t kOffVz = 24;
|
||||||
|
constexpr std::size_t kOffYaw = 40;
|
||||||
|
constexpr std::size_t kOffYawRate = 44;
|
||||||
|
constexpr std::size_t kOffTypeMask = 48;
|
||||||
|
// u8 三字段线序(生成头 mavlink_msg_set_position_target_local_ned.h 为准)
|
||||||
|
constexpr std::size_t kOffTargetSystem = 50;
|
||||||
|
constexpr std::size_t kOffTargetComponent = 51;
|
||||||
|
constexpr std::size_t kOffCoordFrame = 52;
|
||||||
|
|
||||||
|
// ---- 预期掩码值(与实现中常量一致:置位 = 忽略) ----
|
||||||
|
// 基础 0x01C7(忽略位置与加速度)| 航向相关位
|
||||||
|
constexpr std::uint16_t kExpectMaskYaw = 0x01C7 | 0x0800; // 忽略 yaw_rate
|
||||||
|
constexpr std::uint16_t kExpectMaskYawRate = 0x01C7 | 0x0400; // 忽略 yaw
|
||||||
|
constexpr std::uint16_t kExpectMaskBrake = 0x01C7 | 0x0C00; // 两者均忽略
|
||||||
|
|
||||||
|
/// 捕获式传输:记录最后一次发送的字节流,可注入失败
|
||||||
|
class CaptureTransport : public ICommandTransport {
|
||||||
|
public:
|
||||||
|
bool Send(const std::uint8_t* data, std::size_t len) override {
|
||||||
|
++send_count_;
|
||||||
|
last_.assign(data, data + len);
|
||||||
|
return succeed_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetSucceed(bool ok) { succeed_ = ok; }
|
||||||
|
int send_count() const { return send_count_; }
|
||||||
|
const std::vector<std::uint8_t>& last() const { return last_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool succeed_ = true;
|
||||||
|
int send_count_ = 0;
|
||||||
|
std::vector<std::uint8_t> last_;
|
||||||
|
};
|
||||||
|
|
||||||
|
float ReadFloat(const std::vector<std::uint8_t>& buf, std::size_t payload_off) {
|
||||||
|
float v;
|
||||||
|
std::memcpy(&v, buf.data() + kHeaderLen + payload_off, sizeof(v));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t ReadU16(const std::vector<std::uint8_t>& buf, std::size_t payload_off) {
|
||||||
|
return static_cast<std::uint16_t>(buf[kHeaderLen + payload_off]) |
|
||||||
|
(static_cast<std::uint16_t>(buf[kHeaderLen + payload_off + 1]) << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ExpectId84Header(const std::vector<std::uint8_t>& buf) {
|
||||||
|
ASSERT_GE(buf.size(), kHeaderLen + kMsgId84PayloadLen + 2); // +CRC
|
||||||
|
EXPECT_EQ(buf[0], kMavlinkV2Stx);
|
||||||
|
EXPECT_EQ(buf[1], kMsgId84PayloadLen);
|
||||||
|
// msgid 24 位小端(字节 7-9)= 84
|
||||||
|
EXPECT_EQ(buf[7], 84);
|
||||||
|
EXPECT_EQ(buf[8], 0);
|
||||||
|
EXPECT_EQ(buf[9], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
|
||||||
|
|
||||||
|
TEST(MavlinkGatewayTest, YawRateModeSelectsRateMask) {
|
||||||
|
CaptureTransport transport;
|
||||||
|
MavlinkCommandGateway gateway(transport, 1, 2, 3, 4);
|
||||||
|
|
||||||
|
ControlOutput out;
|
||||||
|
out.mode = drone::vtc::ControlMode::kVelocityHeading;
|
||||||
|
out.vx_mps = 1.5;
|
||||||
|
out.vy_mps = -0.5;
|
||||||
|
out.vz_mps = 0.25;
|
||||||
|
out.yaw_rate_dps = 5.0;
|
||||||
|
out.yaw_deg = 0.0;
|
||||||
|
ASSERT_TRUE(gateway.SendVelocityHeading(out, 12345));
|
||||||
|
|
||||||
|
const auto& buf = transport.last();
|
||||||
|
ExpectId84Header(buf);
|
||||||
|
EXPECT_EQ(ReadU16(buf, kOffTypeMask), kExpectMaskYawRate);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffVx), 1.5F);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffVy), -0.5F);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffVz), 0.25F);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffYawRate), static_cast<float>(5.0 * kDegToRad));
|
||||||
|
EXPECT_EQ(buf[kHeaderLen + kOffCoordFrame], 1); // MAV_FRAME_LOCAL_NED
|
||||||
|
EXPECT_EQ(buf[kHeaderLen + kOffTargetSystem], 3); // target_system
|
||||||
|
EXPECT_EQ(buf[kHeaderLen + kOffTargetComponent], 4); // target_component
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MavlinkGatewayTest, YawOnlySelectsYawMask) {
|
||||||
|
CaptureTransport transport;
|
||||||
|
MavlinkCommandGateway gateway(transport, 1, 2, 3, 4);
|
||||||
|
|
||||||
|
ControlOutput out;
|
||||||
|
out.mode = drone::vtc::ControlMode::kVelocityHeading;
|
||||||
|
out.yaw_deg = 10.0;
|
||||||
|
out.yaw_rate_dps = 0.0;
|
||||||
|
ASSERT_TRUE(gateway.SendVelocityHeading(out, 1));
|
||||||
|
|
||||||
|
const auto& buf = transport.last();
|
||||||
|
ExpectId84Header(buf);
|
||||||
|
EXPECT_EQ(ReadU16(buf, kOffTypeMask), kExpectMaskYaw);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffYaw), static_cast<float>(10.0 * kDegToRad));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MavlinkGatewayTest, BothZeroKeepsHeadingViaRateMask) {
|
||||||
|
// A2 回归:旧实现在零角速率时落到航向掩码,等于误发绝对航向 0(正北)。
|
||||||
|
// 正确行为:速率掩码 + 零角速率 = 保持当前航向。
|
||||||
|
CaptureTransport transport;
|
||||||
|
MavlinkCommandGateway gateway(transport, 1, 2, 3, 4);
|
||||||
|
|
||||||
|
ControlOutput out;
|
||||||
|
out.mode = drone::vtc::ControlMode::kVelocityHeading;
|
||||||
|
out.vx_mps = 1.0;
|
||||||
|
ASSERT_TRUE(gateway.SendVelocityHeading(out, 1));
|
||||||
|
|
||||||
|
const auto& buf = transport.last();
|
||||||
|
ExpectId84Header(buf);
|
||||||
|
EXPECT_EQ(ReadU16(buf, kOffTypeMask), kExpectMaskYawRate);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffYawRate), 0.0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MavlinkGatewayTest, BrakeHoverSendsZeroVelocityAllIgnored) {
|
||||||
|
CaptureTransport transport;
|
||||||
|
MavlinkCommandGateway gateway(transport, 1, 2, 3, 4);
|
||||||
|
ASSERT_TRUE(gateway.SendBrakeHover(777));
|
||||||
|
|
||||||
|
const auto& buf = transport.last();
|
||||||
|
ExpectId84Header(buf);
|
||||||
|
EXPECT_EQ(ReadU16(buf, kOffTypeMask), kExpectMaskBrake);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffVx), 0.0F);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffVy), 0.0F);
|
||||||
|
EXPECT_FLOAT_EQ(ReadFloat(buf, kOffVz), 0.0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MavlinkGatewayTest, TransportFailurePropagates) {
|
||||||
|
CaptureTransport transport;
|
||||||
|
transport.SetSucceed(false);
|
||||||
|
MavlinkCommandGateway gateway(transport, 1, 2, 3, 4);
|
||||||
|
|
||||||
|
ControlOutput out;
|
||||||
|
out.mode = drone::vtc::ControlMode::kVelocityHeading;
|
||||||
|
EXPECT_FALSE(gateway.SendVelocityHeading(out, 1));
|
||||||
|
EXPECT_FALSE(gateway.SendBrakeHover(1));
|
||||||
|
EXPECT_EQ(transport.send_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// pid_controller_test.cpp —— PidController 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §6.2 / §6.4):
|
||||||
|
// 位置式 P/I/D 三项、积分钳位、输出限幅、异常节拍保护、
|
||||||
|
// anti-windup 条件积分(C1 修复回归:饱和冻结 + 误差反号回卷)、
|
||||||
|
// ResetIntegral / Reset 行为。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/pid_controller.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::PidController;
|
||||||
|
using drone::vtc::PidParams;
|
||||||
|
|
||||||
|
PidParams MakeParams(double kp, double ki, double kd, double integral_limit,
|
||||||
|
double output_limit, double filter_coef = 0.0) {
|
||||||
|
PidParams p;
|
||||||
|
p.kp = kp;
|
||||||
|
p.ki = ki;
|
||||||
|
p.kd = kd;
|
||||||
|
p.integral_limit = integral_limit;
|
||||||
|
p.derivative_filter_coef = filter_coef;
|
||||||
|
p.output_limit = output_limit;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, ProportionalOnly) {
|
||||||
|
PidController pid(MakeParams(2.0, 0.0, 0.0, 0.0, 0.0));
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(3.0, 0.1), 6.0);
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(-1.5, 0.1), -3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, IntegralAccumulates) {
|
||||||
|
PidController pid(MakeParams(0.0, 1.0, 0.0, 0.0, 0.0));
|
||||||
|
// 两拍 error=2、dt=0.5:积分累计 1.0 → 2.0
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(2.0, 0.5), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(2.0, 0.5), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, IntegralClampedToLimit) {
|
||||||
|
PidController pid(MakeParams(0.0, 1.0, 0.0, /*integral_limit=*/1.0, 0.0));
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
pid.Update(10.0, 1.0);
|
||||||
|
}
|
||||||
|
// 积分候选值被钳位在 1.0 → 输出恒为 1.0
|
||||||
|
EXPECT_DOUBLE_EQ(pid.LastOutput(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, OutputClampedToLimit) {
|
||||||
|
PidController pid(MakeParams(10.0, 0.0, 0.0, 0.0, /*output_limit=*/3.0));
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(1.0, 0.1), 3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(-1.0, 0.1), -3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, AntiWindupFreezeWhileSaturatedSameDirection) {
|
||||||
|
// kp=0、ki=2、output_limit=5:error=+1 持续饱和时积分冻结,
|
||||||
|
// 输出恒钉在 +5(10 拍累计 10 拍也不会让积分继续增长)。
|
||||||
|
PidController pid(MakeParams(0.0, 2.0, 0.0, 0.0, 5.0));
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(10.0, 1.0), 5.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, AntiWindupUnwindsAfterErrorFlips) {
|
||||||
|
// C1 回归:误差反号后积分必须立即回卷(旧判据"积分与输出同向"会
|
||||||
|
// 冻结回卷、输出一直钉在 +5)。
|
||||||
|
// kp=0、ki=2、limit=5:+10 饱和 3 拍、积分冻结在 0;
|
||||||
|
// error=-1 首拍 → 输出 2×(-1) = -2(立即回卷);
|
||||||
|
// 持续负误差使输出到达负饱和 -5(积分冻结在 -2,输出 = clamp(-6))。
|
||||||
|
PidController pid(MakeParams(0.0, 2.0, 0.0, 0.0, 5.0));
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(10.0, 1.0), 5.0);
|
||||||
|
}
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(-1.0, 1.0), -2.0); // 首拍立即回卷
|
||||||
|
double out = -2.0;
|
||||||
|
for (int i = 1; i < 12; ++i) {
|
||||||
|
out = pid.Update(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
EXPECT_NEAR(out, -5.0, 1e-9); // 旧判据此处仍为 +5.0
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, DerivativeFirstStepIsZero) {
|
||||||
|
PidController pid(MakeParams(0.0, 0.0, 1.0, 0.0, 0.0));
|
||||||
|
// 首拍无微分历史:微分项为 0
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(5.0, 1.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, DerivativeWithLowPassFilter) {
|
||||||
|
PidController pid(MakeParams(0.0, 0.0, 1.0, 0.0, 0.0, /*filter=*/0.5));
|
||||||
|
pid.Update(0.0, 1.0); // 建立微分历史(导数 0)
|
||||||
|
// 第二拍原始导数 1.0,低通后 0.5×0 + 0.5×1.0 = 0.5
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(1.0, 1.0), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, ZeroOrNegativeDtHoldsLastOutput) {
|
||||||
|
PidController pid(MakeParams(1.0, 0.0, 0.0, 0.0, 0.0));
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(2.0, 0.1), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(99.0, 0.0), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(99.0, -1.0), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, ResetIntegralClearsOnlyIntegral) {
|
||||||
|
PidController pid(MakeParams(0.0, 1.0, 1.0, 0.0, 0.0));
|
||||||
|
pid.Update(2.0, 1.0); // 积分 = 2
|
||||||
|
pid.ResetIntegral();
|
||||||
|
// 积分清零、微分历史保留:输出应为 kd×(0−2)/1 = −2
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(0.0, 1.0), -2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PidControllerTest, ResetClearsEverything) {
|
||||||
|
PidController pid(MakeParams(1.0, 1.0, 1.0, 0.0, 0.0));
|
||||||
|
pid.Update(5.0, 1.0);
|
||||||
|
pid.Reset();
|
||||||
|
EXPECT_DOUBLE_EQ(pid.LastOutput(), 0.0);
|
||||||
|
// 全清后等同全新控制器:首拍无微分历史
|
||||||
|
EXPECT_DOUBLE_EQ(pid.Update(3.0, 1.0), 3.0 + 3.0); // kp·e + ki·∫e
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// rate_limiter_test.cpp —— Clamp / SlewRateLimiter 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §8、状态机设计 S6):
|
||||||
|
// 静态限幅、变化率限幅步进、首条指令从零渐变(S6)、
|
||||||
|
// 异常节拍保护、Reset 归零。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/rate_limiter.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::Clamp;
|
||||||
|
using drone::vtc::SlewRateLimiter;
|
||||||
|
|
||||||
|
TEST(ClampTest, ClampsValue) {
|
||||||
|
EXPECT_DOUBLE_EQ(Clamp(5.0, -2.0, 2.0), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(Clamp(-5.0, -2.0, 2.0), -2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(Clamp(1.5, -2.0, 2.0), 1.5);
|
||||||
|
// 边界值:落在区间内
|
||||||
|
EXPECT_DOUBLE_EQ(Clamp(2.0, -2.0, 2.0), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(Clamp(-2.0, -2.0, 2.0), -2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, FirstCommandRampsFromZero) {
|
||||||
|
// S6:初始输出为 0,首条大指令只能按变化率渐变
|
||||||
|
SlewRateLimiter lim(/*rate_limit=*/2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, 0.5), 1.0); // 0 + 2×0.5
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, 0.5), 2.0); // 继续渐变
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, 10.0), 10.0); // 步长足够 → 到达目标
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, TracksTargetWithinStep) {
|
||||||
|
SlewRateLimiter lim(10.0);
|
||||||
|
// 步长 10×1=10,目标 3 在步长内 → 直接到达
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(3.0, 1.0), 3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(3.0, 1.0), 3.0); // 已在目标:保持
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, NegativeDirection) {
|
||||||
|
SlewRateLimiter lim(2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(-10.0, 0.5), -1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(-10.0, 0.5), -2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, ZeroOrNegativeDtHolds) {
|
||||||
|
SlewRateLimiter lim(2.0);
|
||||||
|
lim.Limit(10.0, 1.0); // 输出 2.0
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, 0.0), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, -1.0), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, ZeroRateLimitFreezesOutput) {
|
||||||
|
SlewRateLimiter lim(0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(5.0, 1.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, ResetToValue) {
|
||||||
|
SlewRateLimiter lim(2.0);
|
||||||
|
lim.Limit(10.0, 1.0);
|
||||||
|
lim.Reset();
|
||||||
|
EXPECT_DOUBLE_EQ(lim.LastOutput(), 0.0);
|
||||||
|
lim.Reset(3.5);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.LastOutput(), 3.5);
|
||||||
|
// 复位后从新值继续渐变
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, 1.0), 5.5); // 3.5 + 2×1
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SlewRateLimiterTest, CustomInitialValue) {
|
||||||
|
SlewRateLimiter lim(1.0, /*initial=*/5.0);
|
||||||
|
EXPECT_DOUBLE_EQ(lim.Limit(10.0, 1.0), 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// staleness_checker_test.cpp —— StalenessChecker 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §7.1):
|
||||||
|
// 从未取样判超龄、有效期内新鲜、超出有效期超龄、
|
||||||
|
// 边界恰好在有效期上(不算超龄)、时间戳不一致判超龄(C4)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/staleness_checker.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::StalenessChecker;
|
||||||
|
|
||||||
|
TEST(StalenessCheckerTest, NeverSampledIsStale) {
|
||||||
|
EXPECT_TRUE(StalenessChecker::IsStale(0, 1000, 200));
|
||||||
|
EXPECT_TRUE(StalenessChecker::IsStale(-5, 1000, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(StalenessCheckerTest, WithinValidityIsFresh) {
|
||||||
|
EXPECT_FALSE(StalenessChecker::IsStale(900, 1000, 200));
|
||||||
|
EXPECT_FALSE(StalenessChecker::IsStale(1000, 1000, 200)); // 刚取样
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(StalenessCheckerTest, BeyondValidityIsStale) {
|
||||||
|
EXPECT_TRUE(StalenessChecker::IsStale(799, 1000, 200)); // 201 > 200
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(StalenessCheckerTest, ExactlyAtBoundaryIsFresh) {
|
||||||
|
// 超龄判定为严格大于:恰好等于有效期仍算新鲜
|
||||||
|
EXPECT_FALSE(StalenessChecker::IsStale(800, 1000, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(StalenessCheckerTest, InconsistentTimestampIsStale) {
|
||||||
|
// C4:now < sample 时间戳不一致 → 年龄不可信 → 判超龄(宁降级不轻信)
|
||||||
|
EXPECT_TRUE(StalenessChecker::IsStale(2000, 1000, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// tracking_control_law_test.cpp —— TrackingControlLaw 主控律单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §2.3 / §4 / §5 / §6 / §7 / §9):
|
||||||
|
// 超龄降级(姿态缺失/过期、视觉超龄、降级优先级)、判丢零速刹车、
|
||||||
|
// Coast 清积分与标志、速率式/位置式航向通道(含绝对航向合成与回绕,
|
||||||
|
// A2)、垂直统一速度支路(A1)、距离通道三种无距离降级动作、
|
||||||
|
// 首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/tracking_control_law.h"
|
||||||
|
#include "visual_track_control/vtc_config.h"
|
||||||
|
#include "visual_track_control/vtc_types.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace drone::vtc;
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
constexpr double kDegToRad = kPi / 180.0;
|
||||||
|
constexpr double kRadToDeg = 180.0 / kPi;
|
||||||
|
|
||||||
|
/// 测试基准配置:大限幅使限幅器近似透明,便于独立验证各通道数学。
|
||||||
|
/// 画面 1000×1000、视场角 90° → 针孔系数 2·tan(45°)/1000 = 0.002,
|
||||||
|
/// 偏移 100 像素 → 视线角 atan(0.2) ≈ 11.3099°。
|
||||||
|
TrackingConfig MakeConfig() {
|
||||||
|
TrackingConfig cfg;
|
||||||
|
cfg.control.frequency_hz = 20.0;
|
||||||
|
cfg.control.visual_stale_ms = 200;
|
||||||
|
cfg.control.radar_stale_ms = 500;
|
||||||
|
cfg.control.attitude_stale_ms = 300;
|
||||||
|
cfg.camera.image_width = 1000;
|
||||||
|
cfg.camera.image_height = 1000;
|
||||||
|
cfg.camera.fov_h_deg = 90.0;
|
||||||
|
cfg.camera.fov_v_deg = 90.0;
|
||||||
|
cfg.heading.mode = HeadingMode::kRate;
|
||||||
|
cfg.heading.gain = 1.0;
|
||||||
|
cfg.heading.yaw_rate_limit_dps = 30.0;
|
||||||
|
cfg.heading.yaw_slew_limit_dps2 = 100000.0;
|
||||||
|
cfg.vertical.vz_gain_mps_per_deg = 0.1;
|
||||||
|
cfg.vertical.vz_limit_mps = 2.0;
|
||||||
|
cfg.distance.d_exp_m = 10.0;
|
||||||
|
cfg.distance.kp = 0.5;
|
||||||
|
cfg.distance.ki = 0.0;
|
||||||
|
cfg.distance.kd = 0.0;
|
||||||
|
cfg.distance.integral_limit = 0.0;
|
||||||
|
cfg.distance.derivative_filter_coef = 0.0;
|
||||||
|
cfg.distance.approach_velocity_limit_mps = 3.0;
|
||||||
|
cfg.distance.retreat_velocity_limit_mps = 1.0;
|
||||||
|
cfg.distance.no_distance_action = NoDistanceAction::kHold;
|
||||||
|
cfg.distance.no_distance_approach_limit_mps = 0.5;
|
||||||
|
cfg.accel_limit.ax_mps2 = 1000.0;
|
||||||
|
cfg.accel_limit.ay_mps2 = 1000.0;
|
||||||
|
cfg.accel_limit.az_mps2 = 1000.0;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜
|
||||||
|
ControlSnapshot MakeSnapshot(std::int64_t now_ms) {
|
||||||
|
ControlSnapshot s;
|
||||||
|
s.track.tracked = true;
|
||||||
|
s.track.is_predicted = false;
|
||||||
|
s.track.center = {500.0, 500.0};
|
||||||
|
s.track_time_ms = now_ms;
|
||||||
|
s.attitude_present = true;
|
||||||
|
s.attitude_time_ms = now_ms;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 超龄降级(§7.1 / §9.2) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, AttitudeMissingDegrades) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.attitude_present = false;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kDegradedHold);
|
||||||
|
EXPECT_EQ(out.degraded_reason, DegradedReason::kAttitudeStale);
|
||||||
|
EXPECT_FALSE(out.valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, AttitudeStaleDegrades) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.attitude_time_ms = 600; // 400 ms 前 > 300 ms 阈值
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kDegradedHold);
|
||||||
|
EXPECT_EQ(out.degraded_reason, DegradedReason::kAttitudeStale);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, VisionStaleDegrades) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track_time_ms = 700; // 300 ms 前 > 200 ms 阈值
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kDegradedHold);
|
||||||
|
EXPECT_EQ(out.degraded_reason, DegradedReason::kVisionStale);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, NeverSampledTrackDegrades) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track_time_ms = 0; // 从未取样
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kDegradedHold);
|
||||||
|
EXPECT_EQ(out.degraded_reason, DegradedReason::kVisionStale);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, AttitudeStaleBeatsVisionStale) {
|
||||||
|
// 降级优先级:姿态校验在前(§9.2 姿态过期禁止一切空间转换)
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.attitude_present = false;
|
||||||
|
snap.track_time_ms = 0;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.degraded_reason, DegradedReason::kAttitudeStale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判丢刹车(§6.4) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, LostTargetBrakes) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.tracked = false;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kBrakeHover);
|
||||||
|
EXPECT_TRUE(out.valid);
|
||||||
|
EXPECT_DOUBLE_EQ(out.vx_mps, 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(out.vy_mps, 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(out.vz_mps, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Coast(§6.4) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, CoastSetsFlagAndKeepsTracking) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.is_predicted = true;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kVelocityHeading);
|
||||||
|
EXPECT_TRUE(out.coast_active);
|
||||||
|
EXPECT_TRUE(out.valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 水平航向通道(§4.2,A2) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, RateModeMapsBetaToYawRate) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.center = {600.0, 500.0}; // 目标偏右 100 像素
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
const double beta_deg = std::atan(0.2) * kRadToDeg; // ≈ 11.31°
|
||||||
|
EXPECT_NEAR(out.yaw_rate_dps, beta_deg, 1e-6); // gain=1,右转正
|
||||||
|
EXPECT_DOUBLE_EQ(out.yaw_deg, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, RateModeClampsToYawRateLimit) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.center = {950.0, 500.0}; // 大偏移:β≈43.5° > 30°/s 上限
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_NEAR(out.yaw_rate_dps, 30.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, PositionModeComposesAbsoluteHeading) {
|
||||||
|
// A2:位置式输出为绝对 NED 航向 = 当前航向 + 限幅修正量
|
||||||
|
auto cfg = MakeConfig();
|
||||||
|
cfg.heading.mode = HeadingMode::kPosition;
|
||||||
|
TrackingControlLaw law(cfg);
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.center = {600.0, 500.0};
|
||||||
|
snap.yaw_rad = 45.0 * kDegToRad;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
const double beta_deg = std::atan(0.2) * kRadToDeg;
|
||||||
|
EXPECT_NEAR(out.yaw_deg, 45.0 + beta_deg, 1e-6);
|
||||||
|
EXPECT_DOUBLE_EQ(out.yaw_rate_dps, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, PositionModeWrapsAround180) {
|
||||||
|
auto cfg = MakeConfig();
|
||||||
|
cfg.heading.mode = HeadingMode::kPosition;
|
||||||
|
TrackingControlLaw law(cfg);
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.center = {600.0, 500.0};
|
||||||
|
snap.yaw_rad = 175.0 * kDegToRad;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
const double beta_deg = std::atan(0.2) * kRadToDeg;
|
||||||
|
// 175 + 11.31 = 186.31 → 回绕到 ≈ -173.69
|
||||||
|
EXPECT_NEAR(out.yaw_deg, 175.0 + beta_deg - 360.0, 1e-6);
|
||||||
|
EXPECT_GT(out.yaw_deg, -180.0);
|
||||||
|
EXPECT_LT(out.yaw_deg, 180.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 垂直统一速度支路(§5,A1) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, VerticalOffsetDrivesVz) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.center = {500.0, 600.0}; // 目标偏低 100 像素 → α>0 → 下降
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
const double alpha_deg = std::atan(0.2) * kRadToDeg;
|
||||||
|
EXPECT_NEAR(out.vz_mps, 0.1 * alpha_deg, 1e-6);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kVelocityHeading); // A1:不切姿态微调模式
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, VerticalVelocityClamped) {
|
||||||
|
TrackingControlLaw law(MakeConfig());
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.track.center = {500.0, 990.0}; // α≈44.5° × 0.1 = 4.45 > 2 上限
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_NEAR(out.vz_mps, 2.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 距离通道(§6.2 / §6.3) ----
|
||||||
|
|
||||||
|
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;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_NEAR(out.vx_mps, 3.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_NEAR(out.vx_mps, -1.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, NoDistanceHoldZerosVx) {
|
||||||
|
TrackingControlLaw law(MakeConfig()); // 缺省 kHold
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.associated_to_target = false;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kVelocityHeading);
|
||||||
|
EXPECT_DOUBLE_EQ(out.vx_mps, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, NoDistanceSlowApproach) {
|
||||||
|
auto cfg = MakeConfig();
|
||||||
|
cfg.distance.no_distance_action = NoDistanceAction::kSlowApproach;
|
||||||
|
TrackingControlLaw law(cfg);
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.associated_to_target = false;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_NEAR(out.vx_mps, 0.5, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, NoDistanceExitDegrades) {
|
||||||
|
auto cfg = MakeConfig();
|
||||||
|
cfg.distance.no_distance_action = NoDistanceAction::kExit;
|
||||||
|
TrackingControlLaw law(cfg);
|
||||||
|
auto snap = MakeSnapshot(1000);
|
||||||
|
snap.associated_to_target = 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) {
|
||||||
|
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 阈值 → 距离不可用
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
EXPECT_EQ(out.mode, ControlMode::kVelocityHeading);
|
||||||
|
EXPECT_DOUBLE_EQ(out.vx_mps, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 首拍/限幅(§8、S6) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, FirstTickVxSlewsFromZero) {
|
||||||
|
// S6:首条速度指令必须经变化率限幅,从 0 渐变而非阶跃
|
||||||
|
auto cfg = MakeConfig();
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 机体系 → NED 旋转(§2.3,A4) ----
|
||||||
|
|
||||||
|
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;
|
||||||
|
const auto out = law.Update(snap, 1000);
|
||||||
|
// 机体前向 3.0 → 东向 3.0,北向 ≈ 0
|
||||||
|
EXPECT_NEAR(out.vx_mps, 0.0, 1e-9);
|
||||||
|
EXPECT_NEAR(out.vy_mps, 3.0, 1e-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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reset(§6.4) ----
|
||||||
|
|
||||||
|
TEST(TrackingControlLawTest, ResetRestoresSlewFromZero) {
|
||||||
|
auto cfg = MakeConfig();
|
||||||
|
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;
|
||||||
|
for (std::int64_t t = 1000; t < 3000; t += 50) {
|
||||||
|
snap.track_time_ms = t;
|
||||||
|
snap.radar_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.attitude_time_ms = 3000;
|
||||||
|
const auto out = law.Update(snap, 3000);
|
||||||
|
// Reset 后限幅器归零:又从 0.1 开始渐变
|
||||||
|
EXPECT_NEAR(out.vx_mps, 0.1, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// vtc_config_test.cpp —— TrackingConfig::LoadFromJson 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖(物理追踪思路 §8、需求分析 NFR-007):
|
||||||
|
// 真实配置文件加载成功与取值正确(含 A1/A2 修改后的字段结构)、
|
||||||
|
// 缺字段 / 缺分组 / 非法枚举 / 非法数值 / 文件缺失均抛带字段名的异常。
|
||||||
|
//
|
||||||
|
// 依赖编译期宏:
|
||||||
|
// VTC_TEST_CONFIG —— config/track_control.json 绝对路径
|
||||||
|
// VTC_TEST_TMP_DIR —— 测试临时文件输出目录(构建目录)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "visual_track_control/vtc_config.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using drone::vtc::HeadingMode;
|
||||||
|
using drone::vtc::NoDistanceAction;
|
||||||
|
using drone::vtc::TrackingConfig;
|
||||||
|
|
||||||
|
/// 与真实配置同构的完整合法 JSON(用于派生各非法变体)
|
||||||
|
const char* kFullJson = R"({
|
||||||
|
"control": {
|
||||||
|
"frequency_hz": 20.0,
|
||||||
|
"visual_stale_ms": 200,
|
||||||
|
"radar_stale_ms": 500,
|
||||||
|
"attitude_stale_ms": 300
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"image_width": 1920,
|
||||||
|
"image_height": 1080,
|
||||||
|
"fov_h_deg": 60.0,
|
||||||
|
"fov_v_deg": 35.0
|
||||||
|
},
|
||||||
|
"heading": {
|
||||||
|
"mode": "rate",
|
||||||
|
"gain": 1.0,
|
||||||
|
"yaw_rate_limit_dps": 30.0,
|
||||||
|
"yaw_slew_limit_dps2": 60.0
|
||||||
|
},
|
||||||
|
"vertical": {
|
||||||
|
"vz_gain_mps_per_deg": 0.1,
|
||||||
|
"vz_limit_mps": 2.0
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"d_exp_m": 10.0,
|
||||||
|
"kp": 0.5,
|
||||||
|
"ki": 0.05,
|
||||||
|
"kd": 0.1,
|
||||||
|
"integral_limit": 2.0,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"accel_limit": {
|
||||||
|
"ax_mps2": 2.0,
|
||||||
|
"ay_mps2": 2.0,
|
||||||
|
"az_mps2": 1.5
|
||||||
|
}
|
||||||
|
})";
|
||||||
|
|
||||||
|
/// 在 json 中把首个 from 替换为 to(未找到即测试失败)
|
||||||
|
void ReplaceOnce(std::string& json, const std::string& from, const std::string& to) {
|
||||||
|
const auto pos = json.find(from);
|
||||||
|
ASSERT_NE(pos, std::string::npos) << "未找到待替换片段: " << from;
|
||||||
|
json.replace(pos, from.size(), to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 JSON 文本写入临时文件并返回路径(调用方用完后删除)
|
||||||
|
std::string WriteTempJson(const std::string& content, const char* name) {
|
||||||
|
const std::string path = std::string(VTC_TEST_TMP_DIR) + "/" + name;
|
||||||
|
std::ofstream ofs(path);
|
||||||
|
EXPECT_TRUE(static_cast<bool>(ofs)) << "无法写入临时配置: " << path;
|
||||||
|
ofs << content;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, LoadRealConfigSucceeds) {
|
||||||
|
const TrackingConfig cfg = TrackingConfig::LoadFromJson(VTC_TEST_CONFIG);
|
||||||
|
EXPECT_DOUBLE_EQ(cfg.control.frequency_hz, 20.0);
|
||||||
|
EXPECT_EQ(cfg.control.visual_stale_ms, 200);
|
||||||
|
EXPECT_EQ(cfg.camera.image_width, 1920);
|
||||||
|
EXPECT_EQ(cfg.camera.image_height, 1080);
|
||||||
|
EXPECT_DOUBLE_EQ(cfg.camera.fov_h_deg, 60.0);
|
||||||
|
EXPECT_EQ(cfg.heading.mode, HeadingMode::kRate); // A2:默认速率式
|
||||||
|
EXPECT_DOUBLE_EQ(cfg.heading.yaw_rate_limit_dps, 30.0);
|
||||||
|
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_DOUBLE_EQ(cfg.accel_limit.ax_mps2, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, FullJsonRoundTripMatchesExpected) {
|
||||||
|
// 内嵌合法 JSON 与真实配置文件行为一致
|
||||||
|
const std::string path = WriteTempJson(kFullJson, "vtc_cfg_full.json");
|
||||||
|
const TrackingConfig cfg = TrackingConfig::LoadFromJson(path);
|
||||||
|
EXPECT_EQ(cfg.heading.mode, HeadingMode::kRate);
|
||||||
|
EXPECT_DOUBLE_EQ(cfg.distance.kp, 0.5);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, PositionModeAccepted) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json, "\"mode\": \"rate\"", "\"mode\": \"position\"");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_position.json");
|
||||||
|
const TrackingConfig cfg = TrackingConfig::LoadFromJson(path);
|
||||||
|
EXPECT_EQ(cfg.heading.mode, HeadingMode::kPosition);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, MissingFileThrowsWithPath) {
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson("/nonexistent/vtc_no_such_file.json");
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("vtc_no_such_file.json"),
|
||||||
|
std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, MalformedJsonThrows) {
|
||||||
|
const std::string path = WriteTempJson("{ this is not json", "vtc_cfg_bad.json");
|
||||||
|
EXPECT_THROW(TrackingConfig::LoadFromJson(path), std::runtime_error);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, MissingFieldThrowsWithFieldName) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json, "\"gain\": 1.0,\n", "");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_missing.json");
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson(path);
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("heading.gain"), std::string::npos);
|
||||||
|
}
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, MissingSectionThrows) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json,
|
||||||
|
",\n \"accel_limit\": {\n"
|
||||||
|
" \"ax_mps2\": 2.0,\n"
|
||||||
|
" \"ay_mps2\": 2.0,\n"
|
||||||
|
" \"az_mps2\": 1.5\n"
|
||||||
|
" }",
|
||||||
|
"");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_nosection.json");
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson(path);
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("accel_limit"), std::string::npos);
|
||||||
|
}
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, InvalidHeadingModeThrows) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json, "\"mode\": \"rate\"", "\"mode\": \"magic\"");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_badmode.json");
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson(path);
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("heading.mode"), std::string::npos);
|
||||||
|
}
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, InvalidNoDistanceActionThrows) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json, "\"no_distance_action\": \"hold\"",
|
||||||
|
"\"no_distance_action\": \"zoom\"");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_badaction.json");
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson(path);
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("no_distance_action"),
|
||||||
|
std::string::npos);
|
||||||
|
}
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, NegativeFrequencyThrows) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json, "\"frequency_hz\": 20.0", "\"frequency_hz\": -1.0");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_negfreq.json");
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson(path);
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("control.frequency_hz"),
|
||||||
|
std::string::npos);
|
||||||
|
}
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(VtcConfigTest, FovOutOfRangeThrows) {
|
||||||
|
std::string json = kFullJson;
|
||||||
|
ReplaceOnce(json, "\"fov_h_deg\": 60.0", "\"fov_h_deg\": 200.0");
|
||||||
|
const std::string path = WriteTempJson(json, "vtc_cfg_badfov.json");
|
||||||
|
try {
|
||||||
|
TrackingConfig::LoadFromJson(path);
|
||||||
|
FAIL() << "应当抛出异常";
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
EXPECT_NE(std::string(e.what()).find("camera.fov_h_deg"), std::string::npos);
|
||||||
|
}
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
# 视觉跟踪控制律实现文档(visual_track_control)
|
||||||
|
|
||||||
|
> 独立静态库子工程,实现 `../docs/物理追踪思路.md`(下称"规格")定义的
|
||||||
|
> 视觉跟踪阶段(S7 VISUAL_TRACKING)控制律:水平航向、垂直速度、
|
||||||
|
> 距离 PID 三路控制 + 统一限幅 + Coast 清积分 / 判丢刹车 + 超龄降级,
|
||||||
|
> 输出端随机体航向完成机体系→局部 NED 旋转,经 `MavlinkCommandGateway`
|
||||||
|
> 打包为 MAVLink 字节流,交由注入的 `ICommandTransport` 实现送达。
|
||||||
|
>
|
||||||
|
> 本文档按 `../AGENTS.md`"实现文档规范"六要素组织。三份文档分工明确、
|
||||||
|
> 互不重复:
|
||||||
|
>
|
||||||
|
> - `../docs/物理追踪思路.md`——控制律**物理与行为规格**(唯一权威),
|
||||||
|
> 本文档不重复抄录公式推导;
|
||||||
|
> - `../docs/视觉跟踪控制律技术路线.md`——**语言无关实现路线**:单周期八步
|
||||||
|
> 伪代码、构件与状态清单、坐标系与符号约定、打包掩码取舍、重写指引;
|
||||||
|
> - 本文档——**C++ 落地说明**:公共头一览、构建与测试方式、日志行为、
|
||||||
|
> 排查与修改要点。
|
||||||
|
>
|
||||||
|
> 追踪库行为约定见 `../target_tracker/target_tracker.md`,同样只引用不复述。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、功能职责与边界
|
||||||
|
|
||||||
|
命名空间 `drone::vtc`,头文件保护一律 `#pragma once`。
|
||||||
|
|
||||||
|
**做什么**:
|
||||||
|
|
||||||
|
- **三路控制律**(`TrackingControlLaw::Update()` 单周期计算):
|
||||||
|
- 水平航向(规格 §4):像素偏移 → 水平视线角 β → 增益 → 变化率限幅 →
|
||||||
|
角速度限幅,按 `HeadingMode` 配置输出速率式 `yaw_rate_dps`(默认)
|
||||||
|
或位置式 `yaw_deg`(绝对 NED 航向,见 §3.8);
|
||||||
|
- 垂直速度(规格 §5):垂直视线角 α → 统一速度支路 → `vz_mps`
|
||||||
|
垂直速度设定值(原"小误差姿态微调/大误差垂直速度"双支路已取消,
|
||||||
|
原因见 `vtc_types.h` 的 `ControlMode` 注释与 §3.8);
|
||||||
|
- 距离通道(规格 §6):`Δd = d − d_exp` → `PidController` → 前向速度
|
||||||
|
`vx_mps`;雷达未关联/超龄时按 `NoDistanceAction` 配置降级
|
||||||
|
(hold / slow_approach / exit)。
|
||||||
|
- **统一限幅**(规格 §8):所有输出通道内联完成限幅——`Clamp` 静态限幅 +
|
||||||
|
`SlewRateLimiter` 变化率限幅(航向修正/角速度、前向/垂直加速度),
|
||||||
|
全部在机体系完成。
|
||||||
|
- **坐标系旋转**(规格 §2.3):输出端用 `RotateBodyToNed()` 把机体系
|
||||||
|
速度(前/右/下)随快照航向 ψ 旋转为局部 NED(北/东/地),与网关的
|
||||||
|
`MAV_FRAME_LOCAL_NED` 一致;仅绕下轴旋转(滚转/俯仰对速度指令为
|
||||||
|
二阶小量,由飞控内环处理)。
|
||||||
|
- **Coast 清积分**(规格 §6.4):`is_predicted == true` 时清空距离 PID
|
||||||
|
积分项(`ResetIntegral()`),防积分饱和。
|
||||||
|
- **判丢刹车**(规格 §6.4):`tracked == false` 时输出 `kBrakeHover`
|
||||||
|
零速刹车,并清积分与全部限幅器历史(保证恢复跟踪后指令从零渐变)。
|
||||||
|
- **超龄降级**(规格 §7.1、§9.2):姿态过期/缺失 → `kDegradedHold`
|
||||||
|
(`kAttitudeStale`,禁止控制);视觉结果超龄 → `kDegradedHold`
|
||||||
|
(`kVisionStale`);判定器为无状态 `StalenessChecker::IsStale()`。
|
||||||
|
- **MAVLink 打包**(规格 §2.2):`MavlinkCommandGateway` 把
|
||||||
|
`ControlOutput` 打包为 `SET_POSITION_TARGET_LOCAL_NED`(ID 84)字节流。
|
||||||
|
- **配置化**(规格 §8、需求分析 NFR-007):全部阈值集中在
|
||||||
|
`TrackingConfig`,由 `TrackingConfig::LoadFromJson()` 启动期一次性加载,
|
||||||
|
代码不写死任何参数。
|
||||||
|
|
||||||
|
**不做什么**(边界):
|
||||||
|
|
||||||
|
- **不做感知**:不运行检测/光流/雷达驱动,只消费调用方填入的
|
||||||
|
`ControlSnapshot`;
|
||||||
|
- **不做状态机仲裁**:S6/S8 切换、目标丢失策略(`ITargetLostStrategy`)、
|
||||||
|
障碍接管均在状态机层,本库只按输入标志产生当拍输出;
|
||||||
|
- **不写串口/传输**:库内无任何链路代码,字节流交给 `ICommandTransport`
|
||||||
|
实现方;
|
||||||
|
- **不解析 MAVLink 输入**:只有打包发送方向,无 `mavlink_parse_*`;
|
||||||
|
- **不做机体补偿**:`ControlSnapshot` 的 `roll_rad` / `pitch_rad` /
|
||||||
|
`yaw_rate_radps` / `velocity_*` 字段为预留输入(规格 §4.1);当前仅
|
||||||
|
`yaw_rad` 参与计算(NED 旋转与位置式航向合成);
|
||||||
|
- **不做热更新**:配置启动期加载一次,调参重启生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、接口与数据流
|
||||||
|
|
||||||
|
### 2.1 公共头一览
|
||||||
|
|
||||||
|
| 头文件 | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| `include/visual_track_control/vtc_types.h` | `ControlMode`、`DegradedReason`、`ControlSnapshot`、`ControlOutput` |
|
||||||
|
| `include/visual_track_control/vtc_config.h` | `HeadingMode`、`NoDistanceAction`、各分组配置结构、`TrackingConfig` |
|
||||||
|
| `include/visual_track_control/tracking_control_law.h` | `TrackingControlLaw`(主控律) |
|
||||||
|
| `include/visual_track_control/pid_controller.h` | `PidParams`、`PidController` |
|
||||||
|
| `include/visual_track_control/rate_limiter.h` | `Clamp()`、`SlewRateLimiter`(header-only) |
|
||||||
|
| `include/visual_track_control/staleness_checker.h` | `StalenessChecker`(header-only) |
|
||||||
|
| `include/visual_track_control/line_of_sight.h` | `PixelToAngle`(像素→视线角,全程针孔模型) |
|
||||||
|
| `include/visual_track_control/frame_transform.h` | `RotateBodyToNed()`(机体系→局部 NED,header-only) |
|
||||||
|
| `include/visual_track_control/i_command_transport.h` | `ICommandTransport`(传输抽象) |
|
||||||
|
| `include/visual_track_control/mavlink_command_gateway.h` | `MavlinkCommandGateway`(MAVLink 打包) |
|
||||||
|
|
||||||
|
### 2.2 关键字段(以代码为准)
|
||||||
|
|
||||||
|
`enum class ControlMode`:`kVelocityHeading`(速度航向主输出)/
|
||||||
|
`kBrakeHover`(零速刹车)/ `kDegradedHold`(降级保持,禁止正常控制)。
|
||||||
|
(原 `kAttitudeTrim` 姿态微调模式已取消,见 §3.8。)
|
||||||
|
|
||||||
|
`enum class DegradedReason`:`kNone` / `kAttitudeStale`(姿态过期)/
|
||||||
|
`kVisionStale`(视觉超龄)/ `kNoDistanceExit`(无距离且配置退出近距操作)。
|
||||||
|
|
||||||
|
`ControlSnapshot`(输入,纯值类型):
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `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` | 雷达取样单调时钟 |
|
||||||
|
| `attitude_present` / `attitude_time_ms` | 姿态源可用性与取样时刻(过期→禁止控制,规格 §9.2) |
|
||||||
|
| `yaw_rad` | 自机航向(弧度,NED);**参与计算**:输出端机体系→NED 旋转(§2.3)与位置式绝对航向合成 |
|
||||||
|
| `roll_rad` / `pitch_rad` / `yaw_rate_radps` | 自机姿态其余分量(弧度,FRD);预留,机体补偿未实现(规格 §4.1) |
|
||||||
|
| `velocity_x_mps` / `velocity_y_mps` / `velocity_z_mps` | 自机速度分量(预留,接入主工程时由 `FlightStateSnapshot` 填充) |
|
||||||
|
|
||||||
|
`ControlOutput`(输出,已限幅):
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `mode` / `valid` | 输出模式 / 指令是否可用于发送 |
|
||||||
|
| `vx_mps` / `vy_mps` / `vz_mps` | **局部 NED 速度分量(北/东/地,米/秒)**:各通道在机体系计算并限幅后,输出端随 `snapshot.yaw_rad` 旋转得到;旋转为正交变换,合速度大小不变。机体系侧向恒为 0,旋转后 `vy_mps` 可非零 |
|
||||||
|
| `yaw_deg` | 位置式目标**绝对航向**(度,NED,右转为正):由当前航向 + 变化率限幅后的修正量合成,回绕到 [-180, 180)(ID 84 的 yaw 字段为绝对值) |
|
||||||
|
| `yaw_rate_dps` | 速率式偏航角速度指令(度/秒,右转为正;天然是相对量,无坐标系歧义) |
|
||||||
|
| `degraded_reason` | 降级原因(仅 `kDegradedHold` 有意义) |
|
||||||
|
| `coast_active` | 本周期处于 Coast(`is_predicted == true`) |
|
||||||
|
|
||||||
|
`TrackingConfig` 六分组:`control`(`frequency_hz`、`visual_stale_ms`、
|
||||||
|
`radar_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`
|
||||||
|
(`ax_mps2`、`ay_mps2`、`az_mps2`)。JSON 键名与字段一一对应,见
|
||||||
|
`config/track_control.json`。`heading.mode` 默认速率式(`"rate"`);
|
||||||
|
相机组原 `linear_ratio` 键已随线性/针孔双路一并移除(§3.5)。
|
||||||
|
|
||||||
|
### 2.3 ICommandTransport 契约
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class ICommandTransport {
|
||||||
|
public:
|
||||||
|
virtual bool Send(const std::uint8_t* data, std::size_t len) = 0;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **只吃字节流**:串口、电台、回环打印均可注入实现;后续重写传输层
|
||||||
|
只换实现,不动打包与控制律;
|
||||||
|
- 返回 `true` = 已交付底层链路,`false` = 发送失败(调用方可计数/告警);
|
||||||
|
- 实现方保证不长期持有 `data` 指针(同步拷贝或同步写出)。
|
||||||
|
|
||||||
|
### 2.4 数据流向
|
||||||
|
|
||||||
|
```text
|
||||||
|
推理侧每帧: TargetTracker::Update(detections, frame_shape)
|
||||||
|
控制线程(固定周期, 规格 §7.1):
|
||||||
|
TargetTracker::LastResult() # 线程安全拷贝
|
||||||
|
→ 取样瞬间用单调时钟打戳填入 ControlSnapshot.track_time_ms
|
||||||
|
→ TrackingControlLaw::Update(snapshot, now_ms) # 单周期计算
|
||||||
|
→ 按 ControlOutput.mode 分发:
|
||||||
|
kVelocityHeading → MavlinkCommandGateway::SendVelocityHeading()
|
||||||
|
kBrakeHover → MavlinkCommandGateway::SendBrakeHover()
|
||||||
|
kDegradedHold → 不发送(仅告警/降级)
|
||||||
|
→ mavlink_msg_to_send_buffer → ICommandTransport::Send(字节流)
|
||||||
|
停止视觉闭环: TrackingControlLaw::Reset()(规格 §6.4)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、关键实现点
|
||||||
|
|
||||||
|
### 3.1 传输接口只吃字节流的双重解耦
|
||||||
|
|
||||||
|
打包(`MavlinkCommandGateway`)与传输(`ICommandTransport`)分离:
|
||||||
|
网关只负责 `ControlOutput` → MAVLink 字节,不感知链路形态;传输实现
|
||||||
|
只感知字节,不感知消息语义。对应规格 §2.2 控制单出口约束在独立库中的
|
||||||
|
映射——控制律不直接写飞控。
|
||||||
|
|
||||||
|
### 3.2 TrackResult 无时间戳,由调用方取样打戳
|
||||||
|
|
||||||
|
`TrackResult` 是追踪库自有类型,本身不含时间戳(规格 §7.2)。约定:
|
||||||
|
调用方在读取 `LastResult()` 的**瞬间**用单调时钟打戳填入
|
||||||
|
`track_time_ms`;超龄判定一律用"取样时间 + `valid_for_ms`"
|
||||||
|
(`StalenessChecker::IsStale(sample_time_ms, now_ms, valid_for_ms)`):
|
||||||
|
|
||||||
|
- `sample_time_ms <= 0` 视为从未取样,直接判超龄;
|
||||||
|
- `now_ms < sample_time_ms`(时间戳不一致:时钟源错配/打戳错误)同样
|
||||||
|
判超龄——单一单调时钟下不应发生,宁可降级也不信任不可核实的取样。
|
||||||
|
|
||||||
|
### 3.3 PID anti-windup 策略(条件积分)
|
||||||
|
|
||||||
|
`PidController::Update()`(位置式)三层防护:
|
||||||
|
|
||||||
|
1. **积分钳位**:候选积分值先钳制在 ±`integral_limit`;
|
||||||
|
2. **anti-windup(条件积分)**:输出饱和(`|raw| > output_limit`)且
|
||||||
|
**本拍积分增量仍在推深饱和**(增量 `error·dt` 与原始输出 `raw`
|
||||||
|
同号)时,冻结本步积分累加(不采纳候选积分值);误差反号后增量反向,
|
||||||
|
积分立即恢复回卷,无饱和残留。判据刻意基于"增量方向"而非"积分项与
|
||||||
|
输出同向":后者在误差反号后仍会冻结回卷;也不用 `raw·error` 的
|
||||||
|
变体含混表述,实现即 `(error*dt_s) * raw > 0`;
|
||||||
|
3. **输出限幅**:最终输出钳制在 ±`output_limit`。
|
||||||
|
|
||||||
|
微分带一阶低通(`derivative_filter_coef ∈ [0,1)`,0 = 不滤波);
|
||||||
|
`dt_s <= 0` 时保持上次输出(异常节拍保护)。`ResetIntegral()` 只清积分,
|
||||||
|
保留微分历史与上次输出;`Reset()` 清全部,均 O(1)(Coast 清积分,
|
||||||
|
规格 §6.4)。
|
||||||
|
|
||||||
|
### 3.4 SlewRateLimiter 兼作首条指令突变抑制
|
||||||
|
|
||||||
|
`SlewRateLimiter` 保存上次输出,单步变化不超过 `rate_limit · dt_s`;
|
||||||
|
构造时初值置 0,天然实现规格要求的"首条视觉速度设定值必须经过限幅"
|
||||||
|
(状态机设计 S6):切换进入视觉跟踪后首条指令从零按加速度限幅渐变,
|
||||||
|
无需专门的切换分支。判丢进刹车时调用 `Reset()` 归零,恢复跟踪后同样
|
||||||
|
从零渐变。主控律共 4 个限幅器:`yaw_deg_limiter_` / `yaw_rate_limiter_`
|
||||||
|
(度/秒²)、`vx_limiter_` / `vz_limiter_`(米/秒²)。
|
||||||
|
|
||||||
|
### 3.5 像素→角度:全程针孔模型
|
||||||
|
|
||||||
|
`PixelToAngle` 构造期预计算针孔系数 `2·tan(θ/2)/size`,热路径仅一次
|
||||||
|
乘加 + `atan`:`angle = atan(offset·2·tan(θ/2)/size)`(规格 §4.1/§5.1)。
|
||||||
|
原"小偏移线性近似/大偏移针孔"双路切换已取消:线性式与针孔式在切换边界
|
||||||
|
两侧不连续(θ=60°、边界比例 0.2 处约 8% 角度台阶),会向控制环路注入
|
||||||
|
突变;`atan` 代价可忽略,全程采用精确针孔式。配置中 `linear_ratio` 键
|
||||||
|
随之移除。水平 β(`PixelToAngle(W, θ_h)`)与垂直 α(`PixelToAngle(H, θ_v)`)
|
||||||
|
各一个实例,构造期校验参数合法性(尺寸、视场角),非法抛
|
||||||
|
`std::invalid_argument`。
|
||||||
|
|
||||||
|
### 3.6 全工程仅 mavlink_command_gateway.cpp 一个 TU 包含 MAVLink 头
|
||||||
|
|
||||||
|
c_library_v2 的 `mavlink_helpers.h` 中 **`MAVLINK_HELPER` 默认为空宏**,
|
||||||
|
辅助函数直接以外部链接定义在头文件中;多个翻译单元包含 `common/mavlink.h`
|
||||||
|
会产生**多重定义**链接错误。因此:
|
||||||
|
|
||||||
|
- `common/mavlink.h` 只被 `src/mavlink_command_gateway.cpp` 包含
|
||||||
|
(该文件也是全库唯一的 MAVLink 知识落点);单元测试同样**不得**包含
|
||||||
|
该头,按 MAVLink v2 线格式手工解码字节流断言(见 §5.3);
|
||||||
|
- 公共头 `mavlink_command_gateway.h` 不出现任何 `mavlink_*` 类型,
|
||||||
|
打包/发送缓冲用成员固定字节存储承载(`msg_storage_` / `tx_buffer_`,
|
||||||
|
各 512 字节,`.cpp` 中 `static_assert` 校验 ≥ `sizeof(mavlink_message_t)`
|
||||||
|
与 `MAVLINK_MAX_PACKET_LEN`);
|
||||||
|
- 打包用 `mavlink_msg_*_pack`(无通道状态、纯静态内联、可重入),
|
||||||
|
不使用 `_pack_chan` 通道 API;
|
||||||
|
- CMake 中 MAVLink include 目录设为 `SYSTEM PRIVATE`:压第三方告警噪音,
|
||||||
|
且不泄漏给下游。
|
||||||
|
|
||||||
|
同理,`nlohmann/json` 只被 `src/vtc_config.cpp` 一个 TU 包含。
|
||||||
|
|
||||||
|
### 3.7 配置启动期一次加载,不做热更新
|
||||||
|
|
||||||
|
`TrackingConfig::LoadFromJson()` 仅在启动期调用一次:解析结果拷贝进
|
||||||
|
`TrackingConfig` 值对象后即与 JSON 脱钩,热路径绝不触碰 JSON 对象;
|
||||||
|
调参重启即可。若未来需要热更新,扩展点为**原子指针交换**:持有
|
||||||
|
`std::atomic<const TrackingConfig*>`,后台解析新配置成功后原子换指针,
|
||||||
|
控制线程每拍读取当前指针快照使用(当前未实现,不引入额外复杂度)。
|
||||||
|
|
||||||
|
### 3.8 主控律单周期流程
|
||||||
|
|
||||||
|
`Update()` 按固定流程执行(`last_now_ms_` 推算 dt,首拍与异常节拍
|
||||||
|
(时钟回拨/`now_ms` 未前进)用 `1.0 / control.frequency_hz` 兜底,
|
||||||
|
dt 上限 `kMaxDtS = 0.5` 秒):
|
||||||
|
|
||||||
|
① 超龄判定(姿态过期/缺失 → `kDegradedHold`;视觉超龄 → `kDegradedHold`)
|
||||||
|
→ ② 判丢刹车(清积分与限幅器历史)→ ③ Coast 清距离积分
|
||||||
|
→ ④ 像素偏移 → β/α(针孔模型)→ ⑤ 水平航向(按 `HeadingMode` 分支)
|
||||||
|
→ ⑥ 垂直统一速度支路(α → `vz_gain·α` → 加速度限幅 → 速度限幅)
|
||||||
|
→ ⑦ 距离通道(关联成立且未超龄才可用;否则清积分并按
|
||||||
|
`no_distance_action` 降级)→ ⑧ 机体系→NED 旋转(`RotateBodyToNed`,
|
||||||
|
随 `snapshot.yaw_rad`)→ 置 `valid = true` 输出。
|
||||||
|
|
||||||
|
两个设计决策记录:
|
||||||
|
|
||||||
|
- **垂直双支路合并(A1)**:规格 §5.2 原设"小误差姿态微调(`kAttitudeTrim`
|
||||||
|
→ ID 82)/ 大误差垂直速度"双支路。姿态微调支路会把整个输出切成姿态
|
||||||
|
消息,导致同周期的距离通道前向速度被丢弃(逼近停滞),且双消息类型
|
||||||
|
逐拍翻转会扰动 PX4 offboard。现垂直通道一律走速度支路:小误差经
|
||||||
|
`vz_gain` 线性映射为小垂直速度,行为连续、全周期单一 offboard 消息。
|
||||||
|
- **位置式航向输出绝对值(A2)**:ID 84 的 `yaw` 字段是**绝对** NED
|
||||||
|
航向。若把 β 修正量直接填入,飞控会转向"修正角"方向而非目标方向。
|
||||||
|
位置式分支输出 `WrapDeg180(当前航向 + 限幅修正量)`;速率式
|
||||||
|
(默认)输出角速度指令,天然相对量、无此问题。
|
||||||
|
|
||||||
|
### 3.9 MAVLink 消息映射(规格 §2.2)
|
||||||
|
|
||||||
|
全库仅 ID 84 `SET_POSITION_TARGET_LOCAL_NED` 一种消息(原 `kAttitudeTrim`
|
||||||
|
→ ID 82 `SET_ATTITUDE_TARGET` 已随姿态微调支路移除):
|
||||||
|
|
||||||
|
| 模式 | 消息 | 要点 |
|
||||||
|
|------|------|------|
|
||||||
|
| `kVelocityHeading` | ID 84 | `coordinate_frame = MAV_FRAME_LOCAL_NED`(速度分量已是 NED 语义)。速度模式 `type_mask` 基础位 `kMaskVelocityBase = 0x01C7`(忽略位置与加速度),航向三分支:角速率非零 → `kMaskVelocityYawRate`(再忽略 yaw);仅航向角非零 → `kMaskVelocityYaw`(再忽略 yaw_rate);**两者均零 → 速率掩码配零角速率(保持当前航向)**——旧实现把零角速率落到航向掩码,等于误发绝对航向 0(指向正北) |
|
||||||
|
| `kBrakeHover` | ID 84 | `kMaskBrake = 0x0DC7`:速度全零,位置/加速度/航向/角速率字段全部忽略 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、日志行为
|
||||||
|
|
||||||
|
遵循 `../AGENTS.md` 日志纪律:**库内仅状态切换点打日志,热路径零日志、
|
||||||
|
零堆分配**。
|
||||||
|
|
||||||
|
| 位置 | 日志 | 触发条件 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `tracking_control_law.cpp`(`Update()` 内 `FinishAndLog`) | `spdlog::info` "[ControlLaw] 状态切换: X -> Y (原因)" | **仅当 `ControlMode` 相对 `last_mode_` 发生变化**时打印(刹车进出、降级切换、进入/退出 Coast 跟踪);同一模式连续多拍不重复打印 |
|
||||||
|
| `tracking_control_law.cpp` | —— | 每拍正常计算(含三路通道数学)**不打任何日志** |
|
||||||
|
| `mavlink_command_gateway.cpp` / `pid_controller.cpp` / `line_of_sight.cpp` / `vtc_config.cpp` | —— | 不打日志(配置错误走异常,见第六章) |
|
||||||
|
|
||||||
|
示例程序(`examples/track_control_demo.cpp`,非生产代码)另有两处
|
||||||
|
**演示观测日志**,均带节流防刷屏:
|
||||||
|
|
||||||
|
- `LoggingTransport::Send()`:字节统计与包头十六进制摘要,**秒级节流**
|
||||||
|
(距上次打印 ≥ 1000 ms 才输出);
|
||||||
|
- 主循环状态摘要:**每 10 拍一次**(20 Hz 下即半秒一次)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、构建、测试与运行方式
|
||||||
|
|
||||||
|
### 5.1 构建
|
||||||
|
|
||||||
|
`../AGENTS.md` 规定构建环境为 WSL2 Ubuntu 24.04。**当前开发机 WSL 仅
|
||||||
|
装有 Ubuntu 22.04(发行版名 `Ubuntu`)**,本库的构建与测试验证在
|
||||||
|
22.04(cmake 3.22 / g++ 11.4)完成,行为与 24.04 无差异;24.04 发行版
|
||||||
|
安装后应按其复核一遍(见 §6.4)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S visual_track_control -B build_vtc -DBUILD_TESTING=OFF
|
||||||
|
cmake --build build_vtc -j$(nproc)
|
||||||
|
./build_vtc/track_control_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
- **为什么 `-DBUILD_TESTING=OFF`**:本库以 `add_subdirectory` 零改动复用
|
||||||
|
`target_tracker`,而 `target_tracker/CMakeLists.txt` 中 `include(CTest)`
|
||||||
|
会把 `BUILD_TESTING` 默认置为 `ON`,从而连带构建追踪库的 3 个测试
|
||||||
|
可执行文件。本子工程不关心其测试,显式关闭以加快构建(追踪库自身的
|
||||||
|
测试方式见 `../target_tracker/target_tracker.md` §五)。
|
||||||
|
- **本库自身测试不复用 `BUILD_TESTING`**:由独立选项 `VTC_BUILD_TESTS`
|
||||||
|
(默认 `ON`)控制,与上面的关闭互不干扰。
|
||||||
|
- **示例无需 `LD_LIBRARY_PATH`**:`target_tracker` 为 SHARED 库,
|
||||||
|
构建期 `CMAKE_BUILD_RPATH` 已指向构建目录,`track_control_demo`
|
||||||
|
可直接运行。
|
||||||
|
- 示例缺省配置路径由编译期宏 `VTC_DEMO_DEFAULT_CONFIG` 内置为
|
||||||
|
`config/track_control.json` 的绝对路径;也可命令行传入
|
||||||
|
`track_control_demo [配置文件路径]`。
|
||||||
|
|
||||||
|
### 5.2 示例演示内容
|
||||||
|
|
||||||
|
单线程模拟完整链路(合成运动检测框 → `TargetTracker::Update()` →
|
||||||
|
`LastResult()` + 打戳 → `TrackingControlLaw::Update()` → Gateway →
|
||||||
|
`LoggingTransport` 字节打印),按配置频率固定节拍依次走三阶段
|
||||||
|
(对应规格 §6.4):
|
||||||
|
|
||||||
|
1. **正常跟踪(6 秒)**:持续喂入向画面中心漂移的合成检测框,雷达
|
||||||
|
关联成立,输出速率式速度航向意图(vx 按加速度限幅渐变至接近速度上限,
|
||||||
|
角速度随 β 收敛而减小);
|
||||||
|
2. **Coast(4 秒)**:停止喂检测 → `is_predicted == true` → 清积分、
|
||||||
|
继续预测跟踪;同时取消雷达关联,演示 §6.3 无距离降级路径(hold:
|
||||||
|
前向清零);
|
||||||
|
3. **判丢刹车(3 秒)**:连续丢帧超过追踪阈值 → `tracked == false` →
|
||||||
|
零速刹车。
|
||||||
|
|
||||||
|
示例将追踪库判丢阈值放大为 `kDemoMaxLostFrames = 60` 帧(默认 8 帧在
|
||||||
|
20 Hz 下 Coast 仅 0.4 秒不便观察),**仅演示专用**,正式参数以追踪库
|
||||||
|
规格为准。程序跑完总拍数后自行调用 `TrackingControlLaw::Reset()` 并
|
||||||
|
正常退出(返回 0),无需手动终止。
|
||||||
|
|
||||||
|
### 5.3 单元测试(Google Test)
|
||||||
|
|
||||||
|
测试源文件位于 `tests/`,覆盖 8 个测试套件:
|
||||||
|
|
||||||
|
| 测试文件 | 覆盖要点 |
|
||||||
|
|----------|----------|
|
||||||
|
| `rate_limiter_test.cpp` | `Clamp` 边界;`SlewRateLimiter` 单步限幅、`dt<=0`/冻结保护、`Reset` |
|
||||||
|
| `staleness_checker_test.cpp` | 从未取样、时钟回拨(`now < sample`)、恰在有效期边界(临界判新鲜) |
|
||||||
|
| `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 模式可接受、缺文件/缺字段/缺分组/类型错/枚举非法/数值越界均抛异常且消息携带字段名 |
|
||||||
|
| `mavlink_command_gateway_test.cpp` | 手工解码 MAVLink v2 字节流(不含 `mavlink.h`,构建约束见 §3.6):帧头、ID 84、三种掩码分支(含"双零 → 速率掩码"回归)、坐标基准字节、目标系统/组件字节、传输失败传播 |
|
||||||
|
|
||||||
|
运行方式(构建后):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build_vtc/vtc_unit_tests # 直接跑
|
||||||
|
ctest --test-dir build_vtc --output-on-failure # 或经 ctest(gtest_discover_tests 注册)
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:系统安装 Google Test(`libgtest-dev`,FindGTest 模块模式,无需
|
||||||
|
CMake config 包);未找到时自动跳过测试构建并打印状态,不影响库与示例。
|
||||||
|
`vtc_config_test` 通过编译期宏 `VTC_TEST_CONFIG` 读取真实配置文件
|
||||||
|
`config/track_control.json`——**修改该配置文件时须同步核对测试断言**。
|
||||||
|
|
||||||
|
### 5.4 部署提示
|
||||||
|
|
||||||
|
`target_tracker` 为 SHARED 库(`libtarget_tracker.so.1`):部署
|
||||||
|
`track_control_demo` 或未来接入主工程时,必须将该 so 一并随带
|
||||||
|
(目录式部署与 RPATH 约定见 `../target_tracker/target_tracker.md` §7)。
|
||||||
|
本库 `visual_track_control` 自身为 STATIC 库,链接进可执行文件,无需随带。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、排查 / 修改要点
|
||||||
|
|
||||||
|
### 6.1 配置缺字段 / 非法值报错定位
|
||||||
|
|
||||||
|
`LoadFromJson()` 缺字段、类型错误、非法取值均抛 `std::runtime_error`,
|
||||||
|
**异常消息携带完整字段名**(如 `配置缺失字段或类型错误: heading.gain`、
|
||||||
|
`配置非法(必须为正): control.frequency_hz`、
|
||||||
|
`配置非法: distance.no_distance_action 仅支持 hold / slow_approach / exit`),
|
||||||
|
按异常文本直接定位到 `config/track_control.json` 的对应分组与键即可;
|
||||||
|
文件打开/解析失败则携带文件路径。`PixelToAngle` 构造期参数非法(尺寸
|
||||||
|
非正、视场角不在 (0, π))抛 `std::invalid_argument`,同样见消息文本。
|
||||||
|
|
||||||
|
### 6.2 MAVLink 消息待实测适配项
|
||||||
|
|
||||||
|
以下各项均以 **PX4 1.11.3 Offboard 实测为准**(规格 §10.1 待定项),
|
||||||
|
全部集中在 `src/mavlink_command_gateway.cpp`,修改时只动这一个文件:
|
||||||
|
|
||||||
|
| 适配项 | 位置 | 现状 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `coordinate_frame` | `SendVelocityHeading()` / `SendBrakeHover()` 的 `mavlink_msg_set_position_target_local_ned_pack` 调用 | 固定取 `MAV_FRAME_LOCAL_NED`;速度分量已由主控律旋转为局部 NED 语义(§3.8 步骤⑧),语义是否匹配待实测确认 |
|
||||||
|
| 速度模式 `type_mask` | 匿名命名空间 `kMaskVelocityBase` / `kMaskVelocityYaw` / `kMaskVelocityYawRate` / `kMaskBrake` | 掩码位组合待实测确认 |
|
||||||
|
|
||||||
|
### 6.3 接入主工程时需补齐的事项
|
||||||
|
|
||||||
|
1. **机体补偿(可选)**:机体系→NED 旋转已在库内完成(仅绕下轴按
|
||||||
|
`yaw_rad`,规格 §2.3);`roll_rad` / `pitch_rad` / `yaw_rate_radps` /
|
||||||
|
`velocity_*` 字段仍为预留,规格 §4.1 的机体补偿启用时由姿态源填充;
|
||||||
|
2. **`TrackedTarget` 主题接线**:`TrackResult` 尚未进入主工程
|
||||||
|
`include/common/types.h`;需先按 `../target_tracker/target_tracker.md`
|
||||||
|
§7 与规格 §7.2 正式定义 `TrackedTarget` 消息与 `kTrackedTarget` 主题,
|
||||||
|
由接线部件完成 `TrackResult` → `TrackedTarget` 转换与控制线程订阅;
|
||||||
|
3. **传输实现替换**:把示例的 `LoggingTransport` 换成真实链路实现
|
||||||
|
(`ICommandTransport` 注入),打包与控制律零改动;
|
||||||
|
4. **雷达关联标志来源**:`associated_to_target` 必须由感知融合层按
|
||||||
|
规格 §6.3 / 需求分析 FR-041 的判据给出,控制律不自行关联。
|
||||||
|
|
||||||
|
### 6.4 红线声明
|
||||||
|
|
||||||
|
- `config/track_control.json` 与 `TrackingConfig` 默认值中**所有数值均为
|
||||||
|
占位值**(对应规格第 8 章待定项与需求分析 §7 未标定前提):相机内参/
|
||||||
|
畸变/外参未标定、PID 与限幅参数未整定。标定与实测整定完成前,本库
|
||||||
|
**仅限 SITL/台架录制数据回放验证数学链路,不得用于实飞**。
|
||||||
|
- 构建环境差异:`../AGENTS.md` 规定 WSL2 Ubuntu 24.04,当前开发机仅
|
||||||
|
装有 Ubuntu 22.04,验证在 22.04 完成;安装 24.04 后需复核构建与测试。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# utils —— reference-c++ 两个子项目的 Python 移植
|
||||||
|
|
||||||
|
将 `reference-c++/` 下的 `target_tracker`(多目标追踪过滤器)与
|
||||||
|
`visual_track_control`(视觉跟踪控制律)移植为 Python,**两包互不依赖**
|
||||||
|
(唯一的数据转换点在胶水层 demo)。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
utils/
|
||||||
|
├── target_tracker/ # 包 1:卡尔曼滤波多目标追踪(无第三方硬依赖,仅 numpy)
|
||||||
|
│ └── README.md # 接口清单 + C++ 差异
|
||||||
|
├── visual_track_control/ # 包 2:三通道控制律 + MAVSDK offboard 网关
|
||||||
|
│ └── README.md # 接口清单 + C++ 差异
|
||||||
|
├── demo_track_control.py # 胶水层集成演示(模拟检测 → 追踪 → 控制律 → 发送)
|
||||||
|
├── config/track_control.json # 控制律配置(拷贝自 reference-c++)
|
||||||
|
├── tests/ # pytest 测试(对应 C++ 全部 13 个测试文件)
|
||||||
|
│ ├── target_tracker/ # 27 条
|
||||||
|
│ └── visual_track_control/ # 78 条
|
||||||
|
├── conftest.py # 保证可从任意工作目录导入两包
|
||||||
|
└── pytest.ini # 收集 *_test.py 命名(与 C++ 测试文件名对齐)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
conda env `pytorch`(`D:\program file\conda\envs\pytorch`),Python 3.12,
|
||||||
|
依赖:numpy、mavsdk、pytest。
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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)。
|
||||||
|
|
||||||
|
## 运行演示
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# mock 传输(本地记录指令,无实机)
|
||||||
|
python utils/demo_track_control.py --speedup 10
|
||||||
|
|
||||||
|
# MAVSDK 实发(需飞控/SITL 在 udp://:14540)
|
||||||
|
python utils/demo_track_control.py --transport mavsdk
|
||||||
|
```
|
||||||
|
|
||||||
|
时间线 13 s(20 Hz):正常跟踪 6 s(vx 从零渐变 → 逼近限速 3 m/s)→
|
||||||
|
目标消失 4 s(Coast 预测,60 帧后轨道判亡)→ 判零速刹车 3 s。
|
||||||
|
|
||||||
|
## 移植约定
|
||||||
|
|
||||||
|
- MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支
|
||||||
|
映射保留 C++ A2 回归语义,详见包 2 README。
|
||||||
|
- 测试逐条移植 C++ gtest 断言(`EXPECT_DOUBLE_EQ` → 精确 `==`,
|
||||||
|
`EXPECT_NEAR` → `pytest.approx`);MAVSDK 部分因无 SITL/实机,仅 mock 验证。
|
||||||
|
- 行为修复项(C1/C2/C4、A1/A2/A4、S6)在测试文件头注释中标注对应章节。
|
||||||
Binary file not shown.
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"_comment": "视觉跟踪控制律配置。全部数值为占位示例(对应 docs/物理追踪思路.md 第 8 章待定项),键名后缀标注单位;标定完成前仅限回放验证,不得用于实飞。",
|
||||||
|
|
||||||
|
"control": {
|
||||||
|
"frequency_hz": 20.0,
|
||||||
|
"visual_stale_ms": 200,
|
||||||
|
"radar_stale_ms": 500,
|
||||||
|
"attitude_stale_ms": 300
|
||||||
|
},
|
||||||
|
|
||||||
|
"camera": {
|
||||||
|
"image_width": 1920,
|
||||||
|
"image_height": 1080,
|
||||||
|
"fov_h_deg": 60.0,
|
||||||
|
"fov_v_deg": 35.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"heading": {
|
||||||
|
"mode": "rate",
|
||||||
|
"gain": 1.0,
|
||||||
|
"yaw_rate_limit_dps": 30.0,
|
||||||
|
"yaw_slew_limit_dps2": 60.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"vertical": {
|
||||||
|
"vz_gain_mps_per_deg": 0.1,
|
||||||
|
"vz_limit_mps": 2.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"distance": {
|
||||||
|
"d_exp_m": 10.0,
|
||||||
|
"kp": 0.5,
|
||||||
|
"ki": 0.05,
|
||||||
|
"kd": 0.1,
|
||||||
|
"integral_limit": 2.0,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
|
||||||
|
"accel_limit": {
|
||||||
|
"ax_mps2": 2.0,
|
||||||
|
"ay_mps2": 2.0,
|
||||||
|
"az_mps2": 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""pytest 根配置:保证无论从哪个工作目录启动 pytest,均可导入 utils/ 下的两个包。"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_UTILS_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(_UTILS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_UTILS_DIR))
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""demo_track_control.py —— 视觉跟踪控制链路集成演示(胶水层)。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/visual_track_control/examples/track_control_demo.cpp。
|
||||||
|
|
||||||
|
串联三个组件(target_tracker 与 visual_track_control 互不 import,
|
||||||
|
本脚本是唯一的转换点):
|
||||||
|
模拟检测序列 → TargetTracker(8 维恒速卡尔曼多目标追踪)
|
||||||
|
→ TrackResult 转 TrackState(解耦边界)
|
||||||
|
→ TrackingControlLaw(三通道控制律 + 限幅 + 状态机)
|
||||||
|
→ CommandSender(mock 记录 / MAVSDK offboard 实发)
|
||||||
|
|
||||||
|
场景时间线(20 Hz,--speedup 可加速):
|
||||||
|
0~ 6s 正常跟踪:目标匀速横移 + 雷达距离 30→10 m 前向逼近
|
||||||
|
6~10s 目标消失:追踪器 Coast 预测(60 帧 = 3 s 后轨道判丢,
|
||||||
|
控制律自动切换零速刹车)
|
||||||
|
10~13s 持续判丢:维持零速刹车
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python utils/demo_track_control.py # mock 传输
|
||||||
|
python utils/demo_track_control.py --transport mavsdk # MAVSDK(需飞控/SITL)
|
||||||
|
python utils/demo_track_control.py --speedup 4 # 4 倍速
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from target_tracker.target_tracker import Detection, TargetTracker, TrackerConfig
|
||||||
|
from visual_track_control.config import load_from_json
|
||||||
|
from visual_track_control.mavsdk_gateway import MavsdkCommandGateway
|
||||||
|
from visual_track_control.tracking_control_law import TrackingControlLaw
|
||||||
|
from visual_track_control.types import (ControlMode, ControlSnapshot,
|
||||||
|
ControlOutput, TrackState)
|
||||||
|
|
||||||
|
logger = logging.getLogger("demo")
|
||||||
|
|
||||||
|
_DEMO_DIR = Path(__file__).resolve().parent
|
||||||
|
_DEFAULT_CONFIG = _DEMO_DIR / "config" / "track_control.json"
|
||||||
|
|
||||||
|
# 模拟场景参数
|
||||||
|
_NORMAL_SECS = 6.0 # 正常跟踪阶段
|
||||||
|
_COAST_SECS = 4.0 # 目标消失 → Coast(含轨道判亡)
|
||||||
|
_LOST_SECS = 3.0 # 判丢后持续刹车阶段
|
||||||
|
_DEMO_MAX_LOST_FRAMES = 60 # 追踪器 Coast 窗口(20 Hz 下 = 3 s)
|
||||||
|
_STATUS_EVERY_TICKS = 10 # 每 0.5 s 打印一行状态
|
||||||
|
|
||||||
|
|
||||||
|
def track_result_to_state(result) -> TrackState:
|
||||||
|
"""两包之间的唯一转换点:TrackResult(box 为对象)→ TrackState(plain tuple)。"""
|
||||||
|
box = result.box
|
||||||
|
raw = result.raw
|
||||||
|
return TrackState(
|
||||||
|
tracked=result.tracked,
|
||||||
|
primary_id=result.primary_id,
|
||||||
|
center=result.center,
|
||||||
|
box=(box.x1, box.y1, box.x2, box.y2) if box is not None else None,
|
||||||
|
confidence=result.confidence,
|
||||||
|
is_predicted=result.is_predicted,
|
||||||
|
lost_frames=result.lost_frames,
|
||||||
|
n_active=result.n_active,
|
||||||
|
raw=(raw.x1, raw.y1, raw.x2, raw.y2) if raw is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_detections(tick: int, normal_ticks: int, period_s: float):
|
||||||
|
"""场景检测源:正常阶段目标匀速横移,之后消失。"""
|
||||||
|
if tick >= normal_ticks:
|
||||||
|
return []
|
||||||
|
cx = 960.0 + 3.0 * tick # 3 px/帧 匀速右移
|
||||||
|
cy = 540.0
|
||||||
|
w, h = 120.0, 160.0
|
||||||
|
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;之后未关联。"""
|
||||||
|
if tick >= normal_ticks:
|
||||||
|
return 0.0, False
|
||||||
|
distance = max(10.0, 30.0 - tick * period_s * 3.0)
|
||||||
|
return distance, True
|
||||||
|
|
||||||
|
|
||||||
|
class MockCommandSender:
|
||||||
|
"""CommandSender 的 mock 实现:记录最近一条指令描述(无实机验证能力)。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.last_desc = "-"
|
||||||
|
|
||||||
|
async def start(self) -> bool:
|
||||||
|
logger.info("[MockTransport] offboard 启动(模拟)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def send_velocity_heading(self, output: ControlOutput,
|
||||||
|
time_boot_ms=None) -> bool:
|
||||||
|
self.last_desc = (f"vx={output.vx_mps:+.2f} vy={output.vy_mps:+.2f} "
|
||||||
|
f"vz={output.vz_mps:+.2f} "
|
||||||
|
f"yaw_rate={output.yaw_rate_dps:+.2f}°/s "
|
||||||
|
f"yaw={output.yaw_deg:+.2f}°")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def send_brake_hover(self, time_boot_ms=None) -> bool:
|
||||||
|
self.last_desc = "零速刹车(全零机体速度设定点)"
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
logger.info("[MockTransport] offboard 停止(模拟)")
|
||||||
|
|
||||||
|
|
||||||
|
async def make_sender(args) -> object:
|
||||||
|
if args.transport == "mock":
|
||||||
|
return MockCommandSender()
|
||||||
|
from mavsdk import System
|
||||||
|
drone = System()
|
||||||
|
logger.info("连接飞控 %s ...", args.conn)
|
||||||
|
await drone.connect(system_address=args.conn)
|
||||||
|
async for state in drone.core.connection_state():
|
||||||
|
if state.is_connected:
|
||||||
|
logger.info("飞控已连接")
|
||||||
|
break
|
||||||
|
return MavsdkCommandGateway(drone)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(sender, out: ControlOutput, now_ms: int):
|
||||||
|
"""按模式分发指令;DEGRADED_HOLD 不发送(禁止正常控制)。"""
|
||||||
|
if out.mode is ControlMode.VELOCITY_HEADING and out.valid:
|
||||||
|
return await sender.send_velocity_heading(out, now_ms)
|
||||||
|
if out.mode is ControlMode.BRAKE_HOVER and out.valid:
|
||||||
|
return await sender.send_brake_hover(now_ms)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def run_demo(args) -> None:
|
||||||
|
cfg = load_from_json(args.config)
|
||||||
|
period_s = 1.0 / cfg.control.frequency_hz
|
||||||
|
period_ms = int(round(period_s * 1000.0))
|
||||||
|
|
||||||
|
tracker = TargetTracker(TrackerConfig(max_lost_frames=_DEMO_MAX_LOST_FRAMES))
|
||||||
|
law = TrackingControlLaw(cfg)
|
||||||
|
sender = await make_sender(args)
|
||||||
|
|
||||||
|
normal_ticks = int(round(_NORMAL_SECS / period_s))
|
||||||
|
coast_ticks = int(round(_COAST_SECS / period_s))
|
||||||
|
lost_ticks = int(round(_LOST_SECS / period_s))
|
||||||
|
total_ticks = normal_ticks + coast_ticks + lost_ticks
|
||||||
|
|
||||||
|
if not await sender.start():
|
||||||
|
logger.error("offboard 启动失败,退出")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("开始 %d 拍(%.1f s,%.1f Hz):正常 %.1fs → Coast %.1fs → 判丢 %.1fs",
|
||||||
|
total_ticks, total_ticks * period_s, cfg.control.frequency_hz,
|
||||||
|
_NORMAL_SECS, _COAST_SECS, _LOST_SECS)
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
attitude_present=True,
|
||||||
|
attitude_time_ms=now_ms, # 模拟姿态源持续可用
|
||||||
|
yaw_rad=0.0, # 机头朝北(NED 旋转恒等)
|
||||||
|
)
|
||||||
|
out = law.update(snap, now_ms)
|
||||||
|
await dispatch(sender, out, now_ms)
|
||||||
|
|
||||||
|
if tick % _STATUS_EVERY_TICKS == 0:
|
||||||
|
logger.info(
|
||||||
|
"t=%5.2fs 检测=%d 追踪[trk=%d pred=%d lost=%d] | "
|
||||||
|
"[%s] vx=%+.2f vy=%+.2f vz=%+.2f yaw_rate=%+.2f coast=%d | 发送: %s",
|
||||||
|
(tick * period_s), len(detections), result.tracked,
|
||||||
|
result.is_predicted, result.lost_frames, out.mode.name,
|
||||||
|
out.vx_mps, out.vy_mps, out.vz_mps, out.yaw_rate_dps,
|
||||||
|
out.coast_active, sender.last_desc)
|
||||||
|
await asyncio.sleep(period_s / args.speedup)
|
||||||
|
finally:
|
||||||
|
await sender.stop()
|
||||||
|
logger.info("演示结束")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="视觉跟踪控制链路集成演示")
|
||||||
|
parser.add_argument("--transport", choices=["mock", "mavsdk"], default="mock",
|
||||||
|
help="指令发送方式:mock=本地记录,mavsdk=offboard 实发")
|
||||||
|
parser.add_argument("--conn", default="udp://:14540",
|
||||||
|
help="MAVSDK 连接串(仅 --transport mavsdk 生效)")
|
||||||
|
parser.add_argument("--speedup", type=float, default=1.0,
|
||||||
|
help="时间倍速(>1 加速演示)")
|
||||||
|
parser.add_argument("--config", default=str(_DEFAULT_CONFIG),
|
||||||
|
help="控制律配置文件路径")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO,
|
||||||
|
format="%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
|
||||||
|
datefmt="%H:%M:%S")
|
||||||
|
asyncio.run(run_demo(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = *_test.py test_*.py
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# target_tracker(Python 版)
|
||||||
|
|
||||||
|
多目标追踪过滤器:8 维恒速卡尔曼滤波 + 贪婪关联 + 主目标锁定选举。
|
||||||
|
移植自 `reference-c++/target_tracker/`(规格:docs/视觉追踪技术路线.md)。
|
||||||
|
|
||||||
|
## 接口清单
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Detection(x1, y1, x2, y2, conf, cls_id=0)` | 单帧检测框输入 |
|
||||||
|
| `FrameShape(height, width)` | 画面尺寸(仅 Coast 兜底选举取画面中心用) |
|
||||||
|
| `TrackResult` | 每帧输出九字段:tracked / primary_id / center / box / confidence / is_predicted / lost_frames / n_active / raw |
|
||||||
|
| `TrackerConfig(max_lost_frames=8, max_association_dist=200.0, min_hits=3, dist_weight=0.5, iou_weight=0.5)` | 追踪器配置 |
|
||||||
|
| `TargetTracker(config=None)` | 追踪器主类 |
|
||||||
|
| `.update(detections, frame_shape=None) -> TrackResult` | 单帧主流程(§7 六步) |
|
||||||
|
| `.last_result() -> TrackResult` | 跨线程读取最近一帧结果(加锁拷贝) |
|
||||||
|
| `.reset()` / `.active_count()` / `.active_tracks()` / `.get_config()` | 生命周期与查询 |
|
||||||
|
| `KalmanBoxFilter` / `BoxCXCYWH` | 单目标滤波器(可独立使用:initiate / predict / update) |
|
||||||
|
| `Tracklet` / `BoxXYXY` / `to_center_size` / `to_xyxy` / `compute_iou` | 轨道与几何工具 |
|
||||||
|
|
||||||
|
## 与 C++ 版的差异
|
||||||
|
|
||||||
|
- **依赖**:矩阵运算用 numpy(C++ 手写定长运算避免 Eigen);关联/选举路径用纯标量,与 C++ 逐位同序。
|
||||||
|
- **Optional 语义**:C++ `std::optional<T>` → Python `None`;`State()` → `state` 属性(返回拷贝)。
|
||||||
|
- **线程模型**:与 C++ 一致——仅 `last_result()` 加 `threading.Lock`,`update()` 单线程调用。
|
||||||
|
- **字段命名**:成员函数蛇形命名(`IsDead` → `is_dead` 属性,`MarkMiss` → `mark_miss`)。
|
||||||
|
- 行为语义(死亡判定 `lost_count > max_lost` 严格大于、best_cost=1.0 严格小于、
|
||||||
|
ID 严格递增不复用、Q 用状态原值 / R 用 abs×2 的刻意不对称、S 求逆前 1e-9 正则化)
|
||||||
|
与 C++ 完全一致,由 27 条移植测试锁定。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""target_tracker —— 轻量级多目标追踪过滤器(C++ 版 reference-c++/target_tracker 的 Python 移植)。
|
||||||
|
|
||||||
|
纯算法包,零第三方运行依赖之外的约束:仅依赖 numpy,不依赖 visual_track_control。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .kalman_box_filter import STD_POS, STD_VEL, INI_POS, INI_VEL, R_SCALE, BoxCXCYWH, KalmanBoxFilter
|
||||||
|
from .target_tracker import Detection, FrameShape, TargetTracker, TrackerConfig, TrackResult
|
||||||
|
from .tracklet import BoxXYXY, Tracklet, compute_iou, to_center_size, to_xyxy
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"STD_POS", "STD_VEL", "INI_POS", "INI_VEL", "R_SCALE",
|
||||||
|
"BoxCXCYWH", "BoxXYXY", "KalmanBoxFilter", "Tracklet",
|
||||||
|
"Detection", "FrameShape", "TargetTracker", "TrackerConfig", "TrackResult",
|
||||||
|
"compute_iou", "to_center_size", "to_xyxy",
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,111 @@
|
|||||||
|
"""单目标 8 维恒定速度卡尔曼滤波器。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/target_tracker(规格:docs/视觉追踪技术路线.md §4):
|
||||||
|
状态 x[8] = [cx, cy, w, h, vx, vy, vw, vh],观测 z[4] = [cx, cy, w, h],dt = 1 帧。
|
||||||
|
矩阵运算用 numpy 表达(C++ 版为避免 Eigen 手写定长运算,此处等价)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# §4.2 / §8.2 内部常量(经验值,针对 640×480 画面调优)
|
||||||
|
STD_POS = 1.0 / 50.0 # 位置噪声比例系数
|
||||||
|
STD_VEL = 1.0 / 200.0 # 速度噪声比例系数
|
||||||
|
INI_POS = 2.0 # 初始位置协方差
|
||||||
|
INI_VEL = 100.0 # 初始速度协方差
|
||||||
|
R_SCALE = 2.0 # R 额外缩放因子(增强平滑)
|
||||||
|
_S_REG = 1e-9 # S 求逆前的对角正则化(§9.3)
|
||||||
|
|
||||||
|
# 状态转移矩阵 F(§4.1):I4 + 右上 I4
|
||||||
|
_F = np.eye(8, dtype=float)
|
||||||
|
_F[0:4, 4:8] = np.eye(4)
|
||||||
|
|
||||||
|
# 观测矩阵 H(§4.1):[I4 | 0],直接观测位置与尺寸、不观测速度
|
||||||
|
_H_COLS = slice(0, 4)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoxCXCYWH:
|
||||||
|
"""轴对齐边界框(中心点 + 宽高表示,卡尔曼观测空间)。"""
|
||||||
|
|
||||||
|
cx: float = 0.0
|
||||||
|
cy: float = 0.0
|
||||||
|
w: float = 0.0
|
||||||
|
h: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class KalmanBoxFilter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._x = np.zeros(8, dtype=float)
|
||||||
|
self._p = np.zeros((8, 8), dtype=float)
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initialized(self) -> bool:
|
||||||
|
return self._initialized
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> np.ndarray:
|
||||||
|
"""状态向量 [cx, cy, w, h, vx, vy, vw, vh](拷贝)。"""
|
||||||
|
return self._x.copy()
|
||||||
|
|
||||||
|
def initiate(self, z: BoxCXCYWH) -> None:
|
||||||
|
# §4.3:速度置零;P = diag(2,2,2,2,100,100,100,100)
|
||||||
|
self._x = np.array([z.cx, z.cy, z.w, z.h, 0.0, 0.0, 0.0, 0.0])
|
||||||
|
self._p = np.diag([INI_POS] * 4 + [INI_VEL] * 4)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def predict(self) -> BoxCXCYWH | None:
|
||||||
|
# §4.4:未初始化返回 None
|
||||||
|
if not self._initialized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# §4.2 动态过程噪声:Q = diag(σ_pos², σ_vel²),σ 按状态中 w/h 缩放。
|
||||||
|
# Q 直接使用状态值、不做 abs,与 R 的 abs 处理刻意不同(规格 §4.2 注记)。
|
||||||
|
w = self._x[2]
|
||||||
|
h = self._x[3]
|
||||||
|
std_pos = np.array([w * STD_POS, h * STD_POS, w * STD_POS, h * STD_POS])
|
||||||
|
std_vel = np.array([w * STD_VEL, h * STD_VEL, w * STD_VEL, h * STD_VEL])
|
||||||
|
q = np.zeros((8, 8), dtype=float)
|
||||||
|
q[np.arange(4), np.arange(4)] = std_pos * std_pos
|
||||||
|
q[np.arange(4) + 4, np.arange(4) + 4] = std_vel * std_vel
|
||||||
|
|
||||||
|
# §4.4:x ← F·x;P ← F·P·Fᵀ + Q
|
||||||
|
self._x = _F @ self._x
|
||||||
|
self._p = _F @ self._p @ _F.T + q
|
||||||
|
return BoxCXCYWH(float(self._x[0]), float(self._x[1]),
|
||||||
|
float(self._x[2]), float(self._x[3]))
|
||||||
|
|
||||||
|
def update(self, z: BoxCXCYWH) -> None:
|
||||||
|
# §4.5:未初始化直接返回
|
||||||
|
if not self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# §4.2 动态观测噪声:R 用 abs(w)/abs(h) 缩放并额外乘 R_SCALE
|
||||||
|
w = abs(self._x[2])
|
||||||
|
h = abs(self._x[3])
|
||||||
|
rw = R_SCALE * (w * STD_POS) * (w * STD_POS)
|
||||||
|
rh = R_SCALE * (h * STD_POS) * (h * STD_POS)
|
||||||
|
r = np.diag([rw, rh, rw, rh])
|
||||||
|
|
||||||
|
# §4.5:S = H·P·Hᵀ + R。H = [I4|0],故 H·P·Hᵀ 即 P 左上 4×4 子块
|
||||||
|
s = self._p[:4, :4] + r
|
||||||
|
try:
|
||||||
|
s_inv = np.linalg.inv(s + _S_REG * np.eye(4))
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
return # 求逆失败:防御性跳过本次更新(C++ 版同样跳过)
|
||||||
|
|
||||||
|
# §4.5:K = P·Hᵀ·S⁻¹,P·Hᵀ 即 P 的前 4 列(8×4)
|
||||||
|
k = self._p[:, _H_COLS] @ s_inv
|
||||||
|
|
||||||
|
# 残差 y = z − H·x(H·x 即状态前 4 维)
|
||||||
|
y = np.array([z.cx, z.cy, z.w, z.h]) - self._x[:4]
|
||||||
|
|
||||||
|
# x ← x + K·y
|
||||||
|
self._x = self._x + k @ y
|
||||||
|
|
||||||
|
# P ← (I8 − K·H)·P:K·H 是 K 的前 4 列铺在前 4 列上
|
||||||
|
kh = np.zeros((8, 8), dtype=float)
|
||||||
|
kh[:, :4] = k
|
||||||
|
self._p = (np.eye(8) - kh) @ self._p
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""多目标追踪过滤器(TargetTracker)。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/target_tracker/src/target_tracker.cpp
|
||||||
|
(规格:docs/视觉追踪技术路线.md §5 关联 / §6 生命周期与锁定 / §7 六步主流程)。
|
||||||
|
|
||||||
|
线程模型(§9.5):update() 由单线程(推理主循环)调用;仅对 last_result
|
||||||
|
缓存加锁,last_result() 返回受保护拷贝。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from .kalman_box_filter import BoxCXCYWH
|
||||||
|
from .tracklet import BoxXYXY, Tracklet, compute_iou, to_center_size
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Detection:
|
||||||
|
"""单个检测框(§2.2 输入元素:[x1,y1,x2,y2,conf,cls_id])。"""
|
||||||
|
|
||||||
|
x1: float = 0.0
|
||||||
|
y1: float = 0.0
|
||||||
|
x2: float = 0.0
|
||||||
|
y2: float = 0.0
|
||||||
|
conf: float = 0.0
|
||||||
|
cls_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameShape:
|
||||||
|
"""画面尺寸(height, width):仅 Coast 兜底选举时用于取画面中心。"""
|
||||||
|
|
||||||
|
height: int = 0
|
||||||
|
width: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackResult:
|
||||||
|
"""每帧追踪输出(§2.3 九字段)。"""
|
||||||
|
|
||||||
|
tracked: bool = False
|
||||||
|
primary_id: Optional[int] = None
|
||||||
|
center: tuple[float, float] = (0.0, 0.0)
|
||||||
|
box: Optional[BoxXYXY] = None
|
||||||
|
confidence: float = 0.0
|
||||||
|
is_predicted: bool = False
|
||||||
|
lost_frames: int = 0
|
||||||
|
n_active: int = 0
|
||||||
|
raw: Optional[BoxXYXY] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackerConfig:
|
||||||
|
"""追踪器配置参数(§8.1 默认值取自规格)。"""
|
||||||
|
|
||||||
|
max_lost_frames: int = 8
|
||||||
|
max_association_dist: float = 200.0
|
||||||
|
min_hits: int = 3
|
||||||
|
dist_weight: float = 0.5
|
||||||
|
iou_weight: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AssociationResult:
|
||||||
|
matches: list[tuple[int, int]] = field(default_factory=list)
|
||||||
|
unmatched_track_ids: list[int] = field(default_factory=list)
|
||||||
|
unmatched_det_indices: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TargetTracker:
|
||||||
|
def __init__(self, config: Optional[TrackerConfig] = None) -> None:
|
||||||
|
self._config = config if config is not None else TrackerConfig()
|
||||||
|
self._next_id = 0
|
||||||
|
self._tracks: dict[int, Tracklet] = {}
|
||||||
|
self._frame_count = 0
|
||||||
|
self._locked_primary_id: Optional[int] = None
|
||||||
|
self._last_result = TrackResult()
|
||||||
|
self._last_result_mutex = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> TrackerConfig:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def get_config(self) -> TrackerConfig:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def update(self, detections: Sequence[Detection],
|
||||||
|
frame_shape: Optional[FrameShape] = None) -> TrackResult:
|
||||||
|
# §7 六步主流程
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
# 入口防御:过滤坐标或置信度含 NaN/Inf 的检测(非有限值一旦进入
|
||||||
|
# 卡尔曼状态将永久污染轨道并传导到主目标输出)
|
||||||
|
valid_detections = [
|
||||||
|
det for det in detections
|
||||||
|
if all(math.isfinite(v) for v in (det.x1, det.y1, det.x2, det.y2, det.conf))
|
||||||
|
]
|
||||||
|
|
||||||
|
# 步骤 1:所有存活轨道先行预测(推进一帧)
|
||||||
|
for track in self._tracks.values():
|
||||||
|
track.predict()
|
||||||
|
|
||||||
|
# 步骤 2:贪婪关联(无检测时全部轨道进入 Coast 计数)
|
||||||
|
if valid_detections:
|
||||||
|
assoc = self._associate_detections(valid_detections)
|
||||||
|
else:
|
||||||
|
assoc = _AssociationResult(unmatched_track_ids=list(self._tracks.keys()))
|
||||||
|
|
||||||
|
# 步骤 3:用匹配到的检测更新轨道
|
||||||
|
for track_id, det_idx in assoc.matches:
|
||||||
|
det = valid_detections[det_idx]
|
||||||
|
raw_box = BoxXYXY(det.x1, det.y1, det.x2, det.y2)
|
||||||
|
self._tracks[track_id].update(raw_box, det.conf, det.cls_id)
|
||||||
|
|
||||||
|
# 步骤 4:未匹配轨道计丢失,死亡者回收(锁定唯一解除途径之一)
|
||||||
|
for track_id in assoc.unmatched_track_ids:
|
||||||
|
track = self._tracks.get(track_id)
|
||||||
|
if track is None:
|
||||||
|
continue
|
||||||
|
track.mark_miss()
|
||||||
|
if track.is_dead:
|
||||||
|
if self._locked_primary_id == track_id:
|
||||||
|
self._locked_primary_id = None
|
||||||
|
del self._tracks[track_id]
|
||||||
|
|
||||||
|
# 步骤 5:未匹配检测 → 创建新轨道(next_id 从 0 递增永不复用)
|
||||||
|
for det_idx in assoc.unmatched_det_indices:
|
||||||
|
det = valid_detections[det_idx]
|
||||||
|
measurement = to_center_size(BoxXYXY(det.x1, det.y1, det.x2, det.y2))
|
||||||
|
self._tracks[self._next_id] = Tracklet(
|
||||||
|
self._next_id, measurement, det.conf, det.cls_id,
|
||||||
|
self._config.max_lost_frames)
|
||||||
|
self._next_id += 1
|
||||||
|
|
||||||
|
# 步骤 6:选取/延续主目标,组装输出并缓存
|
||||||
|
result = self._select_primary(frame_shape)
|
||||||
|
with self._last_result_mutex:
|
||||||
|
self._last_result = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def last_result(self) -> TrackResult:
|
||||||
|
with self._last_result_mutex:
|
||||||
|
return self._last_result
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
# §2.1:清空所有轨道、ID 计数器、锁定状态与缓存结果(next_id 归零)
|
||||||
|
self._tracks.clear()
|
||||||
|
self._next_id = 0
|
||||||
|
self._frame_count = 0
|
||||||
|
self._locked_primary_id = None
|
||||||
|
with self._last_result_mutex:
|
||||||
|
self._last_result = TrackResult()
|
||||||
|
|
||||||
|
def active_count(self) -> int:
|
||||||
|
return len(self._tracks)
|
||||||
|
|
||||||
|
def active_tracks(self) -> dict[int, Tracklet]:
|
||||||
|
return dict(self._tracks)
|
||||||
|
|
||||||
|
def _associate_detections(self, detections: Sequence[Detection]) -> _AssociationResult:
|
||||||
|
result = _AssociationResult()
|
||||||
|
|
||||||
|
# §5.3:按轨道 confidence 降序(平局按 ID 升序保证确定性)。
|
||||||
|
# 非有限置信度按 −∞ 处理(排最后),避免 NaN 破坏全序。
|
||||||
|
def sort_key(track_id: int) -> tuple[float, int]:
|
||||||
|
raw = self._tracks[track_id].confidence
|
||||||
|
conf = raw if math.isfinite(raw) else -math.inf
|
||||||
|
return (-conf, track_id)
|
||||||
|
|
||||||
|
sorted_ids = sorted(self._tracks.keys(), key=sort_key)
|
||||||
|
det_assigned = [False] * len(detections)
|
||||||
|
|
||||||
|
for track_id in sorted_ids:
|
||||||
|
track = self._tracks[track_id]
|
||||||
|
# §5.3:最优代价初始 1.0,仅 cost 严格小于才匹配(cost ≥ 1.0 判为不可匹配)
|
||||||
|
best_cost = 1.0
|
||||||
|
best_det = -1
|
||||||
|
for d, det in enumerate(detections):
|
||||||
|
if det_assigned[d]:
|
||||||
|
continue
|
||||||
|
det_cx = (det.x1 + det.x2) / 2.0
|
||||||
|
det_cy = (det.y1 + det.y2) / 2.0
|
||||||
|
dist = math.hypot(track.last_center[0] - det_cx,
|
||||||
|
track.last_center[1] - det_cy)
|
||||||
|
norm_dist = dist / max(self._config.max_association_dist, 1e-3)
|
||||||
|
iou = (compute_iou(track.last_box,
|
||||||
|
BoxXYXY(det.x1, det.y1, det.x2, det.y2))
|
||||||
|
if track.last_box is not None else 0.0)
|
||||||
|
cost = (self._config.dist_weight * norm_dist +
|
||||||
|
self._config.iou_weight * (1.0 - iou))
|
||||||
|
if cost < best_cost:
|
||||||
|
best_cost = cost
|
||||||
|
best_det = d
|
||||||
|
if best_det >= 0:
|
||||||
|
result.matches.append((track_id, best_det))
|
||||||
|
det_assigned[best_det] = True
|
||||||
|
else:
|
||||||
|
result.unmatched_track_ids.append(track_id)
|
||||||
|
|
||||||
|
result.unmatched_det_indices = [
|
||||||
|
d for d, assigned in enumerate(det_assigned) if not assigned
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _select_primary(self, frame_shape: Optional[FrameShape]) -> TrackResult:
|
||||||
|
result = TrackResult(n_active=len(self._tracks))
|
||||||
|
|
||||||
|
# §6.3:轨道表为空 → 清除锁定,返回 tracked=false
|
||||||
|
if not self._tracks:
|
||||||
|
self._locked_primary_id = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
# §6.3 ① 锁定延续:锁定轨道存活则直接输出(Coast 中照样输出,
|
||||||
|
# 不被高置信度目标抢占)
|
||||||
|
chosen: Optional[Tracklet] = None
|
||||||
|
is_predicted = False
|
||||||
|
if self._locked_primary_id is not None:
|
||||||
|
track = self._tracks.get(self._locked_primary_id)
|
||||||
|
if track is not None:
|
||||||
|
chosen = track
|
||||||
|
is_predicted = track.is_coasting
|
||||||
|
|
||||||
|
# §6.3 ② 重新选举(无锁定 / 锁定目标已死亡被回收)
|
||||||
|
if chosen is None:
|
||||||
|
measured = [t for t in self._tracks.values() if not t.is_coasting]
|
||||||
|
coasting = [t for t in self._tracks.values() if t.is_coasting]
|
||||||
|
|
||||||
|
if measured:
|
||||||
|
# confirmed ← measured 中 hits ≥ min_hits 者;为空则退回 measured
|
||||||
|
# (min_hits 只影响候选池优先级,不阻止输出)
|
||||||
|
pool = [t for t in measured if t.hits >= self._config.min_hits]
|
||||||
|
if not pool:
|
||||||
|
pool = measured
|
||||||
|
chosen = pool[0]
|
||||||
|
for t in pool:
|
||||||
|
if t.confidence > chosen.confidence:
|
||||||
|
chosen = t
|
||||||
|
is_predicted = False
|
||||||
|
elif coasting:
|
||||||
|
# 无实测轨道:选离画面中心最近者(缺省按 640×480 中心 (320,240))
|
||||||
|
center_x = frame_shape.width / 2.0 if frame_shape and frame_shape.width > 0 else 320.0
|
||||||
|
center_y = frame_shape.height / 2.0 if frame_shape and frame_shape.height > 0 else 240.0
|
||||||
|
best_dist_sq = math.inf
|
||||||
|
for t in coasting:
|
||||||
|
dx = t.last_center[0] - center_x
|
||||||
|
dy = t.last_center[1] - center_y
|
||||||
|
dist_sq = dx * dx + dy * dy
|
||||||
|
if dist_sq < best_dist_sq:
|
||||||
|
best_dist_sq = dist_sq
|
||||||
|
chosen = t
|
||||||
|
is_predicted = True
|
||||||
|
else:
|
||||||
|
# 理论上不可达(轨道非空则 measured/coasting 必居其一);防御清锁
|
||||||
|
self._locked_primary_id = None
|
||||||
|
return result
|
||||||
|
self._locked_primary_id = chosen.track_id # 选举后锁定
|
||||||
|
|
||||||
|
# 组装输出(§2.3 九字段);raw 仅实测时有值,Coast 时为空
|
||||||
|
result.tracked = True
|
||||||
|
result.primary_id = chosen.track_id
|
||||||
|
result.center = chosen.last_center
|
||||||
|
result.box = chosen.last_box
|
||||||
|
result.confidence = chosen.confidence
|
||||||
|
result.is_predicted = is_predicted
|
||||||
|
result.lost_frames = chosen.lost_count
|
||||||
|
result.raw = None if is_predicted else chosen.last_raw_box
|
||||||
|
return result
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""轨道(Tracklet)与基础几何工具。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/target_tracker/include/target_tracker/tracklet.h
|
||||||
|
(规格:docs/视觉追踪技术路线.md §3.2 / §5.2 / §7)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .kalman_box_filter import BoxCXCYWH, KalmanBoxFilter
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoxXYXY:
|
||||||
|
"""轴对齐边界框(像素坐标,左上/右下角表示)。"""
|
||||||
|
|
||||||
|
x1: float = 0.0
|
||||||
|
y1: float = 0.0
|
||||||
|
x2: float = 0.0
|
||||||
|
y2: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def to_center_size(box: BoxXYXY) -> BoxCXCYWH:
|
||||||
|
# §7 TRACKLET_UPDATE:cx=(x1+x2)/2;cy=(y1+y2)/2;w=x2−x1;h=y2−y1
|
||||||
|
return BoxCXCYWH((box.x1 + box.x2) / 2.0, (box.y1 + box.y2) / 2.0,
|
||||||
|
box.x2 - box.x1, box.y2 - box.y1)
|
||||||
|
|
||||||
|
|
||||||
|
def to_xyxy(box: BoxCXCYWH) -> BoxXYXY:
|
||||||
|
# §7 TRACKLET_PREDICT 的 center_size 逆变换
|
||||||
|
return BoxXYXY(box.cx - box.w / 2.0, box.cy - box.h / 2.0,
|
||||||
|
box.cx + box.w / 2.0, box.cy + box.h / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_iou(a: BoxXYXY, b: BoxXYXY) -> float:
|
||||||
|
"""标准轴对齐 IoU(§5.2):任一框为空、交集 ≤ 0 或并集 ≤ 0 时返回 0。"""
|
||||||
|
area_a = (a.x2 - a.x1) * (a.y2 - a.y1)
|
||||||
|
area_b = (b.x2 - b.x1) * (b.y2 - b.y1)
|
||||||
|
if area_a <= 0.0 or area_b <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
inter_w = min(a.x2, b.x2) - max(a.x1, b.x1)
|
||||||
|
inter_h = min(a.y2, b.y2) - max(a.y1, b.y1)
|
||||||
|
if inter_w <= 0.0 or inter_h <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
inter_area = inter_w * inter_h
|
||||||
|
union_area = area_a + area_b - inter_area
|
||||||
|
if union_area <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
return inter_area / union_area
|
||||||
|
|
||||||
|
|
||||||
|
class Tracklet:
|
||||||
|
"""单条轨道:生命周期计数 + 辅助状态 + 卡尔曼滤波器(§3.2)。
|
||||||
|
|
||||||
|
构造即创建轨道(§7 步骤 5):用首次观测初始化滤波器,age=1、hits=1、
|
||||||
|
lost_count=0,last_center / last_box 立即由观测重建(创建当帧即可参与输出)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, track_id: int, measurement: BoxCXCYWH, conf: float,
|
||||||
|
cls_id: int, max_lost_frames: int) -> None:
|
||||||
|
self.track_id = track_id
|
||||||
|
self.kf = KalmanBoxFilter()
|
||||||
|
self.lost_count = 0
|
||||||
|
self.max_lost = max_lost_frames
|
||||||
|
self.confidence = conf
|
||||||
|
self.cls_id = cls_id
|
||||||
|
self.age = 1
|
||||||
|
self.hits = 1
|
||||||
|
self.last_center: tuple[float, float] = (measurement.cx, measurement.cy)
|
||||||
|
self.last_box: Optional[BoxXYXY] = to_xyxy(measurement)
|
||||||
|
# 创建即源于实测检测:同步记录原始检测框(Coast 期间保留不更新)
|
||||||
|
self.last_raw_box: Optional[BoxXYXY] = self.last_box
|
||||||
|
self.kf.initiate(measurement)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dead(self) -> bool:
|
||||||
|
# §6.1 注记 2:lost_count > max_lost(严格大于)
|
||||||
|
return self.lost_count > self.max_lost
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_coasting(self) -> bool:
|
||||||
|
# §3.2:lost_count > 0 即处于纯预测状态
|
||||||
|
return self.lost_count > 0
|
||||||
|
|
||||||
|
def predict(self) -> None:
|
||||||
|
# §7 TRACKLET_PREDICT:预测成功时更新 last_center / last_box 且 age+1
|
||||||
|
pred = self.kf.predict()
|
||||||
|
if pred is not None:
|
||||||
|
self.last_center = (pred.cx, pred.cy)
|
||||||
|
self.last_box = to_xyxy(pred)
|
||||||
|
self.age += 1
|
||||||
|
|
||||||
|
def update(self, box: BoxXYXY, conf: float, cls_id: int) -> None:
|
||||||
|
# §7 TRACKLET_UPDATE:框 → [cx,cy,w,h] → kf.update
|
||||||
|
self.kf.update(to_center_size(box))
|
||||||
|
self.last_center = ((box.x1 + box.x2) / 2.0, (box.y1 + box.y2) / 2.0)
|
||||||
|
self.last_box = box
|
||||||
|
self.last_raw_box = box
|
||||||
|
self.confidence = conf
|
||||||
|
self.cls_id = cls_id
|
||||||
|
self.lost_count = 0
|
||||||
|
self.hits += 1
|
||||||
|
self.age += 1
|
||||||
|
|
||||||
|
def mark_miss(self) -> None:
|
||||||
|
# §7 步骤 4:未匹配计丢失
|
||||||
|
self.lost_count += 1
|
||||||
|
self.age += 1
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,107 @@
|
|||||||
|
"""kalman_box_filter_test.py —— KalmanBoxFilter 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/target_tracker/tests/kalman_box_filter_test.cpp。
|
||||||
|
覆盖(视觉追踪技术路线 §4):初始化状态、静止目标收敛、匀速目标速度收敛
|
||||||
|
且预测超前一个 dt、未初始化 predict 返回 None。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from target_tracker.kalman_box_filter import BoxCXCYWH, KalmanBoxFilter
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninitialized_predict_returns_none():
|
||||||
|
kf = KalmanBoxFilter()
|
||||||
|
assert not kf.initialized
|
||||||
|
assert kf.predict() is None
|
||||||
|
|
||||||
|
# 未初始化时 update 无副作用
|
||||||
|
kf.update(BoxCXCYWH(100.0, 100.0, 20.0, 20.0))
|
||||||
|
assert not kf.initialized
|
||||||
|
assert kf.predict() is None
|
||||||
|
for v in kf.state:
|
||||||
|
assert v == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_initiate_sets_state_correctly():
|
||||||
|
kf = KalmanBoxFilter()
|
||||||
|
kf.initiate(BoxCXCYWH(320.0, 240.0, 40.0, 30.0))
|
||||||
|
assert kf.initialized
|
||||||
|
|
||||||
|
expected = [320.0, 240.0, 40.0, 30.0, 0.0, 0.0, 0.0, 0.0]
|
||||||
|
state = kf.state
|
||||||
|
for i, want in enumerate(expected):
|
||||||
|
assert state[i] == want, f"index={i}"
|
||||||
|
|
||||||
|
# 初始化后 predict 返回状态前 4 维(速度为零 → 位置不变,§4.4)
|
||||||
|
pred = kf.predict()
|
||||||
|
assert pred is not None
|
||||||
|
assert pred.cx == pytest.approx(320.0, abs=1e-9)
|
||||||
|
assert pred.cy == pytest.approx(240.0, abs=1e-9)
|
||||||
|
assert pred.w == pytest.approx(40.0, abs=1e-9)
|
||||||
|
assert pred.h == pytest.approx(30.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_target_converges_to_observation():
|
||||||
|
kf = KalmanBoxFilter()
|
||||||
|
z = BoxCXCYWH(100.0, 150.0, 40.0, 60.0)
|
||||||
|
kf.initiate(BoxCXCYWH(104.0, 156.0, 42.0, 62.0)) # 带初始偏差,验证收敛能力
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
kf.predict()
|
||||||
|
kf.update(z)
|
||||||
|
|
||||||
|
state = kf.state
|
||||||
|
assert state[0] == pytest.approx(z.cx, abs=0.5) # cx
|
||||||
|
assert state[1] == pytest.approx(z.cy, abs=0.5) # cy
|
||||||
|
assert state[2] == pytest.approx(z.w, abs=0.5) # w
|
||||||
|
assert state[3] == pytest.approx(z.h, abs=0.5) # h
|
||||||
|
# 静止目标速度应收敛到约 0
|
||||||
|
assert state[4] == pytest.approx(0.0, abs=0.1)
|
||||||
|
assert state[5] == pytest.approx(0.0, abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_velocity_converges_and_predict_leads_one_dt():
|
||||||
|
kf = KalmanBoxFilter()
|
||||||
|
vx = 3.0 # 像素/帧
|
||||||
|
vy = 2.0 # 像素/帧
|
||||||
|
|
||||||
|
# 目标从 (100, 100) 出发匀速运动,每帧观测一次(predict → update)
|
||||||
|
z = BoxCXCYWH(100.0, 100.0, 30.0, 30.0)
|
||||||
|
kf.initiate(z)
|
||||||
|
for _ in range(60):
|
||||||
|
kf.predict()
|
||||||
|
z.cx += vx
|
||||||
|
z.cy += vy
|
||||||
|
kf.update(z)
|
||||||
|
|
||||||
|
state = kf.state
|
||||||
|
# 速度收敛到真实速度(像素/帧)
|
||||||
|
assert state[4] == pytest.approx(vx, abs=0.3), "vx 未收敛"
|
||||||
|
assert state[5] == pytest.approx(vy, abs=0.3), "vy 未收敛"
|
||||||
|
# 位置贴合当前观测
|
||||||
|
assert state[0] == pytest.approx(z.cx, abs=1.5)
|
||||||
|
assert state[1] == pytest.approx(z.cy, abs=1.5)
|
||||||
|
|
||||||
|
# 再预测一帧:应超前当前观测一个 dt(≈ 速度 × 1 帧)
|
||||||
|
pred = kf.predict()
|
||||||
|
assert pred is not None
|
||||||
|
assert pred.cx == pytest.approx(z.cx + vx, abs=1.0), "预测未超前一个 dt"
|
||||||
|
assert pred.cy == pytest.approx(z.cy + vy, abs=1.0), "预测未超前一个 dt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_target_noise_scaling_stable():
|
||||||
|
# 动态噪声不退化:小尺寸目标多帧观测仍稳定收敛(§4.2 噪声与尺寸成比例)
|
||||||
|
kf = KalmanBoxFilter()
|
||||||
|
z = BoxCXCYWH(50.0, 60.0, 4.0, 4.0) # 小目标(4×4 像素)
|
||||||
|
kf.initiate(z)
|
||||||
|
for _ in range(30):
|
||||||
|
kf.predict()
|
||||||
|
kf.update(z)
|
||||||
|
state = kf.state
|
||||||
|
assert state[0] == pytest.approx(z.cx, abs=0.5)
|
||||||
|
assert state[1] == pytest.approx(z.cy, abs=0.5)
|
||||||
|
assert math.isfinite(state[2])
|
||||||
|
assert math.isfinite(state[3])
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""target_tracker_test.py —— TargetTracker 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/target_tracker/tests/target_tracker_test.cpp。
|
||||||
|
逐条覆盖 docs/视觉追踪技术路线.md §9.4 行为一致性验收用例(硬验收标准):
|
||||||
|
1. 空检测帧序列:Coast 持续 max_lost_frames 帧,第 max_lost_frames+1 帧判丢
|
||||||
|
2. ID 严格递增不复用(含 Reset 后归零)
|
||||||
|
3. 锁定不被抢占;锁定目标死亡后切换到新目标
|
||||||
|
4. min_hits 回退:无已确认实测轨道时新轨道也可当选主目标
|
||||||
|
5. cost=1.0 恰好不构成匹配(严格小于)
|
||||||
|
6. 置信度优先:两轨道竞争同一检测,置信度高者获胜
|
||||||
|
另含 NaN/Inf 防御与 §2.3/§6.3/§9.5 行为测试。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from target_tracker.target_tracker import (Detection, FrameShape, TargetTracker,
|
||||||
|
TrackerConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def make_det(x1, y1, x2, y2, conf, cls=0):
|
||||||
|
return Detection(x1, y1, x2, y2, conf, cls)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 验收用例 1(§9.4-1):空检测帧序列
|
||||||
|
# ============================================================================
|
||||||
|
def test_empty_frames_coast_then_lost():
|
||||||
|
tracker = TargetTracker() # 默认配置 max_lost_frames = 8
|
||||||
|
assert tracker.get_config().max_lost_frames == 8
|
||||||
|
|
||||||
|
# 第 1 帧:给出一个检测,建立轨道并锁定为主目标
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.9)])
|
||||||
|
assert r.tracked
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert not r.is_predicted # 当帧有实测
|
||||||
|
assert r.lost_frames == 0
|
||||||
|
assert r.n_active == 1
|
||||||
|
assert r.box is not None
|
||||||
|
assert r.raw is not None # 当帧关联到原始检测框
|
||||||
|
|
||||||
|
# 后续空检测帧:应持续 Coast 共 max_lost_frames 帧(is_predicted=True)
|
||||||
|
for i in range(1, 9):
|
||||||
|
r = tracker.update([])
|
||||||
|
assert r.tracked, f"第 {i} 帧 Coast 期间不应判丢"
|
||||||
|
assert r.is_predicted, f"第 {i} 帧应为纯预测输出"
|
||||||
|
assert r.lost_frames == i
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.box is not None # Coast 期间继续输出预测框
|
||||||
|
assert r.raw is None # Coast 时 raw 为空(§2.3)
|
||||||
|
assert r.confidence == pytest.approx(0.9) # 继承最近实测置信度(§2.3)
|
||||||
|
|
||||||
|
# 第 max_lost_frames + 1 帧:轨道死亡回收 → tracked=False
|
||||||
|
r = tracker.update([])
|
||||||
|
assert not r.tracked
|
||||||
|
assert r.primary_id is None
|
||||||
|
assert r.box is None
|
||||||
|
assert r.lost_frames == 0 # 丢失时 lost_frames=0(§2.3)
|
||||||
|
assert r.n_active == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 验收用例 2(§9.4-2):ID 单调性
|
||||||
|
# ============================================================================
|
||||||
|
def test_id_strictly_increasing_no_reuse():
|
||||||
|
cfg = TrackerConfig()
|
||||||
|
cfg.max_lost_frames = 1 # 缩短 Coast 窗口,加速轨道死亡
|
||||||
|
tracker = TargetTracker(cfg)
|
||||||
|
|
||||||
|
# 目标 A 出现 → id 0
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
id_a = r.primary_id
|
||||||
|
assert id_a == 0
|
||||||
|
|
||||||
|
# 目标 A 消失至死亡(max_lost=1:第 2 帧 Coast、第 3 帧死亡回收)
|
||||||
|
tracker.update([])
|
||||||
|
r = tracker.update([])
|
||||||
|
assert not r.tracked
|
||||||
|
assert tracker.active_count() == 0
|
||||||
|
|
||||||
|
# 目标 B 出现(同一位置):ID 不复用,严格递增 → id 1
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
id_b = r.primary_id
|
||||||
|
assert id_b == 1
|
||||||
|
assert id_b > id_a
|
||||||
|
|
||||||
|
# 目标 C 同帧新建 → id 2(同帧多目标亦严格递增)
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.9),
|
||||||
|
make_det(400.0, 300.0, 430.0, 330.0, 0.8)])
|
||||||
|
assert tracker.active_count() == 2
|
||||||
|
max_id = max(tracker.active_tracks().keys())
|
||||||
|
assert max_id == 2
|
||||||
|
|
||||||
|
# Reset 后计数器归零(§6.2):新轨道重新从 id 0 开始
|
||||||
|
tracker.reset()
|
||||||
|
assert tracker.active_count() == 0
|
||||||
|
assert not tracker.last_result().tracked
|
||||||
|
r = tracker.update([make_det(50.0, 50.0, 70.0, 70.0, 0.9)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.primary_id == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 验收用例 3(§9.4-3):锁定不被抢占
|
||||||
|
# ============================================================================
|
||||||
|
def test_lock_not_stolen_and_switch_after_death():
|
||||||
|
cfg = TrackerConfig()
|
||||||
|
cfg.max_lost_frames = 3
|
||||||
|
tracker = TargetTracker(cfg)
|
||||||
|
|
||||||
|
# 低置信度目标 A 建立并锁定
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.4)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
id_a = r.primary_id
|
||||||
|
|
||||||
|
# A 消失进入 Coast;期间出现更高置信度新目标 B(远离 A,不会被误关联)
|
||||||
|
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.primary_id == id_a, "Coast 期间锁定不应被高置信度目标抢占"
|
||||||
|
assert r.is_predicted
|
||||||
|
assert tracker.active_count() == 2 # A(Coast)与 B 并存
|
||||||
|
|
||||||
|
# 继续 Coast,锁定依旧延续
|
||||||
|
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
|
||||||
|
assert r.primary_id == id_a
|
||||||
|
assert r.is_predicted
|
||||||
|
|
||||||
|
# A 继续丢失直至死亡(max_lost=3:lost 达 4 帧时回收)
|
||||||
|
for _ in range(2):
|
||||||
|
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
|
||||||
|
# A 已死亡删除 → 锁定解除,重新选举切换到 B(此时 B 已确认且实测)
|
||||||
|
assert r.tracked
|
||||||
|
assert r.primary_id != id_a, "锁定目标死亡后应切换到新目标"
|
||||||
|
assert not r.is_predicted
|
||||||
|
|
||||||
|
# 后续 B 持续作为主目标
|
||||||
|
r = tracker.update([make_det(500.0, 300.0, 540.0, 340.0, 0.95)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.primary_id != id_a
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 验收用例 4(§9.4-4):min_hits 回退
|
||||||
|
# ============================================================================
|
||||||
|
def test_min_hits_fallback_allows_new_track():
|
||||||
|
cfg = TrackerConfig()
|
||||||
|
cfg.min_hits = 5 # 抬高确认门槛,保证测试期间轨道始终未确认
|
||||||
|
tracker = TargetTracker(cfg)
|
||||||
|
|
||||||
|
# 首帧新建轨道 hits=1 < min_hits:候选池 confirmed 为空 → 退回 measured
|
||||||
|
r = tracker.update([make_det(200.0, 150.0, 240.0, 190.0, 0.6)])
|
||||||
|
assert r.tracked, "无已确认轨道时新轨道应能当选主目标"
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.primary_id == 0
|
||||||
|
assert not r.is_predicted
|
||||||
|
|
||||||
|
# 再跟几帧(hits 增长但仍未达 min_hits=5),依旧当选
|
||||||
|
for _ in range(3):
|
||||||
|
r = tracker.update([make_det(200.0, 150.0, 240.0, 190.0, 0.6)])
|
||||||
|
assert r.tracked
|
||||||
|
assert r.primary_id == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 验收用例 5(§9.4-5):cost 边界(cost = 1.0 恰好不匹配)
|
||||||
|
# ============================================================================
|
||||||
|
def test_cost_exactly_one_does_not_match():
|
||||||
|
cfg = TrackerConfig()
|
||||||
|
cfg.max_lost_frames = 1
|
||||||
|
cfg.max_association_dist = 200.0
|
||||||
|
cfg.dist_weight = 1.0 # 纯距离项,IoU 项权重为 0 且两框零交集
|
||||||
|
cfg.iou_weight = 0.0
|
||||||
|
tracker = TargetTracker(cfg)
|
||||||
|
|
||||||
|
# 建立轨道:中心 (100,100)
|
||||||
|
r = tracker.update([make_det(90.0, 90.0, 110.0, 110.0, 0.9)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
id_a = r.primary_id
|
||||||
|
|
||||||
|
# 下一帧检测中心 (100,300):距离 = 200 = Dmax → cost = 1.0,恰好不匹配
|
||||||
|
r = tracker.update([make_det(90.0, 290.0, 110.0, 310.0, 0.9)])
|
||||||
|
# 未匹配 → 原轨道 Coast、检测新建轨道;两个目标并存
|
||||||
|
assert tracker.active_count() == 2, "cost=1.0 不应匹配,应新建轨道"
|
||||||
|
# 原轨道(锁定)进入 Coast:lost_frames=1
|
||||||
|
assert r.tracked
|
||||||
|
assert r.is_predicted
|
||||||
|
assert r.primary_id == id_a
|
||||||
|
assert r.lost_frames == 1
|
||||||
|
|
||||||
|
# 对照:距离略小于阈值(199px → cost=0.995 < 1.0)应匹配成功
|
||||||
|
tracker2 = TargetTracker(cfg)
|
||||||
|
r = tracker2.update([make_det(90.0, 90.0, 110.0, 110.0, 0.9)])
|
||||||
|
id_b = r.primary_id
|
||||||
|
r = tracker2.update([make_det(90.0, 289.0, 110.0, 309.0, 0.9)])
|
||||||
|
assert tracker2.active_count() == 1, "cost<1.0 应匹配成功,不应新建轨道"
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.primary_id == id_b
|
||||||
|
assert not r.is_predicted # 实测命中
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 验收用例 6(§9.4-6):置信度优先
|
||||||
|
# ============================================================================
|
||||||
|
def test_higher_confidence_track_wins_detection():
|
||||||
|
tracker = TargetTracker()
|
||||||
|
|
||||||
|
# 同帧建立两条轨道:A 置信度 0.5、B 置信度 0.9,相距 80px(不互相重叠)
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.5),
|
||||||
|
make_det(180.0, 100.0, 200.0, 120.0, 0.9)])
|
||||||
|
assert tracker.active_count() == 2
|
||||||
|
|
||||||
|
# 找出两条轨道 ID(按置信度区分)
|
||||||
|
id_low = None
|
||||||
|
id_high = None
|
||||||
|
for track_id, track in tracker.active_tracks().items():
|
||||||
|
if track.confidence > 0.8:
|
||||||
|
id_high = track_id
|
||||||
|
else:
|
||||||
|
id_low = track_id
|
||||||
|
assert id_low is not None
|
||||||
|
assert id_high is not None
|
||||||
|
|
||||||
|
# 下一帧只给一个检测,放在两轨道预测中心之间(各距 40px,对称):
|
||||||
|
# 距离项对两者相同,贪婪按置信度降序 → 高置信度轨道 B 优先匹配
|
||||||
|
r = tracker.update([make_det(140.0, 100.0, 160.0, 120.0, 0.7)])
|
||||||
|
|
||||||
|
tracks = tracker.active_tracks()
|
||||||
|
assert len(tracks) == 2
|
||||||
|
# 获胜者:命中(lost=0、hits=2、置信度更新)
|
||||||
|
assert tracks[id_high].lost_count == 0, "高置信度轨道应获胜"
|
||||||
|
assert tracks[id_high].hits == 2
|
||||||
|
assert tracks[id_high].confidence == pytest.approx(0.7)
|
||||||
|
# 失败者:未匹配进入 Coast(lost=1、hits 不变)
|
||||||
|
assert tracks[id_low].lost_count == 1, "低置信度轨道应落选"
|
||||||
|
assert tracks[id_low].hits == 1
|
||||||
|
assert tracks[id_low].confidence == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NaN/Inf 防御(评审修复项)
|
||||||
|
# ============================================================================
|
||||||
|
def test_non_finite_detections_are_discarded():
|
||||||
|
tracker = TargetTracker()
|
||||||
|
nan = math.nan
|
||||||
|
inf = math.inf
|
||||||
|
|
||||||
|
# 第 1 帧:建立正常轨道
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 140.0, 140.0, 0.9)])
|
||||||
|
assert r.tracked
|
||||||
|
|
||||||
|
# 第 2 帧:混入 NaN 坐标 / Inf 坐标 / NaN 置信度检测与一个正常检测:
|
||||||
|
# 非有限检测全部被丢弃,仅正常检测参与关联,不新建轨道
|
||||||
|
r = tracker.update([make_det(nan, 100.0, 140.0, 140.0, 0.9),
|
||||||
|
make_det(100.0, -inf, 140.0, 140.0, 0.9),
|
||||||
|
make_det(100.0, 100.0, nan, 140.0, 0.9),
|
||||||
|
make_det(102.0, 102.0, 142.0, 142.0, nan),
|
||||||
|
make_det(101.0, 101.0, 141.0, 141.0, 0.9)])
|
||||||
|
assert tracker.active_count() == 1, "非有限检测不应新建轨道"
|
||||||
|
assert r.tracked
|
||||||
|
assert not r.is_predicted # 正常检测命中,实测输出
|
||||||
|
# 主目标输出未被 NaN 污染
|
||||||
|
assert math.isfinite(r.center[0])
|
||||||
|
assert math.isfinite(r.center[1])
|
||||||
|
assert r.box is not None
|
||||||
|
assert math.isfinite(r.box.x1) and math.isfinite(r.box.y1)
|
||||||
|
assert math.isfinite(r.box.x2) and math.isfinite(r.box.y2)
|
||||||
|
assert math.isfinite(r.confidence)
|
||||||
|
assert r.raw is not None # 实测帧 raw 有值
|
||||||
|
|
||||||
|
# 第 3 帧:仅含 NaN 检测 → 等价空帧,轨道 Coast 不崩溃
|
||||||
|
r = tracker.update([make_det(nan, nan, nan, nan, nan)])
|
||||||
|
assert r.tracked
|
||||||
|
assert r.is_predicted
|
||||||
|
assert r.lost_frames == 1
|
||||||
|
assert r.raw is None # Coast 时 raw 为空(§2.3)
|
||||||
|
assert math.isfinite(r.center[0])
|
||||||
|
|
||||||
|
# 持续 NaN 检测直至轨道死亡回收,全程不崩溃
|
||||||
|
for _ in range(10):
|
||||||
|
r = tracker.update([make_det(nan, 0.0, 10.0, 10.0, 0.9)])
|
||||||
|
assert not r.tracked
|
||||||
|
assert tracker.active_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 补充行为测试(§2.3 / §6.3 / §9.5)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def test_lost_result_fields_are_empty():
|
||||||
|
tracker = TargetTracker()
|
||||||
|
r = tracker.update([]) # 空帧序列起点:无任何轨道
|
||||||
|
assert not r.tracked
|
||||||
|
assert r.primary_id is None
|
||||||
|
assert r.box is None
|
||||||
|
assert r.raw is None
|
||||||
|
assert r.lost_frames == 0
|
||||||
|
assert r.n_active == 0
|
||||||
|
assert r.confidence == 0.0
|
||||||
|
|
||||||
|
# LastResult 返回同一快照(§9.5 跨线程读取入口)
|
||||||
|
cached = tracker.last_result()
|
||||||
|
assert not cached.tracked
|
||||||
|
|
||||||
|
|
||||||
|
def test_coasting_fallback_chooses_nearest_to_frame_center():
|
||||||
|
# Coast 兜底选举:无实测轨道时选离画面中心最近的 Coast 轨道(§6.3)。
|
||||||
|
# 锁定延续优先:需先让锁定轨道死亡解除锁定,才会进入重新选举分支。
|
||||||
|
cfg = TrackerConfig()
|
||||||
|
cfg.max_lost_frames = 2
|
||||||
|
tracker = TargetTracker(cfg)
|
||||||
|
|
||||||
|
# F1:角落目标(高置信度)先建立并被锁定
|
||||||
|
tracker.update([make_det(10.0, 10.0, 30.0, 30.0, 0.95)])
|
||||||
|
# F2:画面中心附近目标建立;角落目标丢失(1)
|
||||||
|
tracker.update([make_det(300.0, 220.0, 340.0, 260.0, 0.9)])
|
||||||
|
# F3:远处目标建立;角落丢失(2)、中心目标丢失(1)
|
||||||
|
tracker.update([make_det(600.0, 450.0, 620.0, 470.0, 0.85)])
|
||||||
|
# F4:全部丢失:角落丢失(3) > max_lost 死亡解除锁定;
|
||||||
|
# 其余两条 Coast 存活 → 重新选举选离画面中心最近者
|
||||||
|
r = tracker.update([], FrameShape(480, 640))
|
||||||
|
assert r.tracked
|
||||||
|
assert r.is_predicted
|
||||||
|
assert r.center[0] == pytest.approx(320.0, abs=25.0) # 靠近画面中心 (320,240)
|
||||||
|
assert r.center[1] == pytest.approx(240.0, abs=25.0)
|
||||||
|
assert r.n_active == 2 # 角落轨道已回收
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_primary_kept_while_measured():
|
||||||
|
# 锁定延续优先于置信度选举(§6.3 ①)
|
||||||
|
tracker = TargetTracker()
|
||||||
|
r = tracker.update([make_det(100.0, 100.0, 120.0, 120.0, 0.4)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
locked_id = r.primary_id
|
||||||
|
|
||||||
|
# 高置信度新目标出现并持续,锁定目标也持续实测 → primary_id 不变
|
||||||
|
for i in range(5):
|
||||||
|
r = tracker.update([make_det(100.0 + i, 100.0, 120.0 + i, 120.0, 0.4),
|
||||||
|
make_det(400.0, 300.0, 430.0, 330.0, 0.95)])
|
||||||
|
assert r.primary_id is not None
|
||||||
|
assert r.primary_id == locked_id
|
||||||
|
assert not r.is_predicted
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_frame_center_is_320x240():
|
||||||
|
# frame_shape 缺省按 640×480 画面中心 (320,240) 计算(§8.2 / §2.2)
|
||||||
|
cfg = TrackerConfig()
|
||||||
|
cfg.max_lost_frames = 2
|
||||||
|
tracker = TargetTracker(cfg)
|
||||||
|
|
||||||
|
# F1:角落目标先建立并被锁定(高置信度)
|
||||||
|
tracker.update([make_det(0.0, 0.0, 20.0, 20.0, 0.95)])
|
||||||
|
# F2:左侧目标(中心 (280,240),距默认中心 40px)建立
|
||||||
|
tracker.update([make_det(270.0, 230.0, 290.0, 250.0, 0.9)])
|
||||||
|
# F3:右侧目标(中心 (500,240),距默认中心 180px)建立
|
||||||
|
tracker.update([make_det(490.0, 230.0, 510.0, 250.0, 0.85)])
|
||||||
|
# F4:全部丢失:锁定轨道死亡解除锁定,两条 Coast 轨道兜底选举
|
||||||
|
r = tracker.update([])
|
||||||
|
assert r.tracked
|
||||||
|
assert r.is_predicted
|
||||||
|
assert r.center[0] == pytest.approx(280.0, abs=30.0) # 左侧更近,当选
|
||||||
|
assert r.center[1] == pytest.approx(240.0, abs=30.0)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""tracklet_test.py —— Tracklet 与几何工具单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/target_tracker/tests/tracklet_test.cpp。
|
||||||
|
覆盖(视觉追踪技术路线 §3.2/§5.2/§6.1/§7):框互转、IoU 边界、
|
||||||
|
IsDead/IsCoasting 判定(严格大于)、Predict/Update/MarkMiss 计数语义。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from target_tracker.tracklet import (BoxXYXY, BoxCXCYWH, Tracklet, compute_iou,
|
||||||
|
to_center_size, to_xyxy)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 框表示互转(§7) ----
|
||||||
|
|
||||||
|
def test_round_trip_preserves_values():
|
||||||
|
box = BoxXYXY(10.0, 20.0, 50.0, 80.0)
|
||||||
|
c = to_center_size(box)
|
||||||
|
assert c.cx == 30.0 # (10+50)/2
|
||||||
|
assert c.cy == 50.0 # (20+80)/2
|
||||||
|
assert c.w == 40.0 # 50−10
|
||||||
|
assert c.h == 60.0 # 80−20
|
||||||
|
|
||||||
|
back = to_xyxy(c)
|
||||||
|
assert back.x1 == box.x1
|
||||||
|
assert back.y1 == box.y1
|
||||||
|
assert back.x2 == box.x2
|
||||||
|
assert back.y2 == box.y2
|
||||||
|
|
||||||
|
|
||||||
|
# ---- IoU 边界(§5.2) ----
|
||||||
|
|
||||||
|
def test_identical_boxes_return_one():
|
||||||
|
a = BoxXYXY(10.0, 10.0, 30.0, 30.0)
|
||||||
|
assert compute_iou(a, a) == pytest.approx(1.0, abs=1e-12) # 完全重合 = 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_disjoint_boxes_return_zero():
|
||||||
|
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
|
||||||
|
b = BoxXYXY(20.0, 20.0, 30.0, 30.0)
|
||||||
|
assert compute_iou(a, b) == 0.0 # 零交集
|
||||||
|
|
||||||
|
|
||||||
|
def test_touching_edges_return_zero():
|
||||||
|
# 仅边/角相接:交集宽高为 0 → 交集面积 ≤ 0 返回 0(§5.2)
|
||||||
|
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
|
||||||
|
b = BoxXYXY(10.0, 0.0, 20.0, 10.0) # 右边缘相接
|
||||||
|
assert compute_iou(a, b) == 0.0
|
||||||
|
c = BoxXYXY(10.0, 10.0, 20.0, 20.0) # 角点相接
|
||||||
|
assert compute_iou(a, c) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_box_returns_zero():
|
||||||
|
normal = BoxXYXY(0.0, 0.0, 10.0, 10.0)
|
||||||
|
zero_area = BoxXYXY(5.0, 5.0, 5.0, 5.0) # 零面积
|
||||||
|
inverted = BoxXYXY(10.0, 10.0, 0.0, 0.0) # 反向(负宽高)
|
||||||
|
assert compute_iou(normal, zero_area) == 0.0
|
||||||
|
assert compute_iou(zero_area, normal) == 0.0
|
||||||
|
assert compute_iou(normal, inverted) == 0.0
|
||||||
|
assert compute_iou(zero_area, zero_area) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_overlap_is_correct():
|
||||||
|
# A = [0,0,10,10](面积 100),B = [5,5,15,15](面积 100),交集 25
|
||||||
|
a = BoxXYXY(0.0, 0.0, 10.0, 10.0)
|
||||||
|
b = BoxXYXY(5.0, 5.0, 15.0, 15.0)
|
||||||
|
assert compute_iou(a, b) == pytest.approx(25.0 / 175.0, abs=1e-12)
|
||||||
|
assert compute_iou(b, a) == pytest.approx(25.0 / 175.0, abs=1e-12) # 对称性
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 构造与派生状态(§3.2 / §6.1) ----
|
||||||
|
|
||||||
|
def test_construction_initial_fields():
|
||||||
|
t = Tracklet(7, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 1, 8)
|
||||||
|
|
||||||
|
assert t.track_id == 7
|
||||||
|
assert t.lost_count == 0
|
||||||
|
assert t.max_lost == 8
|
||||||
|
assert t.confidence == 0.9
|
||||||
|
assert t.cls_id == 1
|
||||||
|
assert t.age == 1 # 创建时为 1(§3.2)
|
||||||
|
assert t.hits == 1 # 创建时为 1(§3.2)
|
||||||
|
assert t.last_center == (100.0, 100.0)
|
||||||
|
assert t.last_box is not None # 创建当帧即赋 last_box(§7 步骤 5)
|
||||||
|
assert t.last_box.x1 == 90.0
|
||||||
|
assert t.last_box.y1 == 90.0
|
||||||
|
assert t.last_box.x2 == 110.0
|
||||||
|
assert t.last_box.y2 == 110.0
|
||||||
|
assert not t.is_dead
|
||||||
|
assert not t.is_coasting
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_dead_uses_strictly_greater():
|
||||||
|
# IsDead 使用严格大于:lost_count == max_lost 仍存活(§6.1 注记 2)
|
||||||
|
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
|
||||||
|
for _ in range(8):
|
||||||
|
t.mark_miss()
|
||||||
|
assert not t.is_dead, f"lost_count={t.lost_count} 未超过 max_lost 不应判死"
|
||||||
|
assert t.lost_count == 8
|
||||||
|
t.mark_miss() # lost_count = 9 > 8
|
||||||
|
assert t.is_dead
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_coasting_when_lost_positive():
|
||||||
|
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
|
||||||
|
assert not t.is_coasting
|
||||||
|
t.mark_miss()
|
||||||
|
assert t.is_coasting # lost_count > 0 即 Coast(§3.2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 生命周期方法计数语义(§7) ----
|
||||||
|
|
||||||
|
def test_predict_advances_age_and_updates_last_state():
|
||||||
|
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
|
||||||
|
age_before = t.age
|
||||||
|
t.predict() # 速度为零:位置不变,但 age+1、last_box 更新为预测框
|
||||||
|
assert t.age == age_before + 1
|
||||||
|
assert t.last_box is not None
|
||||||
|
assert t.last_center[0] == pytest.approx(100.0, abs=1e-9)
|
||||||
|
assert t.last_center[1] == pytest.approx(100.0, abs=1e-9)
|
||||||
|
# hits / lost_count / confidence 不受预测影响
|
||||||
|
assert t.hits == 1
|
||||||
|
assert t.lost_count == 0
|
||||||
|
assert t.confidence == 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_resets_lost_and_increments_hits():
|
||||||
|
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
|
||||||
|
t.mark_miss()
|
||||||
|
t.mark_miss()
|
||||||
|
assert t.lost_count == 2
|
||||||
|
assert t.is_coasting
|
||||||
|
|
||||||
|
age_before = t.age
|
||||||
|
box = BoxXYXY(150.0, 160.0, 170.0, 180.0)
|
||||||
|
t.update(box, 0.75, 2)
|
||||||
|
|
||||||
|
assert t.lost_count == 0 # 匹配成功清零(§7)
|
||||||
|
assert t.hits == 2 # hits+1
|
||||||
|
assert t.age == age_before + 1 # age+1
|
||||||
|
assert t.confidence == 0.75
|
||||||
|
assert t.cls_id == 2
|
||||||
|
assert t.last_center == (160.0, 170.0) # ((150+170)/2, (160+180)/2)
|
||||||
|
assert t.last_box is not None
|
||||||
|
assert t.last_box.x1 == box.x1
|
||||||
|
assert t.last_raw_box is not None # 实测框同步记录
|
||||||
|
assert t.last_raw_box.x2 == box.x2
|
||||||
|
assert not t.is_coasting
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_miss_increments_lost_and_age():
|
||||||
|
t = Tracklet(0, BoxCXCYWH(100.0, 100.0, 20.0, 20.0), 0.9, 0, 8)
|
||||||
|
age_before = t.age
|
||||||
|
t.mark_miss()
|
||||||
|
assert t.lost_count == 1
|
||||||
|
assert t.age == age_before + 1
|
||||||
|
# MarkMiss 不影响 hits / confidence
|
||||||
|
assert t.hits == 1
|
||||||
|
assert t.confidence == 0.9
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,43 @@
|
|||||||
|
"""frame_transform_test.py —— rotate_body_to_ned 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/frame_transform_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §2.3,A4):零航向恒等、朝东时机体前向 → 东向、
|
||||||
|
垂直分量不变、模长守恒。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.frame_transform import rotate_body_to_ned
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_yaw_is_identity():
|
||||||
|
north, east, down = rotate_body_to_ned(1.0, 0.5, -2.0, 0.0)
|
||||||
|
assert north == pytest.approx(1.0, abs=1e-12) # 北 = 前
|
||||||
|
assert east == pytest.approx(0.5, abs=1e-12) # 东 = 右
|
||||||
|
assert down == pytest.approx(-2.0, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaw_east_90_maps_forward_to_east():
|
||||||
|
north, east, _ = rotate_body_to_ned(2.0, 0.0, 0.0, math.pi / 2.0) # 机头朝东
|
||||||
|
assert north == pytest.approx(0.0, abs=1e-9) # 北向 ≈ 0
|
||||||
|
assert east == pytest.approx(2.0, abs=1e-9) # 东向 = 前向速度
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_is_east_when_heading_north():
|
||||||
|
north, east, _ = rotate_body_to_ned(0.0, 1.0, 0.0, 0.0)
|
||||||
|
assert north == pytest.approx(0.0, abs=1e-12)
|
||||||
|
assert east == pytest.approx(1.0, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_down_component_unchanged():
|
||||||
|
_, _, down = rotate_body_to_ned(1.0, 1.0, 3.5, math.pi / 3.0)
|
||||||
|
assert down == 3.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_preserves_horizontal_norm():
|
||||||
|
# 旋转为正交变换:水平合速度不变(限幅约束不被破坏的前提)
|
||||||
|
north, east, _ = rotate_body_to_ned(3.0, -4.0, 1.0, 0.7)
|
||||||
|
assert math.hypot(north, east) == pytest.approx(math.hypot(3.0, -4.0),
|
||||||
|
abs=1e-9)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""line_of_sight_test.py —— PixelToAngle 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/line_of_sight_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §4.1/§5.1,C2):零偏移、画面边缘偏移 = 半视场角、
|
||||||
|
奇对称、单调性、与线性近似的偏离(全程针孔模型)、构造期非法参数抛异常。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.line_of_sight import PixelToAngle
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_offset_gives_zero_angle():
|
||||||
|
conv = PixelToAngle(1000.0, math.radians(90.0))
|
||||||
|
assert conv.convert(0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_half_size_offset_gives_half_fov():
|
||||||
|
# 针孔模型:偏移 = size/2 时 angle = atan(tan(θ/2)) = θ/2
|
||||||
|
conv = PixelToAngle(1000.0, math.radians(90.0))
|
||||||
|
assert conv.convert(500.0) == pytest.approx(math.radians(45.0), abs=1e-12)
|
||||||
|
assert conv.convert(-500.0) == pytest.approx(-math.radians(45.0), abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_odd_symmetry():
|
||||||
|
conv = PixelToAngle(1920.0, math.radians(60.0))
|
||||||
|
for offset in (1.0, 100.0, 730.5):
|
||||||
|
assert conv.convert(-offset) == pytest.approx(-conv.convert(offset),
|
||||||
|
abs=1e-15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monotonic_increasing():
|
||||||
|
conv = PixelToAngle(1080.0, math.radians(35.0))
|
||||||
|
prev = conv.convert(-600.0)
|
||||||
|
for x in range(-500, 601, 100):
|
||||||
|
cur = conv.convert(float(x))
|
||||||
|
assert cur > prev
|
||||||
|
prev = cur
|
||||||
|
|
||||||
|
|
||||||
|
def test_deviates_from_linear_approximation():
|
||||||
|
# C2:全程针孔模型。θ=90°、偏移比例 0.25 处线性近似误差约 4°,
|
||||||
|
# 针孔结果必须与之明显偏离(证明未走线性支路)。
|
||||||
|
conv = PixelToAngle(1000.0, math.radians(90.0))
|
||||||
|
pinhole_deg = math.degrees(conv.convert(250.0)) # atan(250·2·tan45°/1000)
|
||||||
|
linear_deg = 250.0 * (90.0 / 1000.0) # θ·offset/size = 22.5°
|
||||||
|
assert pinhole_deg == pytest.approx(math.degrees(math.atan(0.5)), abs=1e-9)
|
||||||
|
assert abs(pinhole_deg - linear_deg) > 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_rejects_invalid_args():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
PixelToAngle(0.0, 1.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
PixelToAngle(-10.0, 1.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
PixelToAngle(1000.0, 0.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
PixelToAngle(1000.0, math.pi)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
PixelToAngle(1000.0, math.pi + 0.1)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""mavsdk_gateway_test.py —— MavsdkCommandGateway 单元测试(mock offboard)。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/mavlink_command_gateway_test.cpp。
|
||||||
|
C++ 版校验 MAVLink ID 84 线格式字节流;Python 版采用 MAVSDK 语义映射,
|
||||||
|
改为以 FakeOffboard 捕获设定点对象,校验三分支映射(物理追踪思路 §2.2、
|
||||||
|
A2 掩码三分支的语义等价):
|
||||||
|
|
||||||
|
角速率非零 → 机体速度设定点 + 非零 yawspeed;
|
||||||
|
航向角非零(角速率为零)→ NED 速度设定点 + 绝对 yaw;
|
||||||
|
两者均零 → 机体速度设定点 + 零 yawspeed(保持航向,A2 回归语义);
|
||||||
|
刹车 → 全零机体速度设定点。
|
||||||
|
传输失败(异常)→ 返回 False 且不向外抛。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.mavsdk_gateway import MavsdkCommandGateway
|
||||||
|
from visual_track_control.types import ControlOutput
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOffboard:
|
||||||
|
"""捕获式 offboard mock:记录每次调用的设定点,可注入失败。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.body_setpoints = []
|
||||||
|
self.ned_setpoints = []
|
||||||
|
self.attempts = 0
|
||||||
|
self.start_count = 0
|
||||||
|
self.stop_count = 0
|
||||||
|
self.succeed = True
|
||||||
|
|
||||||
|
async def set_velocity_body(self, setpoint):
|
||||||
|
self.attempts += 1 # 与 C++ CaptureTransport 一致:失败也计入发送次数
|
||||||
|
if not self.succeed:
|
||||||
|
raise RuntimeError("mock transport failure")
|
||||||
|
self.body_setpoints.append(setpoint)
|
||||||
|
|
||||||
|
async def set_velocity_ned(self, setpoint):
|
||||||
|
self.attempts += 1
|
||||||
|
if not self.succeed:
|
||||||
|
raise RuntimeError("mock transport failure")
|
||||||
|
self.ned_setpoints.append(setpoint)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
if not self.succeed:
|
||||||
|
raise RuntimeError("mock start failure")
|
||||||
|
self.start_count += 1
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self.stop_count += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def send_count(self):
|
||||||
|
return self.attempts
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDrone:
|
||||||
|
def __init__(self):
|
||||||
|
self.offboard = FakeOffboard()
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def make_gateway():
|
||||||
|
drone = FakeDrone()
|
||||||
|
return MavsdkCommandGateway(drone), drone.offboard
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_sends_initial_setpoint_then_starts():
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
assert run(gateway.start())
|
||||||
|
assert offboard.start_count == 1
|
||||||
|
# PX4 要求先流式发送首条设定点(全零)再请求启动
|
||||||
|
assert len(offboard.body_setpoints) == 1
|
||||||
|
sp = offboard.body_setpoints[0]
|
||||||
|
assert sp.forward_m_s == 0.0
|
||||||
|
assert sp.right_m_s == 0.0
|
||||||
|
assert sp.down_m_s == 0.0
|
||||||
|
assert sp.yawspeed_deg_s == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_failure_returns_false():
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
offboard.succeed = False
|
||||||
|
assert not run(gateway.start())
|
||||||
|
|
||||||
|
|
||||||
|
def _make_velocity_heading_output():
|
||||||
|
from visual_track_control.types import ControlMode
|
||||||
|
out = ControlOutput()
|
||||||
|
out.mode = ControlMode.VELOCITY_HEADING
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaw_rate_branch_uses_body_frame_with_yawspeed():
|
||||||
|
from visual_track_control.types import ControlMode
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
out = _make_velocity_heading_output()
|
||||||
|
out.vx_body_mps = 1.5
|
||||||
|
out.vz_mps = 0.25
|
||||||
|
out.yaw_rate_dps = 5.0
|
||||||
|
assert run(gateway.send_velocity_heading(out, 12345))
|
||||||
|
assert offboard.send_count == 1
|
||||||
|
assert len(offboard.body_setpoints) == 1
|
||||||
|
sp = offboard.body_setpoints[0]
|
||||||
|
assert sp.forward_m_s == pytest.approx(1.5)
|
||||||
|
assert sp.right_m_s == 0.0
|
||||||
|
assert sp.down_m_s == pytest.approx(0.25)
|
||||||
|
assert sp.yawspeed_deg_s == pytest.approx(5.0)
|
||||||
|
assert offboard.ned_setpoints == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaw_only_branch_uses_ned_frame_with_absolute_yaw():
|
||||||
|
# 航向角非零且角速率为零 → 航向掩码语义:NED 速度 + 绝对航向
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
out = _make_velocity_heading_output()
|
||||||
|
out.vx_mps = 2.0
|
||||||
|
out.vy_mps = -1.0
|
||||||
|
out.vz_mps = 0.5
|
||||||
|
out.yaw_deg = 10.0
|
||||||
|
out.yaw_rate_dps = 0.0
|
||||||
|
assert run(gateway.send_velocity_heading(out, 1))
|
||||||
|
assert offboard.send_count == 1
|
||||||
|
assert len(offboard.ned_setpoints) == 1
|
||||||
|
sp = offboard.ned_setpoints[0]
|
||||||
|
assert sp.north_m_s == pytest.approx(2.0)
|
||||||
|
assert sp.east_m_s == pytest.approx(-1.0)
|
||||||
|
assert sp.down_m_s == pytest.approx(0.5)
|
||||||
|
assert sp.yaw_deg == pytest.approx(10.0)
|
||||||
|
assert offboard.body_setpoints == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_zero_keeps_heading_via_body_zero_yawspeed():
|
||||||
|
# A2 回归:两者均零时机体速度设定点配零角速率 = 保持当前航向,
|
||||||
|
# 不得误发绝对航向 0(正北)。
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
out = _make_velocity_heading_output()
|
||||||
|
out.vx_body_mps = 1.0
|
||||||
|
out.yaw_rate_dps = 0.0
|
||||||
|
out.yaw_deg = 0.0
|
||||||
|
assert run(gateway.send_velocity_heading(out, 1))
|
||||||
|
assert len(offboard.body_setpoints) == 1
|
||||||
|
sp = offboard.body_setpoints[0]
|
||||||
|
assert sp.forward_m_s == pytest.approx(1.0)
|
||||||
|
assert sp.yawspeed_deg_s == 0.0
|
||||||
|
assert offboard.ned_setpoints == [] # 不得走绝对航向支路
|
||||||
|
|
||||||
|
|
||||||
|
def test_brake_hover_sends_zero_velocity_body():
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
assert run(gateway.send_brake_hover(777))
|
||||||
|
assert offboard.send_count == 1
|
||||||
|
sp = offboard.body_setpoints[0]
|
||||||
|
assert sp.forward_m_s == 0.0
|
||||||
|
assert sp.right_m_s == 0.0
|
||||||
|
assert sp.down_m_s == 0.0
|
||||||
|
assert sp.yawspeed_deg_s == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_transport_failure_propagates_as_false():
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
offboard.succeed = False
|
||||||
|
out = _make_velocity_heading_output()
|
||||||
|
assert not run(gateway.send_velocity_heading(out, 1))
|
||||||
|
assert not run(gateway.send_brake_hover(1))
|
||||||
|
assert offboard.send_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_calls_offboard_stop():
|
||||||
|
gateway, offboard = make_gateway()
|
||||||
|
run(gateway.stop())
|
||||||
|
assert offboard.stop_count == 1
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""pid_controller_test.py —— PidController 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/pid_controller_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §6.2/§6.4):位置式 P/I/D 三项、积分钳位、输出限幅、
|
||||||
|
异常节拍保护、anti-windup 条件积分(C1 修复回归)、ResetIntegral / Reset。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.pid_controller import PidController, PidParams
|
||||||
|
|
||||||
|
|
||||||
|
def make_params(kp, ki, kd, integral_limit, output_limit, filter_coef=0.0):
|
||||||
|
return PidParams(kp=kp, ki=ki, kd=kd, integral_limit=integral_limit,
|
||||||
|
derivative_filter_coef=filter_coef, output_limit=output_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def test_proportional_only():
|
||||||
|
pid = PidController(make_params(2.0, 0.0, 0.0, 0.0, 0.0))
|
||||||
|
assert pid.update(3.0, 0.1) == 6.0
|
||||||
|
assert pid.update(-1.5, 0.1) == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_integral_accumulates():
|
||||||
|
pid = PidController(make_params(0.0, 1.0, 0.0, 0.0, 0.0))
|
||||||
|
# 两拍 error=2、dt=0.5:积分累计 1.0 → 2.0
|
||||||
|
assert pid.update(2.0, 0.5) == 1.0
|
||||||
|
assert pid.update(2.0, 0.5) == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_integral_clamped_to_limit():
|
||||||
|
pid = PidController(make_params(0.0, 1.0, 0.0, integral_limit=1.0, output_limit=0.0))
|
||||||
|
for _ in range(5):
|
||||||
|
pid.update(10.0, 1.0)
|
||||||
|
# 积分候选值被钳位在 1.0 → 输出恒为 1.0
|
||||||
|
assert pid.last_output == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_clamped_to_limit():
|
||||||
|
pid = PidController(make_params(10.0, 0.0, 0.0, 0.0, output_limit=3.0))
|
||||||
|
assert pid.update(1.0, 0.1) == 3.0
|
||||||
|
assert pid.update(-1.0, 0.1) == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_anti_windup_freeze_while_saturated_same_direction():
|
||||||
|
# kp=0、ki=2、output_limit=5:error=+1 持续饱和时积分冻结,
|
||||||
|
# 输出恒钉在 +5(10 拍累计也不会让积分继续增长)。
|
||||||
|
pid = PidController(make_params(0.0, 2.0, 0.0, 0.0, 5.0))
|
||||||
|
for _ in range(10):
|
||||||
|
assert pid.update(10.0, 1.0) == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_anti_windup_unwinds_after_error_flips():
|
||||||
|
# C1 回归:误差反号后积分必须立即回卷(旧判据"积分与输出同向"会
|
||||||
|
# 冻结回卷、输出一直钉在 +5)。
|
||||||
|
# kp=0、ki=2、limit=5:+10 饱和 3 拍、积分冻结在 0;
|
||||||
|
# error=-1 首拍 → 输出 2×(-1) = -2(立即回卷);
|
||||||
|
# 持续负误差使输出到达负饱和 -5(积分冻结在 -2,输出 = clamp(-6))。
|
||||||
|
pid = PidController(make_params(0.0, 2.0, 0.0, 0.0, 5.0))
|
||||||
|
for _ in range(3):
|
||||||
|
assert pid.update(10.0, 1.0) == 5.0
|
||||||
|
assert pid.update(-1.0, 1.0) == -2.0 # 首拍立即回卷
|
||||||
|
out = -2.0
|
||||||
|
for _ in range(11):
|
||||||
|
out = pid.update(-1.0, 1.0)
|
||||||
|
assert out == pytest.approx(-5.0, abs=1e-9) # 旧判据此处仍为 +5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivative_first_step_is_zero():
|
||||||
|
pid = PidController(make_params(0.0, 0.0, 1.0, 0.0, 0.0))
|
||||||
|
# 首拍无微分历史:微分项为 0
|
||||||
|
assert pid.update(5.0, 1.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivative_with_low_pass_filter():
|
||||||
|
pid = PidController(make_params(0.0, 0.0, 1.0, 0.0, 0.0, filter_coef=0.5))
|
||||||
|
pid.update(0.0, 1.0) # 建立微分历史(导数 0)
|
||||||
|
# 第二拍原始导数 1.0,低通后 0.5×0 + 0.5×1.0 = 0.5
|
||||||
|
assert pid.update(1.0, 1.0) == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_or_negative_dt_holds_last_output():
|
||||||
|
pid = PidController(make_params(1.0, 0.0, 0.0, 0.0, 0.0))
|
||||||
|
assert pid.update(2.0, 0.1) == 2.0
|
||||||
|
assert pid.update(99.0, 0.0) == 2.0
|
||||||
|
assert pid.update(99.0, -1.0) == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_integral_clears_only_integral():
|
||||||
|
pid = PidController(make_params(0.0, 1.0, 1.0, 0.0, 0.0))
|
||||||
|
pid.update(2.0, 1.0) # 积分 = 2
|
||||||
|
pid.reset_integral()
|
||||||
|
# 积分清零、微分历史保留:输出应为 kd×(0−2)/1 = −2
|
||||||
|
assert pid.update(0.0, 1.0) == -2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_clears_everything():
|
||||||
|
pid = PidController(make_params(1.0, 1.0, 1.0, 0.0, 0.0))
|
||||||
|
pid.update(5.0, 1.0)
|
||||||
|
pid.reset()
|
||||||
|
assert pid.last_output == 0.0
|
||||||
|
# 全清后等同全新控制器:首拍无微分历史
|
||||||
|
assert pid.update(3.0, 1.0) == 3.0 + 3.0 # kp·e + ki·∫e
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""rate_limiter_test.py —— Clamp / SlewRateLimiter 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/rate_limiter_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §8、状态机设计 S6):静态限幅、变化率限幅步进、
|
||||||
|
首条指令从零渐变、异常节拍保护、Reset 归零。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.rate_limiter import SlewRateLimiter, clamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_clamps_value():
|
||||||
|
assert clamp(5.0, -2.0, 2.0) == 2.0
|
||||||
|
assert clamp(-5.0, -2.0, 2.0) == -2.0
|
||||||
|
assert clamp(1.5, -2.0, 2.0) == 1.5
|
||||||
|
# 边界值:落在区间内
|
||||||
|
assert clamp(2.0, -2.0, 2.0) == 2.0
|
||||||
|
assert clamp(-2.0, -2.0, 2.0) == -2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_command_ramps_from_zero():
|
||||||
|
# S6:初始输出为 0,首条大指令只能按变化率渐变
|
||||||
|
lim = SlewRateLimiter(2.0)
|
||||||
|
assert lim.limit(10.0, 0.5) == 1.0 # 0 + 2×0.5
|
||||||
|
assert lim.limit(10.0, 0.5) == 2.0 # 继续渐变
|
||||||
|
assert lim.limit(10.0, 10.0) == 10.0 # 步长足够 → 到达目标
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracks_target_within_step():
|
||||||
|
lim = SlewRateLimiter(10.0)
|
||||||
|
# 步长 10×1=10,目标 3 在步长内 → 直接到达
|
||||||
|
assert lim.limit(3.0, 1.0) == 3.0
|
||||||
|
assert lim.limit(3.0, 1.0) == 3.0 # 已在目标:保持
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_direction():
|
||||||
|
lim = SlewRateLimiter(2.0)
|
||||||
|
assert lim.limit(-10.0, 0.5) == -1.0
|
||||||
|
assert lim.limit(-10.0, 0.5) == -2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_or_negative_dt_holds():
|
||||||
|
lim = SlewRateLimiter(2.0)
|
||||||
|
lim.limit(10.0, 1.0) # 输出 2.0
|
||||||
|
assert lim.limit(10.0, 0.0) == 2.0
|
||||||
|
assert lim.limit(10.0, -1.0) == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_rate_limit_freezes_output():
|
||||||
|
lim = SlewRateLimiter(0.0)
|
||||||
|
assert lim.limit(5.0, 1.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_to_value():
|
||||||
|
lim = SlewRateLimiter(2.0)
|
||||||
|
lim.limit(10.0, 1.0)
|
||||||
|
lim.reset()
|
||||||
|
assert lim.last_output == 0.0
|
||||||
|
lim.reset(3.5)
|
||||||
|
assert lim.last_output == 3.5
|
||||||
|
# 复位后从新值继续渐变
|
||||||
|
assert lim.limit(10.0, 1.0) == 5.5 # 3.5 + 2×1
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_initial_value():
|
||||||
|
lim = SlewRateLimiter(1.0, initial=5.0)
|
||||||
|
assert lim.limit(10.0, 1.0) == 6.0
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""staleness_checker_test.py —— is_stale 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/staleness_checker_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §7.1):从未取样判超龄、有效期内新鲜、超出有效期超龄、
|
||||||
|
边界恰好在有效期上(不算超龄)、时间戳不一致判超龄(C4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from visual_track_control.staleness_checker import is_stale
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_sampled_is_stale():
|
||||||
|
assert is_stale(0, 1000, 200)
|
||||||
|
assert is_stale(-5, 1000, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_within_validity_is_fresh():
|
||||||
|
assert not is_stale(900, 1000, 200)
|
||||||
|
assert not is_stale(1000, 1000, 200) # 刚取样
|
||||||
|
|
||||||
|
|
||||||
|
def test_beyond_validity_is_stale():
|
||||||
|
assert is_stale(799, 1000, 200) # 201 > 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_at_boundary_is_fresh():
|
||||||
|
# 超龄判定为严格大于:恰好等于有效期仍算新鲜
|
||||||
|
assert not is_stale(800, 1000, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inconsistent_timestamp_is_stale():
|
||||||
|
# C4:now < sample 时间戳不一致 → 年龄不可信 → 判超龄(宁降级不轻信)
|
||||||
|
assert is_stale(2000, 1000, 200)
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""tracking_control_law_test.py —— TrackingControlLaw 主控律单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/tracking_control_law_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉
|
||||||
|
超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向
|
||||||
|
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道三种
|
||||||
|
无距离降级动作、首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.config import (HeadingMode, NoDistanceAction,
|
||||||
|
TrackingConfig)
|
||||||
|
from visual_track_control.tracking_control_law import TrackingControlLaw
|
||||||
|
from visual_track_control.types import (ControlMode, ControlSnapshot,
|
||||||
|
DegradedReason, TrackState)
|
||||||
|
|
||||||
|
|
||||||
|
def make_config() -> TrackingConfig:
|
||||||
|
"""测试基准配置:大限幅使限幅器近似透明,便于独立验证各通道数学。
|
||||||
|
画面 1000×1000、视场角 90° → 针孔系数 2·tan(45°)/1000 = 0.002,
|
||||||
|
偏移 100 像素 → 视线角 atan(0.2) ≈ 11.3099°。
|
||||||
|
"""
|
||||||
|
cfg = TrackingConfig()
|
||||||
|
cfg.control.frequency_hz = 20.0
|
||||||
|
cfg.control.visual_stale_ms = 200
|
||||||
|
cfg.control.radar_stale_ms = 500
|
||||||
|
cfg.control.attitude_stale_ms = 300
|
||||||
|
cfg.camera.image_width = 1000
|
||||||
|
cfg.camera.image_height = 1000
|
||||||
|
cfg.camera.fov_h_deg = 90.0
|
||||||
|
cfg.camera.fov_v_deg = 90.0
|
||||||
|
cfg.heading.mode = HeadingMode.RATE
|
||||||
|
cfg.heading.gain = 1.0
|
||||||
|
cfg.heading.yaw_rate_limit_dps = 30.0
|
||||||
|
cfg.heading.yaw_slew_limit_dps2 = 100000.0
|
||||||
|
cfg.vertical.vz_gain_mps_per_deg = 0.1
|
||||||
|
cfg.vertical.vz_limit_mps = 2.0
|
||||||
|
cfg.distance.d_exp_m = 10.0
|
||||||
|
cfg.distance.kp = 0.5
|
||||||
|
cfg.distance.ki = 0.0
|
||||||
|
cfg.distance.kd = 0.0
|
||||||
|
cfg.distance.integral_limit = 0.0
|
||||||
|
cfg.distance.derivative_filter_coef = 0.0
|
||||||
|
cfg.distance.approach_velocity_limit_mps = 3.0
|
||||||
|
cfg.distance.retreat_velocity_limit_mps = 1.0
|
||||||
|
cfg.distance.no_distance_action = NoDistanceAction.HOLD
|
||||||
|
cfg.distance.no_distance_approach_limit_mps = 0.5
|
||||||
|
cfg.accel_limit.ax_mps2 = 1000.0
|
||||||
|
cfg.accel_limit.ay_mps2 = 1000.0
|
||||||
|
cfg.accel_limit.az_mps2 = 1000.0
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def make_snapshot(now_ms: int) -> ControlSnapshot:
|
||||||
|
"""新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜。"""
|
||||||
|
return ControlSnapshot(
|
||||||
|
track=TrackState(tracked=True, is_predicted=False,
|
||||||
|
center=(500.0, 500.0)),
|
||||||
|
track_time_ms=now_ms,
|
||||||
|
attitude_present=True,
|
||||||
|
attitude_time_ms=now_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 超龄降级(§7.1 / §9.2) ----
|
||||||
|
|
||||||
|
def test_attitude_missing_degrades():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.attitude_present = False
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||||
|
assert out.degraded_reason is DegradedReason.ATTITUDE_STALE
|
||||||
|
assert not out.valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_attitude_stale_degrades():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.attitude_time_ms = 600 # 400 ms 前 > 300 ms 阈值
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||||
|
assert out.degraded_reason is DegradedReason.ATTITUDE_STALE
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_stale_degrades():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track_time_ms = 700 # 300 ms 前 > 200 ms 阈值
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||||
|
assert out.degraded_reason is DegradedReason.VISION_STALE
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_sampled_track_degrades():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track_time_ms = 0 # 从未取样
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||||
|
assert out.degraded_reason is DegradedReason.VISION_STALE
|
||||||
|
|
||||||
|
|
||||||
|
def test_attitude_stale_beats_vision_stale():
|
||||||
|
# 降级优先级:姿态校验在前(§9.2 姿态过期禁止一切空间转换)
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.attitude_present = False
|
||||||
|
snap.track_time_ms = 0
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.degraded_reason is DegradedReason.ATTITUDE_STALE
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 判丢刹车(§6.4) ----
|
||||||
|
|
||||||
|
def test_lost_target_brakes():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.tracked = False
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.BRAKE_HOVER
|
||||||
|
assert out.valid
|
||||||
|
assert out.vx_mps == 0.0
|
||||||
|
assert out.vy_mps == 0.0
|
||||||
|
assert out.vz_mps == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Coast(§6.4) ----
|
||||||
|
|
||||||
|
def test_coast_sets_flag_and_keeps_tracking():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.is_predicted = True
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.VELOCITY_HEADING
|
||||||
|
assert out.coast_active
|
||||||
|
assert out.valid
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 水平航向通道(§4.2,A2) ----
|
||||||
|
|
||||||
|
def test_rate_mode_maps_beta_to_yaw_rate():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.center = (600.0, 500.0) # 目标偏右 100 像素
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
beta_deg = math.degrees(math.atan(0.2)) # ≈ 11.31°
|
||||||
|
assert out.yaw_rate_dps == pytest.approx(beta_deg, abs=1e-6) # gain=1,右转正
|
||||||
|
assert out.yaw_deg == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_mode_clamps_to_yaw_rate_limit():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.center = (950.0, 500.0) # 大偏移:β≈43.5° > 30°/s 上限
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.yaw_rate_dps == pytest.approx(30.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_mode_composes_absolute_heading():
|
||||||
|
# A2:位置式输出为绝对 NED 航向 = 当前航向 + 限幅修正量
|
||||||
|
cfg = make_config()
|
||||||
|
cfg.heading.mode = HeadingMode.POSITION
|
||||||
|
law = TrackingControlLaw(cfg)
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.center = (600.0, 500.0)
|
||||||
|
snap.yaw_rad = math.radians(45.0)
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
beta_deg = math.degrees(math.atan(0.2))
|
||||||
|
assert out.yaw_deg == pytest.approx(45.0 + beta_deg, abs=1e-6)
|
||||||
|
assert out.yaw_rate_dps == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_mode_wraps_around_180():
|
||||||
|
cfg = make_config()
|
||||||
|
cfg.heading.mode = HeadingMode.POSITION
|
||||||
|
law = TrackingControlLaw(cfg)
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.center = (600.0, 500.0)
|
||||||
|
snap.yaw_rad = math.radians(175.0)
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
beta_deg = math.degrees(math.atan(0.2))
|
||||||
|
# 175 + 11.31 = 186.31 → 回绕到 ≈ -173.69
|
||||||
|
assert out.yaw_deg == pytest.approx(175.0 + beta_deg - 360.0, abs=1e-6)
|
||||||
|
assert -180.0 < out.yaw_deg < 180.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 垂直统一速度支路(§5,A1) ----
|
||||||
|
|
||||||
|
def test_vertical_offset_drives_vz():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.center = (500.0, 600.0) # 目标偏低 100 像素 → α>0 → 下降
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
alpha_deg = math.degrees(math.atan(0.2))
|
||||||
|
assert out.vz_mps == pytest.approx(0.1 * alpha_deg, abs=1e-6)
|
||||||
|
assert out.mode is ControlMode.VELOCITY_HEADING # A1:不切姿态微调模式
|
||||||
|
|
||||||
|
|
||||||
|
def test_vertical_velocity_clamped():
|
||||||
|
law = TrackingControlLaw(make_config())
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.track.center = (500.0, 990.0) # α≈44.5° × 0.1 = 4.45 > 2 上限
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.vz_mps == pytest.approx(2.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 距离通道(§6.2 / §6.3) ----
|
||||||
|
|
||||||
|
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
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.associated_to_target = False
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.mode is ControlMode.VELOCITY_HEADING
|
||||||
|
assert out.vx_mps == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_distance_slow_approach():
|
||||||
|
cfg = make_config()
|
||||||
|
cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH
|
||||||
|
law = TrackingControlLaw(cfg)
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.associated_to_target = False
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
assert out.vx_mps == pytest.approx(0.5, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_distance_exit_degrades():
|
||||||
|
cfg = make_config()
|
||||||
|
cfg.distance.no_distance_action = NoDistanceAction.EXIT
|
||||||
|
law = TrackingControlLaw(cfg)
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.associated_to_target = 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
|
||||||
|
snap = make_snapshot(1000)
|
||||||
|
snap.radar_distance_m = 30.0
|
||||||
|
snap.associated_to_target = True
|
||||||
|
snap.radar_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
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 首拍/限幅(§8、S6) ----
|
||||||
|
|
||||||
|
def test_first_tick_vx_slews_from_zero():
|
||||||
|
# S6:首条速度指令必须经变化率限幅,从 0 渐变而非阶跃
|
||||||
|
cfg = make_config()
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 机体系 → NED 旋转(§2.3,A4) ----
|
||||||
|
|
||||||
|
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
|
||||||
|
out = law.update(snap, 1000)
|
||||||
|
# 机体前向 3.0 → 东向 3.0,北向 ≈ 0
|
||||||
|
assert out.vx_mps == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert out.vy_mps == pytest.approx(3.0, abs=1e-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
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Reset(§6.4) ----
|
||||||
|
|
||||||
|
def test_reset_restores_slew_from_zero():
|
||||||
|
cfg = make_config()
|
||||||
|
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
|
||||||
|
for t in range(1000, 3000, 50):
|
||||||
|
snap.track_time_ms = t
|
||||||
|
snap.radar_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.attitude_time_ms = 3000
|
||||||
|
out = law.update(snap, 3000)
|
||||||
|
# Reset 后限幅器归零:又从 0.1 开始渐变
|
||||||
|
assert out.vx_mps == pytest.approx(0.1, abs=1e-9)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""vtc_config_test.py —— TrackingConfig::load_from_json 单元测试。
|
||||||
|
|
||||||
|
移植自 reference-c++/visual_track_control/tests/vtc_config_test.cpp。
|
||||||
|
覆盖(物理追踪思路 §8、需求分析 NFR-007):真实配置文件加载成功与取值
|
||||||
|
正确(含 A1/A2 修改后的字段结构)、缺字段 / 缺分组 / 非法枚举 / 非法数值 /
|
||||||
|
文件缺失均抛带字段名的 ValueError。
|
||||||
|
|
||||||
|
C++ 版用字符串替换构造非法变体;Python 版改为 dict 变异(更稳健),
|
||||||
|
临时文件经 pytest 的 tmp_path 提供。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from visual_track_control.config import (HeadingMode, NoDistanceAction,
|
||||||
|
load_from_json)
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json"
|
||||||
|
|
||||||
|
|
||||||
|
def full_config() -> dict:
|
||||||
|
"""与真实配置同构的完整合法配置(用于派生各非法变体)。"""
|
||||||
|
return {
|
||||||
|
"control": {
|
||||||
|
"frequency_hz": 20.0,
|
||||||
|
"visual_stale_ms": 200,
|
||||||
|
"radar_stale_ms": 500,
|
||||||
|
"attitude_stale_ms": 300,
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"image_width": 1920,
|
||||||
|
"image_height": 1080,
|
||||||
|
"fov_h_deg": 60.0,
|
||||||
|
"fov_v_deg": 35.0,
|
||||||
|
},
|
||||||
|
"heading": {
|
||||||
|
"mode": "rate",
|
||||||
|
"gain": 1.0,
|
||||||
|
"yaw_rate_limit_dps": 30.0,
|
||||||
|
"yaw_slew_limit_dps2": 60.0,
|
||||||
|
},
|
||||||
|
"vertical": {
|
||||||
|
"vz_gain_mps_per_deg": 0.1,
|
||||||
|
"vz_limit_mps": 2.0,
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"d_exp_m": 10.0,
|
||||||
|
"kp": 0.5,
|
||||||
|
"ki": 0.05,
|
||||||
|
"kd": 0.1,
|
||||||
|
"integral_limit": 2.0,
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
"accel_limit": {
|
||||||
|
"ax_mps2": 2.0,
|
||||||
|
"ay_mps2": 2.0,
|
||||||
|
"az_mps2": 1.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_temp_json(tmp_path: Path, config: dict, name: str) -> str:
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_real_config_succeeds():
|
||||||
|
cfg = load_from_json(str(REAL_CONFIG))
|
||||||
|
assert cfg.control.frequency_hz == 20.0
|
||||||
|
assert cfg.control.visual_stale_ms == 200
|
||||||
|
assert cfg.camera.image_width == 1920
|
||||||
|
assert cfg.camera.image_height == 1080
|
||||||
|
assert cfg.camera.fov_h_deg == 60.0
|
||||||
|
assert cfg.heading.mode is HeadingMode.RATE # A2:默认速率式
|
||||||
|
assert cfg.heading.yaw_rate_limit_dps == 30.0
|
||||||
|
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.accel_limit.ax_mps2 == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_json_round_trip_matches_expected(tmp_path):
|
||||||
|
# 内嵌合法配置与真实配置文件行为一致
|
||||||
|
path = write_temp_json(tmp_path, full_config(), "vtc_cfg_full.json")
|
||||||
|
cfg = load_from_json(path)
|
||||||
|
assert cfg.heading.mode is HeadingMode.RATE
|
||||||
|
assert cfg.distance.kp == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_mode_accepted(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
config["heading"]["mode"] = "position"
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_position.json")
|
||||||
|
cfg = load_from_json(path)
|
||||||
|
assert cfg.heading.mode is HeadingMode.POSITION
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_throws_with_path():
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json("/nonexistent/vtc_no_such_file.json")
|
||||||
|
assert "vtc_no_such_file.json" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_json_throws(tmp_path):
|
||||||
|
path = tmp_path / "vtc_cfg_bad.json"
|
||||||
|
path.write_text("{ this is not json", encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
load_from_json(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_field_throws_with_field_name(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
del config["heading"]["gain"]
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_missing.json")
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json(path)
|
||||||
|
assert "heading.gain" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_section_throws(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
del config["accel_limit"]
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_nosection.json")
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json(path)
|
||||||
|
assert "accel_limit" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_heading_mode_throws(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
config["heading"]["mode"] = "magic"
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_badmode.json")
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json(path)
|
||||||
|
assert "heading.mode" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_no_distance_action_throws(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
config["distance"]["no_distance_action"] = "zoom"
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_badaction.json")
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json(path)
|
||||||
|
assert "no_distance_action" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_frequency_throws(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
config["control"]["frequency_hz"] = -1.0
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_negfreq.json")
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json(path)
|
||||||
|
assert "control.frequency_hz" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fov_out_of_range_throws(tmp_path):
|
||||||
|
config = full_config()
|
||||||
|
config["camera"]["fov_h_deg"] = 200.0
|
||||||
|
path = write_temp_json(tmp_path, config, "vtc_cfg_badfov.json")
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_from_json(path)
|
||||||
|
assert "camera.fov_h_deg" in str(exc_info.value)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# visual_track_control(Python 版)
|
||||||
|
|
||||||
|
视觉跟踪控制律:像素视线角 → 水平航向 / 垂直速度 / 距离 PID 三通道 + 统一限幅 +
|
||||||
|
状态机(正常 / Coast / 判丢刹车 / 降级保持)+ MAVSDK offboard 指令输出。
|
||||||
|
移植自 `reference-c++/visual_track_control/`(规格:docs/物理追踪思路.md)。
|
||||||
|
|
||||||
|
## 接口清单
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `TrackingConfig` / `load_from_json(path)` | 配置加载(启动期校验,错误携带字段名,抛 `ValueError`) |
|
||||||
|
| `HeadingMode` / `NoDistanceAction` | 配置枚举(rate/position;hold/slow_approach/exit) |
|
||||||
|
| `TrackingControlLaw(cfg)` | 主控律 |
|
||||||
|
| `.update(snapshot, now_ms) -> ControlOutput` | 单周期八步流程(姿态/视觉超龄 → 判丢 → Coast → 三通道 → NED 旋转) |
|
||||||
|
| `.reset()` | 停止闭环时清全部内部状态 |
|
||||||
|
| `ControlSnapshot` | 输入快照(自带 `TrackState`,track_time_ms 由调用方打戳) |
|
||||||
|
| `ControlOutput` | 输出:mode / valid / vx·vy·vz(NED)/ vx_body_mps(机体系前向)/ yaw_deg / yaw_rate_dps / degraded_reason / coast_active |
|
||||||
|
| `PidController` / `PidParams` | 距离 PID(anti-windup 条件积分) |
|
||||||
|
| `SlewRateLimiter` / `clamp` | 变化率限幅(初值 0 → 首条指令渐变,S6) |
|
||||||
|
| `PixelToAngle` / `is_stale` / `rotate_body_to_ned` | 针孔视线角 / 超龄判定 / 机体→NED 旋转 |
|
||||||
|
| `CommandSender`(Protocol) | 指令输出抽象(供注入 mock) |
|
||||||
|
| `MavsdkCommandGateway(drone)` | MAVSDK offboard 网关(async:start / send_velocity_heading / send_brake_hover / stop) |
|
||||||
|
|
||||||
|
## 与 C++ 版的差异
|
||||||
|
|
||||||
|
- **MAVLink → MAVSDK 语义映射**(替代 C++ 的 ID 84 手工编码):
|
||||||
|
- 角速率非零 → `set_velocity_body(forward, 0, down, yawspeed)`;
|
||||||
|
- 航向角非零(角速率为零)→ `set_velocity_ned(north, east, down, yaw)`;
|
||||||
|
- 两者均零 → `set_velocity_body` 配零角速率(保持航向,A2 回归语义);
|
||||||
|
- 刹车 → `set_velocity_body(0,0,0,0)`(MAVSDK 无刹车掩码的行为等效适配);
|
||||||
|
- 降级保持不发送(由调用方判断模式)。
|
||||||
|
- **异步**:MAVSDK Python 为 asyncio 接口,网关方法均为协程;传输异常捕获后返回
|
||||||
|
`False`(与 C++ 传输失败传播一致)。
|
||||||
|
- **解耦**:C++ 的 ControlSnapshot 内嵌 `target_tracker::TrackResult` 是唯一耦合点;
|
||||||
|
Python 版自带字段对齐的 `TrackState`(box/raw 为 plain tuple),两包互不 import,
|
||||||
|
转换在胶水层(`utils/demo_track_control.py` 的 `track_result_to_state`)。
|
||||||
|
- **新增字段** `ControlOutput.vx_body_mps`:旋转前的机体系前向速度,供速率式
|
||||||
|
机体设定点直接使用(免反旋)。
|
||||||
|
- **日志**:spdlog → 标准 `logging`,仅状态切换点打印(热路径零日志)不变。
|
||||||
|
- 行为语义(姿态超龄优先于视觉、判丢进刹车清限幅器、Coast 清距离积分、
|
||||||
|
dt 兜底 1/frequency_hz 上限 0.5s、WrapDeg180 回绕)与 C++ 一致,由移植测试锁定。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""视觉跟踪控制包(visual_track_control Python 版)。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/visual_track_control/。本包与 target_tracker
|
||||||
|
包互不依赖(TrackState 为字段对齐的独立值类型,胶水层负责转换)。
|
||||||
|
|
||||||
|
分层:
|
||||||
|
纯算法构件 rate_limiter / staleness_checker / line_of_sight /
|
||||||
|
frame_transform / pid_controller
|
||||||
|
配置 config(TrackingConfig + load_from_json)
|
||||||
|
主控律 tracking_control_law(TrackingControlLaw)
|
||||||
|
指令输出 command_sender(抽象 Protocol)+ mavsdk_gateway(MAVSDK offboard)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .command_sender import CommandSender
|
||||||
|
from .config import (AccelLimitConfig, CameraGroupConfig, ControlGroupConfig,
|
||||||
|
DistanceGroupConfig, HeadingGroupConfig, HeadingMode,
|
||||||
|
NoDistanceAction, TrackingConfig, VerticalGroupConfig,
|
||||||
|
load_from_json)
|
||||||
|
from .frame_transform import rotate_body_to_ned
|
||||||
|
from .line_of_sight import PixelToAngle
|
||||||
|
from .mavsdk_gateway import MavsdkCommandGateway
|
||||||
|
from .pid_controller import PidController, PidParams
|
||||||
|
from .rate_limiter import SlewRateLimiter, clamp
|
||||||
|
from .staleness_checker import is_stale
|
||||||
|
from .tracking_control_law import TrackingControlLaw
|
||||||
|
from .types import (ControlMode, ControlOutput, ControlSnapshot, DegradedReason,
|
||||||
|
TrackState)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CommandSender",
|
||||||
|
"MavsdkCommandGateway",
|
||||||
|
"TrackingControlLaw",
|
||||||
|
"TrackingConfig",
|
||||||
|
"load_from_json",
|
||||||
|
"HeadingMode",
|
||||||
|
"NoDistanceAction",
|
||||||
|
"ControlGroupConfig",
|
||||||
|
"CameraGroupConfig",
|
||||||
|
"HeadingGroupConfig",
|
||||||
|
"VerticalGroupConfig",
|
||||||
|
"DistanceGroupConfig",
|
||||||
|
"AccelLimitConfig",
|
||||||
|
"ControlMode",
|
||||||
|
"DegradedReason",
|
||||||
|
"TrackState",
|
||||||
|
"ControlSnapshot",
|
||||||
|
"ControlOutput",
|
||||||
|
"PidController",
|
||||||
|
"PidParams",
|
||||||
|
"SlewRateLimiter",
|
||||||
|
"clamp",
|
||||||
|
"is_stale",
|
||||||
|
"PixelToAngle",
|
||||||
|
"rotate_body_to_ned",
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
|||||||
|
"""控制指令发送抽象(语义接口,对应 C++ ICommandTransport + 网关门面)。
|
||||||
|
|
||||||
|
C++ 版的 ICommandTransport 是字节流传输抽象(Send(bytes)),MAVLink 编码
|
||||||
|
在网关内完成;Python 版采用 MAVSDK 语义映射后,"传输"被抽象为设定点级
|
||||||
|
指令发送。本 Protocol 仅声明门面接口,供胶水层依赖注入与 mock 测试。
|
||||||
|
|
||||||
|
异常约定:实现不得向外抛传输层异常,失败一律返回 False(网关已捕获)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from .types import ControlOutput
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CommandSender(Protocol):
|
||||||
|
async def start(self) -> bool:
|
||||||
|
"""进入 offboard 模式。失败返回 False。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send_velocity_heading(self, output: ControlOutput,
|
||||||
|
time_boot_ms: Optional[int] = None) -> bool:
|
||||||
|
"""发送速度+航向意图(ControlMode.VELOCITY_HEADING)。失败返回 False。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send_brake_hover(self, time_boot_ms: Optional[int] = None) -> bool:
|
||||||
|
"""发送零速刹车/悬停意图(ControlMode.BRAKE_HOVER)。失败返回 False。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""退出 offboard 模式(结束时调用)。"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""视觉跟踪控制律配置(TrackingConfig + load_from_json)。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/visual_track_control/src/vtc_config.cpp
|
||||||
|
(规格:docs/物理追踪思路.md §8:全部阈值集中配置,NFR-007)。
|
||||||
|
|
||||||
|
启动期一次性解析:缺字段 / 类型错误 / 非法取值抛 ValueError 并携带完整
|
||||||
|
字段名(如 "heading.gain");解析结果与 JSON 脱钩,热路径不做热更新。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .types import ControlMode # noqa: F401 (re-export 便利性)
|
||||||
|
|
||||||
|
|
||||||
|
class HeadingMode(Enum):
|
||||||
|
"""航向通道模式(§4.2)。默认速率式:yaw_rate 天然是相对量,无坐标系歧义。"""
|
||||||
|
|
||||||
|
RATE = "rate" # β → 偏航角速度,输出 yaw_rate_dps
|
||||||
|
POSITION = "position" # β → 修正量,与当前航向合成绝对航向输出 yaw_deg
|
||||||
|
|
||||||
|
|
||||||
|
class NoDistanceAction(Enum):
|
||||||
|
"""雷达距离不可用(未关联/超龄)时的距离通道降级动作(§6.3)。"""
|
||||||
|
|
||||||
|
HOLD = "hold" # 保持:前向速度指令清零
|
||||||
|
SLOW_APPROACH = "slow_approach" # 限速接近:按 no_distance_approach_limit_mps 缓速
|
||||||
|
EXIT = "exit" # 退出近距操作:输出降级保持
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ControlGroupConfig:
|
||||||
|
frequency_hz: float = 20.0
|
||||||
|
visual_stale_ms: int = 200
|
||||||
|
radar_stale_ms: int = 500
|
||||||
|
attitude_stale_ms: int = 300
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraGroupConfig:
|
||||||
|
image_width: int = 1920
|
||||||
|
image_height: int = 1080
|
||||||
|
fov_h_deg: float = 60.0
|
||||||
|
fov_v_deg: float = 35.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeadingGroupConfig:
|
||||||
|
mode: HeadingMode = HeadingMode.RATE
|
||||||
|
gain: float = 1.0
|
||||||
|
yaw_rate_limit_dps: float = 30.0
|
||||||
|
yaw_slew_limit_dps2: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerticalGroupConfig:
|
||||||
|
vz_gain_mps_per_deg: float = 0.1
|
||||||
|
vz_limit_mps: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DistanceGroupConfig:
|
||||||
|
d_exp_m: float = 10.0
|
||||||
|
kp: float = 0.0
|
||||||
|
ki: float = 0.0
|
||||||
|
kd: float = 0.0
|
||||||
|
integral_limit: float = 0.0
|
||||||
|
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
|
||||||
|
no_distance_approach_limit_mps: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccelLimitConfig:
|
||||||
|
ax_mps2: float = 2.0
|
||||||
|
ay_mps2: float = 2.0
|
||||||
|
az_mps2: float = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackingConfig:
|
||||||
|
control: ControlGroupConfig = field(default_factory=ControlGroupConfig)
|
||||||
|
camera: CameraGroupConfig = field(default_factory=CameraGroupConfig)
|
||||||
|
heading: HeadingGroupConfig = field(default_factory=HeadingGroupConfig)
|
||||||
|
vertical: VerticalGroupConfig = field(default_factory=VerticalGroupConfig)
|
||||||
|
distance: DistanceGroupConfig = field(default_factory=DistanceGroupConfig)
|
||||||
|
accel_limit: AccelLimitConfig = field(default_factory=AccelLimitConfig)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON 解析与校验(错误消息携带完整字段名,与 C++ 版文本一致)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _require_section(root: dict, section: str) -> dict:
|
||||||
|
value = root.get(section)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"配置缺失分组或类型错误: {section}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_number(value: Any) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_integer(value: Any) -> bool:
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_number(section: dict, section_name: str, key: str) -> float:
|
||||||
|
value = section.get(key)
|
||||||
|
if not _is_number(value):
|
||||||
|
raise ValueError(f"配置缺失字段或类型错误: {section_name}.{key}")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_integer(section: dict, section_name: str, key: str) -> int:
|
||||||
|
value = section.get(key)
|
||||||
|
if not _is_integer(value):
|
||||||
|
raise ValueError(f"配置缺失字段或类型错误(需整数): {section_name}.{key}")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_string(section: dict, section_name: str, key: str) -> str:
|
||||||
|
value = section.get(key)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"配置缺失字段或类型错误(需字符串): {section_name}.{key}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_positive(value: float, field_name: str) -> float:
|
||||||
|
if not (value > 0.0):
|
||||||
|
raise ValueError(f"配置非法(必须为正): {field_name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_non_negative(value: float, field_name: str) -> float:
|
||||||
|
if value < 0.0:
|
||||||
|
raise ValueError(f"配置非法(不得为负): {field_name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_range(value: float, lo: float, hi: float, field_name: str) -> float:
|
||||||
|
if not (lo < value < hi):
|
||||||
|
raise ValueError(f"配置非法(超出范围): {field_name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_control(root: dict) -> ControlGroupConfig:
|
||||||
|
s = _require_section(root, "control")
|
||||||
|
c = 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.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:
|
||||||
|
raise ValueError("配置非法: control.*_stale_ms 必须为正")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _load_camera(root: dict) -> CameraGroupConfig:
|
||||||
|
s = _require_section(root, "camera")
|
||||||
|
c = CameraGroupConfig()
|
||||||
|
c.image_width = int(_require_positive(
|
||||||
|
float(_require_integer(s, "camera", "image_width")), "camera.image_width"))
|
||||||
|
c.image_height = int(_require_positive(
|
||||||
|
float(_require_integer(s, "camera", "image_height")), "camera.image_height"))
|
||||||
|
c.fov_h_deg = _require_range(_require_number(s, "camera", "fov_h_deg"),
|
||||||
|
0.0, 180.0, "camera.fov_h_deg")
|
||||||
|
c.fov_v_deg = _require_range(_require_number(s, "camera", "fov_v_deg"),
|
||||||
|
0.0, 180.0, "camera.fov_v_deg")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _load_heading(root: dict) -> HeadingGroupConfig:
|
||||||
|
s = _require_section(root, "heading")
|
||||||
|
h = HeadingGroupConfig()
|
||||||
|
mode = _require_string(s, "heading", "mode")
|
||||||
|
if mode == "position":
|
||||||
|
h.mode = HeadingMode.POSITION
|
||||||
|
elif mode == "rate":
|
||||||
|
h.mode = HeadingMode.RATE
|
||||||
|
else:
|
||||||
|
raise ValueError("配置非法: heading.mode 仅支持 position / rate")
|
||||||
|
h.gain = _require_non_negative(_require_number(s, "heading", "gain"), "heading.gain")
|
||||||
|
h.yaw_rate_limit_dps = _require_positive(
|
||||||
|
_require_number(s, "heading", "yaw_rate_limit_dps"), "heading.yaw_rate_limit_dps")
|
||||||
|
h.yaw_slew_limit_dps2 = _require_positive(
|
||||||
|
_require_number(s, "heading", "yaw_slew_limit_dps2"), "heading.yaw_slew_limit_dps2")
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vertical(root: dict) -> VerticalGroupConfig:
|
||||||
|
s = _require_section(root, "vertical")
|
||||||
|
v = VerticalGroupConfig()
|
||||||
|
v.vz_gain_mps_per_deg = _require_non_negative(
|
||||||
|
_require_number(s, "vertical", "vz_gain_mps_per_deg"),
|
||||||
|
"vertical.vz_gain_mps_per_deg")
|
||||||
|
v.vz_limit_mps = _require_positive(
|
||||||
|
_require_number(s, "vertical", "vz_limit_mps"), "vertical.vz_limit_mps")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _load_distance(root: dict) -> DistanceGroupConfig:
|
||||||
|
s = _require_section(root, "distance")
|
||||||
|
d = DistanceGroupConfig()
|
||||||
|
d.d_exp_m = _require_positive(_require_number(s, "distance", "d_exp_m"),
|
||||||
|
"distance.d_exp_m")
|
||||||
|
d.kp = _require_non_negative(_require_number(s, "distance", "kp"), "distance.kp")
|
||||||
|
d.ki = _require_non_negative(_require_number(s, "distance", "ki"), "distance.ki")
|
||||||
|
d.kd = _require_non_negative(_require_number(s, "distance", "kd"), "distance.kd")
|
||||||
|
d.integral_limit = _require_non_negative(
|
||||||
|
_require_number(s, "distance", "integral_limit"), "distance.integral_limit")
|
||||||
|
# 容许 -0.0:范围下界略低于 0,读入后归一化为 0
|
||||||
|
d.derivative_filter_coef = _require_range(
|
||||||
|
_require_number(s, "distance", "derivative_filter_coef"),
|
||||||
|
-0.000001, 1.0, "distance.derivative_filter_coef")
|
||||||
|
if d.derivative_filter_coef < 0.0:
|
||||||
|
d.derivative_filter_coef = 0.0
|
||||||
|
d.approach_velocity_limit_mps = _require_positive(
|
||||||
|
_require_number(s, "distance", "approach_velocity_limit_mps"),
|
||||||
|
"distance.approach_velocity_limit_mps")
|
||||||
|
d.retreat_velocity_limit_mps = _require_positive(
|
||||||
|
_require_number(s, "distance", "retreat_velocity_limit_mps"),
|
||||||
|
"distance.retreat_velocity_limit_mps")
|
||||||
|
action = _require_string(s, "distance", "no_distance_action")
|
||||||
|
if action == "hold":
|
||||||
|
d.no_distance_action = NoDistanceAction.HOLD
|
||||||
|
elif action == "slow_approach":
|
||||||
|
d.no_distance_action = NoDistanceAction.SLOW_APPROACH
|
||||||
|
elif action == "exit":
|
||||||
|
d.no_distance_action = NoDistanceAction.EXIT
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"配置非法: distance.no_distance_action 仅支持 hold / slow_approach / exit")
|
||||||
|
d.no_distance_approach_limit_mps = _require_non_negative(
|
||||||
|
_require_number(s, "distance", "no_distance_approach_limit_mps"),
|
||||||
|
"distance.no_distance_approach_limit_mps")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _load_accel_limit(root: dict) -> AccelLimitConfig:
|
||||||
|
s = _require_section(root, "accel_limit")
|
||||||
|
a = AccelLimitConfig()
|
||||||
|
a.ax_mps2 = _require_positive(_require_number(s, "accel_limit", "ax_mps2"),
|
||||||
|
"accel_limit.ax_mps2")
|
||||||
|
a.ay_mps2 = _require_positive(_require_number(s, "accel_limit", "ay_mps2"),
|
||||||
|
"accel_limit.ay_mps2")
|
||||||
|
a.az_mps2 = _require_positive(_require_number(s, "accel_limit", "az_mps2"),
|
||||||
|
"accel_limit.az_mps2")
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def load_from_json(path: str) -> TrackingConfig:
|
||||||
|
"""从 JSON 文件一次性加载配置(启动期调用,调参重启生效)。"""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fp:
|
||||||
|
root = json.load(fp)
|
||||||
|
except OSError:
|
||||||
|
raise ValueError(f"配置文件打开失败: {path}") from None
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"配置文件解析失败: {path} ({exc})") from None
|
||||||
|
|
||||||
|
if not isinstance(root, dict):
|
||||||
|
raise ValueError(f"配置文件解析失败: {path} (根节点必须为对象)")
|
||||||
|
|
||||||
|
cfg = TrackingConfig()
|
||||||
|
cfg.control = _load_control(root)
|
||||||
|
cfg.camera = _load_camera(root)
|
||||||
|
cfg.heading = _load_heading(root)
|
||||||
|
cfg.vertical = _load_vertical(root)
|
||||||
|
cfg.distance = _load_distance(root)
|
||||||
|
cfg.accel_limit = _load_accel_limit(root)
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""机体系(前/右/下)→ 局部 NED(北/东/地)坐标旋转。
|
||||||
|
|
||||||
|
对应 C++ 参考实现 reference-c++/visual_track_control/include/visual_track_control/frame_transform.h
|
||||||
|
(规格:docs/物理追踪思路.md §2.3)。
|
||||||
|
|
||||||
|
速度设定值只随航向角 ψ(绕下轴旋转)旋转:滚转/俯仰对速度指令为二阶
|
||||||
|
小量,由飞控内环处理。旋转为正交变换:合速度大小不变,因此"限幅在机
|
||||||
|
体系完成、旋转在输出端执行"不破坏 §8 限幅约束。
|
||||||
|
|
||||||
|
正方向验证(NED:x=北,y=东,z=地;航向 ψ 自北顺时针为正):
|
||||||
|
机体前向 (1,0) 在 ψ=90°(机头朝东)时 → (0, 1) = 东;
|
||||||
|
机体右向 (0,1) 在 ψ=0°(机头朝北)时 → (0, 1) = 东。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_body_to_ned(v_forward: float, v_right: float, v_down: float,
|
||||||
|
yaw_rad: float) -> tuple[float, float, float]:
|
||||||
|
"""绕下轴按航向角 ψ 旋转,返回 (北, 东, 地);垂直分量不变。"""
|
||||||
|
c = math.cos(yaw_rad)
|
||||||
|
s = math.sin(yaw_rad)
|
||||||
|
north = v_forward * c - v_right * s
|
||||||
|
east = v_forward * s + v_right * c
|
||||||
|
return north, east, v_down
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user