OpenCV 工业视觉学习笔记(一):Mat、图像读取与灰度化

前言

最近开始系统学习 OpenCV,希望通过一段时间的学习,逐步掌握传统图像处理方法,并将这些知识应用到工业视觉、缺陷检测和上位机视觉软件开发中。

这一系列文章主要用于记录自己的学习过程。相比单纯罗列 OpenCV API,尽量理解每个函数背后的含义,通过代码验证实际效果。

本篇主要学习以下内容:

  1. OpenCV 中的 Mat 是什么;
  2. 如何读取、显示和保存图像;
  3. 如何获取图像的宽度、高度、通道数和数据类型;
  4. OpenCV 中的 BGR 通道顺序;
  5. 如何将彩色图像转换为灰度图;
  6. Mat 的浅拷贝与深拷贝;
  7. 手动遍历像素实现灰度化。

一、开发环境

本系列代码使用的开发环境如下:

  • 操作系统:Windows
  • 开发工具:Visual Studio 2022
  • 开发语言:C++
  • OpenCV 版本:OpenCV 4.x
  • C++ 标准:C++17

示例代码需要包含以下头文件:

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

using namespace cv;
using namespace std;

二、Mat 是什么

在 OpenCV 中,图像通常使用 cv::Mat 对象进行存储和处理。

Mat image;

Mat 可以理解为一个用于管理矩阵数据的类。图像本质上也是一个矩阵,因此可以使用 Mat 保存图像的像素数据。

一个 Mat 对象主要包含两部分:

  • 矩阵头;
  • 实际数据。

矩阵头中保存图像的宽度、高度、通道数、数据类型、每行字节数以及数据地址等信息。

真正的像素数据则存储在另外一块内存区域中。

这种设计的好处是:复制一个 Mat 对象时,不一定需要立即复制整张图像的数据,从而减少不必要的内存开销。


三、创建 Mat 对象

1. 创建一个空 Mat

Mat image;

此时 image 中还没有图像数据。

可以通过 empty() 判断一个 Mat 是否为空:

if (image.empty())
{
    cout << "Mat对象为空" << endl;
}

2. 创建指定大小的图像

下面创建一张宽度为 640、高度为 480 的三通道图像:

Mat image(480, 640, CV_8UC3);

这里需要注意,构造函数中的顺序是:

Mat(行数, 列数, 数据类型);

也就是:

Mat(图像高度, 图像宽度, 数据类型)

因此:

Mat image(480, 640, CV_8UC3);

表示:

  • 高度:480;
  • 宽度:640;
  • 通道数:3;
  • 每个通道的数据类型:无符号 8 位整数。

3. 创建指定颜色的图像

Mat image(480, 640, CV_8UC3, Scalar(0, 0, 255));

这段代码会创建一张红色图像。

OpenCV 默认使用的是 BGR 通道顺序,而不是 RGB。

Scalar(蓝色, 绿色, 红色);

因此:

Scalar(0, 0, 255)

表示红色。

其他常见颜色如下:

Scalar(255, 0, 0);       // 蓝色
Scalar(0, 255, 0);       // 绿色
Scalar(0, 0, 255);       // 红色
Scalar(255, 255, 255);   // 白色
Scalar(0, 0, 0);         // 黑色

四、Mat 数据类型

OpenCV 中常见的数据类型包括:

数据类型 含义
CV_8UC1 8位无符号、单通道
CV_8UC3 8位无符号、三通道
CV_8UC4 8位无符号、四通道
CV_16UC1 16位无符号、单通道
CV_32FC1 32位浮点、单通道
CV_32FC3 32位浮点、三通道
CV_64FC1 64位浮点、单通道

CV_8UC3 为例,可以拆分为:

CV_ + 8U + C3

其中:

  • 8U 表示每个通道使用 8 位无符号整数;
  • C3 表示图像有三个通道。

普通彩色图像通常使用:

CV_8UC3

普通灰度图通常使用:

CV_8UC1

进行梯度计算、卷积或者坐标计算时,则经常使用:

CV_32F

