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
}
}
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时,缓冲区满,直接丢弃旧帧
还有一种更轻量的方式——双缓冲。两个缓冲区交替使用:
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 本章知识体系
下面这张图总结了图像采集与预处理的完整流程:
从传感器到可用数据,每一步都有优化空间。我个人觉得,帧缓存管理和色彩空间转换是最容易被忽视的两个环节。很多人花大量时间调算法,结果发现性能瓶颈在预处理阶段——嗯,这其实挺可惜的。
记住一句话:预处理做得好,算法事半功倍。