03 AR 投影与误差修正:把地图上的点贴到视频画面上

17 阅读6分钟

03 AR 投影与误差修正:把地图上的点贴到视频画面上

我们要做什么

看无人机航拍视频时,我们想在画面上叠加标签——"XX 大楼"正好贴在那栋楼的位置。这要做的事叫投影:知道楼的经纬度、飞机的位置和摄像头朝向,算出楼在画面里的哪个像素。

麻烦的是:公式算出来的位置总是偏一点。这一篇我们就搞清楚为什么会偏,以及怎么用"机器学习"这个听起来高大上、其实很简单的方法把偏差修掉。

先懂几个概念

概念 1:投影就像打手电

晚上你用手电筒照墙上的地图,地图上的点会在对面墙上投出一个影子。摄像头就是那只"手电筒"(反过来:光线从楼出发,穿过镜头,投在传感器上)。

知道三件事就能算影子位置:

  • 手电筒在哪(飞机的 GPS)
  • 手电筒朝哪(飞机姿态角 + 摄像头云台角)
  • 墙有多远/楼在哪(楼的经纬度和高度)

这套计算叫针孔相机模型,是一串坐标系转换:经纬度 → 以地心为原点的坐标 → 以飞机为原点的坐标 → 以摄像头为原点的坐标 → 画面上的像素。公式是固定的,照着套就行。

概念 2:为什么算出来会偏

公式是理想化的,现实世界到处是"小毛病":

  • 镜头是广角的,边缘会弯(像鱼眼照片)
  • 摄像头装的时候歪了 2°(没人能装得绝对正)
  • 说明书说视角 60°,实际可能 62°
  • 飞机姿态数据和摄像头数据不是同一瞬间的(有延迟)

这些误差每台设备都不一样,没法用统一公式描述。

概念 3:用"回归"修正——本质就是找规律

思路特别朴素:

  1. 采样:拍一堆"算出来的位置"和"人眼标的正确位置"的对照数据
  2. 找规律:发现"算的位置越靠画面右边,偏得越多"之类的规律
  3. 拟合:用一条曲线(二次多项式)描述这个规律
  4. 修正:以后算完位置,查一下规律曲线,把偏差加回去

这就是"机器学习"在这里的全部含义——拟合一条二次曲线,y = ax² + bx + c,没有神经网络,没有玄学。

概念 4:分片——一个规律管不了全局

画面左上角的偏差规律和右下角的不一样(镜头畸变是对称的),所以把画面切成 3×3 九宫格,每格各自拟合一条曲线。就像南北方气候规律不同,分地区预报更准。

整体流程图

【第一步:采集样本(离线,做一次)】
投影公式算的位置 (x)  ──┐
                        ├─→ 偏差 = 正确位置 - 算的位置  → 存成样本表
人眼标注的正确位置  ────┘

【第二步:训练(离线,做一次)】
样本表按"画面区域"分成 9 组
每组拟合一条二次曲线:偏差 = a·x² + b·x + c
                              ↓
                        得到 9 条曲线(模型)

【第三步:运行(实时,每帧都做)】
新点进来 → 投影公式算出位置 → 看落在哪一格
        → 用那一格的曲线算偏差 → 位置 + 偏差 = 最终位置

动手做

为了聚焦"修正"这个核心,我们跳过复杂的 3D 投影公式,直接模拟:假设投影公式算出的位置是 x(0~1 之间),因为设备毛病,它总有偏差。我们采集样本、训练曲线、对比修正前后的效果。

第 1 步:Node 生成模拟样本

新建 demos/03-ar,npm init -y && npm i express cors,新建 server.js:

const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());

// 模拟"设备的真实毛病":每个区域的偏差规律不同
// (真实世界这个规律来自镜头畸变+安装误差,无法写公式)
function realError(x, region) {
  const base = [0.05, -0.02, 0.08][region];   // 每个区域基础偏差不同
  return base + 0.15 * x * x - 0.1 * x;       // 还随位置非线性变化
}

app.get('/api/samples', (req, res) => {
  const samples = [];
  for (let i = 0; i < 180; i++) {
    const region = i % 3;                      // 模拟画面分 3 个区域
    const x = Math.random();                   // 投影公式算出的位置
    const offset = realError(x, region) + (Math.random() - 0.5) * 0.01; // 真实偏差+一点噪声
    samples.push({ x, region, offset });
  }
  res.json({ samples });
});

app.listen(3000, () => console.log('http://localhost:3000'));

第 2 步:前端训练 + 对比

新建 index.html(回归库用 CDN 引入):

