OpenCV单目相机标定全流程:从棋盘格检测到重投影误差分析
2026/9/12 15:43:12 网站建设 项目流程

简介:面向计算机视觉入门学习者的相机内参标定配套资源,以棋盘格为标定板,通过Python脚本实现单目相机标定,适合需要动手理解焦距、主点与畸变系数求解过程的读者,也是机器人视觉、自动驾驶等应用方向的基础练习。压缩包共6个文件,包含5张不同角度和距离拍摄的棋盘格图像和1个Python脚本,整体仅1.12MB,图像供角点检测与标定实验使用,脚本则覆盖图像处理、角点提取、参数解算与畸变校正的完整流程。目前已有1063人学习浏览,可作为课堂实验或自学项目的参考资料。通过这套小规模资源,读者可快速复现棋盘格角点检测、世界坐标与像素坐标对应、重投影误差最小化等关键步骤,并得到可保存的相机内参与畸变系数,为后续三维重建与视觉应用打下基础。

1. 为什么棋盘格标定不能只看重投影误差

很多项目把“相机内参标定”做成一行calibrateCamera调用,看到重投影误差 0.2 就交给下个环节。但同一组棋盘格图片换一个标定库,或换一套角点检测参数,结果可能差出几个像素;新拍一个角度,误差立刻反弹。真正决定标定质量的,是角点检测的亚像素精度、图片姿态覆盖、畸变模型阶数和验证方法。这篇博客从针孔成像模型开始,把 OpenCV 单目棋盘格标定的完整流程拆开,并给出可直接运行的代码与参数边界。适合第一次系统接触标定的开发者,也适合手里已经有一组内参、却不确定该不该信的工程师。

2. 相机内参与畸变模型:标定到底在求什么

2.1 针孔模型与内参矩阵 K

标定的目标不是算出一个“看起来能用的矩阵”,而是估计相机的固有属性:焦距、主点、畸变系数。三维空间点 P 先经外参 [R|t] 变换到相机坐标系,再投影到像素平面:

lambda * [u, v, 1]^T = K * [R | t] * [X, Y, Z, 1]^T

K 是 3x3 内参矩阵。fx、fy 分别表示焦距在 x/y 方向的像素当量,物理含义是镜头焦距除以单个像素的长宽,方形像素下两者近似相等,但不要假设相等。cx、cy 是主点坐标,即光轴穿过像平面的位置。短焦镜头或批量组装的模组,主点偏离画面中心 5~10 个像素很常见,直接固定为图像中心会带来边缘误差。

实践中,我通常会把重投影过程包装成一个函数,方便后续逐图分析误差:

import cv2 import numpy as np def project_board_points(obj_pts, rvec, tvec, k_mat, dist): """ obj_pts: (N,3) 棋盘格对象点 rvec, tvec: 当前视角的旋转向量和平移向量 k_mat: 3x3 内参矩阵 dist: 畸变系数,OpenCV 顺序为 k1,k2,p1,p2,k3 """ img_pts, _ = cv2.projectPoints(obj_pts, rvec, tvec, k_mat, dist) return img_pts.reshape(-1, 2)

cv2.projectPoints会先使用旋转向量和平移向量把对象点变换到相机坐标系,再对归一化坐标做畸变映射,最后用内参矩阵投影到像素平面。这个包装函数让第 4 章的逐图误差统计变得干净,也能直接用来做标定结果的离线验证。注意传入的obj_pts是 float32,k_mat要用 double,否则个别 OpenCV 版本会因为类型不匹配抛异常。

2.2 棋盘格作为标定靶的几何依据

棋盘格之所以成为单目标定的默认靶,不只是因为角点好找。角点是两个黑白区域的公共顶点,在局部图像上能产生很强的梯度响应,可以稳定收敛到亚像素位置;相比之下,圆点图案在透视投影下会变成椭圆,圆心检测还需要额外做椭圆拟合补偿。

另一个关键点是棋盘格是平面靶,所有对象点第三维坐标固定为 0。每一个视角的投影关系由此坍缩成一个单应性矩阵 H,标定过程实际上是约束不同视角单应矩阵之间的一致性。平面靶有一个已知退化场景:如果所有拍摄都只绕同一根轴旋转,比如只左右转动相机,那么对 fx 和 fy 的约束会变弱,重投影误差看起来不高,内参却不可信。这也是后文采集规范要求“倾斜加旋转”的原因。

2.3 畸变模型与 OpenCV 参数顺序

镜头畸变分为两类。径向畸变由镜片曲率引起,表现为直线向中心或边缘弯曲,用 k1、k2、k3 描述;切向畸变由镜头与像面不平行引起,用 p1、p2 描述。OpenCV 标准模型下 dist 向量的排列顺序是:

