ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Matlab相机标定例程去畸变全解析:原理、代码与工程实践

Matlab相机标定例程去畸变全解析:原理、代码与工程实践

1. 进入标定工程前,先把“去畸变”这件事想清楚

做视觉测量、三维重建或者机器人抓取的工程师,多半都跟相机标定打过交道。Matlab的相机标定例程在业界用得相当广,因为它有图形化界面、能直接导出参数、还自带批量处理工具。但真正到了项目落地的时候,很多人的困惑不在“怎么跑通标定”,而在“标定完之后怎么把畸变矫正真正用到自己的程序里”。网上关于这套例程的教程不少,但大多是点到为止地贴一段undistortImage,看得懂开头看不懂细节。这让我觉得有必要把“Matlab相机标定例程中去畸变过程”从头到尾拆干净,包括背后那张畸变模型长什么样、Matlab到底帮你做了什么、以及不想用App时怎么直接调源程序来处理批量图片和视频帧。

这篇文章适合三类人:第一类是刚接触相机标定、想把原理和代码一起搞明白的初学者;第二类是已经在用Matlab标定工具箱、但遇到黑边、误差偏高、批处理卡壳等实际问题的人;第三类是想把标定结果接进自研算法、需要自己控制去畸变细节的开发者。我会把原理、参数含义、调用方式、踩坑记录全部揉在一起讲,尽量让这篇东西能当一份“可以直接抄作业”的工程笔记来用。

提示:本文主要基于Matlab Image Processing Toolbox中的相机标定模块,示例代码使用R2020a之后版本基本都能跑通,旧版本个别函数名有差异,后文会提到兼容性注意点。

2. 先把物理模型聊清楚:畸变到底是什么、标定又在求什么

2.1 针孔模型是理想世界,现实中的镜头总要给你加点“扭曲”

相机标定理论上的起点是针孔模型。理想情况下,一个三维点从世界坐标到像素坐标的投影,只经过一次刚体变换(外参)加一次透视投影(内参),数学上写成:

s·[u, v, 1]T = K·[R|t]·[X, Y, Z, 1]T

其中K是内参矩阵:

K = [fx, 0, cx; 0, fy, cy; 0, 0, 1]

fx和fy是焦距在像素单位下的表示,cx和cy是主点坐标。这个模型假设光线直线传播、透镜完美对称。可现实中的镜头是物理透镜,磨制精度、镜片组合、装配误差都会引入偏差,最典型的就是桶形畸变和枕形畸变。拍一张直线构成的棋盘格,你会发现远离画面中心的外围格子是弯的,边缘越往外越明显。这就是径向畸变,给针孔模型套上一层非线性扰动。

另一方面,镜头和传感器平面很难做到绝对平行,安装时有一点倾斜,画面就会产生梯形变形,这种叫切向畸变。工业相机里切向畸变通常比径向畸变小,但在低成本的广角模组或手机摄像头里,切向分量并不总能忽视。

所以标定这件事干两件事:一是把内参K和外参[R|t]求出来,二是把畸变系数求出来。只有两样同时拿到手,去畸变才有足够的数学依据。

1.2 标定到底在求什么:内参、外参与畸变系数的关系

Matlab整个标定流程围绕cameraParameters这个对象展开,它是一个数据容器,内部记录着内参矩阵、畸变系数、图像尺寸、标定板信息等。你通过Camera Calibrator App跑完流程后,导出变量就是它。常用字段包括:

  • IntrinsicMatrix:内参矩阵,注意Matlab里存放的是转置形式,读的时候别弄反
  • RadialDistortion:径向畸变系数[k1, k2, k3]
  • TangentialDistortion:切向畸变系数[p1, p2]
  • MeanReprojectionError:每张标定图的平均重投影误差
  • WorldPoints:标定板角点的世界坐标