<!DOCTYPE html>
<html>
<body>
  <h3>AR 投影误差修正实验</h3>
  <p>蓝点 = 原始偏差散点 | 红线 = 分区域拟合的修正曲线 | 修正后残差应贴近 0</p>
  <canvas id="cv" width="820" height="400" style="border:1px solid #ccc"></canvas>
  <pre id="out"></pre>

  <script src="https://cdn.jsdelivr.net/npm/ml-regression-polynomial@2/lib/index.iife.min.js"></script>
  <script>
    const { samples } = await fetch('http://localhost:3000/api/samples').then(r => r.json());
    const { PolynomialRegression } = window.ML;
    const cv = document.getElementById('cv');
    const ctx = cv.getContext('2d');
    // 坐标转换:x∈[0,1]→[20,800],偏差∈[-0.2,0.2]→纵轴,0 线在 200 处
    const px = x => 20 + x * 780;
    const py = o => 200 - o * 800;

    // 1. 画原始偏差散点(3 个区域 3 种颜色)
    const colors = ['#1e90ff', '#20b2aa', '#9370db'];
    for (const s of samples) {
      ctx.fillStyle = colors[s.region];
      ctx.fillRect(px(s.x) - 1, py(s.offset) - 1, 3, 3);
    }

    // 2. 分区域训练:每区一条二次曲线
    const models = {};
    for (let r = 0; r < 3; r++) {
      const group = samples.filter(s => s.region === r);
      models[r] = new PolynomialRegression(group.map(s => s.x), group.map(s => s.offset), 2);
    }
    // 对照组:不分区域,全量数据硬凑一条曲线
    const globalModel = new PolynomialRegression(samples.map(s => s.x), samples.map(s => s.offset), 2);

    // 3. 画分区拟合曲线(红)和全局曲线(灰)
    for (let r = 0; r < 3; r++) {
      ctx.strokeStyle = 'red'; ctx.beginPath();
      for (let x = 0; x <= 1; x += 0.01) {
        const y = py(models[r].predict(x));
        x === 0 ? ctx.moveTo(px(x), y) : ctx.lineTo(px(x), y);
      }
      ctx.stroke();
    }
    ctx.strokeStyle = '#aaa'; ctx.beginPath();
    for (let x = 0; x <= 1; x += 0.01) {
      const y = py(globalModel.predict(x));
      x === 0 ? ctx.moveTo(px(x), y) : ctx.lineTo(px(x), y);
    }
    ctx.stroke();

    // 4. 数字说话:三种策略的平均误差
    let raw = 0, gErr = 0, sErr = 0;
    for (const s of samples) {
      raw += Math.abs(s.offset);                              // 不修正
      gErr += Math.abs(s.offset - globalModel.predict(s.x));  // 全局一条曲线
      sErr += Math.abs(s.offset - models[s.region].predict(s.x)); // 分区曲线
    }
    document.getElementById('out').textContent =
      `平均偏差(不修正):   ${(raw / samples.length).toFixed(4)}\n` +
      `修正后(全局一条):   ${(gErr / samples.length).toFixed(4)}\n` +
      `修正后(分区域):     ${(sErr / samples.length).toFixed(4)}  ← 最小`;
  </script>
</body>
</html>

跑起来看看

node server.js    # 然后打开 index.html

你会看到:

平均偏差(不修正):   0.0521
修正后(全局一条):   0.0413
修正后(分区域):     0.0038  ← 最小

解读:

  • 不修正:偏差摆在那,标签和楼对不上
  • 全局一条曲线:有改善但不理想——因为三个区域规律不同,一条曲线"顾此失彼"
  • 分区域:每个区域各管各的,误差降到接近噪声水平(0.01 的噪声是模拟时加的,理论上不可能比噪声更小)

画面上的灰色全局曲线想同时讨好三堆散点,结果谁都贴不好;红色分区曲线各自贴合自己的散点。

关键点回顾

知识点一句话
投影就是"打手电算影子":知道位置、朝向、目标,就能算画面坐标
为什么会偏镜头畸变、安装歪了、说明书不准——每台设备都不一样,没法写统一公式
回归修正采集"算的 vs 对的"样本,拟合一条二次曲线描述偏差规律,用的时候加回去
分片不同区域规律不同,分成九宫格各自拟合,比一条全局曲线准得多
为什么只用二次样本有限,曲线太复杂(次数太高)会"死记硬背"样本,换个点就失灵(过拟合)

想一想

  1. 把 degree 从 2 改成 8 再跑,样本内误差会降,但这是好事吗?(提示:换一批新样本试试,高次曲线在新数据上往往崩掉)
  2. 真实场景除了"画面区域",还按"机头朝向"分组(朝北飞和朝南飞时,安装误差的投影方向不同)。如果给你两个分组维度,模型数量会爆炸吗?怎么权衡?