ARTICLE DETAIL

资讯详情

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

单目+双目三维重建实战:从标定到点云的完整Python实现

单目+双目三维重建实战:从标定到点云的完整Python实现 简介这套源码基于Python实现单目与双目视觉三维重建覆盖单目关键点估计、双目深度恢复、图像拼接等典型任务面向计算机视觉方向的毕业设计、课程设计或项目开发。压缩包共42个文件整体大小约80.22MB包含3个Python脚本、1个Markdown说明、3个文本辅助文档、34张JPG图像及PNG示意文件。脚本按单目、双目、图像融合模块拆分图片数据可用于相机标定、特征提取与重建效果对比文档对工程结构、运行逻辑和依赖环境作了梳理。源码经过严格测试并保留清晰的目录结构便于二次开发、算法替换或数据集扩展。目前已有172人学习下载适合希望系统理解三维重建全流程并快速动手实践的开发者参考。1. 单目双目三维重建是课程设计的完整闭环不是两个模块的简单拼装如果你正在做课程设计或毕业设计看到“基于python开发的单目双目视觉三维重建源码”这个标题先别把它理解成“单目一套、双目一套、各跑各的”。实际能拿到分、能写进论文里的方案是一套从图像采集、标定、立体匹配走到三维点云的完整工程单目负责快速测距和粗定位双目负责稠密视差和点云重建两者在同一个Python工程里互补。这套东西适合需要完整演示效果、又要能讲清楚原理的课设和毕设也适合刚接手视觉项目开发的从业者用来建立坐标系、标定、视差、点云这条主线认知。下面按这条主线把原理、可复现代码和踩坑点讲透。2. 单目三维重建的真实边界它不是重建是带先验的深度估计2.1 单目为什么解不出绝对深度从4个自由度和相机方程说起把三维空间里的一个点投影到二维图像小孔成像模型写下来是世界坐标 (X, Y, Z) 通过外参变换到相机坐标 (Xc, Yc, Zc)再通过内参投影到像素坐标 (u, v)整个过程里已知的是像素坐标未知的是包括尺度在内的4个自由度。单个相机只有两条约束方程深度 Z 被直接丢掉了。所以“单目三维重建”严格讲不成立单目只能做“带先验的深度估计”这也是很多毕设开题时被答辩老师追问最多的地方。常见的先验有这么几种已知目标尺寸比如人脸宽度、车牌宽度、ARUCO码边长用像素宽度反推距离已知地面平面通过消失点和相机高度估计路面上物体的距离运动视差同一目标在相机移动中出现视差本质是“用时间换视角”在这个标题的项目里单目模块最常见的角色是第一种先测出目标大概距离再指导双目在什么深度范围、什么图像区域里做稠密匹配。理解了这一点你就知道标题里的“单目”不是用来输出点云的它是双目的前置引导。2.2 先落地一个能跑的单目测距ArUco标记加已知尺寸的快速实现在Python里做单目测距最简单可靠的方式是用ArUco码。它本身自带角点检测和ID识别不需要训练模型标定一次相机内参后就能稳定测距。import cv2 import numpy as np # 注意OpenCV 4.7以下用Dictionary_get4.7以上建议用getPredefinedDictionary aruco_dict cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) params cv2.aruco.DetectorParameters_create() KNOWN_WIDTH_MM 80 # ArUco码的实际边长单位毫米 fx 812.34 # 相机内参fx来自棋盘格标定单位像素 cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: break corners, ids, _ cv2.aruco.detectMarkers(frame, aruco_dict, parametersparams) if ids is not None: # corners[0] 是第一个marker的四角点顺序是左上、右上、右下、左下 width_px np.linalg.norm(corners[0][0][0] - corners[0][0][1]) if width_px 1e-6: distance_mm KNOWN_WIDTH_MM * fx / width_px cv2.putText(frame, fdist: {distance_mm/1000:.2f} m, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2) cv2.imshow(mono_distance, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这里的核心公式是D W * fx / w_px它来自相似三角形目标实际宽度 W在图像里占了 w_px 像素焦距 fx 已知距离就被唯一确定。需要注意fx必须是像素单位不能拿毫米焦距直接代入width_px取的是ArUco码上边两个角点的距离不是整张图宽度测距结果对KNOWN_WIDTH_MM极其敏感贴码之前用卡尺量准如果你没有ArUco码也可以打印一张黑色正方形贴在硬纸板上用轮廓检测取边长。但ArUco的好处是自带ID后续做多目标定位、目标身份区分都方便在课程设计的演示环节也更完整。这个代码跑通后单目模块就完成了一大半。2.3 单目测距的三个参数陷阱目标宽度、观测角度、焦距误差单目测距看起来只有一行公式实际调参时容易翻车的地方不少。第一目标宽度不准。宽度误差会线性传导到距离误差80mm的码你量成90mm测出的距离就会虚高12.5%。所以不要用卷尺大概量一下要精确到毫米。第二观测角度不是正对。公式假设相机光轴垂直指向ArUco码平面。一旦目标倾斜图像里测到的宽度会变短距离就被高估。斜着30度时误差通常在15%以上。解决方案是在测距前用cv2.aruco.estimatePoseSingleMarkers解出旋转向量判断角度超过阈值就告警或不做测距。第三fx不准。很多人图省事用图像宽度/(2*tan(FOV/2))估算fx这种近似在画面边缘误差很大。单目测距的精度上限由fx决定而fx必须来自标定。你可以在后续双目标定阶段顺便把单目内参一起标出来不要单独省这步。这三个坑告诉我们一个结论单目测距只能给出“大概在这个深度范围”的先验别拿它当精密仪器。在标题这种“单目双目”项目里单目的任务是把目标深度从完全未知缩小到某个区间剩下的事交给双目。3. 双目三维重建的完整链路从标定到点云一条命令一个节点3.1 视差与深度为什么基线越大越能看见“深度”双目视觉能恢复深度靠的是左右相机对同一空间点的“视差”。三维点越近它在左右图里的水平位置差越大越远视差越小这就是人眼判断远近的原理。数学关系是depth fx * baseline / disparitybaseline左右相机光心之间的距离单位米disparity左右图像的像素横坐标差单位像素fx内参中的焦距单位像素从这个公式能读出两个关键判断基线越大相同视差对应的深度越大远距离精度越好视差误差对深度的影响与距离平方成正比所以双目重建远距离物体时精度掉得特别快这决定了做三维重建时的硬件选择课设/毕设阶段如果手里只有普通的USB双摄模组基线一般只有6~12cm有效重建距离通常在0.2~3米。超出这个范围点云会变得很稀疏这是物理限制不是代码能完全弥补的。整个双目三维重建流程在OpenCV里是一条固定流水线单目标定 - 立体标定 - 立体校正 - 立体匹配 - 点云生成。下面按这个顺序把每一段代码和参数说清楚。3.2 第一步棋盘格采集与相机内参标定双目重建的地基是内参和畸变系数。不做标定直接跑SGBM出来的点云会扭曲得不成样子。第一步是采集标定板图像并计算左右相机内参。import cv2 import numpy as np # 棋盘格的内角点数例如打印的是10x7格子内角点就是(9,6) CHECKERBOARD (9, 6) criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) objp np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objpoints [] # 3D角点单位可以理解为“格数”后续需要乘实际格子边长 imgpoints_l [] imgpoints_r [] for name_l, name_r in zip(files_left, files_right): img_l cv2.imread(name_l) img_r cv2.imread(name_r) gray_l cv2.cvtColor(img_l, cv2.COLOR_BGR2GRAY) gray_r cv2.cvtColor(img_r, cv2.COLOR_BGR2GRAY) ret_l, corners_l cv2.findChessboardCorners(gray_l, CHECKERBOARD, None) ret_r, corners_r cv2.findChessboardCorners(gray_r, CHECKERBOARD, None) if ret_l and ret_r: objpoints.append(objp) corners_l cv2.cornerSubPix(gray_l, corners_l, (11, 11), (-1, -1), criteria) corners_r cv2.cornerSubPix(gray_r, corners_r, (11, 11), (-1, -1), criteria) imgpoints_l.append(corners_l) imgpoints_r.append(corners_r) ret_l, K1, D1, _, _ cv2.calibrateCamera(objpoints, imgpoints_l, gray_l.shape[::-1], None, None) ret_r, K2, D2, _, _ cv2.calibrateCamera(objpoints, imgpoints_r, gray_r.shape[::-1], None, None) np.savez(camera_params.npz, K1K1, D1D1, K2K2, D2D2, ret_lret_l, ret_rret_r) print(left rms:, ret_l, right rms:, ret_r)这里有一个常见误解CHECKERBOARD填的是内角点数量不是棋盘格数量。如果打印的棋盘是10x7个小方格内角点就是9x6写错的话findChessboardCorners会一直失败。另外objp里每个角点的坐标单位是“格”不是毫米。如果你之后希望点云以米为单位需要在三角化时乘上棋盘格的实际边长。calibrateCamera返回的ret就是重投影误差单位是像素小于0.3说明标定质量不错超过0.5就需要检查图片数量和角度分布。3.3 第二步立体校正让左右图严格行对齐左右相机安装时不可能完全平行直接做匹配会找不到对应点。立体校正就是把左右图像重投影到同一个理想平面上让同名点处于同一行。# 继续上面的变量K1, D1, K2, D2 是单目标定结果 # R 是右相机相对左相机的旋转矩阵T 是平移向量来自 stereocalibrate ret_stereo, K1, D1, K2, D2, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, K1, D1, K2, D2, gray_l.shape[::-1], criteriacriteria, flagscv2.CALIB_FIX_INTRINSIC) R1, R2, P1, P2, Q, validPixROI1, validPixROI2 cv2.stereoRectify( K1, D1, K2, D2, gray_l.shape[::-1], R, T, alpha0, flagscv2.CALIB_ZERO_DISPARITY) map1x, map1y cv2.initUndistortRectifyMap( K1, D1, R1, P1, gray_l.shape[::-1], cv2.CV_32FC1) map2x, map2y cv2.initUndistortRectifyMap( K2, D2, R2, P2, gray_l.shape[::-1], cv2.CV_32FC1) rect_l cv2.remap(img_l, map1x, map1y, cv2.INTER_LINEAR) rect_r cv2.remap(img_r, map2x, map2y, cv2.INTER_LINEAR)这段代码的关键参数是flagscv2.CALIB_ZERO_DISPARITY。它让左右相机的主点在校正后保持不变视差只由水平位移产生后续Q矩阵才能正确地把视差转成三维坐标。alpha0表示裁剪掉校正后无内容的黑色区域会损失一部分边缘视野但能减少无效像素。拿到rect_l和rect_r之后一定要画几条水平线检查行对齐check np.hstack([rect_l, rect_r]) for y in range(0, check.shape[0], 100): cv2.line(check, (0, y), (check.shape[1], y), (0, 255, 0), 1)如果同一行上的对应点在视觉上有明显上下偏移说明立体校正没生效需要回去检查标定图像质量而不是急着调SGBM。行对齐误差超过1个像素视差图质量会明显下降。3.4 第三步SGBM视差计算与参数调节立体匹配算法里OpenCV自带StereoBM和StereoSGBM。BM快但噪声大SGBM慢一点但视差图更平滑课设毕设通常选SGBM。left_matcher cv2.StereoSGBM_create( minDisparity-1, numDisparities64, blockSize11, P18 * 3 * blockSize * blockSize, P232 * 3 * blockSize * blockSize, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, modecv2.STEREO_SGBM_MODE_SGBM) disparity left_matcher.compute( cv2.cvtColor(rect_l, cv2.COLOR_BGR2GRAY), cv2.cvtColor(rect_r, cv2.COLOR_BGR2GRAY)).astype(np.float32) / 16.0SGBM输出的是定点数除以16才转换成真实像素视差。这个16是算法内部的精度缩放别漏掉否则后面转点云时深度会整体偏差16倍。各参数的实际作用参数常见范围作用与调参方向numDisparities16~128必须16的倍数视差搜索范围越大能覆盖越近的物体但耗时增加minDisparity-32~0搜索起点设为负数能减少视差图左侧空洞blockSize3~15奇数匹配窗口大小越大越平滑但边缘细节丢失P1/P28/32 * 通道数 * blockSize²平滑惩罚项P2越大越能抑制深度突变uniquenessRatio5~15唯一性约束越大误匹配越少但过低纹理区域容易空洞disp12MaxDiff0~2左右一致性检查阈值建议1speckleWindowSize50~200去除视差孤立小区域speckleRange8~32连通区域允许的视差波动调参顺序不要乱先固定blockSize把numDisparities调到覆盖目标深度范围再调uniquenessRatio降噪最后用speckleWindowSize清理小噪点。如果觉得视差图“太碎”优先加P2而不是强行加大blockSize。3.5 第四步视差转三维点云并输出PLY得到视差图后用立体校正阶段的Q矩阵做重投影这一步把每个像素映射到三维坐标points3D cv2.reprojectImageTo3D(disparity, Q) # 视差为0的点是匹配失败点直接剔除 mask disparity 0 points points3D[mask] colors rect_l[mask] # 过滤掉相机背后的点Z0和过远的点 valid (points[:, 2] 0.1) (points[:, 2] 10.0) points points[valid] colors colors[valid] import open3d as o3d pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points) pcd.colors o3d.utility.Vector3dVector(colors / 255.0) o3d.io.write_point_cloud(reconstruction.ply, pcd)Q矩阵的第4行第3列通常是1.0 / baseline所以输出的Z轴单位由立体标定时使用的棋盘格尺寸决定。如果你在objp里用的是“格”而不是实际米数点云坐标会按格缩放。为了让PLY文件里的尺寸和真实世界对应标定时应把objp乘上棋盘格实际边长比如objp * 0.025表示每个格子25mm。这一段跑通后你就拥有了一套完整的双目三维重建最小系统。很多课程设计做到这里就可以演示了但标题里还有“单目”下一章讲怎么把单目和双目拧成一个整体。4. 单目双目怎么真正合起来用单目做兴趣区用双目做稠密建点4.1 为什么要让单目先去“摸个底”直接对整幅图跑SGBM也不是不行但在实际场景里有两个问题算力浪费。SGBM是典型的计算密集算法视差搜索范围越大、图像分辨率越高耗时越长。对1080P图像全图搜索100像素视差普通笔记本可能跑出几百毫秒甚至一秒以上课设演示时卡顿感很强。误匹配。背景里的大片无纹理区域比如白墙、天空、桌面SGBM在这些区域会给出大量错误视差产生的点云像雪花一样乱飞。如果用单目先定位目标在画面里的位置和大概深度SGBM就可以只在目标区域、目标深度范围内搜索。在标题这种“单目双目”的架构里单目检测模块通常就是一个ArUco检测器或者YOLO目标检测器。它输出目标在左图中的包围盒以及单目测距得到的深度先验。这个深度先验能直接换算成视差范围。4.2 用单目距离约束SGBM的视差搜索范围视差和深度的关系是disparity fx * baseline / depth。假设单目测出目标距离大约1.5米基线0.12米fx约800那目标处的视差大约就是64像素。这个时候把SGBM的搜索窗口设置在64附近而不是从0搜到128既能减少计算量也能避开背景干扰。import numpy as np fx 812.34 # 来自双目标定 baseline 0.12 # 单位米来自 stereoCalibrate 得到的 T dist_guess 1.5 # 单位米来自单目测距 # 目标处的理论视差 disp_center int(fx * baseline / dist_guess) # 搜索范围在理论值前后各留24像素余量并保证是16的倍数 margin 24 num_disparities 64 if disp_center - margin 0: min_disp 0 num_disparities 128 else: min_disp disp_center - margin num_disparities 64 # 确保 num_disparities 是16的倍数且非负 num_disparities max(16, int(np.ceil(num_disparities / 16.0) * 16)) left_matcher.setMinDisparity(min_disp) left_matcher.setNumDisparities(num_disparities)这里有个容易被忽略的边界条件minDisparity可以为负但numDisparities必须是正的16倍数。如果目标很近理论视差大于搜索窗口SGBM会匹配失败这时要增大num_disparities而不是继续提高min_disp。深度先验还能用来过滤点云。单目测出1.5米双目重建后突然出现一堆深度在0.5米或5米的点大概率是误匹配直接按距离区间切掉depth points3D[:, 2] depth_mask (depth dist_guess * 0.5) (depth dist_guess * 1.5)这一步不是简单的滤波而是把单目模块的粗测信息和双目的精测信息做了决策级融合论文里可以写成“基于深度先验的离群点剔除”。在答辩时这个点很加分的。4.3 融合后的结果检查谁在拖慢速度谁在破坏精度单目和双目融合后代码运行时间大多花在三个地方图像缩放、SGBM匹配、点云生成。要判断瓶颈在哪最笨也最有效的方法是分别给三段代码打时间戳。import time t0 time.time() rect_l cv2.remap(img_l, map1x, map1y, cv2.INTER_LINEAR) t1 time.time() disparity left_matcher.compute(gray_l, gray_r) t2 time.time() points3D cv2.reprojectImageTo3D(disparity, Q) t3 time.time() print(fremap: {(t1-t0)*1000:.1f}ms, sgbm: {(t2-t1)*1000:.1f}ms, reproject: {(t3-t2)*1000:.1f}ms)一般来说SGBM会占掉80%以上的时间。所以性能优化优先级是先缩小SGBM输入图像尺寸再限制视差搜索范围最后才是换算法。如果用ROI框出目标区域把SGBM只跑在这个区域里速度还能再快一截——但这时要注意ROI外的点云会被丢弃如果你要重建整个场景就不能这么干。另外要提醒一句单目测距的误差不要直接当成双目的误差。单目给出的是搜索区间真正决定点云精度的还是双目基线、标定精度和SGBM参数。不要为了让单目“看起来准”而拼命调ArUco宽度那是舍本逐末。5. 避坑清单标定、立体匹配与点云输出的五个高频翻车点5.1 标定重投影误差居高不下内参总觉得“飘”现象calibrateCamera返回的rms超过0.5或者内参fx每次标定结果差几十像素。原因标定图片太少或者角度分布过于集中棋盘格在画面里占比太小。解决重新采集20~30张左右图像对。采集时让棋盘格以不同倾斜角度出现在画面四周和中心每张图里棋盘格面积至少占画面的1/3。如果某一对图像只有单侧检测到角点直接丢弃这组数据不要硬标。标定板采集是整条重建链路里最像“玄学”的环节但90%的问题出在偷懒少拍了图片。5.2 视差图左边大片空洞深度图像被撕掉一块现象生成的视差图左侧有大片黑色区域右侧正常。原因左图的左边界附近匹配点在右图上已经超出图像范围SGBM找不到对应点。解决把minDisparity设为负数比如-8或-16给左边界留出搜索余量。注意minDisparity和numDisparities是配合使用的搜索范围是[minDisparity, minDisparitynumDisparities]。设了负值之后右侧的搜索上限会变小如果右侧也出现空洞就同时增大numDisparities。5.3 平面物体重建出来是“弧面”怎么看怎么别扭现象一面平整的墙或一块标定板重建点云后变成向内凹或向外凸的曲面。原因立体校正不彻底左右图行对齐误差超过1像素或者畸变标定不够准边缘像素的投影位置有系统偏差。SGBM的匹配误差也会造成小范围起伏但不至于整体弯曲。解决先用3.3节的方法画横线检查行对齐。如果对齐没问题检查内参标定时的重投影误差。最后再用平面拟合验证对点云跑RANSAC平面拟合看拟合误差。如果整体呈弧面最优先怀疑的就是标定环节不要急着调SGBM参数。5.4 低纹理区域出现大量错误匹配点云像“雪花”现象白墙、桌面这类区域点云稀疏且杂乱黑色噪点或飞点特别多。原因立体匹配在无纹理区域没有足够的灰度信息来区分同名点SGBM只能按平滑约束瞎猜。光照过强导致的反光会让这个问题更严重。解决先调uniquenessRatio到10以上提升匹配唯一性要求再调speckleWindowSize到100以上把孤立的小块噪点消掉。如果还是不行用WLS滤波对视差图做后处理需要安装opencv-contrib-python调用cv2.ximgproc.createDisparityWLSFilter。最后一道防线是直接用4.2节的距离先验把明显不合理的深度点剔除。5.5 点云颜色错位不知道颜色从哪张图来的现象点云形状对了但颜色像“贴错皮”边缘有重影。原因点云颜色来自左图但重建出的三维点坐标和左图像素不是一一对应尤其是视差边缘区域微小误差会被放大成明显的颜色错位。还有人把右图颜色直接赋给了左图坐标那必然错位。解决统一从reprojectImageTo3D输出的坐标和左图颜色生成点云不要和右图混用。剔除disparity 0和disparity 0的点因为这些点没有有效匹配颜色没有意义。保存PLY后用Open3D或MeshLab打开检查边缘是否和图像边缘吻合。6. 验证这套系统是否可靠的三步检查法6.1 对标定结果做反投影误差检查标定完不要急着跑重建先看calibrateCamera的rms值。单目标定rms要小于0.3像素双目标定stereoCalibrate返回的rms也要在0.3附近。如果超过0.5重建精度没有保证。更直观的验证是把标定板放在已知距离用双目视差计算的深度和实际距离对比误差在2%以内算正常。6.2 对单目测距做定距离验证把ArUco码放在1米、1.5米、2米处各测十次记录误差均值和波动。如果误差超过5%检查目标宽度是否量准、fx是否来自标定。这一项验证至少要做否则融合方案里“单目引导双目”的可信度不够。6.3 用Open3D从PLY看三维结构是否正常点云生成后用Open3D做两步检查。第一步统计点云数量太少说明匹配大面积失败太多但有大量飞点说明参数太宽松。第二步对典型平面目标做RANSAC平面拟合看拟合内点比例和平面法向量。如果标定板拟合出来的法向量和相机光轴明显不垂直说明校正还有问题。import open3d as o3d pcd o3d.io.read_point_cloud(reconstruction.ply) plane_model, inliers pcd.segment_plane(distance_threshold0.005, ransac_n3, num_iterations1000) print(plane normal:, plane_model[:3]) print(inlier points:, len(inliers), /, len(pcd.points))我自己的习惯是每换一次相机或场景都先跑一轮这个三步检查确认标定、匹配、点云三条链路都在正常状态再继续往下调参数。这套项目里最容易让人卡住的就是标定那关图片拍够了、角度铺开了后面SGBM和点云基本是水到渠成。希望这篇笔记能帮你把这条链路走通少交点标定学费。本文还有配套的精品资源点击获取
返回列表