简介:面向计算机视觉研究的三维重建项目,融合双目视觉与结构光投影两大技术路线,采用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, dtype=np.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_px=32, step=k, steps=12) for k in range(12)]这里period_px=32是条纹周期,表示一个完整的 2π 相位对应 32 个像素;steps=12表示在一个周期内均匀采样 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, threshold=0.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], dtype=np.int32) for bit in bits: code = (code << 1) | bit return code逻辑是:只有正向图亮且反向图暗的位置,当前二进制位才为 1;两者不一致时该位为 0,这样实现的是“稳定位优先”。threshold=0.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统一设为 0,square_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), flags=cv2.CALIB_FIX_INTRINSIC)RMS是标定残差,单位是像素,通常小于 0.2 才说明标定板位姿足够丰富。K是 3x3 内参矩阵,D是畸变系数,R、T描述右相机相对左相机的位姿,E、F是本质矩阵和基础矩阵。采集标定板时不要只把板放在画面正中,至少要覆盖四角和中心区域,并且左右相机中都要完整看到所有角点,否则R、T会偏。
3.2 stereoRectify 的参数选择与 remap 流程
stereoCalibrate之后,左右图像还不能直接做像素匹配,需要用stereoRectify把两个相机变成理想的前向平行配置。这里的核心参数是alpha:
R1, R2, P1, P2, Q, validPixROI1, validPixROI2 = cv2.stereoRectify( K1, D1, K2, D2, (width, height), R, T, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0, 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)alpha=0表示校正后只保留最大有效区域,边缘会被裁剪,但畸变最小;alpha=1表示保留原始图像的所有像素,代价是边缘区域存在明显拉伸。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( minDisparity=0, numDisparities=128, blockSize=7, P1=8 * 3 * 7 * 7, P2=32 * 3 * 7 * 7, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2, disp12MaxDiff=1, ) 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 倍时能保留边缘,设太大细节会被抹平成斜坡。blockSize=7适合理想曝光下的结构光图,光照噪声大时提到 9。numDisparities=128表示最大视差搜索范围,物体离相机越近,需要的范围越大。
点云写 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 文件可以保持最简的顶点列表,点太多时再考虑抽稀。
下面的参数表是从这个项目场景里整理出来的常用口径:
| 参数 | 推荐值 | 影响 |
|---|---|---|
minDisparity | 0 | 太小会裁掉近距离物体 |
numDisparities | 128 | 越大越吃内存,且必须能被 16 整除 |
blockSize | 7 或 9 | 太小噪点多,太大细节模糊 |
P1/P2 | 8*3*7*7 / 32*3*7*7 | 对视差平滑程度影响最大 |
uniquenessRatio | 10 | 低于 5 时误匹配显著增多 |
4.3 点云尺度、坐标系和常见坑
最容易踩的坑是尺度跑偏。标定时square_size_mm=30,点云坐标就按毫米算;如果写成了 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, axis=1)) 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_size=0.5) pcd, _ = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=1.5)voxel_size=0.5表示把空间分成 0.5 毫米的小格,每个格子里只保留一个点;std_ratio=1.5表示邻域距离均值超过 1.5 倍标准差的点会被去掉。这两个值的选取取决于点云单位:用毫米时 0.5 对大多数桌面级测量都合适,如果扫描的是大型机柜,我通常把它调到 2 到 5 毫米。
最后再说一个容易被忽略的细节:相位图和视差图转点云后,靠近视差搜索范围边界的点往往带系统性偏斜。常规做法是直接丢弃disp > numDisparities - 16的像素,把边界区域让出来,而不是强行线性补偿。这样处理后模型边缘干净,后续接配准或接 3DGS 分步学习管线时,也不会被外围毛刺拖累收敛速度。
本文还有配套的精品资源,点击获取