(k1, k2, p1, p2, k3)

畸变后坐标可以写成:

x_dist = x * (1 + k1*r^2 + k2*r^4 + k3*r^6) + 2*p1*x*y + p2*(r^2 + 2*x^2) y_dist = y * (1 + k1*r^2 + k2*r^4 + k3*r^6) + p1*(r^2 + 2*y^2) + 2*p2*x*y

这里的 x、y 是归一化像平面坐标,不是像素坐标。k3 只在图像边缘贡献显著,普通镜头往往接近 0,图片数量不够时强行估计 k3 容易过拟合。鱼眼镜头则需要换cv2.fisheye模型,参数定义与普通针孔模型完全不同。常用参数的量级见下表,可作为标定结果的 sanity check:

参数索引物理含义常见量级
fx, fyK[0,0], K[1,1]焦距像素当量300 ~ 3000
cx, cyK[0,2], K[1,2]主点坐标图像中心附近
k1, k2dist[0], dist[1]径向畸变系数-0.5 ~ 0.5
p1, p2dist[2], dist[3]切向畸变系数-0.05 ~ 0.05
k3dist[4]三阶径向系数通常小于 0.1

2.4 模型复杂度与图片数量要匹配

重投影误差一定会随参数增多而下降,但泛化能力不一定提升。10 张图以内建议只估计 k1、k2、p1、p2;超过 20 张且覆盖了画面边角,再考虑 k3。还有一点容易被忽略:如果把主点、焦距、切向畸变同时放开,它们之间存在较强的相关性,小样本下会有多组解同时满足误差约束。所以第 4 章的流程会先跑默认参数,再根据误差分布决定是否固定某些自由度。

3. 棋盘格角点检测与亚像素细化:误差的第一道关口

3.1 findChessboardCorners 的 flags 如何选

OpenCV 提供两套检测入口。经典接口是findChessboardCorners,对光照均匀的清晰图片效果稳定;新接口findChessboardCornersSB基于梯度方向,对压缩噪声和失焦更耐受,但计算稍重。先看经典接口的常见用法:

found, corners = cv2.findChessboardCorners( gray, (9, 6), flags=cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE | cv2.CALIB_CB_FILTER_QUADS )

flags 中ADAPTIVE_THRESH对每个局部区域做自适应二值化,处理光照不匀;NORMALIZE_IMAGE先做全局直方图拉伸,低对比度图片优先调它;FILTER_QUADS过滤掉不是凸四边形的候选块,减少误检。三者组合是标定脚本里最常见的配置。如果仍然失败,我会先换成findChessboardCornersSB(gray, (9, 6)),不再手动cornerSubPix,因为 SB 返回的已经是浮点亚像素坐标。

需要特别注意,pattern size 填的是内角点数量而不是格子数量。比如 9x6 表示有 9 列内角、6 行内角,对应 10x7 个格子。填成 10x7 是新手最常见的报错来源,因为图里根本没有那么多角点。不同素材下 API 选择参考下表:

素材状态推荐接口备注
光照均匀、图片清晰findChessboardCorners速度最快,参数稳定
弱光、压缩图、模糊findChessboardCornersSB梯度自适应更强
标定板带二维码 markerfindChessboardCornersSB + CALIB_CB_MARKER角点排序更稳定
极端广角边缘畸变过大先缩放到小图检测检测成功后再映射回原分辨率

3.2 cornerSubPix 亚像素细化

经典接口返回的角点坐标是整数像素。直接进入标定,重投影误差的下限大约在 0.3 到 0.5 像素;做一次亚像素细化,通常能把角点定位误差压到 0.1 像素以内。细化调用如下:

criteria = (cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 30, 0.001) corners_refined = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)

winSize是搜索窗口半径,11x11 表示以初值为中心的 11x11 邻域参与梯度统计。窗口太小在模糊图上不收敛,太大则相邻角点梯度互相干扰。zeroZone传 (-1,-1) 表示不使用零区域,让所有邻近像素参与计算。criteria的 30 是最大迭代次数,0.001 是角点位置变化的终止精度,一般不需要更严格。

如果细化后角点出现偏移,常见原因是细化用的 gray 图和检测时不是同一张图,比如检测用了增强图、细化用了原始灰度图。务必保证两次调用使用同一个灰度数据。

3.3 对象点顺序与棋盘格索引一致

标定流程中对象点生成看起来简单,但顺序错误会导致误差偏高且无法通过调参数修复。OpenCV 的角点排序是行优先,从图像左上角开始逐行扫描内角点:

