
第2讲:相机标定——为什么图像会变形以及如何校正读者画像:本文适合已经了解视觉系统基本架构、正在搭建自己的视觉检测系统、发现图像有畸变(特别是边缘直线变弯)的工程师。如果你需要高精度测量,标定是绕不过去的一环。阅读时间:约14分钟代码示例:本文包含完整可运行的Python代码(基于OpenCV),建议准备一组棋盘格图片跟着跑一遍。前言:为什么我的测量结果总是不准?“我明明测得是100mm,怎么图像里是105个像素?”“视野边缘的工件尺寸测出来总是偏大?”“明明是直线,图像里怎么是弯的?”如果你遇到过这些问题,那么你的相机没有标定。工业相机的成像过程,本质上是一个"三维世界 → 二维图像"的投影过程。这个投影不是完美的,它会因为镜头制造误差、安装误差而产生畸变。如果不校正畸变,测量误差可以达到5%~10%,这在工业场景中是不可接受的。本讲将深入讲解:畸变产生的原理(径向畸变、切向畸变)棋盘格标定的完整流程用OpenCV实现单目标定和双目标定标定质量评估(重投影误差)Fish-eye镜头标定CameraCalibrator完整工具类一、畸变到底是什么?为什么会产生?1.1 理想成像 vs 真实成像理想针孔相机模型(Pinhole Camera Model):三维点 P(X, Y, Z) → 【针孔】 → 二维点 p(u, v)这个投影是线性的,满足透视投影公式:u = fx * (X/Z) + cx v = fy * (Y/Z) + cy其中(fx, fy)是焦距(像素单位),(cx, cy)是主点(光轴与成像平面的交点)。但真实镜头不是完美的:镜片是曲面 → 光线折射不均匀 →径向畸变镜片安装不平行 → 光轴不垂直于成像平面 →切向畸变1.2 径向畸变(Radial Distortion)径向畸变是最主要的畸变类型,分为两种:桶形畸变(Barrel Distortion)原本的直线 │ │ \ │ / \ │ / \ │ / \ │ / \ │ / \ │ / \ │ / \ │ / \│/ ────────────┼──────────── 图像中心 /│\ / │ \ / │ \ / │ \ / │ \ / │ \ / │ \ / │ \ / │ \特征:图像中心放大率大,边缘放大率小,直线向外"鼓起来"。原因:广角镜头、变焦镜头在广角端。数学模型(畸变修正公式):x_corrected = x * (1 + k1*r² + k2*r⁴ + k3*r⁶) y_corrected = y * (1 + k1*r² + k2*r⁴ + k3*r⁶)其中r = √(x² + y²)是点到中心的距离,(k1, k2, k3)是径向畸变系数。枕形畸变(Pincushion Distortion)与桶形畸变相反,直线向内"收缩"。原因:长焦镜头、变焦镜头在长焦端。数学模型:与桶形畸变相同,只是k1, k2, k3的符号相反。1.3 切向畸变(Tangential Distortion)切向畸变是由于镜头镜片安装不平行于成像平面导致的。理想光轴: ⊥(垂直于成像面) 实际光轴: ∕(倾斜了一个小角度)数学模型:x_corrected = x + [2*p1*x*y + p2*(r² + 2*x²)] y_corrected = y + [p1*(r² + 2*y²) + 2*p2*x*y]其中(p1, p2)是切向畸变系数。1.4 畸变校正的总公式OpenCV使用的是多项式畸变模型(Plumb Bob模型),包含5个畸变系数:畸变系数向量 distCoeffs = [k1, k2, p1, p2, k3]校正过程就是把观测到的畸变坐标,反算回理想坐标:importcv2importnumpyasnp# 假设我们有一张畸变图像distorted_img=cv2.imread("distorted.png")# 相机内参矩阵(需要标定得到)camera_matrix=np.array([[fx,0,cx],[0,fy,cy],[0,0,1]],dtype=np.float32)# 畸变系数(需要标定得到)dist_coeffs=np.array([k1,k2,p1,p2,k3],dtype=np.float32)# 畸变校正undistorted_img=cv2.undistort(distorted_img,camera_matrix,dist_coeffs)关键问题:camera_matrix和dist_coeffs怎么得到?—— 通过标定。二、棋盘格标定原理与流程2.1 标定的数学本质标定的目标是求以下参数:参数类型符号含义数量内参矩阵K (3×3)焦距、主点4个独立参数畸变系数D (1×5)k1,k2,p1,p2,k35个参数外参(每幅图)R,t (3×3, 3×1)旋转、平移每幅图6个参数内参(Intrinsics):相机自身的属性,与相机有关,与拍摄场景无关。标定一次,永久使用。外参(Extrinsics):相机相对于标定板的位置,每拍一张图就有一个外参。2.2 为什么用棋盘格?棋盘格(Checkerboard)是最常用的标定靶,原因如下:角点检测精度高:Harris角点检测可以达到亚像素精度(0.1像素)特征点数量多:一幅图可以检测到几百个角点制作简单、成本低:打印一张棋盘格图片,贴在硬纸板上即可OpenCV直接支持:cv2.findChessboardCorners()一键检测棋盘格规格选择:一般选9×6或8×6(内角点数量,不是格子数量!)格子尺寸要精确知道(比如每格25mm),这是标定的"尺子"精度要求高时,用工业级标定板(铝基板,精度±0.01mm,价格几千元)2.3 标定流程(实战步骤)步骤1:制作/购买棋盘格标定板 ↓ 步骤2:从不同角度拍摄15~20张棋盘格图片 ↓ 步骤3:用cv2.findChessboardCorners()检测角点 ↓ 步骤4:用cv2.cornerSubPix()提高角点精度到亚像素 ↓ 步骤5:调用cv2.calibrateCamera()计算相机参数 ↓ 步骤6:评估标定质量(重投影误差) ↓ 步骤7:保存标定结果,用于后续校正拍摄技巧:标定板要覆盖整个视野(中心、四角、边缘都要有)倾斜角度要大(比如45°),这样才能准确估计畸变至少拍15张,建议拍20~30张光照要均匀,避免反光和阴影三、单目标定完整代码下面给出一套生产级的单目标定代码,这是我多个项目中使用过的。importcv2importnumpyasnpimportglobimportmatplotlib.pyplotaspltfrompathlibimportPathimportpickleclassCameraCalibrator:""" 相机标定工具类(支持单目/双目标定) 功能: - 单目相机标定(内参 + 畸变系数) - 双目相机标定(左右相机内外参 + 旋转平移矩阵) - 标定质量评估(重投影误差) - 畸变校正 - 结果保存/加载 """def__init__(self,chessboard_size=(9,6),square_size_mm=25.0):""" 初始化标定器 Args: chessboard_size: 棋盘格内角点数量 (width, height) square_size_mm: 每个格子的实际尺寸(mm) """self.chessboard_size=chessboard_size self.square_size_mm=square_size_mm# 三维世界坐标(Z=0平面)# 形状: (N, 3),N = 9×6 = 54个角点self.objp=np.zeros((chessboard_size[0]*chessboard_size[1],3),dtype=np.float32)self.objp[:,:2]=np.mgrid[0:chessboard_size[0],0:chessboard_size[1]].T.reshape(-1,2)self.objp*=square_size_mm# 转换为实际尺寸(mm)# 存储每幅图的角点self.objpoints=[]# 三维世界坐标self.imgpoints=[]# 二维图像坐标self.image_size=None# 标定结果self.camera_matrix=None# 3×3内参矩阵self.dist_coeffs=None# 畸变系数self.rvecs=None# 旋转向量(每幅图一个)self.tvecs=None# 平移向量(每幅图一个)self.reprojection_error=None# 重投影误差print(f"标定器初始化完成")print(f" 棋盘格规格:{chessboard_size[0]}×{chessboard_size[1]}内角点")print(f" 格子尺寸:{square_size_mm}mm")defdetect_corners(self,image_path_or_array,show=False):""" 检测单张图片的棋盘格角点 Args: image_path_or_array: 图片路径(str) 或 图像数组(numpy) show: 是否显示检测结果 Returns: ret: 是否成功检测到角点 corners: 角点坐标 (N, 1, 2) gray: 灰度图 """# 读取图像ifisinstance(image_path_or_array,str):img=cv2.imread(image_path_or_array)ifimgisNone:print(f"警告: 无法读取图像{image_path_or_array}")returnFalse,None,Noneelse:img=image_path_or_array.copy()self.image_size=(img.shape[1],img.shape[0])# (width, height)gray=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)# 检测棋盘格角点ret,corners=cv2.findChessboardCorners(gray,self.chessboard_size,cv2.CALIB_CB_ADAPTIVE_THRESH+cv2.CALIB_CB_NORMALIZE_IMAGE+cv2.CALIB_CB_FAST_CHECK)ifret:# 亚像素级角点检测(提高精度)criteria=(cv2.TERM_CRITERIA_EPS+cv2.TERM_CRITERIA_MAX_ITER,30,# 最大迭代次数0.001# 精度阈值)corners_subpix=cv2.cornerSubPix(gray,corners,(11,11),(-1,-1),criteria)ifshow:# 绘制角点vis_img=img.copy()cv2.drawChessboardCorners(vis_img,self.chessboard_size,corners_subpix,ret)cv2.imshow('Chessboard Corners'