相机标定误差很小,为什么测量仍不准?从 OpenCV 重投影误差到独立验证

相机标定结束后,程序输出一个很小的误差:0.1 像素。于是我们保存内参,开始测距,却发现结果与参考值始终有差距。重拍更多照片、增加畸变参数、删掉几张误差大的图,数字变得更漂亮,测量却未必随之改善。

问题往往在于:重投影误差衡量的是一组图像点能否被当前模型解释,而测量准确性还依赖物理尺度、输入数据、工作条件和整条几何链。本文用一个可复现的合成实验说明,为什么连新视角的像素误差都很小,毫米结果仍然可能错。

**阅读范围:**面向 OpenCV、三维视觉和影像软件开发者。本文不做品牌推荐,不给出通用设备精度结论。全部图像点由程序合成;实验验证的是数学与软件行为,没有进行真实相机或临床系统实测。资料核对日期:2026 年 10 月 15 日。

一、先明确标定在估计什么

图 1:相机几何与下游测量的关系(原创教学示意图,使用合成数据)

1.1 内参:把相机中的方向映射到像素

复制代码
K = [[fx,  0, cx],
     [ 0, fy, cy],
     [ 0,  0,  1]]

归一化坐标:x = Xc / Zc,y = Yc / Zc
无畸变理想投影:u = fx*x + cx,v = fy*y + cy

fx、fy 是以像素为单位的焦距参数,不能直接当作镜头铭牌上的毫米焦距;cx、cy 描述主点位置。真实模型还需要考虑畸变。参数只有与对应图像坐标、图像尺寸、镜头状态和畸变模型一起保存,才具有完整含义。基础模型参考:OpenCV calib3d 文档。

1.2 外参:物体坐标到相机坐标

复制代码
P_camera = R @ P_object + t
# solvePnP 输出的 rvec、tvec 描述这个方向的变换。
# 相机中心在物体坐标系中的位置:C_object = -R.T @ t

tvec 不是"相机在世界中的位置"。它是把物体点变换到相机坐标时的平移项,其单位继承物点坐标单位。物点用毫米定义,平移就以毫米解释;物点只用棋盘格数量定义,平移就以格距为单位。rvec 则是旋转向量,应通过 Rodrigues 转换为旋转矩阵,不能把三个分量直接当作三个欧拉角。参考:OpenCV PnP 说明。

1.3 畸变模型:模型适用性比参数数量重要

常见模型包含径向与切向畸变参数,OpenCV 还提供其他扩展与鱼眼模型。选择模型要匹配镜头、成像范围和数据,不能仅因增加参数降低了训练残差,就认为获得了更好的测量模型。一个在已有观测上拟合良好的函数,仍可能在视野边缘或不同条件下表现不合理。

二、重投影误差小,究竟说明了什么?

2.1 它是图像平面上的残差

给定物点坐标、内参、畸变与每幅图的外参,模型会预测角点像素位置。将预测值与检测到的角点位置比较,就得到残差向量。本文明确采用"每个点的二维欧氏距离平方,求平均后开方"的 RMS:

复制代码
error_n = observed_pixel_n - predicted_pixel_n
RMS = sqrt(sum(||error_n||^2) / N)
# N 为参与计算的点数,不是坐标分量数。
# 单位:像素。

这个定义与"每坐标分量 RMS"相差一个归一化因子,也与平均欧氏距离不同。有的示例使用 norm(errors)/N,这也不是上面定义的 RMS。阅读报告或比较两套程序时,先确认公式、点数、单位和输入数据范围,否则可能在比较不同的量。

2.2 拟合对象是角点观测,不是独立物理真值

角点检测结果本身带有不确定性。若标定板的几何描述、角点顺序或成像模型存在偏差,优化器仍可能找到一组参数很好地解释这些观测。低残差可以帮助检查拟合,但不自动验证标定板真实尺寸、相机到物体距离或后续三维重建误差。

经典平面标定方法利用不同姿态下的平面图案估计相机参数,再进行优化;因此观察姿态和几何约束本身就是方法的一部分。原始研究可参阅 张正友:A Flexible New Technique for Camera Calibration。

2.3 一个总分数会隐藏空间分布

整体 RMS 不告诉你误差集中在哪些位置。建议同时观察逐图误差、角点在图像中的覆盖情况和残差方向:中心是否好于边缘?某一侧是否出现方向一致的残差?是否只有一种姿态明显异常?这些现象可以帮助提出问题,但不能仅凭一幅残差图就断定唯一原因。