objp = np.zeros((cols * rows, 3), np.float32) objp[:, :2] = np.mgrid[0:cols, 0:rows].T.reshape(-1, 2)

mgrid生成了 cols x rows 的网格坐标,第 0 维是列索引,第 1 维是行索引,reshape 后正好对应角点检测输出的从左到右、从上到下顺序。如果把 cols 和 rows 写反,标定不会报错,但外参会扭曲、内参误差增大。一个可靠的检查方式是cv2.drawChessboardCorners把角点画回图像,观察角点编号是否蛇形跳跃。

3.4 一张图一个函数:检测与日志

下面这段是项目里常用的检测封装,把对象点生成、SB 检测、失败日志放在一起:

import cv2 import numpy as np CHESSBOARD = (9, 6) # 内角点列数, 行数 SQUARE_MM = 25.0 # 实际棋盘格边长,单位毫米 def detect_corners_from_paths(paths): obj_points = [] img_points = [] for p in paths: img = cv2.imread(p) if img is None: print("[warn]", p, "读取失败") continue gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ok, corners = cv2.findChessboardCornersSB(gray, CHESSBOARD, flags=cv2.CALIB_CB_MARKER) if not ok: print("[skip]", p, "未检测到完整棋盘格") continue objp = np.zeros((CHESSBOARD[0] * CHESSBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHESSBOARD[0], 0:CHESSBOARD[1]].T.reshape(-1, 2) * SQUARE_MM obj_points.append(objp) img_points.append(corners) print("[ok]", p, "角点数", len(corners)) return obj_points, img_points

这段代码把 3.1 和 3.3 的内容合并成一个可复用函数。SB 检测在角点排序上更稳定;SQUARE_MM在这里乘到对象点上,单位只影响外参 tvec 的尺度,不影响内参。如果只关心内参,SQUARE_MM设为任意正数都可以;如果要和手眼标定或三维重建衔接,建议使用真实毫米值。

4. 单目相机内参标定完整流程:从图片序列到 camera_calib.yml

4.1 图片采集:数量、覆盖和姿态缺一不可

标定算法再稳也弥补不了糟糕的输入图集。像 camera.zip 这类压缩包解开往往是 01.png 到 05.png 的散图,我建议先把文件统一归到 images 目录再跑 glob 脚本。图片集需要满足三条:

  • 数量:15 到 25 张。少于 10 张,外参和内参的耦合约束不足;超过 40 张收益明显下降,异常图片混入的成本升高。
  • 覆盖:棋盘格尽量出现在画面中心和四个角。如果只在中间平移拍摄,畸变系数几乎无法约束,模型只能靠先验猜。
  • 姿态:绕相机光轴旋转、绕水平轴和垂直轴倾斜 20 到 40 度。纯正对前后移动只改变棋盘格尺度,对 fx、fy 和畸变的约束非常弱。

棋盘格在画面中最好占 1/4 以上面积,且四周留出一些黑色背景,否则最外圈的角点容易落在图像外或贴边,检测会直接失败。

4.2 calibrateCamera 的调用与 flags 选择

采集完成后的主流程比较固定,先检测角点再标定。完整可运行代码如下:

import cv2 import numpy as np import glob CHESSBOARD = (9, 6) SQUARE_MM = 25.0 objp = np.zeros((CHESSBOARD[0] * CHESSBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHESSBOARD[0], 0:CHESSBOARD[1]].T.reshape(-1, 2) * SQUARE_MM obj_points, img_points = [], [] image_size = None for path in sorted(glob.glob("images/*.png")): img = cv2.imread(path) if img is None: continue gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if image_size is None: image_size = (gray.shape[1], gray.shape[0]) ok, corners = cv2.findChessboardCorners( gray, CHESSBOARD, flags=cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE) if not ok: print("[skip]", path) continue criteria = (cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 30, 1e-3) corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners) if len(obj_points) < 5: raise RuntimeError("有效图片不足 5 张,请检查棋盘格大小或拍摄质量") ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, image_size, cameraMatrix=None, distCoeffs=None, flags=0) print("reprojection error:", ret) print("camera matrix:\n", mtx) print("dist:\n", dist.ravel())

参数说明:cameraMatrixdistCoeffs传 None 表示让算法自动估计全部内参;flags=0等同于默认约束。image_size是像素尺寸,必须与实际图片一致,如果混入不同分辨率的图片,后面的结果没有意义。rvecstvecs是每张图的姿态外参,工程上通常不需要保存,但会用在逐图误差分析里。

