
1. 为什么水下相机标定不能拿空气标定法凑合1.1 空气标定到底标定了什么先说一个基本认知OpenCV里大家最常用的cv2.calibrateCamera本质是基于张正友标定法求解的是“针孔模型 畸变模型”的一组参数。针孔模型描述理想成像关系即三维世界坐标中的点经过外参变换到相机坐标系再经过焦距映射到像素平面畸变模型则补偿镜头本身带来的径向和切向变形通常就是k1、k2、p1、p2、k3这五个系数。这套模型隐含了一个非常关键的假设光线从目标点出发到相机传感器之间只穿过一种均匀介质沿直线传播。在空气里这个假设基本成立所以标定出来的内参很干净重投影误差也能压得很低。换句话说空气标定得到的是一个“描述相机在空气环境下的映射关系”的参数组。很多人忽略的是这套参数只对“光路不拐弯”的环境有效。一旦相机进入水下镜头前方多了一层防水玻璃水里还有悬浮粒子、温差、盐度梯度光路早就不是空气里那条直线了。这时候再拿空气标定出来的内参去解算三维坐标理论上就已经不成立了。1.2 折射如何毁掉你的假设水下相机的典型结构是这样的相机本体密封在防水罩里防水罩前窗是一层平面玻璃或者球形玻璃外面是水里面是空气。光线从水中的目标点出发先进入玻璃再进入空气最后到达传感器。每经过一个不同介质的交界面光线方向就会发生一次偏折这就是中学物理里的斯涅耳定律n1 * sin(θ1) n2 * sin(θ2)水的折射率大约是1.333玻璃大约1.5空气约等于1.0。光从水进入空气时入射角和折射角的差异会非常明显。举例来说如果光线在水中以30度的入射角打到防水罩平面进入空气后的折射角会增大到接近42度。光路在界面上“拐了弯”但相机镜头并不知道这一点它仍然按直线传播去反算目标点位置于是像素坐标和真实世界坐标之间就产生了系统性偏差。这里有个最容易踩的误区很多人觉得“我的防水罩是高透光玻璃厚度很小影响可以忽略”。实际上玻璃厚度可以忽略但水到空气的折射不能忽略。哪怕玻璃的厚度为零只要镜头前方存在水与空气的交界面光路就会偏折。对于广角镜头或者目标点偏离光轴较远的情况这种偏折造成的像素偏移能达到几十甚至上百像素直接映射到三维空间里就是几个厘米甚至更大的测量误差。我在一个泳池视觉定位项目里做过粗略对比同样一个目标在1.5米距离处空气标定参数下水下实测的定位误差超过了3厘米而换用水下重新标定的参数后误差控制在1厘米以内。这不是玄学就是折射在作怪。1.3 什么时候空气标定可以“蒙混过关”也不是说空气标定一文不值。如果满足以下条件用空气标定结果凑合一下还能接受相机视场角很小目标基本在画面中心附近防水罩是薄平面玻璃光线近似垂直入射任务只是做目标检测和分类不做精确测距或三维重建对测量精度的要求很宽松误差几厘米甚至十几厘米都能忍。但一旦你的任务是水下目标定位、避障、三维重建、裂缝测量、构件尺寸检测这类需要和世界坐标打交道的场景空气标定就必须被扔到一边。这类任务要求像素和物理空间的映射误差尽量小任何一个超出模型能力范围的系统误差都会直接变成最终测量的偏差。说白了空气标定法适用于“能糊弄过去”的场景却扛不住任何严格精度要求。我见过不少项目前期没当回事等到外场实测发现尺寸测不准回头查了半天才发现是标定环节埋了雷最后还是要老老实实重新做水下标定。2. 水下相机标定的主流方案与选型思路2.1 先分清你的系统是哪种结构动手标定之前先搞清楚你的相机是怎么防水的因为不同结构对光路的影响差别很大标定方案也不一样。第一种是浸入式水下相机镜头前端直接接触水。部分工业相机经过改造镜头保护镜片直接泡在水里中间没有空气层。这种结构其实最干净因为光线从水直接进入镜头内部理论上可以用常规标定方法配合修正折射率来计算但实际工程中大多也直接用水下拍图的方式标定。第二种是平面防水罩结构镜头依然泡在罩内的空气里前方是一块平面玻璃。这是最常见也最麻烦的结构。因为光线要穿过水、玻璃、空气三种介质而平面玻璃对光路的改变又与入射角强相关——光线越斜折射偏差越大。这种结构下图像边缘的畸变会比空气中标定出来的畸变明显得多。第三种是球形或半球形防水罩常见于运动相机潜水壳。球面窗口在光轴附近近似均匀折射但离轴之后仍然有复杂的像差。有些高端壳体把光心尽可能做得与球心重合能降低一部分折射影响但仍不能完全消除。区分清楚这三种结构是因为它们适用的标定模型完全不同。平面结构可以用高阶多项式畸变去逼近球形结构则更适合轴向折射模型或者至少要知道球面窗口带来的额外变形。2.2 核心原理轴向折射模型水下相机标定领域有一个比较重要的理论基础就是Agrawal等人提出的轴向折射相机模型。这个模型的核心思想可以这样理解在多层介质系统中光线不再从单一光心沿直线发出而是先经过一个虚拟光心附近的“等效光路”再发生轴向偏移。因此空间中一个三维点成像到传感器上的像素位置可以用一个带轴向畸变的针孔模型来参数化。这句话听起来有点绕但落到工程上它给了我们一个非常实用的自由度与其把水下的折射过程全部用物理公式去追迹光路不如在相机投影函数里增加一个“与入射角强相关”的非线性畸变项让畸变系数去吸收折射造成的系统性偏差。这就是为什么很多水下标定方案在OpenCV基础上直接增加畸变阶数把k1、k2、k3乃至更高阶的径向畸变一起拟合反而能明显降低重投影误差。轴向模型和普通针孔模型最大的区别在于普通模型的畸变是镜头本身的像差量级通常很小而轴向模型要容纳的畸变可能非常大尤其在图像边缘。如果只是机械地套用OpenCV自带的五参数或八参数畸变模型可能拟合不干净所以在进阶方案里我会把焦距、主点、畸变系数、外参全部放在同一个最小二乘问题里重新优化。需要说明的是这种“用高阶畸变拟合折射”的方案是一种工程近似不是严格物理模型。如果你的应用要求亚毫米级测量精度还是建议采用完整的多介质光线追迹方法或者使用专业的商业工具箱。但绝大多数工程场景下高阶畸变近似已经能提供足够好的精度实现成本也低得多。2.3 工程上怎么选标定方案选方案没有绝对标准主要看三件事防水罩结构、精度要求、现场条件。我整理了一张选型参考表方便你快速判断自己该走哪条路。系统结构精度要求推荐方案预期水平平面防水罩小视场仅目标识别厘米级以上即可空气标定近似或用快速方案在水下补拍重新拟合低平面防水罩需要测距或尺寸测量毫米到厘米级水下拍图 高阶径向畸变拟合中平面防水罩大视场三维重建亚厘米级轴向模型或完整多介质折射标定中高球形防水罩大视场中等精度轴向模型必要时结合球心对准中高浸入式相机科研级重建亚毫米级完整折射模型 光线追迹验证高如果现场有条件最可靠的方式永远是“水下实拍、水下标定”。不要图省事在岸上标完拿到水里用哪怕你最后用的只是OpenCV快速方案只要数据是在真实水下环境采集的就已经比空气标定靠谱得多。3. 手把手实操从标定板制作到数据采集3.1 标定板准备与防水处理标定板的选择直接影响角点检测质量。先说材质打印在普通A4纸上绝对不行纸张遇水会弯曲变形哪怕不接触水受潮后平面度也不达标。我建议使用陶瓷基板或者厚度在5毫米以上的亚克力板表面平整度好耐水泡。工业现场条件允许的话铝合金基板加上高精度印刷也不错。棋盘格尺寸要根据相机的工作距离和视场大小来定。一个粗略的经验法则是标定板在画面中大约占比30%到60%单个棋盘格在图像里至少要有10个像素以上的边长否则角点检测会抖。比如相机工作距离1米、水平视场约1.5米那么一块A3大小的棋盘格内角点9x6方格边长30毫米就比较合适。防水处理方面最常见的有三种打印后贴膜塑封、打印后滴胶封装、直接购买水下专用标定板。贴膜塑封成本最低但要注意膜表面不能有气泡和褶皱否则会造成伪角点。滴胶封装效果好表面光滑透明但制作比较费时间。如果只是临时验证也可以把亚克力标定板装进密封袋里拍到水中不过平整度和反光会稍微影响精度。制作好的标定板我第一次使用前会先在水盆里拍几张测试图放大逐个角点检查一遍确认没有反光、气泡、变形再正式采集。这一步很多人会跳过但省下的两分钟往往能给后面省出两小时。3.2 水下拍摄规范与硬件建议数据采集是整个标定流程里最需要耐心的一步。很多人拿标定板随手甩几下拍20张就完事结果解算出来内参不稳定重投影误差忽高忽低。水下拍摄和空气中拍摄的区别在于水流会带动标定板轻微晃动防水罩会反光水面波纹会造成局部图像模糊这些干扰都会污染角点坐标。拍摄时尽量遵循以下几点固定相机。相机不要手持最好安装在三脚架、水下支架或机器人云台上减少震动。增加辅助光源。水下光照衰减快能用补光灯尽量用保证棋盘格边缘清晰、黑白对比明显。位姿要有层次。标定板要覆盖画面的中心、四角、四边姿态上要有正对、左右倾斜、前后俯仰、旋转多个变化。简单说别让所有数据都集中在一个角度上。数量宁多勿少。我一般拍25到40张保证至少有20张能成功检测到完整角点。避开水面的直接反光。如果是水池或水箱环境注意调整光源和拍摄角度避免玻璃罩上出现大面积高光斑块。等水面平静再拍。如果有水流轻微波动会导致标定板边缘模糊角点检测精度直接下降。另外对焦也很关键。水下对焦和空气里不一样有些相机的自动对焦在罩内可能不准最好设置为手动对焦并在水中实际拍照确认清晰度。防水罩内如果有雾气也要先处理干净再开始拍摄。3.3 数据采集检查清单我习惯把检查项分成三个阶段拍完一批直接跑一遍检测避免攒到最后发现数据全废。采集前检查标定板表面平整、无气泡、无反光点镜头前窗清洁、无水垢、无划痕相机电池和存储卡容量充足光源角度合适画面上没有明显亮斑采集中检查每换一个位姿先看一眼回放确认棋盘格完整入画画面中棋盘格占比合适四个角没有跑出画面标定板没有因水流产生大幅倾斜和弯曲避免两个以上气泡附着在标定板表面采集后检查用脚本跑一遍角点检测确认哪些图能用统计成功检测的图片数量少于20张就补拍检查检测结果中是否存在明显跳变点比如角点连错行、列错位这套流程走下来基本能保证后面的标定求解不因数据质量而翻车。4. Python代码实现从快速方案到严谨模型4.2 进阶方案轴向折射模型的最小二乘优化快速方案的本质是用标准针孔模型“硬扛”折射误差。虽然能标出一个可用的内参但在大视场或高精度场景下五参数畸变可能不够用。这时我们需要一个更灵活的自定义投影模型把焦距、主点、畸变系数、每张图的外参全部放进同一个优化问题里求解。我的做法是先调用OpenCV的calibrateCamera得到一个初始解然后基于这个初值用scipy.optimize.least_squares重新拟合一个“高阶径向畸变 切向畸变”的投影函数。因为畸变系数会吸收一部分轴向折射误差所以重投影误差通常比直接用OpenCV结果要低很多。下面是完整的进阶代码框架。import numpy as np import cv2 import glob from scipy.optimize import least_squares # 棋盘格内角点数量注意是“内角点” CHESS_COLS 9 CHESS_ROWS 6 SQUARE_SIZE 20.0 # 方格边长单位mm criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) objp np.zeros((CHESS_COLS * CHESS_ROWS, 3), np.float32) objp[:, :2] np.mgrid[0:CHESS_COLS, 0:CHESS_ROWS].T.reshape(-1, 2) * SQUARE_SIZE image_paths sorted(glob.glob(underwater_images/*.jpg)) objpoints [] imgpoints [] image_size None for path in image_paths: img cv2.imread(path) if img is None: continue gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if image_size is None: image_size gray.shape[::-1] # (width, height) ret, corners cv2.findChessboardCorners(gray, (CHESS_COLS, CHESS_ROWS), None) if ret: corners2 cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp.copy()) imgpoints.append(corners2.reshape(-1, 2)) print(f{path}: 检测成功 {len(corners2)} 个角点) else: print(f{path}: 检测失败忽略) if len(objpoints) 15: raise RuntimeError(有效图像太少至少需要15张以上) # 第一步用OpenCV标准标定获得初始值 ret, K0, dist0, rvecs0, tvecs0 cv2.calibrateCamera( objpoints, imgpoints, image_size, None, None ) print(fOpenCV初始重投影误差: {ret:.4f} px) W, H image_size def pack_params(K, dist, rvecs, tvecs): 把所有优化变量展开成一维向量 params [] params.extend([K[0, 0], K[1, 1], K[0, 2], K[1, 2]]) # fx, fy, cx, cy params.extend(dist.ravel().tolist()) # k1,k2,p1,p2,k3 for r, t in zip(rvecs, tvecs): params.extend(r.ravel().tolist()) params.extend(t.ravel().tolist()) return np.array(params, dtypenp.float64) def unpack_params(params, num_images): 恢复出焦距、主点、畸变系数和外参 fx, fy, cx, cy params[0:4] dist params[4:9] idx 9 rvecs [] tvecs [] for _ in range(num_images): rvec params[idx:idx 3].reshape(3, 1) tvec params[idx 3:idx 6].reshape(3, 1) rvecs.append(rvec) tvecs.append(tvec) idx 6 return fx, fy, cx, cy, dist, rvecs, tvecs def project_with_refraction(points_3d, rvec, tvec, fx, fy, cx, cy, dist): 自定义投影函数针孔模型 高阶径向畸变/切向畸变 让畸变项去拟合水下折射造成的非线性误差。 rmat, _ cv2.Rodrigues(rvec) # 转到相机坐标系 pts_cam (rmat points_3d.T).T tvec.reshape(1, 3) # 归一化坐标 x pts_cam[:, 0] / (pts_cam[:, 2] 1e-12) y pts_cam[:, 1] / (pts_cam[:, 2] 1e-12) r2 x * x y * y k1, k2, p1, p2, k3 dist # 高阶径向畸变用于逼近轴向折射误差 radial 1 k1 * r2 k2 * r2 * r2 k3 * r2 * r2 * r2 x_dist x * radial 2 * p1 * x * y p2 * (r2 2 * x * x) y_dist y * radial p1 * (r2 2 * y * y) 2 * p2 * x * y u fx * x_dist cx v fy * y_dist cy return np.stack([u, v], axis-1) def compute_residuals(params, all_objpts, all_imgpts, num_images): 拼接所有观测点的重投影误差 fx, fy, cx, cy, dist, rvecs, tvecs unpack_params(params, num_images) residuals [] for i in range(num_images): proj project_with_refraction( all_objpts[i], rvecs[i], tvecs[i], fx, fy, cx, cy, dist ) residuals.append((proj - all_imgpts[i]).ravel()) return np.concatenate(residuals) # 打包初值 params0 pack_params(K0, dist0, rvecs0, tvecs0) num_images len(objpoints) # 设置优化变量边界 fx_low, fx_high W * 0.2, W * 5.0 fy_low, fy_high H * 0.2, H * 5.0 dist_low [-1, -1, -1, -1, -1] dist_high [1, 1, 1, 1, 1] low [fx_low, fy_low, 0, 0] dist_low [-2] * 6 * num_images high [fx_high, fy_high, W, H] dist_high [2] * 6 * num_images # 最小二乘优化 result least_squares( compute_residuals, params0, args(objpoints, imgpoints, num_images), methodtrf, bounds(low, high), max_nfev2000, verbose1 ) # 提取优化后的参数 fx, fy, cx, cy, dist, rvecs, tvecs unpack_params(result.x, num_images) K np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtypenp.float64) dist np.array(dist, dtypenp.float64) residuals compute_residuals(result.x, objpoints, imgpoints, num_images) rms np.sqrt(np.mean(residuals ** 2)) print(f自定义折射模型重投影误差: {rms:.4f} px) np.savez(camera_underwater_axial.npz, KK, distdist) print(内参矩阵:\n, K) print(畸变系数:, dist)这段代码的关键点有三个。第一dist里不再是镜头物理畸变而是“镜头畸变 折射误差”的合并效果所以可能会出现数值比较大的系数这很正常不要惊慌。第二优化函数里unpack_params把外参也当作变量一起优化如果不优化外参只调内参误差会集中到不正确的位姿估计上结果反而更差。第三边界设置很重要焦距给一个相对宽的范围外参的平移量给±2米左右的余量防止优化器乱跑。4.3 重投影误差与结果评估重投影误差是判断标定质量最直接的指标。它的含义是把标定板上的三维点用标定出的模型投影回图像平面计算投影点和实际检测角点之间的像素距离再对所有点取均方根。这个值越小说明模型和真实成像关系越吻合。重投影误差质量判断建议 0.3 px优秀可以直接用于大多数测量任务0.3 ~ 0.8 px良好水下标定中比较理想0.8 ~ 1.5 px勉强检查数据是否有残余问题 1.5 px较差重新采集数据或换用更严格模型需要提醒的是重投影误差只是一个相对指标。像素误差相同的情况下不同分辨率、不同工作距离对应的空间精度差别很大。一个200万像素的相机和一个1200万像素的相机0.5像素的误差意义完全不同。所以标定完成后一定要做一次端到端的实测验证在水下放置一个已知尺寸的物体用标定结果测量它的长度或位置看空间误差是否满足项目要求。5. 常见问题排查与独家避坑技巧5.1 问题排查速查表水下标定出问题很多时候不是模型不对而是数据有问题。下面是我实际项目中积累的排查表。现象可能原因解决建议重投影误差很大超过1.5px标定板不平面、弯曲变形换用刚性基板避免纸张和薄板角点检测经常失败水下对比度低、棋盘格反光增加补光调整拍摄角度误差集中在图像边缘折射误差没有被模型充分吸收使用高阶畸变或轴向折射模型解算出的焦距不断变化数据位姿太集中增加不同俯仰角、旋转位姿图像边缘模糊防水罩划痕、起雾、对焦不准清洁罩体手动对焦某张图误差远大于其他图该图存在运动模糊或波纹干扰删除该数据重新补拍主点偏移到画面角落数据量不足或模型冗余增加有效数据减少畸变阶数排查时不要迷信单一指标可以把每张图的平均误差打印出来找出异常图单独检查。很多时候问题就出在一两张模糊或反光的图上删掉它们之后整体误差立刻下降。5.2 独家避坑技巧这一节分享几条常规教程里不会写的经验。第一防水罩起雾是标定的头号杀手。水下温差一大罩内空气遇冷凝结镜头前窗蒙上一层薄雾角点检测出来的位置可能只偏差零点几像素但你根本察觉不到。我的做法是在防水罩里放一小包硅胶干燥剂并在下水前把罩子放在水里泡十分钟让内部温度先降下来避免带入热空气。别小看这个细节能省掉大量无效数据。第二标定板反光比想象中严重。亚克力板在水下会形成镜面反射如果补光灯直射棋盘格黑色方块上会出现高光导致角点附近出现灰阶跳变。最好的办法是让光源从侧面打光而不是从相机同方向直射。若条件允许用两盏灯左右对打让标定板表面照度均匀。第三不要忽视外参的初始值。进阶方案里我用OpenCV标定结果作为初值这非常关键。直接随机初始化会让最小二乘优化陷入局部极小值得到一组看似不错但实际错误的参数。如果你发现两次运行结果相差很大大概率是初值不稳定造成的。第四水下标定不是一劳永逸的。防水罩一旦拆装过、前窗玻璃磕碰过、或相机更换过镜头光路就变了之前标定的参数必须重做。项目期间最好固定相机和防水罩的相对位置用螺纹或卡扣锁死不要每次下水前手拧一下那种状态根本谈不上标定一致性。第五如果条件允许做一个尺度验证。在实际环境中放置一个已知长度的刚性杆用标定参数做单目测距或双目立体匹配测出来的长度能反映标定模型的真实精度。这一步比盯着重投影误差有用得多。最后说说我个人的体会。做水下视觉这些年标定环节是我最不愿意省时间的部分。空气标定法在岸上看着方便下了水就是给自己埋雷。真正稳妥的流程是准备一块刚性防水标定板花一个小时在水下采集三十张有效图片再用文中的方案做一次完整优化。这套流程跑下来我的经验是重投影误差普遍能控制在0.5像素以内实测测量精度也基本能满足绝大多数工程需求。如果你也被水下测距不准、重建变形这类问题困扰不妨从标定这一步开始排查。