或:

CV_64F

使用浮点类型可以防止计算结果超出 0~255 范围后发生截断或溢出。


五、读取图像

OpenCV 使用 imread() 读取图像。

Mat image = imread("D:/images/test.jpg");

读取图像后,应当先判断图像是否为空:

Mat image = imread("D:/images/test.jpg");

if (image.empty())
{
    cout << "图像读取失败" << endl;
    return -1;
}

这是一个非常重要的习惯。

图像读取失败的常见原因包括:

  • 文件路径错误;
  • 文件不存在;
  • 文件扩展名错误;
  • 路径中包含特殊字符;
  • 程序没有访问文件的权限;
  • OpenCV 不支持对应的图像格式。

六、imread 的读取模式

imread() 的第二个参数可以指定图像读取方式。

1. 读取彩色图像

Mat image = imread("D:/images/test.jpg", IMREAD_COLOR);

IMREAD_COLOR 表示以三通道彩色图像读取。

即使原图包含透明通道,也会转换为三通道 BGR 图像。


2. 直接读取灰度图

Mat grayImage = imread("D:/images/test.jpg", IMREAD_GRAYSCALE);

此时读取得到的是单通道灰度图。


3. 保持原始格式

Mat image = imread("D:/images/test.png", IMREAD_UNCHANGED);

IMREAD_UNCHANGED 会尽量保留原图的通道数和位深。

例如,读取带透明通道的 PNG 图像时,可能得到四通道图像。


七、获取图像基本信息

读取图像后,可以查看图像的宽度、高度、通道数和数据类型。

Mat image = imread("D:/images/test.jpg");

if (image.empty())
{
    cout << "图像读取失败" << endl;
    return -1;
}

cout << "图像宽度:" << image.cols << endl;
cout << "图像高度:" << image.rows << endl;
cout << "图像通道数:" << image.channels() << endl;
cout << "图像类型:" << image.type() << endl;

其中:

image.cols

表示图像宽度。

image.rows

表示图像高度。

image.channels()

表示图像通道数。

需要注意:

rows = 行数 = 高度
cols = 列数 = 宽度

初学时很容易将 rowscols 写反。


八、显示图像

OpenCV 使用 imshow() 显示图像:

imshow("原始图像", image);

但如果程序直接执行结束,图像窗口会立即关闭,因此通常还需要调用:

waitKey(0);

完整代码如下:

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

using namespace cv;
using namespace std;

