从镜头到航线:Cesium 与 Turf.js 的无人机面状航线自动生成算法
引言:航线规划为什么值得自动化
无人机测绘的典型作业是面状覆盖:给一块不规则测区,飞出一组平行扫描线,沿线按固定间隔拍照,回来空三、拼正射影像。任务书里写的是工程语言——“飞行高度 100 米,GSD 优于 2 cm,航向重叠 80%,旁向重叠 65%“。而飞控里需要的是另一套语言:一串航点坐标、每个航点的动作与朝向。
两套语言之间的翻译,就是本篇要讲的算法:由镜头参数与重叠率算出拍摄间距与扫描线间距,再用扫描线算法把多边形测区展开成蛇形航线。手工拉点不仅慢,而且重叠率一旦调整就要全部重拉——量级一上来,人工规划就成了瓶颈。把这条链路写成代码,参数一动、航线即变,才是专业测绘该有的工作流。
本文延续上一篇的泰州项目背景:底图与影像没问题之后,轮到”怎么飞”了。算法用 Turf.js 做地理计算,用 Cesium 做三维呈现,全部代码可直接复用。
一、算法推导:从相机参数到航线间距
1.1 GSD:一个相似三角形讲完
GSD(Ground Sampling Distance,地面分辨率)是”一个像素落在地上多大”。针孔相机模型里,传感器上的像元与地面上的采样格通过光心构成相似三角形:
GSD = H × p ÷ f_eq
其中 H 是相对航高,p 是像元尺寸,f_eq 是等效焦距(真实焦距 × 变焦倍数)。以 M4TD 广角镜头为例,参数与 100 米航高的数值如下:
const m4tdCameraInfo = {
wide: {
frameWidth: 9.691, // 传感器宽度(mm)
frameHeight: 7.278, // 传感器高度(mm)
focalLength: 6.78, // 真实焦距(mm)
frameSize: 1.197, // 像元大小(μm)
dpiW: 8064, // 水平分辨率(px)
dpiH: 6048, // 垂直分辨率(px)
},
}
| 参数 | 值 | 说明 |
|---|---|---|
| GSD | 1.77 cm | 100 m × 1.197 μm ÷ 6.78 mm |
| 地面幅面 | 142 × 107 m | GSD × 8064 × 6048,恰为 H × 传感器宽高 ÷ 焦距 |
| 拍摄间隔 | 21.4 m | 幅面高 × (1 − 80%) |
| 扫描线间距 | 49.8 m | 幅面宽 × (1 − 65%) |
1.2 载荷计算函数
工程实现里把这段推导收成一个函数(保留了倾斜摄影分支——俯仰角偏离铅垂时,斜距拉长与投影压缩各贡献一个 cos 因子,GSD 放大 1/cos²θ):
/**
* @param height 飞行高度(m)
* @param overlapH 航向重叠率(0~1)→ 沿航线拍摄间隔
* @param overlapW 旁向重叠率(0~1)→ 扫描线间距
* @param zoom 变焦倍数(等效焦距 = focalLength × zoom)
* @param pitchAngle 拍摄俯仰角(-90 正下;倾斜时 GSD 按 1/cos² 放大)
*/
function getDronePayloadInfoExt(height, overlapH, overlapW, zoom = 1, pitchAngle = -90) {
const { frameSize, dpiW, dpiH, focalLength } = m4tdCameraInfo['wide'];
const pitchRadians = (pitchAngle + 90) * Math.PI / 180;
const angleFactor = Math.abs(Math.cos(pitchRadians));
// 工程式:μm/mm 混合单位,/1000 归一为米
const gsd = height * frameSize / zoom / focalLength / 1000 / Math.pow(angleFactor, 2);
const photoH = gsd * dpiH; // 沿航线方向地面覆盖
const photoW = gsd * dpiW; // 垂直航线方向地面覆盖
return {
spacingH: photoH * (1 - overlapH), // 拍摄间隔(航向重叠决定)
spacingW: photoW * (1 - overlapW), // 扫描线间距(旁向重叠决定)
gsd, photoH, photoW,
};
}
两个容易忽略的推论:变焦与航高在数学上可互换(500 m + 5 倍变焦与 100 m + 1 倍变焦的 GSD 完全相同——但前者的幅面间距等比放大,测区需要的扫描线数量不变,视角却窄得多);重叠率同时吃掉两个方向的间距,旁向重叠从 65% 提到 80%,扫描线数量直接翻倍,作业时长与照片量随之暴涨——调参时心里要有这笔账。
二、面状航线算法:Turf.js 扫描线与交点求算
2.1 总体思路
拿到间距之后,问题变成纯几何题:用一组等距平行线覆盖任意多边形。直接对任意角度的平行线求交并不好写——旋转一下就简单了:
- 把多边形绕中心旋转,让扫描推进方向对齐”自上而下”;
- 在旋转后的外接矩形里,自上而下铺水平扫描线(间距即
spacingW); - 每条扫描线与多边形边界求交,交点成对构成一条”拍摄航段”;
- 航段按蛇形排序(上一段的末点靠近下一段的哪端,就从哪端起步);
- 全部旋转回原始坐标系。
2.2 完整实现
const DEG_TO_RAD = Math.PI / 180;
const M_PER_DEG_LAT = 111320; // 纬度方向每度约 111.32 km
// 绕中心点旋转(弧度)
function rotatePoint([px, py], [cx, cy], rad) {
const dx = px - cx, dy = py - cy;
const cos = Math.cos(rad), sin = Math.sin(rad);
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
}
// 获取多边形的所有边(首尾自动闭合)
function getPolygonEdges(polygon) {
const coords = turf.polygonToLine(polygon).geometry.coordinates;
const len = coords.length;
return coords.map((c, i) => turf.lineString([c, coords[(i + 1) % len]]));
}
// 坐标去重
function dedupCoords(coords) {
const seen = new Set();
return coords.filter((c) => {
const key = `${c[0]},${c[1]}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/**
* 计算面状航线
* @param polygonArr 顶点 [lng, lat] 数组
* @param spacingM 扫描线间距(米)
* @param direction 扫描推进方向(0 = 自北向南,顺时针;扫描线与其垂直)
* @param depth 递归深度(无航点时间距 ÷1.5 重试,上限 5 层)
*/
function calculatePolygonalRoute(polygonArr, spacingM, direction = 0, depth = 0) {
if (polygonArr.length < 3) return { waypoints: [], legs: [] };
const spacingDeg = spacingM / M_PER_DEG_LAT; // 关键换算:扫描线 y 是纬度(度)
const polygon = turf.polygon([[...polygonArr, polygonArr[0]]]);
const center = turf.centroid(polygon).geometry.coordinates;
const rad = direction * DEG_TO_RAD;
// ① 绕中心旋转至推进方向对齐
const rotated = polygonArr.map((p) => rotatePoint(p, center, rad));
const rotatedPolygon = turf.polygon([[...rotated, rotated[0]]]);
// ② 外接矩形内自上而下铺扫描线
const [minX, minY, maxX, maxY] = turf.bbox(rotatedPolygon);
const scanHeightM = turf.distance([minX, minY], [minX, maxY], { units: 'meters' });
const scanCount = Math.max(1, Math.ceil(scanHeightM / spacingM));
const edges = getPolygonEdges(rotatedPolygon);
// ③ 逐条扫描线与边界求交,成对生成拍摄航段
const legs = [];
const pad = 0.0005; // 扫描线左右外扩,避免与 bbox 端点相切漏交
for (let i = 0; i < scanCount; i++) {
const y = maxY - spacingDeg * (i + 0.5);
const scanLine = turf.lineString([[minX - pad, y], [maxX + pad, y]]);
const hits = [];
for (const edge of edges) {
const pts = turf.lineIntersect(scanLine, edge);
if (pts.features.length === 1) hits.push(pts.features[0].geometry.coordinates);
}
if (hits.length < 2) continue;
hits.sort((a, b) => a[0] - b[0]); // 按经度排序(凹多边形多交点时保证进/出有序)
for (let j = hits.length - 1; j > 0; j--) {
// 剔除相邻过近交点(顶点恰好落在扫描线上时产生重合解)
if (turf.distance(hits[j - 1], hits[j], { units: 'meters' }) < 0.3) hits.splice(j, 1);
}
if (hits.length < 2) continue;
for (let j = 0; j + 1 < hits.length; j += 2) legs.push([hits[j], hits[j + 1]]);
}
// ④ 蛇形排序:上一段末点靠近下一段哪端,就从哪端起步
const waypoints = [];
for (let i = 0; i < legs.length; i++) {
if (i > 0) {
const last = waypoints[waypoints.length - 1];
const dToFirst = turf.distance(last, legs[i][0], { units: 'meters' });
const dToLast = turf.distance(last, legs[i][1], { units: 'meters' });
if (dToFirst > dToLast) legs[i].reverse();
}
waypoints.push(...legs[i]);
}
// ⑤ 去重 → 旋转回原始坐标系
let result = dedupCoords(waypoints).map((p) => rotatePoint(p, center, -rad));
const outLegs = legs.map((l) => l.map((p) => rotatePoint(p, center, -rad)));
// ⑥ 单点补点:沿正北外扩 5 m,保证至少两点成线
if (result.length === 1) {
result.push(turf.destination(result[0], 5, 0, { units: 'meters' }).geometry.coordinates);
}
// ⑦ 无航点(间距大于测区跨度):间距 ÷1.5 递归重试
if (result.length === 0 && depth < 5) {
return calculatePolygonalRoute(polygonArr, spacingM / 1.5, direction, depth + 1);
}
return { waypoints: result, legs: outLegs };
}
拍摄点沿每条航段按 spacingH 均匀铺满(首尾必有相片,实际间隔略小于标称值——重叠率只会略高不会不足,是保守安全的取法):
function generatePhotoPoints(legs, spacingH) {
const photos = [];
for (const [a, b] of legs) {
const len = turf.distance(a, b, { units: 'meters' });
const bearing = turf.bearing(a, b);
const count = Math.max(2, Math.ceil(len / spacingH) + 1);
for (let i = 0; i < count; i++) {
const d = (len * i) / (count - 1);
photos.push(i === 0 ? a
: turf.destination(a, d, bearing, { units: 'meters' }).geometry.coordinates);
}
}
return photos;
}
2.3 实测数据与三处工程加固
上述实现经过 Node + Turf 7.4.0 的端到端验证(7.2 与 7.4 结果逐项一致),泰州测区(约 630×470 m 五边形)默认参数(H=100 m,重叠 80/65%,zoom=1):
| 推进方向 | 航段数 | 航点 | 拍摄点 | 航程 |
|---|---|---|---|---|
| 0°(北→南) | 9 | 18 | 239 | 4.82 km |
| 30° | 13 | 26 | 258 | 5.09 km |
| 90°(西→东) | 16 | 32 | 294 | 5.73 km |
| 135° | 15 | 30 | 272 | 5.31 km |
方向一转航程就差出近 1 km——真实作业里”沿测区长轴方向扫”是省时间的常识,数据也印证了。极端参数(H=500 m + zoom=5,间距 113.9 m)下递归兜底生效,仍产出 4 条航段;L 形凹多边形 18 个航点全部落在测区内。
相比最初的草稿版本,有三处工程加固值得单独点名:
- 米与度的单位坑(最致命)。扫描线的
y是纬度(度),而spacing是米——若直接y = maxY - spacing * (i + 0.5),等于把 50 米当 50 度用,扫描线间隔比整个省还大,一条都落不进测区。必须先做spacingDeg = spacingM / 111320换算。地理计算中”经纬度不是线性度量”是所有米制参数的第一道翻译关。 - 交点排序与成对取段。
lineIntersect返回的交点顺序跟随边的遍历顺序,凹多边形上一条扫描线可能有 4 个交点——不排序直接连线,航线会在测区里乱穿。按经度排序后两两成对取段,A→B在测区内、B→C是跨凹口的过渡段、C→D又回到测区内,语义正确。 polygonToLine的版本差异。Turf 7 对简单多边形返回单个Feature(.geometry.coordinates直接可用);Turf 6 返回的是FeatureCollection(要走.features[0])。跨版本迁移时这是静默翻车点——返回值形态先用一两个顶点试出来再写死取法。
三、交互式组件:Cesium + Turf.js 的工程落地
算法是纯函数,输入多边形与间距、输出航点。把它变成”参数即改、航线即变”的规划工具,工程重心全在 Cesium 侧。组件 src/components/cesium/FlightRoutePlanner.vue 的关键决策如下。
3.1 双引擎 CDN 注入
沿用上一篇的模式:Cesium 经典构建(约 6 MB / gzip 1.7 MB)+ Turf.js 全量包(7.4.0,约 547 KB)都从同一 CDN(cdnjs.snrat.com)加载、封装成模块级 Promise 单例,onMounted 里 Promise.all 并行加载,配合 Astro 的 client:visible 滚入视口才真正下载:
onMounted(async () => {
await Promise.all([loadCesium(), loadTurf()]); // window.Cesium / window.turf
initScene();
});
底图依旧是天地图 img_w 卫星影像(w 集与 Cesium 默认网格天然同构,Provider 全默认直载——上一篇的结论这里直接复用),另叠加一层 cia_w 影像注记提升可读性。
3.2 CallbackProperty:免重建的动态实体
滑块拖动时要每帧重算重绘,若走”删实体 → 建实体”的路子,GC 压力和卡顿都很难看。Cesium 为此准备了 CallbackProperty——实体属性绑定一个函数,每帧求值。把航线坐标放进一个模块级可变数组、原地更新内容,实体自动跟随:
const routeCartesians = []; // 可变数组:内容原地替换
routeEntity = viewer.entities.add({
polyline: {
// 每帧读取数组当前内容 —— 免删建、免 setValue
positions: new Cesium.CallbackProperty(() => routeCartesians, false),
width: 3.5,
material: Cesium.Color.fromCssColorString('#5AB87A').withAlpha(0.95),
},
});
顶点、多边形轮廓、起点标记、绘制橡皮筋全部走这个模式。数百个拍摄点则交给 PointPrimitiveCollection 批量渲染——重算时 removeAll() 后重加,点原语的开销远低于 Entity。坐标转换链闭环:拾取 pickEllipsoid → Cartographic → toDegrees 得到 [lng, lat] 进算法;渲染 Cartesian3.fromDegrees(lng, lat, height) 出来挂飞行高度——航线悬在半空,斜视角下一眼可辨”飞多高”。
3.3 绘制与编辑:ScreenSpaceEventHandler
交互用一个事件处理器分支管理:绘制模式下左键加点、右键完成;非绘制模式下按下时 scene.pick 拾取顶点(实体 id 以 vtx- 前缀命名),命中则冻结相机控制器进入拖拽,移动即更新顶点坐标并触发重算,抬起时解冻:
handler.setInputAction((movement) => {
if (drawMode.value) return;
const picked = viewer.scene.pick(movement.position);
const entityId = picked && picked.id && picked.id.id;
if (typeof entityId === 'string' && entityId.startsWith('vtx-')) {
draggingVertex = parseInt(entityId.slice(4), 10);
setCameraControls(false); // 拖顶点期间冻结相机,避免"拖地球"
}
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
默认双击缩放行为(removeInputAction(LEFT_DOUBLE_CLICK))一并移除,避免干扰绘制。重算用 requestAnimationFrame 节流——拖动滑块时无论事件多密集,每帧至多执行一次”载荷计算 + 扫描线 + 拍摄点重建”,实测全程不掉帧。
四、视觉与体验:让专业工具长成水墨的样子
专业 GIS 工具的默认审美是高对比荧光色,塞进这个博客里会像宣纸上贴了块电路板。配色沿用全站令牌,图层语义与视觉层级一一对应:
| 图层 | 颜色 | 令牌 | 语义 |
|---|---|---|---|
| 航线 | #5AB87A | jiangnan-400 竹青 | 主特征,卫星影像上的中亮度绿 |
| 拍摄点 | #83CD9A | jiangnan-300 浅竹青 | 从属特征,小尺寸弱化 |
| 测区顶点 / 起点 | #A0522D | accent-ochre 赭石 | 可交互元素,暖色提示”能拖” |
| 测区填充 | #4A7C59 12% | jiangnan-500 | 底层区域感,不抢航线 |
三组绿系 + 一处赭石,正好构成”主体—从属—交互”的层级,且与朱砂印章的点缀逻辑同源。交互细节上:滑块 label 元素包裹输入(隐式关联 + aria-label)、focus-visible 焦点环、tabular-nums 等宽数字(数值变化不引起宽度跳动)、150~300 ms 的颜色过渡、绘制模式的顶部提示条、地图下方的色点图例——都是”看不见但用得顺”的功夫。滑块轨道宣纸色、滑块竹青,悬停加深为竹绿,与上一篇定下的滚动条语言完全一致。
五、总结与展望
把这条链路串起来看:镜头参数(物理层)→ GSD 与间距(数学层)→ 扫描线算法(几何层)→ CallbackProperty 实体(渲染层)→ 事件与节流(交互层)。每层都是纯函数或少量的状态翻译,任何一层的参数变化都能在下一帧反映到画面上——这正是”自动生成”该有的样子。文中的算法在 Node 里端到端验证过方向矩阵、极端参数与凹多边形,代码可直接搬进生产。
往后还能走的路:地形跟随(接入 DEM,把等高航线改为贴地变高,间距计算相应引入相对航高);转弯平滑(航段间以 Dubins 曲线过渡,拍照段保持直线);起降点与电量约束(多边形分割 + 任务分组,超航程测区自动拆架次);成果导出(航点表直接生成 KML 或大疆 WPML,闭环到飞控)。这些每一件都够再写一篇。
下面是活的规划台。拖动高度、重叠率、变焦、方向,或在地图上绘制一块自己的测区、拖拽顶点改形状——航线与统计即时重算:
Live Demo
无人机面状航线 · 自动生成
正在从 CDN 加载 Cesium 与 Turf.js…
航线拍摄点测区顶点(可拖拽编辑)
载荷模型:M4TD 广角镜头(焦距 6.78 mm、8064×6048、像元 1.197 μm),GSD = H × 像元 ÷ (变焦 × 焦距)。 拍摄间隔由航向重叠率决定、扫描线间距由旁向重叠率决定;扫描线与推进方向垂直。拖动任意滑块或顶点,航线即时重算。 底图:天地图卫星影像与注记。
(约 6 MB 的 Cesium 与 547 KB 的 Turf.js 在演示区滚入视口时才从 CDN 加载;默认参数即上文实测的那组——GSD 1.77 cm、9 条航段、239 张拍摄点。)
尺有所短,寸有所长;镜头与航线之间,不过一道相似三角形。落笔为记,以飨同好。