相机标定结束后,程序输出一个很小的误差: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
张正友平面标定方法:Microsoft Research 出版物入口
写在最后:把验证做扎实,也是一种进步
"If I have seen further it is by standing on the sholders of Giants."
------ Isaac Newton(艾萨克·牛顿)
译意:"如果我看得更远,那是因为我站在巨人的肩膀上。"引文保留历史拼写,出处:牛津大学 Newton Project。理解前人的方法,亲手复现一个反例,再把每次验证记录清楚,技术就能一步步走得更稳。