4. 图像采集与预处理:从传感器到可用数据

摄像头工程部署,说白了就是跟图像数据打交道。但很多新手容易忽略一个事实:传感器出来的原始数据,几乎不能直接用。我做了这么多年嵌入式视觉,踩过最大的坑就是——以为图像采集就是调个摄像头驱动,然后直接扔给算法。

嗯,今天我们就来聊聊,从摄像头拿到原始帧,到数据真正能喂给算法,中间到底要过几道关。

4.1 OpenCV图像读取:不只是cv::imread

在嵌入式平台上,cv::imread 其实很少用。为什么?因为它会阻塞。你想想看,摄像头是实时流,每一帧都要及时处理,阻塞一下,后面的帧就丢了。

我个人习惯用 VideoCapture 配合 grab() 和 retrieve() 分离操作:

cv::VideoCapture cap(0);
if (!cap.isOpened()) {
    // 处理打开失败
}

cv::Mat frame;
while (true) {
    if (cap.grab()) {          // 只抓取,不解码
        cap.retrieve(frame);   // 需要时才解码
        // 处理 frame
    }
}
我的经验:在树莓派或Jetson上,grab()/retrieve() 分离能减少约15%的帧处理延迟。尤其是多路摄像头场景,效果更明显。

另外,cv::CAP_PROP_BUFFERSIZE 这个属性很多人不知道。默认的帧缓存大小是4,但在低延迟场景下,我建议改成1或2:

cap.set(cv::CAP_PROP_BUFFERSIZE, 1);

为什么要改?因为缓存越大,你读到的帧越旧。我曾经在一个ADAS项目里,默认缓存导致算法看到的画面比实际晚了3帧——这在高速场景下是要出事的。

4.2 帧缓存管理:环形缓冲区与双缓冲

图像采集是个生产者-消费者模型。摄像头驱动是生产者,你的处理线程是消费者。如果两者速度不匹配,要么丢帧,要么内存暴涨。

我推荐用环形缓冲区(Ring Buffer)来管理帧缓存。结构很简单:

struct FrameBuffer {
    static const int MAX_FRAMES = 4;
    cv::Mat frames[MAX_FRAMES];
    int head;  // 写入位置
    int tail;  // 读取位置
    std::mutex mtx;
};

核心逻辑:

  • 生产者写入 head 位置,然后 head++
  • 消费者从 tail 读取,然后 tail++
  • 当 head == tail 时,缓冲区为空
  • 当 (head+1) % MAX_FRAMES == tail 时,缓冲区满,直接丢弃旧帧
注意:千万不要在回调函数里做图像处理!我曾经见过有人直接在V4L2回调里做缩放和色彩转换,结果导致驱动缓冲区溢出,系统直接卡死。正确的做法是:回调里只做内存拷贝,处理放到独立线程。

还有一种更轻量的方式——双缓冲。两个缓冲区交替使用:

cv::Mat buf[2];
int active = 0;

// 采集线程
buf[active] = capture_frame();
active = 1 - active;

// 处理线程
process(buf[1 - active]);

双缓冲适合单生产者单消费者的场景,延迟最低,但需要保证处理时间小于帧间隔。

4.3 图像缩放与裁剪:性能与精度的平衡

图像缩放是预处理里最耗时的操作之一。为什么?因为每个像素都要做插值计算。一个1920x1080的图像缩放到640x480,如果直接用 cv::resize 的默认方法,在ARM上可能要花5-10ms。

我常用的策略:

场景 推荐插值方法 性能(相对) 质量
实时预览/低延迟 INTER_NEAREST 最快 有锯齿
通用场景 INTER_LINEAR 中等 良好
高精度/离线 INTER_CUBIC 慢 优秀
缩小为主 INTER_AREA 较快 抗混叠好
避坑指南:我曾经在一个无人机项目里,用 INTER_LINEAR 做实时缩放,结果CPU占用率飙到80%。后来换成 INTER_NEAREST,降到30%,而且算法精度只下降了0.5%。所以,不要迷信高精度插值,够用就行。