删除模糊或错误检测的观测有意义;为了压低分数而持续删除"难"的视角,则可能让数据更缺乏约束。应记录删除理由和前后变化,并使用未参与筛选的验证数据比较,而不是只展示最好看的训练结果。

三、一个容易被忽略的原因:标定板的物理尺度错了

图 2:物点与平移同时缩放时,投影可以保持一致(原创教学示意图,使用合成数据)

3.1 为什么 20 mm 误填成 21 mm 仍然能拟合?

考虑投影前的相机坐标:Pc = R·P + t。如果把所有物点统一放大 s 倍,同时把每幅图的平移也放大 s 倍,那么 Pc 变为 s·Pc。透视除法使用 Xc/Zc 与 Yc/Zc,公共尺度会被约掉;依赖归一化坐标的畸变计算也不会因此改变。

复制代码
P_wrong = s * P_true
t_wrong = s * t_true

R @ P_wrong + t_wrong = s * (R @ P_true + t_true)
(s*Xc)/(s*Zc) = Xc/Zc
(s*Yc)/(s*Zc) = Yc/Zc

这意味着,内参可以基本不变,像素预测可以几乎完全一致,但平移的毫米尺度已经错了。这个反例并不是"内参一定估错",而是说明:当前目标函数无法自行证明你输入的尺子是对的。

3.2 新视角也不一定能揭穿公共尺度错误

如果验证仍使用同一块板、同一个错误尺寸,并允许对新图重新估计外参,新的平移同样可以吸收尺度。于是未参与标定的新图也能获得很小的像素误差。它提供了部分泛化检查,却没有建立独立物理尺度。

要验证毫米结果,需要引入不共享该错误的参考,例如合成实验中明确设定的真实平移,或真实实验中经独立确认的长度、位置和测量装置。仅换一张照片,并不意味着验证链条已经独立。

四、完整合成实验:像素误差几乎相同,600 mm 却变成约 630 mm

下面生成 24 个不同姿态的棋盘角点集合,真实格距为 20 mm,并加入标准差为 0.10 像素的二维高斯噪声。对完全相同的图像点做两次标定:一次使用正确物点尺寸,一次把物点整体放大 5%。之后固定各自内参,在一个新视角重新估计外参,比较像素残差与真实 t_z。

棋盘坐标原点放在角点网格中心,因此本文 t_z 指这个物体坐标原点在相机坐标中的 z 分量。它不是到板面任一点的距离,也不是 tvec 的欧氏范数。为了避免概念替换,结果表始终使用 t_z 这个名字。

复制代码
python -m pip install numpy opencv-python
python calibration_demo.py

"""Synthetic calibration experiment: board scale cannot be verified by pixel RMS."""
import json
from pathlib import Path
import cv2
import numpy as np

rng = np.random.default_rng(20261015)
size = (1280, 960)  # width, height
K_true = np.array([[1050., 0., 640.], [0., 1030., 480.], [0., 0., 1.]])
dist_true = np.array([-0.12, 0.035, 0.001, -0.0008, 0.])
board = np.zeros((9 * 6, 3), np.float32)
board[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2) * 20.  # actual mm
board[:, :2] -= [80., 50.]
objects, observations = [], []
for _ in range(24):
    rv = rng.uniform([-0.5, -0.5, -0.3], [0.5, 0.5, 0.3])
    tv = rng.uniform([-150., -90., 450.], [150., 90., 750.])
    pixels, _ = cv2.projectPoints(board, rv, tv, K_true, dist_true)
    pixels += rng.normal(0., 0.10, pixels.shape)
    assert np.all((pixels[..., 0] > 0) & (pixels[..., 0] < size[0]))
    assert np.all((pixels[..., 1] > 0) & (pixels[..., 1] < size[1]))
    objects.append(board.copy())
    observations.append(pixels.astype(np.float32))

criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 200, 1e-10)
def fit(scale):
    return cv2.calibrateCamera([x * scale for x in objects], observations,
                               size, None, None, criteria=criteria)

good = fit(1.0)
wrong = fit(1.05)  # tell the program the 20 mm square is 21 mm
rms_good, K_good, d_good, _, _ = good
rms_wrong, K_wrong, d_wrong, _, _ = wrong

def point_rms(obs, pred):
    delta = obs.reshape(-1, 2) - pred.reshape(-1, 2)
    return float(np.sqrt(np.mean(np.sum(delta * delta, axis=1))))