外参在标定时通常是按“每张标定板图像单独一组”来解的,它描述标定板平面相对于相机坐标系的位姿。因为标定板在每次拍摄时位置和角度都不同,所以每张图对应一组旋转和平移。外参不直接参与去畸变过程,但它的准确性会直接影响内参和畸变系数的求解质量。这个因果关系初学者容易忽略:以为畸变系数只跟镜头有关,跟标定板的摆放无关。实际上,畸变参数是“由外参推算内参和畸变”这套联合优化问题的解,标定板拍得太正、贴得太近、反光太强,都会把误差分摊进内参里,最后畸变系数自然也就不准了。

Matlab把畸变模型固定为常见的多项式模型,径向畸变用k1、k2、k3,切向畸变用p1、p2。它背后对应的是经典的Brown-Conrady模型。你在勾选界面时会看到Distortion coefficients的选项,默认是3个径向加2个切向,也就是5参数模型。如果镜头比较普通,选2个径向加2个切向也够用;如果镜头是鱼眼,那需要换鱼眼标定专用的模型,常规例程并不适用。

2. 从界面操作到数据落盘:标定流程与参数导出细节

2.1 用Camera Calibrator跑一遍全流程的实操关键

Matlab的标定App入口在App标签页下的Image Processing and Computer Vision,也可以直接在命令行敲cameraCalibrator打开。导入图片之后,界面会让你输入棋盘格边长,单位是毫米。这里要特别注意:

  • 棋盘格每个格子的尺寸要量准。别用标称值,用游标卡尺量打印出来的实际值。打印纸受潮或缩放比例不对,毫米级的误差直接变成尺度误差,影响的是三维测量精度。
  • 图片数量建议10到20张,太少的话,内参求解容易陷入局部最优,畸变系数尤其不稳。
  • 拍摄时角度要变化,俯仰角度最好覆盖正负15到30度,还要包含平移、旋转和不同远近的图。如果全都摆在画面正中且角度接近0度,外参退化,内参解出来的方差会很大。

App里点Calibrate之后,可以查看每张图的误差图。Matlab会显示检测到的角点位置与重投影位置之间的连线,越乱说明这张图的误差越大。做完标定,点击Export Camera,工作区里就会出现cameraParams对象。建议用save('cameraParams.mat','cameraParams')存下来,后边的去畸变代码直接从mat文件加载。

2.2 导出标定数据:cameraParameters对象里到底装了什么

很多教程直接说“用cameraParams就行了”,但工程上你最好知道这个对象里有哪些可以编程访问的字段。常用访问方式:

load('cameraParams.mat'); K = cameraParams.IntrinsicMatrix'; % 转置后才是标准的3x3内参 radial = cameraParams.RadialDistortion; % [k1 k2 k3] tangential = cameraParams.TangentialDistortion; % [p1 p2] focalLength = cameraParams.FocalLength; % [fx fy] principalPoint = cameraParams.PrincipalPoint; % [cx cy] imageSize = cameraParams.ImageSize; % [rows cols]

这里的内参矩阵转置是个经典坑。Matlab里存的是K的转置,你直接拿去乘三维坐标会得到错误结果,必须先转置回来。另一个坑是FocalLength和PrincipalPoint这些字段,在老版本工具箱里位置和名称略有差异,如果你用的版本比较旧,建议用fieldnames(cameraParams)先打印所有字段名。

外参信息不直接存在cameraParams中,而是存在一个叫pattern的局部变量里。如果你用编程方式做标定,可以拿到单张图像的外参。用App导出时,cameraParams里其实不保留逐张外参,这一点要注意。需要外参时,通过extrinsics函数配合单张图像和对应点重新计算。

3. 去畸变的核心原理:从畸变坐标到理想坐标的数学过程

3.1 畸变模型拆解:径向畸变与切向畸变的具体作用

去畸变的本质,是把带畸变的图像坐标还原成理想针孔投影下的坐标。Matlab内部使用的畸变公式如下,设归一化平面上的理想坐标为(x, y),畸变后的坐标为(xd, yd):

r² = x² + y²

