水下相机标定,十个项目里我能见到六七个拿空气标定参数直接糊弄的。刚开始我也这么干过,直到有次做水下目标测距,用空气参数算出来的距离误差超过了25%,整个系统联调差点翻车。后来我把水下相机标定的流程重新梳理了一遍,换成水下实拍标定板的方式,用Python配合OpenCV扎扎实实标了一套内参,问题才真正解决。这篇文章就把这条完整的路走一遍,从光路原理、设备准备、图像采集规范到可以直接抄的Python代码,以及我踩过的坑,一次性说清楚。
1. 空气标定法为什么一进水就废
1.1 水下相机看到的根本不是一条直线
很多人的思路很直接:相机在空气里标定好了,密封到防水壳里不就行了?反正镜头没变。问题恰恰出在这个“反正”上。
水下相机的光线路径是:目标→水体→防水壳视窗玻璃→密封舱内空气→相机镜头。光线每穿过一种不同折射率的介质,路径就会偏折一次。决定偏折量的是斯涅耳定律:
n₁·sin(θ₁) = n₂·sin(θ₂)
水的折射率约1.333,空气约1.0,玻璃约1.5。当光线接近垂直入射时,偏折很小;但视场边缘的光线以大角度斜着打入视窗,偏折就非常明显。结果就是:同一个镜头放进水里以后,视场角变小,目标在画面里看起来比实际距离更近,所有基于针孔模型的几何关系全部不再成立。
这里要特别澄清一个概念:水下成像不是你简单换个拍摄介质拍出来效果模糊一点,而是空间投影关系整体变了。你拿空气里标出来的焦距去还原三维坐标,等于拿北京的尺子量上海的布,尺子本身是对的,但测量对象已经换了体系。
1.2 等效焦距到底差多少
工程上有个经验近似:水下等效焦距大约等于空气中焦距乘以水的折射率。
f_water ≈ n_water × f_air ≈ 1.333 × f_air
举个例子。一个镜头在空气中标定出的焦距是1000像素,扔到水下之后,等效焦距大约会变成1333像素。这个偏差非常可观。配合一个2000×1500的传感器画面,视场角的变化肉眼就能看出来。
但需要说明:这个“乘1.333”只是在视场中心附近相对靠谱的近似。越偏离光轴中心,平面视窗带来的折射非线性越强,畸变模式也跟空气中不一样。真实的折射路径还得考虑视窗厚度、视窗到镜头的距离、防水壳的曲率等因素。
1.3 空气参数直接下水,误差有多大
做一个简单的单目测距推演。
假设你在空气中标定得到焦距 f_air = 1000px,把一个直径100mm的圆盘放在水中距离相机1m处。由于水下等效焦距变为约1333px,同一个圆盘在图像里的像素高度会比空气里更大。如果你还拿空气参数算:
估计距离 = 真实尺寸 × f_air / 像素高度 ≈ 100mm × 1000 / 像素高度
真实距离却是:
真实距离 = 真实尺寸 × f_water / 像素高度 ≈ 100mm × 1333 / 像素高度
两者一除,空气参数估计出来的距离大约是真实距离的0.75倍。换句话说,目标明明在1米外,系统告诉你它只有0.75米,误差25%。
25%的误差是什么概念?做水下机器人抓取,机械臂伸过去直接抓空;做水下结构测量,螺栓间距量出来全部偏小;做双目视觉,三角测量出来的深度一样是错的。这就是“空气标定法糊弄”的代价。
2. 水下直接标定为什么是更靠谱的路线
2.1 两条技术路线对比
搞定水下标定主要有两条路线,我放在一起对比:
| 路线 | 核心思路 | 精度上限 | 实施难度 | 适用场景 |
|---|---|---|---|---|
| 物理折射模型 | 对水、玻璃、空气逐层建模,用斯涅耳定律追迹光线 | 高,可达毫米级 | 高,需精确测量视窗厚度、折射率、镜头位置 | 广角镜头、亚毫米级三维重建、科研级项目 |
| 水下实拍标定 | 直接把标定板放进水里,用OpenCV针孔模型拟合 | 中等,通常优于厘米级 | 低,常规标定流程即可 | 绝大多数水下检测、测量、机器人项目 |
物理折射模型的优势是严谨,但它有一个工程上非常难受的硬前提:你必须准确知道防水壳视窗的曲率、厚度、材质折射率,以及镜头到视窗的距离。这些数据在设备手册里常常没有,实测又需要专用仪器。而且算法实现复杂,调试周期以周为单位。我见过不少团队在这条路上耗了一个多月,最后精度还不如水下直接标定。
水下实拍标定的逻辑很朴素:既然折射规律在水下是固定不变的,我直接把标定板放到真实工作环境里,让标定算法自己去吸收折射带来的形变。算法不需要理解折射具体怎么发生,它只需要拟合出一个在“这个水下环境”里能用的等效模型。
2.2 为什么OpenCV的针孔模型在水下也能用
有人会问:OpenCV标定假设的是光线直线传播,水下光线明明是弯的,这样标出来的参数有意义吗?
有,但有边界条件。
水下实拍标定时,标定板上的每个角点确实是从一个非线性光路映射到像素坐标的。OpenCV在做的事,是用一个针孔相机模型加一组畸变系数去拟合这套非线性映射。这个拟合结果不是物理上的真实参数,而是一个“等效参数”:等效焦距、等效畸变。它虽然在物理上不能完美还原光路,但在标定板覆盖过的空间范围内,用它来做投影和反投影是自洽的。
这就像你用一把弯尺子量完一整张桌子,给每段桌面标出刻度,只要你全程用同一把弯尺子,结果依然是可用的。水下标定的关键不是“模型物理上绝对正确”,而是“成像过程前后自洽”。
2.3 什么时候必须放弃标准模型
如果你的相机是大广角甚至鱼眼镜头,标准针孔模型加畸变系数可能兜不住边缘的折射非线性。此时我建议先试OpenCV的鱼眼相机模型(cv2.fisheye),它用等距投影模型拟合大角度畸变,水下效果通常比普通模型好。
如果连鱼眼模型都满足不了毫米级精度,再去考虑物理折射模型。但说实话,大部分工程项目的精度需求是厘米级到毫米级之间,水下实拍标定足够。
3. 下水前把这些设备和拍摄细节先搞定
3.1 标定板怎么选,怎么带下水
标定板是整套流程里最容易“凑合”出问题的环节。普通的A4打印纸加普通墨水,下水十分钟就皱、掉色、反光,角点提取基本报废。
我试过几种方案,最推荐的是高密度PVC板打印棋盘格,表面覆一层哑光防水膜,格子尺寸30mm,内角点数9×6。整套板子大概150mm×100mm,厚度适中,水下不容易弯折,也不会因为太轻飘在水里定不住。
几种常见标定板材质对比:
| 材质 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| 冷裱防水膜打印纸 | 便宜、制作快 | 表面易起泡、贴不平、容易反光 | 一般 |
| 高密度PVC板覆哑光膜 | 平整、耐用、价格适中 | 需要找打印店定制 | 推荐 |
| 陶瓷/玻璃基板 | 精度高、完全防水、平整度极好 | 贵、重、运输易碎 | 高精度场景用 |
| 不锈钢腐蚀棋盘格 | 耐用、精度高、适合水下长期使用 | 成本高、制作周期长 | 长期部署用 |
角点数建议9×6或10×7(注意这是内角点数,不是棋盘格的格子总数)。格子尺寸20到30mm比较合适:太小了水下清晰度不够,角点提取容易失败;太大近距离拍摄时板子占不满视野。
3.2 相机、防水壳和拍摄姿态
一个必须强调的原则:标定时用哪个相机配哪个防水壳,实际干活时也必须用同一个组合。防水壳的视窗厚度和材质会影响折射,换一个壳就等于换了一套光路,标定结果作废。
另外,镜头必须手动对焦并锁定焦距。水下自动对焦会因为水流、光线变化来回拉风箱,焦距一直在漂,标定出来的内参也就没有意义。如果相机没有手动对焦,就尽量固定拍摄距离,但这样会牺牲标定灵活性,能锁焦一定锁焦。
拍摄时的姿态要覆盖足够多的角度变化。下面是我实际执行时的规范:
- 数量:至少拍30张,能拍40到60张最好
- 角度:正视、左右旋转20到30度、上下倾斜20到30度都要覆盖
- 距离:近、中、远三个距离范围都要有
- 位置:棋盘格要分别出现在画面中心、四个角落、四条边缘
- 占比:棋盘格在画面里占比大约为1/3到2/3,太小提取不稳定,太满又缺乏周围环境约束
- 变化:不要只在同一个平面内平移板子,那样外参太相似,标定会退化
还有一个小技巧:水下拍摄时,可以固定相机,通过调整标定板姿态来获取不同角度。这样比人拿着相机晃来晃去要稳定得多,图像清晰度也更有保障。
4. 完整的Python标定代码与结果解读
4.1 环境准备
用到的库只有两个:
pip install opencv-python numpy建议Python版本3.9以上。装完以后,把拍摄的水下标定板图片放到一个目录下,比如叫underwater_calib。
4.2 角点检测与过滤坏图
下面这段代码会遍历目录下所有jpg图片,检测棋盘格角点,并输出带角点标注的可视化图片。凡是没有检测到角点的图,直接打印出来,便于剔除。
import cv2 import numpy as np import glob # 棋盘格内角点数:9列x6行 pattern_size = (9, 6) square_size = 30.0 # 单位mm,按实际标定板填写 # 生成标定板物理坐标系下的三维点坐标 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 objpoints = [] imgpoints = [] images = sorted(glob.glob('underwater_calib/*.jpg')) for fname in images: img = cv2.imread(fname) if img is None: print(f"读取失败: {fname}") continue gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, pattern_size, None) if ret: # 亚像素精细化,把角点定位到亚像素精度 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners) vis = cv2.drawChessboardCorners(img.copy(), pattern_size, corners, ret) cv2.imwrite(fname.replace('.jpg', '_checked.jpg'), vis) print(f"检测成功: {fname},角点数 {len(corners)}") else: print(f"检测失败: {fname}")这段代码跑完以后,去underwater_calib目录里看带_checked后缀的图片,逐张确认角点是否标在正确位置。角点错一个,整张图的数据都会污染标定结果,宁可少用两张,也不要用错图。
4.3 标定主流程与重投影误差
角点提取完成后,接着做标定计算和误差统计。
h, w = gray.shape[:2] # calibrateCamera返回内参矩阵mtx、畸变系数dist、每张图的外参rvecs/tvecs ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, (w, h), None, None ) # 计算整体重投影误差 total_err = 0 for i in range(len(objpoints)): proj_points, _ = cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist) err = cv2.norm(imgpoints[i], proj_points, cv2.NORM_L2) / len(proj_points) total_err += err mean_err = total_err / len(objpoints) print(f"平均重投影误差: {mean_err:.4f} px") print("相机内参矩阵:") print(mtx) print("畸变系数(k1, k2, p1, p2, k3):") print(dist.ravel())重投影误差是评价标定质量最直接的一个指标。简单说,就是把标定板物理坐标重新投影回图像平面,看和实际检测到的角点像素坐标差了多少像素。我个人的经验线是:
- 小于0.1像素:非常好,可以放心用
- 0.1到0.3像素:正常水平,适合绝大多数水下项目
- 0.3到0.5像素:能用,但要检查一下是不是有坏图混进来了
- 大于0.5像素:必须排查,多为标定板弯曲、图像模糊或角度覆盖不足导致
4.4 剔除异常图像并重标定
如果整体误差偏大,用下面这段代码把误差最大的几张图挑出来,从集合里删掉再重新标定。
err_list = [] for i in range(len(objpoints)): proj_points, _ = cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist) err = cv2.norm(imgpoints[i], proj_points, cv2.NORM_L2) / len(proj_points) err_list.append(err) bad_index = [i for i, e in enumerate(err_list) if e > 0.5] print(f"疑似坏图数量: {len(bad_index)}") for i in bad_index: print(f"第{i}张,误差 {err_list[i]:.3f}px,文件: {images[i]}") # 剔除坏图 filtered_objpoints = [objpoints[i] for i in range(len(objpoints)) if i not in bad_index] filtered_imgpoints = [imgpoints[i] for i in range(len(imgpoints)) if i not in bad_index] ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( filtered_objpoints, filtered_imgpoints, (w, h), None, None )重标定之后再看平均重投影误差,如果降下来了,说明问题确实出在坏图上;如果还是高,那就不是一两张图的事,得回去重新拍。
4.5 去畸变,生成可直接用的图像
标定的最终目的通常是把畸变图像校正成直线投影图像。下面这段代码生成去畸变映射并输出结果。
# alpha=1表示保留所有原始像素,图像边缘可能出现黑边 # alpha=0表示裁剪掉黑边区域 newcameramtx, roi = cv2.getOptimalNewCameraMatrix( mtx, dist, (w, h), alpha=0, newImgSize=(w, h) ) mapx, mapy = cv2.initUndistortRectifyMap( mtx, dist, None, newcameramtx, (w, h), cv2.CV_32FC1 ) sample_img = cv2.imread(images[0]) undistorted_img = cv2.remap(sample_img, mapx, mapy, cv2.INTER_LINEAR) cv2.imwrite('undistorted_sample.jpg', undistorted_img) np.savez('calib_result.npz', mtx=mtx, dist=dist, newcameramtx=newcameramtx) print("标定结果已保存到 calib_result.npz")如果你的相机是广角,普通去畸变后边缘可能仍然有明显弯曲残留,这是正常现象。此时可以尝试用鱼眼模型:
# 鱼眼模型标定,需要重新整理数据格式 import cv2 import numpy as np obj_fish = [objp.reshape(1, -1, 3).astype(np.float64)] img_fish = [corners.reshape(1, -1, 2).astype(np.float64) for corners in imgpoints] obj_fish = [objp.reshape(1, -1, 3).astype(np.float64) for _ in range(len(imgpoints))] rms, K, D, rvecs, tvecs = cv2.fisheye.calibrate( obj_fish, img_fish, (w, h), None, None ) print("鱼眼模型重投影误差:", rms) print("K:", K) print("D:", D)鱼眼模型的标定原理和普通模型不同,比较适合大视场角镜头在水下折射叠加后的畸变补偿。如果你的普通模型标定完重投影误差始终很大,强烈建议试一下鱼眼路线。
5. 用重投影误差和实测尺寸双重验证
5.1 重投影误差不能全信
重投影误差只反映“拟合自洽性”,不代表三维测量精度。一个反直觉的情况是:你用60张图做标定,重投影误差很小,但实际测距依然有偏差。原因在于模型本身的系统误差被平均进了等效参数里,在标定板覆盖的空间范围内自洽,一旦超出这个范围,误差就会放大。
所以验证要分两步走:先看重投影误差,再做真实尺寸验证。
5.2 用solvePnP反推标定板姿态
拿一张标定时的图片做单目姿态估计,看看求出来的棋盘格边长和实际是否一致。
# 选一张标定成功的图片 test_img = cv2.imread(images[0]) gray = cv2.cvtColor(test_img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, pattern_size, None) corners = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) # solvePnP求标定板相对于相机的位姿 _, rvec, tvec = cv2.solvePnP(objp, corners, mtx, dist) # 把标定板上两个相邻角点投影回图像,验证距离 p1 = np.array([[0, 0, 0]], dtype=np.float64) p2 = np.array([[square_size, 0, 0]], dtype=np.float64) p1_proj, _ = cv2.projectPoints(p1, rvec, tvec, mtx, dist) p2_proj, _ = cv2.projectPoints(p2, rvec, tvec, mtx, dist) pixel_dist = np.linalg.norm(p2_proj.ravel() - p1_proj.ravel()) print(f"相邻角点像素距离: {pixel_dist:.2f}px")这个像素距离和直接用检测到的相邻角点算出的像素距离做对比,两者应该非常接近。如果差得多,说明模型在水下的投影关系没有拟合好。
5.3 放一个已知尺寸的目标实测
最有效的验证永远是实测。我会把一个已知尺寸的结构件放到水下不同距离处,包括中心的0.5米、1米、1.5米以及视场边缘位置,用标定参数做单目测距。误差在3%以内,说明这套内参可以用;超过5%,就要考虑是不是标定图像覆盖不全或模型不匹配。
6. 水下标定的坑、误区和效率技巧
6.1 最常见的大坑:空气图和水下图混着标
有人图省事,空气里拍20张,水里拍20张,凑在一起标定。这是最致命的一个错误。空气和水下的折射差异会让优化算法在两个互相矛盾的模型之间来回折中,最终标出来的内参两头不靠,两种环境里都不可用。
正确做法是:水下项目全部用下水后拍摄的图像标定,拍摄环境尽量贴近真实作业场景。
6.2 标定板翘曲和气泡
PVC板在水里泡久了,边缘容易翘曲,尤其是不覆膜或者覆膜不均匀的板子。翘曲板子的角点在物理空间里不在同一个平面上,标定模型无法拟合这种形变,重投影误差必然偏高。
另外,水里的小气泡很容易吸附在标定板表面,刚好盖在一个角点上,导致角点检测失败或位置偏移。拍之前拿手或水流冲一下标定板表面,把气泡赶走。
6.3 水下图像对比度低,角点检测老失败
水体对光线的散射和吸收会让图像变灰、变蓝,对比度明显下降。如果你发现角点检测成功率很低,可以先对灰度图做一次直方图均衡化再检测:
gray_eq = cv2.equalizeHist(gray) ret, corners = cv2.findChessboardCorners(gray_eq, pattern_size, None)如果还是不理想,可以用CLAHE局部自适应直方图均衡化:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray_clahe = clahe.apply(gray)注意,这个预处理只用于角点检测,不要拿它去代替灰度图参与标定计算。标定用的角点坐标还是在原始灰度图上做亚像素提取更稳妥。
6.4 灯光和反光
水下拍摄必须补光,但灯光太硬会在标定板表面形成镜面反射区,反光区域的角点会被直接吞掉。解决办法是让光源尽量大面积、柔和地打在板面上,或者调整光源和板面的角度,避免入射角等于反射角。玻璃防水壳表面的反光是另一个麻烦,拍摄时稍微偏转相机角度就能避开。
6.5 混用不同画幅和镜头
这是低级错误,但真发生过:标定时用了1920×1080的图像,实际部署时把相机分辨率切成1280×720。分辨率一变,内参矩阵完全失效。标定必须针对实际使用的分辨率、实际使用的镜头焦距、实际使用的防水壳组合,不能跨组合复用。
6.6 一个实用的小技巧:连拍提高成功率
水下环境中人和设备都在轻微运动,单张照片很容易模糊。我会建议拍摄时对同一个姿态连续拍3到5张,然后挑最清晰的一张用。挑图的标准是棋盘格黑白交界线干净锐利,没有虚影。稍微多花点时间选图,后面标定的省心程度会高很多。
我自己的做法是拍完回来先把所有图过一遍,检测失败的先删掉,检测成功的再逐张看_checked可视化图,重点确认边缘角点有没有标歪。整个过程大概花15分钟,但换来的是标定参数的可信度。这也是为什么我现在敢直接说:别再拿空气标定法糊弄了,在水里真正标一次,你会回来感谢自己的。