返回首页

从镜头到航线:Cesium 与 Turf.js 的无人机面状航线自动生成算法

Cesium Turf.js 无人机测绘 GIS 航线规划

引言:航线规划为什么值得自动化

无人机测绘的典型作业是面状覆盖:给一块不规则测区,飞出一组平行扫描线,沿线按固定间隔拍照,回来空三、拼正射影像。任务书里写的是工程语言——“飞行高度 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)
  },
}
参数值说明
GSD1.77 cm100 m × 1.197 μm ÷ 6.78 mm
地面幅面142 × 107 mGSD × 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 总体思路

拿到间距之后,问题变成纯几何题:用一组等距平行线覆盖任意多边形。直接对任意角度的平行线求交并不好写——旋转一下就简单了:

  1. 把多边形绕中心旋转,让扫描推进方向对齐”自上而下”;
  2. 在旋转后的外接矩形里,自上而下铺水平扫描线(间距即 spacingW);
  3. 每条扫描线与多边形边界求交,交点成对构成一条”拍摄航段”;
  4. 航段按蛇形排序(上一段的末点靠近下一段的哪端,就从哪端起步);
  5. 全部旋转回原始坐标系。

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°(北→南)9182394.82 km
30°13262585.09 km
90°(西→东)16322945.73 km
135°15302725.31 km

方向一转航程就差出近 1 km——真实作业里”沿测区长轴方向扫”是省时间的常识,数据也印证了。极端参数(H=500 m + zoom=5,间距 113.9 m)下递归兜底生效,仍产出 4 条航段;L 形凹多边形 18 个航点全部落在测区内。

相比最初的草稿版本,有三处工程加固值得单独点名:

  1. 米与度的单位坑(最致命)。扫描线的 y 是纬度(度),而 spacing 是米——若直接 y = maxY - spacing * (i + 0.5),等于把 50 米当 50 度用,扫描线间隔比整个省还大,一条都落不进测区。必须先做 spacingDeg = spacingM / 111320 换算。地理计算中”经纬度不是线性度量”是所有米制参数的第一道翻译关。
  2. 交点排序与成对取段。lineIntersect 返回的交点顺序跟随边的遍历顺序,凹多边形上一条扫描线可能有 4 个交点——不排序直接连线,航线会在测区里乱穿。按经度排序后两两成对取段,A→B 在测区内、B→C 是跨凹口的过渡段、C→D 又回到测区内,语义正确。
  3. 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 工具的默认审美是高对比荧光色,塞进这个博客里会像宣纸上贴了块电路板。配色沿用全站令牌,图层语义与视觉层级一一对应:

图层颜色令牌语义
航线#5AB87Ajiangnan-400 竹青主特征,卫星影像上的中亮度绿
拍摄点#83CD9Ajiangnan-300 浅竹青从属特征,小尺寸弱化
测区顶点 / 起点#A0522Daccent-ochre 赭石可交互元素,暖色提示”能拖”
测区填充#4A7C59 12%jiangnan-500底层区域感,不抢航线

三组绿系 + 一处赭石,正好构成”主体—从属—交互”的层级,且与朱砂印章的点缀逻辑同源。交互细节上:滑块 label 元素包裹输入(隐式关联 + aria-label)、focus-visible 焦点环、tabular-nums 等宽数字(数值变化不引起宽度跳动)、150~300 ms 的颜色过渡、绘制模式的顶部提示条、地图下方的色点图例——都是”看不见但用得顺”的功夫。滑块轨道宣纸色、滑块竹青,悬停加深为竹绿,与上一篇定下的滚动条语言完全一致。

五、总结与展望

把这条链路串起来看:镜头参数(物理层)→ GSD 与间距(数学层)→ 扫描线算法(几何层)→ CallbackProperty 实体(渲染层)→ 事件与节流(交互层)。每层都是纯函数或少量的状态翻译,任何一层的参数变化都能在下一帧反映到画面上——这正是”自动生成”该有的样子。文中的算法在 Node 里端到端验证过方向矩阵、极端参数与凹多边形,代码可直接搬进生产。

往后还能走的路:地形跟随(接入 DEM,把等高航线改为贴地变高,间距计算相应引入相对航高);转弯平滑(航段间以 Dubins 曲线过渡,拍照段保持直线);起降点与电量约束(多边形分割 + 任务分组,超航程测区自动拆架次);成果导出(航点表直接生成 KML 或大疆 WPML,闭环到飞控)。这些每一件都够再写一篇。

下面是活的规划台。拖动高度、重叠率、变焦、方向,或在地图上绘制一块自己的测区、拖拽顶点改形状——航线与统计即时重算:

Live Demo

无人机面状航线 · 自动生成

Cesium 1.145 + Turf.js 7.4

正在从 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 张拍摄点。)


尺有所短,寸有所长;镜头与航线之间,不过一道相似三角形。落笔为记,以飨同好。