xd = x(1 + k1·r² + k2·r⁴ + k3·r⁶) + 2·p1·x·y + p2·(r² + 2x²) yd = y(1 + k1·r² + k2·r⁴ + k3·r⁶) + p1·(r² + 2y²) + 2·p2·x·y

这个公式里有三个关键点:

径向畸变那一串多项式,作用是把坐标沿半径方向拉伸或压缩。k1是主导项,控制畸变的整体方向和大小;k2、k3修正高阶项,鱼眼和广角镜头的高阶畸变明显。k3设成0就退化为2参数模型。切向畸变的两个p分量,分别处理图像坐标沿着x和y方向的倾斜补偿。对大部分工业镜头,p1、p2数值很小,常在10⁻⁴量级,但仍然要留在模型里,因为装配误差是客观存在的。

Matlab内部在对图像做去畸变时,并不会直接去找“理想坐标->畸变坐标”的正向关系,而是走反向映射。也就是说,对输出图像中的每一个像素(u, v),先换算到归一化平面,再把畸变加回去,得到它对应原始图像中的采样坐标,最终用插值方式取出像素值。这背后其实是一个迭代求解过程,因为畸变公式是“理想坐标->畸变坐标”的正向形式,而我们拿到的是畸变后的实际坐标,想反推理想坐标没有闭式解,只能迭代逼近。

3.2 从畸变坐标反推理想坐标的迭代策略

实际编程中,如果你想绕过Matlab内置函数,自己实现去畸变,最常用的方法是牛顿迭代。设观测到的归一化畸变坐标为(xd, yd),目标是求满足下式的(x, y):

F(x, y) = 0

其中F即正向畸变公式移项后的结果。迭代初值可以取(xd, yd),因为畸变量通常不大,一步或几步就能收敛。工程上还有另一个更省力的办法:先用一个足够大的网格,把理想坐标映射到畸变坐标,生成查找表,再对查找表做逆查。由于畸变函数在非鱼眼场景下通常是单调的,这个逆查表效果很好。

Matlab的undistortImage函数内部其实使用estimateCameraMatrix相关逻辑并生成一个重映射映射,类似OpenCV中的initUndistortRectifyMap加remap组合。你不需要亲自写迭代,但理解这个过程很有用——当你需要用自定义插值方式或自定义新相机矩阵时,知道底层的映射机制才不会用错参数。

3.3 重映射与插值:最终像素怎么落位,黑边又是怎么来的

去畸变的最后一步是像素采样。原始畸变图像像素在非均匀的位置上,输出图像的正规则网格需要从原图取颜色。插值方式选错了,边缘会出锯齿,或者产生奇怪的光晕。undistortImage默认用双线性插值,一般够用。若对图像质量要求高,可用cubic,但计算量会大一些。若只做视觉检测,双线性插值的速度和精度平衡得更好。

黑边的来源也在这里。去畸变后,原本靠近边缘的桶形畸变像素被向外拉平,输出图像边缘区域缺少有效信息,于是显示为黑色。处理黑边的方式有几种:接受黑边;通过OutputView参数设为'same'让输出图像保持原尺寸比例;调整为'full'让输出画面包含所有有效像素,但图像会被放大,部分边缘被裁剪;或者用新的相机内参矩阵放大焦距,让输出图从中间裁剪得更紧,避免大块黑边。这几种方式的取舍我放到第五节细讲。

4. 程序代码实现:三种调用源程序的使用场景与完整示例

4.1 最快路径:调用内置的undistortImage处理单张图片

去畸变最直接的方式就是调用内置函数。核心代码不长,但参数细节要捋清楚:

%% 加载标定结果 load('cameraParams.mat'); %% 读取待去畸变图像 I = imread('calib_01.jpg'); % 替换为实际图像路径 %% 去畸变并返回新图 [J, newOrigin] = undistortImage(I, cameraParams); %% 可视化对比 figure; imshowpair(I, J, 'montage'); title('左侧为原始图像,右侧为去畸变结果');

