精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。 目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。 自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。 任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。 入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。 新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
This commit is contained in:
+71
-70
@@ -1,51 +1,57 @@
|
||||
# utils —— reference-c++ 的 Python 移植 + 双目链路新实现
|
||||
# utils —— Python 实现(运行链路)
|
||||
|
||||
将 `reference-c++/` 下的 `target_tracker`(多目标追踪过滤器)与
|
||||
`visual_track_control`(视觉跟踪控制律)移植为 Python,**两包互不依赖**
|
||||
(唯一的数据转换点在胶水层 demo)。
|
||||
> **架构现状(2026-10-20)**:运行链路是「光电吊舱 → `pod_bridge` → `task_runner` → PX4」。
|
||||
> 双目 + YOLO 那套可选感知后端(`stereo_yolo` / `target_tracker` / `stereo_ranger`)
|
||||
> **已整体归档到仓库根的 `legacy/`**,默认不参与 pytest 收集;接回方式见 `legacy/README.md`。
|
||||
|
||||
另有双目链路新实现(非 C++ 移植、按规格实现):
|
||||
- `stereo_ranger`(双目稀疏目标测距),规格 `docs/双目测距技术路线.md`;
|
||||
- `stereo_yolo`(双目 YOLO 检测前端),规格 `docs/双目YOLO目标识别需求说明.md`,
|
||||
输出直接作为 stereo_ranger 的输入(仅复用其 `types.py` 纯值类型)。
|
||||
本目录包含:
|
||||
|
||||
Python 侧定位为 **C++ 实现的先验验证**:构件划分与语义按规格冻结,便于逐构件
|
||||
移植对照;`stereo_yolo` 的 C++ 移植注记见其包 README。
|
||||
- **运行链路**:`pod_bridge`(吊舱桥接)+ `task_runner`(任务编排)+ `visual_track_control`
|
||||
(控制律原语,被前两者复用)
|
||||
- **入口共享件**:`sitl_support.py`(时钟/遥测订阅/连接准备/`RATE_*`)
|
||||
- **测试**:`tests/`(446 条)、`conftest.py`、`pytest.ini`
|
||||
|
||||
`visual_track_control` 是 `reference-c++/visual_track_control` 的逐构件 Python 移植
|
||||
(语义对齐、含行为修复);`target_tracker` 的移植版已随归档移入 `legacy/utils/`。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
utils/
|
||||
├── target_tracker/ # 包 1:卡尔曼滤波多目标追踪(无第三方硬依赖,仅 numpy)
|
||||
├── pod_bridge/ # 吊舱 ↔ PX4 桥接(**运行链路核心**)
|
||||
│ ├── bridge.py # PodBridge:L1/L2 切换、目标门控、链路维持
|
||||
│ ├── los_consistency.py # 目标有效性第二判据(LOS 运动学一致性)
|
||||
│ ├── ground_link.py # 地面站 UDP 坐标注入 + 命令队列
|
||||
│ ├── guidance.py # L2 地理引导律
|
||||
│ ├── protocol.py # 表2-1/2-2/3/4 帧编解码
|
||||
│ ├── pod_serial.py # 串口链路(115200 8N1)
|
||||
│ ├── px4_gateway.py # MAVSDK 遥测 + offboard 网关
|
||||
│ ├── virtual_pod.py # 虚拟吊舱(SITL/台架)
|
||||
│ └── {run_bridge,listen_pod,pod_console,sim_fc,verify_px4}.py # 台架工具
|
||||
├── task_runner/ # 任务级编排
|
||||
│ ├── state_machine.py # WAIT→ARM_TAKEOFF→GOTO→SEARCH→TRACK→RTL
|
||||
│ ├── runner.py # 编排器(快照采集/动作/异常兜底)
|
||||
│ ├── return_home.py # 自定义返航(回起飞点→恢复航向→垂直降落)
|
||||
│ ├── pod_control.py # 吊舱识别/锁定指令
|
||||
│ └── log.py # 事件 JSONL + 遥测 CSV + 简报
|
||||
├── visual_track_control/ # 控制律 + MAVSDK 网关(运行链路复用)
|
||||
│ └── README.md # 接口清单 + C++ 差异
|
||||
├── visual_track_control/ # 包 2:三通道控制律 + MAVSDK offboard 网关
|
||||
│ └── README.md # 接口清单 + C++ 差异
|
||||
├── stereo_ranger/ # 包 3:双目稀疏目标测距(纯标准库,无第三方依赖)
|
||||
│ └── README.md # 接口清单 + 与规格的偏差
|
||||
├── stereo_yolo/ # 包 4:双目 YOLO 检测前端(真模型需 ultralytics)
|
||||
│ └── README.md # 接口清单 + C++ 移植注记
|
||||
├── demo_track_control.py # 胶水层集成演示(模拟检测 → 追踪 → 控制律 → 发送)
|
||||
├── demo_stereo_ranger.py # 双目测距演示(合成框对,六场景,无硬件)
|
||||
├── demo_stereo_yolo.py # 检测前端演示(真模型 + 合成素材 → 测距联调)
|
||||
├── assets/stereo_yolo/ # demo 占位素材(AI 合成图,可整体替换为真实素材)
|
||||
├── config/track_control.json # 控制律配置(拷贝自 reference-c++)
|
||||
├── config/stereo_ranger.json # 双目测距配置(占位值,待标定回填)
|
||||
├── config/stereo_yolo.json # 检测前端配置(不存内参,几何参数唯一来源见上)
|
||||
├── tests/ # pytest 测试
|
||||
│ ├── target_tracker/ # 28 条(对应 C++ 测试文件)
|
||||
│ ├── visual_track_control/ # 81 条(对应 C++ 测试文件)
|
||||
│ ├── stereo_ranger/ # 111 条(规格 §11.4 构件级 + 全流程)
|
||||
│ └── stereo_yolo/ # 36 条(假检测器驱动,无模型/素材/硬件)
|
||||
├── conftest.py # 保证可从任意工作目录导入各包
|
||||
└── pytest.ini # 收集 *_test.py 命名(与 C++ 测试文件名对齐);
|
||||
# importlib 导入模式避免跨包同名测试文件冲突
|
||||
├── config/ # 配置 JSON(pod_bridge.json 为主线)
|
||||
├── tests/ # pytest(446 条)
|
||||
│ ├── pod_bridge/ # 208 条(协议/桥接/引导/注入/节流/LOS)
|
||||
│ ├── task_runner/ # 122 条(状态机/编排器/返航/日志/吊舱控制)
|
||||
│ ├── visual_track_control/ # 86 条(对应 C++ gtest + L1 增量)
|
||||
│ └── ground_station/ # 30 条(页面与日志解读)
|
||||
├── conftest.py # 把 utils/ 插进 sys.path + fake_mavsdk fixture
|
||||
├── pytest.ini # 收集 *_test.py + importlib 导入模式
|
||||
└── sitl_support.py # 入口共用:时钟/遥测订阅/连接准备/速率常量
|
||||
```
|
||||
|
||||
## 环境
|
||||
|
||||
conda env `pytorch`(`D:\program file\conda\envs\pytorch`),Python 3.12,
|
||||
依赖:numpy、mavsdk、pytest;stereo_ranger 仅标准库;stereo_yolo 只有真模型
|
||||
推理路径需要 ultralytics(在检测器内部延迟导入,测试与假检测器路径不受影响)。
|
||||
依赖:numpy、mavsdk(<4)、pytest、flask、pyserial
|
||||
(`opencv-python` 仅供 `tools/` 下的相机脚本;`ultralytics` 仅供已归档的双目链路)。
|
||||
|
||||
## 运行测试
|
||||
|
||||
@@ -54,51 +60,46 @@ cd utils
|
||||
"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q
|
||||
```
|
||||
|
||||
256 条测试全部通过:109 条 C++ 移植用例(含 §9.4 六条硬验收、A1/A2/A4
|
||||
回归、PID anti-windup C1、超龄 C4、首拍限幅 S6)+ 111 条 stereo_ranger
|
||||
(合成框对驱动)+ 36 条 stereo_yolo(假检测器驱动)。
|
||||
stereo_ranger / stereo_yolo 测试均不需要真实图像、模型权重或硬件。
|
||||
**运行链路 446 条全部通过(2026-10-20)**:
|
||||
|
||||
## 运行演示
|
||||
| 组 | 条数 |
|
||||
|---|---|
|
||||
| `pod_bridge` | 208 |
|
||||
| `task_runner` | 122 |
|
||||
| `visual_track_control`(含 §9.4 硬验收、A1/A2/A4、C1、C4、S6、L1 角度直入) | 86 |
|
||||
| `ground_station` | 30 |
|
||||
|
||||
归档的可选后端 175 条(stereo_ranger 111 / stereo_yolo 36 / target_tracker 28)
|
||||
在 `legacy/` 下,按需运行:
|
||||
|
||||
```bash
|
||||
# mock 传输(本地记录指令,无实机)
|
||||
python utils/demo_track_control.py --speedup 10
|
||||
|
||||
# MAVSDK 实发(需飞控/SITL 在 udp://:14540)
|
||||
python utils/demo_track_control.py --transport mavsdk
|
||||
|
||||
# 双目测距(合成框对,离线,无相机/飞控)
|
||||
python utils/demo_stereo_ranger.py
|
||||
|
||||
# 双目检测前端(真模型推理;需 ultralytics + models/yolo26n-drone.pt)
|
||||
python utils/demo_stereo_yolo.py
|
||||
cd legacy/utils && PYTHONPATH="../../utils" "D:/program file/conda/envs/pytorch/python.exe" -m pytest -q
|
||||
```
|
||||
|
||||
控制链路时间线 13 s(20 Hz):正常跟踪 6 s(vx 从零渐变 → 逼近限速 3 m/s)→
|
||||
目标消失 4 s(Coast 预测,60 帧后轨道判亡)→ 判零速刹车 3 s。
|
||||
运行链路测试不需要硬件(FakePod / VirtualPod / FakePx4 注入)。
|
||||
|
||||
双目测距演示六场景:逼近(三道有效性门 + 滤波)→ 远场域外(方位仍发布)→
|
||||
单目退化 → 非同帧拒收 → 一致性降级/判无效 → 多目标贪心配对。
|
||||
## 运行入口
|
||||
|
||||
检测前端演示五场景:单图冒烟 → 合成双目对联调(真检测框喂 stereo_ranger,
|
||||
与解析式 D=F·B/Z 对照)→ 空场景 → 左右形状不一致拒收 → 帧周期计时。
|
||||
合成对用 AI 占位素材 `assets/stereo_yolo/drone_sky.png`(可整体替换为真实
|
||||
素材),本机实测 |ΔD|≈0.3 px、|Δy|≈0.2 px;帧周期 25~34 ms(640 输入,
|
||||
双实例并发,随机器负载波动),与 30 fps 预算 33.3 ms 同量级,结论不可外推
|
||||
到机载平台(§5 的算力预算另见平台工程文档)。
|
||||
入口脚本在仓库根(不在 utils 内):
|
||||
|
||||
```bash
|
||||
PY="D:/program file/conda/envs/pytorch/python.exe"
|
||||
"$PY" task_runner_sitl.py --conn udpin://0.0.0.0:14540 # 任务全流程 SITL
|
||||
"$PY" task_runner_real.py --port COM5 --conn udpin://0.0.0.0:14540 --target-type 1
|
||||
"$PY" l1_sitl_test.py --conn udpin://0.0.0.0:14540 # 仅 L1 控制律闭环
|
||||
"$PY" utils/pod_bridge/run_bridge.py --port COM5 --dry-run # S2 台架干跑
|
||||
```
|
||||
|
||||
## 移植约定
|
||||
|
||||
- MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支
|
||||
映射保留 C++ A2 回归语义,详见包 2 README。
|
||||
- MAVLink 部分改为 **MAVSDK 语义映射**(offboard API,非字节透传),三分支映射保留
|
||||
C++ A2 回归语义,详见 `visual_track_control/README.md`。
|
||||
- 测试逐条移植 C++ gtest 断言(`EXPECT_DOUBLE_EQ` → 精确 `==`,
|
||||
`EXPECT_NEAR` → `pytest.approx`);MAVSDK 部分因无 SITL/实机,仅 mock 验证。
|
||||
`EXPECT_NEAR` → `pytest.approx`)。
|
||||
- 行为修复项(C1/C2/C4、A1/A2/A4、S6)在测试文件头注释中标注对应章节。
|
||||
- `stereo_ranger` 不是移植:按规格五步流程新实现,与规格的偏差(越界=有效性门、
|
||||
视差域权威、补充 consistency_jump_px/isolation_gate_px 等)见其包 README;
|
||||
⚠ 全部数值为占位值,标定完成前不得作为飞行控制决策依据。
|
||||
- `stereo_yolo` 不是移植:推理口径为 ultralytics 直跑 `.pt`,letterbox、解码、
|
||||
NMS、坐标回映均在库内完成;C++ 侧复现要点与接口契约硬约束(原生分辨率坐标、
|
||||
左右预处理位级一致、`x1` 即框左边缘、无跨帧状态)见其包 README 的移植注记表。
|
||||
⚠ 占位素材 `assets/stereo_yolo/` 为 AI 合成图,真实双目素材到位后需重跑联调。
|
||||
- ⚠ 全部数值为占位值,标定完成前不得作为飞行控制决策依据。
|
||||
|
||||
## 已归档内容
|
||||
|
||||
`legacy/utils/` 下保留了原 `stereo_yolo` / `stereo_ranger` / `target_tracker` 三包、
|
||||
175 条测试、三个 demo、`stereo_*.json`、素材与 YOLO 权重;说明见 `legacy/README.md`。
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 821 KiB |
@@ -39,7 +39,10 @@
|
||||
"alpha_from_frame": false,
|
||||
"alpha_from_off_target": true,
|
||||
"vertical_enabled": true,
|
||||
"require_vision": false
|
||||
"los_consistency_enabled": true,
|
||||
"los_window_ms": 3000,
|
||||
"los_min_yaw_change_deg": 20.0,
|
||||
"los_max_residual_deg": 8.0
|
||||
},
|
||||
|
||||
"tracking": {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"fx_px": 960.0,
|
||||
"fy_px": 960.0,
|
||||
"cx_px": 960.0,
|
||||
"cy_px": 540.0,
|
||||
"baseline_m": 0.06,
|
||||
"tau_y_px": 2.0,
|
||||
"tau_h_ratio": 0.25,
|
||||
"disparity_min_px": 3.0,
|
||||
"disparity_max_px": 57.6,
|
||||
"max_delta_z_m": 2.0,
|
||||
"consistency_N_frames": 3,
|
||||
"consistency_M_frames": 5,
|
||||
"consistency_jump_px": 3.0,
|
||||
"smooth_alpha": 0.3,
|
||||
"jump_reset_m": 5.0,
|
||||
"filter_reset_frames": 5,
|
||||
"isolation_gate_px": 50.0
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"model_path": "models/yolo26n-drone.pt",
|
||||
"imgsz": 640,
|
||||
"conf_threshold": 0.25,
|
||||
"iou_threshold": 0.7,
|
||||
"class_whitelist": null,
|
||||
"device": null,
|
||||
"concurrency": "parallel"
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
"""demo_stereo_ranger.py —— 双目稀疏目标测距演示(规格 docs/双目测距技术路线.md)。
|
||||
|
||||
全部场景用**解析构造**的左右目检测框驱动:不需要相机、不需要图像、不需要
|
||||
飞控,几何是闭式可验证的(规格 §11.4)。覆盖:
|
||||
|
||||
① 逼近:三条有效性门依次生效(域外 → 预算门 → 有效)+ 时间滤波平滑
|
||||
② 远场域外:valid = false 但 c_mid(虚拟双目中心方位)照常发布
|
||||
③ 单目退化:仅一目检出 → 仅方位记录
|
||||
④ 非同帧拒收:左右帧标识不一致 → 整帧拒收 + 告警节流
|
||||
⑤ 一致性监控:持续视差跳变 → N 帧降置信度 → M 帧判无效
|
||||
⑥ 多目标:贪心一一配对,各自独立状态;未匹配右目退化为仅方位
|
||||
|
||||
用法:
|
||||
python utils/demo_stereo_ranger.py # 默认配置
|
||||
python utils/demo_stereo_ranger.py --config <path> # 指定配置
|
||||
|
||||
⚠ 所有参数均为占位值(焦距/标定待厂商数据):标定与地面真值验证完成前,
|
||||
本模块输出仅限静态标靶/回放/台架验证,不得作为飞行控制决策依据
|
||||
(规格 §12.3 红线)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from stereo_ranger import (
|
||||
Detection,
|
||||
DetectionFrame,
|
||||
RangeRecord,
|
||||
StereoConfig,
|
||||
StereoRanger,
|
||||
load_from_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("demo_stereo_ranger")
|
||||
|
||||
_DEMO_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_CONFIG = _DEMO_DIR / "config" / "stereo_ranger.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 合成框对
|
||||
|
||||
|
||||
def boxes_for(
|
||||
cfg: StereoConfig,
|
||||
x_center_m: float,
|
||||
y_m: float,
|
||||
z_m: float,
|
||||
*,
|
||||
w_m: float = 0.5,
|
||||
h_m: float = 0.4,
|
||||
class_id: int = 0,
|
||||
track_id: Optional[int] = None,
|
||||
confidence: float = 0.95,
|
||||
) -> Tuple[Detection, Detection]:
|
||||
"""模组中心系 (X_C, Y, Z) 的理想目标 → (左目框, 右目框)。
|
||||
|
||||
两目框同宽,因此边缘视差 = 中心视差 = F·B/Z(§3.2/§6.6 的精确投影)。
|
||||
"""
|
||||
x_l = cfg.cx_px + cfg.fx_px * (x_center_m + cfg.baseline_m / 2.0) / z_m
|
||||
x_r = cfg.cx_px + cfg.fx_px * (x_center_m - cfg.baseline_m / 2.0) / z_m
|
||||
y = cfg.cy_px + cfg.fy_px * y_m / z_m
|
||||
w_px = cfg.fx_px * w_m / z_m
|
||||
h_px = cfg.fy_px * h_m / z_m
|
||||
|
||||
def _box(cx_px: float) -> Detection:
|
||||
return Detection(
|
||||
cx_px - w_px / 2.0,
|
||||
y - h_px / 2.0,
|
||||
cx_px + w_px / 2.0,
|
||||
y + h_px / 2.0,
|
||||
confidence=confidence,
|
||||
class_id=class_id,
|
||||
track_id=track_id,
|
||||
)
|
||||
|
||||
return _box(x_l), _box(x_r)
|
||||
|
||||
|
||||
def pair_with_disparity(d_px: float, *, y_center: float = 500.0) -> Tuple[Detection, Detection]:
|
||||
"""构造视差恰为 d_px 的等宽框对(d 取 0.5 的整数倍时浮点精确)。"""
|
||||
width, height = 60.0, 40.0
|
||||
left = Detection(1000.0 - width / 2, y_center - height / 2, 1000.0 + width / 2, y_center + height / 2)
|
||||
right = Detection(
|
||||
1000.0 - d_px - width / 2,
|
||||
y_center - height / 2,
|
||||
1000.0 - d_px + width / 2,
|
||||
y_center + height / 2,
|
||||
)
|
||||
return left, right
|
||||
|
||||
|
||||
def frame(frame_id: int, detections: List[Detection]) -> DetectionFrame:
|
||||
return DetectionFrame(frame_id=frame_id, detections=detections)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 打印
|
||||
|
||||
|
||||
def describe(record: RangeRecord) -> str:
|
||||
z_text = f"{record.forward_distance_m:7.3f}" if record.forward_distance_m is not None else " — "
|
||||
r_text = f"{record.slant_range_m:7.3f}" if record.slant_range_m is not None else " — "
|
||||
d_text = f"{record.disparity_px:7.2f}" if record.disparity_px is not None else " — "
|
||||
return (
|
||||
f"[F{record.frame_id:02d}] {record.degradation.name:<12} valid={int(record.valid)} "
|
||||
f"Z={z_text} m R={r_text} m D={d_text} px "
|
||||
f"c_mid=({record.center_mid_px[0]:7.1f},{record.center_mid_px[1]:6.1f}) "
|
||||
f"conf={record.confidence:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def log_scene(title: str) -> None:
|
||||
logger.info("")
|
||||
logger.info("── %s", title)
|
||||
|
||||
|
||||
def log_config_summary(cfg: StereoConfig) -> None:
|
||||
logger.info("配置:B=%.3f m F=%.1f px (F·B=%.2f px·m)", cfg.baseline_m, cfg.fx_px, cfg.focal_baseline_px_m)
|
||||
logger.info(
|
||||
" 视差域 [%.1f, %.1f] px ⇔ 距离域 [%.2f, %.2f] m(视差域权威)",
|
||||
cfg.disparity_min_px,
|
||||
cfg.disparity_max_px,
|
||||
cfg.z_min_m,
|
||||
cfg.z_max_m,
|
||||
)
|
||||
logger.info(
|
||||
" 误差预算 δZ(δD=1px) ≤ %.1f m ⇒ 有效远界 %.2f m;一致性 N=%d / M=%d(跳变门限 %.1f px)",
|
||||
cfg.max_delta_z_m,
|
||||
cfg.z_error_budget_max_m,
|
||||
cfg.consistency_N_frames,
|
||||
cfg.consistency_M_frames,
|
||||
cfg.consistency_jump_px,
|
||||
)
|
||||
logger.info(
|
||||
" 滤波 α=%.2f(≈%.0f 帧群延迟)跳变重置 %.1f m;目标隔离门限 %.0f px",
|
||||
cfg.smooth_alpha,
|
||||
1.0 / cfg.smooth_alpha,
|
||||
cfg.jump_reset_m,
|
||||
cfg.isolation_gate_px,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 场景
|
||||
|
||||
|
||||
def scene_approach(ranger: StereoRanger, cfg: StereoConfig) -> None:
|
||||
"""① 逼近:Z 20 → 6 m,三条有效性门依次生效,有效帧起低通平滑。"""
|
||||
ranger.reset() # 场景独立:清空跨场景的滤波/一致性/隔离状态
|
||||
log_scene("① 逼近场景:目标沿光轴 20 → 6 m(1 m/帧),观察三道门与滤波")
|
||||
logger.info(" %-7s %-9s %-6s %-7s %s", "真值Z", "D(px)", "valid", "输出Z", "说明")
|
||||
user_ms = 1000
|
||||
for index, z_true in enumerate([20.0, 18.0, 16.0, 14.0, 12.0, 11.0, 10.0, 9.0, 8.0, 7.0, 6.0], start=1):
|
||||
left, right = boxes_for(cfg, 0.0, 0.0, z_true)
|
||||
record = ranger.update(frame(index, [left]), frame(index, [right]), now_ms=user_ms)[0]
|
||||
user_ms += 50
|
||||
if record.disparity_px is not None and record.disparity_px < cfg.disparity_min_px:
|
||||
note = "门① 视差域外 → invalid"
|
||||
elif not record.valid and record.disparity_px is not None:
|
||||
note = "门② 误差预算不满足 → invalid"
|
||||
elif record.forward_distance_m is not None and abs(record.forward_distance_m - z_true) > 1e-9:
|
||||
note = f"有效(滤波后,真值 {z_true:.2f})"
|
||||
else:
|
||||
note = "有效(滤波器初始化)"
|
||||
logger.info(
|
||||
" %-7.2f %-9.2f %-6d %-7s %s",
|
||||
z_true,
|
||||
record.disparity_px if record.disparity_px is not None else float("nan"),
|
||||
int(record.valid),
|
||||
f"{record.forward_distance_m:.3f}" if record.forward_distance_m is not None else "—",
|
||||
note,
|
||||
)
|
||||
logger.info(
|
||||
" → 远场:域外或 δZ 超预算时诚实输出 invalid(不强行估计);"
|
||||
"有效帧之间才做一阶低通,输出滞后于真值属预期(消费侧 PID 与限幅吸收)"
|
||||
)
|
||||
|
||||
|
||||
def scene_far_field_bearing(ranger: StereoRanger, cfg: StereoConfig) -> None:
|
||||
"""② 远场域外:无距离但方位照常发布(远场定速接近策略的输入基础)。"""
|
||||
ranger.reset()
|
||||
log_scene("② 远场域外:Z=25 m(D < 下限),valid=false 但 c_mid 仍发布")
|
||||
x_center_m = 0.6
|
||||
left, right = boxes_for(cfg, x_center_m, 0.0, 25.0)
|
||||
record = ranger.update(frame(20, [left]), frame(20, [right]), now_ms=2000)[0]
|
||||
logger.info(" %s", describe(record))
|
||||
logger.info(
|
||||
" c_mid.x=%.1f = cx + F·X_C/Z = %.1f(虚拟双目中心,无近场视差偏差)",
|
||||
record.center_mid_px[0],
|
||||
cfg.cx_px + cfg.fx_px * x_center_m / 25.0,
|
||||
)
|
||||
logger.info(" → 消费侧据此走\"方位闭环 + 定速接近\"(D4),而不是使用不可靠的远场估计")
|
||||
|
||||
|
||||
def scene_monocular_degradation(ranger: StereoRanger, cfg: StereoConfig) -> None:
|
||||
"""③ 单目退化:仅一目检出 → 仅方位记录(v1.0 接受近场视差偏差)。"""
|
||||
ranger.reset()
|
||||
log_scene("③ 单目退化:仅左目检出")
|
||||
left, _ = boxes_for(cfg, 0.3, 0.0, 4.0, confidence=0.88)
|
||||
records = ranger.update(frame(21, [left]), frame(21, []), now_ms=3000)
|
||||
logger.info(" %s", describe(records[0]))
|
||||
logger.info(" → 退化 = 仅方位:报该目框中心作方位,valid=false 无距离(§2.2 记录两态)")
|
||||
|
||||
|
||||
def scene_frame_mismatch(ranger: StereoRanger) -> None:
|
||||
"""④ 非同帧拒收:左右帧标识不一致 → 整帧拒收(WARN 节流)。"""
|
||||
ranger.reset()
|
||||
log_scene("④ 同帧契约:左右帧标识不一致(F30 vs F31)")
|
||||
left_pair = pair_with_disparity(9.6)
|
||||
for _ in range(2):
|
||||
records = ranger.update(frame(30, [left_pair[0]]), frame(31, [left_pair[1]]))
|
||||
logger.info(" 两次违约共输出记录 %d 条(应为 0);告警按节流规范只在第 1 次打印", len(records))
|
||||
logger.info(" → 不同曝光时刻的框配对解算没有物理意义,宁可拒收不可给值(§2.1)")
|
||||
|
||||
|
||||
def scene_consistency(ranger: StereoRanger) -> None:
|
||||
"""⑤ 一致性监控:持续视差跳变 → N 帧降置信度 → M 帧判无效。"""
|
||||
ranger.reset()
|
||||
log_scene("⑤ 一致性监控:视差逐帧跳变 4 px(门限 3 px)")
|
||||
for index, d_px in enumerate([9.5, 13.5, 17.5, 21.5, 25.5, 29.5], start=40):
|
||||
left, right = pair_with_disparity(d_px)
|
||||
record = ranger.update(frame(index, [left]), frame(index, [right]), now_ms=4000)[0]
|
||||
logger.info(" %s", describe(record))
|
||||
logger.info(" → 只能降级、不能纠偏:连续异常帧数达 N 降置信度、达 M 判无效(§6.7)")
|
||||
|
||||
|
||||
def scene_multi_target(ranger: StereoRanger, cfg: StereoConfig) -> None:
|
||||
"""⑥ 多目标:贪心一一配对;未配对的右目框退化为仅方位记录。"""
|
||||
ranger.reset()
|
||||
log_scene("⑥ 多目标:两个同类别目标 + 一个孤立右目框")
|
||||
left_a, right_a = boxes_for(cfg, -1.0, 0.0, 8.0)
|
||||
left_b, right_b = boxes_for(cfg, 1.2, 0.0, 9.0)
|
||||
extra_right = Detection(360.0, 480.0, 420.0, 540.0)
|
||||
records = ranger.update(frame(50, [left_a, left_b]), frame(50, [right_a, right_b, extra_right]), now_ms=5000)
|
||||
for record in records:
|
||||
logger.info(" %s", describe(record))
|
||||
logger.info(" → 目标数极少(1~2)时 O(n²) 贪心与全局最优几乎必然同解;匹配器接口可替换(§10.3)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
|
||||
|
||||
def run_demo(config_path: str) -> None:
|
||||
cfg = load_from_json(config_path)
|
||||
logger.info("配置来源:%s", config_path)
|
||||
log_config_summary(cfg)
|
||||
logger.warning("⚠ 占位参数未标定:仅限静态标靶/回放/台架验证,不得作为飞行控制决策依据(§12.3)")
|
||||
|
||||
ranger = StereoRanger(cfg) # 演示共用一个实例,状态跨场景延续(逼近 → 后续场景)
|
||||
scene_approach(ranger, cfg)
|
||||
scene_far_field_bearing(ranger, cfg)
|
||||
scene_monocular_degradation(ranger, cfg)
|
||||
scene_frame_mismatch(ranger)
|
||||
scene_consistency(ranger)
|
||||
scene_multi_target(ranger, cfg)
|
||||
logger.info("")
|
||||
logger.info("演示结束:以上全部为合成框对的解析结果,无真实图像与硬件参与")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="双目稀疏目标测距演示(合成框对,离线)")
|
||||
parser.add_argument("--config", default=str(_DEFAULT_CONFIG), help="配置 JSON 路径")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
run_demo(args.config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,286 +0,0 @@
|
||||
"""demo_stereo_yolo.py —— 双目 YOLO 检测前端演示(规格 docs/双目YOLO目标识别需求说明.md)。
|
||||
|
||||
用真实模型(models/yolo26n-drone.pt)与一张占位素材(utils/assets/stereo_yolo/
|
||||
drone_sky.png,AI 合成图、非真实飞行数据)驱动,覆盖:
|
||||
|
||||
① 单图检测冒烟:真实推理 → 原生坐标框 + 出口自检(§2.2)
|
||||
② 合成双目对(已知视差):同一无人机 patch 按已知视差贴到左右图 →
|
||||
真检测框喂 StereoRanger.update() → 与解析真值 Z=F·B/D 对比(§6 下游对接)
|
||||
③ 空场景:无目标 → 两目空帧 → 测距端返回空列表(正常空帧,不告警)
|
||||
④ 形状不一致:左右图分辨率不同 → 整帧拒收输出空(§2.2 第 2 条)
|
||||
⑤ 帧周期:双流满负荷计时(concurrency=parallel,§5 并发要求)
|
||||
|
||||
用法(从仓库根运行):
|
||||
python utils/demo_stereo_yolo.py
|
||||
python utils/demo_stereo_yolo.py --frames 20
|
||||
|
||||
⚠ 占位参数 + 合成素材:仅限台架/回放验证,不得接入实飞控制回路(§7 红线)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from stereo_ranger import (
|
||||
Detection,
|
||||
DetectionFrame,
|
||||
RangeRecord,
|
||||
StereoConfig,
|
||||
StereoRanger,
|
||||
load_from_json as load_ranger_config,
|
||||
)
|
||||
from stereo_yolo import StereoYoloConfig, StereoYoloFrontend, load_from_json
|
||||
|
||||
logger = logging.getLogger("demo_stereo_yolo")
|
||||
|
||||
_DEMO_DIR = Path(__file__).resolve().parent # utils/
|
||||
_REPO_ROOT = _DEMO_DIR.parent
|
||||
_DEFAULT_CONFIG = _DEMO_DIR / "config" / "stereo_yolo.json"
|
||||
_RANGER_CONFIG = _DEMO_DIR / "config" / "stereo_ranger.json"
|
||||
_ASSET = _DEMO_DIR / "assets" / "stereo_yolo" / "drone_sky.png"
|
||||
|
||||
# 合成双目对参数:粘贴同一 patch 的整数水平偏移 = 精确视差
|
||||
_DISPARITY_PX = 12 # ⇔ Z ≈ 4.8 m(F·B=57.6 px·m,域内)
|
||||
_LEFT_CENTER_X = 1200
|
||||
_PATCH_Y_CENTER = 560
|
||||
_CANVAS = (1920, 1080)
|
||||
_FEATHER_PX = 32 # 羽化混合宽度:硬边接缝会被检测器误当目标边缘(实测数 px 框偏移)
|
||||
_RANGER_NOW_MS = 1000
|
||||
|
||||
|
||||
def log_scene(title: str) -> None:
|
||||
logger.info("")
|
||||
logger.info("── %s", title)
|
||||
|
||||
|
||||
def describe(record: RangeRecord) -> str:
|
||||
z_text = f"{record.forward_distance_m:7.3f}" if record.forward_distance_m is not None else " — "
|
||||
d_text = f"{record.disparity_px:7.2f}" if record.disparity_px is not None else " — "
|
||||
return (
|
||||
f"[F{record.frame_id:02d}] {record.degradation.name:<12} valid={int(record.valid)} "
|
||||
f"Z={z_text} m D={d_text} px "
|
||||
f"c_mid=({record.center_mid_px[0]:7.1f},{record.center_mid_px[1]:6.1f}) "
|
||||
f"conf={record.confidence:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def log_frame(eye: str, frame: DetectionFrame) -> None:
|
||||
if not frame.detections:
|
||||
logger.info(" %s:无检出", eye)
|
||||
return
|
||||
for detection in frame.detections:
|
||||
logger.info(
|
||||
" %s:框=(%7.1f,%7.1f,%7.1f,%7.1f) conf=%.3f class=%d",
|
||||
eye,
|
||||
detection.x1,
|
||||
detection.y1,
|
||||
detection.x2,
|
||||
detection.y2,
|
||||
detection.confidence,
|
||||
detection.class_id,
|
||||
)
|
||||
|
||||
|
||||
def _best_box(frame: DetectionFrame) -> Optional[Detection]:
|
||||
if not frame.detections:
|
||||
return None
|
||||
return max(frame.detections, key=lambda detection: detection.confidence)
|
||||
|
||||
|
||||
def _crop_patch(asset: np.ndarray, box: Detection, *, margin_x: float = 0.3, margin_y: float = 0.5) -> np.ndarray:
|
||||
width, height = box.width, box.height
|
||||
x1 = max(0, int(box.x1 - margin_x * width))
|
||||
x2 = min(asset.shape[1], int(box.x2 + margin_x * width))
|
||||
y1 = max(0, int(box.y1 - margin_y * height))
|
||||
y2 = min(asset.shape[0], int(box.y2 + margin_y * height))
|
||||
return asset[y1:y2, x1:x2].copy()
|
||||
|
||||
|
||||
def _sky_canvas(asset: np.ndarray) -> np.ndarray:
|
||||
"""用素材顶部天空条拉伸成画布,避免 patch 与背景出现色差硬边。"""
|
||||
strip = asset[0 : max(8, asset.shape[0] // 12), :, :]
|
||||
return cv2.resize(strip, (_CANVAS[0], _CANVAS[1]))
|
||||
|
||||
|
||||
def _paste_blended(canvas: np.ndarray, patch: np.ndarray, center_x: int, center_y: int) -> None:
|
||||
"""羽化混合粘贴:硬边接缝会被检测器当作目标边缘,造成数 px 的框偏移。"""
|
||||
height, width = patch.shape[:2]
|
||||
alpha = np.zeros((height, width), dtype=np.float32)
|
||||
alpha[_FEATHER_PX : height - _FEATHER_PX, _FEATHER_PX : width - _FEATHER_PX] = 1.0
|
||||
alpha = cv2.GaussianBlur(alpha, (0, 0), _FEATHER_PX * 0.5)[..., None]
|
||||
x0, y0 = int(center_x - width / 2), int(center_y - height / 2)
|
||||
region = canvas[y0 : y0 + height, x0 : x0 + width]
|
||||
canvas[y0 : y0 + height, x0 : x0 + width] = (patch * alpha + region * (1.0 - alpha)).astype(np.uint8)
|
||||
|
||||
|
||||
def build_synthetic_pair(frontend: StereoYoloFrontend, asset: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""素材上检出无人机 → 取 patch → 同一 patch 按已知整数视差贴到左右天空画布。"""
|
||||
frame, _ = frontend.detect(asset, asset, frame_id=0)
|
||||
box = _best_box(frame)
|
||||
if box is None:
|
||||
raise RuntimeError("占位素材未检出无人机,请更换 utils/assets/stereo_yolo/drone_sky.png")
|
||||
patch = _crop_patch(asset, box)
|
||||
left_img, right_img = _sky_canvas(asset), _sky_canvas(asset)
|
||||
_paste_blended(left_img, patch, _LEFT_CENTER_X, _PATCH_Y_CENTER)
|
||||
_paste_blended(right_img, patch, _LEFT_CENTER_X - _DISPARITY_PX, _PATCH_Y_CENTER)
|
||||
return left_img, right_img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 场景
|
||||
|
||||
|
||||
def scene_single_image(frontend: StereoYoloFrontend, asset: np.ndarray) -> None:
|
||||
log_scene("① 单图检测冒烟:真实模型 + 占位素材(同一图喂左右目,仅看输出契约)")
|
||||
left_frame, right_frame = frontend.detect(asset, asset, frame_id=1)
|
||||
log_frame("左目", left_frame)
|
||||
log_frame("右目", right_frame)
|
||||
logger.info(
|
||||
" → 框位于输入图原生坐标系(%d×%d);出口自检/白名单已在识别端完成(§2.2 第 6 条)",
|
||||
asset.shape[1],
|
||||
asset.shape[0],
|
||||
)
|
||||
|
||||
|
||||
def scene_synthetic_stereo(
|
||||
frontend: StereoYoloFrontend,
|
||||
ranger: StereoRanger,
|
||||
ranger_cfg: StereoConfig,
|
||||
asset: np.ndarray,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
log_scene(f"② 合成双目对(粘贴真值视差 {_DISPARITY_PX} px)→ StereoRanger.update()")
|
||||
left_img, right_img = build_synthetic_pair(frontend, asset)
|
||||
left_frame, right_frame = frontend.detect(left_img, right_img, frame_id=2)
|
||||
log_frame("左目", left_frame)
|
||||
log_frame("右目", right_frame)
|
||||
|
||||
left_box, right_box = _best_box(left_frame), _best_box(right_frame)
|
||||
if left_box is not None and right_box is not None:
|
||||
disparity = left_box.x1 - right_box.x1
|
||||
vertical_residual = abs(left_box.y1 - right_box.y1)
|
||||
logger.info(
|
||||
" 实测边缘视差 D=x1_L−x1_R=%.2f px(真值 %d px);|Δy|=%.2f px",
|
||||
disparity,
|
||||
_DISPARITY_PX,
|
||||
vertical_residual,
|
||||
)
|
||||
logger.info(
|
||||
" 解析对照 Z=F·B/D=%.3f m(F·B=%.2f px·m)",
|
||||
ranger_cfg.focal_baseline_px_m / disparity,
|
||||
ranger_cfg.focal_baseline_px_m,
|
||||
)
|
||||
|
||||
records = ranger.update(left_frame, right_frame, now_ms=_RANGER_NOW_MS)
|
||||
for record in records:
|
||||
logger.info(" %s", describe(record))
|
||||
if left_box is not None and right_box is not None:
|
||||
logger.info(
|
||||
" → 误差来源:检测框边缘定位抖动(本合成对 |ΔD|=%.2f px),小视差下被 Z=F·B/D 放大(§7.1)",
|
||||
abs(disparity - _DISPARITY_PX),
|
||||
)
|
||||
logger.info(" → 与真值投影法(demo_stereo_ranger)同量级;真值验证待真实双目素材回填(§6)")
|
||||
return left_img, right_img
|
||||
|
||||
|
||||
def scene_empty(frontend: StereoYoloFrontend, ranger: StereoRanger, sky: np.ndarray) -> None:
|
||||
log_scene("③ 空场景:无目标 → 两目空帧 → 测距端空列表(正常空帧,不告警)")
|
||||
left_frame, right_frame = frontend.detect(sky, sky, frame_id=3)
|
||||
records = ranger.update(left_frame, right_frame, now_ms=2000)
|
||||
logger.info(
|
||||
" 两目检出数:%d / %d;测距记录数:%d(应为 0)",
|
||||
len(left_frame.detections),
|
||||
len(right_frame.detections),
|
||||
len(records),
|
||||
)
|
||||
logger.info(" → 无检测即无输出;\"目标丢失就该没有距离\"(§2.5 第 3 条)")
|
||||
|
||||
|
||||
def scene_shape_mismatch(frontend: StereoYoloFrontend, sky: np.ndarray) -> None:
|
||||
log_scene("④ 形状不一致拒收:左 1920×1080 vs 右 1280×720")
|
||||
resized = cv2.resize(sky, (1280, 720))
|
||||
left_frame, right_frame = frontend.detect(sky, resized, frame_id=4)
|
||||
logger.info(" 左右检出数:%d / %d(应为 0 / 0,WARN 节流打印)", len(left_frame.detections), len(right_frame.detections))
|
||||
logger.info(" → 禁止左右各自缩放/填充,否则破坏校正后的极线对齐(§2.2 第 2 条)")
|
||||
|
||||
|
||||
def scene_timing(frontend: StereoYoloFrontend, left_img: np.ndarray, right_img: np.ndarray, frames: int) -> None:
|
||||
log_scene(f"⑤ 帧周期:双流满负荷 {frames} 帧(concurrency=parallel)")
|
||||
for index in range(2): # 预热
|
||||
frontend.detect(left_img, right_img, frame_id=100 + index)
|
||||
started = time.perf_counter()
|
||||
for index in range(frames):
|
||||
frontend.detect(left_img, right_img, frame_id=200 + index)
|
||||
per_frame_ms = (time.perf_counter() - started) / frames * 1000.0
|
||||
logger.info(" 平均 %.1f ms/帧(%.1f fps);30 fps 预算 33.3 ms/帧(§5)", per_frame_ms, 1000.0 / per_frame_ms)
|
||||
logger.info(" → 本值为台架开发机实测(device 配置 None = 自动选择);平台算力预算另见平台工程文档")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
|
||||
|
||||
def _rebase_model_path(cfg: StereoYoloConfig) -> StereoYoloConfig:
|
||||
"""demo 便利:相对 model_path 按仓库根解析(库内规则是启动工作目录)。"""
|
||||
path = Path(cfg.model_path).expanduser()
|
||||
if not path.is_absolute() and not path.is_file():
|
||||
candidate = _REPO_ROOT / path
|
||||
if candidate.is_file():
|
||||
return replace(cfg, model_path=str(candidate))
|
||||
return cfg
|
||||
|
||||
|
||||
def run_demo(config_path: str, frames: int) -> None:
|
||||
cfg = _rebase_model_path(load_from_json(config_path))
|
||||
ranger_cfg = load_ranger_config(str(_RANGER_CONFIG))
|
||||
logger.info("配置来源:%s", config_path)
|
||||
logger.info(
|
||||
"检测前端:model=%s imgsz=%d conf=%.2f iou=%.2f whitelist=%s device=%s concurrency=%s",
|
||||
Path(cfg.model_path).name,
|
||||
cfg.imgsz,
|
||||
cfg.conf_threshold,
|
||||
cfg.iou_threshold,
|
||||
cfg.class_whitelist,
|
||||
cfg.device,
|
||||
cfg.concurrency,
|
||||
)
|
||||
logger.info(
|
||||
"测距几何(唯一来源 utils/config/stereo_ranger.json):F=%.1f px B=%.3f m F·B=%.2f px·m",
|
||||
ranger_cfg.fx_px,
|
||||
ranger_cfg.baseline_m,
|
||||
ranger_cfg.focal_baseline_px_m,
|
||||
)
|
||||
logger.warning("⚠ 占位参数与合成素材:仅限台架/回放验证,不得接入实飞控制回路(§7 红线同源)")
|
||||
|
||||
asset = cv2.imread(str(_ASSET))
|
||||
if asset is None:
|
||||
raise FileNotFoundError(f"占位素材缺失: {_ASSET}")
|
||||
|
||||
frontend = StereoYoloFrontend(cfg)
|
||||
ranger = StereoRanger(ranger_cfg)
|
||||
try:
|
||||
scene_single_image(frontend, asset)
|
||||
left_img, right_img = scene_synthetic_stereo(frontend, ranger, ranger_cfg, asset)
|
||||
scene_empty(frontend, ranger, _sky_canvas(asset))
|
||||
scene_shape_mismatch(frontend, _sky_canvas(asset))
|
||||
scene_timing(frontend, left_img, right_img, frames)
|
||||
finally:
|
||||
frontend.close()
|
||||
logger.info("")
|
||||
logger.info("演示结束:素材为 AI 生成合成图(替换 utils/assets/stereo_yolo/drone_sky.png 可换真实素材)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="双目 YOLO 检测前端演示(真实模型 + 占位素材)")
|
||||
parser.add_argument("--config", default=str(_DEFAULT_CONFIG), help="检测前端配置 JSON 路径")
|
||||
parser.add_argument("--frames", type=int, default=10, help="帧周期场景的测量帧数")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
run_demo(args.config, args.frames)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,207 +0,0 @@
|
||||
"""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_distance(tick: int, normal_ticks: int, period_s: float):
|
||||
"""场景双目距离源:正常阶段有效、距离 30→10 m;之后无效。"""
|
||||
if tick >= normal_ticks:
|
||||
return 0.0, False
|
||||
distance = max(10.0, 30.0 - tick * period_s * 3.0)
|
||||
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)
|
||||
distance_m, distance_valid = simulate_distance(tick, normal_ticks, period_s)
|
||||
|
||||
result = tracker.update(detections)
|
||||
snap = ControlSnapshot(
|
||||
track=track_result_to_state(result),
|
||||
track_time_ms=now_ms, # 每拍取样即打戳(视觉始终新鲜)
|
||||
target_distance_m=distance_m,
|
||||
distance_valid=distance_valid,
|
||||
distance_time_ms=now_ms,
|
||||
attitude_present=True,
|
||||
attitude_time_ms=now_ms, # 模拟姿态源持续可用
|
||||
yaw_rad=0.0, # 机头朝北(NED 旋转恒等)
|
||||
)
|
||||
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()
|
||||
+115
-13
@@ -33,6 +33,8 @@ from visual_track_control.types import (ControlMode, ControlSnapshot,
|
||||
from .config import BridgeConfig
|
||||
from .ground_link import GroundTarget
|
||||
from .guidance import GuidanceInput, GuidanceLaw
|
||||
from .los_consistency import (LosConsistencyConfig, LosConsistencyMonitor,
|
||||
LosStatus)
|
||||
from .pod_serial import PodLink, PodSerial
|
||||
from .protocol import (CarrierAttitude, ControlCommand, TargetGeo, Telemetry,
|
||||
encode_attitude, encode_control)
|
||||
@@ -49,23 +51,39 @@ class PodBridge:
|
||||
"""px4:Px4Gateway(或等价 mock);pod:PodLink(PodSerial 或内存 FakePod)。"""
|
||||
|
||||
def __init__(self, cfg: BridgeConfig, px4: Px4Gateway, pod: PodLink,
|
||||
now_ms_fn=_monotonic_ms, vision_target_fn=None,
|
||||
now_ms_fn=_monotonic_ms,
|
||||
ground_target_fn=None) -> None:
|
||||
self._cfg = cfg
|
||||
self._px4 = px4
|
||||
self._pod = pod
|
||||
self._now_ms = now_ms_fn
|
||||
self._vision_target_fn = vision_target_fn # §2.3 第二判据(可选,S3+ 接入我方视觉)
|
||||
self._ground_target_fn = ground_target_fn # 地面注入坐标访问器(A2,无激光替代表4)
|
||||
self._use_heading_servo = cfg.heading_servo.enabled # 运行时控制律选择(A3 状态机切换)
|
||||
self._guidance = GuidanceLaw(cfg.guidance)
|
||||
self._tracking = TrackingControlLaw(cfg.tracking) # L1 航向伺服(方案 §0)
|
||||
# 目标有效性第二判据:LOS 运动学一致性(取代已废弃的 require_vision)
|
||||
hs = cfg.heading_servo
|
||||
self._los_monitor = LosConsistencyMonitor(LosConsistencyConfig(
|
||||
enabled=hs.los_consistency_enabled,
|
||||
window_ms=hs.los_window_ms,
|
||||
min_yaw_change_deg=hs.los_min_yaw_change_deg,
|
||||
max_residual_deg=hs.los_max_residual_deg))
|
||||
self._los_consistent = True # 最近一次一致性判定(公共门控用)
|
||||
self._los_logged_ok = True # 日志:只在判定翻转时打印
|
||||
self._attitude_period_ms = max(
|
||||
1, int(round(1000.0 / cfg.link.attitude_downlink_hz)))
|
||||
self._last_attitude_ms: Optional[int] = None
|
||||
# 生效律周期:step() 被外部以更高频率调用时(任务编排器统一 20Hz,而
|
||||
# L2 引导律配置为 10Hz),按此节流,避免 dt 相关项/限幅被以双倍速率推进。
|
||||
self._law_period_ms = max(
|
||||
1, int(round(1000.0 / max(1e-6, self._active_frequency_hz()))))
|
||||
self._last_law_ms: Optional[int] = None
|
||||
self._last_law_output = None # 节流期内重发的上一拍输出
|
||||
self._latest_target = None # (TargetGeo, time_ms)
|
||||
self._latest_telemetry = None # (Telemetry, time_ms)
|
||||
self._step_failures = 0 # run() 连续异常计数(告警节流用)
|
||||
self._stopped = False # stop() 幂等标志(真机实测:RTL 与收尾会各调一次)
|
||||
self._landing_stopped = False # 是否已为降落退出 offboard(遥测仍保留)
|
||||
|
||||
# --- 生命周期 -----------------------------------------------------------
|
||||
async def start(self) -> bool:
|
||||
@@ -74,13 +92,22 @@ class PodBridge:
|
||||
if not await self._px4.start_offboard():
|
||||
logger.error("[Bridge] offboard 启动失败,桥接未运行")
|
||||
return False
|
||||
self._stopped = False
|
||||
logger.info("[Bridge] 启动:引导 %.1fHz,姿态下发 %.1fHz",
|
||||
self._cfg.guidance.frequency_hz,
|
||||
self._cfg.link.attitude_downlink_hz)
|
||||
return True
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""安全停机:先零速刹车,再停 offboard / 遥测 / 串口。"""
|
||||
"""安全停机:先零速刹车,再停 offboard / 遥测 / 串口。
|
||||
|
||||
幂等:任务编排器在进入 RTL 时停一次,收尾 `_teardown` 还会再停一次
|
||||
(真机在环实测确认)。重复执行会二次关闭串口、二次停 offboard,
|
||||
在 MAVSDK/串口驱动上产生无意义告警,故第二次直接返回。
|
||||
"""
|
||||
if self._stopped:
|
||||
return
|
||||
self._stopped = True
|
||||
try:
|
||||
await self._px4.send_brake_hover(self._now_ms())
|
||||
finally:
|
||||
@@ -91,6 +118,20 @@ class PodBridge:
|
||||
self._tracking.reset()
|
||||
logger.info("[Bridge] 已停止")
|
||||
|
||||
async def landing_stop(self) -> None:
|
||||
"""降落前停机:零速刹车 + 退出 offboard,但**保留遥测与串口**。
|
||||
|
||||
与 `stop()` 的差别:交给飞控 `land()` 之后仍需遥测来判定落地、记录高度
|
||||
曲线。若此时就 `stop_telemetry()`,落地段的遥测会冻结在最后高度(SITL
|
||||
实测:日志停在 5m,看不到下降)。完整清理留给收尾 `stop()`。
|
||||
"""
|
||||
try:
|
||||
await self._px4.send_brake_hover(self._now_ms())
|
||||
finally:
|
||||
await self._px4.stop_offboard()
|
||||
self._landing_stopped = True
|
||||
logger.info("[Bridge] 已退出 offboard(保留遥测用于落地判定)")
|
||||
|
||||
# --- 主循环 -------------------------------------------------------------
|
||||
async def run(self, stop_event: asyncio.Event) -> None:
|
||||
"""按引导频率循环 step(),直到 stop_event 置位。
|
||||
@@ -133,8 +174,16 @@ class PodBridge:
|
||||
self._use_heading_servo = enabled
|
||||
if enabled:
|
||||
self._tracking.reset()
|
||||
# 进入 L1:LOS 一致性历史从零开始(避免上一段跟踪的样本污染本次判定)
|
||||
self._los_monitor.reset()
|
||||
self._los_consistent = True
|
||||
self._los_logged_ok = True
|
||||
else:
|
||||
self._guidance.reset()
|
||||
# 切律后按新律频率重算节流周期,并允许立即出第一拍
|
||||
self._law_period_ms = max(
|
||||
1, int(round(1000.0 / max(1e-6, self._active_frequency_hz()))))
|
||||
self._last_law_ms = None
|
||||
|
||||
async def step(self, now_ms: int):
|
||||
"""单控制周期(显式传入时钟,便于确定性单测)。返回 ControlOutput。
|
||||
@@ -142,19 +191,43 @@ class PodBridge:
|
||||
按运行时选择的控制律在 L1(TrackingControlLaw,吊舱角度直入)与
|
||||
L2(GuidanceLaw,表4/注入坐标地理引导)间切换(方案 §7)。
|
||||
"""
|
||||
# 链路维持先做:poll 吊舱 + 按 link.attitude_downlink_hz 下发表3 姿态。
|
||||
# 这两件事与生效律频率无关(吊舱解算依赖 表3,目标门控依赖遥测新鲜度)。
|
||||
self._ingest_pod_frames(now_ms)
|
||||
|
||||
own = self._px4.own_state()
|
||||
self._downlink_attitude(own, now_ms)
|
||||
|
||||
# 生效律节流:外部(任务编排器)统一 20Hz 调用,而 L2 引导律配置为 10Hz。
|
||||
# 未到下一拍时**重发上一拍输出**(而非刹车),否则会与本拍的 GOTO 指令打架。
|
||||
if (self._last_law_ms is not None
|
||||
and now_ms - self._last_law_ms < self._law_period_ms
|
||||
and self._last_law_output is not None):
|
||||
await self._dispatch(self._last_law_output, now_ms)
|
||||
return self._last_law_output
|
||||
|
||||
self._last_law_ms = now_ms
|
||||
if self._use_heading_servo:
|
||||
out = self._tracking.update(self._build_snapshot(own, now_ms), now_ms)
|
||||
else:
|
||||
out = self._guidance.update(
|
||||
self._build_guidance_input(own, now_ms), now_ms)
|
||||
await self._dispatch(out, now_ms)
|
||||
self._last_law_output = out
|
||||
return out
|
||||
|
||||
async def search_step(self, now_ms: int, yaw_rate_dps: float) -> None:
|
||||
"""搜索周期(SEARCH 态):只维持链路与心跳 + 原地偏航扫视,不出控制律。
|
||||
|
||||
之所以不让 SEARCH 走 step():该态既不用 L2 地理引导(已在参考点,再发
|
||||
速度会漂离),也不用 L1(还没锁定目标,控制律会判丢输出刹车)。此处
|
||||
**仍要 poll 吊舱并下发表3 姿态**——否则搜索期间吊舱遥测超龄、锁定位永远
|
||||
不新鲜,搜到目标也切不进 TRACK。
|
||||
"""
|
||||
self._ingest_pod_frames(now_ms)
|
||||
own = self._px4.own_state()
|
||||
self._downlink_attitude(own, now_ms)
|
||||
await self._px4.send_yaw_rate(yaw_rate_dps)
|
||||
|
||||
# --- 吊舱侧 -------------------------------------------------------------
|
||||
def _ingest_pod_frames(self, now_ms: int) -> None:
|
||||
for frame in self._pod.poll():
|
||||
@@ -239,16 +312,24 @@ class PodBridge:
|
||||
return target
|
||||
|
||||
def pod_locked(self, now_ms: int) -> bool:
|
||||
"""吊舱目标门控(公共接口,供编排层/状态机复用,A3)。"""
|
||||
return self._pod_locked(now_ms)
|
||||
"""目标门控(公共接口,供编排层/状态机复用,A3)。
|
||||
|
||||
= 吊舱 bit2 门控 **AND** LOS 运动学一致性判定。
|
||||
这样"bit2 粘滞但角度冻结"的幽灵目标会让状态机退出 TRACK 重新搜索,
|
||||
而不只是让控制律单方面刹车(否则状态机会一直以为还锁着)。
|
||||
"""
|
||||
return self._pod_locked(now_ms) and self._los_consistent
|
||||
|
||||
def _pod_locked(self, now_ms: int) -> bool:
|
||||
"""吊舱目标门控:表2-2 遥测在 link.telemetry_timeout_ms 内、状态字节1
|
||||
bit2=锁定、且(若 link.require_laser_valid)激光测距有效,才算目标有效。
|
||||
"""吊舱 bit2 门控(**不含** LOS 一致性,供内部合成本拍快照用)。
|
||||
|
||||
表2-2 遥测在 link.telemetry_timeout_ms 内、状态字节1 bit2=锁定、
|
||||
且(若 link.require_laser_valid)激光测距有效,才算"吊舱自报锁定"。
|
||||
|
||||
实测依据:部分吊舱目标丢失后**锁定位置位不复位**(脱靶量也没有无效标志位,
|
||||
恒为 0),仅凭 bit2 会朝过期坐标继续飞;激光无效时吊舱不解算目标经纬度,
|
||||
故此兜底。无激光或激光恒无效的吊舱把 require_laser_valid 置 False。
|
||||
更进一步的"角度是否活体"由 LOS 一致性判据在 _build_snapshot 里叠加。
|
||||
|
||||
从未收到遥测 / 遥测超龄 / 搜索状态 / 激光无效 一律视为目标无效(宁可刹车)。"""
|
||||
tel = self._latest_telemetry
|
||||
@@ -293,17 +374,38 @@ class PodBridge:
|
||||
alpha_deg = 0.0
|
||||
|
||||
tracked = self._pod_locked(now_ms)
|
||||
# §2.3:require_vision 时需我方视觉作第二判据(bit2 粘滞兜底)。未接入视觉源
|
||||
# 时 fail-safe 为 False(无法确认 → 宁可刹车)。
|
||||
if tracked and hs.require_vision:
|
||||
tracked = self._vision_target_fn() if self._vision_target_fn else False
|
||||
# 第二判据:LOS 运动学一致性(独立于 bit2)。
|
||||
# 本吊舱无电机、相机刚性固连机体 → 机头转 Δψ 时真实目标的脱靶量必变 ≈−Δψ;
|
||||
# 机头已明显转动而 β 不动 → 角度冻结/幽灵目标 → 当丢目标处理(fail-safe)。
|
||||
# 仅在"吊舱自报锁定"期间喂样本;解锁即清历史,避免跨段污染。
|
||||
if tracked:
|
||||
status = self._los_monitor.update(
|
||||
now_ms, math.degrees(own.yaw_rad), beta_deg)
|
||||
self._los_consistent = status.consistent
|
||||
if status.frozen:
|
||||
tracked = False
|
||||
if self._los_logged_ok:
|
||||
self._los_logged_ok = False
|
||||
logger.warning(
|
||||
"[Bridge] 吊舱角度与机头转动不一致(Δψ=%.1f° Δβ=%.1f° "
|
||||
"残差=%.1f°,超 %.1f°)→ 判为丢目标",
|
||||
status.delta_yaw_deg, status.delta_beta_deg,
|
||||
status.residual_deg, hs.los_max_residual_deg)
|
||||
elif self._los_consistent and not self._los_logged_ok:
|
||||
self._los_logged_ok = True
|
||||
logger.info("[Bridge] 吊舱角度一致性恢复(残差 %.1f°)",
|
||||
status.residual_deg)
|
||||
else:
|
||||
self._los_monitor.reset()
|
||||
self._los_consistent = True
|
||||
self._los_logged_ok = True
|
||||
|
||||
return ControlSnapshot(
|
||||
track=TrackState(tracked=tracked),
|
||||
track_time_ms=t_ms, # 无遥测(t_ms=0) → 控制律判视觉超龄 → 降级
|
||||
los_beta_deg=beta_deg,
|
||||
los_alpha_deg=alpha_deg,
|
||||
distance_valid=False, # L1 无距离(L2 换 stereo_ranger.forward_distance_m)
|
||||
distance_valid=False, # L1 无距离(距离源接入后改为可用,见 prompt 文档)
|
||||
attitude_present=own.has_attitude,
|
||||
attitude_time_ms=own.time_ms,
|
||||
yaw_rad=own.yaw_rad,
|
||||
|
||||
@@ -67,7 +67,14 @@ class HeadingServoConfig:
|
||||
alpha_from_frame: bool = False # α 是否叠加框架角 frame_pitch_deg
|
||||
alpha_from_off_target: bool = True # α 是否叠加脱靶量 off_target_y_deg
|
||||
vertical_enabled: bool = True # 无人机 L1 开垂直(决策3);false → α 强制 0
|
||||
require_vision: bool = False # true = 需我方视觉作第二判据(S3 起,§2.3)
|
||||
# --- 目标有效性第二判据:LOS 运动学一致性(2026-10-19 取代 require_vision)---
|
||||
# 背景:吊舱已取代双目+YOLO 成为唯一目标源,"我方视觉"不再存在;而 bit2
|
||||
# 丢失后不复位、脱靶量丢失表现为 0。故改用"机头转动 vs 脱靶量变化"的一致性
|
||||
# 作为独立于 bit2 的活体判据(详见 los_consistency.py)。
|
||||
los_consistency_enabled: bool = True
|
||||
los_window_ms: int = 3000 # 比较窗口(毫秒)
|
||||
los_min_yaw_change_deg: float = 20.0 # 窗口内机头至少转这么多才判定(激励门槛)
|
||||
los_max_residual_deg: float = 8.0 # |Δβ+Δψ| 上限,超过判不一致
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -283,8 +290,20 @@ def _load_heading_servo(root: dict) -> HeadingServoConfig:
|
||||
s, "heading_servo", "alpha_from_off_target", c.alpha_from_off_target)
|
||||
c.vertical_enabled = _get_bool(
|
||||
s, "heading_servo", "vertical_enabled", c.vertical_enabled)
|
||||
c.require_vision = _get_bool(
|
||||
s, "heading_servo", "require_vision", c.require_vision)
|
||||
c.los_consistency_enabled = _get_bool(
|
||||
s, "heading_servo", "los_consistency_enabled",
|
||||
c.los_consistency_enabled)
|
||||
c.los_window_ms = int(_positive(
|
||||
_get_integer(s, "heading_servo", "los_window_ms", c.los_window_ms),
|
||||
"heading_servo.los_window_ms"))
|
||||
c.los_min_yaw_change_deg = _in_range(
|
||||
_get_number(s, "heading_servo", "los_min_yaw_change_deg",
|
||||
c.los_min_yaw_change_deg),
|
||||
0.0, 180.0, "heading_servo.los_min_yaw_change_deg")
|
||||
c.los_max_residual_deg = _in_range(
|
||||
_get_number(s, "heading_servo", "los_max_residual_deg",
|
||||
c.los_max_residual_deg),
|
||||
0.0, 180.0, "heading_servo.los_max_residual_deg")
|
||||
return c
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"""ground_link.py —— 地面坐标注入通道(预定流程.md「地面给坐标」)。
|
||||
"""ground_link.py —— 地面站链路(坐标注入 + 控制命令,预定流程.md「地面给坐标」)。
|
||||
|
||||
本吊舱无激光 → 表4 目标经纬度永不出(部署文档 §10.0),目标坐标改由地面站注入。
|
||||
本模块负责接收地面站下发的目标坐标(UDP JSON),维护一份带本地打戳的注入目标
|
||||
缓存,供 PodBridge 的 L2 地理引导**优先消费**(回退表4)。
|
||||
本模块负责接收地面站下发的两类 UDP JSON 消息:
|
||||
* 目标坐标 {"lat":..,"lon":..,"alt":..} → 维护一份带本地打戳的注入目标缓存,
|
||||
供 PodBridge 的 L2 地理引导**优先消费**(回退表4);
|
||||
* 控制命令 {"cmd":"<name>"}(rtl / emergency / pod_*)→ 入命令队列,
|
||||
由 TaskRunner 主循环 drain_commands() 取走并分派。
|
||||
|
||||
设计要点:
|
||||
* UDP 无连接监听,每包 JSON {"lat":..,"lon":..,"alt":..}(可选 "ts" 仅诊断,不用)。
|
||||
* UDP 无连接监听;坐标包可选 "ts" 仅诊断,不用。
|
||||
* 新鲜度用「本地接收时刻」打戳(now_ms_fn 单调时钟),**不信任地面站时钟**
|
||||
(两端时钟不同步);超龄由下游按 GroundInjectConfig.timeout_ms 判无效。
|
||||
* parse_payload 为纯函数(可单测);socket / 事件循环拆到 run(),且 socket
|
||||
延迟导入,模块 import 无副作用(bridge 仅复用 GroundTarget 类型)。
|
||||
* 命令是事件(非状态):队列缓存、满则丢最旧;未知命令名由上层忽略。
|
||||
* parse_payload / parse_message 为纯函数(可单测);socket / 事件循环拆到
|
||||
run(),且 socket 延迟导入,模块 import 无副作用(bridge 仅复用 GroundTarget 类型)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
@@ -42,6 +47,18 @@ class GroundTarget:
|
||||
time_ms: int = 0 # 本地接收时刻(单调毫秒),0 = 从未注入
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundCommand:
|
||||
"""地面站控制命令(事件语义)。
|
||||
|
||||
name 为小写命令名(rtl / emergency / pod_detect_on / …);time_ms 为本地
|
||||
接收时刻(单调毫秒,仅日志/诊断用)。合法性(白名单)由上层分派时判。
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
time_ms: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundInjectConfig:
|
||||
"""地面坐标注入配置(全外置;数值占位,待落地时回填)。"""
|
||||
@@ -53,12 +70,13 @@ class GroundInjectConfig:
|
||||
|
||||
|
||||
class GroundLink:
|
||||
"""地面坐标注入接收器。
|
||||
"""地面站链路接收器(坐标注入 + 控制命令队列)。
|
||||
|
||||
用法(在 runner 外部以 asyncio task 运行):
|
||||
link = GroundLink(cfg, now_ms_fn)
|
||||
task = asyncio.create_task(link.run(stop_event))
|
||||
PodBridge 通过 `ground_target_fn=lambda: link.latest()` 读取注入坐标。
|
||||
PodBridge 通过 `ground_target_fn=lambda: link.latest()` 读取注入坐标;
|
||||
TaskRunner 主循环通过 `drain_commands()` 取控制命令。
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: GroundInjectConfig,
|
||||
@@ -66,6 +84,7 @@ class GroundLink:
|
||||
self._cfg = cfg
|
||||
self._now_ms = now_ms_fn
|
||||
self._target = GroundTarget()
|
||||
self._commands: deque[str] = deque(maxlen=16)
|
||||
|
||||
def latest(self) -> GroundTarget:
|
||||
"""返回当前注入目标(可能 time_ms=0 表示从未注入)。"""
|
||||
@@ -76,6 +95,12 @@ class GroundLink:
|
||||
target.time_ms = self._now_ms()
|
||||
self._target = target
|
||||
|
||||
def drain_commands(self) -> list[str]:
|
||||
"""取走全部待处理命令(FIFO,取后清空)。命令为事件,无新鲜度概念。"""
|
||||
cmds = list(self._commands)
|
||||
self._commands.clear()
|
||||
return cmds
|
||||
|
||||
def is_valid(self, now_ms: int) -> bool:
|
||||
"""注入坐标是否新鲜有效(未注入 / 超龄 → False)。"""
|
||||
t = self._target
|
||||
@@ -84,15 +109,19 @@ class GroundLink:
|
||||
return not is_stale(t.time_ms, now_ms, self._cfg.timeout_ms)
|
||||
|
||||
@staticmethod
|
||||
def parse_payload(payload: bytes) -> Optional[GroundTarget]:
|
||||
"""解析地面站 JSON 包(纯函数,可单测)。返回不含 time_ms 的目标;
|
||||
非法 JSON / 非对象 / 缺 lat 或 lon → None(静默丢弃)。"""
|
||||
def _load_object(payload: bytes) -> Optional[dict]:
|
||||
"""解 JSON 且要求顶层为对象(纯函数)。非法 JSON / 非对象 → None。"""
|
||||
try:
|
||||
obj = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def _target_from_object(obj: dict) -> Optional[GroundTarget]:
|
||||
"""从 dict 提取坐标(缺 lat/lon 或类型错 → None)。"""
|
||||
lat = obj.get("lat")
|
||||
lon = obj.get("lon")
|
||||
if not _is_number(lat) or not _is_number(lon):
|
||||
@@ -103,6 +132,27 @@ class GroundLink:
|
||||
return GroundTarget(latitude_deg=float(lat), longitude_deg=float(lon),
|
||||
abs_alt_m=float(alt))
|
||||
|
||||
@staticmethod
|
||||
def parse_payload(payload: bytes) -> Optional[GroundTarget]:
|
||||
"""解析坐标包(纯函数,可单测)。返回不含 time_ms 的目标;
|
||||
非法 JSON / 非对象 / 缺 lat 或 lon → None(静默丢弃)。"""
|
||||
obj = GroundLink._load_object(payload)
|
||||
if obj is None:
|
||||
return None
|
||||
return GroundLink._target_from_object(obj)
|
||||
|
||||
@staticmethod
|
||||
def parse_message(payload: bytes) -> Optional[GroundTarget | GroundCommand]:
|
||||
"""解析任意地面站包(纯函数):{"cmd":".."} → GroundCommand(优先),
|
||||
否则按坐标包解析;非法包 → None(静默丢弃)。"""
|
||||
obj = GroundLink._load_object(payload)
|
||||
if obj is None:
|
||||
return None
|
||||
cmd = obj.get("cmd")
|
||||
if isinstance(cmd, str) and cmd.strip():
|
||||
return GroundCommand(name=cmd.strip().lower())
|
||||
return GroundLink._target_from_object(obj)
|
||||
|
||||
async def run(self, stop_event: asyncio.Event) -> None:
|
||||
"""UDP 接收循环:每收一包合法 JSON 更新注入目标并本地打戳。"""
|
||||
import socket # 延迟导入:仅真正跑 socket 时才需要
|
||||
@@ -123,13 +173,17 @@ class GroundLink:
|
||||
logger.warning("[GroundLink] 接收异常: %s", exc)
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
target = self.parse_payload(data)
|
||||
if target is None:
|
||||
msg = self.parse_message(data)
|
||||
if msg is None:
|
||||
continue # 非法包静默丢弃(热路径不打日志)
|
||||
target.time_ms = self._now_ms()
|
||||
self._target = target
|
||||
msg.time_ms = self._now_ms()
|
||||
if isinstance(msg, GroundCommand):
|
||||
self._commands.append(msg.name)
|
||||
logger.debug("[GroundLink] 收到命令: %s", msg.name)
|
||||
continue
|
||||
self._target = msg
|
||||
logger.debug("[GroundLink] 更新注入目标 (%f, %f, %fm)",
|
||||
target.latitude_deg, target.longitude_deg,
|
||||
target.abs_alt_m)
|
||||
msg.latitude_deg, msg.longitude_deg,
|
||||
msg.abs_alt_m)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""los_consistency.py —— 吊舱角度「运动学一致性」校验(替代旧的 require_vision)。
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
吊舱协议有两处已知缺陷(部署文档 §10):
|
||||
* bit2 锁定位在目标丢失后**不复位**;
|
||||
* 脱靶量没有无效标志位,**丢失时表现为 0**。
|
||||
只信 bit2 会让飞机朝"过期坐标/幽灵目标"继续飞。
|
||||
|
||||
原设计用「我方双目视觉」作第二判据(`require_vision`)。但后续决定**吊舱取代
|
||||
双目 + YOLO 成为唯一目标源**,于是"我方视觉"不再存在:该判据要么恒 False
|
||||
(链路永远进不了 TRACK),要么指向吊舱自身(自证,零信息量)。
|
||||
|
||||
本模块给出**不依赖任何额外传感器**的替代判据:
|
||||
本吊舱无电机、框架刚性固连机体 → 相机光轴随刚体转动。
|
||||
因此目标在世界系静止时,机头转 Δψ,脱靶量必须变化约 −Δψ。
|
||||
若机头已经明显转动(|Δψ| ≥ 阈值)而脱靶量几乎不动 → 角度冻结/幽灵,
|
||||
判为不一致(fail-safe:当丢目标处理)。
|
||||
|
||||
判别量:`residual = Δβ + Δψ`(β 为右正视线角,ψ 为 NED 航向,均取度)。
|
||||
* 一致:|residual| ≤ max_residual_deg(目标在世界系缓慢移动也会落在余量内)
|
||||
* 不一致:|residual| 超限且 |Δψ| 足够大(否则是"没有激励",不做判断)
|
||||
|
||||
局限(必须在文档里写清):它只能证明「角度是活的」,**不能证明「是那个目标」**。
|
||||
若吊舱锁错了物体但角度正常变化,本判据无法识别 —— 那属于识别问题,需另接
|
||||
独立传感器(双目/其他),不是本模块能覆盖的。
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
|
||||
def wrap_deg_180(deg: float) -> float:
|
||||
"""角度回绕到 [-180, 180)。"""
|
||||
d = math.fmod(deg + 180.0, 360.0)
|
||||
if d < 0.0:
|
||||
d += 360.0
|
||||
return d - 180.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LosConsistencyConfig:
|
||||
"""一致性判据参数(全外置)。"""
|
||||
|
||||
enabled: bool = True # False → 关掉判据(退回"只信 bit2")
|
||||
window_ms: int = 3000 # 比较窗口:用"当前"与"约 window 前"的样本比对
|
||||
min_yaw_change_deg: float = 20.0 # 窗口内机头至少转这么多才做判断(激励门槛)
|
||||
max_residual_deg: float = 8.0 # |Δβ+Δψ| 超过即判不一致
|
||||
history_factor: float = 2.0 # 历史缓冲 = window × factor
|
||||
|
||||
|
||||
@dataclass
|
||||
class LosStatus:
|
||||
"""单次判定结果(供门控与日志)。"""
|
||||
|
||||
consistent: bool = True # 是否一致(默认一致 = 不干扰正常跟踪)
|
||||
excited: bool = False # 本窗口是否达到"激励门槛"(转过足够角度)
|
||||
residual_deg: float = 0.0 # Δβ + Δψ
|
||||
delta_yaw_deg: float = 0.0
|
||||
delta_beta_deg: float = 0.0
|
||||
|
||||
@property
|
||||
def frozen(self) -> bool:
|
||||
"""判为"角度冻结/幽灵":有激励但不一致。"""
|
||||
return self.excited and not self.consistent
|
||||
|
||||
|
||||
class LosConsistencyMonitor:
|
||||
"""按窗口比较 Δβ 与 Δψ,判断吊舱角度是否与机体转动一致。
|
||||
|
||||
用法:每个控制周期喂一次(仅在吊舱报告锁定期间喂),
|
||||
`update(now_ms, yaw_deg, beta_deg)` 返回 `LosStatus`。
|
||||
吊舱解锁时应调用 `reset()`,避免上一次跟踪的历史污染下一次。
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: LosConsistencyConfig | None = None) -> None:
|
||||
self._cfg = cfg or LosConsistencyConfig()
|
||||
self._samples: deque = deque()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._samples.clear()
|
||||
|
||||
def update(self, now_ms: int, yaw_deg: float, beta_deg: float) -> LosStatus:
|
||||
cfg = self._cfg
|
||||
if not cfg.enabled:
|
||||
return LosStatus(consistent=True, excited=False)
|
||||
|
||||
self._samples.append((now_ms, float(yaw_deg), float(beta_deg)))
|
||||
keep_ms = cfg.window_ms * max(1.0, cfg.history_factor)
|
||||
while self._samples and now_ms - self._samples[0][0] > keep_ms:
|
||||
self._samples.popleft()
|
||||
|
||||
# 参考样本:窗口内**最老**的那个
|
||||
ref = None
|
||||
for sample in self._samples:
|
||||
if now_ms - sample[0] <= cfg.window_ms:
|
||||
ref = sample
|
||||
break
|
||||
if ref is None:
|
||||
return LosStatus(consistent=True, excited=False)
|
||||
|
||||
span_ms = now_ms - ref[0]
|
||||
# 历史不足一个窗口的 3/4 时,Δψ 天然偏小,不做判断(避免误判丢目标)
|
||||
if span_ms < cfg.window_ms * 0.75:
|
||||
return LosStatus(consistent=True, excited=False)
|
||||
|
||||
delta_yaw = wrap_deg_180(yaw_deg - ref[1])
|
||||
delta_beta = beta_deg - ref[2]
|
||||
residual = delta_beta + delta_yaw
|
||||
excited = abs(delta_yaw) >= cfg.min_yaw_change_deg
|
||||
consistent = (not excited) or (abs(residual) <= cfg.max_residual_deg)
|
||||
return LosStatus(consistent=consistent, excited=excited,
|
||||
residual_deg=residual, delta_yaw_deg=delta_yaw,
|
||||
delta_beta_deg=delta_beta)
|
||||
@@ -3,6 +3,8 @@
|
||||
职责(桥接的飞控侧):
|
||||
* 订阅 PX4 遥测(position / attitude_euler / gps_info / velocity_ned),缓存为
|
||||
OwnState 快照,供桥接层构造 表3(载机姿态下发) 与 GuidanceInput(引导输入);
|
||||
* 订阅飞控状态文本 status_text()(STATUSTEXT),缓存供任务日志/地面站显示
|
||||
(飞控警告/异常信息);
|
||||
* 复用 visual_track_control.MavsdkCommandGateway 发送 offboard 速度设定点
|
||||
(不重造三分支映射,直接喂 GuidanceLaw 的 ControlOutput)。
|
||||
|
||||
@@ -15,6 +17,7 @@ import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
@@ -23,6 +26,7 @@ from visual_track_control.mavsdk_gateway import MavsdkCommandGateway
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GPS_FIX_3D = 3 # mavsdk GpsFixType.THREE_D_FIX 起视为定位有效
|
||||
_FC_MSG_BUFFER = 64 # 飞控状态文本缓存上限(低频流,仅供日志消费)
|
||||
|
||||
|
||||
def _enum_int(value: Any) -> int:
|
||||
@@ -69,6 +73,7 @@ class Px4Gateway:
|
||||
self._now_ms = now_ms_fn or (lambda: int(time.monotonic() * 1000))
|
||||
self._state = OwnState()
|
||||
self._tasks: list = []
|
||||
self._fc_msgs: deque = deque(maxlen=_FC_MSG_BUFFER)
|
||||
|
||||
# --- 连接(实机)-------------------------------------------------------
|
||||
@classmethod
|
||||
@@ -123,13 +128,25 @@ class Px4Gateway:
|
||||
"""返回当前缓存的快照副本(调用方修改不影响内部状态)。"""
|
||||
return replace(self._state)
|
||||
|
||||
def update_fc_status(self, st: Any) -> None:
|
||||
"""缓存一条飞控状态文本(STATUSTEXT);severity 取枚举名(如 WARNING)。"""
|
||||
sev = getattr(st, "type", None)
|
||||
sev_name = getattr(sev, "name", None) or str(sev or "UNKNOWN")
|
||||
self._fc_msgs.append((sev_name, str(getattr(st, "text", ""))))
|
||||
|
||||
def take_fc_messages(self) -> list:
|
||||
"""取走并清空飞控状态文本(供任务日志落盘;无则返回 [])。"""
|
||||
msgs = list(self._fc_msgs)
|
||||
self._fc_msgs.clear()
|
||||
return msgs
|
||||
|
||||
def _recompute_valid(self) -> None:
|
||||
self._state.position_valid = (self._state.has_position
|
||||
and self._state.fix_type >= _GPS_FIX_3D)
|
||||
|
||||
# --- 遥测订阅(异步胶水)----------------------------------------------
|
||||
async def start_telemetry(self) -> None:
|
||||
"""订阅四路遥测流,后台任务持续刷新缓存。幂等:已订阅则直接返回。"""
|
||||
"""订阅遥测流 + 飞控状态文本,后台任务持续刷新缓存。幂等:已订阅则直接返回。"""
|
||||
if self._tasks:
|
||||
return # 已订阅,避免重复起任务(runner 会先订阅,bridge.start 再触发时跳过)
|
||||
t = self._drone.telemetry
|
||||
@@ -140,6 +157,8 @@ class Px4Gateway:
|
||||
asyncio.ensure_future(self._consume(t.gps_info(), self.update_gps)),
|
||||
asyncio.ensure_future(
|
||||
self._consume(t.velocity_ned(), self.update_velocity_ned)),
|
||||
asyncio.ensure_future(
|
||||
self._consume(t.status_text(), self.update_fc_status)),
|
||||
]
|
||||
logger.info("[Px4Gateway] 遥测订阅已启动")
|
||||
|
||||
@@ -171,5 +190,9 @@ class Px4Gateway:
|
||||
async def send_brake_hover(self, time_boot_ms: Optional[int] = None) -> bool:
|
||||
return await self._cmd.send_brake_hover(time_boot_ms)
|
||||
|
||||
async def send_yaw_rate(self, yaw_rate_dps: float) -> bool:
|
||||
"""原地偏航扫视(SEARCH 态):零速 + 指定角速度,保持 offboard 心跳。"""
|
||||
return await self._cmd.send_yaw_rate(yaw_rate_dps)
|
||||
|
||||
async def stop_offboard(self) -> None:
|
||||
await self._cmd.stop()
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""sitl_support.py —— SITL / 真机入口共用的 MAVSDK 小工具。
|
||||
|
||||
从根目录 `pod_sitl_test.py` 拆出:原先两个现行入口
|
||||
(`task_runner_sitl.py` / `task_runner_real.py`)都从这个"测试脚本"里 import
|
||||
`now_ms` / `wait_until_ready` 等,语义上很别扭。现归位为独立支持模块,
|
||||
入口只依赖它,`l1_sitl_test.py` 则作为纯粹的 L1 集成测试脚本。
|
||||
|
||||
内容(刻意保持无业务逻辑):
|
||||
* `RATE_*`:遥测流速率**唯一来源**(各订阅方共用;PX4 速率由最后一次设置决定)
|
||||
* `now_ms`:单调毫秒时钟(与 PodBridge / TaskRunner 同源)
|
||||
* `TelemetryHub`:后台订阅 NED 位置 + 欧拉角,供 VirtualPod 几何合成
|
||||
* `wait_until_ready` / `set_telemetry_rates` / `print_flight_mode`:连接准备与诊断
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
|
||||
from pod_bridge.virtual_pod import CarrierView
|
||||
|
||||
# 遥测流速率(Hz)——**唯一来源**,供各订阅方(Px4Gateway / TelemetryHub)共用。
|
||||
# PX4 的消息速率由最后一次 SET_MESSAGE_INTERVAL 决定,多个订阅方用不同值会互相
|
||||
# 覆盖;速率越高链路负荷越大(实测压出 MAVSDK 回调队列积压告警)。
|
||||
# 取值依据见 set_telemetry_rates()。
|
||||
RATE_ATTITUDE_HZ = 20.0 # 控制律 attitude_stale_ms=300 要用姿态
|
||||
RATE_POSITION_HZ = 10.0 # 起飞到位 / 到参考点距离
|
||||
RATE_POSITION_VELOCITY_NED_HZ = 10.0 # 虚拟吊舱几何(TelemetryHub)
|
||||
RATE_GPS_HZ = 2.0
|
||||
RATE_VELOCITY_HZ = 2.0
|
||||
RATE_IN_AIR_HZ = 2.0
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
"""单调毫秒(与 Px4Gateway / PodBridge / TaskRunner 共用同一时钟源)。"""
|
||||
return int(time.monotonic() * 1000.0)
|
||||
|
||||
|
||||
class TelemetryHub:
|
||||
"""后台订阅 NED 位置/姿态,供 VirtualPod 几何合成与锚点记录。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.yaw_rad = 0.0
|
||||
self.north_m = 0.0
|
||||
self.east_m = 0.0
|
||||
self.down_m = 0.0
|
||||
self.attitude_time_ms = 0
|
||||
self._tasks: list = []
|
||||
|
||||
def start(self, drone) -> None:
|
||||
self._tasks.append(asyncio.create_task(self._attitude_loop(drone)))
|
||||
self._tasks.append(asyncio.create_task(self._position_loop(drone)))
|
||||
|
||||
async def _attitude_loop(self, drone) -> None:
|
||||
async for euler in drone.telemetry.attitude_euler():
|
||||
self.yaw_rad = math.radians(euler.yaw_deg)
|
||||
self.attitude_time_ms = now_ms()
|
||||
|
||||
async def _position_loop(self, drone) -> None:
|
||||
async for pos_vel in drone.telemetry.position_velocity_ned():
|
||||
pos = pos_vel.position
|
||||
self.north_m = pos.north_m
|
||||
self.east_m = pos.east_m
|
||||
self.down_m = pos.down_m
|
||||
|
||||
async def stop(self) -> None:
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
for task in self._tasks:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._tasks.clear()
|
||||
|
||||
def view(self) -> CarrierView:
|
||||
return CarrierView(north_m=self.north_m, east_m=self.east_m,
|
||||
down_m=self.down_m, yaw_rad=self.yaw_rad)
|
||||
|
||||
|
||||
async def print_flight_mode(drone):
|
||||
async for flight_mode in drone.telemetry.flight_mode():
|
||||
print(f"[模式] 当前飞行模式: {flight_mode}")
|
||||
return flight_mode
|
||||
|
||||
|
||||
async def wait_until_ready(drone):
|
||||
"""等待连接 + 位置估计就绪(各阶段限时 30 s)。"""
|
||||
print("等待无人机连接...")
|
||||
try:
|
||||
async with asyncio.timeout(30.0):
|
||||
async for state in drone.core.connection_state():
|
||||
if state.is_connected:
|
||||
print("-- 已连接")
|
||||
break
|
||||
except TimeoutError:
|
||||
raise RuntimeError("30 s 内未建立 MAVSDK 连接(检查 SITL 是否在运行)") from None
|
||||
|
||||
print("等待全球位置估计就绪...")
|
||||
try:
|
||||
async with asyncio.timeout(30.0):
|
||||
async for health in drone.telemetry.health():
|
||||
if health.is_global_position_ok and health.is_home_position_ok:
|
||||
print("-- 位置估计 OK")
|
||||
break
|
||||
except TimeoutError:
|
||||
raise RuntimeError("30 s 内位置估计未就绪") from None
|
||||
|
||||
|
||||
async def set_telemetry_rates(drone):
|
||||
"""按**实际消费需求**设定遥测流速率(不是越高越好)。
|
||||
|
||||
取值依据(2026-10-15 复核):
|
||||
* `attitude_euler` 20Hz —— 控制律 `attitude_stale_ms=300` 要用姿态;20Hz 足够
|
||||
* `position` 10Hz —— 任务层只需「起飞到位」与「到参考点距离」,10Hz 足够
|
||||
* `position_velocity_ned` 10Hz —— 仅虚拟吊舱几何用(真机链路不订阅)
|
||||
* `gps_info` / `velocity_ned` / `in_air` 2Hz —— 状态量,变化极慢
|
||||
|
||||
原值(position/attitude 各 50Hz + pvn 30Hz ≈ 130 msg/s)在 CPU 紧张的宿主机上
|
||||
会压出 MAVSDK「User callback queue slow」告警;下调后链路负荷降一个量级。
|
||||
失败沿用默认值并告警。
|
||||
"""
|
||||
for stream, rate in (("position", RATE_POSITION_HZ),
|
||||
("attitude_euler", RATE_ATTITUDE_HZ),
|
||||
("position_velocity_ned", RATE_POSITION_VELOCITY_NED_HZ),
|
||||
("gps_info", RATE_GPS_HZ),
|
||||
("velocity_ned", RATE_VELOCITY_HZ),
|
||||
("in_air", RATE_IN_AIR_HZ)):
|
||||
try:
|
||||
await getattr(drone.telemetry, f"set_rate_{stream}")(rate)
|
||||
except Exception as e:
|
||||
print(f"! 设置 {stream} 速率失败(沿用默认值): {e}")
|
||||
@@ -1,112 +0,0 @@
|
||||
# stereo_ranger(Python 版)
|
||||
|
||||
双目稀疏目标测距:对**同一时刻**左右两目已立体校正图像上的目标检测框做立体
|
||||
几何解算,输出少量目标的**前向距离 Z + 斜距 R(参考)+ 虚拟双目中心方位
|
||||
c_mid + 有效性/置信度**。不做目标识别、图像采集/立体校正、稠密深度。
|
||||
|
||||
规格:`docs/双目测距技术路线.md`(算法与语义权威)。
|
||||
|
||||
⚠ **全部参数为占位值**(焦距/标定待厂商数据):标定与地面真值验证完成前,
|
||||
本模块输出**仅限静态标靶/回放/台架验证,不得作为飞行控制决策依据**(§12.3)。
|
||||
|
||||
## 接口清单
|
||||
|
||||
| 名称 | 说明 |
|
||||
| --- | --- |
|
||||
| `StereoRanger(config=None, matcher=None)` | 主入口;`matcher` 可注入自定义匹配器 |
|
||||
| `.update(left, right, now_ms=0) -> List[RangeRecord]` | 单周期处理(§5 五步),纯计算、无 IO |
|
||||
| `.reset()` | 清空全部运行期状态(滤波/一致性/隔离/节流计数) |
|
||||
| `.config` / `.matcher` | 只读访问(配置与匹配器) |
|
||||
| `StereoConfig` / `load_from_json(path)` | 17 项配置;缺项/未知字段/类型错误/非法取值一律 `ValueError`(fail-fast) |
|
||||
| `.focal_baseline_px_m` / `.z_min_m` / `.z_max_m` / `.z_error_budget_max_m` | 派生只读量(F·B 常量、视差域互推的距离域、误差预算远界) |
|
||||
| `Detection(x1,y1,x2,y2, confidence, class_id, track_id)` | 单框(校正后坐标系;含 `cx/cy/width/height/bbox` 属性) |
|
||||
| `DetectionFrame(frame_id, detections)` | 单目单帧 |
|
||||
| `RangeRecord` | 输出记录:`frame_id / class_id / degradation / valid / track_id / forward_distance_m(Z) / slant_range_m(R) / disparity_px(D) / center_mid_px(c_mid) / reference_box / reference_eye / confidence / timestamp_ms` |
|
||||
| `Degradation` / `Eye` / `DisparitySource` | 枚举:完整/仅方位;左/右目;同侧边缘/中心回退 |
|
||||
| `sample_point` / `SamplePoint` / `select_disparity` / `Disparity` | 取点器(§6.1,纯函数) |
|
||||
| `Matcher`(Protocol)/ `GreedyMatcher` / `MatchPair` | 匹配器(§6.2,接口注入,可换匈牙利等) |
|
||||
| `solve_range` / `RangeSolution` / `virtual_center_px` / `forward_distance_m` | 测距与方位源(§3.2/§6.6,纯函数) |
|
||||
| `evaluate_validity` / `ValidityDecision` / `range_error_abs_m` / `range_error_rel` / `confidence_from_cost` / `DELTA_D_PX` | 有效性三道门与误差模型(§6.4/§7.1,纯函数) |
|
||||
| `ConsistencyMonitor` / `ConsistencyStatus` | 一致性监控(§6.7,每目标状态) |
|
||||
| `DistanceFilter` | 距离低通 + 跳变重置(§6.5,每目标状态) |
|
||||
| `TargetStore` | 目标隔离与老化清理(§4,track_id 优先 + 最近邻兜底) |
|
||||
| `WarnThrottle` | 告警节流(第 1 次 + 每满 100 次,带累计计数) |
|
||||
|
||||
`logger` 名称为 `stereo_ranger`,走标准 `logging`。
|
||||
|
||||
## 单帧流程(`update` 五步)
|
||||
|
||||
1. **同帧契约**:`left.frame_id != right.frame_id` → 拒收整帧、返回空列表、WARN 节流(§2.1);
|
||||
2. **取点**:逐框取同侧(左)边缘 x 与框中心;坐标非有限值的框丢弃并 WARN(不参与解算也不发布);
|
||||
3. **匹配**:硬约束(类别一致、`|Δy| ≤ tau_y_px`、框高相对差 ≤ `tau_h_ratio`)+ 代价升序贪心一一配对,`cost < 1` 才接受;
|
||||
4. **配对目标**:`D → Z = F·B/D`(`D ≤ 0` 判违例)→ `R` 参考 → `c_mid = (cL + cR)/2` → 门① 域内 / 门② 误差预算 / 门③ 一致性 → 有效帧过 `DistanceFilter`,记 `Z_out`;
|
||||
5. **未配对框**:发布仅方位记录(`BEARING_ONLY`,`valid = false`,右目未匹配同样发布);帧末对未出现目标记失效、连续 `filter_reset_frames` 帧未出现整体清理。
|
||||
|
||||
记录顺序:先按左目框顺序(配对→完整 / 未配对→仅方位),再给未配对右目框的仅方位记录。
|
||||
|
||||
## 实现决策(v1.0 冻结,已回填规格)
|
||||
|
||||
规格 `docs/双目测距技术路线.md` 已按下列决策修订(§4/§6.1/§6.2/§6.4/§6.7/
|
||||
§9/§11 均已更新),本节保留决策动机与实现落点,供重写/移植核对。
|
||||
|
||||
1. **越界 = 有效性门,不是配对硬约束**:规格 §6.2 原把"视差在域内"列入
|
||||
硬约束,与 §5/§6.3/§6.4/§2.3 的表述冲突;按 §5 主流程实现并回填——
|
||||
越界目标仍配对、仍发布 `D` 与 `c_mid`(远场方位闭环的输入基础),仅
|
||||
`valid = false`。
|
||||
2. **视差域权威**:`disparity_min_px / disparity_max_px` 为真源,`z_min_m / z_max_m`
|
||||
为派生只读属性;原 §9.2 占位值不自洽(`D_min = 3 px ⇔ 19.2 m ≠ 20 m`),
|
||||
距离域配置项已从规格删除(§6.4/§9.2)。
|
||||
3. **不实现 §6.1 的"左右边缘各算一次取中值"精化**:两支的 median 恒等于均值,
|
||||
即框中心视差,与"同侧边缘"的理由矛盾,规格已删除该项(早期笔误),留
|
||||
v1.1 亚像素精化。
|
||||
4. **补充配置项**(原文 §9 未列、算法需要,规格 §9.3/§9.5 已回填):
|
||||
`consistency_jump_px`(§6.7 视差跳变门限)、`isolation_gate_px`(§4 最近邻
|
||||
兜底的门限)。
|
||||
5. **δD 固定 1 px**:`DELTA_D_PX = 1.0`(§7.1 ⚠占位);标定回填后可参数化。
|
||||
6. **R 与已发布 Z 同基准**:`R = R_raw · z_out / z_raw`(X/Y/Z 均 ∝ Z,精确换基,
|
||||
保证 `R ≥ Z`);无效帧无 `Z_out` 时 `R` 取本帧原始值(诊断参考)。规格
|
||||
§2.2/§6.5 已注明。
|
||||
7. **置信度映射**(§6.4 ⚠占位):`clamp(1 − cost, 0, 1)`;连续跳变 ≥ N 帧乘 0.5
|
||||
(`_CONFIDENCE_DEGRADE_FACTOR`);判无效置 0。
|
||||
8. **一致性只在配对成功帧推进**:仅方位帧没有 D 观测量,不推进 D 序列;但目标
|
||||
未出现的帧会记一次滤波失效(§6.5 失效清理),两条状态机职责不混。
|
||||
9. **取点回退规则**:边缘视差越窗且中心视差在域内 → 回退中心
|
||||
(`CENTER_FALLBACK` + WARN);两者均越窗 → 保留边缘值供诊断与违例告警。
|
||||
10. **track_id 优先路径提前启用**(v1.1 语义,规格 §4 已按此冻结):左右
|
||||
`track_id` 一致且 > 0 时用 `(track, 类别, id)` 作隔离键;最近邻只在无
|
||||
track_id 的状态间匹配,一一归属。
|
||||
11. **§3.2 勘误**:原文 `X_C = X + B/2` 与 §6.6 的符号相反(应为 `X_C = X − B/2`),
|
||||
规格已修正;代码不输出 `X_C`,以 §6.6 恒等式为准。
|
||||
|
||||
## 运行期状态清单(§11.1,漏一个就变行为)
|
||||
|
||||
| 状态 | 所属 | 初值 | 改变时机 |
|
||||
| --- | --- | --- | --- |
|
||||
| 连续异常计数 ×目标 | `ConsistencyMonitor` | 0 | 每帧异常 +1 / 正常帧清零 |
|
||||
| 上次滤波值、初始化标志、连续失效计数 ×目标 | `DistanceFilter` | 无 / false / 0 | 有效帧写入 / 首有效帧置位 / 无效帧 +1 |
|
||||
| 最近位置、最近出现帧 ×目标 | `TargetStore` | 首帧写入 | 每次 `resolve` |
|
||||
| 帧序号 | `StereoRanger` | 0 | 每帧 +1(拒收帧不加) |
|
||||
| 告警计数 ×异常类 | `WarnThrottle` | 0 | 每次异常 |
|
||||
|
||||
## 测试与演示
|
||||
|
||||
```bash
|
||||
cd utils
|
||||
"D:/program file/conda/envs/pytorch/python.exe" -m pytest tests/stereo_ranger -q # 111 条
|
||||
"D:/program file/conda/envs/pytorch/python.exe" -m pytest -q # 全量 220 条
|
||||
python utils/demo_stereo_ranger.py # 六场景合成框对演示
|
||||
```
|
||||
|
||||
构件级测试逐条对应 §11.4(取点器/匹配器/测距/有效性/滤波器/一致性/全流程),
|
||||
**全部用合成框对驱动,无需真实图像**(几何是解析可断言的):
|
||||
|
||||
- 取点:同侧边缘取值、框宽差不进入视差、中心对照、越窗回退、边界含等号、负视差;
|
||||
- 匹配:硬约束各一例、`cost ≥ 1` 拒绝、贪心排序、一一配对;
|
||||
- 测距:`Z = F·B/D` 回收、`D ≤ 0` 违例、`c_mid` 等权平均恒等式、误差平方增长;
|
||||
- 有效性:三道门与边界归类、预算门通过/拦截两侧;
|
||||
- 滤波/一致性:低通收敛、跳变重置不拖尾、失效清理、N 帧降级 / M 帧判无效 / 正常帧清零;
|
||||
- 全流程:单目标、滤波、远场越界仍配对、单目退化、记录顺序、非同帧拒收与节流、
|
||||
空输入、非有限框、track_id 跨大位移保持状态、最近邻越门限新建状态、匹配器注入、`reset`。
|
||||
|
||||
demo 覆盖:① 逼近(三道门依次生效 + 滤波)② 远场域外(方位照常发布)
|
||||
③ 单目退化 ④ 非同帧拒收与节流 ⑤ 一致性降级/判无效 ⑥ 多目标贪心配对。
|
||||
@@ -1,72 +0,0 @@
|
||||
"""stereo_ranger —— 双目稀疏目标测距(规格:docs/双目测距技术路线.md)。
|
||||
|
||||
纯算法包:输入校正后坐标系的同帧左右目检测框,输出目标距离记录(Z/R/D、
|
||||
虚拟双目中心方位、valid/confidence)。不做目标识别、图像采集与立体校正;
|
||||
仅依赖标准库。⚠ 全部数值参数为占位值,标定与地面验证完成前不得作为飞行
|
||||
控制决策依据(§12.3 红线)。
|
||||
"""
|
||||
|
||||
from .config import StereoConfig, load_from_json
|
||||
from .consistency import ConsistencyMonitor, ConsistencyStatus
|
||||
from .distance_filter import DistanceFilter
|
||||
from .geometry import (
|
||||
DELTA_D_PX,
|
||||
RangeSolution,
|
||||
ValidityDecision,
|
||||
confidence_from_cost,
|
||||
evaluate_validity,
|
||||
forward_distance_m,
|
||||
range_error_abs_m,
|
||||
range_error_rel,
|
||||
solve_range,
|
||||
virtual_center_px,
|
||||
)
|
||||
from .matching import GreedyMatcher, MatchPair, Matcher
|
||||
from .sampling import Disparity, SamplePoint, sample_point, select_disparity
|
||||
from .stereo_ranger import StereoRanger
|
||||
from .target_store import TargetStore
|
||||
from .types import (
|
||||
BBoxXYXY,
|
||||
Degradation,
|
||||
Detection,
|
||||
DetectionFrame,
|
||||
DisparitySource,
|
||||
Eye,
|
||||
RangeRecord,
|
||||
)
|
||||
from .warn_throttle import WarnThrottle
|
||||
|
||||
__all__ = [
|
||||
"BBoxXYXY",
|
||||
"DELTA_D_PX",
|
||||
"ConsistencyMonitor",
|
||||
"ConsistencyStatus",
|
||||
"Degradation",
|
||||
"Detection",
|
||||
"DetectionFrame",
|
||||
"Disparity",
|
||||
"DisparitySource",
|
||||
"DistanceFilter",
|
||||
"Eye",
|
||||
"GreedyMatcher",
|
||||
"MatchPair",
|
||||
"Matcher",
|
||||
"RangeRecord",
|
||||
"RangeSolution",
|
||||
"SamplePoint",
|
||||
"StereoConfig",
|
||||
"StereoRanger",
|
||||
"TargetStore",
|
||||
"ValidityDecision",
|
||||
"WarnThrottle",
|
||||
"confidence_from_cost",
|
||||
"evaluate_validity",
|
||||
"forward_distance_m",
|
||||
"load_from_json",
|
||||
"range_error_abs_m",
|
||||
"range_error_rel",
|
||||
"sample_point",
|
||||
"select_disparity",
|
||||
"solve_range",
|
||||
"virtual_center_px",
|
||||
]
|
||||
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.
@@ -1,123 +0,0 @@
|
||||
"""StereoConfig 与 JSON 配置加载(§9 配置参数全量表)。
|
||||
|
||||
全部参数配置外置、构造期一次加载并校验:缺项、类型错误、非法取值一律拒绝
|
||||
进入运行期(fail-fast)。所有默认值均为 ⚠占位值,未整定前不得用于飞行决策
|
||||
(§12.3 红线 1/2)。
|
||||
|
||||
域权威(实现决策):视差域 disparity_min_px / disparity_max_px 为权威,
|
||||
距离域 z_min_m / z_max_m 由 Z = F·B/D 派生为只读属性——域判定使用视差
|
||||
(§11.2 直接观测量),两套阈值不再冗余存储、避免互推舍入不一致(§6.4)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any, Dict
|
||||
|
||||
# JSON 加载时的整数型字段(其余按数值校验)
|
||||
_INT_FIELDS = frozenset({"consistency_N_frames", "consistency_M_frames", "filter_reset_frames"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class StereoConfig:
|
||||
# ---- 几何组(来自标定 P1/P2,§9.1;⚠全部为占位值)----
|
||||
fx_px: float = 960.0
|
||||
fy_px: float = 960.0
|
||||
cx_px: float = 960.0
|
||||
cy_px: float = 540.0
|
||||
baseline_m: float = 0.06
|
||||
|
||||
# ---- 匹配组(§9.2)----
|
||||
tau_y_px: float = 2.0
|
||||
tau_h_ratio: float = 0.25
|
||||
disparity_min_px: float = 3.0
|
||||
disparity_max_px: float = 57.6 # ⚠占位:F=960/B=0.06 下 ⇔ 近界 1.0 m
|
||||
|
||||
# ---- 有效性组(§9.3)----
|
||||
max_delta_z_m: float = 2.0 # 误差预算 δZ(δD = 1px) 上限
|
||||
consistency_N_frames: int = 3 # 连续跳变降置信度的帧数
|
||||
consistency_M_frames: int = 5 # 连续异常判 invalid 的帧数
|
||||
consistency_jump_px: float = 3.0 # ⚠补充项:§9 未列,§6.7 的视差跳变门限
|
||||
|
||||
# ---- 滤波组(§9.4)----
|
||||
smooth_alpha: float = 0.3
|
||||
jump_reset_m: float = 5.0
|
||||
filter_reset_frames: int = 5
|
||||
|
||||
# ---- 目标隔离(§4:类别 + 框位置最近邻;⚠补充项)----
|
||||
isolation_gate_px: float = 50.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require(self.fx_px > 0.0, "fx_px", "必须为正")
|
||||
_require(self.fy_px > 0.0, "fy_px", "必须为正")
|
||||
_require(self.cx_px >= 0.0, "cx_px", "必须非负")
|
||||
_require(self.cy_px >= 0.0, "cy_px", "必须非负")
|
||||
_require(self.baseline_m > 0.0, "baseline_m", "必须为正")
|
||||
_require(self.tau_y_px > 0.0, "tau_y_px", "必须为正")
|
||||
_require(self.tau_h_ratio > 0.0, "tau_h_ratio", "必须为正")
|
||||
_require(
|
||||
0.0 < self.disparity_min_px < self.disparity_max_px,
|
||||
"disparity_min_px/disparity_max_px",
|
||||
"须满足 0 < 下限 < 上限",
|
||||
)
|
||||
_require(self.max_delta_z_m > 0.0, "max_delta_z_m", "必须为正")
|
||||
_require(self.consistency_N_frames >= 1, "consistency_N_frames", "必须 ≥ 1")
|
||||
_require(
|
||||
self.consistency_M_frames >= self.consistency_N_frames,
|
||||
"consistency_M_frames",
|
||||
"必须 ≥ consistency_N_frames",
|
||||
)
|
||||
_require(self.consistency_jump_px > 0.0, "consistency_jump_px", "必须为正")
|
||||
_require(0.0 < self.smooth_alpha <= 1.0, "smooth_alpha", "须落在 (0, 1]")
|
||||
_require(self.jump_reset_m > 0.0, "jump_reset_m", "必须为正")
|
||||
_require(self.filter_reset_frames >= 1, "filter_reset_frames", "必须 ≥ 1")
|
||||
_require(self.isolation_gate_px > 0.0, "isolation_gate_px", "必须为正")
|
||||
|
||||
@property
|
||||
def focal_baseline_px_m(self) -> float:
|
||||
"""F·B:构造期一次算好的常量,热路径只做乘除(§11.1)。"""
|
||||
return self.fx_px * self.baseline_m
|
||||
|
||||
@property
|
||||
def z_min_m(self) -> float:
|
||||
"""近界(由 disparity_max_px 派生)。"""
|
||||
return self.focal_baseline_px_m / self.disparity_max_px
|
||||
|
||||
@property
|
||||
def z_max_m(self) -> float:
|
||||
"""远界(由 disparity_min_px 派生)。"""
|
||||
return self.focal_baseline_px_m / self.disparity_min_px
|
||||
|
||||
@property
|
||||
def z_error_budget_max_m(self) -> float:
|
||||
"""误差预算门限对应的最远距离 sqrt(δZ_max·F·B)(δD = 1px,§7.2)。"""
|
||||
return math.sqrt(self.max_delta_z_m * self.focal_baseline_px_m)
|
||||
|
||||
|
||||
def load_from_json(path: str) -> StereoConfig:
|
||||
"""从 JSON 文件构建配置;缺项/未知字段/类型错误/非法取值一律抛 ValueError。"""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data: Dict[str, Any] = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("配置非法: 顶层须为 JSON 对象")
|
||||
known = {item.name for item in fields(StereoConfig)}
|
||||
unknown = sorted(set(data) - known)
|
||||
if unknown:
|
||||
raise ValueError(f"未知配置字段: {unknown}")
|
||||
missing = sorted(known - set(data))
|
||||
if missing:
|
||||
raise ValueError(f"缺少配置字段: {missing}")
|
||||
for name, value in data.items():
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"配置类型非法: {name} 须为数值")
|
||||
if name in _INT_FIELDS:
|
||||
if not isinstance(value, int):
|
||||
raise ValueError(f"配置类型非法: {name} 须为整数")
|
||||
elif not isinstance(value, (int, float)):
|
||||
raise ValueError(f"配置类型非法: {name} 须为数值")
|
||||
return StereoConfig(**data)
|
||||
|
||||
|
||||
def _require(condition: bool, field_name: str, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(f"配置非法: {field_name} {message}")
|
||||
@@ -1,74 +0,0 @@
|
||||
"""一致性监控(§6.7):时序层面质量自检,每目标独立状态。
|
||||
|
||||
机载激光移除后无外部距离基准,飞行中只能靠自身时序一致性监控数据质量:
|
||||
**只能降级、不能纠偏**(绝对精度靠地面静态验证)。匹配层面的质量(|Δy|
|
||||
残差、框高一致性、代价门限)由匹配硬约束承担,本节只管时序层面。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Hashable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsistencyStatus:
|
||||
"""单目标单帧一致性判定结果(§6.7)。"""
|
||||
|
||||
jump_px: Optional[float] # |D − 上次 D|;首帧无参考为 None
|
||||
abnormal: bool # 本帧是否跳变超门限
|
||||
streak: int # 连续异常帧数(含本帧,正常帧清零)
|
||||
confidence_degraded: bool # streak ≥ N:置信度降级
|
||||
invalid: bool # streak ≥ M:质量语义判无效
|
||||
|
||||
|
||||
class ConsistencyMonitor:
|
||||
"""每目标:上次视差 + 连续异常计数(§11.1 必须持久化的状态)。"""
|
||||
|
||||
def __init__(self, jump_px: float, degrade_frames: int, invalid_frames: int) -> None:
|
||||
if jump_px <= 0.0:
|
||||
raise ValueError("jump_px 必须为正")
|
||||
if degrade_frames < 1:
|
||||
raise ValueError("degrade_frames 必须 ≥ 1")
|
||||
if invalid_frames < degrade_frames:
|
||||
raise ValueError("invalid_frames 必须 ≥ degrade_frames")
|
||||
self._jump_px = jump_px
|
||||
self._degrade_frames = degrade_frames
|
||||
self._invalid_frames = invalid_frames
|
||||
self._last_d: Dict[Hashable, float] = {}
|
||||
self._streak: Dict[Hashable, int] = {}
|
||||
|
||||
def advance(self, key: Hashable, disparity_px: float) -> ConsistencyStatus:
|
||||
"""推进目标 key 的一致性判定(配对成功帧每帧一次)。"""
|
||||
last = self._last_d.get(key)
|
||||
if last is None:
|
||||
jump: Optional[float] = None
|
||||
abnormal = False
|
||||
streak = 0
|
||||
else:
|
||||
jump = abs(disparity_px - last)
|
||||
abnormal = jump > self._jump_px
|
||||
streak = self._streak.get(key, 0) + 1 if abnormal else 0
|
||||
self._last_d[key] = disparity_px
|
||||
if streak:
|
||||
self._streak[key] = streak
|
||||
else:
|
||||
self._streak.pop(key, None)
|
||||
return ConsistencyStatus(
|
||||
jump_px=jump,
|
||||
abnormal=abnormal,
|
||||
streak=streak,
|
||||
confidence_degraded=streak >= self._degrade_frames,
|
||||
invalid=streak >= self._invalid_frames,
|
||||
)
|
||||
|
||||
def streak(self, key: Hashable) -> int:
|
||||
"""目标 key 当前连续异常计数。"""
|
||||
return self._streak.get(key, 0)
|
||||
|
||||
def drop(self, key: Hashable) -> None:
|
||||
"""清理目标 key 的状态(目标老化清理时调用)。"""
|
||||
self._last_d.pop(key, None)
|
||||
self._streak.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._last_d.clear()
|
||||
self._streak.clear()
|
||||
@@ -1,68 +0,0 @@
|
||||
"""时间滤波器(§6.5):一阶低通 + 单帧跳变重置,每目标独立状态。
|
||||
|
||||
- 单帧毛刺由跳变重置处理,持续性异常由一致性监控(§6.7)处理,两者职责
|
||||
不同不得合并;
|
||||
- 滤波只在**有效帧**之间进行,无效/缺席帧不参与平滑;α = 1 等效不滤波;
|
||||
- 连续失效(无效或缺席)达 filter_reset_frames 帧清空状态,下次有效帧重新
|
||||
初始化(防止用陈旧状态平滑新测量)。
|
||||
"""
|
||||
|
||||
from typing import Dict, Hashable, Optional
|
||||
|
||||
|
||||
class DistanceFilter:
|
||||
"""每目标:上次滤波值 + 连续失效计数(§11.1 必须持久化的状态)。"""
|
||||
|
||||
def __init__(self, alpha: float, jump_reset_m: float, reset_frames: int) -> None:
|
||||
if not 0.0 < alpha <= 1.0:
|
||||
raise ValueError("alpha 须落在 (0, 1]")
|
||||
if jump_reset_m <= 0.0:
|
||||
raise ValueError("jump_reset_m 必须为正")
|
||||
if reset_frames < 1:
|
||||
raise ValueError("reset_frames 必须 ≥ 1")
|
||||
self._alpha = alpha
|
||||
self._jump_reset_m = jump_reset_m
|
||||
self._reset_frames = reset_frames
|
||||
self._values: Dict[Hashable, float] = {}
|
||||
self._invalid_streak: Dict[Hashable, int] = {}
|
||||
|
||||
def advance(self, key: Hashable, z_m: float) -> float:
|
||||
"""有效帧推进:未初始化→直接置值;跳变超门限→重置;否则一阶低通。"""
|
||||
previous = self._values.get(key)
|
||||
self._invalid_streak.pop(key, None)
|
||||
if previous is None:
|
||||
self._values[key] = z_m
|
||||
return z_m
|
||||
if abs(z_m - previous) > self._jump_reset_m:
|
||||
self._values[key] = z_m # 直接重置而非滤波,防止拖尾
|
||||
return z_m
|
||||
smoothed = previous + self._alpha * (z_m - previous)
|
||||
self._values[key] = smoothed
|
||||
return smoothed
|
||||
|
||||
def invalidate(self, key: Hashable) -> None:
|
||||
"""无效/缺席帧:连续失效达门限后清空该目标滤波状态(§6.5 记失效)。"""
|
||||
if key not in self._values:
|
||||
return
|
||||
streak = self._invalid_streak.get(key, 0) + 1
|
||||
if streak >= self._reset_frames:
|
||||
self._values.pop(key, None)
|
||||
self._invalid_streak.pop(key, None)
|
||||
else:
|
||||
self._invalid_streak[key] = streak
|
||||
|
||||
def value(self, key: Hashable) -> Optional[float]:
|
||||
"""目标 key 当前滤波值;未初始化/已清空为 None。"""
|
||||
return self._values.get(key)
|
||||
|
||||
def invalid_streak(self, key: Hashable) -> int:
|
||||
"""目标 key 当前连续失效计数(内部状态,供测试/诊断)。"""
|
||||
return self._invalid_streak.get(key, 0)
|
||||
|
||||
def drop(self, key: Hashable) -> None:
|
||||
self._values.pop(key, None)
|
||||
self._invalid_streak.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._values.clear()
|
||||
self._invalid_streak.clear()
|
||||
@@ -1,84 +0,0 @@
|
||||
"""测距计算与有效性判定(§3.2/§6.4/§7.1):纯函数、无状态。
|
||||
|
||||
Z = F·B/D(前向分量,距离通道唯一消费量,§10.5);X/Y 以左目系表达;
|
||||
R = sqrt(X² + Y² + Z²) 仅参考输出;c_mid = 两目框中心等权平均(虚拟双目
|
||||
中心,§6.6,逐点精确、无近似)。
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .config import StereoConfig
|
||||
from .types import Detection
|
||||
|
||||
DELTA_D_PX = 1.0 # 误差预算的δD 基准(§7.2 定义 δZ(δD = 1px))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RangeSolution:
|
||||
"""测距解算结果(Z 为前向分量,X/Y 属左目系,§3.2)。"""
|
||||
|
||||
z_m: float
|
||||
x_m: float
|
||||
y_m: float
|
||||
r_m: float # 斜距(参考值,不进控制回路)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidityDecision:
|
||||
"""有效性判定结果(§6.4 三道门的前两道;门③一致性由调用方合流)。"""
|
||||
|
||||
in_domain: bool # ① 域内:D ∈ [D_min, D_max]
|
||||
budget_ok: bool # ② 误差预算:δZ(δD = 1px) ≤ δZ_max
|
||||
valid: bool # ① 且 ②
|
||||
|
||||
|
||||
def forward_distance_m(disparity_px: float, config: StereoConfig) -> float:
|
||||
"""Z = F·B/D;调用方须先判 D ≤ 0 物理违例(§6.3/§11.2 除零纪律)。"""
|
||||
if disparity_px <= 0.0:
|
||||
raise ValueError(f"视差须为正(先判违例再除): D={disparity_px}")
|
||||
return config.focal_baseline_px_m / disparity_px
|
||||
|
||||
|
||||
def solve_range(left_box: Detection, right_box: Detection, disparity_px: float, config: StereoConfig) -> RangeSolution:
|
||||
"""由视差与参考目(左目)框中心解算 (Z, X, Y, R)(§3.2)。"""
|
||||
z_m = forward_distance_m(disparity_px, config)
|
||||
x_m = (left_box.cx - config.cx_px) * z_m / config.fx_px
|
||||
y_m = (left_box.cy - config.cy_px) * z_m / config.fy_px
|
||||
r_m = math.sqrt(x_m * x_m + y_m * y_m + z_m * z_m)
|
||||
return RangeSolution(z_m=z_m, x_m=x_m, y_m=y_m, r_m=r_m)
|
||||
|
||||
|
||||
def virtual_center_px(left_box: Detection, right_box: Detection) -> Tuple[float, float]:
|
||||
"""虚拟双目中心 c_mid = (cL + cR)/2(§6.6:等权平均 = 把相机搬到模组中线)。
|
||||
|
||||
与视差取点(同侧边缘)是两个不同的取点,不得混用(§6.6)。
|
||||
"""
|
||||
return ((left_box.cx + right_box.cx) / 2.0, (left_box.cy + right_box.cy) / 2.0)
|
||||
|
||||
|
||||
def range_error_abs_m(z_m: float, config: StereoConfig, delta_d_px: float = DELTA_D_PX) -> float:
|
||||
"""绝对误差估计 δZ ≈ Z²·δD/(F·B)(§7.1)。"""
|
||||
return z_m * z_m * delta_d_px / config.focal_baseline_px_m
|
||||
|
||||
|
||||
def range_error_rel(z_m: float, config: StereoConfig, delta_d_px: float = DELTA_D_PX) -> float:
|
||||
"""相对误差估计 δZ/Z ≈ Z·δD/(F·B)(§7.1)。"""
|
||||
return z_m * delta_d_px / config.focal_baseline_px_m
|
||||
|
||||
|
||||
def evaluate_validity(disparity_px: float, z_m: Optional[float], config: StereoConfig) -> ValidityDecision:
|
||||
"""有效性门①②(§6.4):域内 + 误差预算;z_m=None(D ≤ 0)时预算判失败。"""
|
||||
in_domain = config.disparity_min_px <= disparity_px <= config.disparity_max_px
|
||||
budget_ok = (
|
||||
z_m is not None
|
||||
and math.isfinite(z_m)
|
||||
and range_error_abs_m(z_m, config) <= config.max_delta_z_m
|
||||
)
|
||||
return ValidityDecision(in_domain=in_domain, budget_ok=budget_ok, valid=in_domain and budget_ok)
|
||||
|
||||
|
||||
def confidence_from_cost(cost: float) -> float:
|
||||
"""匹配代价 → 置信度基值:线性映射 clamp(1 − cost, 0, 1)(§6.4 ⚠占位映射)。"""
|
||||
return min(1.0, max(0.0, 1.0 - cost))
|
||||
@@ -1,82 +0,0 @@
|
||||
"""左右匹配器(§6.2):硬约束 + 代价升序贪心一一配对;接口注入。
|
||||
|
||||
默认 GreedyMatcher 为纯函数(§4 构件表);调用方可通过
|
||||
StereoRanger(matcher=...) 注入匈牙利等替换实现而不动调用方(§10.3)。
|
||||
|
||||
硬约束(任一不满足直接拒绝配对,不进代价排序):类别一致、|Δy| ≤ τ_y、
|
||||
框高相对差 ≤ τ_h。视差域 [D_min, D_max] 不参与配对:越界属有效性门
|
||||
(§6.2 已回填,与 §5 失败路径表/§6.3/§6.4/§2.3 一致)。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Protocol, Sequence
|
||||
|
||||
from .config import StereoConfig
|
||||
from .sampling import SamplePoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchPair:
|
||||
"""一一配对结果:左右下标 + 匹配代价(§6.2)。"""
|
||||
|
||||
left_index: int
|
||||
right_index: int
|
||||
cost: float
|
||||
|
||||
|
||||
class Matcher(Protocol):
|
||||
"""匹配器接口(§4:接口注入,默认贪心)。"""
|
||||
|
||||
def match(
|
||||
self,
|
||||
left: Sequence[SamplePoint],
|
||||
right: Sequence[SamplePoint],
|
||||
config: StereoConfig,
|
||||
) -> List[MatchPair]:
|
||||
...
|
||||
|
||||
|
||||
class GreedyMatcher:
|
||||
"""硬约束过滤 + 代价升序贪心:cost < 1 才接受,一一配对。"""
|
||||
|
||||
def match(
|
||||
self,
|
||||
left: Sequence[SamplePoint],
|
||||
right: Sequence[SamplePoint],
|
||||
config: StereoConfig,
|
||||
) -> List[MatchPair]:
|
||||
tau_y = config.tau_y_px
|
||||
tau_h = config.tau_h_ratio
|
||||
candidates = []
|
||||
for i, point_l in enumerate(left):
|
||||
if not point_l.finite:
|
||||
continue
|
||||
for j, point_r in enumerate(right):
|
||||
if not point_r.finite:
|
||||
continue
|
||||
if point_l.box.class_id != point_r.box.class_id:
|
||||
continue
|
||||
dy = abs(point_l.center_y - point_r.center_y)
|
||||
if dy > tau_y:
|
||||
continue
|
||||
height_max = max(point_l.box.height, point_r.box.height)
|
||||
if height_max <= 0.0:
|
||||
continue
|
||||
dh_ratio = abs(point_l.box.height - point_r.box.height) / height_max
|
||||
if dh_ratio > tau_h:
|
||||
continue
|
||||
cost = dy / tau_y + dh_ratio / tau_h
|
||||
if cost >= 1.0:
|
||||
continue
|
||||
candidates.append((cost, i, j))
|
||||
candidates.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||
used_left = set()
|
||||
used_right = set()
|
||||
pairs: List[MatchPair] = []
|
||||
for cost, i, j in candidates:
|
||||
if i in used_left or j in used_right:
|
||||
continue
|
||||
pairs.append(MatchPair(left_index=i, right_index=j, cost=cost))
|
||||
used_left.add(i)
|
||||
used_right.add(j)
|
||||
return pairs
|
||||
@@ -1,73 +0,0 @@
|
||||
"""视差取点器(§6.1):同侧(左)边缘为主方案,边缘视差越窗时回退框中心。
|
||||
|
||||
纯函数、无状态(§4 构件表)。为什么用同侧边缘:同一物理目标在两目中的
|
||||
检测框宽并不严格相等,框中心视差会混入框宽差的一半,同侧边缘视差可抵消
|
||||
框宽项。
|
||||
|
||||
注:§6.1 的"左右两边缘各算一次视差取中值"精化在数学上恒等于框中心视差
|
||||
(两支的 median = 均值),与"中心视差混入框宽差"的理由矛盾,属原文笔误,
|
||||
v1.0 不实现;规格 §6.1 已删除该项并注明(见包 README「实现决策」)。
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import StereoConfig
|
||||
from .types import Detection, DisparitySource
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SamplePoint:
|
||||
"""单目单框取点结果:同侧边缘候选与框中心(§6.1/§6.6)。
|
||||
|
||||
finite=False 表示坐标非有限值:该框不可参与解算,由调用方丢弃并告警。
|
||||
"""
|
||||
|
||||
box: Detection
|
||||
edge_x: float # 同侧(左)边缘 x = box.x1
|
||||
center_x: float
|
||||
center_y: float
|
||||
finite: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Disparity:
|
||||
"""配对后的视差取点结果。"""
|
||||
|
||||
d_px: float
|
||||
source: DisparitySource
|
||||
fallback_used: bool # 边缘视差越窗、回退中心(需告警节流,§6.1)
|
||||
in_domain: bool # d ∈ [D_min, D_max](含边界,§11.2)
|
||||
positive: bool # d > 0(正视差是物理前提,§6.3)
|
||||
|
||||
|
||||
def sample_point(detection: Detection) -> SamplePoint:
|
||||
"""逐框取点(§5 第 ② 步,每目每框一次)。"""
|
||||
finite = all(math.isfinite(value) for value in (detection.x1, detection.y1, detection.x2, detection.y2))
|
||||
return SamplePoint(
|
||||
box=detection,
|
||||
edge_x=detection.x1,
|
||||
center_x=detection.cx,
|
||||
center_y=detection.cy,
|
||||
finite=finite,
|
||||
)
|
||||
|
||||
|
||||
def select_disparity(left: SamplePoint, right: SamplePoint, config: StereoConfig) -> Disparity:
|
||||
"""在同侧边缘视差与框中心视差之间选择(§6.1 主取点 + 越窗回退)。
|
||||
|
||||
规则:边缘视差在域内取边缘;否则若中心视差在域内则回退中心(告警节流);
|
||||
两者均越窗时保留边缘值(供诊断与违例告警),由有效性门判无效。
|
||||
"""
|
||||
d_edge = left.edge_x - right.edge_x
|
||||
if _in_domain(d_edge, config):
|
||||
return Disparity(d_edge, DisparitySource.LEFT_EDGE, False, True, d_edge > 0.0)
|
||||
d_center = left.center_x - right.center_x
|
||||
if _in_domain(d_center, config):
|
||||
return Disparity(d_center, DisparitySource.CENTER_FALLBACK, True, True, d_center > 0.0)
|
||||
d = d_edge if math.isfinite(d_edge) else d_center
|
||||
return Disparity(d, DisparitySource.LEFT_EDGE, False, False, d > 0.0)
|
||||
|
||||
|
||||
def _in_domain(d_px: float, config: StereoConfig) -> bool:
|
||||
return config.disparity_min_px <= d_px <= config.disparity_max_px
|
||||
@@ -1,227 +0,0 @@
|
||||
"""StereoRanger —— 双目稀疏目标测距(规格 docs/双目测距技术路线.md)。
|
||||
|
||||
单周期五步(§5):① 同帧契约检查 → ② 取点 → ③ 左右匹配 →
|
||||
④ 配对目标:测距 + 方位源 + 有效性 + 滤波 → ⑤ 单目退化发布。
|
||||
|
||||
输入为左右目各自、校正后原生坐标系下的检测框;不做目标识别,不做图像
|
||||
采集/立体校正。纯计算单元(§11.3):单周期处理无阻塞、无 IO;同一实例
|
||||
不得并发调用 update()。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from .config import StereoConfig
|
||||
from .geometry import confidence_from_cost, evaluate_validity, solve_range, virtual_center_px
|
||||
from .matching import GreedyMatcher, Matcher, MatchPair
|
||||
from .sampling import SamplePoint, sample_point, select_disparity
|
||||
from .target_store import TargetStore
|
||||
from .types import Degradation, Detection, DetectionFrame, Eye, RangeRecord
|
||||
from .warn_throttle import WarnThrottle
|
||||
|
||||
logger = logging.getLogger("stereo_ranger")
|
||||
|
||||
_CONFIDENCE_DEGRADE_FACTOR = 0.5 # ⚠占位:连续跳变 ≥ N 帧的置信度降级系数(§6.4)
|
||||
|
||||
|
||||
class StereoRanger:
|
||||
"""双目稀疏目标测距器:每帧调用一次 update(),传入同帧左右目检测结果。"""
|
||||
|
||||
def __init__(self, config: Optional[StereoConfig] = None, matcher: Optional[Matcher] = None) -> None:
|
||||
self._config = config if config is not None else StereoConfig()
|
||||
self._matcher = matcher if matcher is not None else GreedyMatcher()
|
||||
self._store = TargetStore(self._config)
|
||||
self._warn = WarnThrottle()
|
||||
self._frame_index = 0
|
||||
|
||||
@property
|
||||
def config(self) -> StereoConfig:
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def matcher(self) -> Matcher:
|
||||
return self._matcher
|
||||
|
||||
def reset(self) -> None:
|
||||
"""清空全部运行期状态(滤波 / 一致性 / 隔离 / 节流计数)。"""
|
||||
self._store.clear()
|
||||
self._warn.clear()
|
||||
self._frame_index = 0
|
||||
|
||||
def update(self, left: DetectionFrame, right: DetectionFrame, now_ms: int = 0) -> List[RangeRecord]:
|
||||
"""单周期处理(§5):返回本帧记录列表。
|
||||
|
||||
顺序:先按左目框顺序给出各自记录(配对→完整记录 / 未配对→仅方位),
|
||||
再给出未配对的右目框的仅方位记录;左目存在时记录数与左目框数一致。
|
||||
"""
|
||||
config = self._config
|
||||
if left.frame_id != right.frame_id:
|
||||
self._warn_emit(
|
||||
"frame_mismatch",
|
||||
"左右帧标识不一致(L=%s, R=%s),拒收本帧",
|
||||
left.frame_id,
|
||||
right.frame_id,
|
||||
)
|
||||
return []
|
||||
|
||||
self._frame_index += 1
|
||||
frame_index = self._frame_index
|
||||
left_points = self._drop_invalid([sample_point(det) for det in left.detections])
|
||||
right_points = self._drop_invalid([sample_point(det) for det in right.detections])
|
||||
|
||||
pairs = self._matcher.match(left_points, right_points, config)
|
||||
pair_of_left = {pair.left_index: pair for pair in pairs}
|
||||
paired_right = {pair.right_index for pair in pairs}
|
||||
|
||||
records: List[RangeRecord] = []
|
||||
for index, point_l in enumerate(left_points):
|
||||
pair = pair_of_left.get(index)
|
||||
if pair is None:
|
||||
records.append(self._bearing_only_record(point_l, left.frame_id, now_ms, Eye.LEFT))
|
||||
continue
|
||||
records.append(
|
||||
self._complete_record(
|
||||
point_l,
|
||||
right_points[pair.right_index],
|
||||
pair,
|
||||
left.frame_id,
|
||||
now_ms,
|
||||
frame_index,
|
||||
)
|
||||
)
|
||||
for index, point_r in enumerate(right_points):
|
||||
if index in paired_right:
|
||||
continue
|
||||
records.append(self._bearing_only_record(point_r, left.frame_id, now_ms, Eye.RIGHT))
|
||||
|
||||
self._store.end_frame(frame_index)
|
||||
return records
|
||||
|
||||
# ---- 内部实现 ----
|
||||
|
||||
def _complete_record(
|
||||
self,
|
||||
point_l: SamplePoint,
|
||||
point_r: SamplePoint,
|
||||
pair: MatchPair,
|
||||
frame_id: int,
|
||||
now_ms: int,
|
||||
frame_index: int,
|
||||
) -> RangeRecord:
|
||||
"""配对成功的记录:测距 + 方位源 + 有效性(门①②)+ 一致性(门③)+ 滤波。"""
|
||||
config = self._config
|
||||
box_l = point_l.box
|
||||
box_r = point_r.box
|
||||
disparity = select_disparity(point_l, point_r, config)
|
||||
center_mid = virtual_center_px(box_l, box_r)
|
||||
|
||||
if disparity.fallback_used:
|
||||
self._warn_emit("center_fallback", "边缘视差越窗,回退框中心(框宽差可能混入视差)")
|
||||
if not disparity.positive:
|
||||
self._warn_emit("disparity_violation", "视差违例 D=%.2f px(须为正),该目标判无效", disparity.d_px)
|
||||
elif not disparity.in_domain:
|
||||
self._warn_emit(
|
||||
"disparity_violation",
|
||||
"视差超界 D=%.2f px(域 [%.2f, %.2f]),该目标判无效",
|
||||
disparity.d_px,
|
||||
config.disparity_min_px,
|
||||
config.disparity_max_px,
|
||||
)
|
||||
|
||||
key = self._store.resolve(
|
||||
box_l.class_id,
|
||||
_shared_track_id(box_l, box_r),
|
||||
center_mid,
|
||||
frame_index,
|
||||
)
|
||||
status = self._store.consistency.advance(key, disparity.d_px)
|
||||
if status.invalid:
|
||||
self._warn_emit(
|
||||
"consistency_invalid",
|
||||
"连续 %d 帧视差跳变超门限(%.1f px),质量判无效",
|
||||
status.streak,
|
||||
config.consistency_jump_px,
|
||||
)
|
||||
|
||||
z_raw = config.focal_baseline_px_m / disparity.d_px if disparity.positive else None
|
||||
solution = solve_range(box_l, box_r, disparity.d_px, config) if z_raw is not None else None
|
||||
decision = evaluate_validity(disparity.d_px, z_raw, config)
|
||||
valid = decision.valid and not status.invalid
|
||||
|
||||
confidence = confidence_from_cost(pair.cost)
|
||||
if status.confidence_degraded:
|
||||
confidence *= _CONFIDENCE_DEGRADE_FACTOR
|
||||
if status.invalid:
|
||||
confidence = 0.0
|
||||
|
||||
if valid:
|
||||
z_out = self._store.distance_filter.advance(key, z_raw)
|
||||
else:
|
||||
self._store.distance_filter.invalidate(key)
|
||||
z_out = None
|
||||
|
||||
# 参考斜距与已发布的前向距离同基准:X/Y/Z 均 ∝ Z,按 z_out/z_raw 缩放即精确换基
|
||||
slant_range_m = None
|
||||
if solution is not None:
|
||||
slant_range_m = solution.r_m if z_out is None else solution.r_m * (z_out / z_raw)
|
||||
|
||||
return RangeRecord(
|
||||
frame_id=frame_id,
|
||||
class_id=box_l.class_id,
|
||||
degradation=Degradation.COMPLETE,
|
||||
valid=valid,
|
||||
track_id=box_l.track_id if box_l.track_id is not None else box_r.track_id,
|
||||
forward_distance_m=z_out,
|
||||
slant_range_m=slant_range_m,
|
||||
disparity_px=disparity.d_px,
|
||||
center_mid_px=center_mid,
|
||||
reference_box=box_l.bbox,
|
||||
reference_eye=Eye.LEFT,
|
||||
confidence=confidence,
|
||||
timestamp_ms=now_ms,
|
||||
)
|
||||
|
||||
def _bearing_only_record(
|
||||
self,
|
||||
point: SamplePoint,
|
||||
frame_id: int,
|
||||
now_ms: int,
|
||||
eye: Eye,
|
||||
) -> RangeRecord:
|
||||
"""单目退化记录(§5 第 ⑤ 步):仅方位,valid = false,无距离。"""
|
||||
detection = point.box
|
||||
return RangeRecord(
|
||||
frame_id=frame_id,
|
||||
class_id=detection.class_id,
|
||||
degradation=Degradation.BEARING_ONLY,
|
||||
valid=False,
|
||||
track_id=detection.track_id,
|
||||
center_mid_px=(detection.cx, detection.cy),
|
||||
reference_box=detection.bbox,
|
||||
reference_eye=eye,
|
||||
confidence=detection.confidence,
|
||||
timestamp_ms=now_ms,
|
||||
)
|
||||
|
||||
def _drop_invalid(self, points: Sequence[SamplePoint]) -> List[SamplePoint]:
|
||||
"""丢弃坐标非有限值的框(不可参与匹配,也不发布记录),告警节流。"""
|
||||
kept: List[SamplePoint] = []
|
||||
for point in points:
|
||||
if point.finite:
|
||||
kept.append(point)
|
||||
else:
|
||||
self._warn_emit("invalid_box", "检测框坐标非法(非有限值),已丢弃该框")
|
||||
return kept
|
||||
|
||||
def _warn_emit(self, category: str, message: str, *args: object) -> None:
|
||||
"""异常日志(§5 节流规范:第 1 次 + 每满 100 次,带累计计数)。"""
|
||||
if not self._warn.should_emit(category):
|
||||
return
|
||||
logger.warning("[%s] " + message + "(累计 %d 次)", category, *args, self._warn.count(category))
|
||||
|
||||
|
||||
def _shared_track_id(box_l: Detection, box_r: Detection) -> Optional[int]:
|
||||
"""左右目轨道关联一致且有效时返回该 id(目标隔离键优先路径,§4)。"""
|
||||
if box_l.track_id is not None and box_l.track_id > 0 and box_l.track_id == box_r.track_id:
|
||||
return box_l.track_id
|
||||
return None
|
||||
@@ -1,119 +0,0 @@
|
||||
"""目标状态隔离(§4):track_id 优先 + 框位置最近邻兜底。
|
||||
|
||||
目标隔离键:track_id 可用(左右一致且 > 0)时优先以 (track, 类别, track_id)
|
||||
为键——上游 target_tracker 已在链路中,规格 §4 已按此冻结;否则回退类别 +
|
||||
框位置最近邻(isolation_gate_px 门限,见包 README「实现决策」)。
|
||||
|
||||
连续 filter_reset_frames 帧未再出现的目标状态整体清理(位置 + 距离滤波 +
|
||||
一致性),避免最近邻把新目标接到陈旧状态上。
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Hashable, Optional, Tuple
|
||||
|
||||
from .config import StereoConfig
|
||||
from .consistency import ConsistencyMonitor
|
||||
from .distance_filter import DistanceFilter
|
||||
|
||||
_KIND_TRACK = "track"
|
||||
_KIND_NEAREST = "nn"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TargetState:
|
||||
class_id: int
|
||||
track_id: Optional[int]
|
||||
center_px: Tuple[float, float]
|
||||
last_seen_frame: int
|
||||
|
||||
|
||||
class TargetStore:
|
||||
"""每目标状态容器:隔离键解析 + 帧末老化清理(§11.1 状态清单)。"""
|
||||
|
||||
def __init__(self, config: StereoConfig) -> None:
|
||||
self._config = config
|
||||
self._distance_filter = DistanceFilter(
|
||||
config.smooth_alpha, config.jump_reset_m, config.filter_reset_frames
|
||||
)
|
||||
self._consistency = ConsistencyMonitor(
|
||||
config.consistency_jump_px, config.consistency_N_frames, config.consistency_M_frames
|
||||
)
|
||||
self._states: Dict[Hashable, _TargetState] = {}
|
||||
self._next_nearest_id = 0
|
||||
|
||||
@property
|
||||
def distance_filter(self) -> DistanceFilter:
|
||||
return self._distance_filter
|
||||
|
||||
@property
|
||||
def consistency(self) -> ConsistencyMonitor:
|
||||
return self._consistency
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""当前存活的目标状态数。"""
|
||||
return len(self._states)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
class_id: int,
|
||||
track_id: Optional[int],
|
||||
center_px: Tuple[float, float],
|
||||
frame_index: int,
|
||||
) -> Hashable:
|
||||
"""解析(或新建)目标 key,并刷新该目标最近位置与出现帧。"""
|
||||
if track_id is not None and track_id > 0:
|
||||
key: Hashable = (_KIND_TRACK, class_id, track_id)
|
||||
self._states[key] = _TargetState(class_id, track_id, center_px, frame_index)
|
||||
return key
|
||||
key = self._nearest_key(class_id, center_px, frame_index)
|
||||
if key is None:
|
||||
self._next_nearest_id += 1
|
||||
key = (_KIND_NEAREST, class_id, self._next_nearest_id)
|
||||
self._states[key] = _TargetState(class_id, None, center_px, frame_index)
|
||||
else:
|
||||
state = self._states[key]
|
||||
state.center_px = center_px
|
||||
state.last_seen_frame = frame_index
|
||||
return key
|
||||
|
||||
def end_frame(self, frame_index: int) -> None:
|
||||
"""帧末收尾:未出现的目标记滤波失效,到期整体清理三类状态。"""
|
||||
stale: List[Hashable] = []
|
||||
for key, state in self._states.items():
|
||||
if state.last_seen_frame == frame_index:
|
||||
continue
|
||||
self._distance_filter.invalidate(key)
|
||||
if frame_index - state.last_seen_frame >= self._config.filter_reset_frames:
|
||||
stale.append(key)
|
||||
for key in stale:
|
||||
del self._states[key]
|
||||
self._distance_filter.drop(key)
|
||||
self._consistency.drop(key)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._states.clear()
|
||||
self._distance_filter.clear()
|
||||
self._consistency.clear()
|
||||
self._next_nearest_id = 0
|
||||
|
||||
def _nearest_key(
|
||||
self,
|
||||
class_id: int,
|
||||
center_px: Tuple[float, float],
|
||||
frame_index: int,
|
||||
) -> Optional[Hashable]:
|
||||
"""同类别、isolation_gate_px 内、本帧尚未被占用的最近状态。"""
|
||||
gate = self._config.isolation_gate_px
|
||||
best_key: Optional[Hashable] = None
|
||||
best_distance: Optional[float] = None
|
||||
for key, state in self._states.items():
|
||||
if key[0] != _KIND_NEAREST or state.class_id != class_id:
|
||||
continue
|
||||
if state.last_seen_frame == frame_index:
|
||||
continue # 本帧已被其他框接管,一一归属
|
||||
distance = math.hypot(center_px[0] - state.center_px[0], center_px[1] - state.center_px[1])
|
||||
if distance <= gate and (best_distance is None or distance < best_distance):
|
||||
best_key, best_distance = key, distance
|
||||
return best_key
|
||||
@@ -1,107 +0,0 @@
|
||||
"""stereo_ranger 输入输出数据结构(纯值语义)。
|
||||
|
||||
规格:docs/双目测距技术路线.md —— §2 输入输出接口、§3 符号约定、§6.6 方位源。
|
||||
|
||||
约定:所有像素坐标位于**已立体校正**的图像坐标系,检测框已映射回该基准
|
||||
(§2.1 校正契约 / 坐标基准一致)。本包仅依赖标准库。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
BBoxXYXY = Tuple[float, float, float, float]
|
||||
|
||||
|
||||
class Eye(Enum):
|
||||
"""物理目(参考目框来源,§2.2)。"""
|
||||
|
||||
LEFT = auto() # 左目(解算参考系)
|
||||
RIGHT = auto() # 右目
|
||||
|
||||
|
||||
class DisparitySource(Enum):
|
||||
"""视差取点来源(§6.1)。"""
|
||||
|
||||
LEFT_EDGE = auto() # 同侧(左)边缘:v1.0 主方案
|
||||
CENTER_FALLBACK = auto() # 边缘视差越窗后回退框中心
|
||||
|
||||
|
||||
class Degradation(Enum):
|
||||
"""记录退化标记(§2.2)。描述记录的来源形态,质量由 valid 单独表达。"""
|
||||
|
||||
COMPLETE = auto() # 完整:配对成功(距离 + 方位两路都在)
|
||||
BEARING_ONLY = auto() # 仅方位:单目检出,无距离
|
||||
|
||||
|
||||
@dataclass
|
||||
class Detection:
|
||||
"""校正后图像坐标系下的一个检测框(px;x1/y1 左上角,x2/y2 右下角)。"""
|
||||
|
||||
x1: float
|
||||
y1: float
|
||||
x2: float
|
||||
y2: float
|
||||
confidence: float = 1.0
|
||||
class_id: int = 0
|
||||
track_id: Optional[int] = None # 检测侧轨道关联;None = 无(§2.2,v1.0 可无)
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.x2 - self.x1
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
return self.y2 - self.y1
|
||||
|
||||
@property
|
||||
def cx(self) -> float:
|
||||
return (self.x1 + self.x2) / 2.0
|
||||
|
||||
@property
|
||||
def cy(self) -> float:
|
||||
return (self.y1 + self.y2) / 2.0
|
||||
|
||||
@property
|
||||
def bbox(self) -> BBoxXYXY:
|
||||
return (self.x1, self.y1, self.x2, self.y2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionFrame:
|
||||
"""单目一帧:帧标识 + 检测框列表。
|
||||
|
||||
同帧契约(§2.1):左右两帧的 frame_id 必须一致,否则整帧拒收、输出空。
|
||||
"""
|
||||
|
||||
frame_id: int
|
||||
detections: Sequence[Detection] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeRecord:
|
||||
"""目标距离记录(§2.2,字段与文档表格逐项对应)。
|
||||
|
||||
- forward_distance_m(Z):唯一供距离通道消费的量;valid=False 或单目
|
||||
退化时为 None(不得强行估计,§12.3 红线 3);
|
||||
- slant_range_m(R):参考值,不进控制回路;无效但仍可解算时保留原始
|
||||
测量值供诊断;
|
||||
- disparity_px(D):本次视差(调试与交叉校验用);
|
||||
- center_mid_px(c_mid):方位源;配对记录为两目框中心等权平均(虚拟
|
||||
双目中心,§6.6),单目退化为该目框中心;
|
||||
- reference_box / reference_eye:参考目框(配对记录恒为左目)。
|
||||
"""
|
||||
|
||||
frame_id: int
|
||||
class_id: int
|
||||
degradation: Degradation
|
||||
valid: bool
|
||||
track_id: Optional[int] = None
|
||||
forward_distance_m: Optional[float] = None
|
||||
slant_range_m: Optional[float] = None
|
||||
disparity_px: Optional[float] = None
|
||||
center_mid_px: Tuple[float, float] = (0.0, 0.0)
|
||||
reference_box: Optional[BBoxXYXY] = None
|
||||
reference_eye: Eye = Eye.LEFT
|
||||
confidence: float = 0.0
|
||||
timestamp_ms: int = 0
|
||||
@@ -1,30 +0,0 @@
|
||||
"""告警节流器(§4/§5):每类异常"第 1 次 + 每满 100 次"打印,带累计计数。
|
||||
|
||||
高频热路径(每帧数学)不打日志;所有异常日志走本通道,且不落在数学热路径
|
||||
的决策上(§11.3 单周期处理无阻塞、无 IO)。
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class WarnThrottle:
|
||||
"""按异常类独立计数与限频。"""
|
||||
|
||||
def __init__(self, every: int = 100) -> None:
|
||||
if every < 1:
|
||||
raise ValueError("every 必须 ≥ 1")
|
||||
self._every = every
|
||||
self._counts: Dict[str, int] = {}
|
||||
|
||||
def should_emit(self, category: str) -> bool:
|
||||
"""推进该类异常计数并返回本次是否应打印(第 1 次 + 每满 every 次)。"""
|
||||
count = self._counts.get(category, 0) + 1
|
||||
self._counts[category] = count
|
||||
return count == 1 or count % self._every == 0
|
||||
|
||||
def count(self, category: str) -> int:
|
||||
"""该类异常的累计次数。"""
|
||||
return self._counts.get(category, 0)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._counts.clear()
|
||||
@@ -1,109 +0,0 @@
|
||||
# stereo_yolo(Python 版)
|
||||
|
||||
双目 YOLO 检测前端:在左右目图像上**独立运行** YOLO 检测器,输出符合
|
||||
`stereo_ranger` 输入契约的检测框(每目一个 `DetectionFrame`、共用 frame_id)。
|
||||
不做测距、左右目关联与立体校正。
|
||||
|
||||
规格:`docs/双目YOLO目标识别需求说明.md`(接口契约以其为准;几何语义权威
|
||||
`docs/双目测距技术路线.md`)。
|
||||
|
||||
⚠ 全部参数为占位值、demo 素材为 AI 合成图:标定与地面真值验证完成前,
|
||||
仅限台架/回放验证,不得接入实飞控制回路(规格 §7 红线同源)。
|
||||
|
||||
## 接口清单
|
||||
|
||||
| 名称 | 说明 |
|
||||
| --- | --- |
|
||||
| `StereoYoloFrontend(config, detector=None)` | 主入口;`detector` 可注入(接口 `detect(img) -> list[Detection]`,单测用假检测器) |
|
||||
| `.detect(left_img, right_img, frame_id) -> (DetectionFrame, DetectionFrame)` | 单帧检测;BGR ndarray(cv2 约定),输出框位于输入图原生坐标系 |
|
||||
| `.close()` | 释放并发线程池 |
|
||||
| `.config` | 只读配置访问 |
|
||||
| `StereoYoloConfig` / `load_from_json(path)` | 7 项配置;缺项/未知字段/类型错误/非法取值一律 `ValueError`(fail-fast) |
|
||||
| `UltralyticsDetector` | ultralytics YOLO `.pt` 后端(双实例池;`ultralytics` 延迟导入) |
|
||||
| `split_sbs(image) -> (left, right)` | side-by-side 单流裁切(宽须为偶数) |
|
||||
| `Detection` / `DetectionFrame` | 直接复用 `stereo_ranger.types`(纯值语义,§2.1 推荐) |
|
||||
| `WarnThrottle` | 告警节流(第 1 次 + 每满 100 次,带累计计数) |
|
||||
|
||||
`logger` 名称为 `stereo_yolo`,走标准 `logging`。
|
||||
|
||||
## 单帧流程(`detect` 四步)
|
||||
|
||||
1. **形状一致性**:左右图形状不一致 → 整帧拒收输出空 + WARN 节流
|
||||
(§2.2 第 2 条:禁止左右各自缩放/填充);
|
||||
2. **左右推理**:`parallel`(默认,ThreadPoolExecutor(2),§5 硬性要求)或
|
||||
`sequential`(基准对照);单目异常被隔离,该目输出空帧列表(§5 失败处理);
|
||||
3. **出口自检 + 白名单**:丢弃非有限值 / `x2<=x1` / `y2<=y1` 的框
|
||||
(§2.2 第 6 条);`class_whitelist` 非空时过滤类别;
|
||||
4. **封装**:每目一个 `DetectionFrame(frame_id, detections)`,`frame_id`
|
||||
左右共用(§2.3 同帧契约)。
|
||||
|
||||
## 实现决策(v1.0 冻结)
|
||||
|
||||
1. **ultralytics 直跑 `.pt`**(项目拍板口径):letterbox、输出解码、NMS、
|
||||
框映射回原生坐标系全部由库内完成;识别端只负责阈值外置、出口自检与
|
||||
契约封装。
|
||||
2. **双实例池**:ultralytics 单实例非线程安全,左右并发各持一份模型
|
||||
(§4 并发策略的"双实例"形态)。
|
||||
3. **无跨帧状态**:前端不持有任何历史(无追踪、无平滑、无 Coast)——
|
||||
结构性满足"只送本帧原生实测框"(§2.5 第 1 条)。
|
||||
4. **形状不一致不 raise**:按"拒收 + 空帧 + 节流 WARN"处理,与 stereo_ranger
|
||||
非同帧拒收同哲学(不阻塞整体)。
|
||||
5. **几何参数单一来源**:`stereo_yolo.json` 不存内参/原分辨率(输出框天然在
|
||||
输入图坐标系),F/B/cx/cy 唯一来源是 `utils/config/stereo_ranger.json`,
|
||||
避免两处漂移(§4 配置一致性)。
|
||||
6. **`track_id` 留 `None`**:外部追踪器注入属链路后段(§2.1;v1.1 可选)。
|
||||
|
||||
## 移植注记(C++ 侧需自行复现的库内行为)
|
||||
|
||||
本次为 ultralytics 直跑口径,以下行为在库内完成、C++ 实现必须逐项复现
|
||||
(不一致会在视差取点上引入系统性偏差):
|
||||
|
||||
| 库内行为 | C++ 需复现要点 |
|
||||
| --- | --- |
|
||||
| letterbox | 等比缩放到 `imgsz`、灰边填充(ultralytics 预测默认填充值 114);左右同一套参数(形状一致时天然满足) |
|
||||
| 输出解码 | YOLO26 检测头输出布局与解码(建议导出一份 ONNX 作为参照物;本仓环境:ultralytics 8.4 / torch 2.11 / onnx 1.18) |
|
||||
| NMS | `conf` 先过滤、NMS 用配置 IoU 门限;`max_det` 默认 300 |
|
||||
| 坐标回映 | letterbox 逆变换(scale/pad)到原生分辨率;输出 `x1,y1,x2,y2` 浮点 |
|
||||
| 并发 | 双实例双线程(单实例并发调用不安全) |
|
||||
|
||||
## 配置项(全部外置,占位值)
|
||||
|
||||
| 键 | 占位值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `model_path` | `models/yolo26n-drone.pt` | 相对路径按启动工作目录解析(demo 内按仓库根兜底) |
|
||||
| `imgsz` | 640 | 须为 32 的整数倍(YOLO 步长约束) |
|
||||
| `conf_threshold` | 0.25 | 识别端自身过滤口径(测距端不消费检测置信度,§2.4) |
|
||||
| `iou_threshold` | 0.7 | NMS IoU 门限 |
|
||||
| `class_whitelist` | `null` | 可选白名单;同一目标左右目类别必须一致(§2.2 第 5 条) |
|
||||
| `device` | `null` | null = 自动选择(`cpu` / `cuda:0` / …) |
|
||||
| `concurrency` | `parallel` | `parallel` / `sequential` |
|
||||
|
||||
## 测试与演示
|
||||
|
||||
```bash
|
||||
cd utils
|
||||
"D:/program file/conda/envs/pytorch/python.exe" -m pytest tests/stereo_yolo -q # 36 条
|
||||
python utils/demo_stereo_yolo.py # 真模型 + 占位素材
|
||||
```
|
||||
|
||||
单测全部用注入的假检测器驱动(无模型、无素材、无硬件):frame_id 左右共用、
|
||||
出口自检丢弃退化框、白名单过滤、单目失败隔离、形状不一致拒收、双人屏障
|
||||
断言真并发(串行实现会屏障超时)。
|
||||
|
||||
demo(真模型 + AI 合成素材 `utils/assets/stereo_yolo/drone_sky.png`):
|
||||
① 单图冒烟 → ② 合成双目对(同一 patch 羽化粘贴、已知视差 12 px,实测
|
||||
|ΔD|≈0.3 px、|Δy|≈0.2 px)→ 真检测框喂 `StereoRanger.update()`,记录值与
|
||||
解析值 `Z=F·B/D` 同量级 → ③ 空场景(正常空帧不告警)→ ④ 形状不一致拒收
|
||||
→ ⑤ 帧周期实测(台架开发机 ≈34 ms/帧,占位)。替换素材文件即可换真实图片。
|
||||
|
||||
## 验收对照(规格 §6)
|
||||
|
||||
| 项 | 状态 |
|
||||
| --- | --- |
|
||||
| 坐标回映误差 <0.5 px | 由库内完成,无法构件级单测;以合成对整体校验(|ΔD|≈0.3 px @1080p,含检测边缘噪声);C++ 移植后需实测 |
|
||||
| 垂直对齐 P95 ≤1 px | 合成对实测 |Δy|≈0.2 px(合成口径,非真实校正质量);待真实素材 |
|
||||
| 类别一致性 | 模型单类 `Drone`,合成对天然一致;待真实素材 |
|
||||
| 输出合法性 | ✓ 单测(非有限值/退化框构造用例) |
|
||||
| 输入纯净性 | ✓ 结构性保证(前端无跨帧状态) |
|
||||
| 下游对接 | ✓ demo 场景②;δZ ∝ Z² 趋势由测距端单测覆盖 |
|
||||
| 帧周期 | △ 台架开发机实测 ≈34 ms/帧(30 fps 预算 33.3 ms);待平台实测(P7) |
|
||||
@@ -1,20 +0,0 @@
|
||||
"""stereo_yolo —— 双目 YOLO 检测前端(规格:docs/双目YOLO目标识别需求说明.md)。
|
||||
|
||||
在左右目图像上独立运行 YOLO 检测器,输出**符合 stereo_ranger 输入契约**的
|
||||
检测框列表(每目一个 DetectionFrame);不做测距、左右目关联与立体校正。
|
||||
⚠ 全部参数为占位值:标定与地面验证完成前仅限台架/回放验证,不得接入实飞
|
||||
控制回路(需求说明 §7 红线同源)。
|
||||
"""
|
||||
|
||||
from .config import StereoYoloConfig, load_from_json
|
||||
from .detector import UltralyticsDetector
|
||||
from .frontend import StereoYoloFrontend
|
||||
from .sbs import split_sbs
|
||||
|
||||
__all__ = [
|
||||
"StereoYoloConfig",
|
||||
"StereoYoloFrontend",
|
||||
"UltralyticsDetector",
|
||||
"load_from_json",
|
||||
"split_sbs",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,86 +0,0 @@
|
||||
"""StereoYoloConfig 与 JSON 配置加载(需求说明 §4 配置项需求)。
|
||||
|
||||
全部配置外置、构造期一次加载并校验:缺项、未知字段、类型错误、非法取值
|
||||
一律拒绝进入运行期(fail-fast)。几何参数(F/B/cx/cy/原分辨率)不在此
|
||||
重复存储——输出框位于**输入图像自身的原生坐标系**,几何参数的唯一来源
|
||||
是 utils/config/stereo_ranger.json(§4 配置一致性要求:同一配置源驱动,
|
||||
杜绝两处漂移)。
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
_CONCURRENCY_MODES = ("parallel", "sequential")
|
||||
_INT_FIELDS = frozenset({"imgsz"})
|
||||
_STR_FIELDS = frozenset({"model_path", "concurrency"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class StereoYoloConfig:
|
||||
model_path: str = "models/yolo26n-drone.pt"
|
||||
imgsz: int = 640 # 左右共用(§2.2 第 2 条:同一缩放、同一填充)
|
||||
conf_threshold: float = 0.25 # ⚠占位:识别端自身过滤口径(§2.4)
|
||||
iou_threshold: float = 0.7 # ⚠占位:NMS IoU 门限
|
||||
class_whitelist: Optional[Tuple[int, ...]] = None # None = 不过滤(§4 可选白名单)
|
||||
device: Optional[str] = None # None = 自动选择(cpu / cuda:0 / …)
|
||||
concurrency: str = "parallel" # parallel = 左右并发(§5 硬性要求,串行会吃掉全部预算)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require(bool(self.model_path.strip()), "model_path", "不得为空")
|
||||
_require(
|
||||
self.imgsz >= 32 and self.imgsz % 32 == 0,
|
||||
"imgsz",
|
||||
"须为 32 的整数倍且 ≥ 32(YOLO 步长约束)",
|
||||
)
|
||||
_require(0.0 <= self.conf_threshold <= 1.0, "conf_threshold", "须落在 [0, 1]")
|
||||
_require(0.0 < self.iou_threshold <= 1.0, "iou_threshold", "须落在 (0, 1]")
|
||||
_require(self.concurrency in _CONCURRENCY_MODES, "concurrency", f"须为 {_CONCURRENCY_MODES} 之一")
|
||||
_require(self.device is None or bool(self.device.strip()), "device", "须为非空字符串或 null")
|
||||
if self.class_whitelist is not None:
|
||||
ids = tuple(self.class_whitelist)
|
||||
_require(
|
||||
len(ids) > 0 and all(isinstance(item, int) and not isinstance(item, bool) and item >= 0 for item in ids),
|
||||
"class_whitelist",
|
||||
"须为非空非负整数列表或 null",
|
||||
)
|
||||
self.class_whitelist = ids
|
||||
|
||||
|
||||
def load_from_json(path: str) -> StereoYoloConfig:
|
||||
"""从 JSON 文件构建配置;缺项/未知字段/类型错误/非法取值一律抛 ValueError。"""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data: Dict[str, Any] = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("配置非法: 顶层须为 JSON 对象")
|
||||
known = {item.name for item in fields(StereoYoloConfig)}
|
||||
unknown = sorted(set(data) - known)
|
||||
if unknown:
|
||||
raise ValueError(f"未知配置字段: {unknown}")
|
||||
missing = sorted(known - set(data))
|
||||
if missing:
|
||||
raise ValueError(f"缺少配置字段: {missing}")
|
||||
for name, value in data.items():
|
||||
if name == "class_whitelist":
|
||||
if value is not None and not (
|
||||
isinstance(value, list)
|
||||
and all(isinstance(item, int) and not isinstance(item, bool) for item in value)
|
||||
):
|
||||
raise ValueError("配置类型非法: class_whitelist 须为整数数组或 null")
|
||||
elif name == "device":
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("配置类型非法: device 须为字符串或 null")
|
||||
elif name in _STR_FIELDS:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"配置类型非法: {name} 须为字符串")
|
||||
elif name in _INT_FIELDS:
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"配置类型非法: {name} 须为整数")
|
||||
elif isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"配置类型非法: {name} 须为数值")
|
||||
return StereoYoloConfig(**data)
|
||||
|
||||
|
||||
def _require(condition: bool, field_name: str, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(f"配置非法: {field_name} {message}")
|
||||
@@ -1,78 +0,0 @@
|
||||
"""UltralyticsDetector —— ultralytics YOLO .pt 直载的检测后端。
|
||||
|
||||
⚠ 移植注记(C++ 侧需自行复现,详见 README):letterbox(缩放 + 填充到
|
||||
imgsz)、输出张量解码、NMS、框映射回输入图原生坐标系,本例由 ultralytics
|
||||
库内完成;移植时必须逐项复现同一几何(步长对齐、填充方式、IoU 门限、
|
||||
原生坐标回映),否则视差取点会引入系统性偏差。
|
||||
"""
|
||||
|
||||
import queue
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from stereo_ranger.types import Detection
|
||||
|
||||
from .config import StereoYoloConfig
|
||||
|
||||
|
||||
class UltralyticsDetector:
|
||||
"""双实例池:ultralytics 单实例非线程安全,左右并发各持一份(§3/§4)。
|
||||
|
||||
接口约定(可注入替换):``detect(image) -> list[Detection]``,
|
||||
image 为 BGR ndarray(cv2 约定),返回框位于该图原生坐标系。
|
||||
"""
|
||||
|
||||
def __init__(self, config: StereoYoloConfig) -> None:
|
||||
self._cfg = config
|
||||
self._model_path = _resolve_model_path(config.model_path)
|
||||
self._pool: "queue.SimpleQueue" = queue.SimpleQueue()
|
||||
from ultralytics import YOLO # 延迟导入:仅真实推理付加载成本,单测注入假检测器(§6)
|
||||
|
||||
for _ in range(2):
|
||||
self._pool.put(YOLO(str(self._model_path)))
|
||||
|
||||
def detect(self, image) -> List[Detection]:
|
||||
model = self._pool.get()
|
||||
try:
|
||||
results = model.predict(
|
||||
source=image,
|
||||
imgsz=self._cfg.imgsz,
|
||||
conf=self._cfg.conf_threshold,
|
||||
iou=self._cfg.iou_threshold,
|
||||
device=self._cfg.device,
|
||||
verbose=False,
|
||||
)
|
||||
finally:
|
||||
self._pool.put(model)
|
||||
if not results:
|
||||
return []
|
||||
return _result_to_detections(results[0])
|
||||
|
||||
|
||||
def _result_to_detections(result) -> List[Detection]:
|
||||
boxes = getattr(result, "boxes", None)
|
||||
if boxes is None or len(boxes) == 0:
|
||||
return []
|
||||
xyxy = boxes.xyxy.cpu().numpy()
|
||||
conf = boxes.conf.cpu().numpy()
|
||||
cls = boxes.cls.cpu().numpy()
|
||||
return [
|
||||
Detection(
|
||||
float(box[0]),
|
||||
float(box[1]),
|
||||
float(box[2]),
|
||||
float(box[3]),
|
||||
confidence=float(score),
|
||||
class_id=int(class_id),
|
||||
)
|
||||
for box, score, class_id in zip(xyxy, conf, cls)
|
||||
]
|
||||
|
||||
|
||||
def _resolve_model_path(raw: str) -> Path:
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"模型权重不存在: {path}(model_path 相对路径按启动工作目录解析)")
|
||||
return path.resolve()
|
||||
@@ -1,113 +0,0 @@
|
||||
"""StereoYoloFrontend —— 双目检测前端主类(需求说明 §2/§3)。
|
||||
|
||||
单帧流程:左右图(已校正、原生分辨率)→ 左右并发检测 → 出口自检 + 类别
|
||||
白名单 → 每目封装 DetectionFrame(共用 frame_id,§2.3 同帧契约)。
|
||||
|
||||
前端**无跨帧状态**:没有历史就没有外推,结构性满足"只送本帧原生实测框"
|
||||
的输入纯净性硬约束(§2.5 第 1 条)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from stereo_ranger.types import Detection, DetectionFrame
|
||||
|
||||
from .config import StereoYoloConfig
|
||||
from .detector import UltralyticsDetector
|
||||
from .warn_throttle import WarnThrottle
|
||||
|
||||
logger = logging.getLogger("stereo_yolo")
|
||||
|
||||
_EYE_LABELS = ("左目", "右目")
|
||||
|
||||
|
||||
class StereoYoloFrontend:
|
||||
"""图像 → 符合 stereo_ranger 输入契约的检测框(§2 接口契约)。
|
||||
|
||||
``detector`` 可注入替换:接口为 ``detect(img) -> list[Detection]``
|
||||
(单测用假检测器驱动,§6 输出合法性/失败隔离用例无需真实模型)。
|
||||
"""
|
||||
|
||||
def __init__(self, config: StereoYoloConfig, detector: Optional[object] = None) -> None:
|
||||
self._cfg = config
|
||||
self._detector = detector if detector is not None else UltralyticsDetector(config)
|
||||
self._throttle = WarnThrottle()
|
||||
self._executor = (
|
||||
ThreadPoolExecutor(max_workers=2, thread_name_prefix="stereo_yolo")
|
||||
if config.concurrency == "parallel"
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def config(self) -> StereoYoloConfig:
|
||||
return self._cfg
|
||||
|
||||
def detect(
|
||||
self,
|
||||
left_img: np.ndarray,
|
||||
right_img: np.ndarray,
|
||||
frame_id: int,
|
||||
) -> Tuple[DetectionFrame, DetectionFrame]:
|
||||
"""左右各输出一个 DetectionFrame;两帧共用同一 frame_id(§2.3)。"""
|
||||
if left_img.shape != right_img.shape:
|
||||
self._warn(
|
||||
"shape_mismatch",
|
||||
f"左右图尺寸不一致({left_img.shape} vs {right_img.shape}),整帧拒收输出空(§2.2 第 2 条)",
|
||||
)
|
||||
return DetectionFrame(frame_id, ()), DetectionFrame(frame_id, ())
|
||||
|
||||
if self._executor is not None:
|
||||
left_future = self._executor.submit(self._detect_eye, left_img, _EYE_LABELS[0])
|
||||
right_future = self._executor.submit(self._detect_eye, right_img, _EYE_LABELS[1])
|
||||
left, right = left_future.result(), right_future.result()
|
||||
else:
|
||||
left = self._detect_eye(left_img, _EYE_LABELS[0])
|
||||
right = self._detect_eye(right_img, _EYE_LABELS[1])
|
||||
|
||||
return DetectionFrame(frame_id, tuple(left)), DetectionFrame(frame_id, tuple(right))
|
||||
|
||||
def close(self) -> None:
|
||||
"""释放并发线程池(前端不再使用时调用)。"""
|
||||
if self._executor is not None:
|
||||
self._executor.shutdown(wait=True)
|
||||
|
||||
# ---------------- 内部 ----------------
|
||||
|
||||
def _detect_eye(self, image: np.ndarray, eye_label: str) -> List[Detection]:
|
||||
try:
|
||||
raw = self._detector.detect(image)
|
||||
except Exception as exc: # 单目失败隔离:不阻塞整体,该目输出空帧列表(§5 失败处理)
|
||||
self._warn(f"infer_failed:{eye_label}", f"{eye_label}推理失败,该目输出空帧列表:{exc!r}")
|
||||
return []
|
||||
return self._sanitize(raw)
|
||||
|
||||
def _sanitize(self, detections: Sequence[Detection]) -> List[Detection]:
|
||||
"""出口自检(§2.2 第 6 条):丢弃非有限值 / x2<=x1 / y2<=y1 的框;白名单过滤。"""
|
||||
kept: List[Detection] = []
|
||||
dropped = 0
|
||||
whitelist = self._cfg.class_whitelist
|
||||
for detection in detections:
|
||||
if not _is_valid_box(detection):
|
||||
dropped += 1
|
||||
continue
|
||||
if whitelist is not None and detection.class_id not in whitelist:
|
||||
continue
|
||||
kept.append(detection)
|
||||
if dropped:
|
||||
self._warn("degenerate_box", f"丢弃退化检测框 {dropped} 个(非有限值或 x2<=x1/y2<=y1)")
|
||||
return kept
|
||||
|
||||
def _warn(self, category: str, message: str) -> None:
|
||||
if self._throttle.should_emit(category):
|
||||
logger.warning("%s(累计 %d 次)", message, self._throttle.count(category))
|
||||
|
||||
|
||||
def _is_valid_box(detection: Detection) -> bool:
|
||||
values = (detection.x1, detection.y1, detection.x2, detection.y2)
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
return False
|
||||
return detection.x2 > detection.x1 and detection.y2 > detection.y1
|
||||
@@ -1,16 +0,0 @@
|
||||
"""side-by-side 单流裁切(需求说明 §2.3:同帧拼接形态推荐优先支持)。
|
||||
|
||||
解码一次、裁切为左右图,天然同曝光、天然同帧;宽须为偶数。
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def split_sbs(image: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""左右各半裁切,返回 (左图, 右图)。"""
|
||||
if image.ndim < 2 or image.shape[1] % 2 != 0:
|
||||
raise ValueError(f"side-by-side 图像宽度须为偶数,实际 shape={image.shape}")
|
||||
half = int(image.shape[1] // 2)
|
||||
return image[:, :half].copy(), image[:, half:].copy()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""告警节流器(需求说明 §5 失败处理):每类异常"第 1 次 + 每满 100 次"打印。
|
||||
|
||||
语义与 stereo_ranger 的节流器一致,但按前端/测距解耦要求(§7)不跨包复用。
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class WarnThrottle:
|
||||
"""按异常类独立计数与限频。"""
|
||||
|
||||
def __init__(self, every: int = 100) -> None:
|
||||
if every < 1:
|
||||
raise ValueError("every 必须 ≥ 1")
|
||||
self._every = every
|
||||
self._counts: Dict[str, int] = {}
|
||||
|
||||
def should_emit(self, category: str) -> bool:
|
||||
"""推进该类异常计数并返回本次是否应打印(第 1 次 + 每满 every 次)。"""
|
||||
count = self._counts.get(category, 0) + 1
|
||||
self._counts[category] = count
|
||||
return count == 1 or count % self._every == 0
|
||||
|
||||
def count(self, category: str) -> int:
|
||||
"""该类异常的累计次数。"""
|
||||
return self._counts.get(category, 0)
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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 条移植测试锁定。
|
||||
@@ -1,15 +0,0 @@
|
||||
"""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.
@@ -1,111 +0,0 @@
|
||||
"""单目标 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
|
||||
@@ -1,269 +0,0 @@
|
||||
"""多目标追踪过滤器(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
|
||||
@@ -1,110 +0,0 @@
|
||||
"""轨道(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
|
||||
@@ -1,9 +1,20 @@
|
||||
"""task_runner —— 任务编排器(预定流程.md 的天空侧任务级状态机)。
|
||||
|
||||
把已通的 L1(航向伺服 TrackingControlLaw)与 L2(地理引导 GuidanceLaw)按任务阶段
|
||||
编排成一条可运行的全流程:WAIT → ARM_TAKEOFF → GOTO → TRACK → APPROACH → RTL。
|
||||
编排成一条可运行的全流程:
|
||||
|
||||
WAIT → ARM_TAKEOFF → GOTO → SEARCH → TRACK → RTL
|
||||
|
||||
* GOTO :L2 地理引导飞向**参考点**(地面给的坐标是搜索起点,不是终点);
|
||||
* SEARCH :到达参考点后原地偏航扫视,靠吊舱/视觉找目标;超时未发现 → RTL;
|
||||
* TRACK :L1 角度直入 + 无距离降级(slow_approach 缓慢靠近);超时 → RTL;
|
||||
* RTL :自定义返航(回起飞点 → 恢复初始航向 → 垂直降落),不爬高;
|
||||
异常/未起飞等情形兜底退回 PX4 原生 RTL。
|
||||
|
||||
模块划分:
|
||||
* state_machine.py 纯逻辑任务状态机(无 IO,可单测)
|
||||
* runner.py 编排器(MAVSDK 飞行编排 + 状态机驱动 + PodBridge 闭环,A3)
|
||||
* runner.py 编排器(MAVSDK 飞行编排 + 状态机驱动 + PodBridge 闭环)
|
||||
* return_home.py 自定义返航纯逻辑(HomingController:转场/航向/高度)
|
||||
* pod_control.py 真机吊舱识别/锁定指令下发(0x07/0x0F/0x0E/0x08)
|
||||
* log.py 结构化任务日志(事件 JSONL + 遥测 CSV + 简报)
|
||||
"""
|
||||
|
||||
+126
-5
@@ -6,15 +6,21 @@
|
||||
* 事件日志(JSONL):状态机转移、飞行编排事件(解锁/起飞/返航)、异常/紧急。
|
||||
低频、带语义、结构化,便于 grep / jq 定位「什么时候发生了什么」。
|
||||
* 周期遥测日志(CSV):每控制周期的位姿 / GPS / 目标 / 控制输出,便于回放与画图。
|
||||
* 简洁任务简报(task_brief.log,纯文本):一行一事件(阶段切换/地面站命令/
|
||||
飞控告警)+ 运行结束汇总(时长/最高/最近距目标/状态链),供人工阅读与归档;
|
||||
机器日志(JSONL/CSV)格式不变。
|
||||
|
||||
时间戳:单调毫秒(now_ms,与运行时代码同源)+ 相对时间 t_s(自首个 now_ms 起)。
|
||||
文件 IO 集中在 open / close,热路径只追加一行,不做 flush 决策。
|
||||
文件 IO 集中在 open / close;热路径只追加(并立即 flush 落盘——缓冲期间文件不可见,
|
||||
地面站页面运行中需要读到最新状态与遥测,2026-10-14 实测)。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -60,13 +66,26 @@ class TaskLogConfig:
|
||||
dir: str = "logs" # 输出目录(相对工作目录)
|
||||
event_file: str = "task_events.jsonl" # 事件日志
|
||||
telemetry_file: str = "task_telemetry.csv" # 周期遥测日志
|
||||
brief_file: str = "task_brief.log" # 人类可读任务简报(追加)
|
||||
telemetry_every_n: int = 1 # 每 N 周期记一次遥测(1 = 每周期)
|
||||
telemetry_flush_ms: int = 1000 # 遥测落盘间隔(毫秒);0 = 每行立即 flush
|
||||
|
||||
|
||||
def _monotonic_ms() -> int:
|
||||
return int(time.monotonic() * 1000)
|
||||
|
||||
|
||||
_STATE_CN = {"wait": "待命", "arm_takeoff": "起飞", "goto": "坐标引导",
|
||||
"search": "搜索", "track": "追踪靠近", "approach": "接近悬停",
|
||||
"rtl": "返航"}
|
||||
|
||||
|
||||
def _fmt_t_s(t_s: float) -> str:
|
||||
"""相对时间 → mm:ss.s(简报用)。"""
|
||||
m, s = divmod(max(0.0, float(t_s or 0.0)), 60.0)
|
||||
return f"{int(m):02d}:{s:04.1f}"
|
||||
|
||||
|
||||
class TaskLogger:
|
||||
"""结构化任务日志。用法:
|
||||
|
||||
@@ -86,12 +105,20 @@ class TaskLogger:
|
||||
self._event_fp = None
|
||||
self._tel_fp = None
|
||||
self._tel_writer = None
|
||||
self._brief_fp = None
|
||||
self._t0_ms: Optional[int] = None
|
||||
self._tick = 0
|
||||
self._last_tel_flush_ms: Optional[int] = None # 遥测上次落盘时刻
|
||||
# 简报汇总统计(随遥测行/事件更新,close 时写一行汇总)
|
||||
self._n_events = 0
|
||||
self._last_t_s = 0.0
|
||||
self._max_rel_alt_m: Optional[float] = None
|
||||
self._min_dist_m: Optional[float] = None
|
||||
self._state_chain: list = []
|
||||
|
||||
# --- 生命周期 -----------------------------------------------------------
|
||||
def open(self) -> None:
|
||||
"""创建输出目录并打开两个日志文件;disabled 时跳过。"""
|
||||
"""创建输出目录并打开日志文件;disabled 时跳过。"""
|
||||
if not self._cfg.enabled:
|
||||
return
|
||||
out_dir = Path(self._cfg.dir)
|
||||
@@ -103,11 +130,22 @@ class TaskLogger:
|
||||
self._tel_writer = csv.writer(self._tel_fp)
|
||||
if (out_dir / self._cfg.telemetry_file).stat().st_size == 0:
|
||||
self._tel_writer.writerow(_TELEMETRY_FIELDS)
|
||||
self._brief_fp = (out_dir / self._cfg.brief_file).open(
|
||||
"a", encoding="utf-8")
|
||||
self._brief_fp.write(
|
||||
f"===== 任务开始 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} =====\n")
|
||||
self._last_tel_flush_ms = None
|
||||
self._event_fp.flush()
|
||||
self._tel_fp.flush()
|
||||
self._brief_fp.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
"""flush 并关闭两个日志文件。"""
|
||||
"""写简报汇总并关闭全部日志文件。"""
|
||||
if self._brief_fp is not None:
|
||||
self._brief_fp.write(self._brief_summary() + "\n\n")
|
||||
self._brief_fp.flush()
|
||||
self._brief_fp.close()
|
||||
self._brief_fp = None
|
||||
if self._event_fp is not None:
|
||||
self._event_fp.flush()
|
||||
self._event_fp.close()
|
||||
@@ -120,16 +158,32 @@ class TaskLogger:
|
||||
|
||||
# --- 记录 ---------------------------------------------------------------
|
||||
def log_event(self, now_ms: int, event_type: str, **fields) -> None:
|
||||
"""追加一条 JSONL 事件。"""
|
||||
"""追加一条 JSONL 事件并**立即落盘**(事件低频且语义关键:地面站要实时看阶段)。
|
||||
|
||||
注意:遥测行不在这里 flush——见 log_telemetry 的节流落盘说明。
|
||||
"""
|
||||
if self._event_fp is None:
|
||||
return
|
||||
record = {"type": event_type, "ts_ms": now_ms,
|
||||
"t_s": self._rel_s(now_ms)}
|
||||
record.update(fields)
|
||||
self._event_fp.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
self._event_fp.flush()
|
||||
self._n_events += 1
|
||||
if self._brief_fp is not None:
|
||||
self._brief_fp.write(self._brief_text(record) + "\n")
|
||||
self._brief_fp.flush()
|
||||
|
||||
def log_telemetry(self, now_ms: int, rec: TelemetryRecord) -> None:
|
||||
"""追加一行遥测(按 telemetry_every_n 节流)。"""
|
||||
"""追加一行遥测(按 telemetry_every_n 节流)。
|
||||
|
||||
落盘策略:**写入靠 Python 缓冲、按 telemetry_flush_ms 定期 flush**,
|
||||
而不是每行 fsync 式 flush。原因:本方法在主控制循环里以 20Hz 调用,
|
||||
每行都 flush 等于每秒 20 次同步磁盘 IO,在树莓派 SD 卡上会阻塞事件
|
||||
循环(实测 MAVSDK 回调队列因此积压告警);事件行仍即时落盘,故页面
|
||||
的阶段显示不受影响,只有图表数据最多滞后 telemetry_flush_ms。
|
||||
close() 会做最后一次 flush,进程正常退出不丢数据。
|
||||
"""
|
||||
if self._tel_writer is None:
|
||||
return
|
||||
self._tick += 1
|
||||
@@ -137,8 +191,75 @@ class TaskLogger:
|
||||
return
|
||||
rec.t_s = self._rel_s(now_ms)
|
||||
self._tel_writer.writerow([getattr(rec, f) for f in _TELEMETRY_FIELDS])
|
||||
self._flush_telemetry(now_ms)
|
||||
# 简报汇总统计(只随真正落盘的行更新)
|
||||
self._last_t_s = rec.t_s
|
||||
if math.isfinite(rec.relative_altitude_m):
|
||||
if (self._max_rel_alt_m is None
|
||||
or rec.relative_altitude_m > self._max_rel_alt_m):
|
||||
self._max_rel_alt_m = rec.relative_altitude_m
|
||||
dist = rec.distance_to_target_m
|
||||
if math.isfinite(dist) and dist > 0:
|
||||
if self._min_dist_m is None or dist < self._min_dist_m:
|
||||
self._min_dist_m = dist
|
||||
if rec.state and (not self._state_chain
|
||||
or self._state_chain[-1] != rec.state):
|
||||
if len(self._state_chain) < 32:
|
||||
self._state_chain.append(rec.state)
|
||||
|
||||
# --- 简报(人类可读,一行一事件)----------------------------------------
|
||||
@staticmethod
|
||||
def _brief_text(record: dict) -> str:
|
||||
t = _fmt_t_s(record.get("t_s", 0.0))
|
||||
etype = record.get("type", "?")
|
||||
if etype == "state":
|
||||
to = _STATE_CN.get(record.get("to_state"),
|
||||
record.get("to_state", "?"))
|
||||
if record.get("from_state") == record.get("to_state"):
|
||||
return f"[{t}] 任务启动(当前: {to})"
|
||||
frm = _STATE_CN.get(record.get("from_state"),
|
||||
record.get("from_state", "?"))
|
||||
return f"[{t}] 阶段: {frm} → {to}({record.get('reason', '')})"
|
||||
if etype == "command":
|
||||
return f"[{t}] 地面站命令: {record.get('name', '')}"
|
||||
if etype == "fc_status":
|
||||
return (f"[{t}] 飞控 {record.get('severity', '')}: "
|
||||
f"{str(record.get('text', '')).strip()}")
|
||||
if etype == "takeoff":
|
||||
return f"[{t}] 起飞至 {record.get('altitude_m', '?')} m"
|
||||
if etype == "takeoff_failed":
|
||||
return f"[{t}] 起飞失败: {record.get('error', '')}"
|
||||
if etype == "rtl":
|
||||
return f"[{t}] 开始返航"
|
||||
fields = [f"{k}={v}" for k, v in record.items()
|
||||
if k not in ("type", "ts_ms", "t_s")]
|
||||
return f"[{t}] {etype}: {', '.join(fields)}"
|
||||
|
||||
def _brief_summary(self) -> str:
|
||||
parts = [f"时长 {self._last_t_s:.1f}s"]
|
||||
if self._max_rel_alt_m is not None:
|
||||
parts.append(f"最高 {self._max_rel_alt_m:.1f}m")
|
||||
if self._min_dist_m is not None:
|
||||
parts.append(f"最近距目标 {self._min_dist_m:.1f}m")
|
||||
parts.append(f"事件 {self._n_events} 条")
|
||||
if self._state_chain:
|
||||
parts.append("状态链 " + "→".join(
|
||||
_STATE_CN.get(s, s) for s in self._state_chain))
|
||||
return "----- 本次运行汇总: " + " · ".join(parts) + " -----"
|
||||
|
||||
# --- 内部 ---------------------------------------------------------------
|
||||
def _flush_telemetry(self, now_ms: int) -> None:
|
||||
"""按 telemetry_flush_ms 节流落盘遥测(0 = 每行立即落盘)。"""
|
||||
interval = self._cfg.telemetry_flush_ms
|
||||
if interval <= 0:
|
||||
self._tel_fp.flush()
|
||||
self._last_tel_flush_ms = now_ms
|
||||
return
|
||||
if (self._last_tel_flush_ms is None
|
||||
or now_ms - self._last_tel_flush_ms >= interval):
|
||||
self._tel_fp.flush()
|
||||
self._last_tel_flush_ms = now_ms
|
||||
|
||||
def _rel_s(self, now_ms: int) -> float:
|
||||
if self._t0_ms is None:
|
||||
self._t0_ms = now_ms
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* 0x08 目标识别关(真正退出识别)
|
||||
|
||||
本模块把这些指令序列封装成 PodController,供 TaskRunner 在 GOTO / RTL 阶段调用。
|
||||
SITL 用 VirtualPod(直接合成锁定态),不注入 PodController。
|
||||
另含地面站手动命令(stop_track / auto_lock / center),与 pod_console.py 按键
|
||||
u / a / c 语义对齐。SITL 用 VirtualPod(直接合成锁定态),不注入 PodController。
|
||||
"""
|
||||
|
||||
from pod_bridge.protocol import ControlCode, ControlCommand
|
||||
@@ -37,3 +38,18 @@ class PodController:
|
||||
"""停止跟踪 + 关闭目标识别(RTL 前调用)。"""
|
||||
self._bridge.send_control(ControlCommand(code=ControlCode.STOP_TRACK))
|
||||
self._bridge.send_control(ControlCommand(code=ControlCode.TARGET_DETECT_OFF))
|
||||
|
||||
def stop_track(self) -> None:
|
||||
"""停止跟踪(只停当前这一次,0x0E;0x0F 常驻自动锁定仍生效)。"""
|
||||
self._bridge.send_control(ControlCommand(code=ControlCode.STOP_TRACK))
|
||||
|
||||
def auto_lock(self) -> None:
|
||||
"""发现目标自动锁定 + 识别转跟踪(0x0F + 0x10)。"""
|
||||
self._bridge.send_control(ControlCommand(
|
||||
code=ControlCode.AUTO_LOCK_ON_FOUND))
|
||||
self._bridge.send_control(ControlCommand(
|
||||
code=ControlCode.DETECT_TO_TRACK))
|
||||
|
||||
def center(self) -> None:
|
||||
"""手动归中(0x2B)。"""
|
||||
self._bridge.send_control(ControlCommand(code=ControlCode.CENTER))
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""return_home.py —— 自定义返航(回起飞点 + 恢复初始航向 + 就地降落)的纯逻辑控制。
|
||||
|
||||
背景:PX4 自带的 RTL 会先爬到 `RTL_RETURN_ALT`(默认数十米)再飞回、再下降到
|
||||
`RTL_DESCEND_ALT` 后落地——即"飞很高再降落"。本模块给出替代方案:
|
||||
|
||||
RETURN 阶段:保持在**转场高度**(取进入返航时的高度与起飞高度的较大者,
|
||||
绝不比任务高度更高)飞向起飞点,机头朝目标方向;
|
||||
YAW 阶段 :到位后原地把机头转回**起飞时刻的航向**;
|
||||
LAND 阶段 :交给飞控 `land()` 垂直降落(不爬升)。
|
||||
|
||||
设计约束(与项目铁律一致):
|
||||
* 纯逻辑:输入为载机位置/航向/相对高度,输出速度与角速度意图,不碰 IO,
|
||||
可确定性单测;实际下发由 runner 负责。
|
||||
* NED 语义:vz 下正。目标高度高于当前 → vz 为负(爬升)。
|
||||
* 只做"航位推算"级别的控制:无距离传感器时不需要它(返航点是已知识别坐标),
|
||||
因此这里用比例控制 + 限幅即可,不引入积分项(避免返航中积分饱和)。
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pod_bridge.guidance import haversine_distance_m, initial_bearing_deg
|
||||
from visual_track_control.rate_limiter import clamp
|
||||
|
||||
|
||||
def wrap_deg_180(deg: float) -> float:
|
||||
"""角度回绕到 [-180, 180)。"""
|
||||
d = math.fmod(deg + 180.0, 360.0)
|
||||
if d < 0.0:
|
||||
d += 360.0
|
||||
return d - 180.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReturnConfig:
|
||||
"""自定义返航参数(全外置;数值为占位,须按机体/场地标定)。"""
|
||||
|
||||
enabled: bool = True # False → 退回 PX4 原生 RTL
|
||||
speed_mps: float = 2.0 # 转场水平速度上限
|
||||
arrival_radius_m: float = 3.0 # 距起飞点该半径内视为"已回到起飞点"
|
||||
timeout_s: float = 120.0 # 转场阶段超时(超时 → 退回 PX4 RTL 兜底)
|
||||
yaw_kp: float = 1.0 # 航向误差 → 偏航角速度(dps/deg)
|
||||
yaw_rate_limit_dps: float = 20.0 # 偏航角速度上限
|
||||
align_kp: float = 1.0 # 恢复初始航向阶段的比例增益
|
||||
align_rate_limit_dps: float = 15.0
|
||||
yaw_align_tolerance_deg: float = 5.0 # 航向对齐容差
|
||||
yaw_align_timeout_s: float = 15.0 # 航向对齐超时(超时也进入降落)
|
||||
alt_kp: float = 0.5 # 高度误差 → vz(m/s per m)
|
||||
alt_limit_mps: float = 0.5 # 垂直速度上限(与 L1 vertical 限速同量级)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomingOutput:
|
||||
"""单周期归航意图(机体系前向 + 垂直 NED + 偏航角速度)。"""
|
||||
|
||||
vx_mps: float = 0.0
|
||||
vz_mps: float = 0.0
|
||||
yaw_rate_dps: float = 0.0
|
||||
distance_m: float = math.inf
|
||||
bearing_err_deg: float = 0.0
|
||||
arrived: bool = False
|
||||
|
||||
|
||||
class HomingController:
|
||||
"""把载机当前位置/航向/高度 → 飞回起飞点的速度与航向意图。
|
||||
|
||||
用法:进入返航时用起飞点经纬度、起飞时刻航向、转场高度构造一次;
|
||||
之后每周期 `update(lat, lon, yaw_rad, rel_alt_m)`。
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: ReturnConfig, home_lat_deg: float,
|
||||
home_lon_deg: float, initial_yaw_rad: float,
|
||||
transit_alt_m: float) -> None:
|
||||
self._cfg = cfg
|
||||
self._home_lat = float(home_lat_deg)
|
||||
self._home_lon = float(home_lon_deg)
|
||||
self._initial_yaw_rad = float(initial_yaw_rad)
|
||||
self._transit_alt_m = float(transit_alt_m)
|
||||
|
||||
@property
|
||||
def home(self) -> tuple[float, float]:
|
||||
return self._home_lat, self._home_lon
|
||||
|
||||
@property
|
||||
def initial_yaw_rad(self) -> float:
|
||||
return self._initial_yaw_rad
|
||||
|
||||
def update(self, lat_deg: float, lon_deg: float, yaw_rad: float,
|
||||
rel_alt_m: float) -> HomingOutput:
|
||||
"""转场阶段单周期意图:朝起飞点飞,同时保持转场高度。"""
|
||||
cfg = self._cfg
|
||||
dist = haversine_distance_m(lat_deg, lon_deg,
|
||||
self._home_lat, self._home_lon)
|
||||
bearing = initial_bearing_deg(lat_deg, lon_deg,
|
||||
self._home_lat, self._home_lon)
|
||||
err = wrap_deg_180(bearing - math.degrees(yaw_rad))
|
||||
|
||||
yaw_rate = clamp(cfg.yaw_kp * err,
|
||||
-cfg.yaw_rate_limit_dps, cfg.yaw_rate_limit_dps)
|
||||
arrived = dist <= cfg.arrival_radius_m
|
||||
# 对准门控:没对准先转,避免斜着"蹭"过去(与 L2 引导同思路)
|
||||
alignment = max(0.0, math.cos(math.radians(err)))
|
||||
vx = 0.0 if arrived else cfg.speed_mps * alignment
|
||||
return HomingOutput(
|
||||
vx_mps=vx,
|
||||
vz_mps=self.alt_hold_vz(rel_alt_m),
|
||||
yaw_rate_dps=yaw_rate,
|
||||
distance_m=dist,
|
||||
bearing_err_deg=err,
|
||||
arrived=arrived,
|
||||
)
|
||||
|
||||
def alt_hold_vz(self, rel_alt_m: float) -> float:
|
||||
"""保持转场高度(NED 下正):低于目标 → 负值(爬升),高于 → 正值(下降)。"""
|
||||
cfg = self._cfg
|
||||
return clamp(cfg.alt_kp * (rel_alt_m - self._transit_alt_m),
|
||||
-cfg.alt_limit_mps, cfg.alt_limit_mps)
|
||||
|
||||
def yaw_align(self, yaw_rad: float) -> tuple[float, float]:
|
||||
"""恢复初始航向:返回 (航向误差 deg, 偏航角速度 dps)。"""
|
||||
cfg = self._cfg
|
||||
err = wrap_deg_180(math.degrees(self._initial_yaw_rad)
|
||||
- math.degrees(yaw_rad))
|
||||
rate = clamp(cfg.align_kp * err,
|
||||
-cfg.align_rate_limit_dps, cfg.align_rate_limit_dps)
|
||||
return err, rate
|
||||
|
||||
def yaw_aligned(self, yaw_rad: float) -> bool:
|
||||
err, _ = self.yaw_align(yaw_rad)
|
||||
return abs(err) <= self._cfg.yaw_align_tolerance_deg
|
||||
+424
-39
@@ -7,6 +7,8 @@ L2(地理引导 GuidanceLaw)按任务阶段编排,配合地面坐标注入
|
||||
职责边界:
|
||||
* 状态机(TaskStateMachine)只做「状态 + 转移判据」决策;
|
||||
* 本编排器负责「采集快照 → 喂状态机 → 按状态执行动作」;
|
||||
* 地面站命令(RTL/紧急/吊舱手动指令,经 GroundLink 队列)主循环逐拍分派;
|
||||
* 飞控状态文本(STATUSTEXT,警告/异常)逐拍落任务日志(fc_status 事件);
|
||||
* 闭环控制复用 PodBridge(L1/L2 运行时切换走 set_heading_servo_enabled);
|
||||
* 飞行编排(解锁/起飞/返航)通过 duck-typed `drone`(MAVSDK System)完成,
|
||||
便于 mock 单测;真实 SITL 装配见仓库根 task_runner_sitl.py。
|
||||
@@ -23,13 +25,38 @@ from typing import Callable, Optional
|
||||
|
||||
from pod_bridge.guidance import haversine_distance_m
|
||||
from visual_track_control.staleness_checker import is_stale
|
||||
from visual_track_control.types import ControlMode, ControlOutput
|
||||
|
||||
from .log import TaskLogger, TelemetryRecord
|
||||
from .return_home import HomingController, ReturnConfig
|
||||
from .state_machine import (TaskInputs, TaskState, TaskStateConfig,
|
||||
TaskStateMachine)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 自定义返航的阶段
|
||||
_RTL_RETURN = "return" # 转场:飞回起飞点,保持转场高度
|
||||
_RTL_YAW = "yaw" # 到位:把机头转回初始航向
|
||||
_RTL_LAND = "land" # 降落:交给飞控垂直降落
|
||||
_RTL_FALLBACK = "fallback" # 兜底:退回 PX4 原生 RTL(会爬升,仅异常时使用)
|
||||
_RTL_IDLE = "idle"
|
||||
|
||||
# 地面站吊舱命令 → PodController 方法(与 ground_station/app.py 白名单对齐)
|
||||
_POD_COMMAND_METHODS = {
|
||||
"pod_detect_on": "arm_detection",
|
||||
"pod_auto_lock": "auto_lock",
|
||||
"pod_stop_track": "stop_track",
|
||||
"pod_detect_off": "stop_tracking",
|
||||
"pod_center": "center",
|
||||
}
|
||||
|
||||
# 搜索扫视换向周期计数中「向右」的半周期序号(0 → 右,1 → 左,交替)
|
||||
_SWEEP_RIGHT, _SWEEP_LEFT = 0, 1
|
||||
|
||||
# RTL 落地判定:单次等待 in_air 遥测的上限(秒)。MAVSDK 遥测流挂起时
|
||||
# `async for` 会永久阻塞并卡死主循环,故必须有兜底(超时 → 下一拍重试)。
|
||||
_IN_AIR_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def _monotonic_ms() -> int:
|
||||
return int(time.monotonic() * 1000)
|
||||
@@ -45,14 +72,14 @@ class TaskRunner:
|
||||
sm TaskStateMachine —— update()
|
||||
ground GroundLink —— latest()(地面注入坐标)
|
||||
drone MAVSDK System —— action.arm/takeoff/return_to_launch/land/disarm
|
||||
vision_fn callable->bool —— 我方视觉第二判据(可选,require_vision 时用)
|
||||
"""
|
||||
|
||||
def __init__(self, cfg, sm_cfg: TaskStateConfig, px4, bridge, sm: TaskStateMachine,
|
||||
ground, drone, vision_fn: Optional[Callable[[], bool]] = None,
|
||||
ground, drone,
|
||||
now_ms_fn: Callable[[], int] = _monotonic_ms,
|
||||
logger: Optional[TaskLogger] = None,
|
||||
pod_control=None) -> None:
|
||||
pod_control=None, land_instead_of_rtl: bool = False,
|
||||
return_cfg: Optional[ReturnConfig] = None) -> None:
|
||||
self._cfg = cfg
|
||||
self._sm_cfg = sm_cfg
|
||||
self._px4 = px4
|
||||
@@ -60,14 +87,32 @@ class TaskRunner:
|
||||
self._sm = sm
|
||||
self._ground = ground
|
||||
self._drone = drone
|
||||
self._vision_fn = vision_fn
|
||||
self._now = now_ms_fn
|
||||
self._logger = logger # 结构化任务日志(可选,B 阶段)
|
||||
self._pod_control = pod_control # 吊舱识别/锁定控制(可选,真机)
|
||||
self._land_instead_of_rtl = land_instead_of_rtl # 收尾改用就地降落
|
||||
self._return_cfg = return_cfg or ReturnConfig()
|
||||
self._centered_since_ms: Optional[int] = None
|
||||
self._rtl_requested = False
|
||||
self._emergency = False
|
||||
self._rtl_done = False
|
||||
# SEARCH 扫视状态:当前扫向(右/左)与本次扫向的起始时刻
|
||||
self._sweep_dir = _SWEEP_RIGHT
|
||||
self._sweep_since_ms: Optional[int] = None
|
||||
self._pod_detection_on = False # 吊舱识别/自动锁定只开一次(防重发 0x07)
|
||||
self._takeoff_done = False # 解锁/起飞只执行一次(防空中重复起飞)
|
||||
self._in_air_stream = None # in_air 遥测流复用(避免每拍重新订阅)
|
||||
self._rtl_entered_ms: Optional[int] = None # 进入 RTL 时刻(落地等待上限用)
|
||||
# --- 自定义返航(回起飞点 → 恢复初始航向 → 降落)---
|
||||
self._home_lat: Optional[float] = None # 起飞点纬度(解锁时刻记录)
|
||||
self._home_lon: Optional[float] = None
|
||||
self._initial_yaw_rad: Optional[float] = None # 起飞时刻航向
|
||||
self._bridge_started = False # 是否已进入 offboard(未起飞则不能自主返航)
|
||||
self._rtl_phase = _RTL_IDLE
|
||||
self._return_ctrl: Optional[HomingController] = None
|
||||
self._return_started_ms: Optional[int] = None
|
||||
self._yaw_started_ms: Optional[int] = None
|
||||
self._land_cmd_sent = False
|
||||
|
||||
# --- 外部控制 -----------------------------------------------------------
|
||||
def request_rtl(self) -> None:
|
||||
@@ -78,6 +123,43 @@ class TaskRunner:
|
||||
"""外部触发紧急终止(丢控 / RC 接管 / 失控)。"""
|
||||
self._emergency = True
|
||||
|
||||
# --- 地面站命令分派 -----------------------------------------------------
|
||||
def _apply_ground_commands(self) -> None:
|
||||
"""排空地面站命令队列并逐条分派(主循环每拍调用)。"""
|
||||
for name in self._ground.drain_commands():
|
||||
self._apply_command(name)
|
||||
|
||||
def _apply_command(self, name: str) -> None:
|
||||
"""分派一条地面站命令;未知命令名忽略并警告。"""
|
||||
if name == "rtl":
|
||||
self.request_rtl()
|
||||
elif name == "emergency":
|
||||
self.trigger_emergency()
|
||||
elif name in _POD_COMMAND_METHODS:
|
||||
if self._pod_control is None:
|
||||
logger.warning("[Task] 地面站命令 %s 被忽略:未配置吊舱控制(SITL)",
|
||||
name)
|
||||
return
|
||||
try:
|
||||
getattr(self._pod_control, _POD_COMMAND_METHODS[name])()
|
||||
except Exception as exc:
|
||||
logger.warning("[Task] 吊舱命令 %s 执行失败: %s", name, exc)
|
||||
return
|
||||
else:
|
||||
logger.warning("[Task] 未知地面站命令: %s", name)
|
||||
return
|
||||
logger.info("[Task] 地面站命令: %s", name)
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(self._now(), "command", name=name)
|
||||
|
||||
def _log_fc_messages(self) -> None:
|
||||
"""把飞控状态文本(STATUSTEXT)落任务日志并输出控制台(每拍调用)。"""
|
||||
for sev, text in self._px4.take_fc_messages():
|
||||
logger.info("[FC] %s: %s", sev, text)
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(self._now(), "fc_status",
|
||||
severity=sev, text=text)
|
||||
|
||||
@property
|
||||
def state(self) -> TaskState:
|
||||
return self._sm.state
|
||||
@@ -92,24 +174,29 @@ class TaskRunner:
|
||||
await self._px4.start_telemetry() # 幂等:先拿遥测,供起飞到位/距离计算
|
||||
if self._logger is not None:
|
||||
self._logger.open()
|
||||
# 记初始状态:页面「任务状态」取日志最后一个 state 事件,缺此行会显示上次旧状态
|
||||
self._logger.log_event(self._now(), "state",
|
||||
from_state=self._sm.state.value,
|
||||
to_state=self._sm.state.value,
|
||||
reason="任务启动")
|
||||
period_s = 1.0 / self._sm_cfg.loop_frequency_hz
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
now_ms = self._now()
|
||||
inputs = self._collect_inputs(now_ms)
|
||||
tr = self._sm.update(inputs, now_ms)
|
||||
if tr.occurred:
|
||||
logger.info("[Task] %s -> %s (%s)",
|
||||
tr.from_state.value, tr.to_state.value, tr.reason)
|
||||
try:
|
||||
await self._iteration(now_ms)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 单周期异常不得让任务带着 offboard 指令静默停摆:转紧急 → 下一拍
|
||||
# 状态机转 RTL(与 bridge.run() 的按周期护栏同思路,但这里必须
|
||||
# 升级为返航,因为再没有别的循环会接管飞机)。
|
||||
logger.error("[Task] 任务周期异常,转紧急返航: %s", exc)
|
||||
self._emergency = True
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(
|
||||
now_ms, "state", from_state=tr.from_state.value,
|
||||
to_state=tr.to_state.value, reason=tr.reason)
|
||||
await self._on_enter(tr.to_state, now_ms)
|
||||
out = await self._tick(now_ms)
|
||||
if self._logger is not None:
|
||||
self._logger.log_telemetry(
|
||||
now_ms, self._build_telemetry_record(now_ms, inputs, out))
|
||||
self._logger.log_event(self._now(), "iteration_error",
|
||||
error=f"{type(exc).__name__}: {exc}")
|
||||
await self._safe_abort()
|
||||
if self._finished():
|
||||
break
|
||||
await asyncio.sleep(period_s)
|
||||
@@ -118,10 +205,52 @@ class TaskRunner:
|
||||
if self._logger is not None:
|
||||
self._logger.close()
|
||||
|
||||
async def _iteration(self, now_ms: int) -> None:
|
||||
"""单周期:命令 → 快照 → 状态机 → 进入动作 → 持续动作 → 日志。"""
|
||||
self._apply_ground_commands() # 地面站命令先于快照采集生效
|
||||
self._log_fc_messages()
|
||||
inputs = self._collect_inputs(now_ms)
|
||||
tr = self._sm.update(inputs, now_ms)
|
||||
if tr.occurred:
|
||||
logger.info("[Task] %s -> %s (%s)",
|
||||
tr.from_state.value, tr.to_state.value, tr.reason)
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(
|
||||
now_ms, "state", from_state=tr.from_state.value,
|
||||
to_state=tr.to_state.value, reason=tr.reason)
|
||||
await self._on_enter(tr.to_state, now_ms)
|
||||
out = await self._tick(now_ms)
|
||||
if self._logger is not None:
|
||||
self._logger.log_telemetry(
|
||||
now_ms, self._build_telemetry_record(now_ms, inputs, out))
|
||||
|
||||
async def _safe_abort(self) -> None:
|
||||
"""异常兜底:直接进入 RTL 流程(不依赖状态机下一拍转移)。
|
||||
|
||||
异常可能来自采集/控制律/日志任意环节,下一拍未必能正常跑完转移;因此在
|
||||
兜底里显式走一次 RTL 进入动作(停机 + 返航),保证飞机不会停在 offboard
|
||||
速度指令上。
|
||||
"""
|
||||
if self._sm.state is TaskState.RTL:
|
||||
return
|
||||
self._sm.force_state(TaskState.RTL, self._now())
|
||||
self._rtl_entered_ms = self._now()
|
||||
if self._pod_control is not None:
|
||||
try:
|
||||
self._pod_control.stop_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# 兜底不走自定义返航:异常后不再信任自身状态/定位,直接交给 PX4 原生 RTL
|
||||
await self._begin_rtl(self._now(), force_fallback=True)
|
||||
except Exception as exc:
|
||||
logger.error("[Task] 异常兜底返航失败: %s", exc)
|
||||
|
||||
# --- 输入采集 -----------------------------------------------------------
|
||||
def _collect_inputs(self, now_ms: int) -> TaskInputs:
|
||||
"""聚合 PX4 / 注入坐标 / 吊舱 / 视觉为一份 TaskInputs 快照。"""
|
||||
own = self._px4.own_state()
|
||||
self._maybe_record_home(own) # 起飞阶段定位可能晚到:每拍补记一次
|
||||
|
||||
injected = self._ground.latest()
|
||||
target_injected = bool(
|
||||
@@ -135,10 +264,12 @@ class TaskRunner:
|
||||
own.latitude_deg, own.longitude_deg,
|
||||
injected.latitude_deg, injected.longitude_deg)
|
||||
|
||||
# 起飞到达判据带容差:悬停/高度估计通常略低于设定目标(实测 SITL 4.99m vs 5.0m),
|
||||
# 无容差会差厘米误判"起飞超时"→ 真机上更明显
|
||||
takeoff_reached = (own.relative_altitude_m
|
||||
>= self._sm_cfg.takeoff_altitude_m)
|
||||
>= self._sm_cfg.takeoff_altitude_m
|
||||
- self._sm_cfg.takeoff_reached_tolerance_m)
|
||||
target_locked = self._bridge.pod_locked(now_ms)
|
||||
vision = bool(self._vision_fn and self._vision_fn())
|
||||
centered, centered_s = self._update_centered(now_ms)
|
||||
|
||||
return TaskInputs(
|
||||
@@ -146,7 +277,6 @@ class TaskRunner:
|
||||
distance_to_target_m=distance,
|
||||
takeoff_reached=takeoff_reached,
|
||||
target_locked=target_locked,
|
||||
vision_has_target=vision,
|
||||
target_centered=centered,
|
||||
centered_duration_s=centered_s,
|
||||
rtl_requested=self._rtl_requested,
|
||||
@@ -174,6 +304,12 @@ class TaskRunner:
|
||||
|
||||
# --- 状态进入动作 -------------------------------------------------------
|
||||
async def _on_enter(self, state: TaskState, now_ms: int) -> None:
|
||||
"""进入状态的**一次性**动作。
|
||||
|
||||
注意:这些动作只在「首次」进入时执行——任务中途可能需要多次经过 GOTO/
|
||||
SEARCH(丢目标回搜),重复 `offboard.start()` / `arm_detection()`
|
||||
(0x07 目标识别开会打断正在进行的跟踪)会造成真机自激,故以标志位守卫。
|
||||
"""
|
||||
if state is TaskState.ARM_TAKEOFF:
|
||||
await self._arm_takeoff()
|
||||
elif state is TaskState.GOTO:
|
||||
@@ -181,17 +317,50 @@ class TaskRunner:
|
||||
logger.error("[Task] offboard 启动失败,紧急终止")
|
||||
self._emergency = True
|
||||
return
|
||||
self._bridge_started = True
|
||||
self._bridge.set_heading_servo_enabled(False) # L2 地理引导
|
||||
if self._pod_control is not None:
|
||||
self._pod_control.arm_detection() # 真机:开启目标识别 + 自动锁定
|
||||
self._start_pod_detection() # 首次:开识别 + 自动锁定
|
||||
elif state is TaskState.SEARCH:
|
||||
self._bridge.set_heading_servo_enabled(False) # 搜索不用控制律
|
||||
self._sweep_dir = _SWEEP_RIGHT # 每次进入从右向起扫
|
||||
self._sweep_since_ms = now_ms
|
||||
elif state is TaskState.TRACK:
|
||||
self._bridge.set_heading_servo_enabled(True) # L1 航向伺服
|
||||
self._bridge.set_heading_servo_enabled(True) # L1 视觉追踪 + 缓慢靠近
|
||||
elif state is TaskState.APPROACH:
|
||||
pass # 悬停由 _tick 持续发零速
|
||||
elif state is TaskState.RTL:
|
||||
self._rtl_entered_ms = now_ms
|
||||
if self._pod_control is not None:
|
||||
self._pod_control.stop_tracking() # 真机:停止跟踪 + 识别关(bridge 停机前)
|
||||
await self._start_rtl()
|
||||
self._pod_control.stop_tracking() # 真机:停止跟踪 + 识别关
|
||||
await self._begin_rtl(now_ms)
|
||||
|
||||
def _start_pod_detection(self) -> None:
|
||||
"""首次到达引导/搜索阶段时开启吊舱目标识别 + 自动锁定(只发一次)。"""
|
||||
if self._pod_control is None or self._pod_detection_on:
|
||||
return
|
||||
self._pod_detection_on = True
|
||||
self._pod_control.arm_detection()
|
||||
|
||||
# --- SEARCH 扫视 --------------------------------------------------------
|
||||
def _search_yaw_rate(self, now_ms: int) -> float:
|
||||
"""SEARCH 扫视角速度:按 sweep_reverse_s 交替左右,0 = 单向扫视。
|
||||
|
||||
吊舱相机刚性固连机体(无电机、不独立转动),故只能靠机头旋转改变观察
|
||||
方向;扫视期间零线速度,飞机保持悬停在参考点附近。
|
||||
"""
|
||||
rate = self._sm_cfg.sweep_rate_dps
|
||||
if rate == 0.0:
|
||||
return 0.0
|
||||
period_s = self._sm_cfg.sweep_reverse_s
|
||||
if period_s > 0.0:
|
||||
if self._sweep_since_ms is None:
|
||||
self._sweep_since_ms = now_ms
|
||||
elif (now_ms - self._sweep_since_ms) >= period_s * 1000.0:
|
||||
self._sweep_since_ms = now_ms
|
||||
self._sweep_dir = (_SWEEP_LEFT if self._sweep_dir == _SWEEP_RIGHT
|
||||
else _SWEEP_RIGHT)
|
||||
sign = 1.0 if self._sweep_dir == _SWEEP_RIGHT else -1.0
|
||||
return sign * rate
|
||||
|
||||
# --- 每周期持续动作 -----------------------------------------------------
|
||||
async def _tick(self, now_ms: int):
|
||||
@@ -199,10 +368,13 @@ class TaskRunner:
|
||||
state = self._sm.state
|
||||
if state in (TaskState.GOTO, TaskState.TRACK):
|
||||
return await self._bridge.step(now_ms)
|
||||
elif state is TaskState.SEARCH:
|
||||
# 原地偏航扫视(同时 poll 吊舱,保遥测新鲜,否则搜到也切不进 TRACK)
|
||||
await self._bridge.search_step(now_ms, self._search_yaw_rate(now_ms))
|
||||
elif state is TaskState.APPROACH:
|
||||
await self._px4.send_brake_hover(now_ms) # 悬停目标上空(保 offboard 心跳)
|
||||
elif state is TaskState.RTL:
|
||||
await self._check_landed()
|
||||
await self._tick_rtl(now_ms)
|
||||
return None
|
||||
# WAIT / ARM_TAKEOFF:无持续动作(待命 / 等起飞高度)
|
||||
|
||||
@@ -238,6 +410,17 @@ class TaskRunner:
|
||||
|
||||
# --- 飞行编排 -----------------------------------------------------------
|
||||
async def _arm_takeoff(self) -> None:
|
||||
"""解锁 + 起飞到悬停高度(每次任务只执行一次)。
|
||||
|
||||
重复执行会导致:机已在空中时再次 arm/takeoff → PX4 拒绝甚至触发保护,
|
||||
且日志看起来像"重新起飞"。故用标志位守卫,重复进入只告警不改动飞机。
|
||||
"""
|
||||
if self._takeoff_done:
|
||||
logger.warning("[Task] 已在飞行中,忽略重复的解锁/起飞请求")
|
||||
return
|
||||
self._takeoff_done = True
|
||||
# 解锁前记录起飞点与初始航向(自定义返航要用;PX4 的 home 此时已固定)
|
||||
self._record_home()
|
||||
try:
|
||||
await self._drone.action.arm()
|
||||
await self._drone.action.set_takeoff_altitude(
|
||||
@@ -254,9 +437,175 @@ class TaskRunner:
|
||||
self._logger.log_event(self._now(), "takeoff_failed",
|
||||
error=str(exc))
|
||||
|
||||
async def _start_rtl(self) -> None:
|
||||
def _record_home(self) -> bool:
|
||||
"""尝试记录起飞点与初始航向;成功返回 True(可重复调用,只记一次)。"""
|
||||
if self._home_lat is not None:
|
||||
return True
|
||||
try:
|
||||
await self._bridge.stop() # 先零速刹车 + 停 offboard/遥测/串口
|
||||
own = self._px4.own_state()
|
||||
except Exception as exc: # 遥测不可用时不阻塞起飞
|
||||
logger.warning("[Task] 读取起飞点失败: %s", exc)
|
||||
return False
|
||||
return self._maybe_record_home(own)
|
||||
|
||||
def _maybe_record_home(self, own) -> bool:
|
||||
"""在**起飞前/起飞中**用首个有效定位记下起飞点与初始航向。
|
||||
|
||||
⚠ SITL 实测踩到的坑:解锁发生在地面站坐标到达后约 0.3s,此时 `gps_info`
|
||||
(2Hz)可能还没到,`position_valid` 仍为 False——若只在解锁那一刻记一次,
|
||||
起飞点就会丢失、自定义返航只能退回 PX4 RTL。故起飞阶段每拍补记。
|
||||
只接受 WAIT / ARM_TAKEOFF 阶段的定位:进入 GOTO 后飞机已在空中,
|
||||
那时的位置不该再当作"起飞点"。
|
||||
"""
|
||||
if self._home_lat is not None:
|
||||
return True
|
||||
if not own.position_valid:
|
||||
return False
|
||||
if self._sm.state not in (TaskState.WAIT, TaskState.ARM_TAKEOFF):
|
||||
return False
|
||||
self._home_lat = own.latitude_deg
|
||||
self._home_lon = own.longitude_deg
|
||||
self._initial_yaw_rad = own.yaw_rad
|
||||
logger.info("[Task] 起飞点已记录: (%.7f, %.7f) 初始航向 %.1f°",
|
||||
self._home_lat, self._home_lon,
|
||||
math.degrees(self._initial_yaw_rad))
|
||||
return True
|
||||
|
||||
# --- 返航(自定义:回起飞点 → 恢复初始航向 → 降落)---------------------
|
||||
def _custom_return_possible(self) -> bool:
|
||||
"""能否走自定义返航(已起飞 + 有起飞点 + 位置有效)。"""
|
||||
if not self._return_cfg.enabled:
|
||||
return False
|
||||
if not self._bridge_started:
|
||||
return False # 没进过 offboard:还在待命/起飞段
|
||||
if self._home_lat is None or self._home_lon is None:
|
||||
return False
|
||||
if self._initial_yaw_rad is None:
|
||||
return False
|
||||
try:
|
||||
return bool(self._px4.own_state().position_valid)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _begin_rtl(self, now_ms: int, force_fallback: bool = False) -> None:
|
||||
"""进入返航:选择「自定义返航」或「就地降落」或「PX4 RTL 兜底」。"""
|
||||
if self._land_instead_of_rtl:
|
||||
self._rtl_phase = _RTL_LAND
|
||||
await self._start_land()
|
||||
return
|
||||
if force_fallback or not self._custom_return_possible():
|
||||
reason = "异常兜底" if force_fallback else "未起飞/无起飞点"
|
||||
logger.info("[Task] 使用 PX4 原生 RTL(%s)", reason)
|
||||
await self._fallback_rtl()
|
||||
return
|
||||
own = self._px4.own_state()
|
||||
# 转场高度:取"当前高度"与"起飞高度"的较大者 —— 不低于任务高度,
|
||||
# 也不会为了返航额外爬高(这正是原生 RTL 会做的事)。
|
||||
transit_alt = max(own.relative_altitude_m,
|
||||
self._sm_cfg.takeoff_altitude_m)
|
||||
self._return_ctrl = HomingController(
|
||||
self._return_cfg, self._home_lat, self._home_lon,
|
||||
self._initial_yaw_rad, transit_alt)
|
||||
self._return_started_ms = now_ms
|
||||
self._rtl_phase = _RTL_RETURN
|
||||
logger.info("[Task] 自定义返航:回起飞点 (%.7f, %.7f),转场高度 %.1fm,"
|
||||
"到位后恢复航向 %.1f° 并降落",
|
||||
self._home_lat, self._home_lon, transit_alt,
|
||||
math.degrees(self._initial_yaw_rad))
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(
|
||||
now_ms, "return_home", home_lat=self._home_lat,
|
||||
home_lon=self._home_lon, transit_alt_m=transit_alt,
|
||||
initial_yaw_deg=math.degrees(self._initial_yaw_rad))
|
||||
|
||||
async def _return_step(self, now_ms: int) -> None:
|
||||
"""转场阶段:朝起飞点飞,保持转场高度。"""
|
||||
cfg = self._return_cfg
|
||||
if (self._return_started_ms is not None
|
||||
and now_ms - self._return_started_ms >= cfg.timeout_s * 1000.0):
|
||||
logger.warning("[Task] 返航转场超时 %.0fs,退回 PX4 RTL", cfg.timeout_s)
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(now_ms, "return_timeout",
|
||||
timeout_s=cfg.timeout_s)
|
||||
await self._fallback_rtl()
|
||||
return
|
||||
own = self._px4.own_state()
|
||||
if not own.position_valid:
|
||||
# 定位丢失:不能自主返航,先刹停等恢复(不盲目飞)
|
||||
logger.warning("[Task] 返航中定位失效,刹停等待")
|
||||
await self._px4.send_brake_hover(now_ms)
|
||||
return
|
||||
cmd = self._return_ctrl.update(own.latitude_deg, own.longitude_deg,
|
||||
own.yaw_rad, own.relative_altitude_m)
|
||||
out = ControlOutput(mode=ControlMode.VELOCITY_HEADING, valid=True,
|
||||
vx_body_mps=cmd.vx_mps, vz_mps=cmd.vz_mps,
|
||||
yaw_rate_dps=cmd.yaw_rate_dps)
|
||||
await self._px4.send_velocity_heading(out, now_ms)
|
||||
if cmd.arrived:
|
||||
logger.info("[Task] 已回到起飞点(距 %.1fm),开始恢复初始航向",
|
||||
cmd.distance_m)
|
||||
self._rtl_phase = _RTL_YAW
|
||||
self._yaw_started_ms = now_ms
|
||||
|
||||
async def _yaw_align_step(self, now_ms: int) -> None:
|
||||
"""恢复初始航向阶段:原地转回起飞时刻的航向。"""
|
||||
cfg = self._return_cfg
|
||||
own = self._px4.own_state()
|
||||
err, rate = self._return_ctrl.yaw_align(own.yaw_rad)
|
||||
# 保持转场高度(不额外爬升)
|
||||
out = ControlOutput(mode=ControlMode.VELOCITY_HEADING, valid=True,
|
||||
vx_body_mps=0.0,
|
||||
vz_mps=self._return_ctrl.alt_hold_vz(
|
||||
own.relative_altitude_m),
|
||||
yaw_rate_dps=rate)
|
||||
await self._px4.send_velocity_heading(out, now_ms)
|
||||
|
||||
timed_out = (self._yaw_started_ms is not None
|
||||
and now_ms - self._yaw_started_ms
|
||||
>= cfg.yaw_align_timeout_s * 1000.0)
|
||||
if abs(err) <= cfg.yaw_align_tolerance_deg:
|
||||
logger.info("[Task] 航向已恢复(误差 %.1f°),开始降落", err)
|
||||
elif timed_out:
|
||||
logger.warning("[Task] 航向对齐超时(误差 %.1f°),仍进入降落", err)
|
||||
else:
|
||||
return
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(now_ms, "yaw_restored",
|
||||
err_deg=err, timed_out=timed_out)
|
||||
self._rtl_phase = _RTL_LAND
|
||||
await self._start_land()
|
||||
|
||||
async def _start_land(self) -> None:
|
||||
"""退出 offboard 并垂直降落(不爬升)。"""
|
||||
if self._land_cmd_sent:
|
||||
return
|
||||
self._land_cmd_sent = True
|
||||
# 只退 offboard、保留遥测:落地判定与高度曲线都依赖遥测(SITL 实测过:
|
||||
# 若在这里就全停,日志会冻结在降落前的高度,看不出下降)。
|
||||
stop_for_landing = getattr(self._bridge, "landing_stop", None)
|
||||
try:
|
||||
if stop_for_landing is not None:
|
||||
await stop_for_landing()
|
||||
else:
|
||||
await self._bridge.stop()
|
||||
except Exception as exc:
|
||||
logger.warning("[Task] 停机异常: %s", exc)
|
||||
try:
|
||||
await self._drone.action.land()
|
||||
except Exception as exc:
|
||||
logger.warning("[Task] 降落指令失败,尝试 PX4 RTL 兜底: %s", exc)
|
||||
try:
|
||||
await self._drone.action.return_to_launch()
|
||||
except Exception:
|
||||
pass
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(self._now(), "rtl", action="land")
|
||||
|
||||
async def _fallback_rtl(self) -> None:
|
||||
"""兜底:停机后交给 PX4 原生 RTL(会先爬升到 RTL_RETURN_ALT)。"""
|
||||
self._rtl_phase = _RTL_FALLBACK
|
||||
try:
|
||||
await self._bridge.stop()
|
||||
except Exception as exc:
|
||||
logger.warning("[Task] 停机异常: %s", exc)
|
||||
try:
|
||||
@@ -268,21 +617,57 @@ class TaskRunner:
|
||||
except Exception:
|
||||
pass
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(self._now(), "rtl")
|
||||
self._logger.log_event(self._now(), "rtl", action="rtl")
|
||||
|
||||
async def _tick_rtl(self, now_ms: int) -> None:
|
||||
"""RTL 状态机:按阶段推进(转场 → 恢复航向 → 降落 / 兜底)。"""
|
||||
phase = self._rtl_phase
|
||||
if phase == _RTL_RETURN:
|
||||
await self._return_step(now_ms)
|
||||
elif phase == _RTL_YAW:
|
||||
await self._yaw_align_step(now_ms)
|
||||
elif phase == _RTL_LAND:
|
||||
await self._check_landed()
|
||||
elif phase == _RTL_FALLBACK:
|
||||
await self._check_landed()
|
||||
# 整体落地等待上限:任何阶段都不得无限等
|
||||
if (not self._rtl_done and self._rtl_entered_ms is not None
|
||||
and now_ms - self._rtl_entered_ms
|
||||
>= self._sm_cfg.rtl_timeout_s * 1000.0):
|
||||
logger.warning("[Task] RTL 等待落地超过 %.0fs,结束任务(无法确认落地)",
|
||||
self._sm_cfg.rtl_timeout_s)
|
||||
self._rtl_done = True
|
||||
if self._logger is not None:
|
||||
self._logger.log_event(now_ms, "rtl_timeout",
|
||||
timeout_s=self._sm_cfg.rtl_timeout_s)
|
||||
|
||||
async def _check_landed(self) -> None:
|
||||
"""RTL 状态每周期取一帧 in_air 遥测;落地后 disarm 并标记完成。"""
|
||||
"""RTL 状态每周期取一帧 in_air 遥测;落地后 disarm 并标记完成。
|
||||
|
||||
两个要点:
|
||||
* `in_air()` 是 MAVSDK 遥测流:流挂起时 `async for` 会**永久阻塞**,
|
||||
主循环随之卡死(rtl_done 永不置位)。故加超时兜底,超时下拍重试。
|
||||
* 生成器**复用**(`_in_air_stream`):每拍新建一个 `in_air()` 会在
|
||||
mavsdk_server 侧反复订阅/退订同一条流(20Hz × 整个 RTL 段),是
|
||||
回调队列的无谓负担;复用后只订阅一次。
|
||||
"""
|
||||
try:
|
||||
async for is_in_air in self._drone.telemetry.in_air():
|
||||
if not is_in_air and not self._rtl_done:
|
||||
self._rtl_done = True
|
||||
try:
|
||||
await self._drone.action.disarm()
|
||||
except Exception:
|
||||
pass
|
||||
break # 只取最新一帧
|
||||
if self._in_air_stream is None:
|
||||
self._in_air_stream = self._drone.telemetry.in_air()
|
||||
async with asyncio.timeout(_IN_AIR_TIMEOUT_S):
|
||||
async for is_in_air in self._in_air_stream:
|
||||
if not is_in_air and not self._rtl_done:
|
||||
self._rtl_done = True
|
||||
try:
|
||||
await self._drone.action.disarm()
|
||||
except Exception:
|
||||
pass
|
||||
break # 只取最新一帧
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
logger.debug("[Task] in_air 遥测超时 %.1fs,下拍重试", _IN_AIR_TIMEOUT_S)
|
||||
except Exception:
|
||||
pass
|
||||
# 建流或消费失败都算流不可用:丢弃缓存,下拍重建(不永久卡在坏流上)
|
||||
self._in_air_stream = None
|
||||
|
||||
# --- 收尾 ---------------------------------------------------------------
|
||||
def _finished(self) -> bool:
|
||||
|
||||
@@ -5,23 +5,29 @@ L2(地理引导 GuidanceLaw)按任务阶段串成一条全流程。本模块
|
||||
+ 守卫」,不驱动 MAVSDK/offboard —— 那些由 runner.py(编排器)负责。每周期吃一份
|
||||
TaskInputs 快照,返回是否转移 + 原因。
|
||||
|
||||
状态链:
|
||||
WAIT ──收到有效坐标──▶ ARM_TAKEOFF ──到起飞高度──▶ GOTO(L2 坐标引导)
|
||||
│ 距目标 < switch_radius
|
||||
│ 且锁定(+可选视觉)
|
||||
状态链(2026-10-15 按实机作业预期重构):
|
||||
WAIT ──收到有效坐标──▶ ARM_TAKEOFF ──到起飞高度──▶ GOTO(L2 飞到参考点)
|
||||
│ 距参考点 < arrival_radius
|
||||
▼
|
||||
APPROACH ◀──居中稳定── TRACK(L1 航向伺服)
|
||||
│
|
||||
任意状态 ──超时 / 返航 / 紧急──▶ RTL
|
||||
SEARCH(原地偏航扫视找目标)──锁定(+可选视觉)──▶ TRACK
|
||||
│ 搜索超时 (L1 视觉追踪 +
|
||||
│ slow_approach 缓慢靠近)
|
||||
▼ │ 追踪超时
|
||||
RTL ◀────────────────────────────────┘
|
||||
|
||||
设计要点(铁律对齐):
|
||||
* 纯逻辑:输入/输出都是 dataclass,时间用外部传入的 now_ms(单调毫秒),
|
||||
不碰时钟/IO/网络,可确定性单测。
|
||||
* 全局最高优先级:emergency(丢控/RC 接管/失控)与 rtl_requested(人工返航)
|
||||
→ 任何非 RTL 状态立即转 RTL(安全第一)。
|
||||
* GOTO→TRACK 守卫复用 L1 方案 §2.3:目标锁定 bit2 +(可选)我方视觉第二判据,
|
||||
避免 bit2 粘滞导致误入追踪。
|
||||
* TRACK 丢目标:若注入坐标仍有效 → 退回 GOTO 重新引导;否则 → RTL。
|
||||
* 「参考点」语义:地面给的坐标是**搜索起点**,不是终点——单点坐标精度不足以
|
||||
直接覆盖目标,故进入到达半径即转 SEARCH 扫视,由吊舱/视觉去发现目标。
|
||||
* GOTO/SEARCH→TRACK 守卫复用 L1 方案 §2.3:目标锁定 bit2 +(可选)我方视觉
|
||||
第二判据,避免 bit2 粘滞导致误入追踪。
|
||||
* TRACK 丢目标 → 回 SEARCH 重新扫视(**不回 GOTO**:飞机本就在参考点附近,
|
||||
回 GOTO 只会反复重跑进入动作,而参考点坐标本身不精确);坐标同时失效 → RTL。
|
||||
* TRACK 不因「目标居中」退出:居中正是吊舱对准目标、应当继续缓慢靠近的时刻。
|
||||
接近的收尾只由超时 / 人工返航 / 紧急终止(无距离输入,判不出"已到目标")。
|
||||
* 阈值/超时全外置(TaskStateConfig),代码不写死数值。
|
||||
"""
|
||||
|
||||
@@ -35,9 +41,10 @@ class TaskState(Enum):
|
||||
|
||||
WAIT = "wait" # 待命:等地面坐标
|
||||
ARM_TAKEOFF = "arm_takeoff" # 解锁 + 起飞到悬停高度
|
||||
GOTO = "goto" # L2 坐标引导(飞向注入坐标)
|
||||
TRACK = "track" # L1 航向伺服(吊舱角度追踪)
|
||||
APPROACH = "approach" # 接近/悬停目标上空
|
||||
GOTO = "goto" # L2 地理引导:飞向参考点(地面给的搜索起点)
|
||||
SEARCH = "search" # 到点后原地偏航扫视搜索目标(吊舱/视觉)
|
||||
TRACK = "track" # L1 视觉追踪 + 缓慢靠近(slow_approach 保守速度)
|
||||
APPROACH = "approach" # 到点悬停(真机可选收尾;SITL/常规流程不用)
|
||||
RTL = "rtl" # 返航 + 落地 + 上锁(终态,reset() 才回 WAIT)
|
||||
|
||||
|
||||
@@ -45,15 +52,19 @@ class TaskState(Enum):
|
||||
class TaskStateConfig:
|
||||
"""状态机配置(全外置;数值均为占位,须台架/实飞标定后回填)。"""
|
||||
|
||||
switch_radius_m: float = 30.0 # GOTO→TRACK 切换半径(米)
|
||||
require_vision: bool = False # GOTO→TRACK 是否需视觉第二判据(S3 起开)
|
||||
arrival_radius_m: float = 30.0 # GOTO→SEARCH 到达参考点半径(米)
|
||||
arm_takeoff_timeout_s: float = 60.0 # 起飞阶段超时
|
||||
goto_timeout_s: float = 120.0 # 坐标引导超时
|
||||
track_timeout_s: float = 120.0 # 追踪超时
|
||||
center_hold_s: float = 3.0 # TRACK→APPROACH 居中稳定时长(秒)
|
||||
approach_hover_s: float = 10.0 # APPROACH 悬停时长(到点 → RTL)
|
||||
goto_timeout_s: float = 120.0 # 飞向参考点超时
|
||||
search_timeout_s: float = 20.0 # 搜索超时(未发现目标 → 返航)
|
||||
track_timeout_s: float = 20.0 # 追踪/靠近段上限(到点 → 返航)
|
||||
approach_hover_s: float = 10.0 # APPROACH 悬停时长(到点 → RTL;已非主流程)
|
||||
takeoff_altitude_m: float = 5.0 # 起飞目标高度(runner 起飞 + takeoff_reached 判据)
|
||||
takeoff_reached_tolerance_m: float = 0.5 # 起飞到达判据容差(低于目标不超过此值也算到达;
|
||||
# 悬停/高度估计误差下避免差厘米误判"起飞超时")
|
||||
center_threshold_deg: float = 1.0 # 脱靶量居中阈值(|off_target| < 此值判居中)
|
||||
sweep_rate_dps: float = 15.0 # SEARCH 原地扫视角速度(度/秒)
|
||||
sweep_reverse_s: float = 4.0 # SEARCH 扫视换向周期(秒);0 = 单向扫视
|
||||
rtl_timeout_s: float = 180.0 # RTL 等待落地上限(秒);超时即结束任务,防无限等待
|
||||
loop_frequency_hz: float = 20.0 # runner 主循环频率(bridge.step 节拍)
|
||||
|
||||
|
||||
@@ -66,12 +77,11 @@ class TaskInputs:
|
||||
"""
|
||||
|
||||
target_injected: bool = False # 地面坐标有效注入(新鲜、未超龄)
|
||||
distance_to_target_m: float = math.inf # 到注入目标的水平距离(GOTO 用)
|
||||
distance_to_target_m: float = math.inf # 到参考点的水平距离(GOTO 用)
|
||||
takeoff_reached: bool = False # 已到达起飞目标高度
|
||||
target_locked: bool = False # 吊舱锁定(bit2 + 遥测新鲜)
|
||||
vision_has_target: bool = False # 我方视觉第二判据(可选,require_vision 时必看)
|
||||
target_centered: bool = False # 目标居中(脱靶量长期≈0)
|
||||
centered_duration_s: float = 0.0 # 居中持续时长(外部累加,秒)
|
||||
target_locked: bool = False # 目标有效(吊舱 bit2 + 遥测新鲜 + LOS 一致性,见 bridge)
|
||||
target_centered: bool = False # 目标居中(脱靶量≈0)——仅供日志/诊断
|
||||
centered_duration_s: float = 0.0 # 居中持续时长(外部累加,秒)——不再作转移判据
|
||||
rtl_requested: bool = False # 人工返航命令
|
||||
emergency: bool = False # 紧急(丢控 / RC 接管 / 失控)
|
||||
|
||||
@@ -103,6 +113,15 @@ class TaskStateMachine:
|
||||
self._state = TaskState.WAIT
|
||||
self._entered_ms = 0
|
||||
|
||||
def force_state(self, state: TaskState, now_ms: int) -> None:
|
||||
"""强制落到某状态(仅供编排器异常兜底;不产生 TaskTransition)。
|
||||
|
||||
正常转移一律走 update()。此入口用于「主循环抛异常、必须立刻进 RTL」这类
|
||||
非判据驱动的安全路径——此时再喂一拍快照不可靠。
|
||||
"""
|
||||
self._state = state
|
||||
self._entered_ms = now_ms
|
||||
|
||||
def update(self, inputs: TaskInputs, now_ms: int) -> TaskTransition:
|
||||
"""吃一份快照,返回转移结果;无转移时 occurred=False、to_state 保持原状态。"""
|
||||
state = self._state
|
||||
@@ -127,23 +146,31 @@ class TaskStateMachine:
|
||||
elif state is TaskState.GOTO:
|
||||
if not inputs.target_injected:
|
||||
return self._goto(TaskState.RTL, "注入坐标失效", now_ms)
|
||||
if (inputs.distance_to_target_m <= self._cfg.switch_radius_m
|
||||
and self._lock_ok(inputs)):
|
||||
return self._goto(TaskState.TRACK, "进入追踪半径且目标锁定", now_ms)
|
||||
# 到参考点即结束地理引导 → 转扫视搜索(参考点是搜索起点,不是终点)
|
||||
if inputs.distance_to_target_m <= self._cfg.arrival_radius_m:
|
||||
return self._goto(TaskState.SEARCH, "已到参考点,开始搜索", now_ms)
|
||||
if self._elapsed_s(now_ms) >= self._cfg.goto_timeout_s:
|
||||
return self._goto(TaskState.RTL, "坐标引导超时", now_ms)
|
||||
return self._goto(TaskState.RTL, "飞向参考点超时", now_ms)
|
||||
|
||||
elif state is TaskState.SEARCH:
|
||||
# 扫视中一旦发现并锁定目标 → 进追踪(缓慢靠近)
|
||||
if self._lock_ok(inputs):
|
||||
return self._goto(TaskState.TRACK, "搜索发现目标并锁定", now_ms)
|
||||
if not inputs.target_injected:
|
||||
return self._goto(TaskState.RTL, "注入坐标失效", now_ms)
|
||||
if self._elapsed_s(now_ms) >= self._cfg.search_timeout_s:
|
||||
return self._goto(TaskState.RTL, "搜索超时未发现目标", now_ms)
|
||||
|
||||
elif state is TaskState.TRACK:
|
||||
if not inputs.target_locked:
|
||||
# 丢目标:坐标仍有效 → 退回 GOTO 重新引导;否则终止返航。
|
||||
# 丢目标:坐标仍有效 → 回参考点附近重新扫视;否则终止返航。
|
||||
if inputs.target_injected:
|
||||
return self._goto(TaskState.GOTO, "目标丢失,退回坐标引导", now_ms)
|
||||
return self._goto(TaskState.SEARCH, "目标丢失,重新搜索", now_ms)
|
||||
return self._goto(TaskState.RTL, "目标丢失且坐标失效", now_ms)
|
||||
if (inputs.target_centered
|
||||
and inputs.centered_duration_s >= self._cfg.center_hold_s):
|
||||
return self._goto(TaskState.APPROACH, "目标居中稳定", now_ms)
|
||||
# 注意:不再有「居中稳定 → APPROACH」分支。居中表示吊舱已对准目标,
|
||||
# 正是应当继续按 slow_approach 缓慢靠近的时刻,不是终止条件。
|
||||
if self._elapsed_s(now_ms) >= self._cfg.track_timeout_s:
|
||||
return self._goto(TaskState.RTL, "追踪超时", now_ms)
|
||||
return self._goto(TaskState.RTL, "追踪/靠近超时", now_ms)
|
||||
|
||||
elif state is TaskState.APPROACH:
|
||||
if self._elapsed_s(now_ms) >= self._cfg.approach_hover_s:
|
||||
@@ -154,15 +181,13 @@ class TaskStateMachine:
|
||||
|
||||
# --- 内部辅助 -----------------------------------------------------------
|
||||
def _lock_ok(self, inputs: TaskInputs) -> bool:
|
||||
"""GOTO→TRACK 的目标锁定判据(复用 L1 方案 §2.3)。
|
||||
"""进 TRACK 的目标判据(GOTO/SEARCH→TRACK)。
|
||||
|
||||
需吊舱锁定 bit2;若 require_vision 还需我方视觉第二判据(bit2 粘滞兜底)。
|
||||
`target_locked` 已由编排层合成:吊舱 bit2 + 遥测新鲜 + **LOS 运动学一致性**
|
||||
(见 bridge.pod_locked / los_consistency)。状态机不再重复做视觉判据 ——
|
||||
吊舱已取代双目+YOLO 成为唯一目标源,"我方视觉"不复存在。
|
||||
"""
|
||||
if not inputs.target_locked:
|
||||
return False
|
||||
if self._cfg.require_vision and not inputs.vision_has_target:
|
||||
return False
|
||||
return True
|
||||
return inputs.target_locked
|
||||
|
||||
def _elapsed_s(self, now_ms: int) -> float:
|
||||
"""进入当前状态的时长(秒)。_entered_ms=0(未打戳)时返回 0。"""
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""app_test.py —— 地面站 Flask 端点测试(test_client + 真实 UDP 回环监听)。
|
||||
|
||||
覆盖:POST /api/inject 合法/非法(UDP 实收断言)、局部坐标注入换算与拒绝、
|
||||
POST /api/command 白名单校验与 UDP 下发、POST /api/continuous 服务端持续注入启停、
|
||||
GET /api/link 链路目标读写、GET /api/state 空日志目录兜底与 continuous 标志。
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from ground_station.app import create_app # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udp_listener():
|
||||
"""真实 UDP 回环监听:返回 (port, recv_one)。"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.settimeout(1.5)
|
||||
|
||||
def recv_one():
|
||||
data, _addr = s.recvfrom(2048)
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
yield s.getsockname()[1], recv_one
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, udp_listener):
|
||||
port, _recv = udp_listener
|
||||
app = create_app(udp_port=port, log_dir=str(tmp_path),
|
||||
inject_interval_s=0.05) # 测试用短周期
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# --- /api/inject ------------------------------------------------------------
|
||||
def test_inject_ok_sends_udp(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/inject", json={"lat": 1.5, "lon": 2.5, "alt": 10})
|
||||
assert r.get_json()["ok"] is True
|
||||
assert recv_one() == {"lat": 1.5, "lon": 2.5, "alt": 10.0}
|
||||
|
||||
|
||||
def test_inject_missing_lat_400(client):
|
||||
r = client.post("/api/inject", json={"lon": 2.5})
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_inject_non_numeric_400(client):
|
||||
r = client.post("/api/inject", json={"lat": "x", "lon": 2.5})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# --- 局部坐标注入 -------------------------------------------------------------
|
||||
_TEL_HEADER = ("t_s,state,pos_valid,fix_type,num_satellites,latitude_deg,"
|
||||
"longitude_deg,relative_altitude_m,yaw_deg,target_injected,"
|
||||
"distance_to_target_m,target_locked,off_target_x_deg,"
|
||||
"off_target_y_deg,mode,yaw_rate_dps,vx_body_mps,vz_mps\n")
|
||||
|
||||
|
||||
def _write_telemetry(log_dir, lat=47.0, lon=8.0, pos_valid="True"):
|
||||
with open(Path(log_dir) / "task_telemetry.csv", "w", encoding="utf-8") as f:
|
||||
f.write(_TEL_HEADER)
|
||||
f.write(f"1.0,wait,{pos_valid},3,10,{lat},{lon},0,0,False,inf,False,"
|
||||
"0,0,,0,0,0\n")
|
||||
|
||||
|
||||
def test_inject_local_converts_from_vehicle_position(client, udp_listener, tmp_path):
|
||||
_write_telemetry(tmp_path, lat=47.0, lon=8.0)
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15, "alt": 0})
|
||||
j = r.get_json()
|
||||
assert j["ok"] is True
|
||||
exp_lat = 47.0 + 30 / 111320.0
|
||||
exp_lon = 8.0 + 15 / (111320.0 * math.cos(math.radians(47.0)))
|
||||
msg = recv_one()
|
||||
assert msg["lat"] == pytest.approx(exp_lat, abs=1e-9)
|
||||
assert msg["lon"] == pytest.approx(exp_lon, abs=1e-9)
|
||||
assert j["local"]["north_m"] == 30 and j["local"]["east_m"] == 15
|
||||
assert j["local"]["origin_lat"] == pytest.approx(47.0)
|
||||
|
||||
|
||||
def test_inject_local_rejected_without_telemetry(client):
|
||||
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
|
||||
assert r.status_code == 400
|
||||
assert "无法换算" in r.get_json()["error"]
|
||||
|
||||
|
||||
def test_inject_local_rejected_when_pos_invalid(client, tmp_path):
|
||||
_write_telemetry(tmp_path, pos_valid="False")
|
||||
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_inject_local_bad_numbers_400(client, tmp_path):
|
||||
_write_telemetry(tmp_path)
|
||||
assert client.post("/api/inject",
|
||||
json={"north_m": "x", "east_m": 15}).status_code == 400
|
||||
|
||||
|
||||
def test_inject_local_home_origin_uses_session_start(client, udp_listener, tmp_path):
|
||||
# 同一段:起始有效定位 47.0/8.0(≈起飞点),之后移动到 47.5/8.5
|
||||
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
|
||||
f.write(_TEL_HEADER)
|
||||
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
f.write("60.0,goto,True,3,10,47.5,8.5,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
_port, recv_one = udp_listener
|
||||
|
||||
j = client.post("/api/inject",
|
||||
json={"north_m": 0, "east_m": 0, "origin": "home"}).get_json()
|
||||
assert j["ok"] is True and j["local"]["origin"] == "home"
|
||||
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 段首而非最后行
|
||||
assert j["local"]["origin_lon"] == pytest.approx(8.0)
|
||||
recv_one()
|
||||
|
||||
j2 = client.post("/api/inject",
|
||||
json={"north_m": 0, "east_m": 0}).get_json() # current
|
||||
assert j2["local"]["origin"] == "current"
|
||||
assert j2["local"]["origin_lat"] == pytest.approx(47.5) # 当前位置
|
||||
recv_one()
|
||||
|
||||
|
||||
def test_inject_local_home_origin_skips_invalid_rows(client, tmp_path):
|
||||
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
|
||||
f.write(_TEL_HEADER)
|
||||
f.write("0.5,wait,False,0,0,0.0,0.0,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
j = client.post("/api/inject",
|
||||
json={"north_m": 30, "east_m": 15,
|
||||
"origin": "home"}).get_json()
|
||||
assert j["ok"] is True
|
||||
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 取首个有效定位
|
||||
|
||||
|
||||
def test_inject_local_bad_origin_400(client, tmp_path):
|
||||
_write_telemetry(tmp_path)
|
||||
r = client.post("/api/inject",
|
||||
json={"north_m": 1, "east_m": 1, "origin": "mars"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_inject_local_home_origin_rejected_without_telemetry(client):
|
||||
r = client.post("/api/inject",
|
||||
json={"north_m": 30, "east_m": 15, "origin": "home"})
|
||||
assert r.status_code == 400
|
||||
assert "起飞点" in r.get_json()["error"]
|
||||
|
||||
|
||||
# --- /api/command -----------------------------------------------------------
|
||||
def test_command_whitelist_ok_sends_udp(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/command", json={"cmd": "rtl"})
|
||||
assert r.get_json() == {"ok": True, "sent": {"cmd": "rtl"}}
|
||||
assert recv_one() == {"cmd": "rtl"}
|
||||
|
||||
|
||||
def test_command_all_whitelist_entries_accepted(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
for cmd in ("rtl", "emergency", "pod_detect_on", "pod_auto_lock",
|
||||
"pod_stop_track", "pod_detect_off", "pod_center"):
|
||||
r = client.post("/api/command", json={"cmd": cmd})
|
||||
assert r.get_json()["ok"] is True
|
||||
assert recv_one() == {"cmd": cmd}
|
||||
|
||||
|
||||
def test_command_unknown_rejected_no_udp(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/command", json={"cmd": "reboot"})
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["ok"] is False
|
||||
with pytest.raises(socket.timeout):
|
||||
recv_one() # 未下发任何 UDP → 监听超时即证明
|
||||
|
||||
|
||||
def test_command_non_string_rejected(client):
|
||||
r = client.post("/api/command", json={"cmd": 1})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# --- /api/continuous --------------------------------------------------------
|
||||
def test_continuous_requires_prior_inject(client):
|
||||
r = client.post("/api/continuous", json={"on": True})
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_continuous_non_bool_rejected(client):
|
||||
r = client.post("/api/continuous", json={"on": "yes"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_continuous_sends_repeatedly_and_stops(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 手动单发
|
||||
|
||||
r = client.post("/api/continuous", json={"on": True})
|
||||
assert r.get_json() == {"ok": True, "continuous": True}
|
||||
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 服务端自动重发
|
||||
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0}
|
||||
|
||||
r = client.post("/api/continuous", json={"on": False})
|
||||
assert r.get_json() == {"ok": True, "continuous": False}
|
||||
time.sleep(0.2)
|
||||
while True: # 排空在途包
|
||||
try:
|
||||
recv_one()
|
||||
except socket.timeout:
|
||||
break
|
||||
with pytest.raises(socket.timeout):
|
||||
recv_one() # 停止后不再发
|
||||
|
||||
|
||||
def test_continuous_uses_latest_inject(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
recv_one()
|
||||
client.post("/api/inject", json={"lat": 3.0, "lon": 4.0}) # 中途改坐标
|
||||
recv_one()
|
||||
|
||||
client.post("/api/continuous", json={"on": True})
|
||||
assert recv_one() == {"lat": 3.0, "lon": 4.0, "alt": 0.0}
|
||||
client.post("/api/continuous", json={"on": False})
|
||||
|
||||
|
||||
# --- /api/link --------------------------------------------------------------
|
||||
def test_link_get_default_and_options(client, udp_listener):
|
||||
port, _recv = udp_listener
|
||||
j = client.get("/api/link").get_json()
|
||||
assert j["host"] == "127.0.0.1"
|
||||
assert j["port"] == port
|
||||
assert any(o["host"] == "127.0.0.1" for o in j["options"])
|
||||
|
||||
|
||||
def test_link_set_bad_host_or_port_400(client):
|
||||
assert client.post("/api/link", json={"host": " "}).status_code == 400
|
||||
assert client.post("/api/link",
|
||||
json={"host": "1.2.3.4", "port": "x"}).status_code == 400
|
||||
assert client.post("/api/link",
|
||||
json={"host": "1.2.3.4", "port": 70000}).status_code == 400
|
||||
|
||||
|
||||
def test_link_switch_redirects_inject_and_command(client, udp_listener):
|
||||
_port_a, recv_a = udp_listener
|
||||
s_b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s_b.bind(("127.0.0.1", 0))
|
||||
s_b.settimeout(1.5)
|
||||
port_b = s_b.getsockname()[1]
|
||||
try:
|
||||
r = client.post("/api/link", json={"host": "127.0.0.1", "port": port_b})
|
||||
assert r.get_json() == {"ok": True, "host": "127.0.0.1", "port": port_b}
|
||||
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
data, _addr = s_b.recvfrom(2048)
|
||||
assert json.loads(data.decode("utf-8")) == {"lat": 1.0, "lon": 2.0,
|
||||
"alt": 0.0}
|
||||
client.post("/api/command", json={"cmd": "rtl"})
|
||||
data, _addr = s_b.recvfrom(2048)
|
||||
assert json.loads(data.decode("utf-8")) == {"cmd": "rtl"}
|
||||
|
||||
with pytest.raises(socket.timeout):
|
||||
recv_a() # 旧目标不再收到任何包
|
||||
finally:
|
||||
s_b.close()
|
||||
|
||||
|
||||
# --- /api/state -------------------------------------------------------------
|
||||
def test_state_empty_log_dir(client):
|
||||
j = client.get("/api/state").get_json()
|
||||
assert j["state"] == "unknown"
|
||||
assert j["telemetry"] is None
|
||||
assert j["continuous"] is False
|
||||
|
||||
|
||||
def test_video_disabled_by_default(client):
|
||||
assert client.get("/api/state").get_json()["video"] == {
|
||||
"enabled": False, "online": False}
|
||||
assert client.get("/video_feed").status_code == 503
|
||||
|
||||
|
||||
def test_state_reports_continuous(client):
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
client.post("/api/continuous", json={"on": True})
|
||||
assert client.get("/api/state").get_json()["continuous"] is True
|
||||
client.post("/api/continuous", json={"on": False})
|
||||
assert client.get("/api/state").get_json()["continuous"] is False
|
||||
@@ -0,0 +1,138 @@
|
||||
"""video_source_test.py —— RTSP→MJPEG 视频源单元测试(注入假采集器,不依赖真流)。
|
||||
|
||||
覆盖:拉帧→JPEG 编码缓存、宽度限制(等比缩放)、断线重连(重新调用工厂)、
|
||||
打开失败退避重试、online 判定、MJPEG 分块格式。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from ground_station.video_source import RtspVideoSource # noqa: E402
|
||||
|
||||
|
||||
class FakeCapture:
|
||||
"""假采集器:可注入读失败次数;帧为纯色小图。"""
|
||||
|
||||
def __init__(self, fail_reads=0, height=60, width=120):
|
||||
self.fail_reads = fail_reads
|
||||
self._frame = np.full((height, width, 3), 128, dtype=np.uint8)
|
||||
self.released = False
|
||||
|
||||
def isOpened(self):
|
||||
return True
|
||||
|
||||
def read(self):
|
||||
if self.fail_reads > 0:
|
||||
self.fail_reads -= 1
|
||||
return False, None
|
||||
return True, self._frame
|
||||
|
||||
def release(self):
|
||||
self.released = True
|
||||
|
||||
|
||||
def _wait(pred, timeout_s=4.0, step=0.02):
|
||||
t0 = time.monotonic()
|
||||
while time.monotonic() - t0 < timeout_s:
|
||||
if pred():
|
||||
return True
|
||||
time.sleep(step)
|
||||
return False
|
||||
|
||||
|
||||
def _decode(jpeg):
|
||||
import cv2
|
||||
return cv2.imdecode(np.frombuffer(jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
|
||||
|
||||
def test_frames_encoded_and_online():
|
||||
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
|
||||
capture_factory=lambda: FakeCapture())
|
||||
assert src.online is False # 尚无帧 → 离线
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None)
|
||||
seq, jpeg = src.latest()
|
||||
assert seq >= 1
|
||||
assert jpeg[:2] == b"\xff\xd8" # JPEG SOI 头
|
||||
assert _decode(jpeg).shape[1] == 120 # 宽度不超限
|
||||
assert src.online is True
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_downscale_to_max_width():
|
||||
src = RtspVideoSource("rtsp://fake", width=60, fps=50,
|
||||
capture_factory=lambda: FakeCapture(height=60, width=120))
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None)
|
||||
img = _decode(src.latest()[1])
|
||||
assert img.shape[1] == 60 and img.shape[0] == 30 # 等比缩到 60 宽
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_reconnect_after_read_failure():
|
||||
opened = []
|
||||
|
||||
def factory():
|
||||
cap = FakeCapture(fail_reads=(10 ** 6 if not opened else 0))
|
||||
opened.append(cap)
|
||||
return cap
|
||||
|
||||
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
|
||||
capture_factory=factory)
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None, timeout_s=5.0)
|
||||
assert len(opened) >= 2 # 首采集器读失败 → 重新打开
|
||||
assert opened[0].released is True
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_open_failure_keeps_retrying():
|
||||
calls = []
|
||||
|
||||
class ClosedCapture:
|
||||
def isOpened(self):
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
pass
|
||||
|
||||
def factory():
|
||||
calls.append(1)
|
||||
return ClosedCapture()
|
||||
|
||||
src = RtspVideoSource("rtsp://fake", capture_factory=factory)
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: len(calls) >= 2, timeout_s=4.0) # 1s 退避后重试
|
||||
assert src.latest()[1] is None
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_mjpeg_stream_chunks():
|
||||
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
|
||||
capture_factory=lambda: FakeCapture())
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None)
|
||||
gen = src.mjpeg_stream()
|
||||
chunk = next(gen)
|
||||
assert chunk.startswith(b"--frame\r\nContent-Type: image/jpeg\r\n")
|
||||
assert b"Content-Length: " in chunk
|
||||
assert chunk.endswith(b"\r\n")
|
||||
gen.close()
|
||||
finally:
|
||||
src.stop()
|
||||
@@ -6,6 +6,7 @@ start/stop 生命周期与停机刹车、run() 主循环异常护栏与故障恢
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from dataclasses import replace
|
||||
|
||||
@@ -29,6 +30,7 @@ class FakePx4:
|
||||
self.offboard_start_ok = True
|
||||
self.velocity_calls = []
|
||||
self.brake_calls = []
|
||||
self.yaw_calls = []
|
||||
|
||||
def own_state(self):
|
||||
return replace(self._state)
|
||||
@@ -54,6 +56,10 @@ class FakePx4:
|
||||
self.brake_calls.append(time_boot_ms)
|
||||
return True
|
||||
|
||||
async def send_yaw_rate(self, yaw_rate_dps):
|
||||
self.yaw_calls.append(yaw_rate_dps)
|
||||
return True
|
||||
|
||||
|
||||
class FakePod:
|
||||
def __init__(self):
|
||||
@@ -159,6 +165,116 @@ def test_attitude_downlink_throttled():
|
||||
assert len(pod.sent) == 2
|
||||
|
||||
|
||||
def test_control_law_throttled_to_own_frequency():
|
||||
"""生效律按自身频率推进:L2 配置 10Hz,外部 20Hz 调用时控制律只跑 10 次/秒。
|
||||
|
||||
链路维持(表3 姿态下发)不受此节流影响——否则吊舱解算会断流。
|
||||
"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
cfg = BridgeConfig()
|
||||
cfg.heading_servo.enabled = False # L2 地理引导(10Hz)
|
||||
cfg.guidance.frequency_hz = 10.0
|
||||
cfg.link.attitude_downlink_hz = 20.0
|
||||
cfg.ground_inject.enabled = True
|
||||
ground = GroundTarget(latitude_deg=0.01, longitude_deg=0.0, time_ms=1000)
|
||||
b = PodBridge(cfg, px4, pod, now_ms_fn=lambda: 1000,
|
||||
ground_target_fn=lambda: ground)
|
||||
|
||||
# 统计控制律实际推进次数(包装 update,不改变行为)
|
||||
law_updates = {"n": 0}
|
||||
real_update = b._guidance.update
|
||||
|
||||
def counting_update(inp, now):
|
||||
law_updates["n"] += 1
|
||||
return real_update(inp, now)
|
||||
|
||||
b._guidance.update = counting_update
|
||||
|
||||
ticks = 10 # 20Hz 调用 10 拍 = 500ms
|
||||
|
||||
async def scenario():
|
||||
for i in range(ticks):
|
||||
await b.step(1000 + i * 50)
|
||||
asyncio.run(scenario())
|
||||
|
||||
# 10Hz 下 500ms 只应推进 5 拍控制律;其余 5 拍重发上一拍输出保心跳
|
||||
assert law_updates["n"] == 5
|
||||
assert len(px4.velocity_calls) == ticks # offboard 设定点每拍都在下发
|
||||
assert len(pod.sent) == ticks # 表3 姿态下发不受控制律节流影响
|
||||
|
||||
|
||||
def test_control_law_throttle_resets_on_law_switch():
|
||||
"""切律后立即出第一拍(不被上一拍的节流压住)。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
cfg = BridgeConfig()
|
||||
cfg.guidance.frequency_hz = 10.0
|
||||
b = _bridge(px4, pod, cfg)
|
||||
|
||||
async def scenario():
|
||||
await b.step(1000)
|
||||
b.set_heading_servo_enabled(True)
|
||||
await b.step(1010) # 切律后 10ms 即应推进
|
||||
asyncio.run(scenario())
|
||||
assert b._last_law_ms == 1010
|
||||
|
||||
|
||||
def test_stop_is_idempotent():
|
||||
"""重复 stop() 只真正停机一次(真机在环实测:RTL 与收尾各调一次)。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
b = _bridge(px4, pod)
|
||||
|
||||
async def scenario():
|
||||
await b.start()
|
||||
await b.stop()
|
||||
await b.stop() # 第二次应为 no-op
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert pod.closed is True
|
||||
assert px4.offboard_stopped is True
|
||||
assert len(px4.brake_calls) == 1 # 刹车只发一次
|
||||
|
||||
|
||||
def test_start_after_stop_reenables_stop():
|
||||
"""停机后重新 start() 可再次生效(标志位复位)。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
b = _bridge(px4, pod)
|
||||
|
||||
async def scenario():
|
||||
await b.start()
|
||||
await b.stop()
|
||||
assert b._stopped is True
|
||||
await b.start()
|
||||
assert b._stopped is False
|
||||
await b.stop()
|
||||
asyncio.run(scenario())
|
||||
assert len(px4.brake_calls) == 2 # 两次停机各发一次刹车
|
||||
|
||||
|
||||
def test_landing_stop_keeps_telemetry_and_pod_open():
|
||||
"""降落前只退 offboard:遥测与串口保留,供落地判定与高度曲线。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
b = _bridge(px4, pod)
|
||||
|
||||
async def scenario():
|
||||
await b.start()
|
||||
await b.landing_stop()
|
||||
assert b._landing_stopped is True
|
||||
assert b._stopped is False # 未全停
|
||||
assert px4.offboard_stopped is True
|
||||
assert px4.telemetry_stopped is False # 遥测仍在
|
||||
assert pod.closed is False # 串口仍在
|
||||
await b.stop() # 收尾才全停
|
||||
asyncio.run(scenario())
|
||||
assert px4.telemetry_stopped is True
|
||||
assert pod.closed is True
|
||||
assert len(px4.brake_calls) == 2 # 两次停机各发一次刹车
|
||||
|
||||
|
||||
# --- 吊舱帧入站缓存 --------------------------------------------------------
|
||||
def test_target_frame_updates_latest_and_flips_to_velocity():
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
@@ -423,6 +539,12 @@ def _locked_off_target(x=0.0, y=0.0, frame_yaw=0.0, frame_pitch=0.0, laser=50.0)
|
||||
frame_yaw_deg=frame_yaw, frame_pitch_deg=frame_pitch)
|
||||
|
||||
|
||||
def _set_pose(px4, now_ms: int, yaw_deg: float):
|
||||
"""更新假飞控姿态与时间戳(保证控制律的姿态超龄判定通过)。"""
|
||||
px4._state = replace(px4._state, yaw_rad=math.radians(yaw_deg),
|
||||
time_ms=now_ms, position_time_ms=now_ms)
|
||||
|
||||
|
||||
def test_l1_off_target_beta_drives_yaw_rate():
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
@@ -503,26 +625,73 @@ def test_l1_no_telemetry_degrades_and_keeps_heartbeat():
|
||||
assert len(px4.brake_calls) == 1
|
||||
|
||||
|
||||
def test_l1_require_vision_blocks_without_source():
|
||||
# §2.3 第二判据:require_vision=True 但未接视觉源 → fail-safe 视为无目标 → 刹车
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
def test_l1_los_consistent_keeps_tracking():
|
||||
"""机头右转多少、脱靶量就左移多少(目标在世界系静止)→ 判一致,正常跟踪。"""
|
||||
clock = {"ms": 1_000_000}
|
||||
px4 = FakePx4(_valid_state(clock["ms"]))
|
||||
pod = FakePod()
|
||||
pod.queue(_locked_off_target(x=10.0))
|
||||
b = _bridge(px4, pod, _l1_config(require_vision=True))
|
||||
out = asyncio.run(b.step(1000))
|
||||
assert out.mode.name == "BRAKE_HOVER"
|
||||
assert px4.velocity_calls == []
|
||||
b = PodBridge(_l1_config(), px4, pod, now_ms_fn=lambda: clock["ms"])
|
||||
|
||||
async def scenario():
|
||||
for i in range(80): # 4s,20°/s 右转、脱靶量 −20°/s
|
||||
clock["ms"] += 50
|
||||
yaw = 20.0 * (i + 1) * 0.05
|
||||
beta = 10.0 - 20.0 * (i + 1) * 0.05
|
||||
_set_pose(px4, clock["ms"], yaw)
|
||||
pod.queue(_locked_off_target(x=beta))
|
||||
out = await b.step(clock["ms"])
|
||||
return out
|
||||
|
||||
def test_l1_require_vision_passes_with_source():
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
out = asyncio.run(scenario())
|
||||
assert b._los_consistent is True
|
||||
assert out.mode.name == "VELOCITY_HEADING"
|
||||
assert b.pod_locked(clock["ms"]) is True
|
||||
|
||||
|
||||
def test_l1_los_frozen_angle_blocks_tracking():
|
||||
"""机头转了 60° 而脱靶量几乎不变 → 角度冻结/幽灵目标 → 判丢(刹车)。"""
|
||||
clock = {"ms": 1_000_000}
|
||||
px4 = FakePx4(_valid_state(clock["ms"]))
|
||||
pod = FakePod()
|
||||
pod.queue(_locked_off_target(x=10.0))
|
||||
b = PodBridge(_l1_config(require_vision=True), px4, pod,
|
||||
now_ms_fn=lambda: 0, vision_target_fn=lambda: True)
|
||||
out = asyncio.run(b.step(1000))
|
||||
b = PodBridge(_l1_config(), px4, pod, now_ms_fn=lambda: clock["ms"])
|
||||
|
||||
async def scenario():
|
||||
out = None
|
||||
for i in range(80): # 4s,右转 20°/s,脱靶量恒定 10°
|
||||
clock["ms"] += 50
|
||||
yaw = 20.0 * (i + 1) * 0.05
|
||||
_set_pose(px4, clock["ms"], yaw)
|
||||
pod.queue(_locked_off_target(x=10.0))
|
||||
out = await b.step(clock["ms"])
|
||||
return out
|
||||
|
||||
out = asyncio.run(scenario())
|
||||
assert b._los_consistent is False
|
||||
assert out.mode.name == "BRAKE_HOVER" # 控制律被喂 tracked=False
|
||||
assert b.pod_locked(clock["ms"]) is False # 状态机侧也判为丢目标
|
||||
|
||||
|
||||
def test_l1_los_disabled_falls_back_to_bit2():
|
||||
"""关掉一致性判据 → 退回"只信 bit2"(角度冻结也不再判丢)。"""
|
||||
clock = {"ms": 1_000_000}
|
||||
px4 = FakePx4(_valid_state(clock["ms"]))
|
||||
pod = FakePod()
|
||||
b = PodBridge(_l1_config(los_consistency_enabled=False), px4, pod,
|
||||
now_ms_fn=lambda: clock["ms"])
|
||||
|
||||
async def scenario():
|
||||
out = None
|
||||
for i in range(80):
|
||||
clock["ms"] += 50
|
||||
yaw = 20.0 * (i + 1) * 0.05
|
||||
_set_pose(px4, clock["ms"], yaw)
|
||||
pod.queue(_locked_off_target(x=10.0))
|
||||
out = await b.step(clock["ms"])
|
||||
return out
|
||||
|
||||
out = asyncio.run(scenario())
|
||||
assert b._los_consistent is True
|
||||
assert out.mode.name == "VELOCITY_HEADING"
|
||||
assert out.yaw_rate_dps == pytest.approx(10.0, abs=1e-9)
|
||||
|
||||
|
||||
# --- 地面坐标注入(A2)-------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""ground_link_test.py —— 地面坐标注入通道单元测试。
|
||||
"""ground_link_test.py —— 地面站链路单元测试。
|
||||
|
||||
覆盖:parse_payload 解析(合法/缺字段/非法 JSON/非对象/类型错误)、
|
||||
is_valid 新鲜度判定(未注入/新鲜/超龄边界)。
|
||||
parse_message 双通道(坐标 + {"cmd"} 命令)、命令队列 drain、
|
||||
is_valid 新鲜度判定(未注入/新鲜/超龄边界)、run() 真 UDP 回环。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pod_bridge.ground_link import GroundInjectConfig, GroundLink, GroundTarget
|
||||
from pod_bridge.ground_link import (GroundCommand, GroundInjectConfig,
|
||||
GroundLink, GroundTarget)
|
||||
|
||||
|
||||
# --- parse_payload ----------------------------------------------------------
|
||||
@@ -92,3 +94,108 @@ def test_latest_returns_injected_target():
|
||||
_inject(link, 1000)
|
||||
assert link.latest().latitude_deg == pytest.approx(1.0)
|
||||
assert link.latest().time_ms == 1000
|
||||
|
||||
|
||||
# --- parse_message(坐标 + 命令双通道)---------------------------------------
|
||||
def test_parse_message_command():
|
||||
m = GroundLink.parse_message(b'{"cmd": "rtl"}')
|
||||
assert isinstance(m, GroundCommand)
|
||||
assert m.name == "rtl"
|
||||
|
||||
|
||||
def test_parse_message_command_normalized():
|
||||
m = GroundLink.parse_message(b'{"cmd": " RTL "}')
|
||||
assert isinstance(m, GroundCommand)
|
||||
assert m.name == "rtl"
|
||||
|
||||
|
||||
def test_parse_message_cmd_takes_precedence_over_coords():
|
||||
m = GroundLink.parse_message(b'{"cmd": "emergency", "lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundCommand)
|
||||
assert m.name == "emergency"
|
||||
|
||||
|
||||
def test_parse_message_coords_still_work():
|
||||
m = GroundLink.parse_message(b'{"lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundTarget)
|
||||
assert m.latitude_deg == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_parse_message_empty_cmd_falls_back_to_coords():
|
||||
m = GroundLink.parse_message(b'{"cmd": "", "lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundTarget)
|
||||
|
||||
|
||||
def test_parse_message_non_string_cmd_falls_back_to_coords():
|
||||
m = GroundLink.parse_message(b'{"cmd": 5, "lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundTarget)
|
||||
|
||||
|
||||
def test_parse_message_invalid_returns_none():
|
||||
assert GroundLink.parse_message(b"not json") is None
|
||||
|
||||
|
||||
# --- drain_commands ---------------------------------------------------------
|
||||
def test_drain_commands_empty():
|
||||
assert _link().drain_commands() == []
|
||||
|
||||
|
||||
def test_drain_commands_fifo_and_clear():
|
||||
link = _link()
|
||||
link._commands.append("rtl")
|
||||
link._commands.append("pod_center")
|
||||
assert link.drain_commands() == ["rtl", "pod_center"]
|
||||
assert link.drain_commands() == [] # 取后清空
|
||||
|
||||
|
||||
def test_commands_queue_drops_oldest_when_full():
|
||||
link = _link()
|
||||
for i in range(20):
|
||||
link._commands.append(f"c{i}")
|
||||
cmds = link.drain_commands()
|
||||
assert len(cmds) == 16 # maxlen=16
|
||||
assert cmds[0] == "c4" and cmds[-1] == "c19"
|
||||
|
||||
|
||||
# --- run() 真 UDP 回环(坐标 + 命令)-----------------------------------------
|
||||
def test_run_receives_coords_and_commands():
|
||||
import asyncio
|
||||
import socket as socket_mod
|
||||
|
||||
probe = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
|
||||
link = GroundLink(GroundInjectConfig(host="127.0.0.1", port=port),
|
||||
now_ms_fn=lambda: 1234)
|
||||
addr = ("127.0.0.1", port)
|
||||
|
||||
async def scenario():
|
||||
stop = asyncio.Event()
|
||||
task = asyncio.ensure_future(link.run(stop))
|
||||
udp = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
|
||||
try:
|
||||
for _ in range(200): # 重发防 bind 竞态
|
||||
udp.sendto(b'{"lat": 1.5, "lon": 2.5}', addr)
|
||||
await asyncio.sleep(0.01)
|
||||
if link.latest().time_ms == 1234:
|
||||
break
|
||||
udp.sendto(b'{"cmd": "rtl"}', addr)
|
||||
for _ in range(100):
|
||||
if link._commands:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
udp.close()
|
||||
stop.set()
|
||||
task.cancel() # 收包循环阻塞在 sock_recvfrom,须 cancel 唤醒(同 SITL 入口)
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert link.latest().latitude_deg == pytest.approx(1.5)
|
||||
assert link.latest().time_ms == 1234
|
||||
assert link.drain_commands() == ["rtl"]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""los_consistency_test.py —— 吊舱角度运动学一致性判据的纯逻辑测试。
|
||||
|
||||
背景:本吊舱无电机、相机刚性固连机体,机头转 Δψ 时真实目标脱靶量必变 ≈−Δψ。
|
||||
判据 `residual = Δβ + Δψ`:超限且激励足够 → 判不一致(角度冻结/幽灵目标)。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pod_bridge.los_consistency import (LosConsistencyConfig,
|
||||
LosConsistencyMonitor, wrap_deg_180)
|
||||
|
||||
|
||||
def _mon(cfg=None):
|
||||
return LosConsistencyMonitor(cfg or LosConsistencyConfig())
|
||||
|
||||
|
||||
def _drive(mon, n, dt_ms, yaw0=0.0, beta0=0.0, yaw_rate=0.0, beta_rate=0.0,
|
||||
t0=0):
|
||||
"""按固定角速度喂 n 拍,返回最后一次状态。"""
|
||||
status = None
|
||||
for i in range(n):
|
||||
t = t0 + (i + 1) * dt_ms
|
||||
yaw = yaw0 + yaw_rate * (i + 1) * dt_ms / 1000.0
|
||||
beta = beta0 + beta_rate * (i + 1) * dt_ms / 1000.0
|
||||
status = mon.update(t, yaw, beta)
|
||||
return status
|
||||
|
||||
|
||||
# --- 条件开关 ---------------------------------------------------------------
|
||||
def test_disabled_always_consistent():
|
||||
mon = _mon(LosConsistencyConfig(enabled=False))
|
||||
status = _drive(mon, 100, 50, yaw_rate=30.0, beta_rate=0.0)
|
||||
assert status.consistent is True
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
def test_no_history_is_not_excited():
|
||||
mon = _mon()
|
||||
status = mon.update(1000, 0.0, 10.0)
|
||||
assert status.consistent is True
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
def test_insufficient_span_is_not_excited():
|
||||
"""窗口未积累到 3/4 之前不做判断(避免一开始就误判丢目标)。"""
|
||||
mon = _mon(LosConsistencyConfig(window_ms=3000))
|
||||
status = _drive(mon, 20, 50, yaw_rate=40.0, beta_rate=0.0) # 仅 1s
|
||||
assert status.excited is False
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
# --- 一致:机头转、脱靶量反向跟随 -------------------------------------------
|
||||
def test_consistent_rotation_is_ok():
|
||||
"""右转 20°/s 且 β 以 −20°/s 变化 → 残差≈0。"""
|
||||
mon = _mon()
|
||||
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=-20.0) # 4s
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
assert status.residual_deg == pytest.approx(0.0, abs=0.5)
|
||||
|
||||
|
||||
def test_consistent_left_rotation_is_ok():
|
||||
"""左转(负角速度)且 β 正向跟随 → 同样一致(符号对称)。"""
|
||||
mon = _mon()
|
||||
status = _drive(mon, 80, 50, yaw_rate=-20.0, beta_rate=20.0)
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
def test_target_motion_within_residual_tolerance_is_ok():
|
||||
"""目标自身在动(β 变化与 −Δψ 不完全相等)但残差在容差内 → 仍判一致。"""
|
||||
mon = _mon(LosConsistencyConfig(max_residual_deg=8.0))
|
||||
# 4s 内 Δψ=80°,β 只反向变 76° → 残差 4°(< 8°)
|
||||
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=-19.0)
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
# --- 不一致:角度冻结/幽灵目标 ---------------------------------------------
|
||||
def test_frozen_beta_while_yawing_is_inconsistent():
|
||||
"""机头转 80° 而脱靶量纹丝不动 → 角度冻结 → 判不一致。
|
||||
|
||||
比较窗口为 3s(默认),故 4s 数据里只比较最后 3s:Δψ = 20°/s × 3s = 60°。
|
||||
"""
|
||||
mon = _mon()
|
||||
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=0.0)
|
||||
assert status.excited is True
|
||||
assert status.consistent is False
|
||||
assert status.frozen is True
|
||||
assert status.residual_deg == pytest.approx(60.0, abs=2.0) # 窗口内 Δψ
|
||||
|
||||
|
||||
def test_small_yaw_change_does_not_judge_even_if_residual_big():
|
||||
"""转得不够多(< 激励门槛)时不判不一致 —— 避免误杀。"""
|
||||
mon = _mon(LosConsistencyConfig(min_yaw_change_deg=20.0))
|
||||
# 窗口内只转 8°,虽然 β 没动(残差 8°)但未达激励门槛
|
||||
status = _drive(mon, 80, 50, yaw_rate=2.0, beta_rate=0.0)
|
||||
assert status.excited is False
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
# --- 历史管理 ---------------------------------------------------------------
|
||||
def test_reset_clears_history():
|
||||
mon = _mon()
|
||||
_drive(mon, 80, 50, yaw_rate=20.0, beta_rate=0.0)
|
||||
assert mon._samples # 有历史
|
||||
mon.reset()
|
||||
assert not mon._samples
|
||||
status = mon.update(9999, 0.0, 0.0)
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
def test_old_samples_dropped():
|
||||
"""超出历史保留期的样本被丢弃,不会永久污染判定。"""
|
||||
mon = _mon(LosConsistencyConfig(window_ms=1000, history_factor=2.0))
|
||||
_drive(mon, 40, 50, yaw_rate=50.0, beta_rate=0.0) # 2s
|
||||
# 再喂一拍"很久之后",旧样本应被清掉
|
||||
status = mon.update(60_000, 0.0, 0.0)
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
# --- 角度回绕 ---------------------------------------------------------------
|
||||
@pytest.mark.parametrize("deg,expect", [
|
||||
(0.0, 0.0), (179.0, 179.0), (181.0, -179.0),
|
||||
(-181.0, 179.0), (359.0, -1.0),
|
||||
])
|
||||
def test_wrap_deg_180(deg, expect):
|
||||
assert wrap_deg_180(deg) == pytest.approx(expect)
|
||||
|
||||
|
||||
def test_wrap_around_north_is_consistent():
|
||||
"""航向跨过 ±180 与 β 同步变化时,不应因回绕误判。"""
|
||||
mon = _mon()
|
||||
# 从 yaw=170 转到 190(即 -170),β 从 10 到 -10
|
||||
for i in range(80):
|
||||
t = (i + 1) * 50
|
||||
yaw = 170.0 + 20.0 * (i + 1) * 0.05
|
||||
beta = 10.0 - 20.0 * (i + 1) * 0.05
|
||||
status = mon.update(t, wrap_deg_180(yaw), beta)
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
@@ -43,11 +43,13 @@ class FakeOffboard:
|
||||
class FakeTelemetry:
|
||||
"""各遥测流:先吐预设消息,再挂起(由 stop_telemetry 取消)。"""
|
||||
|
||||
def __init__(self, position=None, attitude=None, gps=None, velocity=None):
|
||||
def __init__(self, position=None, attitude=None, gps=None, velocity=None,
|
||||
status=None):
|
||||
self._position = position or []
|
||||
self._attitude = attitude or []
|
||||
self._gps = gps or []
|
||||
self._velocity = velocity or []
|
||||
self._status = status or []
|
||||
|
||||
async def _stream(self, msgs):
|
||||
for m in msgs:
|
||||
@@ -66,6 +68,9 @@ class FakeTelemetry:
|
||||
def velocity_ned(self):
|
||||
return self._stream(self._velocity)
|
||||
|
||||
def status_text(self):
|
||||
return self._stream(self._status)
|
||||
|
||||
|
||||
class FakeDrone:
|
||||
def __init__(self, telemetry=None):
|
||||
@@ -197,6 +202,35 @@ def test_start_telemetry_populates_cache_then_stop():
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
# --- 飞控状态文本(STATUSTEXT)----------------------------------------------
|
||||
class Sev(Enum):
|
||||
INFO = 1
|
||||
WARNING = 2
|
||||
|
||||
|
||||
def test_update_fc_status_and_take_messages():
|
||||
gw = Px4Gateway(FakeDrone())
|
||||
gw.update_fc_status(SimpleNamespace(type=Sev.WARNING, text="Low battery"))
|
||||
gw.update_fc_status(SimpleNamespace(type="INFO", text="Takeoff detected"))
|
||||
assert gw.take_fc_messages() == [("WARNING", "Low battery"),
|
||||
("INFO", "Takeoff detected")]
|
||||
assert gw.take_fc_messages() == [] # 取后清空
|
||||
|
||||
|
||||
def test_status_text_subscription_captures_messages():
|
||||
telemetry = FakeTelemetry(status=[
|
||||
SimpleNamespace(type=Sev.WARNING, text="EKF variance")])
|
||||
gw = Px4Gateway(FakeDrone(telemetry))
|
||||
|
||||
async def scenario():
|
||||
await gw.start_telemetry()
|
||||
await asyncio.sleep(0.05)
|
||||
assert gw.take_fc_messages() == [("WARNING", "EKF variance")]
|
||||
await gw.stop_telemetry()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_default_own_state_is_invalid():
|
||||
s = OwnState()
|
||||
assert s.has_position is False and s.position_valid is False
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
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.
@@ -1,94 +0,0 @@
|
||||
"""StereoConfig 校验与 JSON 加载测试(规格 §9:配置外置、fail-fast)。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import StereoConfig, load_from_json
|
||||
|
||||
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "stereo_ranger.json"
|
||||
|
||||
|
||||
def _config_dict() -> dict:
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write(tmp_path: Path, data: dict) -> str:
|
||||
path = tmp_path / "stereo.json"
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_defaults_are_valid_and_domains_are_consistent():
|
||||
cfg = StereoConfig()
|
||||
assert cfg.focal_baseline_px_m == pytest.approx(cfg.fx_px * cfg.baseline_m)
|
||||
assert cfg.z_min_m == pytest.approx(cfg.focal_baseline_px_m / cfg.disparity_max_px)
|
||||
assert cfg.z_max_m == pytest.approx(cfg.focal_baseline_px_m / cfg.disparity_min_px)
|
||||
assert cfg.z_error_budget_max_m == pytest.approx((cfg.max_delta_z_m * cfg.focal_baseline_px_m) ** 0.5)
|
||||
|
||||
|
||||
def test_load_from_json_matches_defaults():
|
||||
assert load_from_json(str(CONFIG_PATH)) == StereoConfig()
|
||||
|
||||
|
||||
def test_missing_field_rejected(tmp_path):
|
||||
data = _config_dict()
|
||||
data.pop("baseline_m")
|
||||
with pytest.raises(ValueError, match="缺少配置字段"):
|
||||
load_from_json(_write(tmp_path, data))
|
||||
|
||||
|
||||
def test_unknown_field_rejected(tmp_path):
|
||||
data = _config_dict()
|
||||
data["radar_fallback"] = True
|
||||
with pytest.raises(ValueError, match="未知配置字段"):
|
||||
load_from_json(_write(tmp_path, data))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("fx_px", "960"),
|
||||
("consistency_N_frames", 3.5),
|
||||
("smooth_alpha", True),
|
||||
],
|
||||
)
|
||||
def test_type_error_rejected(tmp_path, field, value):
|
||||
data = _config_dict()
|
||||
data[field] = value
|
||||
with pytest.raises(ValueError, match="配置类型非法"):
|
||||
load_from_json(_write(tmp_path, data))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("fx_px", 0.0),
|
||||
("fy_px", -1.0),
|
||||
("cx_px", -0.5),
|
||||
("cy_px", -0.5),
|
||||
("baseline_m", 0.0),
|
||||
("tau_y_px", 0.0),
|
||||
("tau_h_ratio", 0.0),
|
||||
("disparity_min_px", 0.0),
|
||||
("disparity_min_px", 60.0),
|
||||
("max_delta_z_m", 0.0),
|
||||
("consistency_N_frames", 0),
|
||||
("consistency_M_frames", 2),
|
||||
("consistency_jump_px", 0.0),
|
||||
("smooth_alpha", 0.0),
|
||||
("smooth_alpha", 1.5),
|
||||
("jump_reset_m", 0.0),
|
||||
("filter_reset_frames", 0),
|
||||
("isolation_gate_px", 0.0),
|
||||
],
|
||||
)
|
||||
def test_invalid_values_rejected(field, value):
|
||||
with pytest.raises(ValueError, match="配置非法"):
|
||||
StereoConfig(**{field: value})
|
||||
|
||||
|
||||
def test_boundary_values_accepted():
|
||||
cfg = StereoConfig(disparity_min_px=3.0, disparity_max_px=3.0 + 1e-9, smooth_alpha=1.0, consistency_M_frames=3)
|
||||
assert cfg.z_max_m > cfg.z_min_m
|
||||
@@ -1,80 +0,0 @@
|
||||
"""一致性监控测试(规格 §6.7/§11.4:N 帧降级、M 帧判 invalid、正常帧清零、每目标隔离)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import ConsistencyMonitor
|
||||
|
||||
|
||||
def _monitor() -> ConsistencyMonitor:
|
||||
return ConsistencyMonitor(jump_px=3.0, degrade_frames=3, invalid_frames=5)
|
||||
|
||||
|
||||
def test_first_frame_has_no_reference_jump():
|
||||
status = _monitor().advance("t", 10.0)
|
||||
assert status.jump_px is None
|
||||
assert status.abnormal is False
|
||||
assert status.streak == 0
|
||||
|
||||
|
||||
def test_normal_jump_resets_streak():
|
||||
monitor = _monitor()
|
||||
monitor.advance("t", 10.0)
|
||||
abnormal = monitor.advance("t", 16.0)
|
||||
assert abnormal.abnormal and abnormal.streak == 1
|
||||
normal = monitor.advance("t", 17.0)
|
||||
assert not normal.abnormal and normal.streak == 0
|
||||
|
||||
|
||||
def test_jump_at_threshold_is_normal():
|
||||
monitor = _monitor()
|
||||
monitor.advance("t", 10.0)
|
||||
assert not monitor.advance("t", 13.0).abnormal # |ΔD| = 3.0 不超门限
|
||||
|
||||
|
||||
def test_degrades_at_n_frames_and_invalidates_at_m_frames():
|
||||
monitor = _monitor()
|
||||
monitor.advance("t", 10.0)
|
||||
disparities = [14.0, 18.0, 22.0, 26.0, 30.0] # 每帧跳变 4 px > 3 px
|
||||
statuses = [monitor.advance("t", d) for d in disparities]
|
||||
assert [s.streak for s in statuses] == [1, 2, 3, 4, 5]
|
||||
assert [s.confidence_degraded for s in statuses] == [False, False, True, True, True]
|
||||
assert [s.invalid for s in statuses] == [False, False, False, False, True]
|
||||
|
||||
|
||||
def test_targets_are_isolated():
|
||||
monitor = _monitor()
|
||||
monitor.advance("a", 10.0)
|
||||
monitor.advance("b", 20.0)
|
||||
monitor.advance("a", 15.0) # a 跳变异常
|
||||
assert monitor.streak("a") == 1
|
||||
assert monitor.streak("b") == 0
|
||||
|
||||
|
||||
def test_drop_clears_only_target():
|
||||
monitor = _monitor()
|
||||
monitor.advance("a", 10.0)
|
||||
monitor.advance("a", 20.0)
|
||||
monitor.drop("a")
|
||||
assert monitor.streak("a") == 0
|
||||
assert monitor.advance("a", 40.0).jump_px is None # 状态已清空,重新起始
|
||||
|
||||
|
||||
def test_clear_resets_all():
|
||||
monitor = _monitor()
|
||||
monitor.advance("a", 10.0)
|
||||
monitor.advance("a", 20.0)
|
||||
monitor.clear()
|
||||
assert monitor.streak("a") == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"jump_px": 0.0, "degrade_frames": 3, "invalid_frames": 5},
|
||||
{"jump_px": 3.0, "degrade_frames": 0, "invalid_frames": 5},
|
||||
{"jump_px": 3.0, "degrade_frames": 5, "invalid_frames": 3},
|
||||
],
|
||||
)
|
||||
def test_invalid_ctor_args(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
ConsistencyMonitor(**kwargs)
|
||||
@@ -1,94 +0,0 @@
|
||||
"""时间滤波器测试(规格 §6.5/§11.4:低通收敛、跳变重置不拖尾、失效清理、每目标隔离)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import DistanceFilter
|
||||
|
||||
|
||||
def _filter() -> DistanceFilter:
|
||||
return DistanceFilter(alpha=0.3, jump_reset_m=5.0, reset_frames=5)
|
||||
|
||||
|
||||
def test_first_value_initializes():
|
||||
assert _filter().advance("t", 10.0) == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_low_pass_step_response():
|
||||
distance_filter = _filter()
|
||||
distance_filter.advance("t", 10.0)
|
||||
assert distance_filter.advance("t", 12.0) == pytest.approx(10.6)
|
||||
|
||||
|
||||
def test_low_pass_converges_to_measurement():
|
||||
distance_filter = _filter()
|
||||
distance_filter.advance("t", 10.0)
|
||||
value = 10.0
|
||||
for _ in range(40):
|
||||
value = distance_filter.advance("t", 12.0)
|
||||
assert value == pytest.approx(12.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_alpha_one_is_identity():
|
||||
distance_filter = DistanceFilter(alpha=1.0, jump_reset_m=5.0, reset_frames=5)
|
||||
distance_filter.advance("t", 10.0)
|
||||
assert distance_filter.advance("t", 12.0) == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_jump_reset_without_tail():
|
||||
distance_filter = _filter()
|
||||
distance_filter.advance("t", 10.0)
|
||||
assert distance_filter.advance("t", 20.0) == pytest.approx(20.0) # |Δ| > 5 → 直接重置
|
||||
assert distance_filter.value("t") == pytest.approx(20.0)
|
||||
assert distance_filter.advance("t", 20.2) == pytest.approx(20.06) # 之后恢复低通
|
||||
|
||||
|
||||
def test_invalidate_keeps_state_before_threshold_and_clears_after():
|
||||
distance_filter = _filter()
|
||||
distance_filter.advance("t", 10.0)
|
||||
for _ in range(4):
|
||||
distance_filter.invalidate("t")
|
||||
assert distance_filter.value("t") == pytest.approx(10.0)
|
||||
assert distance_filter.invalid_streak("t") == 4
|
||||
distance_filter.invalidate("t")
|
||||
assert distance_filter.value("t") is None
|
||||
assert distance_filter.advance("t", 30.0) == pytest.approx(30.0) # 重新初始化而非平滑
|
||||
|
||||
|
||||
def test_valid_frame_resets_invalid_streak():
|
||||
distance_filter = _filter()
|
||||
distance_filter.advance("t", 10.0)
|
||||
distance_filter.invalidate("t")
|
||||
distance_filter.invalidate("t")
|
||||
distance_filter.advance("t", 10.5)
|
||||
assert distance_filter.invalid_streak("t") == 0
|
||||
|
||||
|
||||
def test_invalidate_without_state_is_noop():
|
||||
distance_filter = _filter()
|
||||
distance_filter.invalidate("t")
|
||||
assert distance_filter.value("t") is None
|
||||
|
||||
|
||||
def test_targets_are_isolated():
|
||||
distance_filter = _filter()
|
||||
distance_filter.advance("a", 10.0)
|
||||
distance_filter.advance("b", 100.0)
|
||||
assert distance_filter.advance("a", 12.0) == pytest.approx(10.6)
|
||||
assert distance_filter.value("b") == pytest.approx(100.0)
|
||||
distance_filter.drop("a")
|
||||
assert distance_filter.value("a") is None
|
||||
assert distance_filter.value("b") == pytest.approx(100.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"alpha": 0.0, "jump_reset_m": 5.0, "reset_frames": 5},
|
||||
{"alpha": 1.5, "jump_reset_m": 5.0, "reset_frames": 5},
|
||||
{"alpha": 0.3, "jump_reset_m": 0.0, "reset_frames": 5},
|
||||
{"alpha": 0.3, "jump_reset_m": 5.0, "reset_frames": 0},
|
||||
],
|
||||
)
|
||||
def test_invalid_ctor_args(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
DistanceFilter(**kwargs)
|
||||
@@ -1,103 +0,0 @@
|
||||
"""测距计算与有效性判定测试(规格 §3.2/§6.4/§6.6/§7.1/§11.4)。"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import (
|
||||
Detection,
|
||||
StereoConfig,
|
||||
confidence_from_cost,
|
||||
evaluate_validity,
|
||||
forward_distance_m,
|
||||
range_error_abs_m,
|
||||
range_error_rel,
|
||||
solve_range,
|
||||
virtual_center_px,
|
||||
)
|
||||
|
||||
CFG = StereoConfig()
|
||||
|
||||
|
||||
def _boxes_for_point(x_m: float, y_m: float, z_m: float, width_px: float = 50.0, height_px: float = 40.0):
|
||||
"""由左目系 (X, Y, Z) 反推左右目框(同宽,边缘视差 = 中心视差 = F·B/Z)。"""
|
||||
d_px = CFG.focal_baseline_px_m / z_m
|
||||
x_l = CFG.cx_px + CFG.fx_px * x_m / z_m
|
||||
y_l = CFG.cy_px + CFG.fy_px * y_m / z_m
|
||||
left = Detection(x_l - width_px / 2, y_l - height_px / 2, x_l + width_px / 2, y_l + height_px / 2)
|
||||
right = Detection(
|
||||
x_l - width_px / 2 - d_px, y_l - height_px / 2, x_l + width_px / 2 - d_px, y_l + height_px / 2
|
||||
)
|
||||
return left, right, d_px
|
||||
|
||||
|
||||
def test_solve_range_recovers_ground_truth():
|
||||
# §11.4:已知 Z 反推 D 构造框对,断言 Z = F·B/D 回收
|
||||
left, right, d_px = _boxes_for_point(0.6, 0.3, 6.0)
|
||||
solution = solve_range(left, right, d_px, CFG)
|
||||
assert solution.z_m == pytest.approx(6.0)
|
||||
assert solution.x_m == pytest.approx(0.6)
|
||||
assert solution.y_m == pytest.approx(0.3)
|
||||
assert solution.r_m == pytest.approx(math.sqrt(0.6**2 + 0.3**2 + 6.0**2))
|
||||
|
||||
|
||||
def test_forward_distance_rejects_non_positive_disparity():
|
||||
with pytest.raises(ValueError):
|
||||
forward_distance_m(0.0, CFG)
|
||||
with pytest.raises(ValueError):
|
||||
forward_distance_m(-2.0, CFG)
|
||||
|
||||
|
||||
def test_virtual_center_equals_midline_camera_projection():
|
||||
# §6.6 恒等式:xL = cx + F(X_C + B/2)/Z,xR = cx + F(X_C − B/2)/Z ⇒ c_mid = cx + F·X_C/Z
|
||||
z_m = 5.0
|
||||
x_center_m = 0.57
|
||||
x_l = CFG.cx_px + CFG.fx_px * (x_center_m + CFG.baseline_m / 2.0) / z_m
|
||||
x_r = CFG.cx_px + CFG.fx_px * (x_center_m - CFG.baseline_m / 2.0) / z_m
|
||||
left = Detection(x_l - 30.0, 480.0, x_l + 30.0, 520.0)
|
||||
right = Detection(x_r - 30.0, 480.0, x_r + 30.0, 520.0)
|
||||
center_mid = virtual_center_px(left, right)
|
||||
assert center_mid[0] == pytest.approx(CFG.cx_px + CFG.fx_px * x_center_m / z_m)
|
||||
assert center_mid[1] == pytest.approx(500.0)
|
||||
|
||||
|
||||
def test_error_model_is_quadratic_in_z():
|
||||
assert range_error_abs_m(10.0, CFG) == pytest.approx(10.0**2 / CFG.focal_baseline_px_m)
|
||||
assert range_error_rel(10.0, CFG) == pytest.approx(10.0 / CFG.focal_baseline_px_m)
|
||||
assert range_error_abs_m(20.0, CFG) == pytest.approx(4.0 * range_error_abs_m(10.0, CFG))
|
||||
|
||||
|
||||
def test_validity_gate_1_out_of_domain():
|
||||
decision = evaluate_validity(CFG.disparity_min_px - 0.1, 30.0, CFG)
|
||||
assert not decision.in_domain and not decision.valid
|
||||
|
||||
|
||||
def test_validity_gate_2_error_budget_blocks_far_field():
|
||||
# 域内(D = 4.8 px → Z = 12 m)但 δZ = 2.5 m > 2 m,预算门拦截
|
||||
d_px = 4.8
|
||||
z_m = CFG.focal_baseline_px_m / d_px
|
||||
decision = evaluate_validity(d_px, z_m, CFG)
|
||||
assert decision.in_domain and not decision.budget_ok and not decision.valid
|
||||
|
||||
|
||||
def test_validity_gate_2_boundary_sides():
|
||||
z_limit = CFG.z_error_budget_max_m
|
||||
assert evaluate_validity(CFG.focal_baseline_px_m / z_limit, z_limit * 0.999, CFG).budget_ok
|
||||
assert not evaluate_validity(CFG.focal_baseline_px_m / z_limit, z_limit * 1.001, CFG).budget_ok
|
||||
|
||||
|
||||
def test_validity_requires_finite_z():
|
||||
assert not evaluate_validity(5.0, None, CFG).valid
|
||||
assert not evaluate_validity(5.0, float("inf"), CFG).valid
|
||||
|
||||
|
||||
def test_domain_boundaries_are_inclusive():
|
||||
assert evaluate_validity(CFG.disparity_min_px, 19.2, CFG).in_domain
|
||||
assert evaluate_validity(CFG.disparity_max_px, 1.0, CFG).in_domain
|
||||
|
||||
|
||||
def test_confidence_from_cost_linear_clamped():
|
||||
assert confidence_from_cost(0.0) == pytest.approx(1.0)
|
||||
assert confidence_from_cost(0.4) == pytest.approx(0.6)
|
||||
assert confidence_from_cost(1.0) == pytest.approx(0.0)
|
||||
assert confidence_from_cost(1.5) == pytest.approx(0.0)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""匹配器测试(规格 §6.2/§11.4:硬约束各一例、贪心排序、一一配对、cost ≥ 1 拒绝)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import Detection, GreedyMatcher, SamplePoint, StereoConfig, sample_point
|
||||
|
||||
CFG = StereoConfig()
|
||||
MATCHER = GreedyMatcher()
|
||||
|
||||
|
||||
def _point(cx: float, cy: float, *, w: float = 60.0, h: float = 40.0, class_id: int = 0) -> SamplePoint:
|
||||
return sample_point(
|
||||
Detection(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0, class_id=class_id)
|
||||
)
|
||||
|
||||
|
||||
def test_class_mismatch_rejected():
|
||||
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0, class_id=1)], CFG) == []
|
||||
|
||||
|
||||
def test_dy_beyond_tau_rejected():
|
||||
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 502.1)], CFG) == []
|
||||
|
||||
|
||||
def test_dy_within_tau_accepted_with_cost():
|
||||
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 501.9)], CFG)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0].cost == pytest.approx(1.9 / CFG.tau_y_px)
|
||||
|
||||
|
||||
def test_height_ratio_beyond_tau_rejected():
|
||||
assert MATCHER.match([_point(1000.0, 500.0, h=100.0)], [_point(1000.0, 500.0, h=140.0)], CFG) == []
|
||||
|
||||
|
||||
def test_height_ratio_near_tau_accepted():
|
||||
pairs = MATCHER.match([_point(1000.0, 500.0, h=120.0)], [_point(1000.0, 500.0, h=159.0)], CFG)
|
||||
assert len(pairs) == 1
|
||||
|
||||
|
||||
def test_cost_at_one_rejected():
|
||||
# dy = tau_y ⇒ cost = 1.0,严格 cost < 1 才接受(§6.2)
|
||||
assert MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 502.0)], CFG) == []
|
||||
|
||||
|
||||
def test_combined_cost_at_one_rejected():
|
||||
# dy/tau = 0.5 与 Δh/(tau_h·h_max) = 0.5 相加恰为 1.0 → 拒绝
|
||||
assert MATCHER.match([_point(1000.0, 500.0, h=112.0)], [_point(1000.0, 501.0, h=128.0)], CFG) == []
|
||||
|
||||
|
||||
def test_greedy_pairs_cheapest_first_and_one_to_one():
|
||||
left = [_point(1000.0, 500.0), _point(1000.0, 501.0)]
|
||||
right = [_point(1000.0, 500.0), _point(1000.0, 501.0)]
|
||||
pairs = MATCHER.match(left, right, CFG)
|
||||
assert [(p.left_index, p.right_index) for p in pairs] == [(0, 0), (1, 1)]
|
||||
assert pairs[0].cost == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_one_left_claims_cheapest_right():
|
||||
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0), _point(1000.0, 500.5)], CFG)
|
||||
assert [(p.left_index, p.right_index) for p in pairs] == [(0, 0)]
|
||||
|
||||
|
||||
def test_two_lefts_cannot_share_one_right():
|
||||
left = [_point(1000.0, 500.0), _point(1000.0, 500.2)]
|
||||
right = [_point(1000.0, 500.0)]
|
||||
pairs = MATCHER.match(left, right, CFG)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0].left_index == 0
|
||||
|
||||
|
||||
def test_unmatched_right_is_not_paired():
|
||||
pairs = MATCHER.match([_point(1000.0, 500.0)], [_point(1000.0, 500.0), _point(1400.0, 500.0)], CFG)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0].right_index == 0
|
||||
|
||||
|
||||
def test_non_finite_points_excluded():
|
||||
bad = sample_point(Detection(float("nan"), 500.0, 1060.0, 540.0))
|
||||
good = _point(1000.0, 500.0)
|
||||
assert MATCHER.match([bad], [good], CFG) == []
|
||||
assert MATCHER.match([good], [bad], CFG) == []
|
||||
|
||||
|
||||
def test_zero_height_box_rejected():
|
||||
left = sample_point(Detection(970.0, 500.0, 1030.0, 500.0))
|
||||
right = sample_point(Detection(970.0, 500.0, 1030.0, 500.0))
|
||||
assert MATCHER.match([left], [right], CFG) == []
|
||||
@@ -1,79 +0,0 @@
|
||||
"""视差取点器测试(规格 §6.1/§11.4:同侧边缘、框宽差隔离、越窗回退、边界含等号)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import Detection, DisparitySource, StereoConfig, sample_point, select_disparity
|
||||
|
||||
CFG = StereoConfig()
|
||||
|
||||
|
||||
def _box(x1: float, x2: float, *, y1: float = 500.0, y2: float = 540.0) -> Detection:
|
||||
return Detection(x1, y1, x2, y2)
|
||||
|
||||
|
||||
def _disparity(left: Detection, right: Detection):
|
||||
return select_disparity(sample_point(left), sample_point(right), CFG)
|
||||
|
||||
|
||||
def test_left_edge_used_as_primary_sampling():
|
||||
d = _disparity(_box(1000.0, 1060.0), _box(990.4, 1050.4))
|
||||
assert d.source is DisparitySource.LEFT_EDGE
|
||||
assert d.d_px == pytest.approx(9.6)
|
||||
assert d.in_domain and d.positive and not d.fallback_used
|
||||
|
||||
|
||||
def test_box_width_difference_does_not_enter_edge_disparity():
|
||||
# §11.4:构造两目不等宽框,断言同侧边缘视差相同(框宽差不进入视差)
|
||||
left = _box(1000.0, 1100.0) # 宽 100
|
||||
right_narrow = _box(994.0, 1024.0) # 宽 30,左边缘差同 6 px
|
||||
right_wide = _box(994.0, 1094.0) # 宽 100
|
||||
d_narrow = _disparity(left, right_narrow)
|
||||
d_wide = _disparity(left, right_wide)
|
||||
assert d_narrow.d_px == pytest.approx(6.0)
|
||||
assert d_wide.d_px == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_center_disparity_would_be_polluted_by_width_difference():
|
||||
# 对照:中心视差 41 px = 边缘视差 6 px + 框宽差的一半 35 px,证明主取点选择边缘的理由
|
||||
left = _box(1000.0, 1100.0) # 宽 100, center 1050
|
||||
right_narrow = _box(994.0, 1024.0) # 宽 30, center 1009
|
||||
assert (left.cx - right_narrow.cx) == pytest.approx(41.0)
|
||||
|
||||
|
||||
def test_center_fallback_when_edge_out_of_window():
|
||||
# 边缘视差 60 px 越窗(> 57.6),中心视差 40 px 在域内 → 回退中心并标记
|
||||
left = _box(1000.0, 1060.0) # center 1030
|
||||
right = _box(940.0, 1040.0) # center 990
|
||||
d = _disparity(left, right)
|
||||
assert d.fallback_used is True
|
||||
assert d.source is DisparitySource.CENTER_FALLBACK
|
||||
assert d.d_px == pytest.approx(40.0)
|
||||
assert d.in_domain and d.positive
|
||||
|
||||
|
||||
def test_both_out_of_window_keeps_edge_value_for_diagnostics():
|
||||
left = _box(1000.0, 1200.0) # center 1100
|
||||
right = _box(900.0, 1000.0) # center 950
|
||||
d = _disparity(left, right)
|
||||
assert d.d_px == pytest.approx(100.0) # 边缘值保留(诊断 + 违例告警)
|
||||
assert not d.in_domain and not d.fallback_used and d.positive
|
||||
|
||||
|
||||
def test_domain_boundaries_are_inclusive():
|
||||
assert _disparity(_box(1000.0, 1060.0), _box(997.0, 1057.0)).in_domain # D = 3.0 = 下限
|
||||
# 上限用二进制可精确表示的 57.5 构造:十进制字面量 57.6 无法精确表示,
|
||||
# 框坐标相减会落到 57.6 常量之上,边界断言被浮点舍入干扰
|
||||
exact_cfg = StereoConfig(disparity_max_px=57.5)
|
||||
left, right = _box(1000.0, 1060.0), _box(942.5, 1002.5)
|
||||
assert select_disparity(sample_point(left), sample_point(right), exact_cfg).in_domain # D = 57.5 = 上限
|
||||
|
||||
|
||||
def test_negative_disparity_flagged_as_violation():
|
||||
d = _disparity(_box(1000.0, 1060.0), _box(1010.0, 1070.0))
|
||||
assert d.d_px == pytest.approx(-10.0)
|
||||
assert not d.positive and not d.in_domain
|
||||
|
||||
|
||||
def test_non_finite_box_marked_unusable():
|
||||
assert sample_point(Detection(float("nan"), 0.0, 10.0, 10.0)).finite is False
|
||||
assert sample_point(Detection(0.0, 0.0, 10.0, 10.0)).finite is True
|
||||
@@ -1,292 +0,0 @@
|
||||
"""全流程测试(规格 §5/§11.4:单目标、多目标、单目退化、非同帧拒收、越界、跳变)。
|
||||
|
||||
合成框对驱动,无需真实图像:几何是解析可断言的(§11.4)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import (
|
||||
Degradation,
|
||||
Detection,
|
||||
DetectionFrame,
|
||||
Eye,
|
||||
MatchPair,
|
||||
StereoConfig,
|
||||
StereoRanger,
|
||||
)
|
||||
|
||||
CFG = StereoConfig()
|
||||
LOGGER_NAME = "stereo_ranger"
|
||||
|
||||
|
||||
def _boxes(
|
||||
x_center_m: float = 0.0,
|
||||
y_m: float = 0.0,
|
||||
z_m: float = 6.0,
|
||||
*,
|
||||
w_m: float = 0.5,
|
||||
h_m: float = 0.3,
|
||||
class_id: int = 0,
|
||||
track_id: Optional[int] = None,
|
||||
confidence: float = 0.95,
|
||||
) -> Tuple[Detection, Detection]:
|
||||
"""模组中心系 (X_C, Y, Z) 目标 → 左右目检测框(两目同宽,边缘视差 = 中心视差)。"""
|
||||
x_l = CFG.cx_px + CFG.fx_px * (x_center_m + CFG.baseline_m / 2.0) / z_m
|
||||
x_r = CFG.cx_px + CFG.fx_px * (x_center_m - CFG.baseline_m / 2.0) / z_m
|
||||
y = CFG.cy_px + CFG.fy_px * y_m / z_m
|
||||
w_px = CFG.fx_px * w_m / z_m
|
||||
h_px = CFG.fy_px * h_m / z_m
|
||||
|
||||
def _box(cx_px: float) -> Detection:
|
||||
return Detection(
|
||||
cx_px - w_px / 2.0,
|
||||
y - h_px / 2.0,
|
||||
cx_px + w_px / 2.0,
|
||||
y + h_px / 2.0,
|
||||
confidence=confidence,
|
||||
class_id=class_id,
|
||||
track_id=track_id,
|
||||
)
|
||||
|
||||
return _box(x_l), _box(x_r)
|
||||
|
||||
|
||||
def _pair_with_disparity(d_px: float, *, y_center: float = 500.0, class_id: int = 0) -> Tuple[Detection, Detection]:
|
||||
"""构造视差恰为 d_px 的等宽框对(d 取 0.5 的整数倍时浮点精确)。"""
|
||||
width, height = 60.0, 40.0
|
||||
left = Detection(1000.0 - width / 2, y_center - height / 2, 1000.0 + width / 2, y_center + height / 2, class_id=class_id)
|
||||
right = Detection(
|
||||
1000.0 - d_px - width / 2,
|
||||
y_center - height / 2,
|
||||
1000.0 - d_px + width / 2,
|
||||
y_center + height / 2,
|
||||
class_id=class_id,
|
||||
)
|
||||
return left, right
|
||||
|
||||
|
||||
def _frame(frame_id: int, detections: List[Detection]) -> DetectionFrame:
|
||||
return DetectionFrame(frame_id=frame_id, detections=detections)
|
||||
|
||||
|
||||
def test_single_target_complete_valid_record():
|
||||
ranger = StereoRanger()
|
||||
left, right = _boxes(x_center_m=0.0, z_m=6.0)
|
||||
records = ranger.update(_frame(1, [left]), _frame(1, [right]), now_ms=1000)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.degradation is Degradation.COMPLETE
|
||||
assert record.valid is True
|
||||
assert record.forward_distance_m == pytest.approx(6.0)
|
||||
assert record.slant_range_m == pytest.approx(math.hypot(CFG.baseline_m / 2.0, 6.0)) # R 含左目系 +B/2 横向偏移
|
||||
assert record.disparity_px == pytest.approx(CFG.focal_baseline_px_m / 6.0)
|
||||
assert record.center_mid_px == pytest.approx((CFG.cx_px, CFG.cy_px))
|
||||
assert record.reference_box == left.bbox
|
||||
assert record.reference_eye is Eye.LEFT
|
||||
assert record.confidence == pytest.approx(1.0)
|
||||
assert record.frame_id == 1 and record.timestamp_ms == 1000
|
||||
|
||||
|
||||
def test_filter_smooths_across_valid_frames():
|
||||
ranger = StereoRanger()
|
||||
left_1, right_1 = _boxes(z_m=6.0)
|
||||
left_2, right_2 = _boxes(z_m=6.5)
|
||||
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
|
||||
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
|
||||
assert records[0].valid is True
|
||||
assert records[0].forward_distance_m == pytest.approx(6.0 + 0.3 * (6.5 - 6.0))
|
||||
# 参考斜距与已发布 Z 同基准:按 z_out/z_raw 从本帧原始斜距精确缩放(R ≥ Z)
|
||||
raw_slant = math.hypot(CFG.baseline_m / 2.0, 6.5)
|
||||
assert records[0].slant_range_m == pytest.approx(raw_slant * 6.15 / 6.5)
|
||||
|
||||
|
||||
def test_jump_reset_skips_smoothing():
|
||||
ranger = StereoRanger()
|
||||
left_1, right_1 = _boxes(z_m=4.0)
|
||||
left_2, right_2 = _boxes(z_m=10.0) # 跳变 6 m > jump_reset_m = 5 m
|
||||
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
|
||||
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
|
||||
assert records[0].forward_distance_m == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_far_field_out_of_domain_still_matched():
|
||||
# 越界 = 有效性门(决策):配对仍成立,c_mid(无偏方位)与 D 照常发布,无 Z
|
||||
ranger = StereoRanger()
|
||||
left, right = _boxes(x_center_m=0.6, z_m=25.0) # D ≈ 2.3 px < 3 px
|
||||
records = ranger.update(_frame(1, [left]), _frame(1, [right]))
|
||||
|
||||
record = records[0]
|
||||
assert record.degradation is Degradation.COMPLETE
|
||||
assert record.valid is False
|
||||
assert record.forward_distance_m is None
|
||||
assert record.disparity_px == pytest.approx(2.304)
|
||||
assert record.center_mid_px == pytest.approx((CFG.cx_px + CFG.fx_px * 0.6 / 25.0, CFG.cy_px))
|
||||
assert record.slant_range_m == pytest.approx(math.hypot(0.6 + CFG.baseline_m / 2.0, 25.0))
|
||||
|
||||
|
||||
def test_error_budget_gate_blocks_far_but_in_domain():
|
||||
ranger = StereoRanger()
|
||||
left, right = _boxes(z_m=12.0) # D = 4.8 px 域内,δZ = 2.5 m > 2 m
|
||||
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
|
||||
assert record.disparity_px == pytest.approx(4.8)
|
||||
assert record.valid is False
|
||||
assert record.forward_distance_m is None
|
||||
assert record.slant_range_m == pytest.approx(math.hypot(CFG.baseline_m / 2.0, 12.0))
|
||||
|
||||
|
||||
def test_error_budget_gate_passes_just_inside():
|
||||
ranger = StereoRanger()
|
||||
left, right = _pair_with_disparity(5.8) # Z ≈ 9.93 m,δZ ≈ 1.71 m ≤ 2 m
|
||||
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
|
||||
assert record.valid is True
|
||||
assert record.forward_distance_m == pytest.approx(CFG.focal_baseline_px_m / 5.8)
|
||||
|
||||
|
||||
def test_negative_disparity_violation(caplog):
|
||||
ranger = StereoRanger()
|
||||
left = Detection(1000.0, 500.0, 1060.0, 540.0)
|
||||
right = Detection(1010.0, 500.0, 1070.0, 540.0)
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
|
||||
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
|
||||
assert record.degradation is Degradation.COMPLETE
|
||||
assert record.valid is False
|
||||
assert record.forward_distance_m is None
|
||||
assert record.slant_range_m is None
|
||||
assert record.disparity_px == pytest.approx(-10.0)
|
||||
assert any("disparity_violation" in message for message in caplog.messages)
|
||||
|
||||
|
||||
def test_center_fallback_keeps_target_valid(caplog):
|
||||
ranger = StereoRanger()
|
||||
left = Detection(1000.0, 500.0, 1060.0, 540.0) # 宽 60, center 1030
|
||||
right = Detection(940.0, 500.0, 1040.0, 540.0) # 宽 100, center 990
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
|
||||
record = ranger.update(_frame(1, [left]), _frame(1, [right]))[0]
|
||||
assert record.disparity_px == pytest.approx(40.0) # 边缘 60 px 越窗 → 中心 40 px
|
||||
assert record.valid is True
|
||||
assert record.forward_distance_m == pytest.approx(CFG.focal_baseline_px_m / 40.0)
|
||||
assert any("center_fallback" in message for message in caplog.messages)
|
||||
|
||||
|
||||
def test_single_eye_degradation_left_and_right():
|
||||
ranger = StereoRanger()
|
||||
left, right = _boxes(x_center_m=0.3, z_m=4.0)
|
||||
|
||||
left_only = ranger.update(_frame(1, [left]), _frame(1, []))
|
||||
assert len(left_only) == 1
|
||||
record = left_only[0]
|
||||
assert record.degradation is Degradation.BEARING_ONLY
|
||||
assert record.valid is False
|
||||
assert record.center_mid_px == pytest.approx((left.cx, left.cy))
|
||||
assert record.forward_distance_m is None and record.disparity_px is None
|
||||
assert record.reference_eye is Eye.LEFT
|
||||
assert record.confidence == pytest.approx(left.confidence)
|
||||
|
||||
right_only = ranger.update(_frame(2, []), _frame(2, [right]))
|
||||
assert len(right_only) == 1
|
||||
assert right_only[0].reference_eye is Eye.RIGHT
|
||||
assert right_only[0].center_mid_px == pytest.approx((right.cx, right.cy))
|
||||
|
||||
|
||||
def test_records_order_left_first_then_unmatched_right():
|
||||
ranger = StereoRanger()
|
||||
left, right_matched = _boxes(z_m=6.0, class_id=0)
|
||||
_, right_extra = _boxes(z_m=6.0, class_id=0, y_m=0.0)
|
||||
right_extra = Detection(400.0, 500.0, 460.0, 540.0) # 远处无对应左框
|
||||
records = ranger.update(_frame(1, [left]), _frame(1, [right_matched, right_extra]))
|
||||
assert len(records) == 2
|
||||
assert records[0].degradation is Degradation.COMPLETE
|
||||
assert records[0].valid is True
|
||||
assert records[1].degradation is Degradation.BEARING_ONLY
|
||||
assert records[1].reference_eye is Eye.RIGHT
|
||||
|
||||
|
||||
def test_frame_id_mismatch_rejects_frame_and_throttles_warning(caplog):
|
||||
ranger = StereoRanger()
|
||||
left, right = _boxes()
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
|
||||
assert ranger.update(_frame(1, [left]), _frame(2, [right])) == []
|
||||
assert ranger.update(_frame(3, [left]), _frame(4, [right])) == []
|
||||
warnings = [message for message in caplog.messages if "frame_mismatch" in message]
|
||||
assert len(warnings) == 1 # 第 1 次打印,第 2 次被节流
|
||||
assert "累计 1 次" in warnings[0]
|
||||
|
||||
|
||||
def test_empty_input_returns_empty_without_warning(caplog):
|
||||
ranger = StereoRanger()
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
|
||||
assert ranger.update(_frame(1, []), _frame(1, [])) == []
|
||||
assert caplog.messages == []
|
||||
|
||||
|
||||
def test_non_finite_box_dropped_with_warning(caplog):
|
||||
ranger = StereoRanger()
|
||||
bad = Detection(float("nan"), 500.0, 1060.0, 540.0)
|
||||
good_left, good_right = _boxes(z_m=6.0)
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
|
||||
records = ranger.update(_frame(1, [bad, good_left]), _frame(1, [good_right]))
|
||||
assert len(records) == 1
|
||||
assert records[0].valid is True
|
||||
assert any("invalid_box" in message for message in caplog.messages)
|
||||
|
||||
|
||||
def test_consistency_degrades_then_invalidates(caplog):
|
||||
ranger = StereoRanger()
|
||||
disparities = [9.5, 13.5, 17.5, 21.5, 25.5, 29.5] # 每帧跳变 4 px > 3 px
|
||||
records = []
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
|
||||
for index, d_px in enumerate(disparities, start=1):
|
||||
left, right = _pair_with_disparity(d_px)
|
||||
records.append(ranger.update(_frame(index, [left]), _frame(index, [right]))[0])
|
||||
|
||||
assert [record.valid for record in records] == [True, True, True, True, True, False]
|
||||
assert records[3].confidence == pytest.approx(0.5) # 第 3 帧起置信度降级(N = 3)
|
||||
assert records[5].forward_distance_m is None
|
||||
assert records[5].confidence == pytest.approx(0.0)
|
||||
assert any("consistency_invalid" in message for message in caplog.messages)
|
||||
|
||||
|
||||
def test_track_id_keeps_state_across_large_pixel_move():
|
||||
ranger = StereoRanger()
|
||||
left_1, right_1 = _boxes(x_center_m=0.6, z_m=4.0, track_id=7)
|
||||
left_2, right_2 = _boxes(x_center_m=0.0, z_m=4.5, track_id=7) # c_mid 移动 144 px > 门限
|
||||
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
|
||||
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
|
||||
assert records[0].track_id == 7
|
||||
assert records[0].forward_distance_m == pytest.approx(4.0 + 0.3 * (4.5 - 4.0))
|
||||
|
||||
|
||||
def test_without_track_id_large_move_starts_new_state():
|
||||
ranger = StereoRanger()
|
||||
left_1, right_1 = _boxes(x_center_m=0.6, z_m=4.0)
|
||||
left_2, right_2 = _boxes(x_center_m=0.0, z_m=4.5) # 最近邻越门限 → 新目标状态
|
||||
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
|
||||
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
|
||||
assert records[0].forward_distance_m == pytest.approx(4.5) # 未平滑
|
||||
|
||||
|
||||
def test_custom_matcher_injection():
|
||||
class _NoMatchMatcher:
|
||||
def match(self, left, right, config) -> List[MatchPair]:
|
||||
return []
|
||||
|
||||
ranger = StereoRanger(matcher=_NoMatchMatcher())
|
||||
left, right = _boxes(z_m=6.0)
|
||||
records = ranger.update(_frame(1, [left]), _frame(1, [right]))
|
||||
assert len(records) == 2
|
||||
assert all(record.degradation is Degradation.BEARING_ONLY for record in records)
|
||||
|
||||
|
||||
def test_reset_clears_state():
|
||||
ranger = StereoRanger()
|
||||
left_1, right_1 = _boxes(z_m=6.0)
|
||||
left_2, right_2 = _boxes(z_m=6.5)
|
||||
ranger.update(_frame(1, [left_1]), _frame(1, [right_1]))
|
||||
ranger.reset()
|
||||
records = ranger.update(_frame(2, [left_2]), _frame(2, [right_2]))
|
||||
assert records[0].forward_distance_m == pytest.approx(6.5) # 未平滑(状态已清空)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""目标隔离测试(规格 §4:track_id 优先 + 框位置最近邻、帧末老化清理)。"""
|
||||
|
||||
from stereo_ranger import StereoConfig, TargetStore
|
||||
|
||||
CFG = StereoConfig() # isolation_gate_px = 50,filter_reset_frames = 5
|
||||
|
||||
|
||||
def test_track_key_stable_across_position_move():
|
||||
store = TargetStore(CFG)
|
||||
key_1 = store.resolve(0, 7, (100.0, 100.0), 1)
|
||||
key_2 = store.resolve(0, 7, (400.0, 100.0), 2) # 位置大幅移动仍是同一目标
|
||||
assert key_1 == key_2
|
||||
assert store.count == 1
|
||||
|
||||
|
||||
def test_nearest_neighbor_keeps_identity_within_gate():
|
||||
store = TargetStore(CFG)
|
||||
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
|
||||
key_2 = store.resolve(0, None, (130.0, 140.0), 2) # 距离 50 ≤ 门限
|
||||
assert key_1 == key_2
|
||||
|
||||
|
||||
def test_beyond_gate_creates_new_key():
|
||||
store = TargetStore(CFG)
|
||||
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
|
||||
key_2 = store.resolve(0, None, (200.0, 100.0), 2) # 距离 100 > 门限
|
||||
assert key_1 != key_2
|
||||
assert store.count == 2
|
||||
|
||||
|
||||
def test_class_id_separates_states():
|
||||
store = TargetStore(CFG)
|
||||
assert store.resolve(0, None, (100.0, 100.0), 1) != store.resolve(1, None, (100.0, 100.0), 1)
|
||||
|
||||
|
||||
def test_two_boxes_same_frame_claim_distinct_states():
|
||||
store = TargetStore(CFG)
|
||||
key_1 = store.resolve(0, None, (100.0, 100.0), 1)
|
||||
key_2 = store.resolve(0, None, (110.0, 100.0), 1) # 同帧第二框不得接管已占用状态
|
||||
assert key_1 != key_2
|
||||
assert store.count == 2
|
||||
|
||||
|
||||
def test_state_evicted_after_reset_frames_unseen():
|
||||
store = TargetStore(CFG)
|
||||
key = store.resolve(0, None, (100.0, 100.0), 1)
|
||||
store.distance_filter.advance(key, 5.0)
|
||||
store.consistency.advance(key, 9.6)
|
||||
for frame in range(2, 7): # 第 2~6 帧:连续 5 帧缺席
|
||||
store.end_frame(frame)
|
||||
assert store.count == 0
|
||||
assert store.distance_filter.value(key) is None
|
||||
assert store.consistency.streak(key) == 0
|
||||
|
||||
|
||||
def test_present_but_invalid_keeps_position_state():
|
||||
store = TargetStore(CFG)
|
||||
key = store.resolve(0, None, (100.0, 100.0), 1)
|
||||
store.distance_filter.advance(key, 5.0)
|
||||
for frame in range(2, 8):
|
||||
store.resolve(0, None, (100.0, 100.0), frame) # 每帧仍出现
|
||||
store.distance_filter.invalidate(key)
|
||||
assert store.count == 1 # 位置状态保留
|
||||
assert store.distance_filter.value(key) is None # 滤波状态已按门限清空
|
||||
|
||||
|
||||
def test_clear_resets_everything():
|
||||
store = TargetStore(CFG)
|
||||
key = store.resolve(0, 5, (100.0, 100.0), 1)
|
||||
store.distance_filter.advance(key, 5.0)
|
||||
store.clear()
|
||||
assert store.count == 0
|
||||
assert store.distance_filter.value(key) is None
|
||||
@@ -1,45 +0,0 @@
|
||||
"""告警节流测试(规格 §5:第 1 次 + 每满 100 次,带累计计数)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_ranger import WarnThrottle
|
||||
|
||||
|
||||
def test_first_and_every_hundred_emit():
|
||||
throttle = WarnThrottle() # 默认 every=100
|
||||
assert throttle.should_emit("a") is True
|
||||
for _ in range(98):
|
||||
assert throttle.should_emit("a") is False
|
||||
assert throttle.should_emit("a") is True # 第 100 次
|
||||
for _ in range(99):
|
||||
assert throttle.should_emit("a") is False
|
||||
assert throttle.should_emit("a") is True # 第 200 次
|
||||
assert throttle.count("a") == 200
|
||||
|
||||
|
||||
def test_categories_are_independent():
|
||||
throttle = WarnThrottle()
|
||||
assert throttle.should_emit("a") is True
|
||||
assert throttle.should_emit("b") is True
|
||||
assert throttle.count("a") == 1
|
||||
assert throttle.count("b") == 1
|
||||
assert throttle.count("never_seen") == 0
|
||||
|
||||
|
||||
def test_custom_interval():
|
||||
throttle = WarnThrottle(every=3)
|
||||
# 规格语义"第 1 次 + 计数每满 every" → 第 1/3/6 次打印
|
||||
assert [throttle.should_emit("a") for _ in range(6)] == [True, False, True, False, False, True]
|
||||
|
||||
|
||||
def test_clear_resets_counts():
|
||||
throttle = WarnThrottle()
|
||||
throttle.should_emit("a")
|
||||
throttle.clear()
|
||||
assert throttle.count("a") == 0
|
||||
assert throttle.should_emit("a") is True
|
||||
|
||||
|
||||
def test_invalid_interval():
|
||||
with pytest.raises(ValueError):
|
||||
WarnThrottle(every=0)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,96 +0,0 @@
|
||||
"""StereoYoloConfig 校验与 JSON 加载测试(需求说明 §4:配置外置、fail-fast)。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from stereo_yolo import StereoYoloConfig, load_from_json
|
||||
|
||||
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "stereo_yolo.json"
|
||||
|
||||
|
||||
def _config_dict() -> dict:
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write(tmp_path: Path, data: dict) -> str:
|
||||
path = tmp_path / "stereo_yolo.json"
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_load_from_json_matches_defaults():
|
||||
assert load_from_json(str(CONFIG_PATH)) == StereoYoloConfig()
|
||||
|
||||
|
||||
def test_whitelist_normalized_to_tuple():
|
||||
cfg = StereoYoloConfig(class_whitelist=[0, 2])
|
||||
assert cfg.class_whitelist == (0, 2)
|
||||
|
||||
|
||||
def test_missing_field_rejected(tmp_path):
|
||||
data = _config_dict()
|
||||
data.pop("imgsz")
|
||||
with pytest.raises(ValueError, match="缺少配置字段"):
|
||||
load_from_json(_write(tmp_path, data))
|
||||
|
||||
|
||||
def test_unknown_field_rejected(tmp_path):
|
||||
data = _config_dict()
|
||||
data["letterbox_scale"] = 0.5
|
||||
with pytest.raises(ValueError, match="未知配置字段"):
|
||||
load_from_json(_write(tmp_path, data))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("model_path", 123),
|
||||
("imgsz", "640"),
|
||||
("imgsz", True),
|
||||
("conf_threshold", "0.25"),
|
||||
("iou_threshold", None),
|
||||
("class_whitelist", 0),
|
||||
("class_whitelist", ["0"]),
|
||||
("device", 5),
|
||||
("concurrency", 1),
|
||||
],
|
||||
)
|
||||
def test_type_error_rejected(tmp_path, field, value):
|
||||
data = _config_dict()
|
||||
data[field] = value
|
||||
with pytest.raises(ValueError, match="配置类型非法"):
|
||||
load_from_json(_write(tmp_path, data))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("model_path", ""),
|
||||
("imgsz", 0),
|
||||
("imgsz", 600),
|
||||
("conf_threshold", -0.1),
|
||||
("conf_threshold", 1.5),
|
||||
("iou_threshold", 0.0),
|
||||
("class_whitelist", []),
|
||||
("class_whitelist", [-1]),
|
||||
("device", " "),
|
||||
("concurrency", "batch"),
|
||||
],
|
||||
)
|
||||
def test_invalid_values_rejected(field, value):
|
||||
with pytest.raises(ValueError, match="配置非法"):
|
||||
StereoYoloConfig(**{field: value})
|
||||
|
||||
|
||||
def test_boundary_values_accepted():
|
||||
cfg = StereoYoloConfig(
|
||||
imgsz=32,
|
||||
conf_threshold=0.0,
|
||||
iou_threshold=1.0,
|
||||
class_whitelist=(0,),
|
||||
device="cpu",
|
||||
concurrency="sequential",
|
||||
)
|
||||
assert cfg.imgsz == 32
|
||||
@@ -1,155 +0,0 @@
|
||||
"""StereoYoloFrontend 出口契约测试(需求说明 §2.2/§2.3/§2.5/§5)。
|
||||
|
||||
全部用注入的假检测器驱动:不需要模型权重、不需要图像素材、不需要硬件;
|
||||
真并行用双人屏障断言(串行实现会屏障超时)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from stereo_ranger.types import Detection
|
||||
from stereo_yolo import StereoYoloConfig, StereoYoloFrontend
|
||||
|
||||
|
||||
class ScriptedDetector:
|
||||
"""按调用顺序返回脚本项:list[Detection] 正常返回;Exception 抛出。"""
|
||||
|
||||
def __init__(self, script):
|
||||
self._script = list(script)
|
||||
self.calls = 0
|
||||
|
||||
def detect(self, image):
|
||||
self.calls += 1
|
||||
if not self._script:
|
||||
raise AssertionError("假检测器脚本已耗尽(调用次数多于预期)")
|
||||
item = self._script.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return list(item)
|
||||
|
||||
|
||||
class BarrierDetector:
|
||||
"""两次调用必须同时在途(屏障超时即证明串行或单线程执行)。"""
|
||||
|
||||
def __init__(self, barrier: threading.Barrier, detections):
|
||||
self._barrier = barrier
|
||||
self._detections = list(detections)
|
||||
|
||||
def detect(self, image):
|
||||
self._barrier.wait(timeout=2.0)
|
||||
return list(self._detections)
|
||||
|
||||
|
||||
def _img(fill=0, shape=(8, 8, 3)) -> np.ndarray:
|
||||
return np.full(shape, fill, dtype=np.uint8)
|
||||
|
||||
|
||||
def _det(x1=1.0, y1=2.0, x2=11.0, y2=12.0, class_id=0, confidence=0.9, track_id=None) -> Detection:
|
||||
return Detection(x1, y1, x2, y2, confidence=confidence, class_id=class_id, track_id=track_id)
|
||||
|
||||
|
||||
def _frontend(script, **cfg_kwargs) -> StereoYoloFrontend:
|
||||
kwargs = {"concurrency": "sequential"}
|
||||
kwargs.update(cfg_kwargs)
|
||||
return StereoYoloFrontend(StereoYoloConfig(**kwargs), detector=ScriptedDetector(script))
|
||||
|
||||
|
||||
def test_frames_share_frame_id_and_keep_boxes():
|
||||
left_box, right_box = _det(1.0, 2.0, 11.0, 12.0, confidence=0.77), _det(3.0, 4.0, 13.0, 14.0)
|
||||
frontend = _frontend([[left_box], [right_box]])
|
||||
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=7)
|
||||
|
||||
assert left_frame.frame_id == right_frame.frame_id == 7
|
||||
assert left_frame.detections == (left_box,)
|
||||
assert right_frame.detections == (right_box,)
|
||||
assert left_frame.detections[0].confidence == pytest.approx(0.77)
|
||||
assert left_frame.detections[0].track_id is None # 无追踪器时留空(§2.1)
|
||||
assert frontend._detector.calls == 2
|
||||
|
||||
|
||||
def test_whitelist_filters_by_class():
|
||||
frontend = _frontend([[_det(class_id=3)], [_det(class_id=0)]], class_whitelist=(0,))
|
||||
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=1)
|
||||
|
||||
assert left_frame.detections == ()
|
||||
assert len(right_frame.detections) == 1 and right_frame.detections[0].class_id == 0
|
||||
|
||||
|
||||
def test_no_whitelist_keeps_all_classes():
|
||||
frontend = _frontend([[_det(class_id=3)], [_det(class_id=5)]])
|
||||
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=1)
|
||||
|
||||
assert len(left_frame.detections) == 1 and len(right_frame.detections) == 1
|
||||
|
||||
|
||||
def test_degenerate_boxes_dropped(caplog):
|
||||
nan_box = _det(x1=float("nan"))
|
||||
zero_width = _det(x1=5.0, x2=5.0)
|
||||
inverted = _det(x1=20.0, x2=10.0)
|
||||
good = _det()
|
||||
frontend = _frontend([[nan_box, zero_width, inverted, good], []])
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
|
||||
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=2)
|
||||
|
||||
assert left_frame.detections == (good,)
|
||||
assert right_frame.detections == ()
|
||||
assert "丢弃退化检测框" in caplog.text
|
||||
assert "累计 1 次" in caplog.text
|
||||
|
||||
|
||||
def test_boundary_touching_box_kept():
|
||||
hairline = _det(x1=10.0, x2=10.0 + 1e-9)
|
||||
frontend = _frontend([[hairline], []])
|
||||
left_frame, _ = frontend.detect(_img(), _img(), frame_id=3)
|
||||
assert left_frame.detections == (hairline,)
|
||||
|
||||
|
||||
def test_one_eye_failure_isolated(caplog):
|
||||
good = _det()
|
||||
frontend = _frontend([[good], RuntimeError("boom")])
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
|
||||
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=4)
|
||||
|
||||
assert left_frame.detections == (good,)
|
||||
assert right_frame.detections == ()
|
||||
assert left_frame.frame_id == right_frame.frame_id == 4
|
||||
assert "右目推理失败" in caplog.text
|
||||
|
||||
|
||||
def test_shape_mismatch_rejected(caplog):
|
||||
frontend = _frontend([]) # 脚本为空:若检测器被调用会抛断言
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="stereo_yolo"):
|
||||
left_frame, right_frame = frontend.detect(_img(shape=(8, 8, 3)), _img(shape=(8, 6, 3)), frame_id=5)
|
||||
|
||||
assert left_frame.detections == () and right_frame.detections == ()
|
||||
assert left_frame.frame_id == right_frame.frame_id == 5
|
||||
assert frontend._detector.calls == 0
|
||||
assert "左右图尺寸不一致" in caplog.text
|
||||
|
||||
|
||||
def test_parallel_eyes_run_concurrently():
|
||||
barrier = threading.Barrier(2)
|
||||
box = _det()
|
||||
frontend = StereoYoloFrontend(
|
||||
StereoYoloConfig(concurrency="parallel"),
|
||||
detector=BarrierDetector(barrier, [box]),
|
||||
)
|
||||
try:
|
||||
left_frame, right_frame = frontend.detect(_img(), _img(), frame_id=6)
|
||||
finally:
|
||||
frontend.close()
|
||||
|
||||
assert left_frame.detections == (box,)
|
||||
assert right_frame.detections == (box,)
|
||||
|
||||
|
||||
def test_close_is_idempotent():
|
||||
frontend = StereoYoloFrontend(StereoYoloConfig(concurrency="parallel"), detector=ScriptedDetector([]))
|
||||
frontend.close()
|
||||
frontend.close()
|
||||
@@ -1,27 +0,0 @@
|
||||
"""split_sbs 裁切测试(需求说明 §2.3:sbs 单流形态)。"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from stereo_yolo import split_sbs
|
||||
|
||||
|
||||
def test_split_returns_left_and_right_halves():
|
||||
image = np.zeros((4, 10, 3), dtype=np.uint8)
|
||||
image[:, :5] = 1
|
||||
image[:, 5:] = 2
|
||||
|
||||
left, right = split_sbs(image)
|
||||
|
||||
assert left.shape == (4, 5, 3) and right.shape == (4, 5, 3)
|
||||
assert (left == 1).all() and (right == 2).all()
|
||||
|
||||
|
||||
def test_odd_width_rejected():
|
||||
with pytest.raises(ValueError, match="宽度须为偶数"):
|
||||
split_sbs(np.zeros((4, 9, 3), dtype=np.uint8))
|
||||
|
||||
|
||||
def test_non_image_rejected():
|
||||
with pytest.raises(ValueError, match="宽度须为偶数"):
|
||||
split_sbs(np.zeros((9,), dtype=np.uint8))
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user