裁剪就简单多了,用ROI(Region of Interest):

cv::Rect roi(x, y, width, height);
cv::Mat cropped = frame(roi);

注意:frame(roi) 返回的是共享内存的 Mat,不会拷贝数据。如果你要修改裁剪后的图像,记得调用 .clone()。

4.4 色彩空间转换:RGB/YUV/HSV

摄像头传感器输出的原始数据通常是 Bayer格式,经过ISP处理后变成 YUV 或 RGB。在嵌入式平台上,YUV是最常见的输出格式,因为它的数据量比RGB小。

为什么需要色彩空间转换?

  • YUV → RGB:大多数算法(如CNN)需要RGB输入
  • RGB → HSV:颜色分割、目标跟踪常用
  • RGB → Gray:特征检测、二值化

OpenCV提供了 cv::cvtColor:

cv::Mat yuv_frame, rgb_frame;
// 假设 yuv_frame 是 NV12 格式
cv::cvtColor(yuv_frame, rgb_frame, cv::COLOR_YUV2RGB_NV12);

这里有个坑:YUV的排列方式有很多种。NV12、NV21、YUYV、UYVY……每种对应的转换代码都不一样。我建议先确认摄像头驱动输出的具体格式,再选择正确的转换标志。

我的习惯:在项目初期,我会写一个测试程序,把摄像头输出的原始数据保存成文件,然后用 ffprobe 或 yuvplayer 查看格式。确认无误后再写转换代码。这一步能省掉后面很多调试时间。

HSV转换在颜色分割场景下特别有用。比如检测红色物体:

cv::Mat hsv, mask;
cv::cvtColor(rgb_frame, hsv, cv::COLOR_RGB2HSV);
cv::inRange(hsv, cv::Scalar(0, 100, 100), cv::Scalar(10, 255, 255), mask);

注意:HSV的H通道范围是0-179(OpenCV中),不是0-360。很多人第一次用都会搞错。

4.5 去噪与锐化:让图像更干净

摄像头在低光照下会产生噪声,尤其是CMOS传感器。常见的噪声类型:

  • 高斯噪声:传感器热噪声,亮度随机变化
  • 椒盐噪声:像素死点,黑白点随机出现
  • 量化噪声:ADC精度不足导致的条纹

去噪方法对比:

方法 适用场景 性能 副作用
高斯滤波 高斯噪声 快 边缘模糊
中值滤波 椒盐噪声 中等 细节保留好
双边滤波 边缘保持去噪 慢 计算量大
非局部均值 高质量去噪 极慢 不适合实时

在嵌入式平台上,我通常用 高斯滤波 + 中值滤波 的组合:

cv::Mat denoised;
cv::GaussianBlur(frame, denoised, cv::Size(3, 3), 1.0);
cv::medianBlur(denoised, denoised, 3);

锐化则用拉普拉斯算子或USM(Unsharp Masking):

cv::Mat blurred, sharpened;
cv::GaussianBlur(frame, blurred, cv::Size(0, 0), 3.0);
cv::addWeighted(frame, 1.5, blurred, -0.5, 0, sharpened);
注意:锐化会放大噪声。如果你先锐化再去噪,效果会很差。正确的顺序是:先去噪,再锐化。我见过有人搞反顺序,结果图像越处理越花。

4.6 本章知识体系

下面这张图总结了图像采集与预处理的完整流程:

图像采集与预处理流程 图像采集与预处理流程 摄像头传感器 帧采集 (grab/retrieve) 帧缓存管理 (环形/双缓冲) 预处理:缩放/裁剪/色彩转换/去噪/锐化 可用数据 (送入算法) 关键:每一步都可能成为性能瓶颈,需要根据硬件平台和算法需求做取舍

从传感器到可用数据,每一步都有优化空间。我个人觉得,帧缓存管理和色彩空间转换是最容易被忽视的两个环节。很多人花大量时间调算法,结果发现性能瓶颈在预处理阶段——嗯,这其实挺可惜的。

记住一句话:预处理做得好,算法事半功倍。