newOrigin是输出图像相对于原始图像坐标系的偏移量。这个返回值平时容易被忽略,但当你把去畸变后的像素坐标映射回原始图像坐标做特征匹配时,必须把它加回去。两次undistortImage之间如果不管newOrigin,坐标会差一个平移量,标定结果再好也没用。

再看一个常见需求:用一张图去畸变,并提取角点做后续位姿估计。

I = imread('calib_02.jpg'); [J, newOrigin] = undistortImage(I, cameraParams); % 检测棋盘格角点 [imagePoints, boardSize] = detectCheckerboardPoints(J); % 关键:把点坐标还原到原始图像坐标系下 imagePoints = imagePoints - newOrigin; % 计算外参 [rotationMatrix, translationVector] = extrinsics(imagePoints, cameraParams.WorldPoints, cameraParams);

这段代码里imagePoints = imagePoints - newOrigin是核心。因为undistortImage输出的J是已经重映射过的图像,角点坐标是在J的坐标系的;要算外参,必须用原图坐标系的点。extrinsics函数要求输入点坐标与cameraParams的内部坐标系一致,所以偏移量必须补偿回去。

4.2 高自由度的手动控制:自己构造映射表来去畸变

内置undistortImage好用,但灵活性不够。比如你想把去畸变后的图像输出到任意尺寸、任意内参矩阵,或者你想在GPU上批量处理,就需要自己动手构造重映射参数。

% 图像尺寸 imageSize = size(I); nrows = imageSize(1); ncols = imageSize(2); % 构建像素网格 [u, v] = meshgrid(1:ncols, 1:nrows); % 归一化坐标 K = cameraParams.IntrinsicMatrix'; fx = K(1,1); fy = K(2,2); cx = K(1,3); cy = K(2,3); x = (u - cx) / fx; y = (v - cy) / fy; % 应用畸变模型 r2 = x.^2 + y.^2; radial = 1 + cameraParams.RadialDistortion(1) * r2 + cameraParams.RadialDistortion(2) * r2.^2 + cameraParams.RadialDistortion(3) * r2.^3; dx = 2 * cameraParams.TangentialDistortion(1) .* x .* y + cameraParams.TangentialDistortion(2) .* (r2 + 2 * x.^2); dy = cameraParams.TangentialDistortion(1) .* (r2 + 2 * y.^2) + 2 * cameraParams.TangentialDistortion(2) .* x .* y; xd = x .* radial + dx; yd = y .* radial + dy; % 反算到像素坐标 ud = xd * fx + cx; vd = yd * fy + cy; % 使用interp2进行插值采样 J = zeros(nrows, ncols, size(I,3)); for c = 1:size(I,3) J(:, :, c) = interp2(double(I(:, :, c)), ud, vd, 'linear', 0); end J = uint8(J);

这段代码对理解“反向映射”非常有帮助。注意interp2(double(I(...)), ud, vd),Matlab的interp2要求输入坐标与网格索引对齐,所以我这里把图像先转成double再插值,最后再转回uint8。如果原始图像很大,比如1200万像素,这段代码跑起来会比较慢。批量处理时建议先用imwarp的一些加速路径,或者考虑把映射表一次算好,之后只做查表。

其实Matlab提供了imwarp相关的底层重映射工具,不过它更接近图像配准函数,直接在标定场景下用容易混淆参数。我的建议是:如果只是想快速去畸变,用内置undistortImage;如果要做深度定制,才自己构建网格和映射。

4.3 批量流水线:视频帧序列与图片集合的自动去畸变

工程场景里,你往往要对一整段视频或上百张图片批量去畸变。逐张调用undistortImage没问题,但有几个性能优化点值得写进代码。

