第03章 - 空地一体时空融合
无人机在地图上划一条航线只要三秒钟,摄像头拍一段视频也只要按一下录制,可要把这两样东西真对上,你会发现它们压根不在同一个世界里:视频是像素,航线是经纬度,飞控说的是上电后的毫秒计数,摄像机说的是 PTS。本章就解决这件事——把空中平台的数据与地面 GIS 底座放进同一套坐标、同一条时间轴、同一个精度口径,然后才谈得上配准、融合、联动。
读完这一章,你能自己跑通一条最小的空地一体链路:把像素坐标换算到经纬度、把 POS 与视频帧对齐到 20 ms 以内、用 2.5D 代价地图规划一条带安全裕度的巡检航线、用平面标记引导 3 m 内自主降落、把实时视频按真视角贴到三维地球上、最后用 ffmpeg + COLMAP(或 VGGT 前馈)+ OpenDroneMap 出正射、用同一套位姿训 3DGS。三维底座按 3D Tiles 1.1 与带 LOD 的 splat 分层,精度按底图来源分级承诺。所有环节都给出可复制的命令与验收数字,而不是概念图。
学习目标:掌握七个坐标系与一条转换链,能用 pyproj + numpy 独立实现并做往返校验;能算出 GSD、重叠率、航高与航时;能写出 solvePnP 位姿解算与视觉伺服状态机;能在「地面四边形贴图」和「按 DEM 逐像素投影」之间做取舍、能按 3D Tiles 1.1 与 splat LOD 分层搭三维底座;能用 DeepSeek Harness(下称 DSH)批量造数、生成回归测试、定位日志异常时段。 前置知识:Python 3.10+、numpy、OpenCV 基础;知道旋转矩阵与欧拉角的关系;听过 WGS84、ECEF、RTK、POS 这几个词;会写最简单的 bash。
3.1 空地一体:概念与坐标体系
3.1.1 一句话定义:同基准才谈得上融合
空地一体不是「把无人机视频接到大屏上」。它的定义是:把空中平台(无人机、自动机场、吊舱)与地面设施(固定相机、RTK 基站、GIS 底座、指挥席)的数据,放进统一的时空框架里做配准、融合与联动。拆开只有三件事:
- 统一坐标:像素、机体系、局部 ENU、ECEF、经纬度之间可逆互转,误差可量化。
- 统一时间:UTC、GPS 周秒、PTS、设备上电计数能互相对齐,对齐残差有统计。
- 统一度量:GSD、定位精度、时间误差统一到「米」和「秒」,而不是「差不多清楚」「基本同步」。
缺任何一条,后端的融合都会变成玄学:大屏上视频飘、航线偏、降落点抖,而你无法判断是标定错了、时间错了还是底图基准错了。
┌─────────────────────────────┐
│ 空基:无人机 / 机场 / 吊舱 │
│ POS · IMU · 视频 · 载荷数据 │
└──────────────┬──────────────┘
│ 坐标系:ECEF / ENU / 机体系
┌───────────────────────────────▼───────────────────────────────┐
│ 时空基准层(本章重点,必须先做对) │
│ · 坐标:WGS84 ↔ CGCS2000 ↔ 高斯投影 ↔ 地方独立系 ↔ ENU │
│ · 时间:UTC ↔ GPS 周秒 ↔ 视频 PTS ↔ 飞控上电计数 │
│ · 度量:GSD / 定位精度 / 时间误差 → 统一折算成米 │
└───────────────────────────────┬───────────────────────────────┘
│ 统一后的 (t, lon, lat, h, rpy)
┌───────────────────────────────▼───────────────────────────────┐
│ 地基:GIS 底座 · 三维场景 · 成果库 · 指挥席 │
└───────────────────────────────────────────────────────────────┘
把融合按「在流水线哪一段汇合」分成四层,各层的验收口径完全不同:
| 融合层次 | 汇合的数据 | 典型手段 | 验收口径 |
|---|---|---|---|
| 基准层 | 坐标、时间、高程 | 七参数/四参数、NTP/PTP、大地水准面模型 | 坐标往返 < 1 mm,时间残差 RMS < 20 ms |
| 数据层 | 视频帧、点云、栅格 | 时间戳配对、空间索引 | 帧—位姿配对无错位,抽帧不丢 |
| 特征层 | 影像特征、检测框、目标 ID | 单应估计、多视图几何、ReID | 重投影误差 < 1.5 px,ID 切换率 < 5% |
| 决策层 | 告警、工单、处置记录 | 规则引擎 + 时空关联 | 同一事件跨空/地两路可归并 |
大多数团队一上来就做「决策层大屏」,结果每一层都在给基准层还债。正确的顺序是反的:先把基准层做对,再做数据层,特征层和决策层才有意义。
3.1.2 七个坐标系与一条转换链
同一个空间点,在一条数据链路上会以七种身份出现。记住这张表,比记住任何公式书都管用:
| 序号 | 坐标系 | 常用缩写 | 定义要点 | 单位 | 典型来源 |
|---|---|---|---|---|---|
| 1 | WGS84 地理坐标 | EPSG:4326 | 经度、纬度、椭球高,基准 WGS84 | 度 / m | 原始 GNSS、GPS 模块 |
| 2 | CGCS2000 地理坐标 | EPSG:4490 | 2000 国家大地坐标系,与 WGS84 差在 cm 级 | 度 / m | 国内测绘成果、正式报批 |
| 3 | 高斯-克吕格投影 | EPSG:4534~4554 等 | 3 度带或 6 度带,横轴墨卡托 | m | 地形图、线划数据、规划 |
| 4 | 地心地固 | ECEF / EPSG:4978 | 原点在地心,X 轴指向本初子午线与赤道交点 | m | 卫星轨道、惯导解算 |
| 5 | 局部切平面 | ENU / NED | 以某点为原点的东-北-天或北-东-地 | m | 飞控本地坐标、避障、编队 |
| 6 | 机体坐标系 | FRD / FLU | 前-右-下(PX4/ArduPilot)或前-左-上(ROS REP-103) | m / rad | 姿态解算、手眼标定 |
| 7 | 相机与像素坐标 | cam / pixel | 相机系 x 右 y 下 z 前(OpenCV),像素为 (u, v) | m / px | 检测框、PnP、AR 投影 |
转换链必须一条走通,中间任何一跳搞错都会在最后表现为「偏移几十米」:
像素 (u,v)
│ ① 去畸变 + 内参逆:x_cam = K^-1 · [u, v, 1]^T (归一化像平面)
▼
相机坐标系 (x 右, y 下, z 前)
│ ② 外参:T_body_cam(手眼标定得到,含 R 与 t)
▼
机体坐标系 (FRD: 前右 下 / FLU: 前左 上,注意符号)
│ ③ 姿态:R_enu_body = Rz(yaw)·Ry(pitch)·Rx(roll)
▼
ENU 局部切平面 (原点为 home 点)
│ ④ 平移:加原点 ECEF,再用 ENU 旋转矩阵转 ECEF
▼
ECEF (X, Y, Z)
│ ⑤ 椭球反算(Bowring 或迭代)
▼
经纬高 (lon, lat, h)
用矩阵写出来只有一句:T_enu_pixel = T_enu_body · T_body_cam · T_cam_pixel。难的不是乘法,而是每个矩阵的方向约定和时间戳归属——这个矩阵描述的是哪一时刻的姿态,比它算得准不准更致命。
坑 1:把「像素到经纬度」当成一个二维单应。只要相机有俯仰、地面有起伏(>2 m),单应就会在画面边缘产生数米误差。斜视场景必须走三维射线与地面求交,不能用单应近似。
坑 2:机体坐标系方向搞反。PX4 与 ArduPilot 的姿态输出是 FRD(z 向下),ROS/OpenCV 习惯 FLU 与「z 向前」。差一个符号就是「飞机往左倾时画面往上飘」。
坑 3:把 EPSG:4326 的轴序当 (x, y)。OGC 规范里 4326 是 lat, lon,而大多数代码库默认 lon, lat。pyproj 里务必显式写 always_xy=True,否则整条链路静默转置。
3.1.3 时间基准:0.5 s 的错位就是 5 m 的位移
坐标对齐之后,第二个必须钉死的是时间。先把常见时间源列清楚:
| 时间源 | 基准 | 是否含闰秒 | 精度量级 | 使用注意 |
|---|---|---|---|---|
| UTC / NTP | 世界协调时 | 含 | 局域网 1~5 ms,公网 20~100 ms | 闰秒当秒会出现 23:59:60 |
| GPS 周秒(TOW) | GPS 时 | 不含 | PPS 可达 < 1 μs | 与 UTC 差整秒(当前 18 s,随闰秒变化) |
| Unix 时间戳 | UTC 线性化 | 不含 | 取决于对时 | 与 GPS 时差 18 s,不是 0 |
| 视频 PTS | 流时间基(常见 1/90000) | 无 | 帧间隔量级 | 从 0 起还是从 container start_time 起,看 ffprobe |
| 飞控上电计数 | 设备本地 | 无 | ms 级 | 每次上电归零,必须与 UTC 对齐一次 |
| 相机曝光中点 | 设备本地 | 无 | 行曝光时间量级 | 卷帘快门需按行补偿 |
数量级换算一下:多旋翼巡检速度 10 m/s,0.5 s 的时间错位就是 5 m 的位置错位;测绘航线 20 m/s 时,100 ms 错位等于 2 m。所以时间对齐的目标不是「看起来同步」,而是「残差有数、漂移可测」。
用最小二乘同时估出「偏移」和「漂移率」,比手工改偏移靠谱得多:
# time_align.py —— 用一次飞行的视频 PTS 与飞控 UTC 时间戳做线性拟合
import numpy as np
# 真实场景:t_video 来自 ffprobe 逐帧 pts_time,t_utc 来自飞控日志的 GPS 时间
# 这里用合成数据复现「25 fps + 30 ppm 时钟漂移 + 0.8 s 固定偏移 + 8 ms 抖动」
t_video = np.arange(0.0, 300.0, 0.04)
rng = np.random.default_rng(0)
t_utc = 1735689600.0 + t_video * (1 + 30e-6) - 0.8 + rng.normal(0.0, 0.008, t_video.size)
k, b = np.polyfit(t_video, t_utc, 1)
resid = t_utc - (k * t_video + b)
print(f"scale = {k:.9f}")
print(f"drift = {(k - 1) * 1e6:+.1f} ppm")
print(f"offset = {b - 1735689600.0:+.3f} s")
print(f"rms = {resid.std() * 1000:.1f} ms")
print(f"max = {np.abs(resid).max() * 1000:.1f} ms")
print(f"30ppm 漂移在 300 s 内累计 {30e-6 * 300 * 1000:.0f} ms,约 {30e-6 * 300 * 10:.2f} m(10 m/s)")
输出应接近 drift = +30 ppm、offset = -0.800 s、rms ≈ 8 ms。如果残差里出现「阶梯」,通常是丢帧或时间基换算错了(比如 mp4 的 timescale 是 12800 而不是 90000);如果残差是单调增大的曲线,说明两边时钟有温漂,需要用分段线性或二次拟合。
验收口径建议直接写进 CI:
| 指标 | 通过阈值 | 超阈值时先查什么 |
|---|---|---|
| 漂移率 | ≤ 50 ppm | 相机晶振、编码器 timescale、是否 drop frame |
| 残差 RMS | ≤ 20 ms | 时间戳来源是否同一物理事件、抖动是否被平滑 |
| 残差最大值 | ≤ 50 ms | 丢帧、PTS 回绕、日志时间戳取的是「接收时间」而非「采集时间」 |
| 帧—位姿配对率 | ≥ 99% | 飞控日志频率是否覆盖全程、是否有断链 |
3.1.4 用 pyproj 与 numpy 打通转换链
先解决投影。国内项目最常踩的坑是「WGS84 直接当 CGCS2000 用」和「地方独立坐标系没有参数只有四参数文本」。前者在 cm 级任务(视觉降落、形变监测)不能忽略,后者必须把参数变成 PROJ 字符串或写进 proj.db。
# crs_convert.py —— WGS84 ↔ CGCS2000 高斯投影(3 度带,中央子午线 114E)
from pyproj import Transformer, CRS
WGS84 = CRS.from_epsg(4326) # 经纬度,度
CGCS2000 = CRS.from_epsg(4490) # 经纬度,度
GK_CM114 = CRS.from_epsg(4547) # CGCS2000 / 3-degree Gauss-Kruger CM 114E
to_gk = Transformer.from_crs(WGS84, GK_CM114, always_xy=True)
to_wgs = Transformer.from_crs(GK_CM114, WGS84, always_xy=True)
lon, lat = 113.900000, 22.500000
x, y = to_gk.transform(lon, lat) # x = 东坐标,y = 北坐标,单位 m
lon2, lat2 = to_wgs.transform(x, y)
print(f"GK : E={x:.3f} N={y:.3f}")
print(f"round : {lon2:.9f} {lat2:.9f}")
print(f"d_lon : {abs(lon2 - lon) * 1e9:.3f} 纳度")
# WGS84 与 CGCS2000 的差异:两系都对齐到 ITRF 系列,国内范围差异在 cm 级
t_2k = Transformer.from_crs(WGS84, CGCS2000, always_xy=True)
print(f"WGS84==CGCS2000 差异(同一点): {t_2k.transform(lon, lat)}")
注意 EPSG:4534~4554 是「CM」系列,东坐标不含带号前缀(500 km 假东之后的 6 位数);而 4513~4533 是「zone」系列,东坐标带两位带号。两者看似只差一个数字,混用会让整景数据平移 30 km 以上。带号与 CM 的对应关系可以用一条命令验证:
# 查看某个 EPSG 的 PROJ 字符串与适用范围
projinfo EPSG:4547 -o PROJ
projinfo EPSG:4547 -o WKT2:2019 | head -20
# 打印该投影带内一点的实际坐标
echo "113.9 22.5" | cs2cs EPSG:4326 EPSG:4547 -f "%.3f"
再解决局部切平面。ENU 是所有避障、编队、降落控制的公共语言,必须自己实现一遍并做往返校验,不要依赖「某个库应该有」:
# enu.py —— 大地坐标 ↔ ECEF ↔ ENU,全部用 numpy 手写,便于单元测试
import math
import numpy as np
A = 6378137.0 # WGS84 长半轴
F = 1 / 298.257223563 # 扁率
E2 = F * (2 - F) # 第一偏心率平方
def geodetic_to_ecef(lon, lat, h):
lam, phi = math.radians(lon), math.radians(lat)
sin_phi = math.sin(phi)
n = A / math.sqrt(1 - E2 * sin_phi * sin_phi)
x = (n + h) * math.cos(phi) * math.cos(lam)
y = (n + h) * math.cos(phi) * math.sin(lam)
z = (n * (1 - E2) + h) * sin_phi
return np.array([x, y, z])
def ecef_to_geodetic(xyz, tol=1e-12, max_iter=15):
x, y, z = xyz
lam = math.atan2(y, x)
p = math.hypot(x, y)
phi = math.atan2(z, p * (1 - E2))
h = 0.0
for _ in range(max_iter):
n = A / math.sqrt(1 - E2 * math.sin(phi) ** 2)
h = p / math.cos(phi) - n
phi_new = math.atan2(z, p * (1 - E2 * n / (n + h)))
if abs(phi_new - phi) < tol:
phi = phi_new
break
phi = phi_new
n = A / math.sqrt(1 - E2 * math.sin(phi) ** 2)
h = p / math.cos(phi) - n
return math.degrees(lam), math.degrees(phi), h
def enu_rotation(lon0, lat0):
lam, phi = math.radians(lon0), math.radians(lat0)
sl, cl, sp, cp = math.sin(lam), math.cos(lam), math.sin(phi), math.cos(phi)
return np.array([
[-sl, -sp * cl, cp * cl],
[cl, -sp * sl, cp * sl],
[0.0, cp, sp],
]) # 每行对应 E / N / U 方向在 ECEF 中的分量
def geodetic_to_enu(lon, lat, h, lon0, lat0, h0):
d_ecef = geodetic_to_ecef(lon, lat, h) - geodetic_to_ecef(lon0, lat0, h0)
return enu_rotation(lon0, lat0) @ d_ecef
def enu_to_geodetic(e, n, u, lon0, lat0, h0):
d_ecef = enu_rotation(lon0, lat0).T @ np.array([e, n, u])
return ecef_to_geodetic(geodetic_to_ecef(lon0, lat0, h0) + d_ecef)
if __name__ == "__main__":
lon0, lat0, h0 = 113.9, 22.5, 20.0
lon, lat, h = 113.901, 22.5008, 120.0
e, n, u = geodetic_to_enu(lon, lat, h, lon0, lat0, h0)
print(f"ENU : E={e:.3f} N={n:.3f} U={u:.3f}")
lon2, lat2, h2 = enu_to_geodetic(e, n, u, lon0, lat0, h0)
print(f"back : {lon2:.9f} {lat2:.9f} {h2:.4f}")
print(f"残差 : {abs(lon2 - lon) * 1e9:.3f} 纳度, {abs(h2 - h):.6f} m")
往返残差应该在 1e-8 度量级(即毫米以下),如果跑出来是「米」,先看 enu_rotation 的转置用错了没有:从 ECEF 到 ENU 是 R·d,从 ENU 到 ECEF 是 R^T·d。
还剩一个高频事故点:高程基准。上面的 h 是椭球高,而国内地图、DEM、机场标高用的是 1985 国家高程基准的正常高,两者差一个高程异常(大地水准面差距),国内量级在 -10 m 到 -40 m 之间,必须用 EGM96/EGM2008 或省级似大地水准面精化模型换算。换算错了不会报错,只会让飞机「按地图高度在山顶下方 20 m 飞」。
# 用 PROJ 做一次大地水准面换算(示例:WGS84 椭球高 → EGM96 正常高)
# 需要 proj-data 提供 geoid 网格;国内高精度请换用省级精化模型
echo "113.9 22.5 120.0" | cs2cs EPSG:4979 EPSG:4326+5773 -f "%.3f"
| 验收项 | 方法 | 通过标准 |
|---|---|---|
| 投影往返 | WGS84 → GK → WGS84 | 经度残差 < 1e-8 度(约 1 mm) |
| ENU 往返 | 经纬高 → ENU → 经纬高 | 平面 < 1 mm,高程 < 2 mm |
| 已知点对 | 取 3 个以上实测控制点 | 平面 < 2 cm,高程 < 5 cm(RTK 条件下) |
| 轴序正确性 | 单点 lon 大于 lat 的国内点位 | 不得出现「纬度 113、经度 22」 |
| 高程基准 | 与水准点比对 | 偏差 < 0.1 m(用了正确格网模型) |
坑 4:
always_xy漏写。pyproj 的 Transformer 默认按 CRS 定义轴序(4326 是 lat, lon),漏写等于把经纬度转置,投影结果还能算出来,只是全错。坑 5:ENU 原点随意选。原点必须选在任务区中心(或 home 点)并在整条链路上固定,中途换原点等于把所有历史轨迹平移。
坑 6:把 ENU 的 U 当成海拔。U 是相对原点的椭球高差;要报给业务方的「海拔」得先加原点高程,再扣高程异常。
3.1.5 实景三维中国与地方基准(2026)
前面四小节讲的是「怎么转」。这一节讲「转过去之后信谁」:项目里真正决定精度的不是你的转换代码,而是底图本身。2026 年的现实口径是这样——实景三维中国已经从大规模建设转入「十五五」提质增效阶段,国、省、市、县四级协同建设,全国 DEM 在东部与西部重点区域优于 2 m 格网、其余优于 5 m 格网,重要地理实体按年度增量更新。这三句话决定了三件事:公共底座是分级精度的、更新是按年计的、成果形式是分层的。进展口径以自然资源部公开信息为准,写方案时注明引用年份。
把「底图从哪来」列成一张能直接拿去做技术方案的对照表:
| 来源 | 平面精度量级 | 高程精度量级 | 更新时效 | 获取方式 | 能承诺的用途 |
|---|---|---|---|---|---|
| 国家/省级实景三维成果(DOM + 2~5 m 格网 DEM) | DOM 0.2~0.5 m 量级 | 格网间距 2~5 m,高程中误差另计 | 重要地理实体年度增量 | 政务共享/申请 | 宏观规划、大屏、选址 |
| 天地图 / 政务影像服务 | 0.5~1 m 量级 | 无独立高程 | 季度~年度 | 免费或政务授权 | 展示、粗略量距 |
| 商业卫星影像 | 0.3~0.5 m 起(可订购更高) | 无,需配 DEM | 1~5 天 | 付费 | 应急踏勘、变化发现 |
| 自采无人机航测(RTK/PPK) | GSD 2~10 cm,检查点 RMSE 2~5 cm | 5~15 cm(取决于控制点) | 天级 | 空域 + 机组 | 航线规划、竣工核查 |
| 现场实测控制点 + 地方 CORS | 2~3 cm | 3~5 cm | 即时 | 设备与人 | 唯一能作为绝对精度判据 |
这张表最容易误读的是第一行:「2 m 格网」说的是采样间距,不是高程中误差。2 m 格网的 DEM 在丘陵与城区,高程中误差做到 0.3~1 m 是常态,拿它做贴地飞行规划,安全裕度必须按最坏值加,而不是按「2 m 精度」加。
承诺精度要按业务场景写,不要按「我们用了实景三维」写:
| 业务承诺 | 该依赖的底图 | 写在方案里的判据 |
|---|---|---|
| 大屏目标标注偏差 < 5 m | 公共底图即可 | 「相对同一底图的标注偏差」,不写绝对精度 |
| 航线规划安全裕度 1.5 m | 2~5 m 格网 DEM 成果 | 「DEM 格网 + 1.5 m 人工裕度」,并注明底图采集期 |
| 设备定位 < 10 cm | 地方 CORS + 已知点 | 写检查点 RMSE 与检查点数量,不写标称值 |
| 1:500 竣工/形变 | 自采 + 控制点 + 独立检查点 | 平面 RMSE ≤ 1 × GSD,高程 ≤ 2 × GSD |
地方基准是第二个容易翻车的地方。每个项目开工前必须把五样东西拿到手并写进项目的基准说明:地方坐标系统名与中央子午线、四参数或七参数及其符号约定、用的是 1985 国家高程基准还是地方高程系、似大地水准面精化模型的版本、CORS/网络 RTK 的源节点与覆盖范围。少一样,后面就会有人用「差不多」的参数凑出一套偏移几十米的成果。
底图到手先做一次「户口检查」,用 GDAL 把 CRS、格网、范围与元数据打印出来,不要看文件名猜:
# 底图三件套:CRS、格网、元数据(-json 输出便于进 CI 做版本比对)
gdalsrsinfo -o proj4 base/dem_2m.tif
gdalinfo -stats -json base/dem_2m.tif | head -40
# 地方四参数(Δx, Δy, 旋转 θ, 尺度 k)写成 PROJ 串再校验,别只存 Excel
# +theta 单位与旋转方向(position_vector / coordinate_frame)由测绘主管单位给定
projinfo "+proj=helmert +x=-1234.567 +y=2345.678 +s=1.2 +theta=0.000573 +convention=position_vector"
再给底图算一次「已知点都差多少」,把绝对精度测出来,而不是靠宣称:
# base_check.py —— 用已知点检查底图/成果的平面偏移,输出残差表
# known_points.csv 列:lon,lat,e_local,n_local(已知点来自 CORS 静态观测或实测控制点)
import csv
import numpy as np
from pyproj import Transformer
T = Transformer.from_crs("EPSG:4326", "EPSG:4547", always_xy=True) # 换成项目的地方 EPSG
rows = []
with open("known_points.csv", newline="", encoding="utf-8") as f:
for r in csv.DictReader(f):
e, n = T.transform(float(r["lon"]), float(r["lat"]))
rows.append((float(r["e_local"]) - e, float(r["n_local"]) - n))
d = np.array(rows)
print(f"点数 = {len(d)}")
print("平面残差 dE/dN (m):")
print(np.array2string(d, precision=3, suppress_small=True))
print(f"RMS 平面偏差 = {np.sqrt((d ** 2).sum(axis=1).mean()):.3f} m(RTK 条件下验收 < 0.05 m)")
print(f"最大偏差 = {np.abs(d).max():.3f} m")
已知点残差是唯一能写进验收报告的数字:它同时暴露底图误差、地方参数错误与转换符号错误。残差在米级时先查参数,降到厘米级才轮到讨论底图本身的精度。
注意:① 把「2 m 格网」当「2 m 精度」。格网间距是采样间隔,高程中误差要看成果报告;两者混用会直接把安全裕度算小。② 拿国家成果名义承诺 1:500。国家层面的实景三维成果不是按 1:500 图根精度验收的,1:500 只能自采 + 控制点 + 独立检查点。③ 地方参数只存文本、不带符号约定。四参数的旋转方向与单位(弧度/秒/ppm)错一位,成果整体平移或旋转,而且照样能算出数、不报错。
地方基准怎么入库、怎么在服务层统一、怎么与 3.1.2 的七坐标系对齐,见 第08章 空间数据底座与时空基准。
3.2 占用网格与航线规划
3.2.1 地图表示:先算内存,再谈算法
规划的第一件事不是选算法,是选地图表示。同样的 1 km² 任务区,换一种表示,内存能差两个数量级:
| 表示 | 数据结构 | 内存量级(1 km² 量级) | 单次查询 | 适用场景 |
|---|---|---|---|---|
| 2D 栅格 | 二维数组,1 字节/格 | 0.1 m 分辨率约 100 MB | O(1) | 二维避障、地面路径、农机 |
| 2.5D 高程栅格 | float32,含坡面 | 1 m 分辨率约 4 MB | O(1) | 地形跟随、能耗代价、航带设计 |
| ESDF(欧氏符号距离场) | float32,每格到最近障碍距离 | 与 2.5D 同级(0.1 m 时 400 MB) | O(1) | 梯度式局部规划、视觉伺服 |
| 3D 占用网格(OctoMap) | 八叉树,节点约 16~48 字节 | 同体积稠密体素可差 5~20 倍 | O(log N) | 三维避障、建筑物巡检 |
| 稠密体素 | 三维数组 | 100×100×40 m @0.2 m 约 50 MB | O(1) | 小范围精细规划、GPU 加速 |
内存不用真跑一遍才知道:按「格点数 × 每格字节数」手算即可。1 km² 在 0.1 m 分辨率下就是 1 亿格,单字节也是 100 MB 量级,所以选表示之前先算这一笔。
结论很直接:0.1 m 的二维栅格只适合几百米见方的局部避障,一个 km² 的巡检任务应该用 0.5~1 m 的 2.5D 代价栅格做全局规划,局部再用 0.1~0.2 m 的局部栅格做重规划。三维占用网格只在「必须穿过建筑/树林间隙」时才值得上,否则纯属给飞控电脑加负载。
3.2.2 代价地图:把「不能飞」和「不想飞」分开
代价地图(costmap)的关键是把两类约束表达清楚:
- 硬约束(blocked):禁飞区、限高面以下、障碍物物理安全半径内——这些格点直接不可通行。
- 软代价(cost):离障碍太近、坡度过大、逆光拍摄、能耗高——这些格点可通行但代价高。
最常用的合成公式:
cost(n) = w_obs · C_obs(n) 障碍斥力(距障碍越近越大,0~1)
+ w_nfz · C_nfz(n) 禁飞区/限高(软约束部分,硬约束另用 blocked 标)
+ w_slope · C_slope(n) 坡度能耗(坡度/30° 截断到 1)
+ w_energy· C_energy(n) 电池余量折算的返航风险
+ w_quality·C_quality(n) 视觉质量(太阳高度角、GSD 偏差、拼接重叠)
path_cost = Σ cost(n) × 步长(n)
不同任务给不同的权重,别一套参数打天下:
| 任务类型 | 安全裕度 (m) | w_obs | w_nfz | w_slope | w_energy | 取向 |
|---|---|---|---|---|---|---|
| 例行巡检 | 1.5 | 1.0 | 10.0 | 0.30 | 0.20 | 稳,宁可绕 |
| 应急处置 | 1.0 | 1.5 | 10.0 | 0.10 | 0.50 | 快,接受更近的贴边 |
| 测绘成图 | 2.0 | 0.8 | 10.0 | 0.40 | 0.10 | 稳 + 保证重叠率 |
| 夜间/低光 | 2.0 | 1.2 | 10.0 | 0.20 | 0.30 | 加大裕度,降低速度 |
生成 2.5D 代价栅格的最小实现(依赖 numpy + scipy):
# costmap.py —— 障碍膨胀 + 禁飞区 + 坡度,输出 cost 与 blocked 两张同尺寸栅格
import numpy as np
from scipy.ndimage import distance_transform_edt, sobel
def build_costmap(dem, occ_mask, nfz_mask, res_m, cfg):
"""
dem : 地面高程栅格 (m),用于算坡度
occ_mask : 障碍占位布尔栅格(建筑、树冠、塔吊)
nfz_mask : 禁飞区布尔栅格
res_m : 栅格分辨率 (m)
cfg : 权重字典,如 {"safety_m":1.5,"w_obs":1.0,"w_slope":0.3}
"""
safety = cfg["safety_m"]
dist_m = distance_transform_edt(~occ_mask) * res_m # 自由格到最近障碍的距离
c_obs = np.clip(1.0 - dist_m / safety, 0.0, 1.0) # 越近越大,0 表示足够远
blocked = (dist_m < safety * 0.3) | nfz_mask # 物理安全半径 + 禁飞区
# Sobel 归一化系数 8 为 3x3 核常数,再除以分辨率得到无量纲坡度
gx = sobel(dem.astype(np.float32), axis=1) / (8.0 * res_m)
gy = sobel(dem.astype(np.float32), axis=0) / (8.0 * res_m)
slope = np.hypot(gx, gy)
c_slope = np.clip(slope / 0.5, 0.0, 1.0) # 坡度 0.5(约 26.6 度)即满代价
cost = cfg["w_obs"] * c_obs + cfg["w_slope"] * c_slope
cost[blocked] = np.inf
return cost, blocked
# 自测:一块 200x200 栅格,中心立一栋 20x20 的建筑
if __name__ == "__main__":
dem = np.zeros((200, 200), dtype=np.float32)
occ = np.zeros((200, 200), dtype=bool)
occ[80:120, 90:110] = True
nfz = np.zeros((200, 200), dtype=bool)
nfz[:, 180:] = True
cost, blocked = build_costmap(dem, occ, nfz, 0.5, {"safety_m": 1.5, "w_obs": 1.0, "w_slope": 0.3})
print("blocked 格点数:", int(blocked.sum()), "/", blocked.size)
print("cost 范围:", float(np.nanmin(cost)), float(np.nanmax(cost)))
注意 cost[blocked] = np.inf 这个写法:把硬约束塞进同一张图,A* 只需要判断 np.isfinite(cost[n]),不用再维护第二张掩膜。代价是不能拿它做插值或梯度——ESDF 要单独保留距离场。
坑 7:膨胀半径按「旋翼半径」算。实际安全裕度应该是旋翼半径 + RTK 水平误差(固定解 2~3 cm,浮动解 0.3~0.5 m)+ 阵风位移(5 级风约 0.3 m)+ 地图配准误差(0.2~0.5 m)。1.5 m 是巡检的常用值,不是随便拍的。
坑 8:DEM 与障碍物不是同一时刻。用两年前的 DOM/DEM 规划,新加的光伏板、通信塔、临时脚手架一个都不在图上。工程上要么起飞前做一次正射巡视,要么把「未知区域」默认加代价。
坑 9:坡度用高程差直接相减。相邻格点高差除以格距才是坡度,且必须先确认 DEM 的像元单位是度还是米(有些数据的 CRS 是经纬度,像元大小是 1e-5 度)。
3.2.3 规划算法:A* 打底,D* Lite 重规划,覆盖路径单独一套
| 算法 | 完备性 | 最优性 | 典型耗时(1 km² / 1 m 栅格) | 适用 |
|---|---|---|---|---|
| Dijkstra | 完备 | 最优 | 秒级,可到十几秒 | 做基准对照,不要上生产 |
| A*(8 邻域) | 完备 | 最优(启发式可采纳时) | 0.1~1 s | 静态全局航线,默认选择 |
| D* Lite | 完备 | 最优 | 首次同 A*,增量更新毫秒级 | 障碍动态出现、地图边走边建 |
| RRT* | 概率完备 | 渐近最优 | 0.5~5 s,取决于采样数 | 高维、连续空间、狭窄通道 |
| 牛耕式覆盖 | 完备(凸区域) | 非最优(航带数近似最优) | 毫秒级 | 测绘、喷洒、大面积巡检 |
A* 的可运行最小实现(八邻域 + 八方向启发式 + 地形代价):
# astar.py —— 八邻域 A*,步长代价 = 几何距离 x (1 + 地形代价)
import heapq
import numpy as np
def astar(cost, start, goal):
h, w = cost.shape
usable = np.isfinite(cost)
def heuristic(a, b):
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
return (dx + dy) + (np.sqrt(2.0) - 2.0) * min(dx, dy) # 八方向精确启发
if not (usable[start] and usable[goal]):
return None
open_heap = [(heuristic(start, goal), 0.0, start)]
best = {start: 0.0}
parent = {}
closed = set()
while open_heap:
_, g_cur, cur = heapq.heappop(open_heap)
if cur in closed:
continue
closed.add(cur)
if cur == goal:
path = [cur]
while cur in parent:
cur = parent[cur]
path.append(cur)
return path[::-1]
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx == 0 and dy == 0:
continue
nb = (cur[0] + dx, cur[1] + dy)
if not (0 <= nb[0] < h and 0 <= nb[1] < w) or not usable[nb]:
continue
step = np.hypot(dx, dy) * (1.0 + float(cost[nb]))
ng = g_cur + step
if ng < best.get(nb, float("inf")):
best[nb] = ng
parent[nb] = cur
heapq.heappush(open_heap, (ng + heuristic(nb, goal), ng, nb))
return None
if __name__ == "__main__":
rng = np.random.default_rng(7)
grid = rng.random((120, 160)).astype(np.float32) * 0.6
grid[10:15, 20:140] = np.inf # 一条横向禁飞带,留出缺口
grid[10:15, 70:75] = 0.0
path = astar(grid, (5, 5), (115, 155))
print("路径点数:", None if path is None else len(path))
print("前 5 个点:", None if path is None else path[:5])
覆盖率更高的做法是分层规划:全局用 1 m 栅格 A(或 D Lite)出航段,局部用 0.2 m 栅格 + 实时障碍做重规划,中间用「航点 + 速度剖面」衔接。把 A* 直接跑在 0.1 m 全图上,规划一次十几秒,实飞时根本来不及。
D* Lite 的价值在于「地图边走边更新」:它从终点反向搜索,障碍变化时只重算受影响的一段,实测比从头跑 A* 快一到两个数量级。工程上常用的简化是「A* + 局部重规划窗口」——每次只重算前方 50~100 m 的走廊,效果够用,实现量小很多。
覆盖路径的生成只需要三个量:单张旁向覆盖宽度 W = GSD × 短边像素数、航带间距 S = W × (1 − 旁向重叠率)、航点数 = 航带数 × 2。矩形区域按牛耕式逐带扫描,奇数带正向、偶数带反向即可。
三个常见变体:分区覆盖(大区域切成若干凹/凸多边形分别跑,边界处再加一条过渡航带)、螺旋覆盖(适合圆形区域与机场净空区)、沿边覆盖(适合河湖岸线、公路、输电线路)。选哪种取决于区域形状:矩形用牛耕,圆形用螺旋,带状用沿边加往返。
3.2.4 GSD、重叠率、航高:三个数互相锁死
地面分辨率(GSD,Ground Sample Distance)的定义是:影像上一个像元在地面上对应多远。用相似三角形直接推:
相机针孔模型:物方一点 P 与像方成像 p 满足相似三角形
像元尺寸 p (m) 地面上对应的 GSD (m)
──────────────── = ─────────────────────
焦距 f (m) 航高 H (m)
=> GSD = p × H / f (最常用的一条公式,记牢)
=> H = GSD × f / p (反算:想要某 GSD 该飞多高)
=> 单张覆盖宽 W = GSD × 像元数 (像元数取对应方向的像素数)
算例(1 英寸 20 MP 相机:像元 2.4 μm,焦距 8.8 mm,5472×3648):
| 航高 H (m) | GSD (cm) | 单张覆盖 (m) | 1 km² 航带数(旁向 60%) | 10 m/s 纯飞行时间 |
|---|---|---|---|---|
| 100 | 2.7 | 149 × 100 | 26 | 42 min |
| 183 | 5.0 | 272 × 182 | 14 | 23 min |
| 250 | 6.8 | 372 × 249 | 10 | 17 min |
| 367 | 10.0 | 546 × 365 | 7 | 11 min |
同一块地,GSD 从 2.7 cm 放到 10 cm,飞行时间从 42 min 降到 11 min——这就是为什么「先问成图比例尺,再定 GSD,再定航高」必须按顺序来。成图比例尺与 GSD 的经验换算:
| 成图比例尺 | 建议 GSD | 说明 |
|---|---|---|
| 1:500 | ≤ 5 cm | 需 RTK/PPK + 地面控制点,慎用纯视觉 |
| 1:1000 | ≤ 10 cm | 常规工程测绘,RTK 足够 |
| 1:2000 | ≤ 20 cm | 大范围巡查、变更调查 |
| 1:5000 | ≤ 50 cm | 摸底、应急,不用于报批 |
重叠率直接决定空三能不能成功:
| 航向重叠 | 旁向重叠 | 匹配成功率的典型表现 | 适用 |
|---|---|---|---|
| 60% | 30% | 平地尚可,起伏地区空洞多 | 不推荐用于成图 |
| 70% | 50% | 大多数地形可用 | 应急快出图 |
| 80% | 60% | 稳定,空洞少 | 常规测绘(默认) |
| 85% | 70% | 最稳,但航时增加 30% 以上 | 建筑密集区、林地 |
航时估算按「有效扫描宽度」算,而不是按航带条数拍脑袋:有效扫描宽度 S = 覆盖宽 × (1 − 旁向重叠率),纯飞行时间 t ≈ 面积 / (速度 × S),再按每条航带 8 s 左右加转弯损耗。
坑 10:GSD 用长边像素算。覆盖宽度必须用「垂直航向的那个边」的像素数,用错边会导致航带间距系统性偏大或偏小,成图出现条带状空洞。
坑 11:把相对航高当绝对航高。H 是「相机到地面」的距离,山区要用地形跟随,否则山脊处 GSD 变小、山谷处 GSD 变大,同一景里 GSD 能差 2 倍。
坑 12:忽略相机倾斜。五镜头倾斜摄影里,倾斜相机的 GSD 与正射相机不同,航线设计要按主相机算,倾斜相机只影响建模纹理。
3.2.5 安全裕度与返航策略
航线再漂亮,安全策略没写清楚就是隐患。给一套能直接落地成状态机的规则:
| 策略 | 触发条件 | 动作 | 参数建议 |
|---|---|---|---|
| 低电量返航 | 剩余电量 ≤ 返航所需电量 × 1.5 | 中断任务,直线返航 | 返航电量按「距离/速度 + 10% 冗余」估 |
| 失联返航 | 遥控/图传中断 > 3 s | 原路返航或直线返航 | 两种都要预演,别临场选 |
| 限高保护 | 相对地面高度 > 120 m | 立即下降到 100 m | 以真高(AGL)为准,不用海拔 |
| 地形跟随 | 前方地面升高 > 15 m/100 m | 按 DEM 抬升,保持离地 80~120 m | 前置 200 m 预瞄 |
| 备降点 | 电量不足以返航 | 就近备降点降落 | 备降点需预先踏勘并记录坐标 |
| 避让民航 | 收到禁飞/限飞通告 | 立即撤出并降落 | 与空管报备流程联动 |
把返航当成一次普通的 A* 规划,只是终点换成 home 点、权重里加大能耗项;电量判断用「返航所需电量 × 1.5 ≤ 剩余电量」这条硬线,其中返航所需电量 = (巡航时间 + 爬升时间) × 单位时间耗电量。
坑 13:限高用海拔判断。120 m 是「真高」概念,山区按海拔判断会直接撞山,必须用地形跟随得到的 AGL。
坑 14:返航高度低于障碍物。返航直线穿越塔吊/高压线是事故高发点,返航路径也要过代价地图。
坑 15:电量模型用标称值。低温、大载重、逆风都会让能耗上升 20%~40%,返航阈值必须留够冗余,宁可提前收工。
3.3 视觉引导自主降落
3.3.1 四种方案对比:精度都是标称的,条件才是真的
降落是整个链路里对精度最敏感的环节:水平偏 1 m 就可能砸到护栏。四种主流方案的实际差异不在「标称精度」,而在「什么条件下还能保持精度」:
| 方案 | 水平精度 | 垂直精度 | 有效距离 | 依赖条件 | 典型失效场景 |
|---|---|---|---|---|---|
| RTK 直降 | 固定解 2~3 cm | 3~5 cm | 全程 | 基站/网络 RTK、天空开阔 | 多路径、遮挡、电磁干扰 → 浮动解 0.3~0.5 m |
| 平面标记(ArUco/AprilTag) | 1~3 cm @ 1~3 m | 0.5~1 cm | 0.3~8 m(取决于标记尺寸与焦距) | 标记可见、无反光 | 逆光、污损、大倾角、标记出画 |
| 深度学习降落板 | 3~10 cm | 5~10 cm | 1~15 m | 训练数据覆盖场景 + 边缘算力 | 域外光照、阴影、部分遮挡 |
| 多传感器融合 | 1~2 cm | 1~2 cm | 全程 | RTK + 视觉 + 测距 + IMU 时间同步 | 任一路时间戳错位就退化 |
工程上的正确做法不是四选一,而是分层降级:远距离靠 RTK 直降到 8 m 以下,5 m 内切视觉标记精调,1 m 内用测距仪与视觉联合判定触地。纯视觉从 50 m 直接降的,十次里总有一两次因为标记太小检不出来。
3.3.2 标定:内参决定下限,手眼决定上限
相机内参标定是整条链路的地基。流程与验收标准要写死:
# 依赖
python -m pip install "opencv-contrib-python>=4.8" numpy
# 采集要求(直接影响能不能达标)
# · 棋盘格 9x6 内角点,方格边长 25 mm(用卡尺实测,不要信打印标称)
# · 20~30 张,覆盖画面四角与中心,倾角 15~45 度
# · 光照均匀,避免反光与运动模糊(相机固定、板动)
# calib_intrinsic.py —— 棋盘格内参标定,输出 RMS 与内参
import glob
import cv2
import numpy as np
PATTERN = (9, 6) # 内角点数(列, 行),不是方格数
SQUARE_M = 0.025 # 方格实际边长(米)
objp = np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE_M
obj_points, img_points = [], []
for path in sorted(glob.glob("calib/*.jpg")):
img = cv2.imread(path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ok, corners = cv2.findChessboardCorners(gray, PATTERN, None)
if not ok:
print("跳过(未检出角点):", path)
continue
corners = cv2.cornerSubPix(
gray, corners, (11, 11), (-1, -1),
(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001))
obj_points.append(objp)
img_points.append(corners)
rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
obj_points, img_points, gray.shape[::-1], None, None)
print(f"重投影 RMS = {rms:.4f} px (验收 < 0.5 px,优秀 < 0.3 px)")
print("K =")
print(np.array2string(K, precision=3, suppress_small=True))
print("畸变系数 =", np.array2string(dist.ravel(), precision=4))
print(f"每张图的平均重投影误差: {[f'{np.mean(np.abs(cv2.projectPoints(objp, rvecs[i], tvecs[i], K, dist)[0] - img_points[i])):.3f}' for i in range(min(3, len(rvecs)))]}")
| 标定项 | 通过标准 | 超标的典型原因 |
|---|---|---|
| RMS 重投影误差 | < 0.5 px | 板面不平、角点检测偏移、图像模糊 |
| 焦距误差 | < 0.5% | 标定板倾角太单一(缺大倾角样本) |
| 主点误差 | < 2 px | 样本没有覆盖画面四角 |
| 畸变系数稳定性 | 两次标定差 < 10% | 镜头松动、对焦环没锁死 |
手眼解算直接用 cv2.calibrateHandEye(eye-in-hand 用 TSAI 或 PARK 均可),输入是 N 组「机体在基站系下的位姿」与「标定板到相机的外参」。验收两条:代回全部样本后重投影误差 < 0.5 px、平移残差 < 5 mm;不达标先加姿态样本,别急着换算法。
坑 16:棋盘格打印后直接量纸。打印机会缩放 1%~3%,必须用卡尺量「黑白格交界到交界」的实际边长,否则内参系统性偏差,降落到最后 1 m 时误差被放大。
坑 17:手眼标定只采一组姿态。运动学退化(所有姿态近似共面)时解不唯一,必须做绕轴旋转激励,采集时把无人机架在三轴云台上转一圈最省事。
坑 18:标定与使用不是同一分辨率。标定用 1920×1080,推理时缩放成 640×360,内参必须按比例缩放(c_x、c_y、f_x、f_y 同时乘缩放比),否则 PnP 直接算歪。
3.3.3 solvePnP:四个点为什么够,平面为什么会退化
透视 n 点定位(PnP)解决的问题是:已知若干点的三维坐标与对应像素坐标,求相机相对这些点的位姿。理论最少点数:
| 解法 | 最少点数 | 是否要求共面 | 适用 |
|---|---|---|---|
| P3P | 3 | 不要求 | 稀疏特征、快速初始化 |
| IPPE | 4 | 必须共面 | 平面标记(降落板最常用) |
| EPnP | 4(≥4 效果好) | 不要求 | 通用,精度中等 |
| 迭代法(LM) | 4 | 不要求 | 需要初值时最稳 |
| SQPNP | 3 | 不要求 | 快速、精度较高 |
平面标记的核心风险是平面位姿二义性:一个平面目标在图像上的投影,可能对应两组位姿(镜面对称解),在正对且距离较远时尤其明显。IPPE 会一次返回两组解,必须用「历史位姿连续性 + 重投影误差」选出真解:
# pnp_landing.py —— 平面标记位姿解算,含二义性消解
import cv2
import numpy as np
MARKER_M = 0.40 # 标记边长(米),实测值
def marker_object_points(size=MARKER_M):
s = size / 2.0
return np.array([[-s, -s, 0.0], [s, -s, 0.0], [s, s, 0.0], [-s, s, 0.0]],
dtype=np.float64)
def solve_pose(image_points, K, dist, prev_tvec=None):
objp = marker_object_points()
ok, rvecs, tvecs, errs = cv2.solvePnPGeneric(
objp, image_points, K, dist, flags=cv2.SOLVEPNP_IPPE)
if not ok:
return None
cands = []
for rvec, tvec, err in zip(rvecs, tvecs, errs):
rvec = rvec.reshape(3, 1)
tvec = tvec.reshape(3, 1)
proj, _ = cv2.projectPoints(objp, rvec, tvec, K, dist)
reproj = float(np.mean(np.linalg.norm(
proj.reshape(-1, 2) - image_points.reshape(-1, 2), axis=1)))
# 距离先验:标记必须落在相机前方 0.3~10 m
cands.append({"rvec": rvec, "tvec": tvec, "reproj": reproj,
"z": float(tvec[2])})
cands = [c for c in cands if 0.2 < c["z"] < 12.0]
if not cands:
return None
if prev_tvec is not None and len(cands) > 1:
# 用上一帧位置选最近的解,抑制正对时的翻转
for c in cands:
c["jump"] = float(np.linalg.norm(c["tvec"].ravel() - np.ravel(prev_tvec)))
cands.sort(key=lambda c: (c["jump"] > 0.5, c["reproj"]))
else:
cands.sort(key=lambda c: c["reproj"])
return cands[0]
精度怎么估?在深度 Z、焦距 f 下,1 像素的角度误差约等于 arctan(1/f),横向位置误差近似:
横向误差 ≈ Z / f (单位:米每像素)
例:Z = 3 m, f = 900 px => 1 px 约 3.3 mm
若重投影误差 1.5 px,则横向不确定度约 5 mm/轴
纵向(深度)误差 ≈ Z² / (f × 标记边长像素数),随距离平方增长
=> 结论:1 m 内用 1 像素的误差也只有毫米级,10 m 外误差到分米级
所以降落策略必然是「越近越准」:8 m 以上别指望视觉给厘米级,老老实实用 RTK;4 m 以内视觉才真正跑赢 RTK。
坑 19:标记边长填标称值。打印 40 cm 的板子实际可能是 39.6 cm,导致深度整体偏 1%,5 m 处就是 5 cm 的纵向误差,最后表现为「降落时总是偏一点点」。
坑 20:只用 4 个角点。标记四个角点受限于角点检测精度(亚像素约 0.1~0.3 px),更好的做法是用标记内部的多个编码点,或对每个角点做多帧滤波。
坑 21:忽略镜头畸变。广角镜头边缘畸变 5%~10%,PnP 会把畸变当成位姿误差,表现为「标记在画面中心时准,偏到边缘就歪」。
3.3.4 视觉伺服状态机:五段式,每段都要有超时
降落不是一个 PID 控制器能搞定的事,它是一条带安全约束的状态机。五个阶段的进入/退出条件、超时与保护动作如下:
| 阶段 | 进入条件 | 控制目标 | 退出条件 | 超时 | 保护动作 |
|---|---|---|---|---|---|
| SEARCH 搜索 | 到达降落区上方 8~15 m | 悬停,云台下视,缓慢旋转 | 连续 5 帧检出标记 | 20 s | 拉高到 15 m 并返航 |
| ALIGN 对准 | 已检出且内点稳定 | 水平误差 < 0.15 m,偏航对齐 | 连续 10 帧水平误差 < 0.15 m | 15 s | 回 SEARCH,降级到 RTK |
| DESCEND 下降 | 对准完成 | 垂直速度 0.3~0.8 m/s,闭环水平 | 离地 < 1.2 m | 30 s | 悬停,重检标记 |
| FINAL 精降 | 离地 < 1.2 m | 速度 0.15~0.3 m/s,视觉 + 测距 | 触地判定成立 | 20 s | 停止下降,原地降落 |
| TOUCHDOWN 触地 | 触地判定 | 切自稳,收油门,停桨 | 桨停确认 | 5 s | 手动接管 |
# landing_sm.py —— 五段式视觉伺服状态机骨架(可直接接 ROS2 / MAVSDK)
import time
class LandingSM:
def __init__(self, kp_xy=2.5, kp_z=1.2, max_vxy=0.8, max_vz=0.6):
self.state = "SEARCH"
self.t_enter = time.time()
self.kp_xy, self.kp_z = kp_xy, kp_z
self.max_vxy, self.max_vz = max_vxy, max_vz
self.lost = 0
def _timeout(self):
return {"SEARCH": 20.0, "ALIGN": 15.0, "DESCEND": 30.0,
"FINAL": 20.0, "TOUCHDOWN": 5.0}[self.state]
def step(self, det, alt_agl, t_now):
"""det: None 或 {"x":..,"y":..,"z":..}(机体系下标记位置,单位 m)"""
dt = t_now - self.t_enter
if dt > self._timeout():
return self._on_timeout()
if det is None:
self.lost += 1
if self.lost > 15 and self.state in ("ALIGN", "DESCEND", "FINAL"):
self.state = "SEARCH"
self.t_enter = t_now
return {"state": self.state, "vz": 0.0 if alt_agl < 6 else -0.2,
"vxy": (0.0, 0.0), "note": "标记丢失"}
self.lost = 0
err = (det["x"], det["y"])
vx = max(-self.max_vxy, min(self.max_vxy, self.kp_xy * err[0]))
vy = max(-self.max_vxy, min(self.max_vxy, self.kp_xy * err[1]))
if self.state == "SEARCH" and det is not None:
self._goto("ALIGN", t_now)
elif self.state == "ALIGN" and abs(err[0]) < 0.15 and abs(err[1]) < 0.15:
self._goto("DESCEND", t_now)
elif self.state == "DESCEND" and alt_agl < 1.2:
self._goto("FINAL", t_now)
elif self.state == "FINAL" and alt_agl < 0.15:
self._goto("TOUCHDOWN", t_now)
# 下降速度随高度收敛:近地更慢,避免触地冲击
vz = -min(self.max_vz, max(0.15, self.kp_z * alt_agl * 0.5))
if self.state in ("SEARCH", "ALIGN"):
vz = -0.2 if self.state == "ALIGN" else 0.0
return {"state": self.state, "vxy": (vx, vy), "vz": vz}
def _goto(self, new_state, t_now):
self.state = new_state
self.t_enter = t_now
def _on_timeout(self):
if self.state == "SEARCH":
return {"state": "ABORT", "vz": 0.0, "vxy": (0.0, 0.0), "note": "搜索超时返航"}
return {"state": "HOVER", "vz": 0.0, "vxy": (0.0, 0.0), "note": "超时悬停重检"}
PID 整定的经验顺序:先位置环 P → 再加 D 压振荡 → 最后少量 I 消稳态误差,不要一上来三个参数一起调。
| 步骤 | 参数起点 | 观察指标 | 调整规则 |
|---|---|---|---|
| 1 | kp_xy = 2.0 | 阶跃响应上升时间 | 震荡就减 30%,迟钝就加 30% |
| 2 | kd_xy = 0.15 | 超调量 | 超调 > 20% 时加 D,噪声大时加低通 |
| 3 | ki_xy = 0.05 | 稳态误差 | 有常值偏差(风)才加,务必限幅 |
| 4 | kp_z = 1.0 | 下降速度平滑度 | 近地振荡时降速并减小 kp_z |
控制输出必须限幅并做一阶低通:视觉检测在 15~30 Hz,飞控在 50~400 Hz,中间不平滑会出现「打摆子」。一阶低通 v_k = a·v_meas + (1-a)·v_{k-1},a 取 0.2~0.35 通常够用;对抖动更敏感的场景用 One Euro 滤波,能在静止时压噪、运动时保相位。
3.3.5 鲁棒性:失锁、抖动、风扰、光照
真实场景里,视觉降落的失败原因八成不是算错,而是「看不见了」:
视觉降落故障树
├─ 检测不到标记
│ ├─ 光照:逆光 / 强反光 / 夜间无补光 → 加偏振片、遮光罩、主动补光、曝光锁定
│ ├─ 尺度:标记 < 15 px → 放大标记 or 降低起始高度 or 换长焦
│ ├─ 角度:俯仰/滚转 > 30 度 → 标记透视压缩,改云台增稳
│ └─ 遮挡:树叶/电线/吊臂 → 多标记冗余 + RTK 兜底
├─ 检到了但位姿跳变
│ ├─ 平面二义性翻转 → 用上一帧做连续性约束
│ ├─ 角点抖动 → 亚像素 + 多帧卡尔曼平滑
│ └─ 时间错位 → 位姿按视频 PTS 插值,不用「最新姿态」
├─ 控制发散
│ ├─ 增益过高 → 降 kp、加 D、加低通
│ ├─ 风扰 → 前馈 + 积分限幅
│ └─ 传感器延迟 → 缩短链路、预测补偿
└─ 触地误判
├─ 测距仪噪声 → 中值滤波 + 连续 N 帧确认
├─ 地面杂物 → 融合 IMU 加速度突变
└─ 地效区气压跳变 → 近地 1 m 内不采信气压高度
失锁处理要分级,不能一丢就爬升:
| 失锁时长 | 动作 | 理由 |
|---|---|---|
| < 0.3 s | 保持当前速度继续下降 | 短暂遮挡(旋翼、云台抖动)常见 |
| 0.3~1.0 s | 悬停,原地等标记重现 | 避免盲降 |
| 1.0~3.0 s | 爬升到 3 m 重新搜索 | 低空盲区最危险 |
| > 3.0 s | 切 RTK 直降或返航 | 视觉已不可信 |
风扰的处理不是「加大增益」,而是加前馈:把风速估计(或 IMU 的比力变化)作为前馈项加到速度指令上,反馈只负责修正残差。积分项一定要限幅(如 ±0.3 m/s),否则风停之后会「窜一下」。
坑 22:用「最新姿态」而不是「视频帧采集时刻的姿态」。相机曝光到图像送入算法有 50~150 ms 延迟,这个延迟在 0.5 m/s 下降时就是 7.5 cm 的纵向位置差,表现为「越降越偏」。
坑 23:触地判定只看高度。气压计在地效区会跳变 0.5~1 m,正确做法是「测距仪连续 N 帧 < 阈值 + IMU 垂直加速度突变 + 视觉 Z 突然不变」三选二。
坑 24:不设视觉降落的「起降窗口」。夜间、雨天、6 级以上风必须直接禁用视觉,靠 RTK 或人工接管,这些条件要写进状态机的入口判断。
3.4 无人机视频 AR 投影
3.4.1 数学:把像素反投影成地面点
AR 投影要回答的问题是:画面上的第 (u, v) 个像素,对应地面上哪个点?针孔模型下就三步:
1) 像素 -> 相机射线(归一化像平面)
d_cam = K^-1 · [u, v, 1]^T K = [[fx,0,cx],[0,fy,cy],[0,0,1]]
2) 相机 -> ENU 方向
d_enu = R_enu_body · R_body_cam · d_cam
3) 射线与地面求交(地面高程 h_g 已知,或与椭球/瓦片求交)
t = (h_g - h_cam) / d_enu.z
P_enu = C_enu + t · d_enu (C_enu 为相机在 ENU 下的位置)
若 d_enu.z >= 0 (射线朝上或水平)则无交点,该像素不可投影
这就是所有 AR 投影的共同内核。剩下的是工程取舍:算哪些像素、用哪张地面、怎么把结果画出来。
3.4.2 两条实现路线:贴四边形,还是逐像素投影
| 路线 | 做法 | 精度 | 性能 | 实现量 | 适用 |
|---|---|---|---|---|---|
| A. 地面四边形贴图 | 取画面四角,反投影到地面得四个点,组成四边形贴视频纹理 | 平坦地面 1~2 m 内;起伏 > 5 m 时边缘偏差可达十几米 | 极高(一次绘制) | 30~50 行 JS | 大屏展示、宣传、平坦城区 |
| B. 按 DEM 网格投影 | 把可视区域切成网格(如 64×64),每个顶点按射线与 DEM 求交,顶点 UV 由投影矩阵反算 | 与 DEM 精度同级(米级) | 中(顶点数 × 求交) | 200~400 行 | 山区、建筑区、正射叠加校验 |
路线 A 的关键是记得它假设地面是一个平面。俯仰角越大、地形起伏越大,误差越离谱——因为透视四边形在平面上才能用仿射纹理映射,而真实地面是曲面,四边形贴上去必然拉伸。
Cesium 里路线 A 的最小写法(视频元素 + 视频材质 + 四个角点):
// ar_overlay.js —— 把 <video> 按四角点贴到地球上
const videoEl = document.getElementById("uav-video");
// 1) 反投影四个角:注意 Cesium 的像素坐标原点是画布左上角
function cornerOnGround(viewer, x, y) {
const ray = viewer.camera.getPickRay(new Cesium.Cartesian2(x, y));
if (!ray) return null;
return viewer.scene.globe.pick(ray, viewer.scene) || null;
}
const corners = [
cornerOnGround(viewer, 0, 0),
cornerOnGround(viewer, w, 0),
cornerOnGround(viewer, w, h),
cornerOnGround(viewer, 0, h),
];
if (corners.some((c) => !c)) {
console.warn("有角点射线未命中地球,检查相机姿态或降低俯仰角");
}
// 2) 用 VideoMaterialProperty 贴到矩形;矩形只适合「近似正射」的场景
viewer.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(lonMin, latMin, lonMax, latMax),
material: new Cesium.VideoMaterialProperty(videoEl, {
loop: true,
autoPlay: true,
color: Cesium.Color.WHITE.withAlpha(0.85),
}),
classificationType: Cesium.ClassificationType.TERRAIN,
},
});
注意上面的矩形写法本身仍是「轴对齐矩形」,只适合小范围且相机近似垂直向下。真正的透视四边形必须自己建网格(路线 B 的简化版):把四边形切成 N×N 个小三角形,每个顶点独立求交,UV 用双线性插值,这样透视关系才正确。纹理矩阵(TextureMatrix)的本质就是把这个从 UV 到纹理坐标的映射作为 uniform 传给着色器;具体 API 名称随 Cesium 版本变化,写代码前用当前版本文档核对,不要照抄旧博客。
本章的 Cesium 口径统一按 CesiumJS 1.147(2026-11 发布) 核对。这个版本除了常规修复,还补了依赖与内存问题——对「连续渲染几天不出错」的指挥大屏来说,锁到一个已验证的小版本比追最新更划算。VideoMaterialProperty、TextureMatrix、Cesium3DTileset 的构造参数与属性名在 1.x 里改过多次,升级前先把用到的 API 对一遍 1.147 文档,再动代码。
3.4.3 坐标纠偏:GCJ-02 只在展示层出现
国内底图(高德、腾讯、部分天地图切片)用的是 GCJ-02,而无人机 RTK 输出是 WGS84,两者在同一位置能差几百米。这不是「精度问题」,是坐标系不同,必须显式转换:
# gcj02.py —— WGS84 -> GCJ-02(公开的近似算法,误差约 1~2 m)
import math
A = 6378245.0
EE = 0.00669342162296594323
def _out_of_china(lng, lat):
return not (72.004 <= lng <= 137.8347 and 0.8293 <= lat <= 55.8271)
def _transform_lat(x, y):
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _transform_lng(x, y):
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def wgs84_to_gcj02(lng, lat):
if _out_of_china(lng, lat):
return lng, lat
dlat = _transform_lat(lng - 105.0, lat - 35.0)
dlng = _transform_lng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * math.pi
magic = 1 - EE * math.sin(radlat) ** 2
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * math.pi)
dlng = (dlng * 180.0) / (A / sqrtmagic * math.cos(radlat) * math.pi)
return lng + dlng, lat + dlat
if __name__ == "__main__":
print(wgs84_to_gcj02(116.404, 39.915)) # 北京,偏移量约 0.005 度量级
反向(GCJ-02 → WGS84)没有解析解,用迭代逼近,一般 3~5 次就收敛到 1e-9 度。工程原则只有一条:
| 层次 | 用哪个基准 | 原因 |
|---|---|---|
| 数据入库(POS、成果、控制点) | 一律 WGS84 / CGCS2000 | 保持测量真值,可回溯 |
| 服务接口 | 明确标注 CRS,默认 WGS84 | 调用方不需要猜 |
| 展示层(国内 Web 底图) | WGS84 → GCJ-02 现算 | 只影响显示,不影响数据 |
| 反向入库 | 禁止 | 偏移后的坐标不可逆、不可复现 |
坑 25:把 GCJ-02 转过的坐标写回数据库。二次转换会累积 1~2 m 误差,且失去可逆性;正确做法是只在前端渲染前转。
坑 26:以为「偏移是固定的」。GCJ-02 偏移量随位置非线性变化,国内不同城市方向与大小都不同,不能用一个常数平移。
坑 27:AR 投影用底图坐标算相机位置。相机位姿来自 WGS84,底图是 GCJ-02,两者混算的结果就是视频整体平移几百米还带旋转。
3.4.4 遮挡、拉伸与边缘:三个必须限制的参数
| 现象 | 成因 | 缓解办法 |
|---|---|---|
| 画面边缘被拉成「面条」 | 射线接近水平,与地面交角太小,t 发散 | 限制显示俯仰角(相机光轴与地面夹角 < 70 度才显示)、边缘 N 像素裁掉 |
| 山坡处纹理穿模 / 悬空 | 平面假设 + DEM 不准 | 切到路线 B,按 DEM 网格投影;或按瓦片分块贴 |
| 画面闪烁抖动 | 姿态与视频帧不同步、RTK 高程跳变 | 用视频 PTS 插值姿态、对位置做低通、固定贴图高度到 DEM |
| 建筑物被「抹平」 | 只投到地形表面,没有建筑体块 | 用 3D Tiles 建筑模型做遮挡测试,或在建筑高度处截断射线 |
限制俯仰角是性价比最高的一条:绝大多数「投影难看」的问题,都是相机俯视角度太小导致的数值发散,把显示条件卡死在 70 度以内,问题就少一大半。
3.4.5 3D Tiles 1.1 与 splat LOD:三维底座的两种格式
巡检项目的三维地球从来不是「一团模型」。它要同时装两类东西:一类是测绘成果——倾斜模型、点云、DEM、BIM/CAD,要求可量测、带属性、有层级;另一类是近景真实感——塔基、站房、设备、走廊,要求「看着像现场」。2026 年的工程答案是把它们拆成两种格式、两层底座,而不是硬塞进同一种数据里。
第一种:3D Tiles 1.1。 相对 1.0,工程上真正用得着的是两个变化:
- metadata(要素级元数据):构件的编号、材质、电压等级、上次巡检结论可以直接写在瓦片里,前端点选即可读出,不用再维护一套「属性服务 + ID 关联」。
- implicit tiling(隐式瓦片):用模板 URI 加 subtree 文件描述四叉/八叉层级,
tileset.json从几十 MB 降到几百 KB,加载器按需推导子瓦片。千万级瓦片才谈得上运维。 - 另外 1.1 允许 glTF 直接作为瓦片内容、一个瓦片挂多份内容;旧的 b3dm/i3dm/pnts 仍能读,但新数据建议直接按 glTF + 结构化元数据组织。
第二种:带层次 LOD 的 3D Gaussian Splats。 Cesium 在 2026-04 推出了「3D Tiles + 层次 LOD」的 splat 方案,CesiumJS 1.147 可以加载带 LOD 的 splat tileset。要点是:splat 不能当一个大 .ply 整体扔进场景,必须按空间切块、每块做多级细节(不同点数与分辨率),运行时按视距切。原因很物理——加载器每一帧都要对可见高斯排序,点数上去了 GPU 排序开销是硬成本,LOD 不是可选优化。
两种格式的分工:
| 维度 | 3D Tiles 1.1(测绘成果) | splat tileset(近景真实感) |
|---|---|---|
| 承载内容 | 倾斜模型、点云、BIM/CAD、地形 | 设备、塔基、站房、走廊近景 |
| 几何性质 | 显式网格/点,可量测、可剖切 | 隐式高斯分布,边界与角点不可靠 |
| 属性 | metadata 要素级属性,可查询 | 一般无属性,需外挂 ID 关联 |
| 精度口径 | 与测绘成果一致(cm~dm 级) | 与拍摄质量、位姿精度同级 |
| 单场景数据量 | 1~50 GB 常见 | 0.5~10 GB 常见 |
| 加载重点 | 瓦片调度、屏幕空间误差、缓存 | 点数预算、每帧排序、LOD 切换 |
| 典型用途 | 量测、属性绑定、淹没/净空分析 | 漫游展示、近景判读、宣传 |
加载骨架如下(属性名以 CesiumJS 1.147 文档为准;splat 的构造入口与发布格式强相关,换格式先查文档):
// base_dual.js —— 双底座:测绘成果走 3D Tiles 1.1,近景走带 LOD 的 splat
const viewer = new Cesium.Viewer("cesiumContainer", {
terrain: Cesium.Terrain.fromWorldTerrain(),
animation: false, // 指挥大屏常年挂机,关掉用不到的动画/时间轴
timeline: false,
});
async function loadDualBase(v) {
// 1) 测绘成果:SSE 决定瓦片调度密度,显存给足但别指望无限加
const survey = await Cesium.Cesium3DTileset.fromUrl(
"https://example.com/tiles/survey/tileset.json",
{
maximumScreenSpaceError: 16, // 默认 16;判读时可降 8,代价是带宽与显存
maximumMemoryUsage: 1024, // MB,默认 512
dynamicScreenSpaceError: true, // 高空俯瞰时放宽,减少无效瓦片
});
v.scene.primitives.add(survey);
// 2) 近景真实感:splat tileset 同样按瓦片调度,不要整包加载
const splat = await Cesium.Cesium3DTileset.fromUrl(
"https://example.com/tiles/splat/tileset.json",
{ maximumScreenSpaceError: 8, maximumMemoryUsage: 768 });
v.scene.primitives.add(splat);
return { survey, splat };
}
loadDualBase(viewer).then(({ survey, splat }) => {
// 3) 两块底座必须落在同一套 CRS/ECEF 上:先对齐再叠加
console.log(survey.boundingSphere, splat.boundingSphere);
});
性能问题基本都集中在这几处:
| 注意点 | 现象 | 处理 |
|---|---|---|
| SSE 设太小 | 首屏瓦片请求爆掉,显存打满后掉帧 | 大屏 16、判读 8,按显存反推上限 |
| 未做预加载 | 相机一动就白屏 | 按航线预取、开 preloadWhenHidden |
| splat 点数预算 | 排序开销随点数上涨,帧率断崖 | 单块点数控制在几十万级,视距分级 |
| 透明排序 | splat 与地形/模型穿插,前后关系错 | 深度分区、必要时降级半透明或裁剪盒 |
| 坐标系混用 | 模型整体偏移几十米 | splat 与 Tiles 先对齐到同一 ECEF,禁止屏幕空间凑 |
注意:① metadata 不是「随便塞属性」。属性名与类型要按结构化元数据 schema 定义并随 tileset 发布,前端才能按 key 查询;把自定义 JSON 直接塞进瓦片,加载器读不出来。② splat 的几何是高斯拟合,不能当测绘成果用——要量测、要角点、要净空分析,回 3D Tiles 的显式几何,splat 只负责「看起来对」。③ 位姿没做米制恢复就叠 splat,近景会整体偏大或偏小,贴到塔基上出现缝隙或穿插,第一反应常误判成「模型精度不够」。
3.4.6 视频贴地 + 模型属性联动(CesiumJS 1.147)
3.4.2 的路线 A/B 解决「视频贴在哪里」,这一节解决「贴上去之后能点开什么」。巡检指挥席上最常见的交互是:视频按真视角铺在塔基附近的地面上,用户点一下画面里的塔,右侧弹出这座塔的属性——电压等级、所属线路、上次巡检结论,再顺手调出对应机位的实时视频。这要求三维场景里的模型不只是「几何」,还要带「要素」与「属性」。
CesiumJS 1.147 补上的一块是 Cesium ion Element Service 支持:BIM/CAD 模型的属性查询与计算、BIM 风格的构件树视图。把它和 3.4.5 的 3D Tiles 1.1 metadata 放在一起看,边界很清楚——metadata 解决「属性存在瓦片里、离线可查」,Element Service 解决「属性怎么组织成可浏览的构件树、怎么做计算」。两条路怎么选:
| 方式 | 属性存在哪 | 优点 | 限制 | 适用 |
|---|---|---|---|---|
| 3D Tiles 1.1 metadata | 随瓦片发布(结构化元数据) | 离线可用、无外部依赖、查询快 | 属性模型偏平,构件树要自己建 | 私有化部署、内网/涉密 |
| Cesium ion Element Service | ion 侧托管的 BIM/CAD 属性 | 属性查询与计算开箱即用,构件树视图现成 | 依赖 ion 资产与网络,出内网要评估 | 互联网侧大屏、BIM 运维 |
联动代码的骨架只有三步:拾取要素、读属性、按业务 ID 查数据。
// link_video_model.js —— 点选模型构件 -> 读属性 -> 联动视频与工单面板
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((click) => {
const picked = viewer.scene.pick(click.position);
if (!Cesium.defined(picked)) return;
if (picked instanceof Cesium.Cesium3DTileFeature) {
// 1) 3D Tiles 1.1:要素属性直接从瓦片 metadata 读
const props = {};
picked.getPropertyIds().forEach((id) => { props[id] = picked.getProperty(id); });
// 2) 业务联动:用构件/设备编号去查视频通道、工单、台账
const deviceId = props.device_id || props.element_id;
console.log("构件属性:", props, "业务 ID:", deviceId);
// fetch(`/api/devices/${encodeURIComponent(deviceId)}/streams`)
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
三个实现约束:
- 拾取层级要定义清楚。视频贴地一般用分类(
ClassificationType.TERRAIN)挂在地形上,3D Tiles 模型在它上面;同一屏幕坐标可能同时命中视频 entity、地形和模型。业务上要先判模型、再判视频,并把「点视频点不到模型」当成可复现的 bug 修,而不是让用户换个角度点。 - 属性查询不要逐帧做。一次点击查一次是毫秒级;但「视频里框出 50 个目标同时查属性」要改用空间索引或 metadata 批量查询,否则每帧几十次拾取,帧率直接掉一半。
- 关联字段要版本化。
device_id、element_id这类关联键写进数据契约(见 3.6.1 的字段规范),模型侧与业务侧字段名对不上时,联调时间比开发还长。
注意:① Element Service 是 ion 侧能力,内网项目不能默认它可用;私有化场景把属性写进 3D Tiles 1.1 metadata,前端自己建树。② 视频贴地 entity 与模型的深度关系受
classificationType、depthTest影响,贴地不等于「盖在模型上」,要叠加先确认渲染顺序,别用透明度硬凑。③Cesium3DTileFeature的方法名与 metadata 读取方式在 1.x 里调整过,升级按 1.147 文档核对,别只改版本号。
3.5 视频轨迹与实时拼接
3.5.1 轨迹与拼接是两件事
新手最常混的一对概念。它们输入相似、输出完全不同:
| 维度 | 视频轨迹 | 实时拼接 |
|---|---|---|
| 要解决的问题 | 飞机飞过哪里,如何可视化 | 多帧影像如何拼成一张大图 |
| 输入 | POS(时间、经纬高、姿态) | 视频帧 + 位姿 + 相机内参 |
| 输出 | 3D 折线 / 轨迹点 | 正射影像 / 拼接图 |
| 精度要求 | 米级即可 | 厘米到分米级 |
| 实时性 | 高(秒级刷新) | 中(关键帧触发) |
| 主要坑 | 时间对齐、抽稀过度 | 累积漂移、接缝、鬼影 |
把这两件事塞进一个服务里,最后一定会因为「轨迹要秒级刷新」和「拼接要滑窗优化」抢资源而崩掉,拆成两个服务更省事。
3.5.2 轨迹:先对齐时间,再抽稀,最后才渲染
轨迹链路三步:POS 与视频对齐(3.1.3 已解决)→ 抽稀 → 三维渲染。抽稀必须在局部平面坐标里做,直接对经纬度做 Douglas-Peucker 会因为经线收敛而失真:
# track.py —— 投影到 ENU 后做 Douglas-Peucker 抽稀
import numpy as np
def perpendicular_distance(p, a, b):
if np.allclose(a, b):
return float(np.linalg.norm(p - a))
return float(np.abs(np.cross(b - a, p - a)) / np.linalg.norm(b - a))
def douglas_peucker(points, tol):
"""points: (N, k) numpy 数组,tol 为容差(米)"""
if len(points) < 3:
return points
keep = np.zeros(len(points), dtype=bool)
keep[0] = keep[-1] = True
stack = [(0, len(points) - 1)]
while stack:
i, j = stack.pop()
if j - i < 2:
continue
seg = points[i + 1:j]
d = np.array([perpendicular_distance(p, points[i], points[j]) for p in seg])
k = int(np.argmax(d))
if d[k] > tol:
idx = i + 1 + k
keep[idx] = True
stack += [(i, idx), (idx, j)]
return points[keep]
if __name__ == "__main__":
# 合成一段 10 Hz、带 RTK 抖动的圆形航迹
t = np.arange(0, 120, 0.1)
r = 80.0
xy = np.c_[r * np.cos(t / 20.0), r * np.sin(t / 20.0)]
xy += np.random.default_rng(1).normal(0, 0.03, xy.shape) # 3 cm 噪声
for tol in (0.1, 0.5, 1.0, 2.0):
keep = douglas_peucker(xy, tol)
print(f"tol={tol:4.2f} m 保留 {len(keep):4d} / {len(xy)} 点 ({len(keep) / len(xy):.1%})")
经验参数:航迹回放取 tol = 0.5 m(人眼在 1:1000 比例下看不出差别),飞行品质分析取 tol = 0.1 m(要保留抖动特征),只做统计报表取 2~5 m。抽稀前先做一次 5~9 点滑动平均,否则噪声点会被 DP 当成「特征点」全留下来。
渲染侧用 Cesium 的 polyline 即可,注意两点:一是 clampToGround: false(要贴地就配 heightReference),二是点数超过 1 万时改用 PolylineCollection 或按时间分段加载,否则首屏卡顿。
3.5.3 实时拼接:特征 → 单应 → 位姿图
实时拼接的骨架是四步,每一帧都在跑:
帧 k-1, k
│ ① ORB 特征提取(每帧 1500~2500 个)
▼
│ ② 特征匹配 + ratio test(0.75)+ RANSAC 求单应 H
▼
│ ③ 内点数 < 阈值 或 位移 > 阈值 → 标记为关键帧
▼
│ ④ 关键帧之间做滑窗位姿图优化(g2o / Ceres),非关键帧直接沿用累积 H
▼
│ ⑤ 用 POS 初值或地面控制点做地理配准,把像素坐标拉到经纬度
▼
拼接图(带地理参考)
工程实现上,关键帧判定是三个条件的「或」:内点数 < 30、帧间位移 > 0.3 倍视野、旋转 > 5 度。只有关键帧才更新累积单应与位姿图,非关键帧直接复用上一帧结果,这是把 CPU 占用压下来的关键。
「实时」与「精度」的取舍体现在三个旋钮上:
| 旋钮 | 偏实时(低延迟) | 偏精度(高精度) | 影响 |
|---|---|---|---|
| 关键帧间隔 | 位移 > 0.5 FOV | 位移 > 0.2 FOV | 关键帧越密,优化越准、算力越高 |
| 滑窗大小 | 5 帧 | 20~50 帧 | 窗口越大漂移越小,延迟越高 |
| 优化频率 | 每 30 帧一次 | 每个关键帧一次 | 优化频率直接决定 CPU 占用 |
| 分辨率 | 640×360 | 1920×1080 | 特征数相同时,高分辨率匹配更稳但更慢 |
实测参考:1080p 输入、关键帧间隔 0.3 FOV、滑窗 10 帧,在 8 核 CPU 上能跑 8~12 fps 的拼接刷新,端到端延迟 1.5~2.5 s;这个延迟对「实时看」够用,对「实时控制」不够。
3.5.4 离线高精度:ffmpeg 抽帧 → COLMAP → ODM → GDAL
要出可交付的正射影像,别在实时链路上加码,直接走离线 SfM 流水线:
# 1) 抽帧:视频 25 fps,抽成 1 fps(重叠率仍够),保留 EXIF/时间戳
mkdir -p frames
ffmpeg -i flight.mp4 -vf "fps=1" -q:v 2 frames/%05d.jpg
# 2) 若视频没有地理标签,用 POS 生成 COLMAP 的 priors(简化做法)
python tools/pos_to_colmap.py --pos pos.csv --frames frames --out colmap_priors.txt
# 3) 空三
colmap feature_extractor --database_path colmap.db --image_path frames \
--ImageReader.single_camera 1 --ImageReader.camera_model OPENCV
colmap exhaustive_matcher --database_path colmap.db
mkdir -p sparse
colmap mapper --database_path colmap.db --image_path frames --output_path sparse
colmap model_analyzer --path sparse/0
# 4) 稠密重建 + 正射(OpenDroneMap 一条命令;也可 OpenMVS 手动跑)
docker run -ti --rm -v "$PWD/dataset:/datasets/project" opendronemap/odm \
--project-path /datasets project --dsm --dtm --orthophoto-resolution 2 --pc-quality high
# 5) 转 COG 并核验
gdal_translate -of COG -co COMPRESS=DEFLATE -co BLOCKSIZE=512 \
odm_orthophoto/odm_orthophoto.tif ortho_cog.tif
gdalinfo -json ortho_cog.tif | head -40
抽帧率的经验公式:帧间位移应小于单张覆盖宽度的 30%,即 fps >= v / (0.3 × W)。10 m/s、覆盖 150 m 时约 0.22 fps,取 1 fps 已经很冗余;反过来,视频 25 fps 全抽会得到 25 倍冗余影像,空三时间爆炸而精度几乎不涨。
3.5.5 质量评估:接缝、鬼影、几何精度
| 指标 | 怎么测 | 合格标准 | 不合格说明 |
|---|---|---|---|
| 平面 RMSE | ≥ 10 个检查点(与控制点不共用) | ≤ 1 × GSD(RTK 辅助时 2~5 cm) | 空三几何差或控制点分布不均 |
| 高程 RMSE | 同上,另加 5 个高程点 | ≤ 2 × GSD | 高程基准未统一、控制点不足 |
| 接缝可见度 | 目视 + 局部梯度统计 | 拼接缝处梯度突变 < 20% | 曝光不一致、色差未匀色 |
| 鬼影 | 运动物体(车、人、树影)区域检查 | 无重复/半透明重影 | 抽帧间隔太长、运动物体未剔除 |
| 空洞率 | 正射图无效像元占比 | < 0.5% | 重叠不足、水面/玻璃导致匹配失败 |
检查点布置比数量更重要:均匀覆盖测区四角与中心、避开建筑阴影与水面、与控制点至少相距 50 m。全部堆在测区一角,RMSE 数字再漂亮也说明不了问题。
坑 28:用控制点算 RMSE。控制点参与了空三解算,误差被「吸收」了,算出来必然偏小;必须留出独立的检查点。
坑 29:正射分辨率拍脑袋定。
--orthophoto-resolution 2表示 2 cm/px,比 GSD 还小并不会变清楚,只会让文件变大、处理变慢。坑 30:忽略匀色。多架次拼接时不同时间的光照差异会形成明显色块,必须先做色差校正(ODM 的
--texturing-skip-global-seam-leveling反过来说就是别关它)。
3.5.6 2026 路线:从「帧间单应拼接」升级到「位姿 + 双产出」
3.5.3 那套「ORB 特征 → 单应 → 滑窗位姿图」是 2020 年前后的主流做法:延迟低、实现短,但误差沿链路累积,只适合做「看起来连续」的预览。它的问题不在算法,而在目标定错了——把预览产物当交付产物用,接缝与漂移永远解释不清。2026 年的工程路线把它拆成两条通道:
视频 + POS
│
├── 实时通道(预览):ORB + 单应/稀疏位姿 + POS 约束
│ → 拼接预览,端到端 1.5~2.5 s,米级,只看覆盖到哪、有无漏拍
│
└── 离线通道(交付):抽帧 → 位姿(COLMAP SfM 或 VGGT 前馈)
├── 3DGS 训练(gsplat / brush)→ 带 LOD 的 splat tileset
└── 稠密重建 + 正射(OpenDroneMap)→ DOM / DSM → COG
两条通道的公共中间层是「位姿 + 帧索引」,交接物用 COLMAP 文本格式最省事:实时侧不依赖它,离线侧所有工具都吃它,第06章已经把格式转换与踩坑走过一遍。把这一层定死之后,实时与离线不再互相绑架——预览卡了不影响交付,交付慢也不影响现场判断。
位姿来源有三条路,选型按这张表:
| 维度 | POS 直接 | COLMAP(SfM) | VGGT(前馈) |
|---|---|---|---|
| 精度 | 取决于 RTK,平面 2~5 cm | 重投影 0.3~0.8 px,尺度由控制点定 | 相对精度好,绝对尺度要恢复 |
| 耗时(约 1000 帧) | 秒级 | 数十分钟到数小时 | 分钟级(按块推理) |
| 算力 | 无额外要求 | CPU/内存为主 | 显存为主,帧数上去就爆 |
| 主要风险 | POS 时间戳错位 | 弱纹理区域注册失败 | 长序列漂移、尺度不确定 |
| 适用 | 已同步 POS 的正射快出 | 交付级正射/建模 | 快速出预览、SfM 失败兜底 |
VGGT 的 2026 口径要写清楚:官方在 2026-05-15 修复了显存问题,同等显存可跑约 2~3 倍帧数;2026-05-18 发布了后继版本 VGGT-Omega。它对「先出个结果看看」特别有用,但三条限制没变:输出是相对尺度、长序列会漂移、没有全局 BA。商用项目还要注意授权——2025-07-29 起官方提供需申请的商用许可 checkpoint(VGGT-1B-Commercial),学术权重不能直接用于商用交付,条款以官方仓库为准。
离线双产出的流水线可以一条命令串起来(抽帧率、参数都以各工具当前 --help 为准):
# 一次抽帧,两条产物:正射/DSM 走 ODM,splat 走 3DGS
mkdir -p frames work dataset/images
ffmpeg -i flight.mp4 -vf "fps=2" -q:v 2 frames/%05d.jpg
# ① 位姿:优先 COLMAP;注册率 < 80% 或时间不够时切 VGGT 前馈
colmap feature_extractor --database_path work/colmap.db --image_path frames \
--ImageReader.single_camera 1 --ImageReader.camera_model OPENCV
colmap exhaustive_matcher --database_path work/colmap.db
colmap mapper --database_path work/colmap.db --image_path frames --output_path work/sparse
# ② 交付产物:正射 + DSM(ODM 要求 project/images 下有影像)
cp frames/*.jpg dataset/images/
docker run -ti --rm -v "$PWD/dataset:/datasets/project" opendronemap/odm \
--project-path /datasets project --dsm --dtm --orthophoto-resolution 2 --pc-quality high
# ③ 展示产物:同一套 COLMAP 位姿喂给 3DGS 训练器(brush 示例)
brush work/dataset --total-train-iters 30000 --export-path work/splat/
两条通道的验收口径必须分开写,否则一定有人拿预览的延迟去比交付的精度:
| 指标 | 实时预览通道 | 离线交付通道 |
|---|---|---|
| 时效 | 端到端 1.5~2.5 s | 小时级(可夜间批跑) |
| 平面精度 | 米级(相对拼接) | 检查点 RMSE ≤ 1 × GSD |
| 高程精度 | 不承诺 | 检查点 RMSE ≤ 2 × GSD |
| 产物 | 拼接预览图、覆盖范围 | DOM / DSM / splat / 点云 |
| 失败处理 | 掉帧降分辨率,继续出图 | 注册失败即报警,不静默出图 |
注意:① 拿单应拼接的预览图当交付正射。接缝处的漂移在预览里看不出来,测图时才发现整景偏移,返工成本远高于直接走离线通道。② VGGT 输出直接当米制用。它的尺度来自训练分布,不做恢复就叠到 3D Tiles 或正射上,几何整体缩放,检查点一测就露馅。③ 双产出各训一遍位姿。正射与 splat 必须共用同一套位姿与同一份米制恢复参数,否则两个产品在同一场景里对不上,前端叠加时看起来像「模型漂移」。
3.6 系统集成:一条最小闭环
3.6.1 组件划分与接口契约
┌──────────────┐ RTMP/WHIP ┌──────────────┐ gRPC ┌──────────────┐
│ 设备接入服务 │ ────────────▶ │ 时空基准服务 │ ─────────▶ │ 视觉服务 │
│ 无人机/机场 │ MQTT POS │ 坐标+时间转换 │ 位姿+帧 │ 检测/位姿解算 │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ MQTT telemetry │ 统一时空对象 │ 结构化结果
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 消息总线(MQTT 测控 / Kafka 事件)→ 三维可视化(Cesium)→ 成果库 │
└──────────────────────────────────────────────────────────────────────┘
| 服务 | 输入 | 输出 | 协议/频率 | 关键约束 |
|---|---|---|---|---|
| 设备接入 | 视频流、遥测、POS | 标准化帧 + 遥测 | RTMP/WHIP、MQTT 1~10 Hz | 必须带设备原始时间戳 |
| 时空基准 | WGS84 坐标、原始时间 | ENU/CGCS2000、UTC | gRPC,同步调用 | 无状态,可水平扩展 |
| 视觉服务 | 帧 + 相机内参 + 位姿 | 检测框、PnP 位姿 | gRPC 流式,10~30 Hz | 输出必须带帧 PTS |
| 三维可视化 | 统一时空对象 | 渲染 | WebSocket | 前端不做坐标换算 |
| 成果库 | 轨迹、正射、告警 | 检索/下载 | REST | 存原始基准,不存纠偏后坐标 |
接口设计的一条硬规矩:每个跨服务消息都带 ts_utc_ms 与 crs 两个字段。没有时间戳的位姿、没有 CRS 的坐标,一律当脏数据处理。
3.6.2 时间同步:NTP 够用,PTP 才够稳
| 方案 | 典型精度 | 部署成本 | 适用 |
|---|---|---|---|
| NTP(chrony) | 局域网 1~5 ms | 低 | 遥测、业务系统 |
| PTP(IEEE 1588) | 亚微秒~百微秒 | 中(需支持硬件时间戳的网卡/交换机) | 多相机同步、激光雷达 |
| GPS 授时(PPS + 串口) | < 1 μs | 中(需天线与开阔天空) | 机场、固定基站 |
| 视频 PTS 对齐 | 帧间隔量级(40 ms@25fps) | 低 | 事后分析、离线拼接 |
偏差检测不要只看「时钟同步了没有」,要看「同一条数据在两个通道里的时间差」的统计量:offset 取中位数抗异常值,MAD 判断抖动是否稳定。两者都稳定才说明固定偏移可补偿;MAD 随时间漂移说明时钟在跑偏,先解决同步再谈融合。
3.6.3 消息总线:MQTT 管测控,Kafka 管事件
| 维度 | MQTT | Kafka |
|---|---|---|
| 延迟 | 毫秒级 | 毫秒~十毫秒级 |
| 吞吐 | 万级 msg/s | 十万~百万级 msg/s |
| 消息保留 | 无(可配 retained) | 按时间/大小持久化 |
| 顺序保证 | 单连接有序 | 分区内有序 |
| 断线重连补发 | QoS 1/2 + 会话 | offset 续读 |
| 适用 | 遥测、指令、状态 | 事件流、视频元数据、审计 |
数据契约用 JSON Schema 固定下来,前后端都按它校验:
{
"schema_version": "1.2",
"type": "object",
"required": ["device_id", "ts_utc_ms", "crs", "seq", "pose"],
"properties": {
"device_id": {"type": "string"},
"ts_utc_ms": {"type": "integer", "minimum": 1600000000000},
"crs": {"type": "string", "enum": ["WGS84", "CGCS2000"]},
"seq": {"type": "integer", "minimum": 0},
"pose": {
"type": "object",
"required": ["lon", "lat", "alt_ellipsoid"],
"properties": {
"lon": {"type": "number", "minimum": -180, "maximum": 180},
"lat": {"type": "number", "minimum": -90, "maximum": 90},
"alt_ellipsoid": {"type": "number"},
"alt_agl": {"type": "number"},
"yaw_deg": {"type": "number"}
}
}
}
}
3.6.4 联调顺序:先回放,再半实物,最后实飞
| 阶段 | 输入 | 验证什么 | 通过标准 |
|---|---|---|---|
| 1. 离线回放 | 录制好的 POS + 视频 + DEM | 坐标链路、时间对齐、AR 投影 | 轨迹与视频重合,投影偏差 < 2 m |
| 2. 半实物(HIL) | PX4 SITL + 仿真视频 + 真实算法 | 状态机、控制增益、超时逻辑 | 20 次降落全部进入 TOUCHDOWN |
| 3. 系留/低空 | 真实飞机、限高 10 m | 真实风扰、图传延迟、电池 | 10 次降落偏差 < 15 cm |
| 4. 实飞 | 完整任务 | 全流程与应急处置 | 无失控、无越界、数据完整 |
坑 31:跳过回放直接实飞。90% 的坐标和时间错误在离线回放阶段就能暴露,且不花钱。
坑 32:总线里塞视频原始帧。视频走流媒体通道(RTMP/WebRTC),总线只传元数据,否则单条消息几百 KB,MQTT broker 直接被打挂。
3.7 用 DeepSeek Harness 做联调与仿真
3.7.1 造数据:让 DSH 生成回放素材
真实的 POS、视频、DEM 不好凑,先用 DSH 造一套可复现的。把任务书写成「可验收」的形式:
# 在项目根目录开一个 headless 会话,任务书要带验收标准
dsh --profile headless "在 tools/ 下生成两个脚本:
1) gen_fake_pos.py:输出 10 Hz 的 POS CSV,字段 time_utc,lon,lat,alt_ellipsoid,roll,pitch,yaw;
生成 120 s 的圆形航迹(半径 80 m),叠加 3 cm 高斯噪声与 0.8 s 时间偏移;
2) gen_fake_dem.py:用 numpy 生成 512x512 的 fBm 地形,高程 0~120 m,
用 rasterio 写成 EPSG:4547 的 GeoTIFF,像元 1 m。
验收:两个脚本 python -m py_compile 通过;生成文件能被 pandas/rasterio 读回;
航迹闭合误差 < 0.5 m;DEM 的 min/max 与打印值一致。"
自己写也就二十行:采样率、航迹半径、噪声水平是仅有的三个参数。把它固化成脚本而不是一次性代码,后面每次改算法都能重跑同一套数据。
3.7.2 写测试:坐标转换要用「已知点对」锁死
坐标转换的测试不能只测「跑通」,要测「数值对不对」。四类用例覆盖足够:
| 用例类型 | 做法 | 容差 |
|---|---|---|
| 往返一致性 | A→B→A 与原值比 | 经度 < 1e-8 度,高程 < 1 mm |
| 已知点对 | 取官方 EPSG 示例点或实测控制点 | 平面 < 1 cm,高程 < 3 cm |
| 边界条件 | 极区、换带边界、负高程、跨 180 度 | 不抛异常、结果单调 |
| 退化输入 | 零向量、NaN、共线点 | 显式返回错误而非静默出错 |
让 DSH 跑回归并把失败用例整理成最小复现:
dsh --profile headless "运行 pytest tests/test_crs.py -q 和 tests/test_time_align.py;
如果有失败,只输出:失败用例名、实际值 vs 期望值、最小复现代码片段(不超过 20 行);
不要改任何源码,只给诊断。" --json > test_report.json
3.7.3 日志分析:先机器筛窗口,再让人/模型判根因
飞控日志动辄几十万行,直接丢给模型是浪费。先用阈值把「可疑时段」筛成几十行的窗口(视觉置信度、位姿跳变、IMU 方差),再把窗口交给 DSH 判根因,命中率高得多。
dsh --profile headless "vision_log 里 confidence 低于 0.5 且连续超过 1 s 的窗口有 3 段(附 CSV)。
同期飞控日志的 IMU 加速度方差上升 4 倍。判断最可能原因,给出:
1) 排除顺序;2) 现场复现步骤;3) 代码里应加的保护逻辑。不要改文件。"
3.7.4 沉淀成技能:一次调通,次次复用
把「坐标链路验收」这类流程写进 $DSH_HOME/skills/<name>/SKILL.md,下次直接加载,不用重新描述:
---
name: crs-check
description: 校验一条空地数据链的坐标与时间基准,输出偏差表
---
1. 读取 pos.csv 与 video_meta.json,检查字段与 CRS 声明
2. 跑 tests/test_crs.py、tests/test_time_align.py
3. 对 3 个实测控制点做往返比对,输出平面/高程偏差
4. 时间残差 > 20 ms 或坐标偏差 > 2 cm 时,给出排查顺序(不改代码)
放进 CI 就是一条命令的事:
# 只读模式跑审查任务,产出机器可读事件流
dsh --profile headless "按 skills/crs-check 的步骤跑一遍,输出偏差表 markdown" \
--permission-mode read-only --json > crs_report.json
坑 33:让 DSH 直接改生产代码。先让它出诊断和补丁建议,人审之后再落地;权限预设用 read-only 或 workspace-write 分级。
坑 34:造数据不带随机种子。每次生成的 POS 不一致,测试就不可复现,回归等于没做。
3.8 本章小结
- 空地一体的本质是统一基准:坐标、时间、度量三件事没做对,后面所有融合都是在还债。
- 判断一条链路是否可信,先看四个数:坐标往返残差、时间残差 RMS、漂移率、帧—位姿配对率。
- 高程基准是最高频的隐形事故:椭球高与 1985 国家高程基准相差 10~40 m,必须用格网模型换算。
- 地图表示先算内存:1 km² 用 0.5~1 m 的 2.5D 栅格做全局,0.1~0.2 m 局部栅格做重规划,3D 占用网格只在必须穿障时上。
- 硬约束与软代价必须分开:禁飞区、限高、膨胀半径进 blocked,坡度与视觉质量进 cost,A* 只判断有限性。
- 航高、GSD、重叠率三者互锁:先定成图比例尺,再定 GSD、航高、航带间距与航时。
- 视觉降落必须分层降级:8 m 以上靠 RTK,5 m 内切平面标记,1 m 内融合测距与触地判定。
- 平面标记的位姿存在二义性,必须用上一帧连续性约束选解;边长实测误差会直接放大成深度误差。
- AR 投影的精度上限由地面模型决定:平地可用透视四边形贴图,起伏地形必须按 DEM 网格求交。
- 轨迹与拼接拆成两个服务:轨迹秒级刷新,拼接滑窗优化,混在一起必然抢资源。
- 验收要有独立检查点:平面 RMSE ≤ 1 × GSD、高程 RMSE ≤ 2 × GSD,检查点与控制点不能共用。
- 联调顺序是离线回放 → 半实物 → 系留 → 实飞,九成的坐标与时间错误在回放阶段就能暴露。
- 底图精度按来源分级承诺:公共实景三维成果给的是 2~5 m 格网与年度增量,1:500 只能自采 + 控制点 + 独立检查点,方案里写检查点 RMSE 而不是标称精度。
- 三维底座分两层:3D Tiles 1.1 承载可量测的测绘成果与 metadata 属性,带 LOD 的 splat tileset 承载近景真实感,两者必须在同一 CRS/ECEF 上叠加。
- 拼接路线从帧间单应升级为「前馈/SfM 位姿 + 双产出」:实时通道只出预览,交付级正射与 splat 走离线通道,中间用 COLMAP 格式解耦。
3.9 动手练习
- 练习 1(坐标链路自检):输入 3 个 RTK 实测点(WGS84)与对应的 CGCS2000 高斯坐标;用 pyproj 做双向转换、numpy 手写 ENU 正反算,并补 pytest 用例。验收:往返平面残差 < 1 mm、高程 < 2 mm,已知点对偏差平面 < 2 cm、高程 < 5 cm。
- 练习 2(GSD 反算航线):输入 1 英寸 20 MP 相机(像元 2.4 μm、8.8 mm 定焦)与 2 km × 3 km 任务区;给出 1:1000 成图对应的 GSD、航高、航带间距、航带数与预估航时。验收:GSD ≤ 10 cm、旁向重叠 60%,航时估计与实飞记录偏差 < 15%。
- 练习 3(标记降落闭环):输入 40 cm 标记板、1080p 下视相机与 PX4 SITL;实现 3.3.4 的五段状态机,含超时与失锁分级。验收:仿真中连续 20 次降落,水平偏差 < 15 cm,触地速度不超过 0.3 m/s。
- 练习 4(最小 AR 投影):输入一段带 POS 的 30 s 视频与 1 m DEM;按 DEM 网格做反投影,输出每帧地面覆盖多边形与偏差统计。验收:平坦区偏差 < 2 m、起伏区 < 1 × GSD,俯仰角超 70 度时正确拒绝显示。
3.10 延伸阅读
- PROJ 官方文档
- pyproj 文档
- CesiumJS 版本发布记录(1.147)
- OpenCV calib3d 模块文档
- COLMAP 官方仓库
- OpenDroneMap 文档
- GDAL 官方文档
- 3D Tiles 规范与工具(CesiumGS/3d-tiles)