OpenCV 工业视觉学习笔记(一):Mat、图像读取与灰度化
前言
最近开始系统学习 OpenCV,希望通过一段时间的学习,逐步掌握传统图像处理方法,并将这些知识应用到工业视觉、缺陷检测和上位机视觉软件开发中。
这一系列文章主要用于记录自己的学习过程。相比单纯罗列 OpenCV API,尽量理解每个函数背后的含义,通过代码验证实际效果。
本篇主要学习以下内容:
- OpenCV 中的
Mat是什么; - 如何读取、显示和保存图像;
- 如何获取图像的宽度、高度、通道数和数据类型;
- OpenCV 中的 BGR 通道顺序;
- 如何将彩色图像转换为灰度图;
Mat的浅拷贝与深拷贝;- 手动遍历像素实现灰度化。
一、开发环境
本系列代码使用的开发环境如下:
- 操作系统: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 = 列数 = 宽度
初学时很容易将 rows 和 cols 写反。
八、显示图像
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;
此时 image1 和 image2 会共享同一块像素数据。
例如:
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 最基础的图像数据结构和图像操作有了初步认识。
需要重点掌握以下内容:
- OpenCV 使用
Mat保存图像和矩阵数据; rows表示高度,cols表示宽度;- OpenCV 默认使用 BGR 通道顺序;
- 彩色图通常是
CV_8UC3; - 灰度图通常是
CV_8UC1; imread()用于读取图像;imshow()用于显示图像;imwrite()用于保存图像;cvtColor()可以将彩色图转换为灰度图;Mat image2 = image1是浅拷贝;clone()和copyTo()可以实现深拷贝;- 像素访问时需要注意行列顺序以及 BGR 通道顺序。
对于 OpenCV 的学习,不能只停留在会调用函数的阶段,还需要理解图像的通道、数据类型、内存结构和像素访问方式。
这些基础内容会直接影响后续的阈值分割、滤波、边缘检测、形态学处理和缺陷检测。
浙公网安备 33010602011771号