2、重投影误差:单相机重投影误差计算与可视化

重投影误差,说白了就是「拍回去看看差多少」。

你标定完相机,内参外参都算出来了。那怎么知道标得准不准?最直观的办法——把三维空间里的角点,按你算出来的参数重新投影到图像上,看看跟实际检测到的角点差了几个像素。

这个差值,就是重投影误差。我习惯叫它「回投误差」,英文叫 Reprojection Error。

2.1 重投影误差的数学定义

先别急着写代码。咱们把公式理清楚,后面调试的时候你才知道问题出在哪。

假设我们有:

  • 世界坐标系下的三维点 P = (X, Y, Z, 1)ᵀ
  • 相机内参矩阵 K
  • 外参(旋转 R + 平移 t)
  • 实际图像上检测到的像素坐标 p = (u, v)ᵀ

重投影过程分三步:

  1. 世界 → 相机:Pcam = [R|t] · P
  2. 相机 → 归一化平面:x = Xcam/Zcam, y = Ycam/Zcam
  3. 归一化 → 像素:p' = K · (x, y, 1)ᵀ,再加畸变校正

得到重投影点 p' = (u', v')ᵀ。误差就是:

e = || p - p' ||₂

单位:像素。理想值 < 0.5 px,工程上 < 1.0 px 可接受。

我在项目里见过有人把误差算成向量范数后取平均,结果发现个别角点误差 3 个像素,平均值才 0.6。嗯,这里要注意——一定要看分布,不能只看均值。

2.2 单相机重投影误差计算流程

我给你画个流程图,你跟着走一遍就清楚了。

单相机重投影误差计算流程 读取标定图像 检测棋盘格角点(亚像素) 加载标定结果(K, D, R, t) 世界点 → 重投影像素坐标 p' 计算 ||p - p'||₂,统计分布 可视化:散点图 + 直方图

流程不复杂,但每一步都有坑。我一个个说。

2.3 代码实现(Python + OpenCV)

直接上代码。这是我项目里一直在用的模板,你拿去改改就能用。

import cv2
import numpy as np
import glob
import matplotlib.pyplot as plt

# 1. 加载标定结果
with np.load('calibration_result.npz') as data:
    mtx = data['mtx']          # 内参矩阵
    dist = data['dist']        # 畸变系数
    rvecs = data['rvecs']      # 旋转向量
    tvecs = data['tvecs']      # 平移向量

# 2. 准备棋盘格三维点
CHECKERBOARD = (9, 6)          # 内角点数量
objp = np.zeros((CHECKERBOARD[0]*CHECKERBOARD[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2)

# 3. 遍历所有图像,计算重投影误差
errors = []
images = glob.glob('calib_images/*.jpg')

for i, fname in enumerate(images):
    img = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None)

    if not ret:
        continue

    # 亚像素精化
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
    corners2 = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)

    # 重投影
    projected, _ = cv2.projectPoints(objp, rvecs[i], tvecs[i], mtx, dist)

    # 计算每个角点的误差
    error = np.linalg.norm(corners2 - projected, axis=2).flatten()
    errors.extend(error)

errors = np.array(errors)
print(f"总角点数: {len(errors)}")
print(f"平均误差: {np.mean(errors):.4f} px")
print(f"最大误差: {np.max(errors):.4f} px")
print(f"标准差:  {np.std(errors):.4f} px")
print(f"误差 < 1px 占比: {np.sum(errors < 1.0) / len(errors) * 100:.1f}%")

小技巧:亚像素精化用 cornerSubPix 时,窗口大小 (11,11) 是经验值。如果图像噪声大,可以放大到 (15,15)。我遇到过标定板反光严重的情况,窗口小了角点位置飘得厉害。

2.4 可视化:一眼看出问题

光看数字不够。我习惯画两张图:

  • 误差散点图:每个角点的误差大小用颜色表示,能看出哪些区域误差大
  • 误差直方图:看整体分布,判断是否服从正态分布
# 4. 可视化
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左图:散点图
ax1 = axes[0]
sc = ax1.scatter(range(len(errors)), errors, c=errors, cmap='viridis', s=8, alpha=0.7)
ax1.set_xlabel('角点索引')
ax1.set_ylabel('重投影误差 (px)')
ax1.set_title('单相机重投影误差分布')
ax1.axhline(y=1.0, color='r', linestyle='--', label='1 px 阈值')
ax1.legend()
plt.colorbar(sc, ax=ax1)

# 右图:直方图
ax2 = axes[1]
ax2.hist(errors, bins=50, color='steelblue', edgecolor='white', alpha=0.8)
ax2.set_xlabel('重投影误差 (px)')
ax2.set_ylabel('角点数量')
ax2.set_title('误差直方图')
ax2.axvline(x=1.0, color='r', linestyle='--', label='1 px 阈值')
ax2.legend()

plt.tight_layout()
plt.show()

2.5 误差分析:什么算「好」?

我直接给经验值,你对照着看:

指标 优秀 合格 需重标定
平均误差 < 0.3 px 0.3 - 0.8 px > 1.0 px
最大误差 < 0.8 px 0.8 - 2.0 px > 2.0 px
标准差 < 0.2 px 0.2 - 0.5 px > 0.5 px
< 1 px 占比 > 95% 80% - 95% < 80%

注意:如果平均误差小但最大误差大,说明有「离群点」。我曾经遇到一个项目,平均误差 0.4 px,但最大误差 3.2 px。查了半天,发现是标定板边缘有一块脏污,导致角点检测偏移。去掉那几张图像后,最大误差降到 0.9 px。

2.6 常见问题与避坑指南

做重投影误差计算,这几个坑我基本都踩过:

  • 角点顺序不一致:findChessboardCorners 返回的角点顺序是从左上到右下,但如果你自己生成 objp 时顺序反了,误差会大得离谱。我建议每次都用 cv2.drawChessboardCorners 看一眼。
  • 畸变参数没传:projectPoints 的 dist 参数如果传 None,相当于假设无畸变。对于广角镜头,误差能差 5 个像素以上。
  • 外参索引对不上:如果某张图没检测到角点,rvecs/tvecs 的索引会错位。我习惯用字典存,不用列表。
  • 标定板不平整:这个最隐蔽。如果标定板贴在弯曲表面上,角点世界坐标就不准了。重投影误差会整体偏大,而且呈现「中间小、边缘大」的规律。

核心原则:重投影误差是标定质量的「照妖镜」。如果误差大,不要急着调算法参数,先检查数据质量——图像是否清晰、标定板是否平整、角点检测是否准确。数据干净了,算法自然就准了。

2.7 本章小结

重投影误差计算,说白了就是三步:

  1. 把三维点投影回图像
  2. 算像素差
  3. 看分布

代码 20 行就能跑通,但真正用好它,需要你对数据质量有感觉。我建议你每次标定完都跑一遍这个流程,把误差分布图存下来。时间长了,你一眼就能看出标定有没有问题。