第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时,发现检测结果总是偏左。排查了半天,发现是blobFromImageswapRB参数设反了。记住:如果你的图像已经是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模型,看看推理结果怎么取出来。