第4章:OpenCV基础——图像处理的第一块敲门砖
说实话,我刚开始做嵌入式AI那会儿,最头疼的不是模型本身,而是图像预处理。你想想看,Yolo模型吃进去的是归一化后的张量,摄像头吐出来的是RAW图,中间这层转换,全靠OpenCV撑着。
这一章,我就带你把这层基础打牢。别小看这些操作,我见过太多项目翻车,最后发现是图像预处理参数没对齐。
4.1 OpenCV简介与安装
OpenCV是什么?说白了,就是一个开源的计算机视觉库。从图像读取、滤波、特征提取,到摄像头驱动、视频编解码,它全包了。在嵌入式设备上部署Yolo,OpenCV几乎是标配。
我个人习惯用OpenCV 4.x版本。为什么?因为DNN模块更成熟,对ONNX的支持也更好。
安装方式
在Ubuntu上,我建议这样装:
# 安装核心库
sudo apt-get install libopencv-dev
# 或者从源码编译(嵌入式设备推荐)
git clone https://github.com/opencv/opencv.git
cd opencv
mkdir build && cd build
cmake -D CMAKE_BUILD_TYPE=RELEASE \
-D CMAKE_INSTALL_PREFIX=/usr/local \
-D WITH_GTK=ON \
-D WITH_FFMPEG=ON ..
make -j4
sudo make install
⚠️ 注意: 在树莓派或Jetson上,千万别用sudo apt-get install的默认版本。我曾经在Jetson Nano上踩过坑——系统自带的OpenCV是3.2,DNN模块不支持某些算子。老老实实从源码编译,或者用NVIDIA官方提供的预编译包。
验证安装
#include <opencv2/opencv.hpp>
#include <iostream>
int main() {
std::cout << "OpenCV version: " << CV_VERSION << std::endl;
return 0;
}
编译运行后,如果看到版本号,说明装好了。
4.2 图像读取与显示
图像读取,最常用的就是imread()。但这里有个细节,很多人不注意。
cv::Mat img = cv::imread("test.jpg", cv::IMREAD_COLOR);
// IMREAD_COLOR: 默认三通道BGR
// IMREAD_GRAYSCALE: 单通道灰度图
// IMREAD_UNCHANGED: 保留Alpha通道
💡 关键点: OpenCV默认读进来的是BGR格式,不是RGB!Yolo训练时用的往往是RGB,所以后面一定要做颜色空间转换。我刚开始做项目时,直接拿BGR图送进模型,结果检测框全偏了,排查了整整两天。
显示图像也很简单:
cv::namedWindow("Display", cv::WINDOW_NORMAL);
cv::imshow("Display", img);
cv::waitKey(0); // 等待按键
嗯,这里要注意:waitKey(0)表示无限等待,直到你按任意键。如果设成waitKey(30),那就是30毫秒超时——这在视频流处理中很常用。
4.3 图像预处理——缩放与归一化
这是Yolo部署中最关键的一步。模型输入尺寸通常是640x640或416x416,但摄像头拍出来的图可能是1920x1080。怎么处理?
4.3.1 缩放(Resize)
cv::Mat resized;
cv::resize(img, resized, cv::Size(640, 640), 0, 0, cv::INTER_LINEAR);
插值方法的选择,我一般这样用:
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 缩小图像 | INTER_AREA | 避免锯齿,保留更多信息 |
| 放大图像 | INTER_LINEAR | 速度与质量平衡 |
| 高精度放大 | INTER_CUBIC | 更平滑,但慢 |
🔧 我的经验: 在Jetson Nano上,INTER_LINEAR和INTER_AREA速度差不多。但如果你用INTER_CUBIC,帧率会掉10%以上。所以,除非精度要求极高,否则别用CUBIC。
4.3.2 归一化(Normalization)
Yolo模型要求输入像素值在0~1之间,或者经过特定均值和标准差缩放。代码实现:
// 方法一:直接除以255.0
cv::Mat float_img;
resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0);
// 方法二:减均值除标准差(Yolo常用)
cv::Mat normalized;
float mean[] = {0.485, 0.456, 0.406};
float std[] = {0.229, 0.224, 0.225};
// 注意:OpenCV是BGR,需要调整顺序
cv::dnn::blobFromImage(resized, normalized, 1.0/255.0,
cv::Size(640, 640),
cv::Scalar(mean[2], mean[1], mean[0]), // BGR顺序
true, // swapRB? 这里设为true,因为输入已经是BGR
false);
⚠️ 避坑指南: 我曾经在部署YoloV5时,发现检测结果总是偏左。排查了半天,发现是blobFromImage的swapRB参数设反了。记住:如果你的图像已经是BGR(OpenCV默认),swapRB应该设为false;如果是RGB,设为true。
4.4 Mat数据结构详解
Mat是OpenCV的核心数据结构。说白了,它就是一个多维数组,用来存图像数据。
4.4.1 基本结构
// Mat的组成:
// - data: 指向像素数据的指针
// - rows, cols: 行数和列数
// - channels(): 通道数(灰度1,彩色3)
// - depth(): 数据类型深度(CV_8U, CV_32F等)
// - step: 每行字节数(包含对齐填充)
cv::Mat img(480, 640, CV_8UC3); // 480行,640列,3通道,每个通道8位无符号
4.4.2 数据访问方式
三种方式,性能差异很大:
// 方式一:at() —— 慢,但安全
for(int r = 0; r < img.rows; r++) {
for(int c = 0; c < img.cols; c++) {
cv::Vec3b pixel = img.at<cv::Vec3b>(r, c);
// pixel[0] = B, pixel[1] = G, pixel[2] = R
}
}
// 方式二:ptr() —— 快,推荐
for(int r = 0; r < img.rows; r++) {
uchar* row_ptr = img.ptr<uchar>(r);
for(int c = 0; c < img.cols * 3; c += 3) {
uchar b = row_ptr[c];
uchar g = row_ptr[c+1];
uchar r = row_ptr[c+2];
}
}
// 方式三:data直接访问 —— 最快,但容易越界
uchar* data = img.data;
int step = img.step;
for(int r = 0; r < img.rows; r++) {
for(int c = 0; c < img.cols * 3; c++) {
// data[r * step + c]
}
}
💡 性能对比: 在640x480的图像上,at()方式大约需要15ms,ptr()方式约3ms,data直接访问约2ms。在嵌入式设备上,这12ms的差距可能就是15帧和30帧的区别。
4.4.3 内存管理
Mat使用引用计数。什么意思?就是多个Mat对象可以共享同一块内存:
cv::Mat img1 = cv::imread("test.jpg");
cv::Mat img2 = img1; // 不复制数据,只增加引用计数
// 如果想深拷贝:
cv::Mat img3 = img1.clone(); // 独立内存
嗯,这里要注意:如果你修改了img2,img1也会变。我见过有人在这个问题上debug了一下午——他以为clone了,其实只是赋值。
4.5 完整预处理流水线
最后,给你一个我在项目中实际使用的预处理函数:
cv::Mat preprocess(const cv::Mat& src, int target_size = 640) {
cv::Mat dst;
// 1. 缩放(保持宽高比,填充黑边)
int h = src.rows, w = src.cols;
float scale = std::min((float)target_size / h, (float)target_size / w);
int new_h = (int)(h * scale);
int new_w = (int)(w * scale);
cv::Mat resized;
cv::resize(src, resized, cv::Size(new_w, new_h));
// 2. 填充到正方形
int top = (target_size - new_h) / 2;
int bottom = target_size - new_h - top;
int left = (target_size - new_w) / 2;
int right = target_size - new_w - left;
cv::Mat padded;
cv::copyMakeBorder(resized, padded, top, bottom, left, right,
cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114));
// 3. 归一化
padded.convertTo(dst, CV_32FC3, 1.0 / 255.0);
return dst;
}
🔧 我的习惯: 填充颜色用114而不是0。为什么?因为Yolo训练时,很多数据集也是用114填充的。用0填充的话,模型可能会在边缘产生假阳性检测。
好了,这一章的内容就到这里。图像预处理看似简单,但每个参数都可能影响最终检测效果。下一章,我们会把预处理后的数据真正送进Yolo模型,看看推理结果怎么取出来。