简介:基于OpenCV的相机标定完整工程,围绕张正友标定法展开,面向计算机视觉初学者和需要相机内外参求解、畸变矫正的开发者,解决标定流程中参数计算与图像去畸变问题。工程包含完整C++源码、VS解决方案及棋盘图,可一键运行,实现相机标定、输出内参外参与旋转平移矩阵、标定效果评价,并利用标定结果对原始棋盘图进行矫正。压缩包为RAR格式,共61个文件,大小17.07MB,主要含C++源文件、Visual Studio工程文件、14张JPG与14张BMP棋盘标定图、编译生成的exe及中间文件,并附带标定输入与结果文本,代码与工程结构清晰,便于直接运行和二次开发。目前已有2057人学习,适合对照代码理解张正友标定从角点提取、单应矩阵计算到内参外参求解、畸变系数优化的完整流程。 做视觉项目绕不开相机标定。不管你是做机器人抓取、AR叠加、三维重建,还是简单做个畸变矫正,第一步都是把相机内部参数搞清楚。OpenCV里集成的张正友标定法,是最经典、也是上手最快的一种方案:只要拿一块棋盘格,拍十几张不同角度的照片,跑一段代码就能得到相机内参和畸变系数。这篇文章我就把“棋盘图生成 + 拍照采集 + 代码实现 + 误差评估”整个流程完整拆开讲,附带可以直接跑的Python代码。适合刚接触计算机视觉的初学者,也适合项目中需要自己完成相机标定的开发者参考。
很多人在这一步会卡住:标定板上场就检测不到角点、标定结果重投影误差大、或者拍了一堆图却不知道怎么筛选。这些问题我都会在后面的实操环节里逐一说明。先把最关键的事情说清楚——张正友标定法到底在求什么,又是怎么求出来的。
1. 张正友标定法的原理:为什么棋盘格能标定相机
1.1 标定到底在求什么
相机成像可以理解成把三维世界坐标映射到二维像素坐标。这个过程里有两个“中间商”:一个是相机内部本身的属性,比如焦距、光心位置、像素倾斜程度,这些叫内参;另一个是相机相对于世界坐标系的位置和姿态,也就是旋转和平移,这些叫外参。除此之外,镜头还会引入畸变,最常见的是径向畸变和切向畸变。
张正友标定法的核心目标,就是通过若干张已知图案的照片,把这几个东西全部估计出来。所谓“已知图案”,就是棋盘格——黑白格子交替排列,每个格子的物理尺寸我们知道,比如一格是30毫米,那么棋盘上任意两个角点的三维相对位置就是确定的。既然位置已知,图案又被相机拍成了二维图像,那么图案点和图像点之间就建立了一组“对应关系”。多组对应关系组合起来,就是一个可以解的数学方程系统。
这里有个容易混淆的点:标定求出的内参并不是“测出来的”,而是“算出来的”。相机在出厂时有个理论焦距,但实际镜头安装、光学结构都存在公差,理论值并不准。标定做的事情,就是通过拍棋盘,反推出一组让“三维点投影到像素坐标”这个过程误差最小的内参和畸变参数。
1.2 棋盘格为什么是标定界的“标准答案”
棋盘格在标定场景里几乎是无敌的存在,原因可以归纳为三点。
第一,角点特征极其明显。棋盘格里黑白方块的交界处,就是“角点”。这些点在图像里表现为灰度值的剧烈变化,算法提取时非常稳定,而且亚像素级别的精度也比较容易达到。相比之下,圆点标定板虽然也能用,但在倾斜角度较大时,圆形会变成椭圆,圆心提取的精度会受影响,棋盘格的角点则没有这个问题。
第二,图案信息“自描述”。只要知道棋盘内角点的行列数(比如9×6,指内部角点数量,不是格子数量)和单个格子的物理尺寸,就能完整还原出所有角点的三维坐标。不需要额外设计复杂的编码图案,因为它不需要区分“哪个角点是哪个”,只需要按照行列顺序逐一对应就行——前提是拍照时棋盘没有旋转超过90度导致顺序错乱。
第三,算法成熟,工具链完备。OpenCV里专门封装了找棋盘角点的函数,从findChessboardCorners到cornerSubPix再到calibrateCamera,整条流水线开箱即用。这也是大多数项目选择棋盘格而不是其他标定图案的直接原因。
1.3 数学流程:从单应矩阵到内外参分解
张正友标定法的数学流程,可以拆成三个关键步骤,理解这三步,代码里的每个参数你就能真正看懂了。
第一步,求解单应矩阵。把棋盘格放在一个平面上,这个平面在某个世界坐标系下可以视为Z=0。此时,三维点投影到图像像素坐标的变换,就退化成了一个3×3的单应矩阵H。每张棋盘照片都能估计出一个H。这个H里同时混着内参和外参的信息,但它是一步到位能算出来的中间量。
第二步,从单应矩阵分解出内参。由于旋转矩阵有“列向量相互正交且模长为1”的约束条件,把这些约束代入单应矩阵的表达式,就能列出关于内参的方程组。理论上拍两三张不同角度的照片就够解了,但实际中我们通常拍15到20张,引入大量冗余数据来抑制噪声。
第三步,估计畸变系数并整体优化。先用线性方法求出内参和外参的初始值,再把畸变参数加进来,把“三维点投影到像素坐标”的误差作为目标函数,用非线性最小二乘(OpenCV里用的是Levenberg-Marquardt算法)迭代优化所有参数。最终得到的,就是那组让重投影误差最小的参数组合。
提示:张正友标定法的中文名经常被打成“张正有标定法”,搜代码的时候两种写法都会出现,原理上指的是同一个算法。论文原题是《A Flexible New Technique for Camera Calibration》,1998年发表的。
2. 标定前的准备:棋盘图生成、打印与拍摄
2.1 用代码生成一张标准棋盘图
市面上的棋盘图模板质量参差不齐,有的没有留白边,有的网格比例不对。最靠谱的方式是自己用代码生成。下面这段代码用OpenCV生成一张8×6内角点(即9×7个格子)的棋盘图,单格尺寸对应打印后为30mm,A4纸打印即可。
import cv2 import numpy as np # 内角点数 rows=6, cols=8,对应格子为 7x9 rows = 6 cols = 8 square_size = 100 # 像素单位,打印时再按实际尺寸换算 # 图像尺寸:格子数*单格尺寸 + 四周留白 margin = 200 img_h = rows * square_size + margin * 2 img_w = cols * square_size + margin * 2 board = np.full((img_h, img_w), 255, dtype=np.uint8) for i in range(rows + 1): for j in range(cols + 1): if (i + j) % 2 == 0: y1 = margin + i * square_size y2 = margin + (i + 1) * square_size x1 = margin + j * square_size x2 = margin + (j + 1) * square_size board[y1:y2, x1:x2] = 0 cv2.imwrite("chessboard.png", board)这里的关键参数是rows和cols,它们表示的不是黑白格子的数量,而是内部角点的数量。比如8×6的意思,就是横向有8个内部角点、纵向有6个内部角点,对应格子是9×7。这个数字在后续代码里还要用到,两处必须保持一致,否则findChessboardCorners永远找不到角点。
生成图片后,打印时要注意两点:一是尽量用激光打印机,黑白格边缘更锐利,喷墨容易洇墨导致边缘发虚;二是把图片打印到硬纸板或铝板上,保持棋盘平面平整,不要用软纸,尤其是不要在标定过程中弯折它。棋盘弯曲是标定误差的一大来源,而且很难事后排查出来。
2.2 拍摄采集的实操要点
棋盘图准备好之后,拍摄这一步决定了标定质量的上限。拍得太随意,后面代码再优化也救不回来。我自己的经验是遵循几个原则。
数量上,至少拍15张,建议20张左右。太少的话方程约束不足,尤其是畸变参数很难收敛;太多则增加筛选成本,边际收益递减。
角度上,需要覆盖“正对、左右倾斜、上下倾斜、旋转”四种姿态。正对拍几张用来确定焦距和光心的大致位置,然后让棋盘在画面中分别左右倾斜、上下倾斜、绕光轴旋转一定角度。倾斜角度从0度到45度之间分布,但尽量不要出现超过60度的大倾斜,因为大角度下角点检测的亚像素精度会下降。
位置上,让棋盘出现在画面的不同区域——左上角、右下角、中心、边缘都要有覆盖。很多人喜欢把棋盘放在画面正中间拍20张,这会导致边缘区域的畸变参数估计不足。相机标定是一个全局拟合的过程,画面边缘的棋盘照片对畸变系数的约束尤其重要。
还有一个细节:拍摄时棋盘要静止。手持棋盘轻微晃动会导致运动模糊,角点位置提取不准。光线条件也尽量均匀,避免强反光和阴影遮挡格子。如果有条件,用三脚架固定相机、手持棋盘变换姿态,比手持相机去拍固定棋盘更容易控制质量。
2.3 环境快速安装
代码只需要OpenCV和NumPy,安装命令很简单:
pip install opencv-python numpy如果你用的是Anaconda,也可以:
conda install -c conda-forge opencv提示:如果只是做标定和图像处理,安装
opencv-python就够了,不需要装opencv-contrib-python,后者主要是给SIFT、SURF等需要额外授权的算法用的,体积更大但标定用不上。
3. 核心代码实现:角点检测、标定与去畸变
3.1 角点检测:从findChessboardCorners到亚像素细化
角点检测是整条流水线的第一环,也是最容易出问题的一环。OpenCV提供了一个组合拳:先用findChessboardCorners做初步检测,再用cornerSubPix优化到亚像素精度。
import cv2 import numpy as np import glob # 棋盘内角点数,必须与棋盘图一致 pattern_size = (8, 6) # 存放角点像素坐标和对应世界坐标 obj_points = [] # 世界坐标系下的三维点 img_points = [] # 图像坐标系下的二维点 # 生成棋盘格角点的世界坐标(Z=0平面),单位设为格子边长 objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) images = glob.glob("calib_images/*.jpg") for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 查找棋盘角点 ret, corners = cv2.findChessboardCorners(gray, pattern_size, None) if ret: # 亚像素细化,提高角点精度 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_refined = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners_refined) # 可视化角点,确认检测正确 vis = cv2.drawChessboardCorners(img, pattern_size, corners_refined, ret) cv2.imshow("corners", vis) cv2.waitKey(200) cv2.destroyAllWindows() print(f"成功检测到角点的图片数: {len(img_points)} / {len(images)}")findChessboardCorners传入的pattern_size必须和你棋盘的实际内角点数完全一致。经常有人问“为什么我检测不到角点”,八成是这里写错了——比如实际棋盘是9×7个格子,内角点是8×6,代码里却写了(9, 7)。另外,图像不能太大,如果单张图片分辨率超过2000万像素,建议先缩小再检测,否则检测速度很慢,而且在大图上findChessboardCorners的鲁棒性反而可能下降。
cornerSubPix的窗口大小(11, 11)是经验值,它基于角点邻域的灰度梯度来精确定位亚像素位置。窗口太小噪声大,太大又可能跨到相邻格子。实际测试下来11×11的窗口在大多数场景下表现最稳。
3.2 标定主流程:calibrateCamera与参数解读
角点收集完毕,核心调用只需要一行calibrateCamera:
# 相机标定 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) print("内参矩阵 mtx:") print(mtx) print("\n畸变系数 dist (k1, k2, p1, p2, k3):") print(dist) print(f"\n重投影误差: {ret}")返回值里每个参数的含义要搞清楚:
mtx:3×3内参矩阵,形式是[[fx, 0, cx], [0, fy, cy], [0, 0, 1]]。fx和fy分别是x、y方向上的焦距(像素单位),cx和cy是光心坐标。注意fx并不是物理焦距,而是“物理焦距×单位像素尺寸的倒数”,所以不同分辨率的相机,fx数值没有直接可比性。dist:畸变系数向量。前两个是径向畸变k1, k2,中间两个是切向畸变p1, p2,最后一个是高阶径向畸变k3。如果不启用CALIB_RATIONAL_MODEL,k3通常不会被估计,向量长度为4。rvecs和tvecs:每张图片对应的外参,即棋盘平面在相机坐标系下的旋转向量和平移向量。ret:重投影误差,单位是像素,这个数值直接反映标定质量,后面细说。
内参矩阵里还有一个容易被忽略的点:gray.shape[::-1]传的是图像尺寸(width, height),顺序不能反。OpenCV里图像shape是(h, w),而标定函数要求的是(w, h),这里写反了会得到完全错误的结果,而且不会有任何报错。
3.3 重投影误差:标定质量的唯一硬指标
calibrateCamera返回的ret就是重投影误差(RMS reprojection error),它计算的是:用标定出来的内参外参,把世界坐标系下的棋盘角点重新投影到图像平面上,与检测到的角点像素坐标之间的平均距离误差。
这个数字是判断标定质量最直接的指标。一般来说:
- 小于0.1像素,非常优秀,说明图片质量和角点检测都很好;
- 0.1到0.3像素,正常水平,多数项目可直接使用;
- 0.3到0.5像素,可以接受,但需要检查是否有模糊或畸变严重的图片混入;
- 大于0.5像素,说明有问题,要么是棋盘不平整,要么是某几张图角点检测错了,需要重新拍摄。
如果总误差偏大,可以用一个循环逐张计算每张图片的单独误差,把最差的那几张挑出来删掉再重新标定:
total_error = 0 for i in range(len(obj_points)): imgpoints2, _ = cv2.projectPoints(obj_points[i], rvecs[i], tvecs[i], mtx, dist) error = cv2.norm(img_points[i], imgpoints2, cv2.NORM_L2) / len(imgpoints2) total_error += error print(f"第 {i+1} 张图误差: {error:.4f} px") print(f"平均误差: {total_error / len(obj_points):.4f} px")这个逐张误差分析很有用。有时候20张图里有1张拍糊了,整体误差会被拉高,但你看总体数字又没那么明显。逐张打印出来后,直接删掉误差明显偏大的图片重新标定,质量立刻上一个台阶。
3.4 畸变矫正与去畸变
标定完后,畸变矫正就是最终目的。OpenCV提供了两个常用函数:undistort和remap。
# 方式一:一步到位 dst = cv2.undistort(img, mtx, dist, None, mtx) # 方式二:先算映射表,再重映射(批量处理时更快) mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, mtx, (w, h), cv2.CV_32FC1) dst = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)如果你的应用场景是对视频流逐帧去畸变,建议用方式二:映射表只需要计算一次,之后每帧用remap处理,速度比undistort快很多。
这里有个细节值得注意:undistort之后,图像边缘区域会出现黑色的无效区域,因为畸变矫正本质上是“把弯曲的部分拉直”,边缘像素会被外推。如果你做的是视觉测量项目,需要保证矫正后的图像不被拉伸变形,建议用getOptimalNewCameraMatrix计算裁剪后的内参:
h, w = img.shape[:2] newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h)) # 用新内参去畸变 dst = cv2.undistort(img, mtx, dist, None, newcameramtx) # 裁剪掉黑色区域 x, y, w_roi, h_roi = roi dst_cropped = dst[y:y + h_roi, x:x + w_roi]alpha参数控制保留多少无效像素:alpha=1保留所有像素(含黑边),alpha=0裁剪到不留黑边,alpha在0到1之间则做折中。对于视觉测量项目,alpha=0通常就够了,因为黑边本来就不可用。
4. 常见问题与排查技巧实录
4.1 角点检测不到?从这几个方向排查
findChessboardCorners返回False是新手遇到最多的问题。我整理过一个排查表,按概率排序:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 所有图片都检测不到 | pattern_size与棋盘实际内角数不一致 | 数清楚棋盘内角点,修正代码参数 |
| 部分图片检测不到 | 棋盘倾斜角度过大或边缘被截断 | 调整拍摄角度,保证所有角点在画面内 |
| 图片模糊导致检测失败 | 快门过慢或手抖 | 提高快门速度,或用三脚架固定相机 |
| 图像反光强烈 | 光照不均匀或棋盘表面反光 | 调整光源方向,避免强光直射 |
| 分辨率太高检测慢 | 图片过大 | 先缩小到2000像素以内再检测 |
还有一个小技巧:findChessboardCorners有一个flags参数,最常用的是cv2.CALIB_CB_ADAPTIVE_THRESH和cv2.CALIB_CB_NORMALIZE_IMAGE。在光照不均的情况下,自适应阈值和图像归一化能明显提升检测率。代价是检测速度稍微变慢,但对于标定这种离线任务完全可以接受。
4.2 重投影误差大?先别急着调代码
误差大的时候,我的第一反应不是改代码,而是回到图片上找原因。经验里最常见的问题是:棋盘纸不平整。很多人随手把棋盘纸打印出来贴在墙上拍,纸的四角会翘起,平面假设被破坏,标定误差自然大。解决方法是贴到硬纸板、亚克力板或者铝板上,保证表面完全平整。
第二个常见问题是用了不同尺寸的棋盘混拍。如果你中途换了一块不同格数的棋盘,而代码里的pattern_size没改,检测会失败;如果你换了一块格数相同但物理尺寸不同的棋盘,代码不会报错,但世界坐标的单位不一致,内参计算结果会乱掉。同一轮标定里,必须使用同一块棋盘、同一个pattern_size。
第三个问题是画面中棋盘占比太小。如果棋盘只占画面不到三分之一,角点的像素精度就有限。拍的时候让棋盘尽量占满画面的大部分区域,但同时保证四个边缘角点都在画面内。
4.3 标定结果不稳定的处理思路
有时候你拍了两组照片,标定出来的内参相差很大,这多半是图片集的信息量不够。比如20张图全是近似正对角度,虽然数量多,但约束单一,解出来的内参方差就会很大。解决办法是增加姿态的多样性——左右倾斜、上下倾斜、旋转、平移都要有,而且要保证这些姿态覆盖画面中心到边缘的各个区域。
另外一个容易踩的坑是:不要用视频帧做标定。虽然看起来视频有很多帧,但相邻帧之间姿态几乎相同,等效于一张图片,信息冗余极高。真正有效的标定数据是“姿态差异大”的图片,而不是“数量多”的图片。如果你从视频里抽帧,建议间隔拉开,每帧之间的棋盘姿态要有明显变化。
5. 完整流程梳理与实操心得
整个流程串起来看就四步:生成棋盘图、打印并采集图片、运行检测与标定代码、评估误差并微调。整条链路里,最容易出问题的是图片采集环节,而不是代码环节——这个话我说过很多次,但每次都有同学不相信,直到自己拍了一组差图回来debug半天才信。
我个人在实际操作中的几点体会:
一是打印精度很重要。如果只是做入门实验,普通A4纸打印的棋盘就够了。但如果要做高精度测量,建议找打印店用高精度设备输出,或者购买工业级陶瓷标定板。标定板的物理精度决定了标定的理论极限,代码再复杂也弥补不了物理器件的误差。
二是每次重拍后必须重新标定。相机如果被拆装过、镜头被重新拧过,或者焦距被调整过,之前标定的内参就不能再用了。很多项目在中期出现测量偏差,最后发现是相机被碰了一下导致镜头位置变了,可代码里还在用旧的内参。
三是不要迷信单一指标。重投影误差小,说明标定数据内部自洽,但不代表标定结果就一定准。因为如果存在系统误差(比如棋盘尺寸标错了),重投影误差依然可以很小。谨慎的做法是做一个实际验证:摆一个已知长度的物体,用标定结果测量它,看看测量值和真实值的偏差。
四是把标定做一个独立的工具模块放进项目工程里,而不是每次临时写脚本。我自己的做法是写一个calibrate.py,支持传入图片目录和棋盘参数,输出标定结果的JSON或YAML文件,这样后续程序直接加载结果文件就行,不需要每次都重新标定。OpenCV还提供了cv2.FileStorage,可以把内参和畸变系数直接存成YAML,再在项目里读出来用,非常方便。
结语
张正友标定法虽然已经有二十多年历史,但它依然是工业界和学术界应用最广泛的相机标定方法,原因就是简单、实用、稳定。只要把棋盘图准备好,拍照时多注意姿态和光照,代码跑通后仔细看看重投影误差和逐张误差,基本就能得到一套可用的内参。
最后再分享一个小扩展:棋盘格练熟之后,可以试试OpenCV的findCirclesGrid,用圆点标定板做同样的流程。对比两种图案在倾斜角度下的检测精度差异,能帮助你更深入理解标定算法的原理。标定这件事,动手跑通一遍,比看十篇原理文章都管用。
本文还有配套的精品资源,点击获取