%% 批量处理图片序列 srcDir = './raw_images'; dstDir = './undistorted_images'; if ~exist(dstDir, 'dir') mkdir(dstDir); end files = dir(fullfile(srcDir, '*.jpg')); numFiles = length(files); % 如果需要显示进度 parfor i = 1:numFiles filename = fullfile(srcDir, files(i).name); I = imread(filename); [J, ~] = undistortImage(I, cameraParams); imwrite(J, fullfile(dstDir, files(i).name)); end

批量处理的细节在于:如果同时处理大量图片,内存占用会快速上升。可以用parfor并行循环,但要注意cameraParams这个对象在并行池里的广播问题,最好先load cameraParams.mat之后再传循环。如果机器内存不够大,建议用parfor时只在每个worker里加载一次参数。

视频处理的思路类似,但要加一个读取视频流框架,并且处理好帧类型转换。

videoReader = VideoReader('input_video.avi'); videoWriter = VideoWriter('output_undistorted.avi', 'Motion JPEG AVI'); open(videoWriter); while hasFrame(videoReader) frame = readFrame(videoReader); % undistortImage要求单波段或彩色图均可,灰度图直接传入没问题 if size(frame, 3) == 1 frameUndistorted = undistortImage(frame, cameraParams); else frameUndistorted = undistortImage(frame, cameraParams); end writeVideo(videoWriter, frameUndistorted); end close(videoWriter);

视频逐帧去畸变时,undistortImage的内部映射表可以对同一分辨率重复使用,但它每次都会重新计算。如果你要处理长视频,建议自己预先算好映射表,对每一帧只做查表和插值。这个先算映射表再复用的思路,能把长视频处理时间缩短一半以上。另外,VideoWriter可能需要指定输出格式,比如'MPEG-4'或'Motion JPEG AVI',具体看Matlab版本支持范围。

5. 常见问题与排查技巧实录

5.1 重投影误差偏高:先从标定图集本身找原因

很多人跑完标定,看到MeanReprojectionError为0.5个像素就开始焦虑,其实要看项目精度需求。一般工业视觉,平均重投影误差在0.2到0.5像素之间是可接受的;低于0.1像素已经非常理想。如果误差超过0.8像素,基本是标定图集的问题。

常见原因有三个:

  • 拍摄时使用了不同分辨率,比如标定时用2448x2048,测试时用1920x1080。相机内参和畸变系数是跟分辨率绑定的,混用必然误差偏大。
  • 标定板不够平整。打印纸贴在普通纸板上往往会翘曲,建议贴在刚性玻璃或铝板上,不然角点提取精度就到不了亚像素。
  • 图片数量太少或角度覆盖太单一。10张图有8张都是正对着拍照,畸变参数几乎无法约束,误差自然高。

排查手法也很直接:App里看每张图的误差分布图。如果只是某一张误差特别大,考虑去掉它重新标定。这种图片往往是轻微失焦或运动模糊,角点检测勉强通过但精度不够。

5.2 去畸变后图像出现黑边和边缘拉丝

黑边问题我在第三部分提过,具体处理策略分两种:

如果只是视觉展示,黑边无伤大雅。但视觉测量时黑边区域不能参与计算,需要做掩膜。undistortImage返回的newOrigin可以帮你计算有效区域。设定一个阈值,把靠近图像边界的一圈像素全部排除即可。

如果要去掉黑边且不想损失视野,可以修改相机内参矩阵。把主点位置微调,并适当缩小焦距,让输出图像的有效像素范围更接近原始图像。实际操作中,我试过直接将内参矩阵的对角元素约乘0.95,并重新设定主点,这样输出图像的黑边会明显收缩,但注意这会改变焦距标定值,三维精度会受影响。补偿方式是让后续算法全部使用新的内参矩阵,而不是原来的cameraParams。

边缘拉丝则多半是插值方式导致的。双线性插值在畸变比较大的边缘会产生轻微模糊或锯齿,改成立方插值可以改善。如果是颜色伪影,可能是外部图像格式转换引起的,例如uint8到double转换时没有归一化。

5.3 相机换了分辨率,标定结果还能直接用吗

