2. OpenCV基础(一):图像读取与显示、属性、保存与像素操作

好,咱们正式开始动手写代码。这一章是OpenCV的入门基本功,说白了就是怎么把一张图片从硬盘里拽出来,看看它长什么样,改几个像素再存回去。这些操作在YOLO项目中几乎天天用——你想想看,训练前要检查数据集,推理时要显示检测结果,后处理要裁剪ROI,全都绕不开这几个函数。

我个人习惯把这一套流程叫做「图像生命周期管理」。别笑,名字起得大,但核心就四个动作:读、看、改、存。咱们一个一个来。

2.1 图像读取:imread

imread 是OpenCV的入口函数。它的签名很简单:

cv::Mat cv::imread(const String& filename, int flags = IMREAD_COLOR)

第一个参数是文件路径,第二个参数是读取模式。模式这块容易踩坑,我列个表:

标志常量 数值 含义
IMREAD_COLOR 1 默认模式,加载为BGR三通道彩色图,忽略Alpha通道
IMREAD_GRAYSCALE 0 加载为单通道灰度图
IMREAD_UNCHANGED -1 保留原图所有通道(含Alpha)
IMREAD_ANYCOLOR 4 按原图色彩加载,但会转成8位
我曾经踩过的坑: 用 IMREAD_COLOR 加载PNG透明图,结果Alpha通道直接丢了。后来做YOLO分割任务时,发现mask边缘有锯齿,查了半天才发现是读取时把透明度信息扔了。如果你需要保留透明通道,务必用 IMREAD_UNCHANGED。

代码示例:

#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    // 彩色读取
    cv::Mat img_color = cv::imread("cat.jpg", cv::IMREAD_COLOR);
    // 灰度读取
    cv::Mat img_gray = cv::imread("cat.jpg", cv::IMREAD_GRAYSCALE);
    // 保留原通道
    cv::Mat img_unchanged = cv::imread("cat.png", cv::IMREAD_UNCHANGED);

    if (img_color.empty()) {
        std::cerr << "图片没找到,检查路径!" << std::endl;
        return -1;
    }
    return 0;
}

注意那个 empty() 检查。我见过太多新手直接拿空Mat往下传,程序崩了还不知道为什么。路径写错、文件名大小写不对、中文路径——这些都是常见原因。

2.2 图像显示:imshow + waitKey

读进来之后,总得看一眼吧?imshow 负责把Mat渲染到窗口上。

cv::imshow("窗口标题", img_color);
cv::waitKey(0);  // 等待按键,0表示无限等待

这里有个关键点:imshow 必须搭配 waitKey 才能正常显示。为什么?因为OpenCV的GUI事件循环是靠 waitKey 驱动的。你不调用它,窗口一闪就没了,或者干脆不显示。

小技巧: waitKey(30) 表示等待30毫秒,常用于视频流显示。返回值是按键的ASCII码,比如按 'q' 退出:if (cv::waitKey(1) == 'q') break;

另外,imshow 默认把图像缩放到适应窗口大小。如果你想固定窗口尺寸,可以用 cv::namedWindow 配合 WINDOW_NORMAL 标志:

cv::namedWindow("可缩放窗口", cv::WINDOW_NORMAL);
cv::imshow("可缩放窗口", img_color);
cv::waitKey(0);

2.3 图像属性:shape 与 dtype

拿到Mat之后,第一件事就是看它的属性。OpenCV的Mat有三个核心属性:

  • rows:图像高度(像素行数)
  • cols:图像宽度(像素列数)
  • channels():通道数(灰度=1,BGR=3,BGRA=4)
  • type():数据类型,比如 CV_8UC3 表示8位无符号三通道
  • depth():每个通道的位深

代码示例:

std::cout << "图像尺寸: " << img_color.rows << " x " << img_color.cols << std::endl;
std::cout << "通道数: " << img_color.channels() << std::endl;
std::cout << "数据类型: " << img_color.type() << std::endl;  // 输出16,对应CV_8UC3

你可能会问:为什么OpenCV用BGR而不是RGB?历史原因——早期Windows的BMP格式就是BGR,OpenCV沿用了这个约定。所以用 imshow 显示没问题,但如果你用 matplotlib 显示,记得转成RGB:cv::cvtColor(img, img_rgb, cv::COLOR_BGR2RGB)。

2.4 图像保存:imwrite

处理完图像,要存盘就用 imwrite。它的签名:

bool cv::imwrite(const String& filename, InputArray img, const std::vector<int>& params = std::vector<int>())

第三个参数是编码参数,我常用的几个:

