
搞三维视觉、相机标定、机器人抓取定位的人几乎天天都在跟这四个坐标系打交道世界坐标系、相机坐标系、成像平面、像素坐标系。很多初学者拿到一块棋盘格拍了十几张照片跑完标定脚本内参外参往文件里一存就以为“搞定了”。但一旦换个场景、换台相机、或者要自己手写投影公式的时候才发现里面每个矩阵到底怎么来的、为什么这么乘、深度信息到底丢在哪一步全是一笔糊涂账。这篇文章我打算把这一整条链路掰开揉碎讲清楚从四个坐标系各自的“落座位置”到世界点如何一步步变成像素点再到为什么单目相机天生丢失深度、畸变怎么混进来、以及标定实操里那些文档不会告诉你的坑。适合刚入门三维视觉的学生、做机械臂手眼标定的工程师、以及所有想把“像素坐标”和“三维坐标”之间那层窗户纸捅破的人。1. 四个坐标系到底在哪儿“落座”很多教程一上来就摆公式搞得人云里雾里。我的习惯是先搞清楚每个坐标系的原点在哪、轴朝哪、单位是什么因为后面所有矩阵乘法的物理意义都是从这几个“坐标系定义”里长出来的。1.1 四个坐标系的一次对号入座先给四个坐标系各找各的“工位”。第一个世界坐标系。它是整个三维场景的“绝对参考系”原点可以放在任意你认为方便的位置。做机械臂抓取原点可以放在机器人基座做三维重建原点可以放在第一帧相机做标定原点通常就放在棋盘格角点上。单位一般是米或毫米轴的方向也可以随意指定只要你心里有数就行。第二个相机坐标系。它固定安装在相机上原点在相机光心也就是镜头镜片组的“光阑中心点”Z轴沿着相机主光轴朝前X轴朝右Y轴朝下。很多人没意识到相机坐标系本质上就是“以相机自己为世界”的一套坐标。第三个成像平面坐标系。它是相机坐标系下的三维点投影到物理感光面后得到的二维坐标原点在光轴与成像面的交点也就是“主点”位置。注意这时的坐标还是物理尺寸单位是毫米。理论上成像平面在镜头后方焦距 f 处计算时为了方便会想象成在镜头前方 f 处这样图像不会倒过来这就是“虚拟成像平面”的由来。第四个像素坐标系。它是最终图像里每个像素的坐标原点在图像左上角单位是像素x轴朝右y轴朝下。它和成像平面坐标系之间差了一个缩放和一个平移缩放是因为感光元件的像素物理尺寸是dx、dy平移是因为像素原点在左上角而主点不一定落在真正的像素中心。这里有个特别容易踩晕的地方像素坐标系和成像平面坐标系原点不一样方向也可能不一样。市面上工业相机的图像坐标系一般就是左上角原点、x朝右y朝下这跟相机坐标系的Y轴朝下是匹配的。所以公式推导的时候不用像教材里那样反复纠结“要不要翻转图像”只要把像素y轴方向朝下这个事记住就行。1.2 很多时候你真正在用的只有三种变换虽然名义上有四个坐标系但世界坐标点变成像素坐标点其实只经历三次变换世界坐标系 → 相机坐标系刚体变换旋转R 平移t也就是“外参”。相机坐标系 → 成像平面坐标系透视投影核心是除以深度Zc。成像平面坐标系 → 像素坐标系缩放 平移这就是“内参”的剩余部分。这里有一个非常重要的点相机内参不是单个矩阵而是“投影 像素化”两段压缩之后合并出来的产物。所以很多人背内参公式时背得熟问你“内参矩阵的fx到底是什么”答不上来。fx其实是焦距f除以像素物理尺寸dx的结果单位是像素不是毫米。fx和fy不一样是因为cmos像素往往不是正方的x方向和y方向的物理尺寸会有差异。为了更直观我习惯把这个关系类比成“房间里的人、你的眼睛、视网膜、你大脑里看到的图像”四者之间的关系世界坐标系是房间里物体的真实位置眼睛的晶状体是相机坐标系光心视网膜是成像平面你脑子里的画面就是像素坐标系。整个标定过程其实就是在反向估计“你的眼睛的晶状体和视网膜到底长什么样”。2. 从世界坐标算出像素坐标完整的“三次变身”理解了四个坐标系的位置就可以把一个三维点从世界坐标系一路算到像素坐标系。这一节我带大家把每一步公式拆开重点讲清楚每一步的物理含义以及为什么要做这一步。2.1 第一跳世界系到相机系——刚体变换假设世界坐标系里有个三维点 P_w [X_w, Y_w, Z_w]^T要把它变换到相机坐标系下公式是P_c R * P_w t其中 R 是 3x3 旋转矩阵t 是 3x1 平移向量。R和t合起来叫“外参”它们描述的是“相机在世界坐标系中的姿态和位置”。更严谨一点外参其实是从世界系到相机系的变换如果你把相机在世界系下的位姿记作 T_wc那 P_c T_wc^{-1} * P_w不过OpenCV里标定出来的rvec和tvec直接就是世界系到相机系的这个变换不用再取逆很多初学者在这里会被绕晕。R 是正交矩阵满足 R^T R I所以它本质上是三维旋转只有3个自由度t有3个自由度因此外参一共6个自由度。这也是为什么棋盘格标定法里单张图可以估计出外参但内参通常需要多张图才稳定——外参只有6个未知数一张图足够解而内参里fx、fy、cx、cy加畸变参数是全局共享的需要多张不同姿态的图来约束。操作上要注意的是旋转矩阵虽然可以拆成欧拉角或者轴角但做变换时务必用旋转矩阵不要用欧拉角直接乘。因为欧拉角在数值计算里有万向锁问题而且先后顺序约定不同结果完全不同几乎每过一段时间就会有人拿欧拉角写标定代码写出诡异结果。2.2 第二跳相机系到成像面——透视投影相机坐标系下的点 P_c [X_c, Y_c, Z_c]^T投影到成像平面物理单位上的坐标是x f * X_c / Z_c y f * Y_c / Z_c这里 f 是焦距单位是毫米。这一步就是小孔成像模型。最核心的操作是除以 Z_c也就是深度。这就是为什么同一个物体离相机越近成像越大因为分母小了。为了数值稳定性工程上经常先投影到“归一化平面”x_n X_c / Z_c y_n Y_c / Z_c也就是把深度归一化到1米的位置然后再乘焦距 f 得到像素物理坐标。这样做的意义在于归一化平面坐标是一个只依赖相机相对姿态、不依赖具体焦距的中间量在做畸变矫正、双目极线校正时非常有用。说句题外话很多第一次写“像素反投影到三维射线”的人会纠结“反投影是不是应该乘以Zc”。正确做法是像素坐标先反算到归一化平面坐标然后射线方向就是 [x_n, y_n, 1]也就是在相机系下的一条射线如果你知道深度 Zc就可以乘回去得到具体三维点。深度信息一旦丢失你只能得到射线不能得到唯一三维点这一点后面专门讲。2.3 第三跳成像面到像素系——数字化采样到了成像平面坐标还是一堆毫米尺度的小数传感器把连续的物理位置采样成离散像素这步用两个缩放因子 dx、dy每个像素的物理尺寸来完成u x / dx cx v y / dy cycx、cy 是主点在像素坐标系中的坐标单位是像素。把第二跳和第三跳合并得到u fx * X_c / Z_c cx v fy * Y_c / Z_c cy其中 fx f / dxfy f / dy。这就是大家熟悉的相机模型。把前面第一跳代进来就是世界坐标系到像素坐标系的完整表达式u fx * (r11*X_w r12*Y_w r13*Z_w tx) / (r31*X_w r32*Y_w r33*Z_w tz) cx v fy * (r21*X_w r22*Y_w r23*Z_w ty) / (r31*X_w r32*Y_w r33*Z_w tz) cy看着吓人其实就是“世界点先旋转平移再除以深度再缩放平移”。工程上用齐次坐标把它写成矩阵形式可以少掉很多括号和分支。约定三维点和二维点都用齐次坐标表示投影模型写成lambda * [u, v, 1]^T K * [R | t] * [X_w, Y_w, Z_w, 1]^T这里的 lambda 实际上就是 Z_c请记住它。lambda 的存在意味着齐次坐标里的最后一维被“吸收”掉了这正是深度信息从方程里消失的数学根源。很多教程不解释 lambda 是什么导致读者一直觉得那是“随便写的一个符号”。2.4 给一个真实的数字例子没有数字的公式都是耍流氓。我拿一个具体例子把上面三步走一遍。假设相机内参固定为fx 1000像素fy 1000像素cx 640cy 360相机在世界坐标系原点正对着世界系的Z轴方向所以 R It 0世界系里放一个点 P_w [2, 0, 5]单位米。这个点在相机正前方5米、右边2米处。第一步世界系到相机系因为R It 0所以 P_c [2, 0, 5]。第二步归一化平面x_n 2 / 5 0.4y_n 0 / 5 0。第三步像素坐标u 1000 * 0.4 640 1040v 1000 * 0 360 360。所以这个三维点落在图像坐标 (1040, 360) 处也就是主点右边400像素的位置。如果你把相机向后移动1米t变成[0,0,1]则 P_c [2, 0, 4]x_n 0.5u 1140像素位置又往右移了100像素。这就是“靠近了成像变大变偏”的量化表现。我强烈建议你拿到任何标定结果后都手动拿一个点这样算一遍把结果拿OpenCV的projectPoints对比一下。这能帮你快速判断自己是不是把某个矩阵代反了也能帮你彻底吃透公式。3. 为什么“深度”会丢单目视觉的信息瓶颈聊到这儿有个问题藏不住了既然世界坐标到像素坐标的完整过程里Z_c被当成分母除掉了那从像素坐标反推三维坐标时Z_c是不是找不回来了答案是单张图确实找不回来丢得死死的。这一节把这件事讲透因为它是理解三维重建、双目视觉、结构光的基石。3.1 两条方程三个未知量观察完整投影公式一个像素坐标 (u, v) 给我们提供了两个方程u fx * X_c / Z_c cx v fy * Y_c / Z_c cy但未知量是 (X_c, Y_c, Z_c)三个。两个方程三个未知数解不出来。这意味着像素坐标系里每一个点对应的是一条从相机光心出发的三维射线而不是一个确定的三维点。你只知道“这个物体在朝那个方向”不知道“它离你多远”。这个约束也可以从几何上理解成像过程把一个3D点投影到了2D平面这是一对多的塌缩。反投影从2D找3D自然就是不唯一的。要让信息变回唯一必须额外加入另一个视角双目或主动深度传感器结构光、ToF才能提供额外的约束方程。所以经常有人问“为什么标定好了相机还是不能从单张图像测距离”。答案就在这儿标定解决的是“像素和三维空间之间的数学关系”问题它不解决“单目深度缺失”问题。标定板之所以能恢复出3D信息是因为标定板上的角点在世界坐标系的坐标是已知的它等于给你的方程强行补上了第三个约束。3.2 单目深度丢失在工程上的后果深度信息丢失这句话听起来很抽象但它在具体项目里非常扎心。最典型的是扫地机器人单目避障你看到前方黑色物体占据了画面下方很大的面积但不知道它到底在1米还是5米外没法决定是刹车还是绕行。再比如单目目标检测换道预警要是没有结合地面平面假设也没法判断前车距离。这里有个工程技巧单目测距很多场景下其实不是真的测距而是“利用几何先验”。最常见的就是假设目标在地平面上加上相机高度和俯仰角已知深度就可以用一帧图像直接算出来。这是因为地面平面约束给你补上了Z_c关系未知量从三个变成两个方程就齐了。这也是为什么在所有自动驾驶单目方案里相机外参标定特别是俯仰角比内参标定更影响测距精度。我见过有团队内参标得很准外参随手一放结果测距误差超过20%。3.3 双目标定里的立体视觉如何“补回”深度既然单目丢了深度双目就是最直接的补救方案。本质就是两个相机从不同位置看同一个三维点分别在左右图像上各得到一个像素坐标于是有了四个方程而未知量还是三个 (X_c, Y_c, Z_c)超定了能解。双目标定的核心参数之一就是基线也就是两个相机光心之间的平移向量。基线越宽近距离深度精度越高远距离反而因为视差变小会变差。这一点在选型时就要考虑不是随便放两个摄像头就行。双目的数学流程是这样的先用标定板把左右相机的内参、畸变系数、两个相机之间的相对位姿都标出来然后做极线校正把两幅图像变到“行对齐”状态。校正之后匹配同一个点在左右图中的横坐标差称为视差 d深度就可以由三角形相似求出Z f * b / d其中 f 是焦距像素值b 是基线长度。这其实就是把小孔成像模型在两个相机之间来回倒腾得出的结果。焦距和基线来自标定视差来自匹配标定错一点最终测距就偏一点。4. 畸变真实镜头里多出来的“跑道偏移”上面所有公式都假设镜头是完美小孔成像。但真实镜头是透镜组光线经过镜片后会有弯曲投影位置会偏离理想点。这个偏离就是畸变。如果标定的时候不管畸变你会在图像边缘看到明显弯曲的直线同时内参也会被带偏。所以这一步绕不开。4.1 径向畸变和切向畸变畸变主要分两类。径向畸变光线越靠近镜头边缘折射越厉害形成“桶形畸变”或“枕形畸变”。这是由透镜形状导致的也是畸变的大头。数学模型里畸变量是相对镜头中心距离 r 的多项式x_distorted x_ideal * (1 k1*r^2 k2*r^4 k3*r^6) y_distorted y_ideal * (1 k1*r^2 k2*r^4 k3*r^6)这里 x_ideal、y_ideal 是理想归一化平面坐标r^2 x_ideal^2 y_ideal^2。注意是对归一化平面坐标做畸变不是在像素坐标上做很多人写代码时容易搞错。切向畸变来源于镜头组装时光轴不严格垂直于感光面玻璃片有微小的偏心。数学上这样加x_distorted x_ideal (2*p1*x_ideal*y_ideal p2*(r^2 2*x_ideal^2)) y_distorted y_ideal (p1*(r^2 2*y_ideal^2) 2*p2*x_ideal*y_ideal)OpenCV里常用的畸变参数是5个k1、k2、p1、p2、k3合起来就是distCoeffs。鱼眼镜头另有约4参数的等距投影模型别混用。4.2 畸变对精度的影响有多大很多同学想到畸变第一反应是“视觉上不好看”所以觉得无所谓。但在测量类任务里畸变是精度杀手。拿典型的500万像素、1/2.5英寸相机配广角镜头来说图像边缘处的径向畸变量往往超过20像素。如果你直接用没矫正的坐标做像素和三维的映射误差会直接变成厘米级。而完整的标定矫正后边缘重投影误差通常可以压到0.1像素以内放到实际距离上一两个毫米的误差就分出来了。在标定流程里畸变参数会跟内参一起被估计出来。为什么必须一起估计因为镜头畸变和主点偏移之间有耦合。如果你只估计内参不估计畸变畸变会“躲”进fx、fy、cx、cy里让内参整体偏移等你再拿这套内参去做三维解算结果就全偏了。我自己的习惯是拿到新镜头第一件事先用棋盘格标一次内参和畸变然后把畸变系数丢给initUndistortRectifyMap生成映射表最后跑一遍remap看直线直不直。直线变直、边缘位置和理论值对得上这套镜头才能用于测量。5. 实操拿着棋盘格把整条链路跑通理论讲完了落地才是重点。这一节我演示一套最常用的平面标定板标定流程用的就是OpenCV加Python两张图读完就能跑。前提示你已经能通过cv2.findChessboardCorners找到角点。5.1 标定前的准备与拍摄要点标定板可以是打印出来的棋盘格也可以是屏幕上显示的图案。打印的话要确保纸张平整最好贴在硬板上。屏幕显示则要注意屏幕反光尽量固定好别晃动。拍摄时记住几个原则标定板姿态要覆盖“正对、倾斜、俯仰、左右偏转”多种情况标定板在画面里的位置不能老在正中央要让它多次出现靠近边缘的位置每个位置的标定板离相机远近也要变化从近到远都来几张。数量上15到20张不同姿态基本够用太少容易造成退化太多则浪费时间。有一个非常隐蔽的坑标定板不要只在一个距离、一个角度上拍。比如所有图片标定板都基本正对相机、位于画面中心那内参中的fx、fy和畸变参数会严重相关标出来的内参几乎不可信。我见过不少朋友拿同一个姿态拍几十张最后明明重投影误差只有0.2像素换个角度测距却差得离谱。根因就是姿态多样性不足。5.2 一份可直接复用的标定代码下面这份代码演示了完整流程从检测角点到标定再到误差评估和畸变矫正。棋盘格尺寸按你自己板子的实际情况填标注的地方注意别写错。import cv2 import numpy as np import glob # 棋盘格参数 pattern_size (9, 6) # 内角点数列数-1, 行数-1 square_size 0.024 # 方格边长单位米 # 生成标定板角点的世界坐标 (z0平面) 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) objp * square_size obj_points [] # 每张图的世界角点 img_points [] # 每张图的像素角点 images sorted(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 not ret: print(f跳过无法检测的图片: {fname}) continue # 亚像素角点精化提高标定精度 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners) # 标定返回内参矩阵K、畸变系数dist、旋转向量、平移向量 ret, K, dist, rvecs, tvecs cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) print(内参矩阵K:\n, K) print(畸变系数dist:\n, dist) # 计算重投影误差 total_error 0 for i in range(len(obj_points)): proj_points, _ cv2.projectPoints( obj_points[i], rvecs[i], tvecs[i], K, dist ) error cv2.norm(img_points[i], proj_points, cv2.NORM_L2) total_error error**2 mean_error np.sqrt(total_error / len(obj_points)) print(f平均重投影误差: {mean_error:.4f} 像素) # 畸变矫正输出无畸变图 map1, map2 cv2.initUndistortRectifyMap( K, dist, None, None, gray.shape[::-1], cv2.CV_32FC1 ) undistorted cv2.remap(img, map1, map2, cv2.INTER_LINEAR) cv2.imwrite(./undistorted_example.jpg, undistorted)这段代码输出里最需要关注的就是平均重投影误差。经验上普通工业镜头这个值应低于0.3像素如果高于0.5像素说明图像里某些角点检测得不好或者标定板姿态不够全需要回炉重拍。5.3 拿到内参和畸变之后怎么用标定完成不是终点拿到了K和dist后面有三种最常见的用法。第一去畸变。用initUndistortRectifyMap加remap生成一张新的无畸变图像后续特征匹配、目标检测都在这张图上做。注意去畸变本身会裁剪边缘像素所以图像视野会比原来略小。第二像素坐标反投影成射线。已知像素 p (u, v)先转归一化平面坐标x_n (u - cx) / fx y_n (v - cy) / fy相机系下射线方向就是 (x_n, y_n, 1)。如果再知道深度Z就用 x x_n * Z, y y_n * Z, z Z 恢复三维点。这里要特别提醒反投影之前一定要先对像素坐标做去畸变否则方向是歪的。第三外参标定与位姿估计。当你知道标定板上的角点世界坐标和对应像素坐标时可以用cv2.solvePnP求解相机相对标定板的位姿rvec和tvec。很多机械臂抓取、增强现实的“物体在相机系下的位姿”本质就是solvePnP在起作用。6. 标定排查实录我踩过的那些坑标定这个事和调代码不一样很多时候问题出在“数学上没错但实际就是不对”。这一节我整理几个最常见的坑基本都是现场踩出来的经验。6.1 角点漏检和误检棋盘格角点检测在强反光、高亮边框、板子倾斜太厉害时经常失败。最常见的错误是角落处的角点被截断或者板子倾斜角度过大导致轮廓模糊。排查办法把每张图的检测结果可视化出来用cv2.drawChessboardCorners把角点画回去看。不要“跳过就不管了”而是尽量重拍那张缺失的视角。标定板的姿态覆盖度比图片数量更重要这是我一直强调的。宁可12张有效的不要20张里有一半缺角点。6.2 主点cx、cy不居中是不是出问题了很多新手看到标定出来的cx、cy离图像中心很远第一反应是“标错了”。其实不一定。主点偏离图像中心是正常现象它由镜头安装和光轴偏差决定偏移几个像素到几十个像素都有。真正需要警惕的是cx、cy在多次标定之间反复横跳或者跟fx、fy高度相关。这通常说明标定图片姿态太少参数被“姿势耦合”住了。解决办法就是增加更极端的视角比如大角度俯仰拍摄标定板。6.3 同一台相机换了分辨率K为什么全变了有人标过一次内参后面把相机分辨率从1920x1080换成1280x720直接把之前的K拿来用结果全偏了。我见过不少这个错误。原理很简单fx和fy是“焦距除以像素物理尺寸”得到的像素尺寸不变但分辨率缩小后图像被剪裁或者缩放主点cx、cy和像素坐标基准都不一样了内参自然要重新标。如果相机支持ROI输出更麻烦ROI的偏移量会直接叠加到cx、cy上。所以在采集图像时建议开头就固定分辨率和ROI别中途随手改。6.4 把外参和内参混在一起使用另一个常见的坑发生在手眼标定或单目测距的场景里。内参是相机自身的属性只跟镜头和传感器有关外参是相机在世界坐标系或机器人坐标系下的位姿是会随安装位置变化的。有人把外参直接合进了一个3x4投影矩阵然后写到配置文件里当“内参”用一旦相机挪了位置就全盘重标。正确的做法是内参和畸变单独保存外参单独保存每次变换相机位置只重标外参内参不动。在实际做机械臂抓取项目时我习惯把“相机内参标定”和“眼在手外标定”分成两个独立步骤先固定好相机物理位置再一次性完成外参求解。分开处理的好处是出问题时能快速定位是相机自身的问题还是机械臂坐标变换的问题。最后一件事每次标定完请做一次端到端验证写了这么多最后分享一个我个人的小习惯。每次标定完我都不会只盯着重投影误差看而是会找一个现场已知尺寸的物体放到相机前方几个不同距离上用solvePnP或反投影射线去反推它的尺寸或距离再做一次对比。重投影误差小只能说明数学拟合好不代表你整套流程在真实场景里一定准。只有端到端测出来的误差在你的容忍范围内这套标定才算真的“能用”。另外标定结果文件里建议把“标定用的图像数量、分辨率、平均误差、标定时间”一并记上。过几个月回头查数据时你会发现这些备注比参数本身更有用。