很多项目会先标完一个分辨率,然后改成裁剪ROI或缩放输出,结果去畸变出一堆怪图。原因在于内参矩阵和畸变系数都定义在原始图像坐标系下。分辨率一变,fx、fy、cx、cy都要按比例缩放,畸变系数本身不随分辨率变化,但径向畸变的r需要用归一化坐标计算,所以只要归一化时用的内参对了,结果就对了。

实践中我建议直接重新标定,别去凑合缩放。因为分辨率缩放时,主点位置和畸变模型的近似关系会引入额外误差,尤其是畸变比较大的广角镜头,缩放后的去畸变结果会出现系统性外扩或收缩,很难调回来。如果项目确实只能标定一次,那测试时保持相同分辨率是底线。

5.4 代码在不同环境间迁移时常见的报错与对策

在同事电脑或服务器上跑同一套代码,常见的坑有:

  • cameraParams变量名不一致。我习惯在导出时统一命名为cameraParams,并在每一段脚本里用load('cameraParams.mat', 'cameraParams')确保读进来的变量名正确。
  • 工具盒版本不同导致函数签名变化。undistortImage在R2013b之前和之后的参数定义不完全相同,建议代码里加一段版本判断,或者直接用which undistortImage确认版本。
  • 图像路径硬编码导致跨平台失败。别写D:\data\...这种绝对路径,改成fullfile(fileparts(mfilename('fullpath')), 'data', 'img.jpg')之类的相对路径写法。

还有一个容易被忽视的点:GPU加速。undistortImage本身并不直接支持gpuArray,但你可以把映射表算出来后用interp2在GPU上做,或者用arrayfun加parallel.gpu.CUDAKernel自己写加速核。对于1080p视频,CPU上逐帧处理大致每帧几十毫秒,GPU加速后能到个位数毫秒,接近实时。这个优化留给有需要的读者,先保证CPU版逻辑跑通,再考虑性能。

6. 一个更稳的调用习惯:把源程序封装成自己的工具函数

不要每次都写一堆加载和调用的重复代码,建议封装成一个工具函数:

function [J, newOrigin] = undistortWrapper(I, paramPath) % 从磁盘加载标定参数并去畸变 S = load(paramPath, 'cameraParams'); cameraParams = S.cameraParams; [J, newOrigin] = undistortImage(I, cameraParams); end

这个封装的意义在于:一是把路径和加载逻辑统一收口,二是在你以后换用不同相机或不同标定文件时,只需要改参数路径,不用动业务代码。如果团队协作,建议把cameraParams放在固定目录,并用脚本统一导出成calibration_data.mat,里面存cameraParams、date、cameraName等字段,方便追溯。

我自己做项目时还会额外算一个“去畸变质量指标”:选取原始图像中一条直线边缘,去畸变后检测边缘点,拟合成直线,看最大偏离误差。这个指标比平均重投影误差更能直接反映测量的实际精度,也非常适合做去畸变前后的效果对比。

7. 写在最后的一点个人经验

标定和去畸变在工程里是一对孪生兄弟,标定结果本身再漂亮,如果去畸变环节用错了坐标、选错了插值方式、忽略了newOrigin,到头来都会在测量误差里还回去。我早期做项目时吃过不少亏,尤其是newOrigin这种看似不起眼的返回值,常常在坐标换算时引起零点几个像素的偏差,排查大半天才发现是这里没补偿。

我用Matlab相机标定例程几年下来,最大的体会是:把函数当作白盒来用,而不是黑盒。undistortImage确实一行代码就能出结果,但如果你能理解它背后的畸变公式、重映射逻辑以及坐标系统,就能在项目里翻出各种功能:自定义去畸变的图像尺寸、补偿偏移、批量加速、甚至把同样的参数导入到OpenCV里做跨语言部署。技术工具都是这样,多沉下去一点,后面能做的事情就多出不少。希望这篇拆解能让你少走一些我已经走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进