4、图像采集与预处理:从SDK到可用图像

图像采集和预处理,说白了就是让相机“看见”并“说清楚”的过程。很多新手觉得这一步就是调个参数、按个快门,其实不然。我见过太多项目,标定算法再牛,采集的图像质量不行,后面全是白费功夫。

这一章,咱们就聊聊怎么用SDK把线扫图像稳稳地抓下来,再通过预处理让它变成标定算法喜欢的样子。

4.1 使用SDK采集线扫图像

线扫相机的SDK各家有各家的写法,但核心逻辑大同小异。我个人习惯把采集流程拆成三步:初始化 → 配置触发 → 循环取图。

4.1.1 初始化与连接

先看一段伪代码,这是最基础的骨架:

// 1. 枚举设备
CameraList list = Camera.Enumerate();
if (list.Count == 0) {
    Log("未找到相机,请检查连接");
    return;
}

// 2. 选择设备并打开
Camera cam = list[0];
cam.Open();

// 3. 设置采集模式(连续采集或触发采集)
cam.TriggerMode = TriggerMode.Continuous; // 连续模式
// cam.TriggerMode = TriggerMode.LineTrigger; // 线触发模式(常用)

// 4. 开始采集
cam.StartGrabbing();
我的经验:枚举设备时,最好加个重试机制。工业现场USB或网线偶尔会接触不良,一次枚举不到就报错,太武断了。我一般重试3次,每次间隔500ms。

4.1.2 触发模式的选择

线扫相机最常用的触发模式有两种:

触发模式 适用场景 注意事项
连续采集 调试、测试、低速场景 容易丢帧,不推荐用于正式标定
线触发(Line Trigger) 运动平台、编码器同步 必须配合编码器信号,行频与运动速度匹配
帧触发(Frame Trigger) 定长拍摄、多相机同步 需要外部信号或软件触发

标定场景下,我强烈建议用线触发。为什么?因为标定板在运动过程中,每一行像素的采集时刻必须与物理位置严格对应。连续采集的话,行频稍微不稳,图像就会拉伸或压缩,标定结果直接偏掉。

避坑指南:我曾经在一个项目里用了连续采集,标定出来的内参矩阵看起来没问题,但重建出来的3D点云总是歪的。查了两天才发现,是行频抖动导致图像在运动方向上有非线性畸变。换成线触发后,问题立刻解决。

4.1.3 回调函数与缓冲区管理

采集图像时,别在主线程里死等。用回调函数是更优雅的方式:

cam.OnImageGrabbed += (sender, args) => {
    ImageFrame frame = args.Frame;
    // 在这里处理图像
    ProcessImage(frame);
    // 记得释放缓冲区
    frame.Dispose();
};

缓冲区管理是个容易忽略的细节。如果回调处理速度跟不上采集速度,缓冲区会爆掉,导致丢帧或程序崩溃。我一般设置至少4个缓冲区,并且保证回调函数里不做耗时操作——比如图像去噪、存储这些,扔到另一个线程去做。

4.2 图像去噪与增强

线扫图像有个特点:信噪比通常比面阵相机低。因为每一行像素的曝光时间极短(微秒级),感光元件接收到的光子少,噪声自然就明显了。

4.2.1 常见的噪声类型

  • 高斯噪声:传感器热噪声,均匀分布在整个图像上。
  • 椒盐噪声:像素坏点或传输误码,表现为黑白点。
  • 条纹噪声:线扫相机特有的,某一行或某几行整体偏亮或偏暗。

对于标定来说,条纹噪声是最致命的。它会让标定板上的圆点或棋盘格角点出现系统性偏移,直接影响标定精度。

4.2.2 去噪策略

我常用的去噪流程是这样的:

  1. 中值滤波:去除椒盐噪声,核大小3x3或5x5,太大容易模糊边缘。
  2. 高斯滤波:平滑高斯噪声,核大小3x3,sigma=0.8~1.2。
  3. 条纹校正:如果是固定条纹,可以用暗场/亮场校正;如果是随机条纹,用行均值滤波。

关键点:标定图像的去噪要“点到为止”。过度平滑会让角点或圆心的定位精度下降。我一般只在噪声明显影响特征提取时才做去噪,否则宁可不做。

4.2.3 图像增强

增强不是为了好看,而是为了让特征更明显。标定场景下,最常用的是直方图均衡化和对比度拉伸。

