第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 动手练习

  1. 端到端时延测量:输入一段机场直播流与一份告警日志,要求给每条告警算出「帧采集时刻 → 工单创建时刻」,输出 P50/P95。验收:能说明为什么不能用推理日志时间戳,且 P95 与手工抽样结果误差 ≤ 0.5 s。
  2. 航线任务联调:用 MQTT 客户端订阅 thing/product/+/state 与 /events,实现「prepare 成功后再 execute」的状态机。验收:并发下发两个任务时不串号,tid 全部配对成功。
  3. VisDrone 基线复现:按 2.5.3 的 640 与 1024 两套配置各训一次,输出整体 mAP50 与分桶召回表。验收:两组固定同一 seed,能解释 1024 的增益来自哪个尺度桶。
  4. 切片推理对齐:在同一批图上比较整图推理与切片推理,输出两种结果的召回、误报与耗时。验收:跨块重复框被正确合并,同一目标不出现两个框。
  5. 像素转经纬度回环验证:挑 20 个已知地面控制点,用 geo_project.py 反算经纬度,统计水平误差。验收:航高 100 m、俯仰误差 ≤ 1° 时平均误差 ≤ 2 m,并能给出误差随航高变化的趋势。

2.11 延伸阅读