简介:这套Python三维重建项目实践聚焦SFM(运动恢复结构)算法,面向计算机视觉学习者、算法研究者和工程技术人员,解决从多视角二维图像序列中恢复场景三维结构的问题。压缩包共含三个文件,包含两个Python脚本和一个Markdown说明文档,脚本覆盖特征检测(如SIFT/SURF)、特征匹配、相机运动估计、三维点云重建与处理等核心模块,说明文档则对图像数据采集、图像校正与滤波、点云去噪简化、网格化及可视化评估等环节进行说明;整个压缩包仅5KB,代码精简、结构清晰,便于快速阅读和二次开发。已有243人学习该资源,适合具备基础Python知识、希望深入理解SFM算法工程实现的读者。通过该项目实践,读者可掌握完整的三维重建流程,并能够针对特征匹配错误、运动估计不准确、重建失败等常见问题直接调整脚本进行验证,从而加深对算法原理与工程落地的理解。
1. 三维重建 SFM 用 Python 做项目实践时,先想清楚这几件事
三维重建里的 SFM(Structure from Motion,运动恢复结构)是指:用 Python 读入几十张围绕同一物体拍摄的照片,不用深度相机和激光雷达,只靠图像本身,推算出每张照片的相机位姿,以及同名点的三维坐标。输出是稀疏点云,后续接 MVS 稠密化,或直接用于 AR、测绘和数字化展示。
很多人习惯直接跑 COLMAP,但用 Python 手写一遍 SFM 的价值,是把「特征匹配 → 对极几何 → PnP → 光束法平差」这条链路真正串通。这份项目实践里的代码通常就按这个顺序组织。下面先立住数学基础,再按「两视图 → 多视图」的顺序,把关键参数和踩坑点展开。
2. SFM 重建的数学基础:相机模型、特征匹配与对极几何
在写任何 SFM 代码之前,必须先把三个概念立住:相机如何把三维点变成二维像素(相机模型),图像之间如何建立对应关系(特征匹配),以及一对对应点能对相机位姿施加什么约束(对极几何)。这三件事立不住,后面代码写出来也是碰运气。
2.1 针孔相机模型:内参矩阵把三维坐标变成像素坐标
SFM 里默认使用针孔相机模型。一个世界坐标系下的三维点 P,先由外参 R、t 变换到相机坐标系:P_cam = R · P_world + t,再由内参矩阵投影到像素平面:
u = fx · X / Z + cx v = fy · Y / Z + cy
写成齐次形式就是 s · [u, v, 1]ᵀ = K · [R | t] · [X, Y, Z, 1]ᵀ。K 由 fx、fy(焦距,像素单位)和 cx、cy(主点坐标,通常接近图像中心)组成;R 和 t 就是 SFM 最终要恢复的相机位姿。
实现时有个实打实的问题:普通照片通常没有可读的内参。常见做法是先按经验初始化焦距,再交给光束法平差去优化。比如知道镜头物理焦距和传感器宽度,可以用 fx = f_mm / sensor_width_mm × image_width 换算;什么都不知道就设成图像宽度这个量级。内参初始化不准不会直接让重建失败,但会让点云整体弯曲,后面第 5 章会展开说。
2.2 特征提取与匹配:SIFT 为什么是 SFM 的默认选择
SFM 的第一步是为每张图像找特征点并建立图像间对应。候选里有 SIFT、ORB 和学习型特征(如 SuperPoint)。离线重建里 SIFT 仍然是最省事的选择:尺度不变性好,对视角和光照变化鲁棒,OpenCV 开箱即用。选型的差别可以看下面这个表。
| 特征类型 | 尺度不变 | 视角鲁棒 | 速度 | 在 SFM 里的定位 |
|---|---|---|---|---|
| SIFT | 强 | 强 | 中 | 默认选择,稳定可靠 |
| ORB | 弱 | 弱 | 快 | 适合实时 SLAM,不适合离线重建 |
| 学习型特征 | 取决于训练数据 | 较强 | 需 GPU | 可作为进阶替换,但环境成本更高 |
提取完成之后直接暴力匹配就能用:BFMatcher搭配knnMatch找最近的两个邻居,然后做 ratio test。Lowe 的建议是最近距离除以次近距离小于 0.7~0.8 才保留,用来去掉「谁都能匹配上」的歧义点。这一步是后面所有几何估计的质量闸门,宁可少而准,不要多而杂。
2.3 对极几何:本质矩阵与基础矩阵的约束关系
两个已匹配的像素点 x1、x2 之间满足对极约束:x2ᵀ · F · x1 = 0,F 是基础矩阵。如果把像素坐标换成相机归一化平面坐标,约束变成 x2ᵀ · E · x1 = 0,E 称为本质矩阵,且 E = [t]ₓ · R,直接编码了两相机间的相对旋转和平移。
SFM 实现中,已知 K 时求 E 比求 F 少一步分解,所以流程上都是先假设或估算内参,再用五点算法加 RANSAC 从匹配点估计 E。RANSAC 在这里的意义不只是鲁棒估计,它同时给出了哪些匹配是内点,这直接决定了后续三角化的输入质量。如果不确定内参,退而求其次先求 F,最后再从 F 恢复 E 也不是不行,但精度会打折扣,项目实践里一般不做这个选择。
3. 两视图重建最小闭环:从两张照片到一组三维点
两视图是 SFM 的最小单元:输入两张图像,输出两个相机的相对位姿和一批三维点。整条流程在这个规模上反复循环,所以先把这一步跑通。运行环境只需要 Python 3.8 以上的解释器和 opencv-python、numpy、scipy 这几个包,在 PyCharm 或 VS Code 里配好解释器,pip install opencv-python numpy scipy之后就能开始。
3.1 特征提取与匹配的完整代码:SFM 的第一步
import cv2 import numpy as np def extract_and_match(img1, img2, ratio=0.75): # 提取 SIFT 特征点和描述子,nfeatures 适当调大让纹理丰富的场景有更多候选 sift = cv2.SIFT_create(nfeatures=8000) kp1, des1 = sift.detectAndCompute(img1, None) kp2, des2 = sift.detectAndCompute(img2, None) # 暴力匹配,每个查询点取最近的两个邻居 matcher = cv2.BFMatcher(cv2.NORM_L2) raw_matches = matcher.knnMatch(des1, des2, k=2) # ratio test:最近距离 / 次近距离小于阈值才保留 good = [] for m, n in raw_matches: if m.distance < ratio * n.distance: good.append(m) # 把匹配对转成 (N, 2) 的坐标数组,后续几何计算直接用 src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 2) return src_pts, dst_pts, good, kp1, kp2nfeatures=8000我一般设得比默认值大,因为重建场景往往纹理丰富,多一些特征点能给后续三角化提供更多候选。knnMatch返回每个查询点距离最近的两个匹配,ratio test 用这两个距离的比值判断当前匹配是否可信。ratio=0.75是一个经验平衡点:取 0.7 更严格,匹配数变少但更准;取 0.8 更宽松,适合本来纹理就稀疏的场景。
3.2 用本质矩阵恢复两相机相对位姿
拿到匹配点之后,下一步是求本质矩阵。OpenCV 的findEssentialMat会在内部完成去内参的步骤,所以这里直接传像素坐标和相机内参 K 即可。
def recover_pose(K, src_pts, dst_pts): # RANSAC + 五点算法估计本质矩阵,顺便给出内点掩码 E, mask = cv2.findEssentialMat( src_pts, dst_pts, K, method=cv2.RANSAC, prob=0.999, threshold=1.0 ) # recoverPose 从 E 分解出的 4 组 (R, t) 里挑出三维点在前方的那组 _, R, t, mask = cv2.recoverPose(E, src_pts, dst_pts, K, mask=mask) return R, t, maskthreshold=1.0是 RANSAC 判断内点的重投影误差阈值,单位是像素,推荐取值在 0.5 到 2.0 之间。设太小,内点太少、估计不稳定;设太大,误匹配会被当成内点混进计算。recoverPose之所以不能省,是因为本质矩阵分解后存在 4 种可能的 (R, t) 组合,只有一个是真实的,它利用「三维点必须在两个相机前方」这一几何约束自动完成选择。
提示:
threshold虽然叫误差阈值,但作用于归一化坐标时 OpenCV 会按内参折算,所以传 1.0 在普通分辨率图像上可用;如果图像是 4K 甚至更高,按比例放大到 2.0 更稳。
3.3 三角化:把匹配点对变成三维坐标
已知两相机内参和相对位姿,就可以反推每个匹配点对应的三维坐标。核心是用投影矩阵构造 4 维齐次坐标,最后归一化到三维。
def triangulate(K, R, t, src_pts, dst_pts, mask): # 第一个相机放在世界原点,第二个相机使用相对位姿 R, t P1 = np.hstack((np.eye(3), np.zeros((3, 1)))) P2 = np.hstack((R, t)) pts4d = cv2.triangulatePoints(K @ P1, K @ P2, src_pts.T, dst_pts.T) pts3d = (pts4d[:3] / pts4d[3]).T # 齐次坐标转笛卡尔坐标 # 剔除在相机后方的点:z1 是第一个相机系下的深度,z2 要转到第二个相机系判断 z1 = pts3d[:, 2] z2 = (R @ pts3d.T + t.reshape(3, 1))[2] valid = mask.ravel().astype(bool) & (z1 > 0) & (z2 > 0) return pts3d[valid]triangulatePoints要求两个完整的 3×4 投影矩阵,所以先把 K 与 [R | t] 相乘。返回结果的第四行是齐次因子,必须先对前三维做除法才是真实坐标。过滤条件里 z1 > 0 和 z2 > 0 保证点同时位于两个相机前方,这一步经常被新手忽略,不做的后果是点云里混入大量镜像点,看起来像物体背后多了一层「鬼影」。
4. 多视图增量式重建:把新相机逐个注册进点云
两视图只有一条基线,能看到的面很有限。要覆盖整个场景,工业级工具几乎都走增量式 SFM:先选一对图像初始化,之后每次加入一张新图像,用 PnP 估计新相机位姿,三角化新出现的点,最后做一次光束法平差让所有已经加入的参数重新对齐。
4.1 增量注册:PnP 求解新相机的外参
增量注册的输入是:新图像上的特征点,以及它们对应的已知三维点。这个 2D-3D 对应关系来自全局特征匹配,把新图像的特征描述子和所有已注册图像的特征描述子做匹配,命中同一个三维点的就算一条对应。整体流程可以对应成下面的表。
| 阶段 | 输入 | 输出 | 关键函数 |
|---|---|---|---|
| 初始化 | 两张图 + 匹配点 | 相对位姿 + 初始点云 | findEssentialMat / triangulatePoints |
| 注册新图 | 新图 + 2D-3D 对应 | 新相机外参 R, t | solvePnPRansac |
| 合并点云 | 新相机 + 已有三维点 | 更新后的全局点云 | triangulatePoints |
| 全局优化 | 所有相机与点云 | 一致化的相机位姿和坐标 | 光束法平差 |
新相机位姿用 PnP 求解,OpenCV 的solvePnPRansac自带鲁棒性,实现如下。
def register_camera(K, pts_2d, pts_3d): # 先用 EPNP 求初始值,它的优点是初值不敏感 _, rvec, tvec, inliers = cv2.solvePnPRansac( pts_3d, pts_2d, K, distCoeffs=None, iterationsCount=1000, reprojectionError=4.0, flags=cv2.SOLVEPNP_EPNP ) # 拿着 EPNP 的结果当初始值,换迭代法精化一遍 ok, rvec, tvec = cv2.solvePnP( pts_3d, pts_2d, K, None, rvec=rvec, tvec=tvec, useExtrinsicGuess=True, flags=cv2.SOLVEPNP_ITERATIVE ) R, _ = cv2.Rodrigues(rvec) # 旋转向量转旋转矩阵 return R, tvec, inlierssolvePnPRansac的输入顺序容易搞反:第一个参数是世界系三维点,第二个是图像系二维点,别传反了。reprojectionError=4.0表示三维点重新投影到图像平面时误差超过 4 像素的点会被丢弃。EPNP 在点对较少时也能给出可用初值,但精度有限,所以后面补了一次SOLVEPNP_ITERATIVE的精化,这一步对后续 BA 的收敛速度帮助很大。
4.2 光束法平差:用 Python 的 scipy 让全局参数一致化
增量注册本身会累积误差:每加入一张图,误差就往全局渗一点。光束法平差把「所有相机的 R、t」和「所有三维点坐标」放在同一个优化问题里,最小化每个三维点在每个观测图像上的重投影误差总和。工业实现通常用 g2o、ceres 这类图优化库,但 Python 里用 scipy 的least_squares就能写一个最小可用的版本。
from scipy.optimize import least_squares def bundle_adjustment(cameras, points_3d, observations, K): n_cams = len(cameras) # cameras: (n_cams, 6),每行是 rvec + tvec # observations: [(cam_idx, point_idx, u, v), ...] def params_to_state(state): cam_part = state[:6 * n_cams].reshape(n_cams, 6) pt_part = state[6 * n_cams:].reshape(-1, 3) return cam_part, pt_part def project(cam, p3d): R, _ = cv2.Rodrigues(cam[:3]) x = R @ p3d + cam[3:] return K[0, 0] * x[0] / x[2] + K[0, 2], K[1, 1] * x[1] / x[2] + K[1, 2] def residuals(state): cam_part, pt_part = params_to_state(state) res = [] for cam_idx, pt_idx, u, v in observations: pu, pv = project(cam_part[cam_idx], pt_part[pt_idx]) res += [pu - u, pv - v] return np.array(res) init = np.concatenate([np.asarray(cameras).ravel(), np.asarray(points_3d).ravel()]) result = least_squares(residuals, init, method="lm", max_nfev=100) return params_to_state(result.x)这段代码把状态向量拆成「相机参数 + 点坐标」两部分,旋转用 Rodrigues 向量而不是矩阵,因为旋转矩阵有 9 个分量却只有 6 个自由度,直接优化会引入冗余自由度。max_nfev=100在调试阶段够用,真实数据集上要放开到 200 以上让误差充分下降。如果拍摄场景退化到近似平面,BA 很容易发散,这时要么换一组初始化图像,要么给三维点加一个小的正则约束。
提示:BA 的初始值必须来自两视图重建的结果,不能随机初始化,否则 least_squares 大概率收敛到局部极小,点云会弯成奇怪的曲面。
4.3 点云导出:用 PLY 格式让重建结果可查看
重建出来的点云如果只在控制台里打坐标,看不出场景对不对。最直接的办法是导出成 PLY 文件,MeshLab、Blender、CloudCompare 都能打开。PLY 的 ASCII 格式很简单,手工导出只十几行代码。
def save_pointcloud_ply(filename, points, colors=None): with open(filename, "w") as f: header = f"ply\nformat ascii 1.0\nelement vertex {len(points)}\n" header += "property float x\nproperty float y\nproperty float z\n" if colors is not None: header += "property uchar red\nproperty uchar green\nproperty uchar blue\n" f.write(header + "end_header\n") for i, p in enumerate(points): line = f"{p[0]:.6f} {p[1]:.6f} {p[2]:.6f}" if colors is not None: c = colors[i] line += f" {int(c[0])} {int(c[1])} {int(c[2])}" f.write(line + "\n")颜色信息在增量式重建里是现成的:三角化一个三维点时,把两个观测图像上对应像素的颜色取平均即可。导出的点云带上颜色后,能立刻看出重建的是不是同一个物体。property的声明顺序必须和每行数据的输出顺序一致,否则读 PLY 的软件会整体错位,这是新手最常见的坑。
5. SFM 参数调优、重投影误差验证与重建失败排查
前面代码能跑通只是第一步,项目实践里大部分时间花在把结果调对。这一章只留三样最常用的东西:参数表、验证指标和故障对照。
对重建质量影响最大的 5 个参数
| 参数 | 推荐范围 | 影响 | 调参建议 |
|---|---|---|---|
| ratio test 阈值 | 0.7 - 0.8 | 匹配质量 | 重建发散时优先调低 |
| RANSAC 阈值 | 0.5 - 2.0 | 内点占比 | 高分辨率图像按比例放大 |
| nfeatures | 4000 - 10000 | 特征点密度 | 纹理多就多给 |
| PnP 重投影阈值 | 2.0 - 8.0 | 注册稳固性 | 先放宽确保注册成功,最后收紧 |
| BA 迭代上限 | 50 - 300 | 收敛充分度 | 先小后大,调试快 |
这些参数有共同规律:早期放宽才能用少量图像把流程跑通,最终出结果时收紧才能保证精度。一上来全部设最严格,往往匹配对不足,什么都重建不出来。
验证:盯住重投影误差的中位数
重投影误差是把三维点按估计的相机位姿投回图像平面,和原始观测像素坐标的距离。直接看中位数和 95 分位数,不要看平均值,几个外点就能把平均值拉垮。
errors = [] for cam_idx, pt_idx, u, v in observations: x = R_list[cam_idx] @ pts3d[pt_idx] + t_list[cam_idx] pu = K[0, 0] * x[0] / x[2] + K[0, 2] pv = K[1, 1] * x[1] / x[2] + K[1, 2] errors.append(np.hypot(pu - u, pv - v)) print(np.median(errors), np.percentile(errors, 95))中位数在 1 像素以内、95 分位数在 3 像素以内,结果通常可用。95 分位数如果冲到几十像素,说明还有大量外点没剔干净,问题多半出在匹配阶段而不是优化阶段。
三种现象对应的排错方向
| 现象 | 常见原因 | 改法 |
|---|---|---|
| 平面场景重建出曲面 | 初始内参偏差大,或 BA 陷入局部极小 | 前几轮 BA 固定内参,收敛后再放开 |
| 同一物体分成几块碎片 | 初始化图像选得差,后续注册跳过了太多图像 | 多试几对初始化图像,选内点数最多的 |
| 点云出现双层重影 | 短基线图像对太多,深度不确定性大 | 剔除基线过短的图像对 |
最后留一个具体技巧:在增量注册每加入一张图后,打印一次该新图的平均重投影误差。如果误差从 1 像素量级突然跳到 10 像素量级,说明就是这一张图把全局 BA 带偏了,把这张图从输入列表里单独剔除重跑注册,比整体调参快得多。
本文还有配套的精品资源,点击获取