int main()
{
    Mat image = imread("D:/images/test.jpg");

    if (image.empty())
    {
        cout << "图像读取失败" << endl;
        return -1;
    }

    imshow("原始图像", image);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

waitKey(0) 表示一直等待用户按键。

如果写成:

waitKey(1000);

则表示等待 1000 毫秒,也就是 1 秒。

destroyAllWindows() 用于关闭 OpenCV 创建的所有窗口。


九、保存图像

OpenCV 使用 imwrite() 保存图像。

imwrite("D:/images/result.jpg", image);

可以通过返回值判断保存是否成功:

bool result = imwrite("D:/images/result.jpg", image);

if (result)
{
    cout << "图像保存成功" << endl;
}
else
{
    cout << "图像保存失败" << endl;
}

不同文件格式具有不同特点:

  • JPG:文件较小,但是采用有损压缩;
  • PNG:支持无损压缩,也支持透明通道;
  • BMP:通常不压缩,文件较大;
  • TIFF:可以保存高位深、浮点或多通道数据,工业视觉中比较常见。

如果保存的是检测中间结果或者标注图,可以使用 JPG 或 PNG。

如果保存的是深度图、高度图或者需要保持原始精度的数据,则不能随意转换为普通的 8 位 JPG 图像。


十、OpenCV 为什么使用 BGR

大多数人熟悉的颜色顺序是 RGB:

红色、绿色、蓝色

但是 OpenCV 默认使用的是 BGR:

蓝色、绿色、红色

例如,一个三通道像素可以使用 Vec3b 表示:

Vec3b pixel = image.at<Vec3b>(100, 200);

其中:

uchar blue = pixel[0];
uchar green = pixel[1];
uchar red = pixel[2];

也就是:

pixel[0] = B
pixel[1] = G
pixel[2] = R

读取某个像素的完整代码如下:

int row = 100;
int col = 200;

Vec3b pixel = image.at<Vec3b>(row, col);

cout << "B:" << static_cast<int>(pixel[0]) << endl;
cout << "G:" << static_cast<int>(pixel[1]) << endl;
cout << "R:" << static_cast<int>(pixel[2]) << endl;

at() 的参数顺序是:

image.at<数据类型>(行坐标, 列坐标);

也就是:

image.at(y, x)

这一点也很容易写反。


十一、什么是灰度图

彩色图像中的每个像素通常包含 B、G、R 三个通道。

灰度图只有一个通道,每个像素只保存一个灰度值。

对于普通 8 位灰度图,灰度值范围为:

0~255

其中:

  • 0 表示黑色;
  • 255 表示白色;
  • 中间数值表示不同程度的灰色。

将彩色图像转换为灰度图,可以减少图像的数据量和后续计算量。

很多传统视觉算法并不关心颜色,只关心物体与背景之间的亮度差异,因此通常会先把彩色图像转换为灰度图。

例如:

  • 阈值分割;
  • 边缘检测;
  • 模板匹配;
  • 轮廓提取;
  • 形态学处理;
  • 特征计算。

十二、使用 cvtColor 进行灰度化

OpenCV 可以使用 cvtColor() 完成颜色空间转换。

Mat grayImage;
cvtColor(image, grayImage, COLOR_BGR2GRAY);

完整代码如下:

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

using namespace cv;
using namespace std;

int main()
{
    Mat image = imread("D:/images/test.jpg");

    if (image.empty())
    {
        cout << "图像读取失败" << endl;
        return -1;
    }

    Mat grayImage;

    cvtColor(image, grayImage, COLOR_BGR2GRAY);

    cout << "原图通道数:" << image.channels() << endl;
    cout << "灰度图通道数:" << grayImage.channels() << endl;

    imshow("原始图像", image);
    imshow("灰度图像", grayImage);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

执行后:

  • 原图一般是三通道;
  • 灰度图是单通道。

十三、灰度值是如何计算的

灰度化不是简单地计算三个通道的平均值。

人眼对不同颜色的敏感程度不同:

  • 对绿色最敏感;
  • 对红色次之;
  • 对蓝色最不敏感。

常见的灰度转换公式可以近似表示为:

Gray = 0.299 × R + 0.587 × G + 0.114 × B

因此绿色通道对灰度值的影响最大,蓝色通道的影响最小。

例如,一个像素的颜色为:

B = 100
G = 150
R = 200

灰度值大约为:

Gray = 0.299 × 200 + 0.587 × 150 + 0.114 × 100

计算结果约为:

Gray ≈ 159

实际调用 cvtColor() 时,OpenCV 内部会采用经过优化的计算方式,因此最终值可能与手工浮点计算存在很小的舍入差异。


十四、手动遍历像素实现灰度化

为了进一步理解图像的数据结构,可以自己遍历每一个像素,实现灰度转换。

Mat ConvertToGray(const Mat& source)
{
    CV_Assert(!source.empty());
    CV_Assert(source.type() == CV_8UC3);

    Mat gray(source.rows, source.cols, CV_8UC1);

    for (int row = 0; row < source.rows; row++)
    {
        for (int col = 0; col < source.cols; col++)
        {
            Vec3b pixel = source.at<Vec3b>(row, col);

            uchar blue = pixel[0];
            uchar green = pixel[1];
            uchar red = pixel[2];

            uchar grayValue = saturate_cast<uchar>(
                0.114 * blue +
                0.587 * green +
                0.299 * red);

            gray.at<uchar>(row, col) = grayValue;
        }
    }

    return gray;
}

调用代码:

Mat grayImage = ConvertToGray(image);

这里使用:

saturate_cast<uchar>()

将计算结果安全地转换为 uchar 类型。

对于 uchar 类型,取值范围为 0~255。

如果计算结果小于 0,会被限制为 0;如果大于 255,会被限制为 255。


十五、使用指针遍历像素

虽然 at() 写法比较直观,但是在大量像素循环中,每次访问都需要进行位置计算。

还可以通过行指针访问图像数据:

Mat ConvertToGrayByPointer(const Mat& source)
{
    CV_Assert(!source.empty());
    CV_Assert(source.type() == CV_8UC3);

    Mat gray(source.rows, source.cols, CV_8UC1);

    for (int row = 0; row < source.rows; row++)
    {
        const Vec3b* sourceRow = source.ptr<Vec3b>(row);
        uchar* grayRow = gray.ptr<uchar>(row);

        for (int col = 0; col < source.cols; col++)
        {
            uchar blue = sourceRow[col][0];
            uchar green = sourceRow[col][1];
            uchar red = sourceRow[col][2];

            grayRow[col] = saturate_cast<uchar>(
                0.114 * blue +
                0.587 * green +
                0.299 * red);
        }
    }

    return gray;
}

这里:

source.ptr<Vec3b>(row)

表示获取彩色图像第 row 行的起始地址。

gray.ptr<uchar>(row)

表示获取灰度图第 row 行的起始地址。

指针遍历通常更接近图像底层数据的访问方式。

不过,在实际项目中,如果 OpenCV 已经提供了对应的优化函数,通常应优先使用 OpenCV 自带函数。

因此,正式项目中进行灰度化时一般直接使用:

cvtColor(image, grayImage, COLOR_BGR2GRAY);

手动遍历更适合用于学习数据结构、自定义像素计算以及验证算法原理。


十六、Mat 的浅拷贝

下面的代码并没有复制一份新的图像数据:

Mat image2 = image1;

此时 image1image2 会共享同一块像素数据。

例如:

Mat image1 = imread("D:/images/test.jpg");
Mat image2 = image1;

image2.setTo(Scalar(0, 0, 255));

执行后,image1 中的图像也会变成红色。

因为两个 Mat 对象指向的是同一块图像数据。

这称为浅拷贝。

浅拷贝只复制矩阵头,并不会复制实际的像素数据,因此速度快,内存开销也比较小。


十七、Mat 的深拷贝

如果希望复制一张完全独立的图像,可以使用 clone()

Mat image2 = image1.clone();

也可以使用:

Mat image2;
image1.copyTo(image2);

此时修改 image2 不会影响 image1

示例:

Mat image1 = imread("D:/images/test.jpg");
Mat image2 = image1.clone();

image2.setTo(Scalar(0, 0, 255));

imshow("image1", image1);
imshow("image2", image2);

waitKey(0);

可以看到:

  • image1 保持原来的图像;
  • image2 被修改为红色。

因此:

Mat image2 = image1;

表示共享数据。

Mat image2 = image1.clone();

表示复制一份新的数据。

在工业视觉程序中,如果原始图像还需要继续使用,而后续操作会修改图像,就需要注意是否应该使用深拷贝。


十八、感兴趣区域 ROI

Mat 还可以表示原图中的一部分区域。

例如,截取一个矩形区域:

Rect roiRect(100, 100, 200, 150);
Mat roi = image(roiRect);

其中:

Rect(x, y, width, height)

表示:

  • 左上角横坐标:100;
  • 左上角纵坐标:100;
  • 区域宽度:200;
  • 区域高度:150。

需要注意:

Mat roi = image(roiRect);

得到的 ROI 默认与原图共享数据。

如果修改 ROI:

roi.setTo(Scalar(0, 0, 255));

原图中的对应区域也会被修改。

如果希望获得独立的区域图像,需要使用:

Mat roi = image(roiRect).clone();

十九、判断图像内存是否连续

可以使用:

bool continuous = image.isContinuous();

判断图像的数据是否连续存储。

if (image.isContinuous())
{
    cout << "图像数据连续" << endl;
}
else
{
    cout << "图像数据不连续" << endl;
}

通常,直接读取的完整图像数据是连续的。

但是从原图中截取的某些 ROI 可能不是连续存储的。

因此在编写底层像素遍历代码时,不能在没有判断的情况下,默认所有 Mat 数据都是完全连续的。

最稳妥的方式仍然是逐行调用:

image.ptr<uchar>(row);

二十、完整示例

下面将图像读取、信息输出、灰度转换、显示和保存组合在一起。

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

using namespace cv;
using namespace std;

int main()
{
    string imagePath = "D:/images/test.jpg";

    Mat source = imread(imagePath, IMREAD_COLOR);

    if (source.empty())
    {
        cerr << "图像读取失败,路径:" << imagePath << endl;
        return -1;
    }

    cout << "图像读取成功" << endl;
    cout << "宽度:" << source.cols << endl;
    cout << "高度:" << source.rows << endl;
    cout << "通道数:" << source.channels() << endl;
    cout << "图像类型值:" << source.type() << endl;
    cout << "是否连续:" << boolalpha
         << source.isContinuous() << endl;

    Mat gray;
    cvtColor(source, gray, COLOR_BGR2GRAY);

    bool saveResult = imwrite(
        "D:/images/test_gray.png",
        gray);

    if (saveResult)
    {
        cout << "灰度图保存成功" << endl;
    }
    else
    {
        cout << "灰度图保存失败" << endl;
    }

    imshow("原始图像", source);
    imshow("灰度图", gray);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

二十一、常见问题

1. 为什么读取图像后是空的

首先检查:

image.empty()

然后重点检查文件路径是否正确。

Windows 路径可以写成:

"D:/images/test.jpg"

也可以写成:

"D:\\images\\test.jpg"

使用正斜杠通常更方便。


2. 为什么红色和蓝色显示反了

因为 OpenCV 默认使用 BGR,而不是 RGB。

错误理解:

pixel[0] = R
pixel[1] = G
pixel[2] = B

OpenCV 实际顺序:

pixel[0] = B
pixel[1] = G
pixel[2] = R

3. 为什么修改复制后的 Mat,原图也发生变化

因为:

Mat image2 = image1;

执行的是浅拷贝,两者共享同一块像素数据。

需要独立图像时,应使用:

Mat image2 = image1.clone();

4. 为什么 at 的坐标顺序和 Point 不一样

通过 at() 访问像素时:

image.at<Vec3b>(row, col);

顺序是:

行、列

也就是:

y、x

而创建 Point 时:

Point point(x, y);

顺序是:

x、y

两种写法的坐标顺序不同,使用时需要特别注意。


5. 灰度图为什么还是能显示出来

灰度图虽然只有一个通道,但 imshow() 可以直接显示单通道图像。

对于 CV_8UC1 图像:

  • 0 显示为黑色;
  • 255 显示为白色;
  • 中间值显示为不同程度的灰色。

二十二、本篇总结

通过本篇学习,我对 OpenCV 最基础的图像数据结构和图像操作有了初步认识。

需要重点掌握以下内容:

  1. OpenCV 使用 Mat 保存图像和矩阵数据;
  2. rows 表示高度,cols 表示宽度;
  3. OpenCV 默认使用 BGR 通道顺序;
  4. 彩色图通常是 CV_8UC3
  5. 灰度图通常是 CV_8UC1
  6. imread() 用于读取图像;
  7. imshow() 用于显示图像;
  8. imwrite() 用于保存图像;
  9. cvtColor() 可以将彩色图转换为灰度图;
  10. Mat image2 = image1 是浅拷贝;
  11. clone()copyTo() 可以实现深拷贝;
  12. 像素访问时需要注意行列顺序以及 BGR 通道顺序。

对于 OpenCV 的学习,不能只停留在会调用函数的阶段,还需要理解图像的通道、数据类型、内存结构和像素访问方式。

这些基础内容会直接影响后续的阈值分割、滤波、边缘检测、形态学处理和缺陷检测。

posted on 2026-08-01 22:50  南小欢  阅读(3)  评论(0)    收藏  举报