# Fresh view and independently specified camera-to-board distance.
rv_test = np.array([0.25, -0.2, 0.15])
tv_test = np.array([35., -20., 600.])
test_pixels, _ = cv2.projectPoints(board, rv_test, tv_test, K_true, dist_true)
test_pixels += rng.normal(0., 0.10, test_pixels.shape)
test_pixels = test_pixels.astype(np.float32)
heldout = []
for scale, K, dist in [(1., K_good, d_good), (1.05, K_wrong, d_wrong)]:
    ok, rv, tv = cv2.solvePnP(board * scale, test_pixels, K, dist)
    assert ok
    pred, _ = cv2.projectPoints(board * scale, rv, tv, K, dist)
    heldout.append((point_rms(test_pixels, pred), float(tv[2, 0])))

# Algebraic invariance, independent of the optimizer.
p1, _ = cv2.projectPoints(board, rv_test, tv_test, K_true, dist_true)
p2, _ = cv2.projectPoints(board * 1.05, rv_test, tv_test * 1.05, K_true, dist_true)
np.testing.assert_allclose(p1, p2, atol=1e-4)
assert abs(rms_good - rms_wrong) < 1e-4
np.testing.assert_allclose(K_good, K_wrong, atol=0.02, rtol=0)
assert abs(heldout[0][1] - 600.) < 3.
assert abs(heldout[1][1] / heldout[0][1] - 1.05) < 1e-3
assert heldout[1][1] - 600. > 25.

# Validate crop-then-resize coordinates with u'=sx*(u-u0), v'=sy*(v-v0).
# This is the declared coordinate convention, without additional half-pixel offsets.
H = np.array([[0.5, 0., -0.5 * 100], [0., 0.75, -0.75 * 80], [0., 0., 1.]])
K_new = H @ K_true
projected_new, _ = cv2.projectPoints(board, rv_test, tv_test, K_new, dist_true)
old_h = np.c_[p1.reshape(-1, 2), np.ones(len(board))]
np.testing.assert_allclose(projected_new.reshape(-1, 2),
                           (H @ old_h.T).T[:, :2], atol=1e-4)

result = dict(opencv=cv2.__version__, training_rms=[rms_good, rms_wrong],
              heldout_rms=[x[0] for x in heldout],
              heldout_tz_mm=[x[1] for x in heldout], true_tz_mm=600.,
              K_good=K_good.tolist(), K_wrong=K_wrong.tolist())
Path(__file__).with_name('results.json').write_text(json.dumps(result, indent=2))
for i, name in enumerate(['correct scale', 'wrong scale +5%']):
    print(f'{name}: train RMS={result["training_rms"][i]:.6f} px; '
          f'held-out RMS={result["heldout_rms"][i]:.6f} px; '
          f'tz={result["heldout_tz_mm"][i]:.3f} mm')
print('PASS: scale invariance, held-out metric error, crop/resize mapping')

4.1 本次实际运行结果

输入物点尺度 训练 RMS(px) 新视角重估外参 RMS(px) 估计 t_z(mm)
正确:20 mm 格距 0.135366 0.125402 600.363
错误:统一放大 5% 0.135366 0.125403 630.382

图 3:合成实验实际输出(原创教学示意图,使用合成数据)

该新视角的真实 t_z 为 600 mm。错误尺度下,像素残差仍然约 0.125 像素,而估计 t_z 约为 630.382 mm。新图确实没有参与内参标定,但仍复用了错误的物点尺度,因此新图残差不能独自否定尺度错误。

本次环境 OpenCV 版本为 5.0.0。代码中的断言还检查了直接投影的尺度不变性、两组内参的接近程度、外参尺度关系和裁切缩放映射。不同数值库与版本可能带来末位差异,关键是几何关系和误差模式。

4.2 这个实验没有证明什么?

它没有证明所有低 RMS 标定都不可靠,也没有模拟真实角点检测、板材形变、温度、滚动快门或光学状态变化。它只给出一个明确反例:像素目标函数存在无法验证公共物理尺度的情形。实际设备的准确度必须另外测量。

五、其他常见原因:同样的分数,不同的误差来源

问题 为什么可能没有体现在单个 RMS 中 优先检查
标定板尺寸或平整度不可靠 外参或其他参数可能吸收部分偏差 独立尺寸确认、固定方式、实际板面状态
姿态与视野覆盖不足 在有限区域拟合好,缺乏其他条件约束 不同倾斜方向、位置、距离和边缘覆盖
模糊、反光或角点偏差 检测结果本身是有偏观测 曝光、运动、反光与角点局部图像
模型过度复杂或不匹配 训练残差降低,外推与稳定性未验证 独立数据、参数稳定性、边缘表现
变焦、对焦或装配状态变化 正在使用的成像系统已不同 锁定状态、重新验证及必要的重标定
分辨率、ROI 或裁切改变 像素坐标定义发生变化 完整像素映射与对应内参
下游坐标或单位错误 内参正确也无法修复变换链错误 变换方向、mm/m、重复应用矩阵
双目或动态任务的额外误差 单相机静态标定不覆盖整条测量链 双目外参、同步、运动与重建验证