calibrateCamera内部使用 Levenberg-Marquardt 迭代,初始值由 DLT 算法提供。如果输出 fx 为负或主点跑出图像范围,优先检查角点顺序和图集覆盖,而不是怀疑迭代没有收敛。不同场景下 flags 的参考选择如下:

flag作用建议使用场景
0全参数自由估计第一次标定、不确定相机内部结构
CALIB_ZERO_TANGENT_DIST切向畸变固定为 0工业镜头装配良好、图片数少
CALIB_FIX_PRINCIPAL_POINT主点固定在图像中心sensor 中心已知且不想引入自由度
CALIB_FIX_K3固定 k3=0普通镜头且图集少于 20 张
CALIB_RATIONAL_MODEL8 参数畸变模型大视场畸变镜头,需要更多图片

4.3 逐图重投影误差分析与剔除异常图片

全局误差只是一个均值,单张图片误差更能暴露问题。比如快门较慢导致的模糊角点,或高光区域导致的角点偏移,都会体现为个别图片误差明显偏大。逐图统计方法如下:

errors = [] for i in range(len(obj_points)): proj, _ = cv2.projectPoints(obj_points[i], rvecs[i], tvecs[i], mtx, dist) diff = img_points[i].reshape(-1, 2) - proj.reshape(-1, 2) errors.append(np.mean(np.linalg.norm(diff, axis=1))) print("per-image errors:", np.round(errors, 3))

单张误差超过中位数的 1.5 倍时,我会把这张图剔除后重新标定,而不是直接复用旧mtxdist。判定标准上,平均重投影误差小于 0.3 像素是理想状态,0.3 到 0.8 像素在普通镜头里可接受,超过 1 像素就要回到角点检测阶段排查。注意误差单位是像素,不同分辨率之间的数值不能直接比较。

4.4 保存 YAML 并接入工程

标定结果通常要跨语言使用,我会同时保存 npz 和 yml 两种格式:

np.savez("camera_calib.npz", mtx=mtx, dist=dist, image_size=image_size) fs = cv2.FileStorage("camera_calib.yml", cv2.FILE_STORAGE_WRITE) fs.write("camera_matrix", mtx) fs.write("dist_coeffs", dist) fs.write("image_width", image_size[0]) fs.write("image_height", image_size[1]) fs.release()

yml 用 OpenCV FileStorage 写入,C++ 和 Python 都能读;npz 适合在 Python 原型里快速加载。读取 yml 时注意 dist 的 shape,写入后读出来可能是 (1,5) 或 (5,1),使用前统一np.ravel()再 reshape。VisionMaster、Halcon 这类视觉软件也接受相机内参输入,参数顺序仍是 k1、k2、p1、p2、k3,只是界面里把字段拆开了。

5. 验证标定结果的三个动作和两个工程细节

5.1 动作一:用未参与标定的图做直线验证

内参好坏的最终标准是去畸变后的图像几何是否变直。拿一张没有参与标定、包含直线的场景图做cv2.undistort,然后在边缘上用 Canny 和 HoughLinesP 提取直线,计算拟合点到直线的最大偏差。偏差超过 2 像素,说明畸变模型残余还比较明显。

newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h)) undistorted = cv2.undistort(img, mtx, dist, None, newcameramtx)

alpha=1保留全部像素,适合验证;alpha=0会裁剪黑边,适合实际部署时扩大有效视场。两者对应的映射矩阵不同,验证和部署最好保持一致。

5.2 动作二:子集标定看主点稳定性

从 20 张图里随机抽 10 张标定一次,重复几次。观察 cx、cy 的波动,如果在 10 个像素以内说明主点约束充分;漂移太大则说明棋盘格在画面中央的图片太多,四角覆盖不足,畸变和主点互相抢参数。此时再去调迭代精度意义不大,应该补充边角图片。

5.3 动作三:观察去畸变后的棋盘格网格变形

选一张倾斜角度大的标定图,去畸变后把角点连线画出来。所有小格边长应接近一致,边长差超过 5% 说明标定结果对画面外推不可靠。这个检验直观且不需要额外标定板。

5.4 工程细节:标定板平整度与分辨率绑定

打印纸直接贴软木板是不可取的,纸张热胀冷缩会带来表面起伏,角点位置系统性偏移。常见做法是 3mm 亚克力板贴哑光棋盘格,工业上也有陶瓷材质。内参严格绑定输入分辨率,1920x1080 的结果缩放到 1280x720 使用时,fx、fy、cx、cy 可以按比例缩放做近似,但畸变系数不能简单照搬,最稳妥是重新采集对应分辨率的标定图。如果连相机 sensor 都换了,哪怕像元尺寸只差零点几微米,旧内参也要作废,重标定比迁移省事得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询