ARTICLE DETAIL

资讯详情

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

Python三维重建实战:从单目/双目相机标定到点云生成与网格化

Python三维重建实战:从单目/双目相机标定到点云生成与网格化 简介这份资源面向希望入门或进阶计算机视觉的学习者围绕Python实现单目与双目视觉三维重建展开可作为毕业设计、课程设计、大作业或工程实训的参考项目。包内共41个文件以34张jpg图像、3个py脚本、2个txt说明、1个md文档和1张png示意图为主压缩包约80.24MB图像素材覆盖多组拍摄对象脚本则对应单目与双目两条重建流程便于对照理解算法输入与输出。目前已有244人学习下载说明该方向具备一定关注度。读者可从中获得一套可直接运行的三维重建代码框架结合配套图像与说明文档快速复现单目、双目重建的基本流程理解相机标定、立体匹配与深度恢复等关键环节并在此基础上进行参数调整与功能扩展适合作为视觉方向实践入门的起点。1. 从两张照片到一堆点云单目/双目三维重建到底在做什么你手上有两张同一场景的照片一张左、一张右或者只有一台普通 USB 摄像头绕着物体拍了一圈。你想从这些二维像素里把物体的三维结构还原出来——这件事就是三维重建。它不依赖激光雷达、不依赖深度相机纯靠 Python 和几何计算把图像里的每一个像素点反投影回三维空间。单目方案只用一台相机靠移动相机产生视差或者靠深度学习模型从单张图里“猜”深度双目方案用两个已知间距的相机同时拍摄靠左右视图的像素匹配直接算出深度。两条路各有各的适用场景单目成本低、部署灵活但尺度不确定、需要额外约束双目精度稳定、尺度真实但标定和匹配环节容易翻车。这篇文章面向的是想用 Python 把这条链路跑通的工程师——不管你是做机器人导航、工业测量、还是想给自己的项目加一个三维感知模块下面的内容都能让你从零搭出一套可复现的流程。热搜里常出现的“单目视觉测距”“nerf三维重建”其实都是这条技术栈上的不同分支前者是单目重建的简化应用后者是近年用隐式表示做重建的新路线但底层对极几何和相机模型的理解是绕不开的。2. 相机模型与标定把像素坐标翻译成三维射线的第一步2.1 针孔模型不是“近似”是你所有计算的基准很多人一上来就急着跑 SIFT 匹配、跑 SFM结果重建出来的点云扭曲得像麻花。血泪经验是标定没做对后面全白费。针孔模型把三维点 ( P(X,Y,Z) ) 投影到像素 ( p(u,v) ) 的过程写成[ s \begin{bmatrix} u \ v \ 1 \end{bmatrix} \begin{bmatrix} f_x 0 c_x \ 0 f_y c_y \ 0 0 1 \end{bmatrix} \begin{bmatrix} R t \end{bmatrix} \begin{bmatrix} X \ Y \ Z \ 1 \end{bmatrix} ]其中 ( f_x, f_y ) 是焦距像素单位( c_x, c_y ) 是主点通常接近图像中心。( R, t ) 是相机外参描述相机在世界坐标系中的位姿。单目重建时你至少需要知道内参矩阵 ( K )双目重建时你还需要两个相机之间的旋转和平移。常见做法是用棋盘格标定板拍 15 到 20 张不同角度的照片用 OpenCV 的calibrateCamera一次性解出内参和畸变系数。import cv2 import numpy as np import glob # 棋盘格内角点数量例如 9x6 pattern_size (9, 6) 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) objpoints [] # 三维点 imgpoints [] # 二维像素点 images glob.glob(calib_images/*.jpg) for fname in images: img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern_size, None) if ret: objpoints.append(objp) # 亚像素级角点优化窗口大小 11x11 corners2 cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria(cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) imgpoints.append(corners2) ret, K, dist, rvecs, tvecs cv2.calibrateCamera(objpoints, imgpoints, gray.shape[::-1], None, None) print(内参矩阵 K:\n, K) print(畸变系数 dist:\n, dist)这段代码的逻辑是先构造棋盘格在世界坐标系下的三维点Z0 平面然后对每张图找角点最后统一优化。cornerSubPix的窗口大小 11x11 是经验值太小会受噪声影响太大可能跨到相邻角点。calibrateCamera返回的dist通常是 5 个参数 ( (k_1, k_2, p_1, p_2, k_3) )分别对应径向畸变和切向畸变。如果重投影误差超过 0.5 像素说明标定板图像质量不够或者角度太单一需要重拍。2.2 双目标定多一步 stereoCalibrate多十个坑双目系统除了各自的内参还需要两个相机之间的外参。OpenCV 提供stereoCalibrate输入是左右相机各自的内参和所有同步拍摄的棋盘格图像。关键参数是flags常用cv2.CALIB_FIX_INTRINSIC固定单目标定结果只优化外参。如果两个相机不是硬件同步标定时棋盘格必须静止否则运动模糊会让外参漂移。# 假设已经分别得到 K1, dist1, K2, dist2 ret, K1, dist1, K2, dist2, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints1, imgpoints2, K1, dist1, K2, dist2, image_size, flagscv2.CALIB_FIX_INTRINSIC ) print(旋转矩阵 R:\n, R) print(平移向量 T:\n, T) # 单位与棋盘格尺寸一致T的模长就是基线长度。如果你棋盘格方格边长是 25mm那么T的单位就是 mm。很多人在这一步忘了统一单位导致后面重建出来的点云尺度差 1000 倍。E是本征矩阵F是基础矩阵后面做极线校正和匹配时会用到。标定完成后用stereoRectify计算校正映射让左右图像的极线水平对齐这样匹配时只需要在同一行搜索效率提升一个数量级。提示标定板不要只在一个距离拍远近都要覆盖否则焦距和畸变系数会耦合。我一般会拍 20 张其中 5 张近距离、10 张中距离、5 张倾斜角度。3. 双目立体匹配从校正图像到视差图的完整链路3.1 极线校正与 SGBM 参数怎么调双目重建的核心是视差 ( d u_L - u_R )深度 ( Z f \cdot B / d )。f是焦距像素B是基线。视差越大深度越近视差为 0 表示无穷远。要得到稠密视差图常用 OpenCV 的StereoSGBM。但在匹配之前必须用initUndistortRectifyMap和remap把左右图校正到同一极线平面。# 接上面的 stereoCalibrate 结果 R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( K1, dist1, K2, dist2, image_size, R, T, alpha0 ) map1x, map1y cv2.initUndistortRectifyMap(K1, dist1, R1, P1, image_size, cv2.CV_32FC1) map2x, map2y cv2.initUndistortRectifyMap(K2, dist2, R2, P2, image_size, cv2.CV_32FC1) imgL cv2.imread(left.png) imgR cv2.imread(right.png) rectL cv2.remap(imgL, map1x, map1y, cv2.INTER_LINEAR) rectR cv2.remap(imgR, map2x, map2y, cv2.INTER_LINEAR) # SGBM 参数 window_size 5 min_disp 0 num_disp 16 * 5 # 必须是 16 的倍数 stereo cv2.StereoSGBM_create( minDisparitymin_disp, numDisparitiesnum_disp, blockSizewindow_size, P18 * 3 * window_size ** 2, P232 * 3 * window_size ** 2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32 ) disparity stereo.compute(rectL, rectR).astype(np.float32) / 16.0numDisparities决定搜索范围必须覆盖场景中最大视差。如果物体离相机很近视差可能超过 100 像素这时num_disp要设到 160 甚至 256。blockSize越大视差图越平滑但边缘越糊越小细节多但噪声大。P1和P2是平滑惩罚项经验公式是P1 8 * channels * blockSize^2P2 32 * channels * blockSize^2。uniquenessRatio设为 5 到 15 之间太低会保留错误匹配太高会丢掉弱纹理区域。speckleWindowSize用来过滤小连通域的噪声如果视差图里有很多散点把它调到 200 以上。3.2 从视差图到点云reprojectImageTo3D 的输入输出拿到视差图后用reprojectImageTo3D结合Q矩阵就能得到每个像素对应的三维坐标。Q是stereoRectify输出的 4x4 矩阵包含了基线、焦距和主点信息。points_3d cv2.reprojectImageTo3D(disparity, Q) # 过滤无效视差 mask disparity disparity.min() mask mask (disparity num_disp) output_points points_3d[mask] output_colors rectL[mask] # 保存为 PLY def write_ply(filename, points, colors): with open(filename, w) as f: f.write(ply\nformat ascii 1.0\n) f.write(felement vertex {len(points)}\n) f.write(property float x\nproperty float y\nproperty float z\n) f.write(property uchar red\nproperty uchar green\nproperty uchar blue\n) f.write(end_header\n) for p, c in zip(points, colors): f.write(f{p[0]} {p[1]} {p[2]} {c[2]} {c[1]} {c[0]}\n) write_ply(cloud.ply, output_points, output_colors)reprojectImageTo3D输出的坐标单位与T一致。如果T是 mm点云就是 mm。注意Q矩阵的第四行第三列是 (-1/T_x)所以视差为 0 的点会被映射到无穷远必须用 mask 过滤掉。保存 PLY 时颜色顺序是 RGB但 OpenCV 读进来是 BGR所以写入时要把通道倒过来。如果点云看起来像一层纸说明视差图几乎全零检查numDisparities是否覆盖了真实视差范围。注意SGBM 对光照变化很敏感。左右相机曝光不一致时先做直方图匹配或者用cv2.createCLAHE做局部对比度增强否则匹配率会掉一半。4. 单目重建没有基线靠什么把深度找回来4.1 运动恢复结构用特征点匹配代替双目视差单目没有固定的基线但你可以移动相机拍一系列图像然后用运动恢复结构SfM同时估计相机位姿和三维点。核心步骤是提取特征点SIFT 或 ORB、匹配、计算基础矩阵或本质矩阵、恢复位姿、三角化。OpenCV 的SIFT和BFMatcher就能搭出最小原型。sift cv2.SIFT_create() bf cv2.BFMatcher() img1 cv2.imread(img1.jpg, 0) img2 cv2.imread(img2.jpg, 0) kp1, des1 sift.detectAndCompute(img1, None) kp2, des2 sift.detectAndCompute(img2, None) matches bf.knnMatch(des1, des2, k2) # Lowes ratio test good [] for m, n in matches: if m.distance 0.75 * n.distance: good.append(m) pts1 np.float32([kp1[m.queryIdx].pt for m in good]) pts2 np.float32([kp2[m.trainIdx].pt for m in good]) # 计算本质矩阵需要内参 K E, mask cv2.findEssentialMat(pts1, pts2, K, methodcv2.RANSAC, prob0.999, threshold1.0) _, R, t, mask_pose cv2.recoverPose(E, pts1, pts2, K) # 三角化 P1 K np.hstack((np.eye(3), np.zeros((3, 1)))) P2 K np.hstack((R, t)) points_4d cv2.triangulatePoints(P1, P2, pts1.T, pts2.T) points_3d points_4d[:3] / points_4d[3]findEssentialMat的threshold是 RANSAC 的内点阈值单位是像素通常设 1.0 到 2.0。recoverPose返回的t是单位向量所以单目重建的尺度是任意的——你不知道两张图之间相机移动了多少厘米。这是单目最本质的局限。要恢复真实尺度必须引入外部约束已知物体尺寸、IMU 数据、或者地面平面假设。热搜里的“单目视觉测距”通常就是假设相机高度已知通过地面平面的单应性来反推距离而不是做完整的三维重建。4.2 深度学习单目深度估计什么时候用 MiDaS什么时候用几何如果你只有单张图几何方法完全失效只能靠学习先验。MiDaS、DPT 这类模型能从单张 RGB 图回归出相对深度图。它们输出的是视差图inverse depth不是真实深度但经过尺度对齐后可以用于三维重建。import torch import cv2 import numpy as np # 加载 MiDaS 小模型 model torch.hub.load(intel-isl/MiDaS, MiDaS_small) model.eval() transform torch.hub.load(intel-isl/MiDaS, transforms).small_transform img cv2.imread(scene.jpg) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) input_batch transform(img_rgb).unsqueeze(0) with torch.no_grad(): prediction model(input_batch) prediction torch.nn.functional.interpolate( prediction.unsqueeze(1), sizeimg_rgb.shape[:2], modebicubic, align_cornersFalse ).squeeze() depth prediction.cpu().numpy() # 归一化到 0-1 depth (depth - depth.min()) / (depth.max() - depth.min())MiDaS 输出的深度是相对的近处值大、远处值小。要转成点云你需要假设一个虚拟焦距和基线或者用已知的相机内参把深度图反投影。常见做法是把相对深度当作视差设定一个虚拟基线 ( B_{virtual} )然后用 ( Z f \cdot B_{virtual} / d ) 计算深度。这个尺度是任意的但点云的相对结构是正确的。如果你的应用只需要知道“哪个物体在前面、哪个在后面”MiDaS 足够如果需要测量真实距离还是得回到双目或者加标定参照物。提示MiDaS 在室内场景表现稳定但在强纹理重复的区域比如瓷砖地面容易产生深度断裂。遇到这种情况用cv2.bilateralFilter对深度图做保边平滑窗口设 9颜色 sigma 设 75空间 sigma 设 75。5. 避坑与排查点云扭曲、匹配失败、尺度漂移的根因5.1 点云像“香蕉”一样弯曲现象重建出来的平面变成弧形直线变成曲线。原因畸变系数没应用或者标定板覆盖范围不够导致径向畸变在图像边缘被错误估计。解决在undistort或remap时确保使用完整的 5 个畸变参数并且标定图像要覆盖到画面四个角。如果畸变仍然严重考虑用cv2.fisheye模型处理广角镜头。5.2 视差图大片黑色匹配率极低现象SGBM 输出的视差图几乎全黑只有零星亮点。原因左右图像没有极线校正或者numDisparities太小真实视差超出了搜索范围。解决先检查校正后的图像左右同一行应该在同一水平线上。如果没对齐重新跑stereoRectify并确认R1, R2正确。然后逐步增大numDisparities从 64 试到 256直到黑色区域减少。5.3 单目重建的尺度每次都不一样现象同一场景跑两次 SfM点云大小差几倍。原因单目三角化只能恢复归一化尺度recoverPose返回的t是单位向量。解决在场景中放置一个已知尺寸的标记物比如 A4 纸在三角化后计算标记物两端点的三维距离然后按比例缩放整个点云。或者用双目方案基线固定尺度天然确定。5.4 点云颜色和位置对不上现象PLY 文件里点的颜色是乱的或者颜色和几何错位。原因reprojectImageTo3D输出的点顺序和rectL的像素顺序一致但 mask 过滤后如果直接取rectL[mask]颜色顺序是对的如果中间做了 resize 或 crop就会错位。解决确保 mask 和颜色图来自同一张校正后的图像不要在中途改变分辨率。5.5 运行速度太慢SGBM 一帧要几秒现象高分辨率图像下 SGBM 耗时超过 2 秒。原因blockSize和numDisparities过大计算量随两者乘积增长。解决先把图像缩放到宽度 640 再匹配得到视差图后再上采样。或者改用cv2.StereoSGBM的MODE_HH模式虽然内存占用高但速度更快。如果还是慢考虑用cv2.cuda版本或者换成 ELAS 算法。6. 把点云用起来从 PLY 到网格与纹理映射的最后一公里点云本身只是一堆散点很多下游任务需要网格。最直接的方法是泊松重建Python 里可以用 Open3D 一行搞定。但泊松重建对噪声敏感点云法线估计不准时重建出来的网格会像融化的蜡。我一般会先做统计滤波去离群点再估计法线最后泊松。import open3d as o3d pcd o3d.io.read_point_cloud(cloud.ply) # 统计滤波每个点检查 20 个邻居标准差倍数 2.0 cl, ind pcd.remove_statistical_outlier(nb_neighbors20, std_ratio2.0) pcd pcd.select_by_index(ind) # 法线估计搜索半径 0.01根据点云尺度调整 pcd.estimate_normals(search_paramo3d.geometry.KDTreeSearchParamHybrid(radius0.01, max_nn30)) pcd.orient_normals_consistent_tangent_plane(30) # 泊松重建深度 9 适合中等细节 mesh, densities o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(pcd, depth9) mesh.compute_vertex_normals() o3d.io.write_triangle_mesh(mesh.ply, mesh)remove_statistical_outlier的std_ratio越小过滤越激进通常设 1.5 到 2.5。estimate_normals的radius要略大于点间距如果点云稀疏半径设大一点否则法线方向会乱。orient_normals_consistent_tangent_plane用来统一法线朝向参数 30 是 k 近邻数量太小会导致朝向不一致。泊松重建的depth控制八叉树深度9 适合大多数场景11 以上会生成巨大网格除非你做文物级扫描。纹理映射是另一个坑。如果你想把原始图像贴回网格需要知道每个三角面片对应的相机位姿和可见性。简单做法是用 Open3D 的create_from_point_cloud_poisson后把每个顶点的颜色从最近邻点云继承虽然糊但省事。要清晰纹理得用mvs-texturing这类工具输入网格和相机参数输出带纹理的 OBJ。这一步在 Python 里没有特别顺手的库通常用 subprocess 调外部命令。验证重建质量我习惯看三个指标点云到网格的平均距离用mesh.compute_distance_to_point_cloud、法线一致性相邻面片法线夹角超过 90 度的比例、以及重投影误差把三维点投影回原图看和特征点的像素偏差。如果平均距离超过点间距的 2 倍说明泊松深度不够或者点云噪声太大。重投影误差超过 2 像素回去检查标定和位姿。最后说一个我踩过的坑不要用reprojectImageTo3D的输出直接做泊松因为视差图在物体边缘会有飞点这些飞点在三维空间里离群很远泊松会把它们连成一片“蜘蛛网”。必须先做半径滤波或者统计滤波把边缘飞点去掉。我现在的习惯是任何点云进 Open3D 之前先跑一遍remove_radius_outlier(nb_points10, radius0.02)半径根据场景尺度调室内场景 0.02 米通常合适。这个习惯帮我省掉了无数次“为什么网格上长刺”的排查时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表