ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

相机归一化全解析:从像素坐标到三维重建的工程实践

相机归一化全解析:从像素坐标到三维重建的工程实践 做视觉的同学不管你是搞SLAM、三维重建、双目匹配还是工业相机选型、图像预处理大概率都躲不开“归一化”这三个字。我最早接触这个概念是在做相机标定的时候当时用的是棋盘格标定板走一遍OpenCV的calibrateCamera发现程序里总是对特征点坐标做一步归一化操作当时没想明白为什么非要绕这么一圈。后来自己在工程里手动实现标定算法或者在写双目测距代码时被精度折腾得够呛才真正意识到相机里的归一化不是课本上的花架子它贯穿了从像素坐标到三维坐标的整个换算链路哪个环节少了它结果就会差得离谱。这篇文章我打算把相机相关的归一化整个拆开来讲从最基础的像素坐标归一化到图像灰度的归一化处理再到标定算法里的数值归一化技巧最后延伸到深度相机和双目视觉里的实际应用。内容尽量贴合实际工程场景会带上具体的公式推导和可运行的代码不管你是刚入门的小白还是已经写了几年视觉代码的老手应该都能在里面找到点能直接拿去用的东西。1. 先把“归一化”这件事掰开揉碎1.1 相机中的归一化远不止图像处理那一套很多人一提归一化第一反应就是把0到255的像素值除以255变成0到1的小数这在图像预处理里确实很常见。但在相机这个领域里归一化还有另一层更根本的含义——它描述的是三维世界中的一个点通过透镜投影到二维图像传感器上的数学过程。我说的这个归一化指的是把相机坐标系下的三维点(X, Y, Z)的X和Y分量同时除以Z得到一个(x, y)坐标。这一步操作在几何上等于把这个三维点投影到了一个距离相机光心正好为1的虚拟平面上这个平面在视觉领域就叫归一化平面。这个平面的意义特别大因为相机内参的标定、畸变矫正、双目视觉里的极线约束全都是围绕着这个归一化平面展开的。为了不让概念混淆我先把相机相关的归一化分成三层来看归一化类型操作对象典型公式主要用途像素坐标归一化图像上的像素坐标(u, v)x (u - cx) / fx去除内参影响建立像素与几何投影的联系灰度值归一化图像的像素灰度值I_norm I / 255.0数值范围压缩利于模型训练和特征提取输入数据归一化标定点的坐标集坐标减去均值再除以标准差提高数值稳定性避免病态矩阵这三层归一化在一条完整的视觉处理链路里经常是同时出现的。比如你要训练一个深度学习模型来估计深度图先要把输入图像做灰度归一化让网络收敛更稳定网络输出的深度图要做像素坐标归一化才能从深度值还原出三维点云而在训练数据准备阶段标定板的特征点坐标又必须先做输入数据归一化才能解算出准确的内参和外参。搞清楚这三者在整条链路里的位置比死记公式有用得多。1.2 归一化在视觉系统里的杠杆作用不少人在视觉项目里被精度问题折磨得头疼却很少想到问题就出在少做了一步归一化。举一个我实际踩过的例子有一回做单目测距用张正友标定法跑完内参之后直接拿像素坐标去解PNP结果距离超过两米就开始飘。后来排查发现我在手动实现PNP求解时没有把像素坐标先通过内参矩阵转换到归一化平面而是把像素坐标当成归一化坐标直接用这相当于把焦距和光心偏差全部当成了外部噪声误差自然会被放大。归一化之所以能起到杠杆作用是因为它改变了问题的尺度。像素坐标系里光心可能在(640, 360)附近一个点的坐标可能是(850, 420)这样的数值进入几何计算后会带来很大的平移偏置。而转换到归一化平面之后坐标大致会落在-1到1之间数值范围小且对称。在这种尺度下做矩阵分解、最小二乘求解数值稳定性会好很多这一点在下面讲标定算法的时候还会反复出现。2. 核心细节拆解像素坐标如何归一化到相机坐标系2.1 从针孔模型到内参矩阵的完整逻辑相机归一化的根源是针孔成像模型。想象一个暗箱前面开一个小孔外面的光线穿过小孔后在后面的感光元件上形成倒像。小孔的位置就是光心而图像平面到光心的距离就是焦距f。三维空间中的点P (X, Y, Z)在针孔模型下投影到图像平面上的位置可以写成x f * X / Zy f * Y / Z这里的x和y是以光心为原点的物理坐标单位是毫米。但图像传感器读出来的数据是以像素为单位的像素坐标系的原点通常在图像的左上角所以还需要引入两个偏移量cx和cy把物理坐标转换成像素坐标u fx * (X / Z) cxv fy * (Y / Z) cy注意我这里的fx和fy不是同一个值它们分别是焦距在x和y方向上的像素度量单位是像素/毫米。之所以会有差异是因为CMOS传感器的像素点不一定是正方形也可能存在装配误差。把这组关系整理成矩阵形式就是经典的Z * [u, v, 1]^T K * [X, Y, Z]^T其中K就是相机内参矩阵它包含了fx、fy、cx、cy四个关键参数。很多工业相机SDK比如海康、大华的MVS在初始化之后都能直接拿到这些参数如果拿不到就得走一遍标定流程自己算。2.2 从像素坐标反推归一化坐标的工程实现在大部分三维视觉算法里拿到像素坐标之后的第一步操作就是反推归一化坐标。这个过程很简单就是上面那个公式的逆运算x (u - cx) / fxy (v - cy) / fy从数学上看这个操作等价于把像素坐标减去光心偏移再除以焦距。但从几何上看它其实是在问一个问题如果有一个三维点它在归一化平面上的投影是(x, y)那么从相机光心到这个点的射线方向就是(x, y, 1)这条射线上的所有点都会投影到同一个像素位置。这个射线方向正是对极几何、三角化、PNP求解的基础。我手头最常用的Python实现长这样import numpy as np def pixel_to_normalized(pts_pixel, K): 将像素坐标转换为归一化平面坐标 pts_pixel: 形状为 (N, 2) 的像素坐标数组 K: 相机内参矩阵 3x3 返回: 形状为 (N, 2) 的归一化坐标数组 fx K[0, 0] fy K[1, 1] cx K[0, 2] cy K[1, 2] # 转换成齐次形式 u pts_pixel[:, 0] v pts_pixel[:, 1] x (u - cx) / fx y (v - cy) / fy return np.stack([x, y], axis1) # 举个实际的例子 K np.array([[915.0, 0.0, 640.0], [0.0, 912.0, 360.0], [0.0, 0.0, 1.0]]) pixel_pts np.array([[850.0, 420.0], [200.0, 150.0]]) norm_pts pixel_to_normalized(pixel_pts, K) print(norm_pts) # 输出: # [[ 0.2295082 0.06578947] # [-0.48087432 -0.23026316]]可以看到第一点在归一化平面上的坐标是(0.23, 0.07)第二点是(-0.48, -0.23)数值都落在一个比较小的对称范围内这就是归一化平面坐标的典型特征。2.3 为什么要除以Z透视投影的几何直觉很多初学者不太理解为什么要除以Z觉得这是凭空冒出来的操作。这里我打一个比方你站在一条笔直的马路上看远处的一盏路灯路灯本身是5米高但你看到的它只有拇指那么大。路灯的三维位置明明没变为什么看起来的大小变了因为人眼接收到的是光线方向而不是物体的绝对尺寸。同样相机传感器能记录的也只有光线到达传感器的方向而这个方向就是归一化平面上的坐标(x, y)。从这个角度理解归一化平面上的一个点其实代表的是空间中的一条方向射线。这条射线从相机光心出发方向上所有三维点都会投影到同一个像素位置。这也是为什么在不同距离上的两个物体只要方向相同在图像上就会重叠的原因。在视觉SLAM或者多视图几何里我们通常只关心射线方向因为方向上的深度信息是后续通过三角化或者深度估计来恢复的。所以先把像素坐标归一化到方向空间再做后续的几何计算这是一种非常自然的解耦方式归一化负责方向深度负责距离两者分开处理问题会清晰很多。2.4 畸变矫正和归一化坐标的正确配合方式如果你的相机用的是普通工业镜头畸变一般不能忽略。畸变模型通常作用在归一化坐标上而不是像素坐标上这一点是很多新手容易搞错的地方。粗略地说相机成像过程是这样的三维点先投影到归一化平面然后归一化坐标(x, y)会受到镜头畸变的影响变成(x_distorted, y_distorted)最后再通过内参矩阵转换成像素坐标。在OpenCV里畸变模型包含径向畸变(k1, k2, k3)和切向畸变(p1, p2)。矫正畸变的正确做法是先从像素坐标反推归一化坐标再用畸变系数做迭代矫正最后把矫正后的归一化坐标用于几何计算。我见过有人图省事直接在像素坐标上做畸变矫正然后抱怨矫正结果有残差问题就出在坐标系混淆上。一个实用的去畸变调用长这样import cv2 import numpy as np def undistort_points(pts_pixel, K, dist_coeffs): 对像素坐标点做畸变矫正返回矫正后的像素坐标 pts pts_pixel.reshape(-1, 1, 2).astype(np.float64) undistorted cv2.undistortPoints(pts, K, dist_coeffs, PK) return undistorted.reshape(-1, 2)这里有个隐藏细节cv2.undistortPoints默认输出的坐标是归一化平面坐标但当你传入参数PK时它会直接帮你把归一化坐标重新投影回像素坐标系一步到位。如果你不传P拿到的是归一化坐标需要自己再乘内参才能变回像素坐标这个细节能帮不少人少走弯路。3. 图像本身的归一化从传感器数据到模型输入3.1 线性归一化与非线性归一化的区别和适用场景图像灰度归一化是另一个应用非常广泛的场景。工业相机输出的原始图像通常是8位或10位/12位灰度图像素值范围很大。比如10位相机的灰度范围是0到102312位是0到4095如果直接把这些大数值丢进深度学习模型训练时梯度可能会因为数值范围太大而不稳定。这时候就需要归一化把数据压缩到模型容易处理的范围内。线性归一化是最直观的做法把原始值线性映射到[0, 1]区间I_norm (I - I_min) / (I_max - I_min)如果整体灰度分布比较均匀用这个方法很有效。但有些场景下图像大部分区域都比较暗只有一小部分高光线性归一化会把暗部细节压没。这时候可以考虑用非线性归一化比如对数变换或者直方图均衡化把暗部的对比度提上来。举一个工业检测里的例子检测金属表面的划痕时金属抛光面反光很强灰度直方图往往集中在一端用线性归一化之后背景和缺陷的差异反而更不明显了。改用直方图均衡化之后缺陷区域的对比度明显提高检测率也跟着上去了。3.2 深度学习预处理中的常用归一化方案对比在深度学习视觉任务里常见的归一化方案有几种除以255、映射到[-1, 1]、零均值归一化。各有各的适用场景我整理成表格对比一下方案公式特点适用场景简单缩放I / 255.0实现最简单速度最快轻量模型、MobileNet系列、多数检测模型对称映射(I / 255.0 - 0.5) * 2数据范围在[-1, 1]中心为0GAN、扩散模型、部分图像生成任务零均值归一化(I - mean) / std数据分布更接近标准正态分布ResNet、ViT等需要稳定收敛的深层模型零均值归一化里的mean和std在ImageNet预训练模型里一般用固定的值比如mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]。很多人在用Pytorch的torchvision.transforms.Normalize时没注意这个细节直接传了个自己算的mean和std导致加载预训练权重后模型效果明显变差。正确的做法是加载预训练模型就用预训练时的归一化参数自己从头训练才用自己的数据统计值。3.3 工程里图像归一化的性能优化在实际项目中图像归一化往往是逐帧执行的如果实现得不好会白白浪费不少CPU时间。比如每帧都自己写一个双重for循环去遍历像素做归一化速度会很感人。我建议直接用NumPy的向量化操作或者OpenCV的convertTo来处理效率差距能有一两个数量级。举一个实际对比的例子。用Python对一张1080p的灰度图(1920x1080)做除以255的归一化纯Python循环遍历需要大约200多毫秒而用OpenCV的cv2.divide(img, 255.0)或者NumPy的img / 255.0只需要几毫秒。对于实时性要求高的视觉系统比如SMT贴片机上的视觉定位每一帧节省的这几十毫秒很可能就是节拍时间能否达标的关键。import cv2 import numpy as np # 读入一张16位深度的工业相机图像 img_raw cv2.imread(industrial_16bit.tif, cv2.IMREAD_UNCHANGED) print(f原始数据范围: {img_raw.min()} ~ {img_raw.max()}) # 方法1: 直接归一化到0~1 img_float img_raw.astype(np.float32) / 65535.0 # 方法2: 先做截断再归一化适合消除极端噪点 clip_min, clip_max np.percentile(img_raw, 1), np.percentile(img_raw, 99) img_clipped np.clip(img_raw, clip_min, clip_max) img_normalized (img_clipped - clip_min) / (clip_max - clip_min)第二种方法在工业场景里更实用因为传感器偶尔会有坏点或者噪点这些极端值如果不先clip掉归一化之后整个图像的对比度都会被拉低视觉算法的稳定性会受到直接影响。4. 相机标定中的归一化数值稳定性的关键操作4.1 为什么DLT和8点法之前必须先做数据归一化张正友标定法和基础矩阵的8点法求解本质上都是先建立线性方程组然后通过最小二乘或者SVD分解求解。这里有个数值分析上的经典问题直接拿原始像素坐标比如几百到几千的数值去构造矩阵矩阵的条件数会很大导致求解结果对噪声极其敏感。打个比方你要测量一个房间的长度如果用一根尺子从墙根开始量误差可能有好几厘米但如果先在地上画一条标准长度的基准线再相对这条线去测量误差就会小很多。数据归一化做的就是这样一件事先把所有坐标变换到一个均值为0、方差为1的标准范围内在标准范围内求解再把结果变换回去。在三维重建领域Hartley在他的经典论文《In Defense of the Eight-Point Algorithm》里专门讲过这个事同样的特征点对数据先做归一化再算基础矩阵精度可以提升好几个数量级。这不是什么玄学而是实打实的数值分析结论。坐标值从几百几千变成-1到1的范围矩阵的条件数会显著下降SVD分解的稳定性大幅提升。4.2 归一化变换矩阵的构造方法在标定算法里归一化通常是通过一个平移加缩放的变换矩阵来实现的。假设有一组像素坐标点集{x_i}归一化的目标是让变换后的坐标均值接近0到原点的平均距离接近根号2二维情况下。具体操作分两步第一步计算坐标的重心mean_x sum(x_i) / Nmean_y sum(y_i) / N第二步计算所有点到重心的平均距离并缩放avg_dist sum(sqrt((x_i - mean_x)**2 (y_i - mean_y)**2)) / Nscale sqrt(2) / avg_dist归一化矩阵T就长这样T | scale 0 -scale * mean_x | | 0 scale -scale * mean_y | | 0 0 1 |然后用这个矩阵左乘所有齐次像素坐标就能得到归一化后的坐标。在求解完基础矩阵F之后别忘了把结果变换回原始坐标系F_original T2^T * F_normalized * T1。很多人在手动实现8点法时结果总是不对检查来检查去最后发现是少了最后这一步反变换这种错误确实很隐蔽。4.3 在OpenCV标定流程中的归一化体现和验证如果你用的OpenCV自带的cv2.findChessboardCorners配合cv2.calibrateCamera你不需要自己手动做归一化因为OpenCV在内部已经做了这一步。但如果你要深入理解标定的原理或者要自己实现标定算法以满足特定需求这一步就绕不开了。验证归一化是否有效的办法很简单同一个标定数据做归一化再求解和直接求解对比两者的重投影误差。我自己在三维重建项目里试过直接求解单应矩阵时重投影误差可能在0.3像素左右先归一化再求解误差可以压到0.08像素以下。对于需要亚像素级精度比如精密测量、工业对位的应用来说这一优化带来的收益非常明显。4.4 相机IMU联合标定里的归一化思路除了相机内参标定在相机和IMU惯性测量单元联合标定中归一化也有它的应用。联合标定需要估计相机和IMU之间的旋转外参以及时间延迟。这个过程中特征点的像素坐标同样会先转换到归一化平面再和IMU的角速度、加速度观测建立几何约束。和纯视觉标定不同的是IMU的数据带有尺度信息角速度单位rad/s加速度单位m/s²和像素坐标的归一化不在同一个尺度体系里。所以在构造联合优化问题时通常会对残差项也做归一化处理让视觉残差和IMU残差的量级差不多。否则IMU残差如果比视觉残差大几个数量级整体优化会完全被IMU主导视觉约束形同虚设。5. 从二维到三维归一化在深度图转点云中的应用5.1 深度值到三维坐标的转换流程深度相机无论是结构光、ToF还是双目输出的深度图每个像素值代表相机到物体表面的距离。要把深度图变成三维点云就需要用到前面讲过的像素坐标归一化。具体流程是每个像素(u, v)先通过内参反推归一化坐标(x, y)然后用深度值d作为Z坐标还原出相机坐标系下的三维点X x * dY y * dZ d在代码里的实现大概是def depth_to_pointcloud(depth_img, K): 将深度图转换为相机坐标系下的点云 depth_img: 单通道深度图单位为毫米或米 K: 相机内参矩阵 返回: 形状为 (H, W, 3) 的点云数组 h, w depth_img.shape fx K[0, 0] fy K[1, 1] cx K[0, 2] cy K[1, 2] # 生成像素网格 u, v np.meshgrid(np.arange(w), np.arange(h)) u u.astype(np.float32) v v.astype(np.float32) # 归一化坐标 x (u - cx) / fx y (v - cy) / fy # 乘以深度得到三维坐标 z depth_img.astype(np.float32) x x * z y y * z points np.stack([x, y, z], axis-1) return points这里有一个经验点深度图里经常有无效像素比如超过量程、或者传感器没收到信号在处理前一定要先过滤掉这些无效值否则点云里会出现乱七八糟的飞点。常用的处理方式是把深度值为0或者大于量程最大值的像素直接mask掉。5.2 双目视觉中的视差归一化和三角化双目测距的原理是通过左右两张图上对应点的视差disparity来估计深度。立体匹配算法输出的视差图和深度之间的关系是Z f * B / d其中f是焦距B是双目基线长度d是视差单位是像素。在这个公式里面归一化体现在哪里其实就在视差的计算过程中。左右图像在做立体匹配之前通常需要先对图像做归一化处理比如对光照变化敏感的场景下用归一化互相关NCC比用绝对差SAD稳定得多。NCC本质上就是对两个图像窗口分别做零均值归一化再计算相关性这能有效抵抗光照差异带来的匹配误差。双目标定在OpenCV里的标准流程是stereoCalibrate这中间也用到了特征点归一化。标定完成后你会得到左右相机的内参、畸变系数以及右相机相对左相机的旋转矩阵R和平移向量T。这套参数在做立体校正rectification和三角化triangulation时都要用到而三角化过程中两幅图像上的匹配点会被转换到归一化平面再做几何求解这一步同样是归一化在起作用。5.3 手眼标定中的归一化操作手眼标定是工业机器人和视觉系统集成的必经之路目标是求解相机坐标系和机器人末端坐标系之间的变换关系。这里有一个不太起眼但很关键的归一化步骤当用标定板在不同位姿下采集图像并解算出相机外参后得到的平移向量通常会转换成毫米或者米单位。但在标定求解过程中不同位姿下的平移量可能差异很大如果直接拿原始数值去构造方程会发生和8点法一样的数值病态问题。我在做机械臂抓取项目时手眼标定的结果经常出现旋转矩阵一点都不正交的情况后来发现是平移向量的尺度差异太大导致的。解决办法很简单在构造线性方程组之前把平移向量做归一化处理让它们的尺度统一。这一个改动之后标定结果的稳定性提高了不少重投影误差从0.8毫米降到了0.3毫米左右。6. 实战中的常见问题和排查技巧6.1 归一化之后图像变黑变白怎么回事有次我在处理红外相机图像时做了个简单的归一化结果输出图像一片漆黑。原因是图像数据本身是16位无符号整型范围和8位图完全不一样如果直接除以255大部分数值都被压缩到0附近视觉效果就是黑的。正确的做法是先搞清楚原始数据到底是多少位再确定除数。如果是16位就除以65535如果是带符号的整型还得先平移数据再去归一化。还有一个容易踩的坑OpenCV读取图像默认是BGR通道顺序而深度学习的预处理通常按RGB顺序。如果你在做归一化之前把通道顺序弄错了不仅颜色异常模型的预测结果也会偏离预期。建议在pipeline里明确标注好图像的颜色空间和位深在第一步就把数据规范化。6.2 归一化平面坐标和像素坐标混淆的典型症状我之前帮一个做双目三维重建的团队排查过问题他们的点云模型出现了一种很有意思的扭曲近处的物体位置基本正确越远的地方偏差越大整个点云像是被拉伸变形了一样。排查到最后发现他们的代码在某一个模块里把像素坐标直接当成归一化坐标用了而内参矩阵里的cx和cy不为零越靠近图像边缘的像素误差就会越大。这种错误的典型特征就是图像中心附近的结果看起来正常边缘和远处开始系统性偏移。如果你发现这类“中心正常、边缘漂移”的情况优先检查代码里有没有漏掉从像素坐标反推归一化坐标这一步。6.3 数值误差的排查思路处理视觉算法时如果结果精度达不到预期我一般会按照下面的顺序去排查排查顺序检查点常见原因1输入数据是否正确图像位深、通道顺序、坐标系定义2内参和畸变系数是否匹配标定图像和实际使用图像的分辨率是否一致3像素坐标和归一化坐标是否混用缺少除法或错误使用内参矩阵4数据归一化是否执行标定类算法是否做了预归一化5输出变换是否逆变换求解完之后忘记把结果变回原始尺度有时候问题不在算法本身而在数据链路。记得有一次排查了很久才发现相机SDK在某个版本里输出的图像翻转了导致所有后续计算都偏移了。所以遇到精度问题别急着怀疑归一化先确认整个数据链路是不是自洽的。6.4 一套可以直接复用的归一化调试模板为了减少重复排查的时间我把自己常用的归一化相关工具函数整理成了一个模板这里分享给大家def normalize_points(pts): 二维点集预归一化返回归一化后的点和变换矩阵 pts np.asarray(pts, dtypenp.float64) centroid np.mean(pts, axis0) pts_centered pts - centroid mean_dist np.mean(np.sqrt(np.sum(pts_centered**2, axis1))) scale np.sqrt(2.0) / (mean_dist 1e-8) T np.array([[scale, 0, -scale * centroid[0]], [0, scale, -scale * centroid[1]], [0, 0, 1]]) pts_homo np.hstack([pts, np.ones((pts.shape[0], 1))]) pts_norm (T pts_homo.T).T[:, :2] return pts_norm, T这个小函数在做基础矩阵估计、单应矩阵求解时可以直接用。核心思路就是先平移再缩放让点集的均值为0、到原点的平均距离为根号2之后再做矩阵分解或线性求解数值稳定性会明显改善。7. 写在最后的几条实操心得做了这么多年视觉相关的工作我对归一化的理解经历了从“一个公式”到“一条贯穿始终的主线”的转变。最开始学的时候觉得这不就是把数变小一点吗后来才慢慢发现它背后连接的是相机投影模型的几何本质、数值线性代数的稳定性要求、以及从二维像素反推三维空间的计算链条。毫不夸张地说把归一化真正理解透了视觉SLAM、三维重建、工业测量这些方向的很多算法你回头看都会清晰很多。如果你正在做相机标定相关的工作我的建议是不要只停留在调用OpenCV的API上自己动手写一遍标定的核心流程哪怕写得很慢、很简陋但当你亲手在代码里完成特征点提取、归一化、SVD分解、反变换这几步时之前很多模糊的概念都会变得具体起来。这个手工实现的过程比看十篇教程都管用。最后分享一个小技巧在实际工程里始终在代码的关键位置保留一份“归一化坐标”和“像素坐标”的互转工具函数并在注释里写清楚转换公式和坐标系定义。视觉项目调试时坐标系混乱是最折磨人的问题前面多花几分钟把工具函数写明白后面能帮你省下好几个小时甚至一整天的排查时间。
返回列表