5.1 为什么多拍相似照片不一定解决问题?

连续拍下许多几乎相同的姿态,增加的是重复观测,不一定增加足够独立的几何约束。更有价值的是在清晰、可检测的前提下,让板面方向和位置发生有计划的变化,覆盖实际使用范围。极端倾斜导致角点模糊或遮挡时,也不能为了姿态多样性强行保留。

照片数量不应被当作单一质量指标。应同时记录成功检测数量、姿态和空间覆盖、逐图残差、独立验证结果以及设备状态。拍摄与检测的实践入口可参考 OpenCV 标定教程。

5.2 板子不是理想平面时,模型输入已经失真

打印缩放、纸张弯曲、安装不平和温度变化等因素,都可能使真实角点与程序定义的平面网格不同。程序不会自动知道板材的实际状态。真实验证中需要明确参考物的几何可信度与测量不确定性,不能因为图案看起来规整就默认尺寸准确。

误填统一尺度只是本文容易解释的一种情况;不均匀拉伸、局部翘曲或错误点序不具有同样简单的尺度不变性。不要把本文"内参基本不变"的结果外推到所有标定板误差。

六、裁切、缩放和去畸变:不要混用不同像素空间

6.1 明确像素映射后,再更新内参

假设图像坐标映射明确为:先去掉左上偏移 (u0,v0),再按 sx、sy 缩放,即 u′=sx(u−u0)、v′=sy(v−v0)。对这一定义,可写成 H,并得到 K_new=H·K。

复制代码
H = np.array([[sx, 0., -sx*u0],
              [0., sy, -sy*v0],
              [0., 0., 1.]])
K_new = H @ K_old
# 对同一归一化畸变模型、同一实际光学状态成立。
# 示例末尾已用 projectPoints 检查这一声明的像素映射。

这里刻意写出"声明的映射":某些图像缩放 API 使用特定像素中心采样约定,可能包含额外偏移;相机硬件 ROI、binning 或其他读出模式也需要依据实际映射验证。不能看到分辨率变为一半,就在所有未知成像模式下机械复用同一个更新公式。

6.2 原始畸变图与去畸变图不是同一套输入

原始角点通常与原始 K、distCoeffs 搭配。若点已去畸变到另一套像素内参下,后续投影或 PnP 应使用与该空间匹配的模型;再套用原畸变参数会重复处理。若 undistortPoints 没有指定输出投影矩阵,它通常返回归一化坐标,不能把结果直接当作原图像素。相关接口约定见 OpenCV calib3d。

建议给变量命名增加空间语义,例如 points_raw_px、points_undistorted_px、points_normalized,避免不同二维数组因为形状相同就混用。保存截图时也应记录是否裁切、缩放或去畸变,方便复现。

七、怎样设计真正有用的独立验证?

图 4:像素拟合、观测泛化与物理测量分层验证(原创教学示意图,使用合成数据)

7.1 第一层:标定数据内部检查

记录参与拟合的图像与角点、总 RMS、逐图 RMS、图像覆盖和残差分布。保留采集与剔除记录,确认模型能解释已有观测,没有明显漏检、错序或不合理参数。它是必要检查,但不能被写成"测量精度认证"。

7.2 第二层:固定内参后的新观测

使用没有参与模型选择和异常筛选的新图,覆盖新的位置或姿态。若需要重新估计外参,就把这点写进验证方法:验证指标是在"允许新外参拟合"的条件下得到的。对更严格的测试,可用一部分点估计姿态,再检查预先留出的其他点,仍需注意这些点可能共享标定板几何偏差。

把同一张照片的角点随机分两组,与使用独立采集、独立条件的数据不是同一种验证。前者仍可能共享曝光、板面形变和检测偏差,结果应按其实际独立程度解释。

7.3 第三层:物理尺度与最终任务验证

先定义任务:测的是平面长度、物体姿态、轴向深度,还是双目三维点?再选择独立参考与工作范围。一个单目像素通常只约束一条视线,没有额外平面、已知物体几何、深度或多视角条件,不能直接唯一恢复任意三维点。

按实际工作距离、图像位置、姿态和设备状态采集验证数据,记录每次误差。除了平均绝对误差,也应观察有符号偏差、波动、长尾与重复性,并说明参考自身的不确定性。不存在一个对所有任务都适用的"0.2 像素合格"阈值。

