☰
水下相机标定避坑指南:从空气标定到轴向折射模型实践
2026/10/5 6:09:38 网站建设 项目流程

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(f"OpenCV初始重投影误差: {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, dtype=np.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), method='trf', bounds=(low, high), max_nfev=2000, verbose=1 ) # 提取优化后的参数 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]], dtype=np.float64) dist = np.array(dist, dtype=np.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', K=K, dist=dist) 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像素以内,实测测量精度也基本能满足绝大多数工程需求。如果你也被水下测距不准、重建变形这类问题困扰,不妨从标定这一步开始排查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询