
做三维重建或者基于学习的MVSMulti-View Stereo的同学一定绕不开数据集这三个字。我自己最初跑MVSNet的时候天真地以为拿手机绕着桌子拍一圈把照片丢给预训练模型就能出深度图结果连续几天在“相机参数格式不对”“深度图全黑”“训练loss不收敛”之间来回折腾。最后才想清楚MVSNet这类方法吃的不是裸图像而是“图像 相机参数 深度范围”这样一个完整集合而且目录结构要按DTU那套约定来组织。COLMAP在整个链路里扮演的角色远不只是“三维重建软件”这么简单它既能通过SfM给你每张图的相机位姿又能用PatchMatch给你生成稠密深度图是连接真实世界照片和深度学习MVS网络最顺畅的一座桥。这篇文章就把我从头到尾的实操流程、格式转换脚本和踩过的各种坑完整记录下来给正要自己造数据、准备用自定义场景微调MVSNet或其他MVS模型的读者一份可以直接抄作业的参考。1. 整体设计思路COLMAP在数据生成链路里的位置1.1 MVSNet到底需要什么样的输入MVSNet是2018年提出的经典深度MVS网络输入是一张参考图和若干张源图再加上每张图对应的相机内外参输出参考图的深度图。因为网络核心操作是可微单应变换differentiable warping相机参数不是可选项而是必须精确到像素级别。具体到数据组织MVSNet沿用DTU数据集的约定每个训练样本是一个场景目录里面有三个子目录。我直接把我常用的标准结构贴出来mvs_input/ scan1/ images/ 000000.jpg 000001.jpg ... cams/ 000000_cam.txt 000001_cam.txt ... depth/ 000000.pfm 000001.pfm ...images里放的是拍摄到的RGB图cams里放的是每张图对应的相机参数文件depth里放的是每张参考图的深度真值伪标签。MVSNet在读取时会把images/000000.jpg对应到cams/000000_cam.txt再对应到depth/000000.pfm所以命名必须一一对应、不能乱序。cam文件是固定行数的文本格式MVSNet的read_cam_file直接按行索引读取少一行、多一个空行都会崩。标准内容长这样extrinsic 0.9999 0.0052 -0.0013 -0.1123 -0.0052 0.9821 -0.0109 0.3211 0.0012 0.0109 0.9931 1.2345 0.0 0.0 0.0 1.0 intrinsic 1200.0 0.0 512.0 0.0 1200.0 384.0 0.0 0.0 1.0 depth_min 0.1000 depth_max 10.0000前4行是外参矩阵4x4的world-to-camera齐次矩阵接下3行是内参K矩阵最后是深度范围。MVSNet会拿这个extrinsic和K去做坐标变换和单应变换所以格式必须严格。1.2 为什么选COLMAP而不是其他标定方式很多入门的朋友会问相机内参不是可以用棋盘格标定吗外参不能靠SLAM吗为什么非COLMAP不可从实际体验来说COLMAP的优势写在面上第一它把特征提取、特征匹配、SfM、MVS、深度图导出全部串在一起你只需要给它一批图片它就能给你输出一份“元数据全家桶”第二COLMAP的稀疏重建内置了Bundle Adjustment内外参是联合优化的精度比单独标定再单独tracking稳定得多第三COLMAP的输出格式是文本和二进制模型文件社区里已经有成熟的转换工具链二次开发成本低。相比之下用Aruco标定板做标定只能拿到特定放置位置下的相机姿态对自由拍摄场景基本无能为力传统SLAM能拿到轨迹但缺少BA对地图点和位姿的联合优化深度图导出也得自己写一整套流程。所以对于自定义真实场景的数据生成COLMAP是压倒性选择。1.3 深度图从哪里来伪标签和渲染真值MVS训练必须要有深度图否则没法算损失。如果你手头没有激光扫描或者深度相机最常见的替代方案是用COLMAP的PatchMatch Stereo生成稠密深度图作为伪标签。PatchMatch本质上也是一个MVS算法它基于多视图光度一致性来估算每个像素的深度和深度学习无关所以不受训练数据限制。但它生成的深度图会有噪声和边缘毛刺不是完美的GT。另一条路线是用Blender、Mitsuba这类渲染器生成合成数据可以拿到绝对精确的深度图但合成数据训练出来的模型在真实场景上往往存在domain gap而且需要搭建虚拟场景成本也不低。所以在真实场景上做数据生成主推COLMAP伪标签路线这也是本文要详细展开的部分。2. COLMAP稀疏重建实操采集、命令与模型导出2.1 图片采集的基本要求先说采集因为这一步决定后面所有环节的天花板。不是随便拍一堆照片就能让COLMAP重建成功的它对输入图像有硬性要求相邻图像的重叠率尽量在70%以上绕物体环形拍摄时相邻帧角度差控制在10到15度以内画面不能有太大面积的模糊不能有快速移动的动态物体光照尽量均匀避免高光、反光和重复纹理比如纯白墙、地毯花纹。还有一点很重要拍摄时要有平移不能只在一个位置旋转相机否则SfM很容易退化。我自己的经验是拍一个放在桌面上的小物体环形绕一圈拍30到40张就够拍一个大尺寸房间按“之”字路径走保证每个区域至少被3张图覆盖总共拍100到200张。拍完后用脚本抽帧检视一遍把模糊的、过曝的直接删掉在这个环节花的时间会成倍回报在后面的重建质量上。2.2 命令行跑通标准SfM流程COLMAP有GUI版和命令行版自动化生成数据集建议直接命令行。这里给出一套我常用的命令# 1. 特征提取 colmap feature_extractor \ --database_path project/database.db \ --image_path project/images \ --ImageReader.single_camera 1 \ --SiftExtraction.use_gpu 1 # 2. 特征匹配 colmap exhaustive_matcher \ --database_path project/database.db \ --SiftMatching.use_gpu 1 # 3. 增量式SfM重建 mkdir -p project/sparse colmap mapper \ --database_path project/database.db \ --image_path project/images \ --output_path project/sparse第一条命令做SIFT特征提取其中single_camera 1表示所有图像共用一个相机内参模型。对手机拍摄的数据来说这是合理的因为同一台手机主摄的焦距基本不变如果让COLMAP每张图单独标定一套内参出来的结果会很鬼畜。第二条命令做特征匹配。如果图片数量只有几十张exhaustive_matcher问题不大一旦到几百张它会在两两匹配上消耗大量时间。这个时候建议换成vocab_tree_matcher并加载COLMAP官方词袋模型或者用sequential_matcher——前提是你的图像文件名按拍摄顺序排列。第三句mapper就是增量式SfM输出在project/sparse/0目录下里面是二进制的cameras.bin、images.bin、points3D.bin。如果只是快速验证一个小数据集可以直接用colmap automatic_reconstructor一把梭跑完但它把很多细节藏起来了出了问题不好排查。我建议还是分步执行至少能定位是哪一步失败。跑完稀疏重建之后建议把二进制模型转成文本格式方便后续脚本读取colmap model_converter \ --input_path project/sparse/0 \ --output_path project/sparse_txt \ --output_type TXT转换后会得到三个文本文件cameras.txt、images.txt、points3D.txt。2.3 COLMAP输出文件的字段含义这一步是后面写转换脚本的基础需要先把COLMAP的文本模型读明白。cameras.txt每行描述一个相机格式是CAMERA_ID MODEL WIDTH HEIGHT PARAMS[]例如1 PINHOLE 1024 768 700.0 700.0 512.0 384.0代表针孔模型焦距fx700、fy700主点cx512、cy384。如果是SIMPLE_RADIAL模型参数只有f cx cy k只标定了一个焦距如果是OPENCV模型还有更多畸变参数。读取时要根据模型类型灵活处理。images.txt内容分两部分每张图像占两行。第一行是IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID IMAGE_NAMEQW/QX/QY/QZ是描述相机位姿的四元数w, x, y, z顺序TX/TY/TZ是平移向量。第二行是每个2D特征点对应的3D点索引和颜色值转换数据集时一般用不到。points3D.txt每行描述一个稀疏3D点包含坐标、颜色、误差和track信息计算深度范围时会用到。这里有个大坑二进制模型.bin解析起来非常麻烦建议先按上面的命令转成TXT。如果安装了pycolmap也可以直接读import pycolmap reconstruction pycolmap.Reconstruction(project/sparse_txt) for image_id, image in reconstruction.images.items(): qvec image.qvec tvec image.tvec camera reconstruction.cameras[image.camera_id] K camera.calibration_matrix()不管用哪种方式目标都是一样的拿到每张图像的四元数、平移向量、相机内参和图像文件路径。3. 核心转换从COLMAP输出到MVSNet目录结构3.1 先搭好目标目录并排序图像这一步的目标是把COLMAP生成的模型转换成MVSNet可以直接读取的数据目录。我一般先在工程目录下建一个mvs_input作为所有场景的根目录然后为每个重建场景建立一个单独的scan文件夹。建目录这种事看着简单但有两个细节直接影响后续训练。第一个是图像命名。MVSNet读取数据时会按文件名逐个加载如果你的图像名是IMG_20240101_123456.jpg这种随机字符串排序后视角顺序很可能乱掉导致后续视角选择完全不对。我建议统一改成从000000开始的六位整数序号比如000000.jpg、000001.jpg这样既稳定又方便对齐cam和depth文件。第二个是images和cams的文件名对应关系。MVSNet默认把图像后缀替换成_cam.txt来找相机文件所以约定必须统一。我自己常用的形式是图像叫000000.jpg相机文件叫000000_cam.txt深度文件叫000000.pfm三者共用一个整数序号。3.2 从四元数到extrinsic矩阵的转换MVSNet要求的extrinsic是world-to-camera的4x4齐次矩阵而COLMAP中qvec和tvec描述的正是“世界坐标到相机坐标”的变换关系也就是X_cam R * X_world t。所以关键就是先把四元数转成旋转矩阵。四元数转旋转矩阵的标准公式我直接写成可用的Python函数import numpy as np def quaternion_to_rotation_matrix(qvec): # qvec: (w, x, y, z) w, x, y, z qvec return np.array([ [1 - 2 * y * y - 2 * z * z, 2 * x * y - 2 * z * w, 2 * x * z 2 * y * w], [2 * x * y 2 * z * w, 1 - 2 * x * x - 2 * z * z, 2 * y * z - 2 * x * w], [2 * x * z - 2 * y * w, 2 * y * z 2 * x * w, 1 - 2 * x * x - 2 * y * y] ])然后构建extrinsic矩阵extrinsic np.eye(4) extrinsic[:3, :3] R extrinsic[:3, 3] tvec注意不要画蛇添足。有些从NeRF、3DGS项目里拿到的位姿是camera-to-worldc2w矩阵如果直接喂给MVSNet坐标系方向完全反了深度图会乱七八糟。MVSNet要的就是COLMAP原生的w2c位姿求逆后反而出错。3.3 K矩阵与图像去畸变的处理K矩阵直接来自cameras.txt。如果相机模型是PINHOLE把fx, fy, cx, cy填进3x3矩阵即可K np.eye(3) K[0, 0] fx K[1, 1] fy K[0, 2] cx K[1, 2] cy如果模型是SIMPLE_RADIAL只有一个焦距f那么K矩阵的fx和fy都用这个值。如果模型是OPENCV包含畸变参数K矩阵里只需要填焦距和主点畸变系数直接忽略——但这里有个严重隐患。MVSNet在可微单应变换中假设图像是针孔相机成像如果你把带桶形畸变的原图直接喂进去网络学到的深度会有系统性偏差尤其在图像边缘。所以实操中我强烈建议在导出数据前先用COLMAP的image_undistorter对图像做去畸变colmap image_undistorter \ --image_path project/images \ --input_path project/sparse_txt \ --output_path project/dense \ --output_type COLMAP这一步会在project/dense/images下生成去畸变图像同时更新稀疏模型K矩阵也会变成纯pinhole模型。后续做PatchMatch深度生成和cam导出都基于这份去畸变数据和更新后的模型。这样做出来的数据集MVSNet吃到的图像和K矩阵才是完全一致的。3.4 一个可复用的转换脚本骨架把上面的逻辑串起来核心转换代码大概长这样。这里我假设已经用pycolmap读入了模型import os import shutil import numpy as np def export_to_mvs(reconstruction, input_image_dir, output_dir, sample_name): image_dir os.path.join(output_dir, sample_name, images) cam_dir os.path.join(output_dir, sample_name, cams) depth_dir os.path.join(output_dir, sample_name, depth) for d in [image_dir, cam_dir, depth_dir]: os.makedirs(d, exist_okTrue) # 按图像名排序保持视角顺序稳定 images sorted(reconstruction.images.values(), keylambda x: x.name) for i, image in enumerate(images): # 旋转矩阵和外参 R quaternion_to_rotation_matrix(image.qvec) extrinsic np.eye(4) extrinsic[:3, :3] R extrinsic[:3, 3] image.tvec # 内参 cam reconstruction.cameras[image.camera_id] K np.eye(3) K[0, 0] cam.fx if hasattr(cam, fx) else cam.params[0] K[1, 1] cam.fy if hasattr(cam, fy) else cam.params[1] K[0, 2] cam.cx if hasattr(cam, cx) else cam.params[2] K[1, 2] cam.cy if hasattr(cam, cy) else cam.params[3] # 深度范围这里先留接口见后面4.2节 depth_min, depth_max estimate_depth_range(reconstruction, image) # 写cam文件 cam_path os.path.join(cam_dir, f{i:06d}_cam.txt) with open(cam_path, w) as f: f.write(extrinsic\n) f.write(\n.join( .join(f{x:.8f} for x in row) for row in extrinsic)) f.write(\n\nintrinsic\n) f.write(\n.join( .join(f{x:.8f} for x in row) for row in K)) f.write(f\n\ndepth_min\n{depth_min:.6f}\n\ndepth_max\n{depth_max:.6f}\n) # 复制图像并重命名 src os.path.join(input_image_dir, image.name) dst os.path.join(image_dir, f{i:06d}.jpg) shutil.copy(src, dst)这里estimate_depth_range先预留接口后面专门讲。如果不想用pycolmap就自己解析cameras.txt和images.txt核心逻辑完全一样只是多了一些字符串处理。MVSNet官方仓库也有个colmap2mvsnet.py脚本但它对多相机模型、去畸变的处理比较简陋实际中我更喜欢基于自写脚本做因为可控性强方便按自己场景调整。4. 深度图生成、尺度对齐与数据集自检4.1 用COLMAP PatchMatch生成稠密深度图真实场景没有GT深度比较靠谱的方式是用COLMAP的PatchMatch Stereo模块生成稠密深度图。注意这个环节要在image_undistorter生成的dense目录上操作colmap patch_match_stereo \ --workspace_path project/dense \ --workspace_format COLMAP \ --PatchMatchStereo.geom_consistency true \ --PatchMatchStereo.max_image_size 2000geom_consistency true会开启几何一致性校验同时生成photometric.bin和geometric.bin两类深度图geometric.bin经过了多视角几何约束校验质量更高我一般优先用它。跑完之后深度图在project/dense/stereo/depth_maps/目录下文件名形如000000.geometric.bin。这些bin文件不是普通的图片格式需要单独写读取函数。COLMAP的bin深度图格式基本是前12个字节存三个int分别是宽度、高度、通道数后面跟一个float32数组。我常用的读取函数长这样import struct def read_colmap_depth_bin(path): with open(path, rb) as f: width struct.unpack(i, f.read(4))[0] height struct.unpack(i, f.read(4))[0] channel struct.unpack(i, f.read(4))[0] data np.fromfile(f, dtypenp.float32).reshape(height, width, channel) return data[..., 0]拿到深度图后MVSNet通常读取.pfm格式。PFM是浮点图像格式方便保存非整数深度值。写PFM的函数很简单def write_pfm(path, image): with open(path, wb) as f: image image.astype(np.float32) f.write(bPF\n) f.write(f{image.shape[1]} {image.shape[0]}\n.encode()) f.write(b-1.0\n) image.tofile(f)然后按照images目录里的顺序把每张深度图写到depth/000000.pfm。4.2 depth_min/depth_max的估计方法很多人喜欢直接写死depth_min0.1、depth_max10.0省事但问题不小。范围给太宽网络的有效深度采样范围被稀释深度图精度下降范围给太窄又会把真实深度裁掉。我建议用点云计算每个视角下的实际深度范围。方法是把COLMAP重建得到的三维点云变换到当前相机坐标系下取z值的有效范围作为参考def estimate_depth_range(reconstruction, image, points3D): R quaternion_to_rotation_matrix(image.qvec) t image.tvec.reshape(3, 1) # 所有点转到该相机坐标系 X_cam (R points3D.T t) z X_cam[2, :] # 只保留有效正深度 z z[np.isfinite(z) (z 0)] if z.size 0: return 0.1, 10.0 # 留一点余量 return z.min() * 0.8, z.max() * 1.2这里points3D如果是稀疏点云点数少、分布稀疏估计出的范围往往偏紧所以更建议用PatchMatch稠密重建后的点云fused.ply来算。还有一种更直接的方法对每个视角直接用该视角PatchMatch深度图的非零值取最小和最大再乘以0.9和1.1效果也不错。4.3 尺度对齐为什么深度图和位姿必须同尺度COLMAP的SfM重建是up-to-scale的重建出来的场景尺寸和真实物理尺寸之间差一个未知的scale因子。如果你只是用同一套COLMAP模型导出cams和深度图那二者天然同尺度不会出问题——这也是最推荐的做法。容易翻车的是混合数据。比如你手里有DTU数据集的一个子集想拿自己的COLMAP重建场景一起混合训练。DTU的尺度是毫米级别的世界坐标而COLMAP重建出来的尺度是一个任意单位两者直接混训会导致网络深度预测混乱。这个时候必须把COLMAP场景的位姿和深度图整体乘以一个scale因子让它和DTU尺度对齐。怎么确定这个scale土办法是在场景里放一个已知尺寸的标定尺或者拿卷尺量出两三个关键点之间的实际距离再在COLMAP点云里量出对应点之间的重建距离实际距离除以重建距离就是scale。如果你只是单跑自己的场景做fine-tune不和其他数据集混合那这个scale不必纠结保持COLMAP内部一致就好。4.4 自检训练前一定要做的可视化验证转换完之后不要急着开训练先做一轮自检这里面能救命的经验太多了。我最常用的方法是在一张深度图上随机采样若干像素点利用深度值和相机内参把像素反投影成三维点再用相邻视角的cam文件把这些三维点投影到邻域图像上看颜色是否一致。如果反投影点在多张图上颜色连贯说明cams和深度图的坐标系是一致的。也可以用Open3D打开稠密点云和相机位姿做可视化。如果点云和相机锥体明显分离、相机的朝向完全不对那大概率是把extrinsic拼成了c2w或者是四元数顺序搞错了。这类检查能帮你省下几天调试训练的时间强烈建议不要跳过。5. 常见问题排查与避坑速查5.1 高频问题与解决思路我把实际使用中遇到比较多的问题整理成一张速查表方便大家按图索骥现象可能原因解决方法MVSNet读取cam文件报IndexErrorcam文件行数不对空行或字段缺失严格按照16行模板生成检查末尾换行符深度图全黑、全是0或NaNPatchMatch失败、深度导出格式错误检查图像重叠率重新跑PatchMatch检查bin文件头训练前期loss正常后期发散depth_min/depth_max范围给太宽用点云重算深度范围去掉无效空间自采数据推理深度图边缘扭曲图像畸变未校正用image_undistorter去畸变后再导出相机位姿和点云明显错位把w2c和c2w搞反了检查extrinsic矩阵确认是X_cam R*X_world t特征匹配极慢图片太多exhaustive_matcher过慢换vocab_tree_matcher或sequential_matcher稀疏重建漂移、注册图像很少图片重叠不足或模糊图太多增加重叠率删除模糊帧分多个子序列重建5.2 我踩过的一个典型案例有一次采集了一个室内场景总共120张图前面SfM跑得好好的PatchMatch也跑完了但拿到的深度图看过去有一半区域是黑的。我一开始以为是PatchMatch参数问题来回调了max_image_size、window_size折腾了一晚上没效果。后来冷静下来把其中一张失败深度图对应的图像和邻域图叠在一起看才发现那个区域有一片纯白的墙面和落地窗玻璃纹理极度稀疏PatchMatch在这种区域本来就拿不到可靠匹配。这个例子告诉我们深度图质量的下限其实在采集阶段就决定了光照均匀、纹理丰富的区域才会生成可靠深度。纯色墙、玻璃、水面这些区域要么在采集时想办法加纹理贴张海报、摆个箱子要么在生成深度图后做后处理把无效深度区域mask掉再参与训练。硬调网络参数是解决不了数据本身的缺陷的。5.3 数据量到底要多大很多用户第一个问题就是我自己造数据集一个场景拍多少张合适从MVSNet训练的角度看一个场景的视角数直接决定训练样本的多样性。DTU原始数据集每个scan大约有49到64张图分多种光照条件。自采数据不需要那么多但至少保证一个场景20张以上并且视角差异足够大否则网络学不到多视角几何关系。如果你只是拿自采数据做预训练模型的fine-tune一个场景二三十张图就够起步如果想从零训练那至少需要几十个场景每个场景二三十张图工作量会大很多。我通常的做法是先用小物体简单背景把整个数据生成流程跑通确认格式没问题再扩展到大场景。5.4 关于MVSNet对场景尺度的敏感性MVSNet本身对场景尺度有一定敏感性主要原因是网络在深度假设上是在depth_min和depth_max之间均匀采样的。如果你的场景深度范围特别大比如从0.3米到100米网络在近处和远处的深度分辨率会很不均匀导致效果变差。这时候可以考虑把场景拆成几个子区域分别生成数据、分别训练或者调整采样策略。我自己的体会是COLMAP生成MVSNet数据集这件事最大的成本不在软件操作上而在数据质量和格式正确性上。格式问题写过一次脚本后就不会再犯但数据质量需要靠采集经验和反复自检来积累。每次拿到一批新图像我都会先跑一个快速SfM看注册率注册率低于90%的批次直接打回重拍绝不带到后面的流程里。这个习惯帮我省掉了大量无效训练时间也推荐你试试。