第02章 - 低空 AI 巡检实战
低空巡检看上去只是两件事:无人机把相机送上天,YOLO 把目标框出来。真到交付现场,卡住项目的往往不是 mAP,而是航线下发失败、推流断开、检测框落不到地图上、一条告警延迟四十秒才冒出来。本章按「设备接入 → 实时推理 → 像素到坐标 → 成果回传」把链路拆开,每段都给出可复制运行的命令、可照抄的参数和真实会踩的坑;然后用 VisDrone 打通训练侧,最后用 DeepSeek Harness(下称 DSH)把数据体检、实验矩阵与排障固化成可重复的流程。时效口径以 2026 年为基线:设备侧看上云 API 2,检测侧看 YOLO26,长尾开放类别看 SAM 3.1,三者怎么分工在 2.2、2.3、2.4 里分别给结论。
学习目标:独立搭起一条「机场上云 → 拉媒体流 → 边缘推理 → 像素转经纬度 → 告警入库」的最小可用链路;能用 VisDrone 训出对小目标可用的检测器;能用 DSH 把数据检查与实验管理变成脚本化流程。 前置知识:Python 3.10+,跑通过一次 YOLO 训练与推理;了解 MQTT 与 HTTP 的基本概念;知道经纬度、投影、DEM 是什么。相机内参与射线求交在正文里从头推,不需要提前掌握。
2.1 低空 AI 巡检的整体链路
把「巡检」当业务看,它是一条有明确起止的流水线:航线规划 → 机场/无人机执行 → 直播与媒体回传 → 边缘或云端推理 → 告警与工单 → 成果归档。每一段都有自己的超时预算,六段加起来才是「告警从出现到人看到」的端到端时延。Demo 阶段 1~3 秒、生产环境 30 秒以上,差异几乎都出在第 3 段(传输)和第 6 段(归档)。
2.1.1 五层参考架构与时延预算
下面这张图是本章后面所有代码的坐标系:设备层往上每过一层都会叠加时延,排查「为什么慢」时先定位问题落在哪一层,再谈优化。
┌───────────────────────────────────────────────────────────────────────────────┐
│ 业务层 航线规划 │ 任务编排 │ 告警去重/工单 │ 成果管理 ✔ 100 ms ~ 2 s │
│ 典型实现:Web 后端 + 规则引擎 + 工单系统 │
├───────────────────────────────────────────────────────────────────────────────┤
│ 推理层 解码 │ 预处理 │ 检测/跟踪 │ 后处理 │ 地理反算 ✔ 30 ~ 300 ms / 帧 │
│ 典型实现:TensorRT / ONNX Runtime / OpenVINO,边缘或云端 GPU │
├───────────────────────────────────────────────────────────────────────────────┤
│ 传输层 4G/5G │ 机场有线回传 │ RTMP/WebRTC 拉流 │ HTTPS 上传 ✔ 200 ms ~ 3 s │
│ 典型实现:媒体网关 + 对象存储 + 设备侧上云 API 的 MQTT/HTTPS 通道 │
├───────────────────────────────────────────────────────────────────────────────┤
│ 设备层 机场 │ 飞行器 │ 云台 │ 可见光/红外/多光谱载荷 ✔ 20 ~ 80 ms │
│ 典型实现:厂商 SDK / 上云 API,对外只暴露 SN + 主题 + 服务调用 │
└───────────────────────────────────────────────────────────────────────────────┘
┌───────────┐ ①航线文件 ┌───────────┐ ②媒体流 ┌───────────┐
│ 规划端 │ ─────────────▶ │ 机场/机载 │ ─────────────▶ │ 推理服务 │
└───────────┘ └───────────┘ └───────────┘
▲ │ │
│ ⑥成果与告警 │ ③遥测/OSD │ ④几何反算
│ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 地图/工单 │ ◀───────────── │ 时序库 │ ◀───────────── │ 空间库 │
└───────────┘ ⑤检索/推送 └───────────┘ ⑤写入 └───────────┘
各环节的经验预算(标注为经验值,需按现场实测校准):
| 环节 | 主要技术 | 经验时延 | 最容易失控的地方 |
|---|---|---|---|
| 航线下发 | MQTT 服务调用 + HTTPS 文件上传 | 0.5 ~ 3 s | 航线文件版本与机场固件不匹配,任务直接失败 |
| 起飞/到位 | 机场开舱、无人机爬升 | 20 ~ 60 s | 现场风速超限,任务被设备侧拒绝 |
| 媒体回传 | RTMP / GB28181 / WebRTC | 0.2 ~ 2 s | 上行带宽按单路 4 Mbps 估,实际峰值翻倍 |
| 解码 + 预处理 | NVDEC / OpenCV / 自研 CUDA kernel | 5 ~ 20 ms/帧 | 解码和推理抢同一块 GPU,互相拖慢 |
| 推理 | TensorRT / ONNX Runtime | 10 ~ 120 ms/帧 | 首次加载引擎 3 ~ 20 s,没做预热就掉帧 |
| 后处理 + 反算 | NMS、跟踪、像素转经纬度 | 5 ~ 30 ms/帧 | 反算依赖的位姿时间戳没对齐,坐标漂几十米 |
| 告警与工单 | 规则引擎 + 去重 + 推送 | 50 ~ 500 ms | 同一条缺陷连续 200 帧触发 200 条告警 |
坑:把「模型推理 20 ms」当成端到端时延。推理只是一帧的处理时间,端到端还要算上推流缓冲(通常 300 ms 起)、解码排队、告警落库与前端轮询间隔。验收时用「事件发生时刻」到「工单创建时刻」的差,不要用推理日志里的耗时。
2.1.2 三类载荷与任务映射
选模型之前先看载荷:同样的可见光相机,飞 50 m 和飞 200 m 得到的像元分辨率差四倍,小目标策略完全不同。
地面采样距离(GSD,单位 cm/px):
GSD = 像元尺寸(μm) × 飞行高度(m) / 焦距(mm) × 0.1
例:像元 1.5 μm、航高 100 m、焦距 8 mm
GSD = 1.5 × 100 / 8 × 0.1 = 1.875 cm/px
一个 40 px 宽的行人 ≈ 75 cm,足以稳定检出;降到 10 px 就只剩 19 cm,必漏
| 载荷 | 典型输出 | 适合的任务 | 模型任务类型 | 实拍注意 |
|---|---|---|---|---|
| 可见光变焦 | 4K 视频 / 4000×3000 照片 | 目标检测(人、车、船、工程机械)、缺陷识别(裂缝、锈蚀、异物) | 检测、实例分割 | 逆光与阴影导致的漏检比模型精度更致命 |
| 红外热成像 | 640×512 伪彩视频 + 原始温度矩阵 | 测温、火点、光伏热斑、夜间人员 | 检测 + 阈值后处理 | 需要辐射标定参数,伪彩图不能反算温度 |
| 多光谱 | 5 ~ 10 波段栅格 | 作物长势、水质、植被覆盖 | 语义分割 / 回归 | 需要辐射定标与波段对齐,不适合实时推理 |
2.1.3 指标口径:别用「准确率」验收
巡检告警是稀疏正样本场景,准确率(accuracy)没有意义——全判「无异常」也能到 99%。验收只用四个口径,且必须在同一份事件表上算:
检出率 Recall = TP / (TP + FN) 人工标注的真事件里,被系统抓到的比例
误报率 FPR = FP / (TP + FP) 系统告警里,复核后不成立的比例
端到端时延 P95 = quantile(t_工单 - t_事件, 0.95) 单位 s
每平方公里成本 = (设备折旧 + 传输 + 算力 + 人工复核) / 巡检面积
其中 TP 的判定要同时满足:同类、时间差 ≤ 10 s、地面距离 ≤ 30 m(经验阈值,按业务调整)。
| 指标 | 采集方式 | 常用验收线(经验) | 备注 |
|---|---|---|---|
| 检出率 | 与人工复核事件表做贪心匹配 | ≥ 90% | 小目标单列,不与大目标混算 |
| 误报率 | 同上 | ≤ 15% | 超过 20% 时人工复核成本会吃掉自动化收益 |
| 端到端时延 P95 | 事件时间戳与工单创建时间戳 | ≤ 5 s | 直播链路走 WebRTC 才能稳定达标 |
| 单路并发成本 | 云账单 / 并发路数 / 小时 | 按项目核算 | 推理占大头时优先考虑边缘部署 |
| 每平方公里成本 | 总成本 / 有效巡检面积 | 按项目核算 | 航线重叠率每提高 10%,成本约上升 8% |
把上表落成脚本,避免每次验收手工对数:
# metrics.py —— 事件级口径计算(只依赖 numpy / pandas)
import numpy as np
import pandas as pd
def haversine_m(lat1, lon1, lat2, lon2):
"""两个经纬度点之间的球面距离(米),WGS84 平均半径。"""
r = 6371008.8
p1, p2 = np.radians(lat1), np.radians(lat2)
dp = p2 - p1
dl = np.radians(lon2) - np.radians(lon1)
a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2
return 2 * r * np.arcsin(np.sqrt(a))
def greedy_match(gt, det, dist_m=30.0, tol_s=10.0):
"""按「同类 + 时间容差 + 距离容差」做一对一贪心匹配,返回匹配数与命中掩码。"""
det = det.reset_index(drop=True)
used = np.zeros(len(det), dtype=bool)
for _, g in gt.iterrows():
d = haversine_m(g["lat"], g["lon"], det["lat"].to_numpy(), det["lon"].to_numpy())
t = (det["ts"] - g["ts"]).dt.total_seconds().abs().to_numpy()
cand = np.flatnonzero((d <= dist_m) & (t <= tol_s) & (det["cls"] == g["cls"]) & ~used)
if cand.size:
used[cand[np.argmin(d[cand])]] = True
return used
gt = pd.read_csv("gt_events.csv", parse_dates=["ts"])
det = pd.read_csv("alarm_events.csv", parse_dates=["ts"])
hit = greedy_match(gt, det)
tp = int(hit.sum())
print(f"recall={tp / max(len(gt), 1):.3f} fpr={(len(det) - tp) / max(len(det), 1):.3f}")
matched = det.loc[hit].merge(gt, on="cls", suffixes=("_det", "_gt"))
lat_ms = (matched["ts_det"] - matched["ts_gt"]).dt.total_seconds()
print(f"latency_p50={lat_ms.quantile(0.5):.2f}s p95={lat_ms.quantile(0.95):.2f}s")
坑:用推理服务日志里的时间戳当「事件时刻」。正确做法是以帧的采集时间(PTS 或设备 OSD 里的时间戳)为起点,否则推流缓冲与队列排队会全部被算进推理耗时,指标好看但没有意义。
2.1.4 低空经济的现实约束(2026)
技术方案能不能落地,先看这笔钱在 2026 年是按什么节奏花的。三个公开数字决定本章的取材顺序:
| 口径 | 数值 | 为什么影响技术方案 |
|---|---|---|
| 国家电网 2026 年首批无人机巡检框架采购 | 18.3 亿元(大疆行业应用中标的公开披露单笔最大合同) | 采购是框架制、分批放量,意味着一次交付要能被同一套平台复制到多省,设备型号与固件版本必须收敛 |
| 全球无人机巡检市场(2025) | 突破 400 亿元,中国贡献 60% 以上 | 供给端在本土,模型、数据集与中文文档的可获取性明显好于其他 AI 子领域 |
| 巡检应用结构(2025) | 电力 / 油气 / 交通合计约 75% | 三类场景的共同点是「资产位置固定、航线可复用、缺陷类别有限」,正好是闭集检测最擅长的形态 |
这三条解释了为什么巡检是低空 AI 里最先规模化的场景,也解释了本章为什么把篇幅压在「航线下发 + 推流 + 像素转经纬度」而不是模型结构上:规模化采购买的是可复制的交付流程,不是单点最高的 mAP。反过来,风景、应急、农林植保这类航线不复用、目标不固定的场景,闭集检测的边际收益低得多,要往 2.3.6 的开放词表方向走。
坑:引用这类市场数字时不标年份与来源,是这类教程最常见的硬伤。同一口径在不同机构的报告里能差 20% 以上,写方案时把「2025 年全球突破 400 亿元、中国占 60% 以上」连同来源年份一起写进正文,评审时才不会被一句「这个数据哪来的」问住;本章数字取自 2026-11 的公开披露,采购与市场规模随年度滚动,落地前以最新公告为准。
2.2 大疆机场上云 API
设备接入是整条链路里最容易返工的一段。原因不复杂:上云 API 是「MQTT + HTTPS」的混合模型,设备状态、服务调用走 MQTT,媒体文件、航线文件、直播鉴权走 HTTPS;两者的事务边界不一致,调试时必须同时看两边。2026 年这套接口已经分成两代,先选对代际再谈联调,是本节的第一个决策点。
2.2.1 上云 API 2(2026)与 v1.x 的关系
上云 API 2 不是 v1.x 的补丁版本,而是换了一层交付形态。判断依据是它给的六样东西,v1.x 一样都没有:
| 维度 | v1.x(存量基线) | 上云 API 2(2026) |
|---|---|---|
| 授权与计费 | 按设备/项目分别申请 | 免费 License,不限设备台数 |
| 接入形态 | 平台自己实现 MQTT 主题与 HTTPS 接口 | PaaS:DaaS SDK 面向对象封装,协议细节对业务无感 |
| 前端 | 自己写驾驶舱与航线编辑器 | 可配置前端组件:驾驶舱、航线编辑器、飞行记录、设备维保 |
| AI 接入 | 自行对接 | AI Plugin + AI Coding 脚手架 |
| 联调方式 | 必须有真机或模拟报文 | 设备模拟器与虚拟调试,无真机也能打通主流程 |
| 交付参考 | 文档 + 少量示例 | 开源 demo 可直接改造 |
能力覆盖面也要澄清:设备管理、实时直播、远程调试与操控、媒体管理、航线管理与任务、日志、模型重建、4G 私有化增强图传。其中远程调试与操控和模型重建是 v1.x 时代需要自己拼的两块,也是 2.4 节端云协同方案里最省事的两个接口。
选型结论只有一句:新项目直接上 API 2,存量 v1.x 项目按下表维护、不主动迁移。
| 项目状态 | 建议 | 理由 |
|---|---|---|
| 新立项、无历史设备 | 直接 API 2 | 免费 License 与不限台数直接消掉商务成本;模拟器让联调不依赖真机,项目周期能压缩 |
| 存量 v1.x、设备固件已冻结 | 维持 v1.x | 迁移风险在设备固件与主题语义,不在业务代码;资产盘点类接口改了要重新验收 |
| 存量 v1.x、但要加远程调试/模型重建 | 新模块用 API 2 旁路接入 | 两代可并行部署在同一平台,按设备分组灰度 |
| 要交付驾驶舱给客户自运营 | API 2 | 可配置前端组件省掉一个前端团队的工作量 |
下面的 2.2.2 ~ 2.2.6 讲的是通用链路(拓扑、主题、联调、直播、鉴权),主题与字段细节以 v1.x 为主线,因为存量项目与公开排障案例大多落在这套协议上;API 2 的 DaaS SDK 把同样的动作包成了对象方法,语义一一对应,读完后者的抽象不影响理解前者。
坑:把 API 2 当成「v1.x 的新版本号」,在一台只跑了旧固件的机场上按 API 2 的对象模型改代码。两代协议的设备侧能力边界不同,先用模拟器跑通 API 2 的连接与任务流程,再确认现场固件是否在 API 2 的支持清单里;设备固件不升级就上 API 2 的远程调试,表现是服务调用成功但设备没有动作。
2.2.2 协议栈与拓扑
网关(gateway)与子设备(sub device)是一对多关系:一个机场是一台网关,挂在它下面的飞行器、载荷是子设备。OSD 属性按网关聚合上报,业务要拿飞行器状态就得从嵌套的 sub_devices 里取。
┌──────────────────────────────┐
│ 业务后端 / 平台 │
│ MQTT Client + HTTPS Client │
└───────┬──────────────┬───────┘
MQTT 1883/8883│ │HTTPS 443
│ │ 媒体 / 航线文件 / 直播鉴权
┌──────────────────────────────▼──┐ ┌──▼─────────────────────────┐
│ MQTT Broker(云侧) │ │ 对象存储 / 媒体网关 │
└──────────────┬───────────────────┘ └──▲─────────────────────────┘
│ 主题:thing/product/{sn}/... │
┌──────────────▼───────────────────┐ │
│ 机场(gateway,SN: 8UADxxxx) │───────────┘ 拉取媒体文件、推流
│ ├─ 飞行器(sub device) │
│ └─ 载荷(相机 / 红外,sub device)│
└──────────────────────────────────┘
| 通道 | 承载协议 | 走什么 | 典型端口 | 调试点 |
|---|---|---|---|---|
| 设备状态与服务 | MQTT(3.1.1 / 5.0 均可) | OSD 属性、事件、服务调用与回复 | 1883 / 8883(TLS) | 订阅通配主题看原始报文 |
| 媒体与文件 | HTTPS | 图片、视频、航线 KMZ、模型文件 | 443 | 检查 URL 有效期与签名 |
| 直播 | RTMP / GB28181 / WebRTC | 实时视频流 | 1935 / 5060 / 443 | 用 ffprobe 验证流可拉 |
设备上线后平台侧要做三件事:解析 update_topo 维护设备树、订阅 OSD 主题、建立 SN 与项目/航线的映射。第一件没做好,后面所有服务调用都会因为拿不到子设备 SN 而失败。
坑:机场在平台上显示「在线」,但下发任务返回设备离线。多数是网关在线而目标子设备(飞行器)不在拓扑里,或者拓扑缓存没刷新。排查顺序:看
update_topo报文的sub_devices→ 看online事件的sub_type→ 再下发任务。
2.2.3 主题(topic)约定:v1.x 协议基线
上云 API 的主题按「产品 + 网关 SN + 消息类型」三段拼接。平台侧订阅 osd/state/events,设备侧订阅 services/property/set,回复类主题成对出现。方向、QoS 与频率如下(字段与主题细节会随上云 API 版本变化,以下以 v1.x 系列为准,最终以所用版本的上云 API 文档为准):
| 方向 | 主题 | 用途 | QoS | 频率/时机 |
|---|---|---|---|---|
| 设备 → 平台 | thing/product/{gateway_sn}/osd |
网关与子设备属性上报(位置、电量、姿态、载荷状态) | 0 | 0.5 ~ 2 Hz |
| 设备 → 平台 | thing/product/{gateway_sn}/state |
网关/子设备上线、离线(含 update_topo) |
1 | 状态变化时 |
| 设备 → 平台 | thing/product/{gateway_sn}/events |
事件:任务进度、HMS 告警、文件上传完成 | 1 | 事件触发 |
| 平台 → 设备 | thing/product/{gateway_sn}/services |
服务调用:航线任务、直播、相机控制 | 1 | 业务触发 |
| 设备 → 平台 | thing/product/{gateway_sn}/services_reply |
服务调用结果(带 tid 关联请求) |
1 | 每次调用一条 |
| 平台 → 设备 | thing/product/{gateway_sn}/property/set |
属性设置(如相机曝光、云台角度) | 1 | 配置变更 |
| 设备 → 平台 | thing/product/{gateway_sn}/requests |
设备主动向平台要数据(如航线文件地址) | 1 | 按需 |
| 平台 → 设备 | thing/product/{gateway_sn}/requests_reply |
对 requests 的回复 |
1 | 每次请求一条 |
两条硬规则值得贴在墙上:第一,事件类消息用 QoS 1,QoS 0 在弱网下会静默丢事件,「任务跑完了但平台没收到」十有八九是这里;第二,services 与 services_reply 必须靠 tid 配对,不要靠主题顺序猜应答,否则并发下发两个任务时会串号。
2.2.4 最小联调:从设备上线到拉回媒体
联调顺序不能乱:① 设备上线 → ② 订阅 OSD 确认状态 → ③ 上传航线文件 → ④ 下发 flighttask_prepare → ⑤ 下发 flighttask_execute → ⑥ 收进度事件 → ⑦ 拉媒体文件。跳过 ②直接 ④,失败时你分不清是航线问题还是设备状态问题。
平台(业务后端) 机场 / 飞行器
│ │
│ ◀── state: update_topo(含 sub_devices 设备树) ──── │
│ ◀── state: online(网关与子设备上线) ───────────── │
│ ◀── osd: 每 0.5~2 s 一次属性上报 ────────────────── │
│ │
│ ① 上传航线 KMZ 到对象存储(HTTPS PUT) │
│ ② services: flighttask_prepare(文件 URL + 参数)──▶ │ 校验航线/机场自检
│ ◀── services_reply: result=0(成功)/非 0(失败)── │
│ ③ services: flighttask_execute ───────────────────▶ │ 开舱、起飞
│ ◀── events: flighttask_progress(进度 0~100)────── │
│ ◀── events: file_upload_callback(媒体文件就绪)─── │
│ ④ HTTPS 拉取媒体文件 → 送推理 │
│ │
订阅侧代码骨架(paho-mqtt 2.x;QoS 与 TLS 配置按平台实际要求调整):
# cloud_mqtt.py —— 订阅 OSD / 状态 / 事件,打印最小可读摘要
import json
import time
import paho.mqtt.client as mqtt
BROKER = "mqtt.example.com"
PORT = 8883 # 8883 为 TLS 端口,按现场调整
GW_SN = "8UADZCK00A0XYZ" # 机场网关序列号
def on_connect(client, userdata, flags, rc, properties=None):
print("connected rc=", rc)
if rc != 0:
return
# OSD 频率高,用 QoS 0 省流量;状态与事件用 QoS 1 保证可达
client.subscribe(f"thing/product/{GW_SN}/osd", qos=0)
client.subscribe(f"thing/product/{GW_SN}/state", qos=1)
client.subscribe(f"thing/product/{GW_SN}/events", qos=1)
client.subscribe(f"thing/product/{GW_SN}/services_reply", qos=1)
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload)
except json.JSONDecodeError:
print("non-json", msg.topic, msg.payload[:64])
return
method = payload.get("method", "?")
data = payload.get("data", {}) or {}
if msg.topic.endswith("/osd"):
battery = (data.get("battery") or {}).get("capacity_percent")
print(f"[OSD] {method} battery={battery}%")
elif msg.topic.endswith("/state"):
print(f"[STATE] {data.get('name')} online={data.get('online')}")
elif msg.topic.endswith("/events"):
print(f"[EVENT] {method} need_reply={payload.get('need_reply')} "
f"{json.dumps(data, ensure_ascii=False)[:120]}")
else:
print(f"[REPLY] tid={payload.get('tid')} {json.dumps(data)[:120]}")
client = mqtt.Client(client_id=f"platform-{int(time.time())}", protocol=mqtt.MQTTv5)
client.username_pw_set("platform_user", "REPLACE_ME")
client.tls_set() # 需要私有 CA 时传 ca_certs="/path/ca.pem"
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, PORT, keepalive=60)
client.loop_forever()
下发航线任务时,services 报文的关键字段是 tid(请求唯一标识)、bid(业务标识)、method、data。下面是最小可运行的调用骨架,tid 必须自己生成并在 services_reply 里做匹配:
# cloud_task.py —— 下发飞行任务(对照 services_reply 判结果)
import json
import time
import uuid
def publish_service(client, gw_sn, method, data, bid="task-001"):
tid = str(uuid.uuid4())
body = {"tid": tid, "bid": bid, "timestamp": int(time.time() * 1000),
"method": method, "data": data}
client.publish(f"thing/product/{gw_sn}/services",
json.dumps(body, ensure_ascii=False), qos=1)
return tid
# ① 准备任务:航线文件必须是设备可访问的 HTTPS/KMZ 地址
prepare = {
"flight_id": "fl-20261113-001",
"execute_time": 0, # 0 表示立即执行
"task_type": 0,
"file": {"url": "https://oss.example.com/routes/inspect-01.kmz"},
}
tid1 = publish_service(client, GW_SN, "flighttask_prepare", prepare)
print("prepare tid", tid1) # 收到 services_reply 且 result==0 后才执行下一步
# ② 执行任务(必须在 prepare 成功之后)
tid2 = publish_service(client, GW_SN, "flighttask_execute",
{"flight_id": "fl-20261113-001"})
print("execute tid", tid2)
| 报文类型 | 必查字段 | 判据 | 失败后的第一动作 |
|---|---|---|---|
update_topo |
sub_devices[].device_sn、sub_type |
目标飞行器与载荷都在列表里 | 检查设备是否绑定到该网关 |
online / offline |
online、device_sn |
与 OSD 的 SN 一致 | 核对 MQTT 客户端是否订阅了正确网关 SN |
services_reply |
tid、result、output |
result == 0 视为成功 |
打开 output 里的原因码查设备侧错误表 |
events(进度) |
method、output.progress |
进度单调递增到 100 | 卡住不动时查机场固件与航线版本 |
file_upload_callback |
file.url、file.object_key |
URL 可 HEAD 通 | 检查对象存储签名与过期时间 |
坑:
services_reply的result为 0 只代表「设备收到了并接受」,不代表任务真的跑起来了。真正的成败要看后续flighttask_progress事件和file_upload_callback;有项目卡了一周,就是因为只看result不看进度事件。
2.2.5 直播:开启、拉流与延迟
直播能力通过服务调用开启(live_start_push / live_stop_push,具体字段以所用版本文档为准),拿到推流地址后在平台侧转拉。三种协议的选择标准是:能接受 1~3 秒延迟就用 RTMP,行业平台要求统一信令用 GB28181,前端要「点一下就看到」用 WebRTC。
| 协议 | 端到端延迟(经验值) | 带宽(1080p25) | 优点 | 代价 |
|---|---|---|---|---|
| RTMP | 1 ~ 3 s | 3 ~ 5 Mbps | 生态最成熟,ffmpeg/nginx 直接接 | 延迟受缓冲影响大,H5 不能直接播 |
| GB28181 | 0.5 ~ 1.5 s | 2 ~ 4 Mbps | 与安防平台/国标级联对接方便 | 信令链路复杂,需 SIP 服务 |
| WebRTC | 0.2 ~ 0.8 s | 2 ~ 4 Mbps | 浏览器原生、抗抖动好 | 需要 TURN/SFU,弱网下降码率明显 |
推流地址拿回来后,先在服务器上验证「流是否真的可拉」,再接前端,能省掉一半联调时间:
# 验证流基本信息;拉不到会在 5 秒内报错
ffprobe -v error -rw_timeout 5000000 \
-show_entries stream=codec_name,width,height,avg_frame_rate \
-of default=nw=1 "rtmp://live.example.com/live/uav-01"
# 拉 10 秒存文件,检查是否有花屏/断流(看帧数与时长是否匹配)
ffmpeg -y -rw_timeout 5000000 -i "rtmp://live.example.com/live/uav-01" \
-t 10 -c copy /tmp/uav-01.flv && ffprobe -v error -show_entries format=duration \
-of default=nw=1 /tmp/uav-01.flv
前端播放的取舍:低延迟场景用 WebRTC(RTCPeerConnection 直连 SFU),兼容性优先用 FLV + flv.js(HTTP-FLV,注意浏览器对长连接和服务端 CORS 的要求)。不要在前端做转码,转码放在转拉服务里,一次转码供多端复用。
坑:直播地址带鉴权参数且有效期很短(常见 10 ~ 60 分钟)。把地址写进前端配置文件的项目,第二天全部黑屏。正确做法是前端只请求「播放凭证」,由后端每次现签地址。
2.2.6 鉴权与安全边界
三类凭据必须分清,混用是最常见的安全事故:设备凭据(MQTT 用户名/密码或 Token,只存在设备与 Broker 之间)、平台侧 API 凭据(业务后端调用设备厂商开放平台用)、对象存储签名(临时、带过期时间)。
边界划分(谁能看到什么):
机场 ──(设备凭据)──▶ MQTT Broker ──(平台凭据)──▶ 业务后端
│
浏览器 ◀──(短期播放/文件签名 URL,≤1 h)────────────┘
禁止:浏览器直连 Broker、前端持有设备密钥、日志打印完整签名 URL
| 措施 | 做法 | 能防什么 | 不能防什么 |
|---|---|---|---|
| 设备白名单 | Broker 侧按 SN/ClientID 绑定账号,非白名单拒绝连接 | 盗用凭据接入 | 凭据被合法设备泄漏 |
| TLS + 客户端证书 | 8883 端口强制 TLS,必要时双向认证 | 中间人与明文抓包 | 设备被物理拆解 |
| 短期签名 URL | 媒体/直播地址带 expires 与签名 |
链接外泄长期可用 | 内网转发(需配合水印与审计) |
| 权限分域 | 一个项目一个主题前缀,订阅做 ACL | 跨项目越权订阅 | 内部误操作 |
| 审计日志 | 记录谁在何时下发了哪条航线 | 追责与回放 | 实时攻击(需配合告警) |
2.2 全节的现场高频问题:
| 现象 | 最可能原因 | 验证方法 | 修复 |
|---|---|---|---|
| 平台显示机场在线,但子设备离线 | 拓扑未刷新或飞行器未绑定 | 打印 update_topo 的 sub_devices |
重新绑定设备并重启机场,等待拓扑事件 |
| 任务下发后无任何回复 | 主题拼错(网关 SN 用了飞行器 SN) | mosquitto_sub -t "thing/product/#" -v 看原始报文 |
统一用网关 SN,飞行器 SN 只出现在 data 里 |
| 事件偶发丢失 | QoS 0 或客户端重连未重订阅 | Broker 日志看断连次数 | 事件改 QoS 1,on_connect 里统一重订阅 |
| 媒体 URL 401 | 签名过期或对象存储时间不同步 | curl -I 看返回码与 Date 头 |
校准 NTP,签名有效期留足余量 |
| 航线文件版本不匹配 | KMZ 由旧版规划工具导出 | 看 services_reply.output 的原因码 |
用与固件匹配的规划工具重新导出 |
| 时间戳错 8 小时 | 设备 UTC 与平台本地时间混用 | 比对 OSD 时间戳与本机 date -u |
全链路统一存 UTC,展示层再转本地时区 |
2.3 实时视觉 AI 模型
巡检任务对模型的要求和三件事强相关:目标多小、画面多密、允许多少的漏报。把这三件事定下来,模型规模、输入分辨率、边缘硬件才有讨论的基础。
2.3.1 任务谱系与选型
| 任务 | 代表做法 | 巡检中的用处 | 输出 | 推理开销(相对) |
|---|---|---|---|---|
| 目标检测 | YOLO 系(YOLO26 为新项目默认,YOLOv8/v11 为存量基线) | 人、车、船、工程机械、异物 | 类别 + 框 + 置信度 | 1.0(基准) |
| 实例分割 | YOLO-seg / Mask R-CNN | 堆料面积、水面漂浮物、裸土范围 | 掩膜 + 框 | 1.3 ~ 1.6 |
| 语义分割 | DeepLab / SegFormer | 河道岸线、地块分类 | 逐像素类别 | 1.5 ~ 2.5 |
| 多目标跟踪 | ByteTrack / BoT-SORT | 去重告警、目标测速、轨迹还原 | 稳定 ID + 轨迹 | 检测的 5% ~ 15% 附加 |
| 关键点 | YOLO-pose / HRNet | 塔吊、脚手架、人员姿态 | 关键点坐标 | 1.2 ~ 1.8 |
| 异常检测 | PatchCore / 自编码器 | 无标注缺陷筛查(表面、部件) | 异常分数图 | 0.8 ~ 2.0 |
选择标准写死三条:有明确目标类别且要位置 → 检测;要面积或边界 → 分割;同一目标连续多帧重复报警 → 必须加跟踪。异常检测只在「缺陷样本太少、无法标注」时用,代价是误报率高、需要人工复核兜底。
2.3.2 YOLO26:2026 年的检测模型默认选项
2026 年做新项目,检测侧的默认答案是 Ultralytics YOLO26(论文 arXiv:2606.03748),三处变化直接改变巡检的工程判断:
- 原生端到端、NMS-free:检测头直接输出最终框,后处理里的 IoU NMS 可以省掉。巡检画面目标密集(车流、人群、光伏板阵列),NMS 在几百个候选框上既耗时又会误删相邻小框,省掉它之后端到端时延预算更好算。
- 更轻的检测头 + 新训练配方:去掉了依赖 DFL 的重回归头,训练侧改成 MuSGD 优化器、渐进损失与 STAL 小目标感知标签分配。对巡检的意义不是「涨多少点」,而是小目标的正样本覆盖更稳,2.5 节要的正是这个。
- 统一任务头:检测、实例分割、语义分割、姿态、OBB 旋转框是同一套权重的五个出口,另有分类与深度估计。杆塔、输电线的倾斜目标用 OBB,堆料面积用分割,不用再维护三个独立模型。
数字口径(论文与官方文档一致):五个检测尺度在 COCO 上 mAP 40.9 ~ 57.5,T4 上 TensorRT 延迟 1.7 ~ 11.8 ms;YOLO26n 在 Intel Xeon CPU 上的 ONNX 推理比 YOLO11n 最多快约 43%。
| 维度 | YOLO11(存量基线) | YOLO26(2026 正式版) | YOLO27(预览) |
|---|---|---|---|
| 发布状态 | 已稳定,老项目在用 | 已发布,论文与权重齐全 | 仅预览,权重未发布 |
| 后处理 | 需要 IoU NMS | 原生端到端,可选 NMS-free 导出 | 规划中,未公开 |
| 检测头 | 较重(DFL 回归) | 更轻,去 DFL | 未公开 |
| 任务头 | 检测 / 分割 / 姿态 / OBB / 分类 | 统一五任务 + 深度估计 | 未公开 |
| 开放词表 | 无(YOLOE 早期版本) | YOLOE-26,支持文本/视觉提示 | 未公开 |
| 巡检建议 | 只作存量与对照基线 | 新项目首选 | 不进生产、不写进交付方案 |
选型写死四条:新项目、有 NVIDIA 边缘设备 → YOLO26n/s;纯 x86 CPU 抽帧 → YOLO26n 的 ONNX 版(CPU 推理的收益在这里最明显);存量已训好的 YOLOv8/v11 → 不换权重,只做对照实验,换模型意味着重训、重标定、重验收;YOLO27 只在读方案时出现,不要排进排期。
# 用同一份 visdrone.yaml 做新旧对照,只改 model 一个变量
yolo detect train data=visdrone.yaml model=yolo26s.pt imgsz=1024 epochs=120 \
batch=8 seed=42 project=runs/cmp name=yolo26s
yolo detect train data=visdrone.yaml model=yolo11s.pt imgsz=1024 epochs=120 \
batch=8 seed=42 project=runs/cmp name=yolo11s
坑:把「NMS-free」理解成「不需要阈值和去重」。端到端头省掉的只是 IoU 抑制这一步,置信度阈值、按 track_id 的告警去重(2.4.3)仍然要做。另一个常见错误是换模型后只比整体 mAP:YOLO26 的收益一部分来自小目标标签分配,验收必须按 2.5.4 的尺度分桶看召回,否则「整体涨了 2 个点、xs 桶掉了」的回归发现不了。
2.3.3 导出链路:PyTorch → ONNX → TensorRT / OpenVINO
训练产物不能直接上边缘设备,标准链路是三段:.pt 导出 ONNX,ONNX 再编译成 TensorRT 引擎(NVIDIA)或 OpenVINO IR(Intel/CPU)。每段都要做一次数值校验,否则定位精度掉点时你无法判断是量化、算子替换还是前后处理不一致。
# ① PyTorch → ONNX(固定输入尺寸,动态 batch 会让部分算子退化)
python -c "from ultralytics import YOLO; \
YOLO('runs/detect/visdrone/weights/best.pt').export(format='onnx', imgsz=1024, opset=17, simplify=True)"
# ② ONNX → TensorRT FP16(Jetson 或 x86+独显;首次编译较慢)
trtexec --onnx=best.onnx --saveEngine=best_fp16.engine --fp16 \
--minShapes=images:1x3x1024x1024 --optShapes=images:4x3x1024x1024 \
--maxShapes=images:8x3x1024x1024 --workspace=4096
# ③ ONNX → OpenVINO FP16(Intel CPU / iGPU)
ovc best.onnx --input "[1,3,1024,1024]" --output_dir ov_ir --compress_to_fp16
# ④ 校验 ONNX 与原始 PyTorch 输出的一致性
python export_parity.py --pt runs/detect/visdrone/weights/best.pt --onnx best.onnx --imgsz 1024
一致性校验脚本直接计算最大绝对误差,不要肉眼看框:
# export_parity.py —— 比对 PyTorch 与 ONNX 的原始输出(不是检测框)
import argparse
import numpy as np
import onnxruntime as ort
import torch
from ultralytics import YOLO
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pt", required=True)
ap.add_argument("--onnx", required=True)
ap.add_argument("--imgsz", type=int, default=1024)
ap.add_argument("--tol", type=float, default=1e-3)
args = ap.parse_args()
model = YOLO(args.pt)
torch_model = model.model.float().eval()
dummy = torch.rand(1, 3, args.imgsz, args.imgsz)
with torch.no_grad():
ref = torch_model(dummy)
ref = (ref[0] if isinstance(ref, (list, tuple)) else ref).numpy()
sess = ort.InferenceSession(args.onnx, providers=["CPUExecutionProvider"])
out = sess.run(None, {sess.get_inputs()[0].name: dummy.numpy()})
out = np.asarray(out[0])
print("shape ref", ref.shape, "shape onnx", out.shape)
if ref.shape != out.shape:
raise SystemExit("shape mismatch: 先检查导出时的 imgsz 与输出节点")
diff = np.abs(ref - out)
print(f"max_abs_diff={diff.max():.6f} mean_abs_diff={diff.mean():.6f}")
if diff.max() > args.tol:
raise SystemExit(f"diff 超过阈值 {args.tol}:检查 opset、simplify 与前后处理")
print("parity OK")
if __name__ == "__main__":
main()
| 转换环节 | 常见问题 | 判据 | 处理 |
|---|---|---|---|
.pt → ONNX |
输出节点名变化,后处理接不上 | 打印 sess.get_outputs()[i].name |
在代码里按索引取,不要硬编字符串 |
| ONNX → TensorRT | 不支持的算子被剔除 | trtexec 日志出现 No importer registered |
改用插件或把该算子留在 CPU 上算 |
| FP16 量化 | 置信度整体偏低 | 与 FP32 引擎逐帧对比 mAP 掉 > 1 个点 | 保留分类头 FP32 或用混合精度 |
| INT8 量化 | 小目标几乎全丢 | 校准集只含大目标,量化偏置 | 校准集按实际场景采样,含 10% 以上小目标帧 |
| OpenVINO | ovc 报输入名不匹配 |
输入名与 ONNX 的 input 不一致 |
加 --input 显式声明形状 |
坑:ONNX 一致性对比用随机张量就够了,但量化校准必须用真实帧。用随机噪声标定 INT8 的 scale,上线后检测框会成片消失,而且日志里一切正常——因为模型确实在跑,只是量化区间完全错位。
2.3.4 边缘设备选型
| 平台 | 典型算力 | 功耗 | 能稳定跑的模型 | 经验 FPS(1024 输入,FP16) | 适用场景 |
|---|---|---|---|---|---|
| Jetson Thor(JetPack 7.x) | 高端档,取代 Orin 成为新旗舰 | 以官方规格书为准 | YOLO26m/l + 分割,可带轻量概念复核 | 以官方实测为准 | 单机多路 + 边缘侧复核 |
| Jetson Orin Nano 8G | ~40 TOPS | 7 ~ 15 W | YOLO26n/s(YOLOv8n/s 存量同档) | 30 ~ 55 | 单路无人机、无长期满载 |
| Jetson Orin NX 16G | ~100 TOPS | 10 ~ 25 W | YOLO26s/m(YOLOv8s/m 存量同档) | 45 ~ 70 | 2 ~ 4 路并发、含跟踪 |
| Jetson AGX Orin 64G | ~275 TOPS | 15 ~ 60 W | YOLO26m/l + 分割 | 80 ~ 120 | 多路 + 分割 + 本地检索 |
| RK3588(6 TOPS NPU) | ~6 TOPS | 5 ~ 10 W | YOLO26n + 小分辨率 | 15 ~ 30 | 低成本固定点位、抽帧检测 |
| x86 + RTX 4060Ti | ~20 TFLOPS FP32 | 100 ~ 160 W | 任意,可多模型 | 100+ | 机房侧多路转拉推理 |
| 纯 CPU(16 核 AVX-512) | — | 45 ~ 100 W | YOLO26n,320 ~ 640 | 3 ~ 8 | 兜底或低频抽帧 |
上表是单模型、单路、batch=1 的经验值,测量条件:TensorRT FP16、解码不计入、输入尺寸标注为 1024(除 CPU 行)。实际部署请用自己的模型在目标设备上跑 trtexec --loadEngine=... --duration=30 取数,不要照抄。
2.3.5 实时性预算怎么做
一条 25 fps 的流,如果模型只能跑到 12 fps,就不要试图「追帧」——追不上,且延迟会越积越大。正确做法是设一个抽帧策略 + 有界队列,把延迟控制成常数。
输入 25 fps ──▶ 抽帧(每 2 帧取 1)──▶ 有界队列(maxsize=8)──▶ 推理批次
│ │
│ ├─ 队列满 → 丢最旧帧(保新弃旧)
│ └─ 队列空 → worker 等待,不忙轮询
▼
帧时间戳必须随帧携带,绝不能在 worker 里取当前时间
# pipeline.py —— 有界队列 + 抽帧 + 时间戳透传的最小实现
import threading
from collections import deque
import cv2
class FrameGrabber(threading.Thread):
def __init__(self, url, every_n=2, maxsize=8):
super().__init__(daemon=True)
self.url = url
self.cap = cv2.VideoCapture(url)
self.every_n = every_n
self.q = deque(maxlen=maxsize) # 有界:满了自动丢最旧
self.lock = threading.Condition()
self.running = True
def _reopen(self):
"""断流后按退避重连,避免死循环空转。"""
backoff = 1.0
while self.running and backoff <= 30.0:
self.cap.release()
if self.cap.open(self.url):
return True
threading.Event().wait(backoff)
backoff *= 2
return False
def run(self):
idx = 0
while self.running:
ok, frame = self.cap.read()
if not ok:
if not self._reopen():
break
continue
idx += 1
if idx % self.every_n:
continue
ts = self.cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 # 采集时间,不是处理时间
with self.lock:
self.q.append((ts, frame))
self.lock.notify()
def get(self, timeout=1.0):
with self.lock:
if not self.q:
self.lock.wait(timeout)
return self.q.popleft() if self.q else None
| 优化手段 | 典型收益 | 代价 | 什么时候用 |
|---|---|---|---|
| 抽帧(1/2 或 1/3) | 算力占用降 50% ~ 66% | 高速目标可能漏检 | 巡检飞行速度 ≤ 10 m/s、目标停留时间 > 1 s |
| 批处理(batch 4 ~ 8) | GPU 利用率从 40% 提到 80%+ | 单帧延迟上升 | 多路并发、云端推理 |
| 输入分辨率 1024 → 640 | 耗时降 55% ~ 65% | 小目标 mAP 掉 10 点以上 | 只做大目标筛查 |
| 解码与推理分卡 | 端到端抖动下降 | 硬件成本 | 多路 4K 转拉 |
| 模型预热 | 首帧不超时 | 启动多 3 ~ 20 s | 必须做,否则第一批请求全超时 |
| CUDA Stream 复用 | 小帧耗时降 10% ~ 20% | 代码复杂度 | 有 C++/TensorRT 能力时 |
坑:
cv2.VideoCapture读 RTMP 断流后不会自动恢复,read()一直返回 False 而进程还在。生产代码必须带重连退避(1 s、2 s、4 s,上限 30 s)并上报「流中断」指标,否则你会在第二天早上发现昨晚十一点就断了。
2.3.6 开放词表与概念分割:SAM 3 / 3.1 能补什么
YOLO26 解决的是「训练时见过的类别」,但巡检现场永远有「表里没有的异常」:塔上有鸟巢、通道里堆了不明物体、绝缘子上挂了异物。这类需求没法穷举标注,闭集检测器的正确回答是「我不知道」,业务需要的却是「框出来给我看」。2026 年补这块缺口的主力是 Meta 的 SAM 3 / SAM 3.1。
SAM 3(2025-11,论文 arXiv:2511.16719) 的核心能力是 Promptable Concept Segmentation(PCS,可提示概念分割):用一句文本概念或几张图像 exemplar 提示一次,就分割出图像或视频中该概念的所有实例,并跨帧跟踪。它和 SAM 2 的差别在这里——SAM 2 一次提示只分割一个对象,SAM 3 找的是「所有 occurrences」,这才符合业务里「把画面里所有疑似异物都圈出来」的问法。
SAM 3.1(2026-03-27) 是同一权重族的推理侧升级,对工程最有价值的是 Object Multiplex:多目标视频跟踪不再逐个对象独立处理,而是把对象分进固定容量桶联合处理,官方给出的口径是单张 H100、128 个目标相对 2025-11 版约 7 倍加速;另有 torch.compile、批处理视觉编码器、减少 CPU–GPU 同步等优化。权重在 Hugging Face 的 facebook/sam3.1,代码在 facebookresearch/sam3,微调仍是「冻结视觉编码器 + 训 prompt/decoder」或 LoRA 两条路。
分工表(本章建议写进方案):
| 需求 | 用哪个 | 单帧/单次成本量级 | 部署位置 |
|---|---|---|---|
| 闭集高频目标(人、车、船、绝缘子破损) | YOLO26n/s | 边缘 10 ~ 100 ms/帧 | 边缘或云端 |
| 长尾开放类别(「有异物」「有鸟巢」) | SAM 3.1 文本概念提示 | 秒级/帧(取决于帧数与目标数) | 云端复核 |
| 视频级实例一致性与轨迹 | SAM 3.1 Object Multiplex | 单 H100 可带 128 目标,随目标数扩展卡数 | 云端 |
| 需要面积、边界、掩膜 | SAM 3.1 掩膜输出 | 掩膜存储与传输成本高于框 | 按需,不常开 |
边缘侧成本必须说清楚:SAM 3.1 不是边缘模型。视觉骨干的参数量与显存占用远超边缘实时预算,视频跟踪还要维护多目标状态,Jetson 级设备只能「抽帧 + 降分辨率 + 限制目标数」地跑,把它当实时流处理器会直接撞上显存与散热。具体每档设备的可行帧数请按权重版本与 JetPack 版本实测,不要沿用上一代模型的换算。合理位置是云端复核环节:边缘用 YOLO26 出初筛框,云端对命中帧用 SAM 3.1 做概念级复核与视频级跟踪(2.4.5 给出链路)。
# sam3_review.py —— 用文本概念复核一张命中帧(官方 sam3 包接口示意,以当前版本为准)
from PIL import Image
from sam3 import build_sam3_image_model, Sam3Processor
model = build_sam3_image_model(ckpt_path="sam3.1.pt", device="cuda")
proc = Sam3Processor(model)
image = Image.open("hit_frames/000123.jpg").convert("RGB")
state = proc.set_image(image)
# 概念写成「名词 + 属性」,不要写「危险」「异常」这类抽象词
out = proc.set_text_prompt(state=state, prompt="bird nest on transmission tower")
instances = out["instances"]
print("命中实例数", len(instances), "最高得分", float(instances["scores"].max()))
坑:把 PCS 当精确分类器验收。它给的是「这个概念像不像」,不是带类别 ID 的检测结果,用它算 mAP 会得到一堆对不上的类号。正确口径是「人工抽检 100 帧,看目标是否被圈到、是否圈多了」,把它当召回补充器而不是替代检测器。第二个坑是概念提示写得太抽象:
danger这类词几乎不出框,写成fallen tree blocking road、bird nest on tower这种具体短语才有稳定结果。
2.4 常见推理模式
把推理放在哪一层,决定了链路的上限。纯云端省心但吃带宽和时延,纯边缘低延迟但运维成本高,端云协同是大多数生产项目的最终形态。
2.4.1 三种模式对比
| 维度 | 纯云端 | 纯边缘 | 端云协同(边缘初筛 + 云端复核) |
|---|---|---|---|
| 端到端时延 | 1 ~ 3 s(含推流) | 0.2 ~ 0.8 s | 0.3 ~ 1.5 s |
| 上行带宽 | 全部视频上传,3 ~ 8 Mbps/路 | 只传结构化结果,< 100 kbps/路 | 传结果 + 关键帧,0.2 ~ 1 Mbps/路 |
| 算力成本 | 云端 GPU 按路数线性增长 | 一次性硬件投入 + 现场运维 | 边缘轻量模型 + 云端重模型 |
| 精度上限 | 高(可用大模型) | 受功耗与散热限制 | 高(复核阶段用大模型) |
| 运维复杂度 | 低(集中升级) | 高(设备分散、升级难) | 中(需管理模型版本与下发) |
| 断网可用性 | 不可用 | 完全可用 | 边缘可用,复核延后 |
| 适合场景 | 固定机房、离线介质巡检 | 实时告警、弱网现场 | 大规模机巢、需要高精度复核 |
判断口诀:告警要实时且现场有电 → 边缘;要精度、要回溯、可接受秒级 → 云端;两者都要且路数 > 20 → 端云协同。
2.4.2 服务化形态与超时策略
推理能力最终要被业务调用,暴露成什么接口决定了超时与重试怎么写:
| 形态 | 协议 | 适合 | 超时设置 | 重试策略 |
|---|---|---|---|---|
| 同步 HTTP | POST /infer(图片或 base64) | 单张图、低频、调试 | 3 ~ 10 s | 只对 5xx 重试 1 次,需幂等 |
| 异步任务 | POST /jobs → GET /jobs/{id} | 批量图片、视频文件 | 提交 2 s,查询 1 s | 提交失败可重试;查询只轮询 |
| 流式 gRPC | 双向流,帧进结果出 | 实时多路、低延迟 | 建流 5 s,心跳 10 s | 断流重连 + 帧序号去重 |
同步接口最容易踩的坑是「上游超时了但推理还在跑」,形成重复计算。解决方式是把请求 ID 透传下去,服务端按 ID 做 60 ~ 120 秒的幂等缓存,命中就直接返回上次结果;同时给推理队列设排队上限,排队超过设定秒数立刻返回 429,让上游做退避,而不是让请求在服务端无界堆积。
坑:健康检查只返回
{"ok": true},负载均衡把流量打到一个模型还没加载完的实例上,表现为「服务正常但全部超时」。健康检查里要显式判断模型句柄非空、预热已跑过一次推理。
2.4.3 Tracking-by-detection:为什么巡检一定要跟踪
没有跟踪时,一个目标连续 200 帧就是 200 条告警,人工复核会直接放弃这个系统。跟踪解决三件事:去重(一个目标一次告警)、测速(连续帧位移除以时间)、轨迹还原(事后回溯目标从哪来到哪去)。
ByteTrack 极简流程(巡检场景够用):
1. 当前帧检测框按置信度分成高/低两组(如 0.5 / 0.1 两个阈值)
2. 高分组与已有轨迹做第一次匹配:IoU + 卡尔曼预测框,匈牙利算法求最优
3. 未匹配的高分框,与「低分组中未匹配的框」做第二次匹配(这是 ByteTrack 的关键)
4. 仍未匹配的高分框 → 新建轨迹(连续 3 帧命中才输出,抑制闪断)
5. 连续 30 帧未匹配的轨迹 → 删除
6. 输出时按 track_id 做告警去重:同一 id + 同一类别 + 时间窗内只发一次
# dedup.py —— 按 track_id 做告警去重与冷却(与检测/跟踪器解耦)
import time
from dataclasses import dataclass, field
@dataclass
class AlarmGate:
cooldown_s: float = 30.0
min_hits: int = 3
_last: dict = field(default_factory=dict)
_hits: dict = field(default_factory=dict)
def accept(self, track_id: int, cls: str, now: float | None = None) -> bool:
now = now if now is not None else time.time()
key = (track_id, cls)
# 连续命中计数:抑制单帧误检造成的瞬时告警
self._hits[key] = self._hits.get(key, 0) + 1
if self._hits[key] < self.min_hits:
return False
last = self._last.get(key)
if last is not None and now - last < self.cooldown_s:
return False
self._last[key] = now
return True
gate = AlarmGate(cooldown_s=30.0, min_hits=3)
assert gate.accept(7, "person", now=100.0) is False # 第 1 次命中
assert gate.accept(7, "person", now=100.1) is False # 第 2 次命中
assert gate.accept(7, "person", now=100.2) is True # 第 3 次命中 → 告警
assert gate.accept(7, "person", now=105.0) is False # 冷却期内
2.4.4 降级策略与成本模型
降级必须提前设计成四级,触发条件自动判断,不允许「出问题了手工重启」:
| 级别 | 触发条件 | 动作 | 对业务的影响 |
|---|---|---|---|
| L0 正常 | 推理延迟 P95 < 200 ms | 全量检测 + 跟踪 + 高精度模型 | 无 |
| L1 轻量 | P95 200 ~ 500 ms 或 GPU 利用率 > 85% | 换轻量模型(n 代替 s/m) | 小目标检出率下降,告警量减少 |
| L2 抽帧 | 队列积压 > 2 s 或持续 L1 超 5 min | 抽帧 1/4,只保留关键帧 | 高速目标可能漏检 |
| L3 只报事件 | 推理不可用或带宽 < 1 Mbps | 停视频上传,只上报设备事件与快照 | 无实时视频,保留告警能力 |
成本模型的三个分量都要按「每路每秒」折算,才能在方案之间比价:
每路每秒成本 = 视频带宽成本 + 推理算力成本 + 存储成本
带宽成本 = 码率(Mbps) × 3600 / 8 / 1024 × 流量单价(元/GB) / 3600
算力成本 = 单路 GPU 占用比 × GPU 小时单价(元/h) / 3600
存储成本 = 码率(Mbps) × 86400 / 8 / 1024 × 保留天数 × 存储单价(元/GB) / 86400
算例(经验单价,仅演示口径):
码率 4 Mbps、流量 0.5 元/GB、GPU 6 元/h、单路占 0.25 卡、存 7 天、存储 0.12 元/GB
带宽 = 4 × 3600 / 8 / 1024 × 0.5 / 3600 ≈ 0.00024 元/路/秒
算力 = 0.25 × 6 / 3600 ≈ 0.00042 元/路/秒
存储 = 4 × 86400 / 8 / 1024 × 7 × 0.12 / 86400 ≈ 0.00005 元/路/秒
合计 ≈ 0.00071 元/路/秒 ≈ 2.56 元/路/小时;30 路常开一天约 1843 元
结论:30 路以上时算力成本开始压倒带宽成本,此时把推理下沉到边缘、只回传结构化结果,
单路成本通常能降到云端的 40% ~ 60%(不含边缘设备折旧与运维人力)。
坑:只按「平均码率」算带宽。无人机图传在转向、变焦、复杂纹理时码率会冲到平均值的 1.5 ~ 2 倍,按平均值采购带宽的做法在实测里通常要返工。按 P95 码率留 1.5 倍余量。
2.4.5 端云协同的新形态:边缘初筛 + 云端概念复核
2.4.1 的端云协同还是「边缘轻模型 + 云端重模型」的粗粒度划分。2026 年有了 NMS-free 的单阶段检测器与概念分割模型,两层可以按任务性质而不是按模型大小来切:
边缘(Jetson Thor / Orin)
拉流 → 抽帧 → 脱敏 → YOLO26n/s(NMS-free)初筛
│
┌──────────────┴───────────────┐
│ 命中(命中率 0.5% ~ 2%) │ 未命中:只上报心跳与统计
▼ ▼
命中帧 + 结构化框 + 关键帧 丢弃(或本地留存 7 天)
│
│ HTTPS 批量上传,单路上行 < 1 Mbps
▼
云端
├─ SAM 3.1 概念复核(文本概念 / exemplar):把初筛框收敛成「成立 / 驳回」
├─ Object Multiplex 视频级跟踪:同一目标跨帧只留一条记录
└─ 复核结论回写告警表:命中、驳回、升级人工三个状态
这样切的三个直接好处:
| 指标 | 纯边缘 | 纯云端 | 边缘初筛 + 云端复核 |
|---|---|---|---|
| 单路上行带宽 | < 100 kbps | 3 ~ 8 Mbps | 0.2 ~ 1 Mbps(按命中率折算) |
| 复核精度 | 受边缘模型规模限制 | 高 | 高(复核用 SAM 3.1) |
| 长尾类别覆盖 | 无 | 无(闭集检测器同样没有) | 有(文本概念随时加) |
| 断网可用性 | 完全可用 | 不可用 | 边缘告警可用,复核延后 |
合规约束比带宽更硬,这一条要写进方案而不是放在附录:
- 无人机航摄影像在多数场景属于测绘成果,采集与处理涉及测绘资质与空域审批,平台侧要留存任务批文与操作员信息;
- 视频与图片上云前必须脱敏:人脸模糊、车牌掩码,脱敏要在边缘完成——先上云再脱敏等于已经完成了违规传输;
- 数据出境、第三方云存储、日志保留期限要按项目所在地的数据安全要求单独评审,测绘成果与位置数据的开放程度和普通视频不是一个口径;
- 告警结论入库时要带「复核来源」字段(边缘初筛 / 云端复核 / 人工),否则出问题时无法回溯责任链。
坑:把「命中率低所以上传量小」当成带宽预算的依据。命中率是统计值,暴雪、浓雾、火灾这类异常天气里误报会短时飙升,上传量按 P95 而非均值预留 3 ~ 5 倍余量;同时给边缘加一个「上传队列上限 + 超限降级为只传缩略图」的开关,否则弱网下队列积压会把磁盘写满,推理进程跟着挂掉。
2.5 VisDrone 数据集训练
巡检模型最常见的起点就是 VisDrone:俯视视角、目标密集、小目标占多数,和真实无人机画面分布接近。直接拿 COCO 预训练权重在 VisDrone 上跑,mAP50 通常只有 0.25 ~ 0.35;按本节流程走完,YOLOv8s 在 1024 输入下能到 0.45 ~ 0.52(经验值,随划分与超参波动)。2026 年新开项目建议直接用 YOLO26 起跑(选型理由见 2.3.2),本节命令同时给出 YOLO26 与 yolov8s 两条线,后者只用于存量基线与回归对照。
2.5.1 数据集构成与类别
VisDrone 有检测、跟踪、分割三个子任务,巡检训练只用检测子集。官方划分为 train 6471 张、val 548 张、test-dev 1610 张、test-challenge 1580 张(图像来自不同城市、不同天气与高度)。测试集标注不公开,本地训练只能用 train/val。
| 类别 ID | 英文名 | 中文 | 巡检中的含义 | 训练注意 |
|---|---|---|---|---|
| 1 | pedestrian | 行人 | 人员入侵、禁入区告警 | 本类样本最多,不要额外加权 |
| 2 | people | 人群 | 聚集、疏散评估 | 与 pedestrian 易混,看标注口径 |
| 3 | bicycle | 自行车 | 非机动车闯入 | 小目标多,受遮挡严重 |
| 4 | car | 小汽车 | 违停、占用应急车道 | 类别不平衡的主要来源 |
| 5 | van | 厢式车 | 占道经营、货物堆放 | 与 truck 边界模糊 |
| 6 | truck | 卡车 | 超限、违规通行 | 长宽比极端,锚框感知明显 |
| 7 | tricycle | 三轮车 | 违规载人载货 | 易与 awning-tricycle 混 |
| 8 | awning-tricycle | 棚顶三轮 | 占道、载客 | 数量少,需检查是否被漏训 |
| 9 | bus | 公交车 | 站点违停 | 大目标,AP 通常最高 |
| 10 | motor | 摩托车 | 未戴头盔、违规上路 | 极小而快,漏检重灾区 |
标注文件是「一行一个目标」的 txt,字段顺序为 bbox_left, bbox_top, bbox_width, bbox_height, score, category, truncation, occlusion。两个细节决定转换是否正确:category=0 表示「忽略区域」,必须整框丢弃;score=0 表示该框无效(常见于测试集与部分训练图),同样丢弃。
2.5.2 标注格式转换:VisDrone txt → YOLO txt
YOLO 要求 class cx cy w h 且全部归一化到 0~1。转换时最容易错的是「用图像真实尺寸还是标注文件里的尺寸」——必须用图像真实尺寸,且要用 PIL 读,不要用文件名猜。
# visdrone2yolo.py —— VisDrone 检测标注转 YOLO,带体检统计
import argparse
import json
from collections import Counter
from pathlib import Path
from PIL import Image
CLASS_MAP = {1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 7, 9: 8, 10: 9}
NAMES = ["pedestrian", "people", "bicycle", "car", "van",
"truck", "tricycle", "awning-tricycle", "bus", "motor"]
def convert(src_img_dir: Path, src_ann_dir: Path, dst_root: Path, split: str):
img_out = dst_root / "images" / split
lbl_out = dst_root / "labels" / split
img_out.mkdir(parents=True, exist_ok=True)
lbl_out.mkdir(parents=True, exist_ok=True)
stat = Counter()
for ann in sorted(src_ann_dir.glob("*.txt")):
img_path = None
for ext in (".jpg", ".png"):
cand = src_img_dir / (ann.stem + ext)
if cand.exists():
img_path = cand
break
if img_path is None:
stat["missing_image"] += 1
continue
with Image.open(img_path) as im:
w, h = im.size
rows = []
for line in ann.read_text(encoding="utf-8").splitlines():
parts = line.strip().split(",")
if len(parts) < 8:
continue
x, y, bw, bh = map(float, parts[:4])
score, cat = float(parts[4]), int(parts[5])
if cat not in CLASS_MAP or score <= 0: # 忽略区域与无效框
stat["dropped"] += 1
continue
if bw <= 1 or bh <= 1:
stat["tiny_drop"] += 1
continue
cx, cy = (x + bw / 2) / w, (y + bh / 2) / h
rows.append(f"{CLASS_MAP[cat]} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
stat[NAMES[CLASS_MAP[cat]]] += 1
(lbl_out / (ann.stem + ".txt")).write_text("\n".join(rows), encoding="utf-8")
# 图片用符号链接省磁盘;Windows 无权限时退化为真实复制
link = img_out / img_path.name
if not link.exists():
try:
link.symlink_to(img_path.resolve())
except OSError:
link.write_bytes(img_path.read_bytes())
return stat
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--src", required=True, help="VisDrone2019-DET-train 目录")
ap.add_argument("--dst", required=True, help="输出目录")
args = ap.parse_args()
src = Path(args.src)
stat = convert(src / "images", src / "annotations", Path(args.dst), "train")
print(json.dumps(dict(stat), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
python visdrone2yolo.py --src data/VisDrone2019-DET-train --dst data/visdrone
python visdrone2yolo.py --src data/VisDrone2019-DET-val --dst data/visdrone
# 抽样检查:抽 5000 个标签文件看是否有越界(归一化值必须在 0~1)
python - <<"PY"
from pathlib import Path
bad = []
for p in list(Path("data/visdrone/labels/train").glob("*.txt"))[:5000]:
for i, line in enumerate(p.read_text().splitlines()):
v = [float(x) for x in line.split()[1:]]
if any(x < 0 or x > 1 for x in v):
bad.append((str(p), i, v))
print("越界标签数:", len(bad), bad[:3])
PY
2.5.3 训练配置:640 与 1024 的取舍
VisDrone 的小目标占比很高:按面积小于 32×32 像素统计,在 640 输入下约有 55% ~ 65% 的目标落入小目标区间;改到 1024 输入,这个比例降到 35% ~ 45%。这就是为什么 VisDrone 上 imgsz=1024 通常比 640 的 mAP50 高 6 ~ 10 个点,代价是显存与耗时约翻倍。
# visdrone.yaml —— 放在项目根目录,path 用绝对路径避免相对路径踩坑
path: /data/visdrone
train: images/train
val: images/val
names:
0: pedestrian
1: people
2: bicycle
3: car
4: van
5: truck
6: tricycle
7: awning-tricycle
8: bus
9: motor
# 基线:640 输入、YOLO26s、AMP 开
yolo detect train data=visdrone.yaml model=yolo26s.pt \
imgsz=640 epochs=100 batch=16 workers=8 amp=True \
project=runs/visdrone name=base640 seed=42
# 小目标友好:1024 输入 + 低 mosaic + 尾部关闭 mosaic + 更小学习率
yolo detect train data=visdrone.yaml model=yolo26s.pt \
imgsz=1024 epochs=120 batch=8 workers=8 amp=True \
mosaic=0.8 close_mosaic=15 scale=0.4 \
lr0=0.005 lrf=0.01 warmup_epochs=3 cos_lr=True \
project=runs/visdrone name=small1024 seed=42
# 存量基线对照:论文口径的旧权重,只用来回答「换 YOLO26 到底涨了多少」
yolo detect train data=visdrone.yaml model=yolov8s.pt \
imgsz=1024 epochs=120 batch=8 workers=8 amp=True \
mosaic=0.8 close_mosaic=15 scale=0.4 \
lr0=0.005 lrf=0.01 warmup_epochs=3 cos_lr=True \
project=runs/visdrone name=v8s1024 seed=42
坑:Ultralytics 每个大版本都会调整 CLI 参数名与默认超参(YOLO26 的训练配方里多了 MuSGD 优化器、渐进损失与 STAL 小目标标签分配,默认值不等于旧版本)。照抄别处的命令行报「unrecognized arguments」时,先跑
yolo detect train --help核对参数是否存在,不要在旧教程和当前版本之间反复猜。
| 参数 | 640 基线 | 1024 小目标 | 作用与调整依据 |
|---|---|---|---|
imgsz |
640 | 1024 | 直接决定小目标的可用像素;显存不足时先降 batch |
batch |
16 | 8 | 保持显存占用接近;1024 下 batch 8 在 24 GB 卡上约用 18 GB |
mosaic |
1.0 | 0.8 | 拼接会把目标缩得更小,过高的 mosaic 对极小目标反而有害 |
close_mosaic |
10 | 15 | 收尾阶段关闭 mosaic,让模型收敛到真实分布 |
scale |
0.5 | 0.4 | 缩小增强会制造更小的目标,小目标任务需减弱 |
amp |
True | True | 显存与速度双收益;出现 NaN 时再关闭排查 |
seed |
42 | 42 | 固定随机种子,保证实验可复现 |
坑:把
path写成相对路径,然后在别的目录启动训练,Ultralytics 会找不到图而静默跳过部分样本,表现为「训练集 6471 张但每个 epoch 只跑了 3000 张」。用绝对路径,并在训练日志里核对train: Scanning ... images的数字。
2.5.4 评估:整体 mAP 好看但小目标全漏
这是 VisDrone 上最常见也最误导人的现象:整体 mAP50 到 0.5 就以为可以交付,实际漏的全是 20 像素以下的目标。诊断方法是按尺度分桶统计召回,而不是只看总 mAP。
# bucket_recall.py —— 按目标像素面积分桶统计召回(YOLO 标签格式,简化匹配口径)
import argparse
from collections import defaultdict
from pathlib import Path
BUCKETS = [("xs", 0, 16), ("s", 16, 32), ("m", 32, 96), ("l", 96, 10**9)]
def area_bucket(area: float) -> str:
for name, lo, hi in BUCKETS:
if lo <= area < hi:
return name
return "l"
def read_labels(path: Path, w: int, h: int):
out = []
if not path.exists():
return out
for line in path.read_text().splitlines():
parts = line.split()
if len(parts) < 5:
continue
_, cx, cy, bw, bh = parts[:5]
out.append((int(parts[0]), float(bw) * w, float(bh) * h))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--gt", required=True, help="labels/val")
ap.add_argument("--pred", required=True, help="推理结果 labels 目录")
ap.add_argument("--size", type=int, default=1024)
args = ap.parse_args()
total, hit = defaultdict(int), defaultdict(int)
for gt_file in Path(args.gt).glob("*.txt"):
preds = read_labels(Path(args.pred) / gt_file.name, args.size, args.size)
for cls, bw, bh in read_labels(gt_file, args.size, args.size):
key = area_bucket(bw * bh)
total[key] += 1
# 简化匹配:同类 + 宽高差小于较大框的一半,视为命中
ok = any(pc == cls and abs(pw - bw) < max(bw, 1) * 0.5
and abs(ph - bh) < max(bh, 1) * 0.5 for pc, pw, ph in preds)
hit[key] += int(ok)
for name, _, _ in BUCKETS:
n = total[name]
print(f"{name:>2} 数量={n:>6} 召回={hit[name] / n:.3f}" if n else f"{name:>2} 数量=0")
if __name__ == "__main__":
main()
# 导出验证集预测(txt 结果,便于与 GT 逐文件比对)
yolo detect predict model=runs/visdrone/small1024/weights/best.pt \
source=data/visdrone/images/val imgsz=1024 conf=0.25 save_txt=True save_conf=True \
project=runs/visdrone name=pred_val
python bucket_recall.py --gt data/visdrone/labels/val \
--pred runs/visdrone/pred_val/labels --size 1024
| 分桶结果(示例量级) | 说明 | 下一步动作 |
|---|---|---|
| xs < 16² 召回 < 0.15 | 极小目标基本没学到 | 提高输入尺寸或改用切片推理,见 2.6 |
| s 16² ~ 32² 召回 0.3 ~ 0.5 | 典型 VisDrone 水平 | 加 P2 检测头或提高 imgsz,收益最明显 |
| m 32² ~ 96² 召回 > 0.7 | 正常 | 不用动 |
| l > 96² 召回 > 0.85 | 正常 | 不用动 |
| 所有桶都低 | 数据或评测脚本错了 | 先核对标签、类名映射与 conf 阈值 |
2.5.5 训练工程:划分、去重、过拟合与续训
数据划分三条规则:
1. 按「架次/场景」划分,不按随机图片划分——同一架次的相邻帧几乎重复,随机划分会让
val 里出现与 train 近乎相同的图,mAP 虚高 5 ~ 15 个点。
2. 每类在 val 中至少出现 50 个实例,否则该类指标没有统计意义。
3. 保留一个「跨城市场景」的 holdout,用来验证泛化,不参与调参。
4. 划分前先做感知哈希去重(8×8 均值哈希 + 汉明距离 ≤ 4 判为重复),重复帧只保留一张,
否则 val 里混进 train 的近似副本,指标会虚高且无法发现过拟合。
| 训练现象 | 判据 | 处理 |
|---|---|---|
| train loss 降但 val mAP 连续 15 epoch 下降 | 过拟合 | 加增强(mosaic、mixup)、减模型规模、早停 |
| train 与 val loss 同时不降 | 学习率过大或标签错 | 先用 100 张图过拟合测试:能否到 0.99 |
| mAP 抖动 > 0.05 | batch 太小或数据量太少 | 增大 batch 或用梯度累积,固定种子复跑 |
| 从头训到 100 epoch 仍不如官方权重 | 初始化或归一化问题 | 用 COCO 预训练权重,确认 amp 没有导致 NaN |
| 显存 OOM 且 batch 降到 1 仍报错 | 输入过大 + 多尺度 | 降 imgsz 到 896,或改用切片推理 |
# 断点续训:从 last.pt 继续(不要用 best.pt,优化器状态会丢)
yolo detect train resume model=runs/visdrone/small1024/weights/last.pt
# 固定种子复跑,验证实验可复现
yolo detect train data=visdrone.yaml model=yolo26s.pt imgsz=1024 epochs=120 \
batch=8 seed=42 deterministic=True project=runs/visdrone name=repro
坑:
resume必须在「同一个 run 目录」下执行。复制last.pt到别处再 resume,Ultralytics 会从头开始并覆盖参数,日志里Resuming training from ...后面跟的 epoch 是 0 就是没接上。
2.6 小目标识别方法
小目标问题的本质是信息量不足:32×32 的目标在 1024 输入下只有不到 30 像素高,经过五次 2 倍下采样后特征图上只剩不到 1 个像素,检测头拿到的是一个被平均掉的模糊响应。所有方法都在解决同一个矛盾——要么让目标占更多像素,要么让网络少丢像素。
2.6.1 根因定位:先分清是「看不到」还是「认不出」
四类根因与判别方法:
① 像素太少(看不到) 把 GT 框裁出来放大到 224×224 肉眼看:能认出 → 信息还在
② 下采样丢失(看不全) 对同一张图把输入从 640 提到 1280,看该类召回是否明显上升
③ 正样本太少(学不到) 统计该类目标在训练集中的实例数与平均面积
④ 标签质量差(学错) 随机抽 100 张叠加可视化,看框是否偏、是否漏标
| 根因 | 快速验证 | 首选方案 | 预期增益(经验) |
|---|---|---|---|
| 像素太少 | 放大裁片仍可辨 | 提高输入分辨率 / 切片推理 | +5 ~ +15 AP_s |
| 下采样丢失 | 提高输入后召回跳升 | 加 P2 小目标头 / 特征融合 | +3 ~ +8 AP_s |
| 正样本太少 | 实例数 < 500 | 过采样、复制粘贴增强、类别加权 | +2 ~ +6 AP_s |
| 标签质量差 | 可视化发现框偏移/漏标 | 清洗标注,重新标注 | 视数据而定,常是最大项 |
2.6.2 方法清单:原理、代价、适用场景
| 方法 | 原理 | 精度增益量级 | 推理开销 | 工程复杂度 | 推荐优先级 |
|---|---|---|---|---|---|
| 提高输入分辨率(640→1024/1280) | 目标占更多像素 | +6 ~ +12 mAP50 | 1.8 ~ 3 倍耗时 | 低 | ★★★★★ |
| 切片推理(SAHI 思路) | 大图切块分别推理再合并 | +8 ~ +20 AP_s | 块数线性增长 | 中 | ★★★★★ |
| 增加 P2 检测头 | 在 1/4 分辨率特征图上出检测 | +3 ~ +8 AP_s | +15% ~ 30% | 中(改模型结构) | ★★★★ |
| 特征融合(BiFPN/加权 FPN) | 让高分辨率特征参与强语义融合 | +2 ~ +5 AP_s | +10% ~ 20% | 中高 | ★★★ |
| 注意力机制(CBAM/EMA) | 强化小目标区域响应 | +1 ~ +4 AP_s | +5% ~ 10% | 低(改结构) | ★★★ |
| 超分辨率预处理 | 先放大再检测 | +2 ~ +6 AP_s | +50% ~ 100% | 中(多一个模型) | ★★ |
| 标签分配优化(ATSS/SimOTA) | 给更多小目标分配正样本 | +1 ~ +4 AP_s | 训练侧开销 | 高(改训练代码) | ★★ |
| TTA / 多尺度测试 | 多尺度预测后融合 | +1 ~ +3 mAP50 | 2 ~ 3 倍耗时 | 低 | ★★(离线仲裁用) |
优先级只看两个变量:目标平均像素高度、能否拿到更高分辨率的原始视频。原始流是 4K 而推理只用了 640,那么「提高输入 + 切片」就是免费收益;原始流本身只有 720p,再切片也只是放大模糊,此时优先改飞控高度或换长焦载荷。
2.6.3 切片推理完整实现:切图 + 重叠 + 坐标还原 + 跨块合并
切片推理的六个关键参数,任何一个写错都会让结果看起来「有框但位置偏」:
| 参数 | 含义 | 建议值 | 设错的后果 |
|---|---|---|---|
slice_wh |
每块尺寸 | 640 或 1024(与训练 imgsz 一致) | 与训练分辨率不一致 → 尺度失配 |
overlap |
重叠比例 | 0.2(目标接近块边缘时提到 0.3) | 太小 → 目标被切断漏检 |
conf |
单块置信度阈值 | 0.25(比整图推理略低) | 太高 → 边缘目标被过滤 |
iou_nms |
合并时 NMS 阈值 | 0.5 ~ 0.6 | 太低 → 相邻块同一目标被重复框误删 |
merge_mode |
合并策略 | NMS 或 NMM(加权平均) | NMM 在高重叠下框更准 |
skip_full |
是否保留整图推理 | 建议同时跑一遍整图 | 大目标在切片里可能不完整 |
# sliced_infer.py —— 切片推理:切图 → 批量推理 → 坐标还原 → 跨块 NMS 合并
from dataclasses import dataclass
import numpy as np
from ultralytics import YOLO
@dataclass
class Det:
cls: int
conf: float
xyxy: tuple[float, float, float, float] # 原图坐标系
def slice_windows(w: int, h: int, size: int, overlap: float):
"""生成 (x0, y0, x1, y1) 滑窗,保证覆盖整图且边界块贴边。"""
step = int(size * (1 - overlap))
xs = list(range(0, max(w - size, 0) + 1, step)) or [0]
ys = list(range(0, max(h - size, 0) + 1, step)) or [0]
if xs[-1] + size < w:
xs.append(max(w - size, 0))
if ys[-1] + size < h:
ys.append(max(h - size, 0))
for y in ys:
for x in xs:
yield x, y, min(x + size, w), min(y + size, h)
def nms(dets: list[Det], iou_thres: float = 0.55) -> list[Det]:
"""按类别分组的纯 numpy NMS,输入输出都用原图坐标。"""
keep_all = []
for cls in {d.cls for d in dets}:
group = [d for d in dets if d.cls == cls]
group.sort(key=lambda d: -d.conf)
keep = []
while group:
best = group.pop(0)
keep.append(best)
rest = []
for d in group:
if iou(best.xyxy, d.xyxy) <= iou_thres:
rest.append(d)
group = rest
keep_all.extend(keep)
keep_all.sort(key=lambda d: -d.conf)
return keep_all
def iou(a, b) -> float:
ax0, ay0, ax1, ay1 = a
bx0, by0, bx1, by1 = b
ix0, iy0 = max(ax0, bx0), max(ay0, by0)
ix1, iy1 = min(ax1, bx1), min(ay1, by1)
iw, ih = max(ix1 - ix0, 0), max(iy1 - iy0, 0)
inter = iw * ih
union = (ax1 - ax0) * (ay1 - ay0) + (bx1 - bx0) * (by1 - by0) - inter
return inter / union if union > 0 else 0.0
def infer_sliced(model: YOLO, img: np.ndarray, size: int = 1024,
overlap: float = 0.2, conf: float = 0.25, batch: int = 8):
h, w = img.shape[:2]
wins = list(slice_windows(w, h, size, overlap))
tiles, metas = [], []
for x0, y0, x1, y1 in wins:
tiles.append(img[y0:y1, x0:x1])
metas.append((x0, y0))
dets: list[Det] = []
for i in range(0, len(tiles), batch):
chunk, meta = tiles[i:i + batch], metas[i:i + batch]
results = model.predict(chunk, imgsz=size, conf=conf, verbose=False)
for res, (x0, y0) in zip(results, meta):
for box in res.boxes:
bx0, by0, bx1, by1 = box.xyxy[0].tolist()
dets.append(Det(int(box.cls.item()), float(box.conf.item()),
(bx0 + x0, by0 + y0, bx1 + x0, by1 + y0)))
return nms(dets, iou_thres=0.55) # 跨块重复框在这里被合并
if __name__ == "__main__":
import cv2
model = YOLO("runs/visdrone/small1024/weights/best.pt")
img = cv2.imread("data/visdrone/images/val/0000006_00159_d_0000014.jpg")
for d in infer_sliced(model, img)[:5]:
print(d.cls, round(d.conf, 3), [round(v, 1) for v in d.xyxy])
坑:坐标还原时把块内坐标加上了
x0, y0,但 NMS 用的是块内坐标——代码看着对,结果框全挤在左上角。还原和合并必须在同一个坐标系里做,建议像上面那样在Det里就存原图坐标,NMS 只吃还原后的框。
2.6.4 实验记录:只改一个变量,记录配置快照
调小目标最容易失控的地方是「同时改了三个参数,精度涨了但不知道为什么」。实验记录表固定六列,每次只允许一个变量不同:
| 实验 ID | 变量 | 配置快照(关键项) | mAP50 | AP_s(16~32²) | 结论 |
|---|---|---|---|---|---|
| E01 | 基线 | imgsz=640, mosaic=1.0, yolov8s(换成 yolo26s 重跑即为新基线) | 0.412 | 0.121 | 对照组 |
| E02 | imgsz | imgsz=1024,其余同 E01 | 0.486 | 0.208 | 分辨率是最大单因素 |
| E03 | mosaic | mosaic=0.8 + close_mosaic=15,其余同 E02 | 0.497 | 0.226 | 有效,+0.018 |
| E04 | 切片推理 | 模型同 E03,推理切 1024/overlap0.2 | 0.512 | 0.301 | 推理侧收益最大 |
| E05 | P2 头 | 在 E03 基础上加 P2 | 0.503 | 0.259 | 训练成本涨 25%,收益中等 |
# 每次实验固定三件事:种子、配置快照、结果落盘
EXP=E05
yolo detect train data=visdrone.yaml model=yolov8s-p2.yaml imgsz=1024 epochs=120 \
batch=8 seed=42 project=runs/exp name=$EXP
cp runs/exp/$EXP/args.yaml runs/exp/$EXP/args.snapshot.yaml # 配置快照留档
cp runs/exp/$EXP/results.csv runs/exp/$EXP/results.keep.csv
git rev-parse HEAD > runs/exp/$EXP/commit.txt # 代码版本留档
坑:
results.csv会被下一次训练覆盖(同名 run 目录递增命名时不会,但手工 copy 容易混)。每次实验结束后把args.yaml、results.csv、best.pt的 SHA256 一起归档,否则两周后你无法复现「当时那个 0.512」。
2.7 视频 AI 与空间数据的融合
检测框给的是像素坐标,业务要的是经纬度。这一段做错,前面所有工作都算不上「空间智能」——只能算看视频。
2.7.1 坐标链路与相机模型
完整链路是五步,每一步都能单独出 bug:像素 → 去畸变像素 → 相机坐标 → 世界坐标(东北天)→ 经纬度。
| 环节 | 输入 | 参数来源 | 常见错误 |
|---|---|---|---|
| 内参 | 像素坐标 | 相机标定(fx, fy, cx, cy) |
用推理分辨率的内参去算原图坐标,没按比例缩放 |
| 畸变 | 去畸变坐标 | k1, k2, p1, p2, k3 |
广角镜头不去畸变,边缘目标偏 20 ~ 50 像素 |
| 外参 | 相机坐标 → 世界 | 无人机 POS + 云台角 | 云台俯仰没叠加到相机姿态,目标整体前移 |
| 时间对齐 | 同一时刻 | OSD 时间戳与帧 PTS | 用当前时刻的外参配 300 ms 前的帧 |
| 投影 | 世界 → 经纬度 | 参考点 + 局部 ENU 近似 | 直接用墨卡托反算,中纬度误差可达米级 |
内参按分辨率缩放的口径要写死:标定得到的是 (w0, h0) 下的内参,推理时用了 (w1, h1),则 fx *= w1 / w0、fy *= h1 / h0、cx *= w1 / w0、cy *= h1 / h0。这一步漏掉,热力图整体偏移且偏移量随目标位置变化。
针孔模型 + 归一化坐标:
相机坐标系:x 向右,y 向下,z 向前(光轴)
像素 :u = fx × (x / z) + cx
v = fy × (y / z) + cy
反投影射线 :(x, y, z) = ((u - cx) / fx, (v - cy) / fy, 1) × t
航向 yaw、俯仰 pitch、横滚 roll 组成旋转矩阵 R(机体 → 东北天)
射线在世界系的方向: d = R × (x, y, 1)
2.7.2 地平面假设:射线与平面求交
巡检目标绝大多数落在地面或近似平面(公路、厂区、耕地),已知该平面高程 h0、相机高程 h 时,射线与水平面求交是最稳的近似,代价只有一次除法。深度方向误差随距离平方增长,因此限制在 300 m 内使用最稳。
# geo_project.py —— 像素框底边中点 → 经纬度(地平面假设)
import math
import numpy as np
R_EARTH = 6378137.0
def euler_to_R(yaw_deg: float, pitch_deg: float, roll_deg: float) -> np.ndarray:
"""ZYX 顺序:先偏航、再俯仰、最后横滚,返回机体→东北天的旋转矩阵。"""
y, p, r = np.radians([yaw_deg, pitch_deg, roll_deg])
rz = np.array([[math.cos(y), -math.sin(y), 0], [math.sin(y), math.cos(y), 0], [0, 0, 1]])
ry = np.array([[math.cos(p), 0, math.sin(p)], [0, 1, 0], [-math.sin(p), 0, math.cos(p)]])
rx = np.array([[1, 0, 0], [0, math.cos(r), -math.sin(r)], [0, math.sin(r), math.cos(r)]])
return rz @ ry @ rx
def pixel_to_ground(u, v, K, R, cam_alt, ground_alt=0.0):
"""返回目标点相对相机的东向/北向偏移(米)。K=(fx,fy,cx,cy)。"""
fx, fy, cx, cy = K
ray_cam = np.array([(u - cx) / fx, (v - cy) / fy, 1.0])
d = R @ ray_cam # 东北天方向(未归一化)
dz = d[2]
if dz > -1e-6: # 射线朝上,打不到地面
raise ValueError("ray does not hit ground: 检查云台俯仰与 R 的符号")
t = (ground_alt - cam_alt) / dz # t > 0
return d[0] * t, d[1] * t
def enu_to_lonlat(lon0, lat0, east, north):
"""局部 ENU 近似:300 m 内误差在厘米级,够巡检用。"""
dlat = north / R_EARTH
dlon = east / (R_EARTH * math.cos(math.radians(lat0)))
return lon0 + math.degrees(dlon), lat0 + math.degrees(dlat)
# 例:云台俯仰 -90°(垂直下视),画面中心点直接落在正下方
K = (1400.0, 1400.0, 960.0, 540.0)
R = euler_to_R(yaw_deg=0, pitch_deg=-90, roll_deg=0)
east, north = pixel_to_ground(960, 540, K, R, cam_alt=100.0, ground_alt=0.0)
print(round(east, 3), round(north, 3)) # 期望 (0, 0)
lon, lat = enu_to_lonlat(116.397, 39.908, east, north)
print(round(lon, 7), round(lat, 7))
2.7.3 误差敏感度:为什么姿态比内参更致命
地平面假设下,目标距离误差主要由姿态误差放大:Δx ≈ h · tan(Δθ),h 是航高。这条公式解释了为什么 1° 的姿态误差在 200 m 航高下就能造成 3.5 m 偏移,而内参差 1% 只影响画面比例。
| 航高 h | 姿态误差 0.5° | 1° | 2° | 5° |
|---|---|---|---|---|
| 50 m | 0.44 m | 0.87 m | 1.75 m | 4.37 m |
| 100 m | 0.87 m | 1.75 m | 3.49 m | 8.75 m |
| 200 m | 1.75 m | 3.49 m | 6.99 m | 17.5 m |
| 300 m | 2.62 m | 5.24 m | 10.5 m | 26.2 m |
配套的工程判断:告警定位精度要 ≤ 5 m,就要求航高 ≤ 100 m 且姿态误差 ≤ 2°;红外载荷的云台角误差通常更大,不要用可见光的精度指标验收红外。另外,DEM 起伏 20 m 的场景里地平面假设会产生同等量级的偏移,此时改用 DEM 射线迭代求交:沿视线以 1 m 步长采样 DEM 高程,找到视线高程与地形高程的第一个交点。
成果落库建议统一成三张表:alarm_point(目标中心经纬度 + 高程 + 精度半径)、alarm_box(地面投影四边形,GeoJSON 存 geometry)、track(track_id + 时间序列,用 LineString 或时序点表),几何字段统一用 EPSG:4326 存储、前端渲染时再用 ST_Transform 或 proj4 转 Web 墨卡托(EPSG:3857)。
坑:把「框中心」直接投影。斜视时框中心可能落在目标后方的地面上(框的下沿才是脚点),人员目标会整体后偏 3 ~ 8 m。人员/车辆类目标用框底边中点投影,建筑类目标用框中心,这个规则要写进标注与反算的同一份约定里。
2.8 用 DeepSeek Harness 串起训练与部署
这一节把前面七节的重复劳动交给 DSH:数据体检、实验矩阵、CI 门禁、报错定位。关键不是「让 AI 写代码」,而是给一个可验收的任务书——写清输入、输出、判据,DSH 产出的脚本才能进流水线。
2.8.1 数据体检:把一次性脚本沉淀成技能
每次拿到新一批巡检数据都要检查:类别分布、坏图、尺寸异常、标签越界、重复帧。把这些检查写成 $DSH_HOME/skills/uav-data-audit/SKILL.md,以后一句话就能复用。
$DSH_HOME/skills/uav-data-audit/
├── SKILL.md # 触发条件 + 检查项 + 验收判据
└── scripts/audit.py # 体检脚本(类别分布/坏图/越界/重复)
# 用 headless 模式跑一次体检,产出结构化报告给流水线消费
dsh --profile headless "检查 data/visdrone 下的 labels 与 images,做四项检查:
1) 类别实例数分布 2) 无法打开或尺寸为 0 的坏图 3) 归一化坐标越界 4) 感知哈希近重复;
输出 audit.json 与一段人类可读摘要;任何一项超阈值时退出码非 0" \
--json --permission-mode workspace-write > audit.log 2>&1
# 流水线只认退出码与 audit.json,不解析自然语言
python -c "import json,sys; d=json.load(open('audit.json')); sys.exit(0 if d['ok'] else 1)"
| 检查项 | 判据(经验阈值) | 不达标时的动作 |
|---|---|---|
| 类别实例数 | 每类 ≥ 500,且最大类/最小类 ≤ 50 | 补采或合并相近类,别硬加权 |
| 坏图 | 0 张无法解码、0 张边长 < 640 | 直接剔除并记录来源架次 |
| 标签越界 | cx/cy/w/h 全部在 (0, 1] | 修标注脚本的归一化分母 |
| 近重复帧 | 重复率 < 3% | 去重后再划分,防止 val 泄漏 |
| 尺寸分布 | 长边 96% 落在同一档 | 混合多档时按档分桶评估 |
2.8.2 实验矩阵:生成配置、批量提交、汇总
# 让 DSH 生成实验矩阵并落成可执行脚本(示例:imgsz × model 两组变量)
dsh --profile headless "基于 runs/visdrone/E03/args.yaml,生成 4 组实验:
imgsz 取 896 或 1024,model 取 yolo26s.pt 或 yolo26m.pt;每组写一个 configs/E0x.yaml,
并生成 run_matrix.sh:串行执行,每组结束后把 results.csv 最后一行追加到 summary.csv" \
--json --permission-mode workspace-write
bash run_matrix.sh
sort -t, -k3 -n -r summary.csv | head -20 # 按 mAP50 排序看结果
矩阵执行纪律(写进任务书,不靠自觉):
1. 一次只跑一组,避免抢显存导致 OOM 与耗时数据失真
2. 每组开始前记录 GPU 空闲状态,结束后记录峰值显存
3. 任一组失败不中断整个矩阵,失败原因写入 summary.csv 的 error 列
4. 全部结束后由 DSH 生成一段「哪一组最好、好多少、是否值得多花的算力」的结论
2.8.3 进 CI:headless + 退出码做门禁
# ci/check.sh —— 训练前的一致性门禁,失败即阻断流水线
set -euo pipefail
dsh --profile headless "检查 visdrone.yaml 的 path/train/val 是否存在、
names 是否为 10 类且与标签里的类别 ID 一致、val 集每类实例数是否 ≥ 50;
只输出结论与修复建议,不修改任何文件" --json --permission-mode read-only > ci-report.json
node -e "const r=require('./ci-report.json'); process.exit(r.verdict==='pass'?0:1)"
echo "[gate] data config OK"
2.8.4 排障:把日志贴给 DSH,要定位不要客套
| 报错现象 | 让 DSH 做的第一件事 | 常见真因 |
|---|---|---|
trtexec 报 builtin_op_importers 不支持某算子 |
逐条对照导出时的 opset 与算子清单 | 自定义后处理被包进了 ONNX 图 |
| ONNX 与 PyTorch 输出 diff 超阈值 | 固定输入种子,逐层比对中间张量 | 预处理归一化不一致(0~1 vs 0~255) |
| 推理服务 P99 突然翻倍 | 对比前后两版的 batch 与显存占用 | 批处理参数被改,或 GPU 被其他进程占用 |
| 告警位置整体偏移 | 校验内参缩放与姿态时间戳 | 推理分辨率变化后没缩放 cx/cy |
dsh 任务跑完但没产物 |
检查 workspace 是否选对、权限是否 read-only | 权限预设挡住写入,任务静默降级为只读 |
坑:把整段几百行日志直接丢给 DSH,得到的往往是泛泛的「检查版本兼容性」。正确姿势是给三样东西:最小复现命令、完整报错栈的前后各 20 行、你已经排除的可能。第三项最省时间,它把搜索空间直接砍掉一半。
2.9 本章小结
- 低空巡检的瓶颈在链路时序而不是模型精度:端到端时延要按「事件时刻 → 工单创建」测量,不要用推理耗时冒充。
- 上云 API 是 MQTT + HTTPS 的混合模型,事件类消息必须 QoS 1,
services与services_reply必须靠tid配对。 - 设备上线联调按「拓扑 → OSD → 上传航线 → prepare → execute → 进度事件 → 拉媒体」七步走,跳步会让排错无从下手。
- 模型导出链路必须做数值一致性校验:ONNX 对比用随机张量即可,INT8 校准必须用真实帧。
- 推理放置看三个量:允许时延、上行带宽、路数;20 路以上且要精度时用端云协同,别在两端之间反复摇摆。
- 巡检告警必须加跟踪与去重门(连续命中 + 冷却窗口),否则一个目标能刷出上百条工单。
- VisDrone 训练的关键参数是
imgsz=1024与close_mosaic;评估必须按目标尺度分桶看召回,整体 mAP 会掩盖小目标全漏。 - 小目标收益排序:提高输入分辨率与切片推理 > P2 检测头 > 特征融合 > 注意力;先用前两个再考虑改结构。
- 像素转经纬度的误差主要来自姿态与航高(
Δx ≈ h · tan(Δθ)),人员类目标必须用框底边中点投影。 - 用 DSH 做数据体检与实验矩阵时,任务书里写清输入、输出、判据与退出码;没有退出码的自动化等于没有自动化。
- 接入层选型:新项目直接上上云 API 2(免费 License、不限设备台数、协议无感、虚拟调试),存量 v1.x 项目只做维护不做迁移,除非要接远程调试与航线编辑器组件。
- 模型层分工:YOLO26 负责闭集高频目标(NMS-free、单阶段、边缘友好),SAM 3.1 负责长尾开放类别与视频级跟踪;YOLO27 仍是预览、权重未发布,不要写进交付方案。
2.10 动手练习
- 端到端时延测量:输入一段机场直播流与一份告警日志,要求给每条告警算出「帧采集时刻 → 工单创建时刻」,输出 P50/P95。验收:能说明为什么不能用推理日志时间戳,且 P95 与手工抽样结果误差 ≤ 0.5 s。
- 航线任务联调:用 MQTT 客户端订阅
thing/product/+/state与/events,实现「prepare 成功后再 execute」的状态机。验收:并发下发两个任务时不串号,tid全部配对成功。 - VisDrone 基线复现:按 2.5.3 的 640 与 1024 两套配置各训一次,输出整体 mAP50 与分桶召回表。验收:两组固定同一
seed,能解释 1024 的增益来自哪个尺度桶。 - 切片推理对齐:在同一批图上比较整图推理与切片推理,输出两种结果的召回、误报与耗时。验收:跨块重复框被正确合并,同一目标不出现两个框。
- 像素转经纬度回环验证:挑 20 个已知地面控制点,用
geo_project.py反算经纬度,统计水平误差。验收:航高 100 m、俯仰误差 ≤ 1° 时平均误差 ≤ 2 m,并能给出误差随航高变化的趋势。
2.11 延伸阅读
- 大疆上云 API 文档(含 v1.x 存量协议)
- 大疆上云 API 2 教程(DaaS SDK、组件与虚拟调试)
- VisDrone 数据集官方仓库
- Ultralytics 文档:YOLO26 模型家族
- SAM 3 论文:Segment Anything with Concepts(arXiv:2511.16719)
- SAM 3 官方实现(含 SAM 3.1 支持)
- Ultralytics 文档:模型导出(ONNX/TensorRT/OpenVINO)
- SAHI:切片推理与辅助推理框架