03 AR 投影与误差修正:把地图上的点贴到视频画面上
我们要做什么
看无人机航拍视频时,我们想在画面上叠加标签------"XX 大楼"正好贴在那栋楼的位置。这要做的事叫投影:知道楼的经纬度、飞机的位置和摄像头朝向,算出楼在画面里的哪个像素。
麻烦的是:公式算出来的位置总是偏一点。这一篇我们就搞清楚为什么会偏,以及怎么用"机器学习"这个听起来高大上、其实很简单的方法把偏差修掉。
先懂几个概念
概念 1:投影就像打手电
晚上你用手电筒照墙上的地图,地图上的点会在对面墙上投出一个影子。摄像头就是那只"手电筒"(反过来:光线从楼出发,穿过镜头,投在传感器上)。
知道三件事就能算影子位置:
- 手电筒在哪(飞机的 GPS)
- 手电筒朝哪(飞机姿态角 + 摄像头云台角)
- 墙有多远/楼在哪(楼的经纬度和高度)
这套计算叫针孔相机模型,是一串坐标系转换:经纬度 → 以地心为原点的坐标 → 以飞机为原点的坐标 → 以摄像头为原点的坐标 → 画面上的像素。公式是固定的,照着套就行。
概念 2:为什么算出来会偏
公式是理想化的,现实世界到处是"小毛病":
- 镜头是广角的,边缘会弯(像鱼眼照片)
- 摄像头装的时候歪了 2°(没人能装得绝对正)
- 说明书说视角 60°,实际可能 62°
- 飞机姿态数据和摄像头数据不是同一瞬间的(有延迟)
这些误差每台设备都不一样,没法用统一公式描述。
概念 3:用"回归"修正------本质就是找规律
思路特别朴素:
- 采样:拍一堆"算出来的位置"和"人眼标的正确位置"的对照数据
- 找规律:发现"算的位置越靠画面右边,偏得越多"之类的规律
- 拟合:用一条曲线(二次多项式)描述这个规律
- 修正:以后算完位置,查一下规律曲线,把偏差加回去
这就是"机器学习"在这里的全部含义------拟合一条二次曲线 ,y = ax² + bx + c,没有神经网络,没有玄学。
概念 4:分片------一个规律管不了全局
画面左上角的偏差规律和右下角的不一样(镜头畸变是对称的),所以把画面切成 3×3 九宫格,每格各自拟合一条曲线。就像南北方气候规律不同,分地区预报更准。
整体流程图
markdown
【第一步:采集样本(离线,做一次)】
投影公式算的位置 (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:
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 引入):
html
<!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>
跑起来看看
bash
node server.js # 然后打开 index.html
你会看到:
makefile
平均偏差(不修正): 0.0521
修正后(全局一条): 0.0413
修正后(分区域): 0.0038 ← 最小
解读:
- 不修正:偏差摆在那,标签和楼对不上
- 全局一条曲线:有改善但不理想------因为三个区域规律不同,一条曲线"顾此失彼"
- 分区域:每个区域各管各的,误差降到接近噪声水平(0.01 的噪声是模拟时加的,理论上不可能比噪声更小)
画面上的灰色全局曲线想同时讨好三堆散点,结果谁都贴不好;红色分区曲线各自贴合自己的散点。
关键点回顾
| 知识点 | 一句话 |
|---|---|
| 投影 | 就是"打手电算影子":知道位置、朝向、目标,就能算画面坐标 |
| 为什么会偏 | 镜头畸变、安装歪了、说明书不准------每台设备都不一样,没法写统一公式 |
| 回归修正 | 采集"算的 vs 对的"样本,拟合一条二次曲线描述偏差规律,用的时候加回去 |
| 分片 | 不同区域规律不同,分成九宫格各自拟合,比一条全局曲线准得多 |
| 为什么只用二次 | 样本有限,曲线太复杂(次数太高)会"死记硬背"样本,换个点就失灵(过拟合) |
想一想
- 把
degree从 2 改成 8 再跑,样本内误差会降,但这是好事吗?(提示:换一批新样本试试,高次曲线在新数据上往往崩掉) - 真实场景除了"画面区域",还按"机头朝向"分组(朝北飞和朝南飞时,安装误差的投影方向不同)。如果给你两个分组维度,模型数量会爆炸吗?怎么权衡?