7.4 重复性好,不等于结果接近真值

如果尺子本身短了,每次量出来都一致,结果仍然可能偏离真实值。重复采集可以观察波动,独立物理参考则帮助观察偏差。两类检查应同时存在,不宜用"连续十次几乎一样"替代准确度验证。

八、把标定结果做成可追溯的工程数据

信息类别 建议保存的内容
硬件与状态 相机、镜头和装配标识;对焦、变焦、光圈及相关状态
图像坐标 宽高、ROI、裁切/缩放映射、原始或去畸变空间
模型与参数 K、畸变模型及系数顺序、flags、优化停止条件
标定板 图案、内角点数、点序、物点坐标、单位和尺寸来源
数据与结果 原图或可追溯索引、角点、剔除理由、逐图与整体误差
验证与版本 独立验证方法和结果、适用条件、软件版本、日期

如果要在流程中自动选用标定文件,应检查它与当前设备和图像模式是否匹配。文件存在不等于文件适用。对超出验证范围或状态已经变化的情况,产品应有清楚的处理方式,而不是继续输出看似精确的小数。

同样,数值优化输出的标准差或不确定性估计也依赖模型与观测假设,不能自动覆盖标定板公共尺度错误、镜头状态漂移和下游算法误差。对这些量的解释,应回到其计算条件与验证范围。

九、一张实用排错表

现象 优先提出的问题 下一步验证
所有距离大致按比例偏 物点尺度、单位或双目基线是否有误? 独立长度参考;核对格距与 mm/m
中心好、边缘差 覆盖不足、模型或像素映射是否有问题? 边缘独立观测与残差图
换分辨率后突然偏 ROI/缩放与 K 是否匹配? 用已知像素映射验证 K 更新
去畸变后 PnP 更差 点的坐标空间与 K、dist 是否配套? 分别验证原始与去畸变链路
静态好、运动时差 同步、曝光或滚动快门因素是否介入? 受控运动与时间同步测试
重复结果稳定但偏 是否存在公共系统偏差? 独立物理参考,而非只重复采样
训练分数下降但测量变差 是否过拟合或筛选改变了覆盖? 固定独立验证集,比较整条任务指标

这些是排查入口,不是由症状直接确认根因。多个误差可以同时存在,单次修正后还应复查其他位置和条件,避免把某个点调准当成整个工作范围已经验证。

结语:先问误差测量了什么,再问它有多小

重投影误差是相机标定中有价值的指标,但它不是所有物理测量误差的总和。本文的尺度反例说明,即使训练图与新视角的像素拟合都很好,毫米尺度仍可能错误。

可靠的工程流程应把拟合检查、新观测验证和独立物理验证分开记录,再连接到目标任务。比继续压低一个分数更有价值的,是知道它能证明什么、不能证明什么,以及哪些条件变化后需要重新验证。

参考资料

OpenCV:Camera Calibration and 3D Reconstruction

OpenCV:Camera calibration With OpenCV

OpenCV:Python Camera Calibration

OpenCV:PnP pose computation

张正友平面标定方法:Microsoft Research 出版物入口

写在最后:把验证做扎实,也是一种进步

"If I have seen further it is by standing on the sholders of Giants."

------ Isaac Newton(艾萨克·牛顿)

译意:"如果我看得更远,那是因为我站在巨人的肩膀上。"引文保留历史拼写,出处:牛津大学 Newton Project。理解前人的方法,亲手复现一个反例,再把每次验证记录清楚,技术就能一步步走得更稳。

相关推荐
goehou1 小时前
流式输出实战:SSE 解析、chunk 拼装与断流重试
python·ai·llm·sse·流式
SamChan901 小时前
图片翻译后文字挤出边框?用 Pillow 测量译文,不再靠字符数猜宽度
图像处理·python·pillow·本地化·图片翻译
2601_962780911 小时前
2027风险管理岗准备路线:数学与应用数学专业如何补齐金融、SQL与Python
python·sql·金融
老唐7771 小时前
Transformer原理大白话讲解
人工智能·python·深度学习·神经网络·计算机视觉·ai·transformer
佳木逢钺1 小时前
从火灾模拟到系统热寂:一个双非运维如何用零 GPU 推导出多智能体系统的终极死锁
人工智能·python·算法·多智能体
fwc2 小时前
第T9周:猫狗识别2
python·深度学习
砚底藏山河2 小时前
数据新鲜度 SLA 监控
java·大数据·python·maven
Wzx1980122 小时前
Python Agent 短期记忆设计:从对话历史到 LangGraph Checkpointer
开发语言·python