3D视觉实战:从零构建线结构光轮廓测量系统
最近在做一个工业质检的小项目,需要快速获取一批不规则金属零件的表面轮廓。传统的接触式测量效率太低,而市面上的3D扫描仪要么太贵,要么精度不够。折腾了一圈,最后把目光投向了线结构光测量——这个听起来有点学术,但实际搭建起来却相当亲民的技术。它就像用一把“光刀”去切割物体表面,然后用相机拍下这道光的变形,就能反推出物体的三维形状。整个过程不需要复杂的设备,一台普通的工业相机、一个线激光器,再加上我们熟悉的Python和OpenCV,就能搭建一套属于自己的3D测量系统。这篇文章,我就把自己从原理摸索到代码落地的完整过程,以及中间踩过的那些坑,毫无保留地分享给同样想快速上手3D视觉的朋友们。
1. 线结构光:原理与核心思想
很多人第一次接触线结构光,容易被那些“光平面”、“世界坐标系”、“相机标定”之类的术语吓到。其实它的核心思想非常直观。想象一下,在一个黑暗的房间里,你手持一支激光笔,如果激光笔发出的是一个光点,你只能知道墙上某一个点的位置。但如果你在激光笔前加一个特殊的透镜(柱面镜),它就能把光点“拉”成一条明亮的细线,就像一把光做的尺子。
当你把这把“光尺”投射到一个平整的墙面上,相机看到的就是一条笔直的亮线。但如果你把一个小物件(比如一个鸡蛋)放在墙前,光尺照在鸡蛋弯曲的表面上,相机看到的就不再是直线,而是一条随着鸡蛋轮廓弯曲的曲线。这条曲线的每一个像素点的偏移量,都编码了鸡蛋表面在该位置的高度信息。这就是线结构光测量最朴素也最核心的原理:通过观测已知形态的结构光图案在物体表面的变形,来解算物体的三维形貌。
与逐点扫描的“点结构光”相比,线结构光一次就能获取物体一个剖面上的数百个点,效率的提升是指数级的。这也是它被称为“光切法”的原因——就像用光做切片,一次切下物体的一整个剖面。
整个测量流程可以抽象为以下几个关键步骤:
- 系统搭建与标定:固定相机和线激光器的相对位置,并精确计算出相机成像的数学模型(内参)以及光平面在空间中的方程(外参)。
- 图像采集与预处理:在黑暗或可控光照环境下,采集投射了激光线的物体图像,并通过图像处理技术提取出那条明亮的激光条纹中心线。
- 条纹中心提取:这是精度保障的关键,需要从图像中亚像素级地定位激光条纹的中心位置。
- 三维坐标重建:将图像上提取的每一个中心点像素坐标,结合相机标定参数和光平面方程,通过三角测量原理,计算其在真实三维空间中的坐标。
- 点云处理与轮廓生成:将计算出的三维点集(点云)进行后续处理,如滤波、拼接(如果是多线扫描),最终生成可用的物体轮廓或三维模型。
这个过程里,标定的精度直接决定了整个系统的测量精度,而条纹中心提取的算法则决定了数据的细节丰富程度。下面,我们就深入每个环节,看看具体怎么操作。
2. 硬件搭建与系统标定:给系统装上“尺子”
在开始写代码之前,你得先把硬件架子搭起来。这套系统最简配置只需要三样东西:
- 面阵CCD/CMOS相机:建议选择全局快门相机,避免拍摄移动物体时产生拖影。分辨率根据测量范围和精度要求来定,通常200万像素以上即可开始实验。
- 线激光器:选择功率合适(Class 2或以下更安全)、线宽均匀、发散角小的型号。波长常见有红光(650nm)和蓝光(450nm),在暗环境下红光更醒目,但某些材料表面蓝光反射特性更好。
- 计算机:用于运行处理程序和存储数据。
安装时,让相机和激光器大致对准待测区域,形成一个经典的三角测量布局。激光器投射的光平面要与相机的成像平面成一定角度(通常20°-45°),这个角度越大,对高度变化的灵敏度越高,但视野可能越小,且容易产生遮挡。
硬件摆好只是第一步,接下来的系统标定才是重头戏,它决定了你的系统“看”得准不准。标定分为两部分:相机标定和光平面标定。
2.1 相机标定:确定相机的“内参”
相机标定的目的是获取相机的内部参数和畸变系数。你可以把它理解为给相机做一次“体检”,弄清楚它的成像特性。我们使用经典的张正友标定法,借助OpenCV可以轻松完成。
你需要准备一个高精度的棋盘格标定板(比如10x7的内角点),从不同角度、不同位置拍摄十几到二十张标定板的图片。确保标定板在每张图中都清晰、完整,且姿态多样。
import cv2
import numpy as np
import glob
# 准备标定板参数 (以10x7内角点为例)
pattern_size = (9, 6) # 内角点数量 (宽度-1, 高度-1)
square_size = 25.0 # 每个方格的实际物理尺寸,单位毫米
# 存储对象点和图像点的容器
obj_points = [] # 3D点 (世界坐标系)
img_points = [] # 2D点 (图像坐标系)
# 生成标定板角点的世界坐标 (Z=0)
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)
objp *= square_size
# 读取所有标定图像
images = glob.glob('./calibration_images/*.jpg')
for fname in images:
img = cv2.imread(fname)

&spm=1001.2101.3001.5002&articleId=154414034&d=1&t=3&u=9302050ea1364ddba8bd54df81260f90)
1018

被折叠的 条评论
为什么被折叠?



