
简介面向计算机视觉研究的三维重建项目融合双目视觉与结构光投影两大技术路线采用12步相移法和互补格雷码完成高精度相位解包裹并通过双目标定、极线校正、相位匹配与视差计算最终输出三维点云模型。资源提供可直接运行的Python实现整体设计完整适合作为课程设计、课题研究或工程验证的参考代码。压缩包共89个文件约104MB涵盖Python主程序、Jupyter Notebook逐步分析脚本、bmp/png格式的投影图案与中间结果图、XML/iml项目配置以及docx/md/txt说明文档代码、数据与文档配套齐全。其中分步解释文件与结果图像直观展示了相位计算和点云生成效果便于对照学习。已有149人下载学习目录内附项目结构、实验数据与说明文件可帮助快速理解从相机标定、相位解包到点云生成的全流程。1. 从一套双目结构光工程里能拆出什么随手拆一个用双目视觉和结构光投影做的三维重建项目zip 里最显眼的是main.py、explain.ipynb以及data下phase、calib两批数据。它最抓人的不是某个单点算法而是完整的工程选型12 步相移法求高精度包裹相位互补格雷码负责把相位展开双目标定拿内外参数再做极线校正最后通过相位匹配和视差计算生成三维点云模型。这套组合比纯 SGM 或单目光栅投影多一个关键优势左右相机看到的是主动投射的编码条纹像素级匹配不再依赖物体纹理白墙、塑料件、不光亮的金属面都能稳定采到点。适合正在搭结构光测量、或者想从被动双目转主动编码的工程师读。2. 投影编码与相位解包裹12步相移和互补格雷码如何分工结构光重建的第一步不是算视差而是把投影仪变成“位置编码器”。相位图到底多干净直接决定后面点云噪声水平。这个项目在同一套采集流程里同时用相移法和格雷码互补格雷码又专门用来压边界误差下面拆开看。2.1 为什么是12步相移而不是4步常见 4 步相移公式简单一个 arctan 就能解出相位但工程上很少直接用在精细重建里。投影仪的 gamma 非线性、相机曝光不均匀、多次反射都会引入谐波误差4 步相移对这些误差几乎没有抑制能力12 步相移在最小二乘意义下能显著衰减低次谐波相位均方根误差通常能降一个量级左右。代价是投影帧数从 4 变成 12采集时间线性上升所以项目里标的是静态高精度场景不是在线流水线。下面这段代码可以从零生成 12 步正弦条纹在本地的explain.ipynb里也能看到同样逻辑的实现import numpy as np def make_fringe(width, height, period_px, step, steps): phase 2.0 * np.pi * step / steps x np.arange(width, dtypenp.float32).reshape(1, -1) gray 0.5 0.5 * np.cos(2.0 * np.pi * x / period_px phase) return (gray * 255).astype(np.uint8) fringes [make_fringe(1280, 800, period_px32, stepk, steps12) for k in range(12)]这里period_px32是条纹周期表示一个完整的 2π 相位对应 32 个像素steps12表示在一个周期内均匀采样 12 个相位。生成时用np.float32而不是np.uint8直接运算可以避免灰度量化拖累相位精度最后转成uint8只是为了让投影仪和相机接口能用。拿到 12 张条纹图后用标准的 N 步相移公式解包裹相位def compute_wrapped_phase(images, steps): num np.zeros(images[0].shape, np.float32) den np.zeros_like(num) for k, img in enumerate(images): intensity img.astype(np.float32) / 255.0 num intensity * np.sin(2.0 * np.pi * k / steps) den intensity * np.cos(2.0 * np.pi * k / steps) return np.arctan2(num, den)这段代码把每个像素在 12 个相位上的强度投影到正弦和余弦基底上np.arctan2返回的角度落在[-π, π)所以叫“包裹相位”。12 步的均值效应会把随机噪声压掉不少但相位仍然有一个周期内无法分辨的问题下一步需要格雷码来编号。2.2 互补格雷码怎样压掉周期边界上的错位包裹相位只在单个条纹周期内有效要恢复绝对相位必须给每个周期一个唯一的整数编号。普通二进制编码在边界处可能有多位同时翻转投影仪散焦或相机采样只要偏一点点解码就会跳号反映在点云上就是周期性的“断层”。格雷码的最大特点就是相邻码字只相差一位边界处的模糊最多影响一个 bit鲁棒性明显更好。互补格雷码在这个项目的用途更具体它同时投影正向格雷码图和反向格雷码图把两个方向的解码结果做一致性判断。如果正向和反向在同一像素上判断一致就采信如果不一致说明这个像素正好落在码字边界交给相位图判断它到底属于左边还是右边周期。这样既能保住格雷码的全局编码能力又能把边界上的离散判断换成连续的相位判断。def complement_gray_decode(pos_imgs, neg_imgs, threshold0.5): 输入正向格雷码图序列和反向格雷码图序列 返回逐像素的格雷码整数 bits [] for p, n in zip(pos_imgs, neg_imgs): positive_high (p threshold) negative_low (n threshold) bit (positive_high negative_low).astype(np.uint8) bits.append(bit) code np.zeros_like(bits[0], dtypenp.int32) for bit in bits: code (code 1) | bit return code逻辑是只有正向图亮且反向图暗的位置当前二进制位才为 1两者不一致时该位为 0这样实现的是“稳定位优先”。threshold0.5是以归一化灰度 0.5 为界如果相机暗电流偏大我一般会先对整组图做灰度直方图统计找一个全局阈值替换不要照抄 0.5。2.3 把格雷码和相位合并成绝对相位解包裹的最终目的是生成一个单调连续的绝对相位图。合并公式不复杂unwrap_phase wrapped_phase (code - 1) * 2.0 * np.pi这里code是互补格雷码解码出的周期编号从 0 开始wrapped_phase落在[-π, π)所以当前周期编号要减 1 才能把第一个周期的相位对齐到[-π, π)。代码里的减法细节容易写错我一般会先对code做一次连通域检查再用np.min确认最小值是不是 0因为 OpenCV 读图时偶尔会把接近 0 的像素压成 0 或 255造成整行码字错位。不同投影编码方案的实际差异可以看这张表项目里选的是最后一行方案相位噪声周期边界可靠性投影帧数适用场景4 步相移 二进制码较高较低少快速验证4 步相移 互补格雷码中中中教学演示12 步相移 互补格雷码低高多离线高精度重建帧数多意味着采集时间变长实测里一个视角大约要投影 12 张相移图加 2 组互补格雷码图如果相机帧率是 30fps单视角至少需要 1 到 2 秒适合固定物体。重建点云前还需要把左右相机各自的绝对相位图做一次横向均值滤波把残留的跳变点修掉。3. 双目标定与极线校正把两台相机拉进同一几何约束相位解包生产出来的是“带绝对周期的相位图”但左右两个相机看到的相位图不在同一个坐标系直接逐像素相减没有意义。这一步必须靠双目标定把两台相机的位置关系、内参畸变全部解出来再用极线校正把左右图拉成严格行对齐。3.1 棋盘格标定流程与参数的意义项目里data/calib保存的就是采集好的棋盘格图像对。单目标定负责求每台相机的内参矩阵K和畸变系数D双目标定负责求两台相机之间的旋转矩阵R和平移向量T。棋盘格角点提取的经典写法是square_size_mm 30.0 objp np.zeros((9 * 6, 3), np.float32) objp[:, :2] np.mgrid[0:9, 0:6].T.reshape(-1, 2) * square_size_mm ret, corners cv2.findChessboardCorners(gray_l, (9, 6), None) criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners cv2.cornerSubPix(gray_l, corners, (5, 5), (-1, -1), criteria)objp是棋盘格的物理坐标z统一设为 0square_size_mm直接决定三维点云的绝对尺度。findChessboardCorners的(9,6)是内角点数不是外角点数cornerSubPix在角点附近做亚像素精化不给这个步骤后面stereoCalibrate的 RMS 很难压到 0.2 像素以内。接着做双目标定RMS, K1, D1, K2, D2, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, K1, D1, K2, D2, (width, height), flagscv2.CALIB_FIX_INTRINSIC)RMS是标定残差单位是像素通常小于 0.2 才说明标定板位姿足够丰富。K是 3x3 内参矩阵D是畸变系数R、T描述右相机相对左相机的位姿E、F是本质矩阵和基础矩阵。采集标定板时不要只把板放在画面正中至少要覆盖四角和中心区域并且左右相机中都要完整看到所有角点否则R、T会偏。3.2 stereoRectify 的参数选择与 remap 流程stereoCalibrate之后左右图像还不能直接做像素匹配需要用stereoRectify把两个相机变成理想的前向平行配置。这里的核心参数是alphaR1, R2, P1, P2, Q, validPixROI1, validPixROI2 cv2.stereoRectify( K1, D1, K2, D2, (width, height), R, T, flagscv2.CALIB_ZERO_DISPARITY, alpha0, newImageSize(width, height)) map1_l, map2_l cv2.initUndistortRectifyMap( K1, D1, R1, P1, (width, height), cv2.CV_32FC1) map1_r, map2_r cv2.initUndistortRectifyMap( K2, D2, R2, P2, (width, height), cv2.CV_32FC1) rectified_l cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR) rectified_r cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR)alpha0表示校正后只保留最大有效区域边缘会被裁剪但畸变最小alpha1表示保留原始图像的所有像素代价是边缘区域存在明显拉伸。Q矩阵是后续用reprojectImageTo3D生成点云的关键它把(x, y, disparity, 1)直接映射成(X, Y, Z, W)。remap的映射表必须是CV_32FC1不能省掉这个类型否则 OpenCV 会按双精度重采样内存占用陡增。下表总结了这组函数在这个项目里的作用函数作用常见问题findChessboardCorners检测棋盘格内角点检查输入尺寸是否为内角点数cornerSubPix亚像素精化角点搜索窗口太大会收敛到邻近角点stereoCalibrate求双目的R/T/E/F图像对数量少于 15 对时结果不稳定stereoRectify生成极线校正投影矩阵alpha和newImageSize必须与原始图一致remap按映射表重采样图像映射表用错类型会导致黑边或错位3.3 极线校正质量的快速验证校正完先别急着跑匹配花半分钟画几条水平参考线能省下后面排错的时间for y in range(0, rectified_l.shape[0], 60): cv2.line(rectified_l, (0, y), (rectified_l.shape[1], y), (0, 255, 0), 1) cv2.line(rectified_r, (0, y), (rectified_r.shape[1], y), (0, 255, 0), 1) check np.hstack([rectified_l, rectified_r])把check显示出来如果同一物体的轮廓在左右图上的高度差超过 1 至 2 个像素说明双目标定参数有问题。更严的做法是在校正图上重新提取棋盘格角点检查同名点的 y 坐标差均值这个值小于 0.3 像素才算合格。项目里explain.ipynb生成的result.png就是这个阶段的可视化输出能直接对照检查。4. 相位匹配、视差计算与三维点云生成左右相位图建立一致坐标系后三维重建就落到了最实在的一步找到一个能覆盖全图的高精度视差图再把它换算成空间坐标。这一步能写进简历也能写进交付报告。4.1 相位匹配比灰度匹配稳在哪里被动双目的 SGM 在纹理重复、墙面空白、强反光区域经常匹配失败原因是代价计算依赖亮度一致性。结构光管线把亮度图案换成了相位图案每个像素的值是对一个连续相位场的采样相同物体表面在左右相机里解出来的绝对相位应该相等。因此相位匹配的代价函数可以写成相位差绝对值左图某像素的绝对相位 φL(x, y)在右图同一行上找 φR(x d, y)使得两者误差最小。实际项目里可以直接用相位差换视差lambda_phase 32 # 与生成条纹的 period_px 一致 disparity (phase_L - phase_R) * lambda_phase / (2.0 * np.pi)phase_L和phase_R是经过互补格雷码展开后的绝对相位图单位是弧度。这段公式成立的前提是左右图已经极线校正过相位差只包含水平方向的视差分量。lambda_phase选小噪声会放大选太大相位匹配的搜索范围不够容易产生空洞。项目里用 32 像素周期配合 12 步相移是我在类似工程里验证过相对均衡的参数。4.2 从视差图到三维坐标Q 矩阵与三角化相位匹配得到的视差已经比较密但仍会有少量孤立坏点。工程里通常会再用 SGBM 对极线校正后的图像重算一版视差作为相位视差的交叉校验或者干脆用相位差结果初始化然后用 WLS 滤波平滑。只要前面Q矩阵已经拿到转点云只需要一行核心代码stereo cv2.StereoSGBM_create( minDisparity0, numDisparities128, blockSize7, P18 * 3 * 7 * 7, P232 * 3 * 7 * 7, uniquenessRatio10, speckleWindowSize100, speckleRange2, disp12MaxDiff1, ) disp stereo.compute(rectified_l, rectified_r).astype(np.float32) / 16.0 xyz cv2.reprojectImageTo3D(disp, Q)StereoSGBM_create的disp返回值单位是 1/16 像素除以 16 才还原成像素视差。P1和P2是视差平滑的惩罚系数P2约为P1的 4 倍时能保留边缘设太大细节会被抹平成斜坡。blockSize7适合理想曝光下的结构光图光照噪声大时提到 9。numDisparities128表示最大视差搜索范围物体离相机越近需要的范围越大。点云写 PLY 前需要把无效视差过滤掉再和 RGB 图对齐mask (disp 0) (xyz[..., 2] max_z) (xyz[..., 2] min_z) points xyz[mask] colors rectified_l[mask]xyz[..., 2]是深度值单位与标定板square_size_mm一致。这一步能有效去掉背景中超过工作距离的杂点。最后写出的 PLY 文件可以保持最简的顶点列表点太多时再考虑抽稀。下面的参数表是从这个项目场景里整理出来的常用口径参数推荐值影响minDisparity0太小会裁掉近距离物体numDisparities128越大越吃内存且必须能被 16 整除blockSize7 或 9太小噪点多太大细节模糊P1/P28*3*7*7 / 32*3*7*7对视差平滑程度影响最大uniquenessRatio10低于 5 时误匹配显著增多4.3 点云尺度、坐标系和常见坑最容易踩的坑是尺度跑偏。标定时square_size_mm30点云坐标就按毫米算如果写成了 1所有物体都会变成原来尺寸的 30 倍。另一个坑是Q矩阵的符号左右相机如果接反T_x的符号会反过来生成点云的 z 轴整体变负肉眼看到的模型是内外面颠倒。我一般先打印xyz[..., 2]的中位数正值说明坐标系正确负值就要调换左右输入顺序重新标定。最后一类问题来自相位周期边缘。格雷码和相移在条纹边界附近大概率存在个别跳变点直接把它们转成点云会形成“毛刺”。项目里explain.ipynb中间输出的explain_15_0.png到explain_18_0.png就是在描述这个现象边界上的相位误差会沿着当前行扩散。针对它合理的处理是裁剪左右边界各 8 个像素同时对视差图做一次 3x3 中值滤波再把滤波前后的结果做差把差值大于 2 像素的像素视为异常并剔除。5. 让重建结果经得起验算重投影误差、相位误差和点云抽稀很多项目写完了能出图但没写怎么验证精度导致换一个相机或者换一个场景就没法复现。这个项目值得借鉴的地方是它把标定和重建放在一个可复现的main.py流程里所以验证也可以照着做。5.1 重投影误差和相位误差怎么算标定阶段的RMS只是一个总体数字我习惯再单独算一遍每个角点的重投影误差total_err 0.0 for k in range(len(objpoints)): proj, _ cv2.projectPoints( objpoints[k], rvecs_l[k], tvecs_l[k], K1, D1) err np.sqrt(np.sum( (proj.reshape(-1, 2) - imgpoints_l[k]) ** 2, axis1)) total_err err.mean() print(平均重投影误差: {:.3f} px.format(total_err / len(objpoints)))rvecs_l和tvecs_l来自单目标定。误差小于 0.2 像素说明标定板位姿覆盖充分超过 0.5 像素时我一般会先删掉边缘角点不完整的帧而不是直接加更多帧。相位误差的验证方法是放一块平面白板重建它的点云后用最小二乘拟合一个平面统计所有点到平面距离的标准差这个值如果大于 0.1 毫米优先检查投影条纹是否存在过曝而不是怀疑标定矩阵。5.2 点云抽稀和法向一致性12 步相移和互补格雷码产出的点云往往过密单帧几百万点很常见。直接塞进渲染器会卡顿法向估计也会被噪点带偏。我一般优先做网格下采样再用法向一致化清理离群点import open3d as o3d pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points) pcd pcd.voxel_down_sample(voxel_size0.5) pcd, _ pcd.remove_statistical_outlier(nb_neighbors20, std_ratio1.5)voxel_size0.5表示把空间分成 0.5 毫米的小格每个格子里只保留一个点std_ratio1.5表示邻域距离均值超过 1.5 倍标准差的点会被去掉。这两个值的选取取决于点云单位用毫米时 0.5 对大多数桌面级测量都合适如果扫描的是大型机柜我通常把它调到 2 到 5 毫米。最后再说一个容易被忽略的细节相位图和视差图转点云后靠近视差搜索范围边界的点往往带系统性偏斜。常规做法是直接丢弃disp numDisparities - 16的像素把边界区域让出来而不是强行线性补偿。这样处理后模型边缘干净后续接配准或接 3DGS 分步学习管线时也不会被外围毛刺拖累收敛速度。本文还有配套的精品资源点击获取