编码参数 作用 示例
IMWRITE_JPEG_QUALITY JPEG压缩质量,0-100 params.push_back(95);
IMWRITE_PNG_COMPRESSION PNG压缩级别,0-9 params.push_back(3);
IMWRITE_WEBP_QUALITY WebP质量,0-100 params.push_back(80);
实战经验: 做YOLO数据集预处理时,我经常批量保存增强后的图片。JPEG质量设95以上基本看不出损失,但文件大小能小一半。PNG压缩级别别设太高,9级压缩慢得离谱,3-5级性价比最高。
std::vector<int> params;
params.push_back(cv::IMWRITE_JPEG_QUALITY);
params.push_back(95);
bool success = cv::imwrite("output.jpg", img_color, params);
if (!success) {
    std::cerr << "保存失败!检查目录权限。" << std::endl;
}

2.5 像素操作与ROI提取

终于到了最核心的部分。在YOLO里,你要频繁地操作像素——画检测框、提取目标区域、做数据增强。OpenCV提供了两种方式:

2.5.1 直接像素访问

最直观的方法是用 at<T>:

// 读取(10, 20)位置的BGR值
cv::Vec3b pixel = img_color.at<cv::Vec3b>(10, 20);
int blue  = pixel[0];
int green = pixel[1];
int red   = pixel[2];

// 修改该像素为红色
img_color.at<cv::Vec3b>(10, 20) = cv::Vec3b(0, 0, 255);

注意:at<T> 的行列顺序是 (row, col),也就是 (y, x)。这和数学坐标系不一样,初学者经常搞反。

对于灰度图:

uchar val = img_gray.at<uchar>(10, 20);
img_gray.at<uchar>(10, 20) = 128;
性能警告: at<T> 每次调用都会做边界检查,在循环里逐像素操作时非常慢。我曾经在一个1080p图像上逐像素遍历,花了200多毫秒。后来改用指针方式,直接降到5毫秒。所以大批量操作时,用 ptr 或 data 指针。

指针方式示例:

for (int r = 0; r < img_color.rows; r++) {
    cv::Vec3b* row_ptr = img_color.ptr<cv::Vec3b>(r);
    for (int c = 0; c < img_color.cols; c++) {
        row_ptr[c][0] = 255;  // 把这一行所有像素的蓝色通道设为255
    }
}

2.5.2 ROI提取

ROI(Region of Interest)是YOLO里最常用的操作之一。比如你检测到一只猫,想把这个区域单独拿出来做分类,就需要ROI。

OpenCV用 cv::Rect 定义矩形区域:

// 定义ROI:左上角(100, 50),宽200,高150
cv::Rect roi(100, 50, 200, 150);
cv::Mat img_roi = img_color(roi);  // 浅拷贝,共享内存

// 如果想独立拷贝一份
cv::Mat img_roi_copy = img_color(roi).clone();

这里有个重要概念:浅拷贝 vs 深拷贝。直接用 () 运算符得到的ROI和原图共享数据内存,修改ROI会影响原图。用 clone() 或 copyTo() 才会开辟新内存。

我画个图帮你理解这个关系:

Mat数据共享关系图 原图 Mat rows=480, cols=640 ROI 浅拷贝 img_color(roi) 共享数据内存 ROI 深拷贝 .clone() ⚠ 修改浅拷贝ROI 原图也会跟着变

ROI的典型应用场景:

  • YOLO推理后处理:从检测框坐标提取目标区域,做二次分类或OCR
  • 数据增强:随机裁剪图像的一部分作为训练样本
  • 图像拼接:把多个ROI贴到一张大图上

完整示例:读取图片,提取ROI,修改像素,保存结果。

#include <opencv2/opencv.hpp>

int main() {
    cv::Mat img = cv::imread("cat.jpg");
    if (img.empty()) return -1;

    // 提取左上角100x100区域
    cv::Rect roi(0, 0, 100, 100);
    cv::Mat roi_img = img(roi).clone();  // 深拷贝,独立操作

    // 把ROI区域全部变蓝
    roi_img = cv::Scalar(255, 0, 0);

    // 在原图相同位置画一个红色矩形框
    cv::rectangle(img, roi, cv::Scalar(0, 0, 255), 2);

    cv::imshow("原图", img);
    cv::imshow("ROI", roi_img);
    cv::waitKey(0);

    cv::imwrite("result.jpg", img);
    return 0;
}
我的习惯: 做ROI操作时,除非明确要修改原图,否则一律用 .clone()。虽然多占一点内存,但能避免一堆诡异的bug。尤其是多线程环境下,共享数据出问题很难排查。

2.6 本章小结

这一章的内容,说白了就是图像处理的「四件套」:读、看、改、存。再加上ROI提取和像素操作,你已经能完成YOLO项目里80%的图像预处理工作了。

嗯,这里要注意:这些基础操作一定要练到肌肉记忆。我刚开始做YOLO项目时,经常在 imread 路径和 at<T> 的坐标顺序上翻车。多写几遍,自然就熟了。