// 对比度拉伸示例(C++风格伪代码)
void ContrastStretch(Image& img, double lowPercent, double highPercent) {
    // 计算直方图
    int hist[256] = {0};
    for (int i = 0; i < img.Width * img.Height; i++) {
        hist[img.Data[i]]++;
    }

    // 找到低百分比和高百分比对应的灰度值
    int total = img.Width * img.Height;
    int lowVal = 0, highVal = 255;
    int sum = 0;
    for (int i = 0; i < 256; i++) {
        sum += hist[i];
        if (sum >= total * lowPercent) { lowVal = i; break; }
    }
    sum = 0;
    for (int i = 255; i >= 0; i--) {
        sum += hist[i];
        if (sum >= total * (1 - highPercent)) { highVal = i; break; }
    }

    // 映射
    for (int i = 0; i < img.Width * img.Height; i++) {
        img.Data[i] = (img.Data[i] - lowVal) * 255 / (highVal - lowVal);
    }
}

lowPercent和highPercent我一般取2%和98%,把两端的极端像素去掉,让有效灰度范围铺满0~255。

4.3 ROI区域裁剪

线扫图像通常很宽(比如8192像素),但高度可能只有几百行。全图处理太浪费算力,而且标定板只占图像的一小部分。ROI裁剪就是为了“只关心该关心的区域”。

4.3.1 如何确定ROI

标定场景下,ROI的确定有两种方式:

  • 固定ROI:标定板位置固定,手动框选一次,后续都用这个区域。
  • 动态ROI:通过图像分析自动找到标定板位置,实时调整。

我个人更推荐动态ROI。因为线扫相机在运动过程中,标定板的位置可能会有微小偏移(比如传送带抖动),固定ROI容易把标定板切出去一部分。

4.3.2 动态ROI实现思路

  1. 对图像做二值化(Otsu或固定阈值)。
  2. 找到最大连通域(标定板通常是图像中最亮的矩形区域)。
  3. 计算连通域的外接矩形,向外扩展20~50像素作为ROI。
小技巧:如果标定板是棋盘格,可以用角点检测的粗定位来辅助ROI。先降采样检测角点,再用角点范围确定ROI,速度很快。

4.4 图像格式转换与存储

相机SDK输出的原始数据通常是Bayer格式或单通道8位/12位灰度。标定算法一般只认8位灰度图(OpenCV的CV_8UC1)。所以格式转换是绕不开的一步。

4.4.1 常见的格式转换

原始格式 转换目标 转换方法
Bayer RG/GB 8位灰度 去马赛克(Demosaic)后转灰度,或直接取G通道
12位灰度 8位灰度 右移4位(>>4)或线性映射到0~255
10位灰度 8位灰度 右移2位(>>2)

对于标定来说,我建议直接取Bayer的G通道作为灰度图。因为人眼对绿色最敏感,G通道的信噪比通常也最高。去马赛克虽然能恢复全彩,但会引入插值误差,对精度要求高的标定来说得不偿失。

4.4.2 存储格式的选择

标定过程中会产生大量图像,存储格式直接影响磁盘占用和读写速度。

  • BMP:无损,但文件大。适合少量关键图像。
  • PNG:无损压缩,文件适中。我一般用这个。
  • JPG:有损压缩,文件小。但压缩伪影会影响标定精度,不推荐。
  • RAW:原始数据,最精确。但后续处理麻烦,需要配套的解析代码。
注意:千万不要用JPG存储标定图像!压缩带来的块效应和振铃效应会让角点检测产生亚像素级别的偏移。我吃过这个亏,标定结果看起来收敛,但实际测量误差总是偏大。换成PNG后,问题消失。

4.4.3 文件命名规范

存储时,文件命名最好包含关键信息,方便后续回溯:

// 命名格式示例
// 标定板类型_采集时间_序号_参数.扩展名
// 例如:
// chessboard_20250321_001_trigger_encoder.tiff
// dot_20250321_002_trigger_encoder.tiff

我个人习惯把触发模式、编码器分辨率、行频等参数也写进文件名或配套的JSON文件里。这样几个月后回头看,还能知道当时是怎么拍的。

4.5 本章知识体系

下面这张图把图像采集与预处理的整体流程串起来了:

图像采集与预处理流程 图像采集与预处理流程 SDK图像采集 图像去噪 中值滤波(椒盐噪声) 高斯滤波(高斯噪声) 条纹校正(条纹噪声) 图像增强 ROI区域裁剪 格式转换与存储 💡 关键原则: • 去噪不过度,保留边缘 • 存储用PNG,不用JPG • 文件名包含采集参数

嗯,整个流程就是这样。从SDK采集到最终存储,每一步都有坑,但也都有对应的解法。你把这些环节都跑通了,后面的标定工作就会顺畅很多。