为了构建计算机视觉应用程序,需要能够访问图像内容,并修改或创建得到新图像。本节将介绍如何操作图片元素(即像素),我们将介绍如何扫描图像并处理其每个像素;同时还将学习如何高效的进行处理,因为即使是中等尺寸的图像也可能包含数十万个像素。
我们已经知道,本质上图像是数值矩阵,因此在 OpenCV
处理图像时我们使用 cv::Mat
数据结构来操作图像。矩阵的每个元素表示一个像素,对于灰度图像(黑白图像),像素是无符号的 8
位值,其中 0
对应于黑色,255
对应于白色;在彩色图像中,需要三个原色值才能表达自然界中的不同颜色。这是因为人类的视觉系统是由三色构成的,视网膜上的三种视锥细胞将颜色信息传递给大脑。这意味着对于彩色图像,每个像素必须使用三个值表示。
在数字图像中,常用的原色通道为红色、绿色和蓝色。因此,在这种情况下,矩阵元素由 8
位值的三元组组成。通常情况下 8
位通道已经足够表示复杂颜色,但有些特殊应用也可能需要 16
位通道(例如医学成像)。
在《OpenCV 基础》一节中,我们了解了 OpenCV
还可以使用其他类型的像素值创建矩阵(或图像),例如,整数( CV_32U
或 CV_32S
)或浮点数( CV_32F
),这些数据类型通常用于存储图像处理任务中的中间值。大多数操作可以应用于任何类型的矩阵;而有一些特定类型的操作只能应用于特定数据类型的矩阵。因此,为了避免常见的程序错误,必须充分了解函数或方法的使用条件。
为了访问矩阵的每个独立元素,需要指定其行号和列号以返回相应的元素,在多通道图像中,相应元素是值向量。
cv::Mat
类包括多种访问图像不同属性的方法,公共成员变量 cols
和 rows
可以获得图像中的列数和行数;可以使用 at(int y, int x)
方法访问元素。但是,编译器在编译时必须知道 at()
方法返回的类型,由于 cv::Mat
可以保存任何类型的元素,因此我们需要指定预期的返回类型,这也就是 at()
方法被实现为模板方法的原因;所以,当调用at方法时,必须指定图像的元素类型:
image.at<uchar>(j, i)= 255;
需要注意的是,我们需要确保指定的类型与矩阵中包含的类型相匹配,at()
方法并不执行任何类型的转换。
接下来,我们通过创建带有椒盐噪声的图像来说明如何访问像素值。我们首先创建一个简单的函数,为图像添加椒盐噪声,椒盐噪声是一种特殊类型的噪声,图像中的一些像素会被随机的使用白色或黑色像素替换,当某些像素的值在传输过程中丢失时,可能会出现这种类型的噪声。在示例代码中,我们简单地随机选择几个像素并将它们修改为白色值。
接下来,我们首先创建一个添加椒盐噪声的函数 salt()
,并在程序中使用此函数以演示如何对图像像素进行访问。
(1) 创建一个具有两个参数的函数,第一个参数是需要修改的图像,第二个参数是要被覆盖为白色值的像素数量:
void salt(cv::Mat image, int n);
(2) 定义两个变量,使用它们来存储图像上的随机位置:
int i, j;
(3) 创建一个迭代 n
次的循环。其中 n
就是 salt()
函数中的第二个参数,它定义了要被覆盖为白色值的像素数量:
for (int k=0; k<n; k++) {
(4) 使用 std::rand()
函数生成两个值用于表示随机图像坐标位置,将 x
坐标值存储在 i
变量中,将 y
坐标值存储在 j
变量中。std::rand()
函数可以返回一个介于 0
和 RAND_MAX
之间的值,然后我们应用求模运算 %
(使用图像的列数或行数),以返回介于 0
和图像宽度/高度之间的值:
i= std::rand()%image.cols;
j= std::rand()%image.rows;
(5) 使用 type()
方法可以区分灰度图像和彩色图像。对于灰度图像,使用函数 at
可以修改位于坐标 (y, x)
处的图像像素值,使用以下方法将单个像素( 8
位值)修改为值 255
:
if (image.type() == CV_8UC1) { // 灰度图像
// 单通道 8 bit 图像
image.at<uchar>(j,i)= 255;
}
(6) 对于彩色图像,需要为三原色通道同时分配值 255
以得到白色像素。我们可以使用数组( nChannel
)访问图像通道,其中 nChannel
是通道索引,0
表示蓝色,1
表示绿色,2
表示红色:
else if (image.type() == CV_8UC3) { // 彩色图像
// 3通道图像
image.at<cv::Vec3b>(j,i)[0]= 255;
image.at<cv::Vec3b>(j,i)[1]= 255;
image.at<cv::Vec3b>(j,i)[2]= 255;
}
在彩色图像中,每个像素都与三个分量相关联——红色、绿色和蓝色通道。因此,包含彩色图像的 cv::Mat
类将返回三个 8
位值的向量,OpenCV
将此类短向量定义为 cv::Vec3b
类型,这是一个包含三个无符号字符的向量,因此我们可以使用以下代码访问彩色图像的像素值:
image.at<cv::Vec3b>(j,i)[channel]= value;
通道索引 channel
用于指定颜色通道,由于 OpenCV
以蓝色、绿色、红色的顺序存储通道值,因此,蓝色为通道 0
。
对于二元素(两通道)和四元素(四通道)向量(分别使用 cv::Vec2b
和 cv::Vec4b
表示)以及其他元素类型,也存在类似的向量类型。例如,对于二元素浮点向量,只需要将类型名称的最后一个字母替换为 f
,即 cv::Vec2f
;对于短整数,只需要将最后一个字母替换为 s
,将最后一个字母替换为 i
表示整数,而将最后一个字母替换为 d
表示双精度浮点向量。所有这些类型都是使用 cv::Vec
模板类定义的,其中 T
为类型,N
为向量元素的数量。
(9) 要使用salt函数,从磁盘中读取图像,并应用 salt()
函数,并使用 cv::imshow()
函数显示图像:
cv::Mat image= cv::imread("1.png",1);
salt(image,3000);
cv::namedWindow("Image");
cv::imshow("Image",image);
最后需要注意的是,我们的图像修改函数使用按值传递来传递图像参数,因为在复制图像时,它们仍然共享相同的图像数据。因此,当需要修改其内容时,不一定必须通过引用传递来传递图像,而且按值传递参数通常会使编译器更容易优化代码。
cv::Mat
类通过使用 C++
模板进行定义以使 cv::Mat
具有通用性。
使用 cv::Mat
类的 at()
方法有时较为麻烦,因为必须在每次调用中将返回的类型指定为模板参数。在矩阵类型已知的情况下,可以使用 cv::Mat_
类,它是 cv::Mat
的模板子类,这个类定义了一些额外的方法,但没有新的数据属性,因此对一个类的指针或引用可以直接转换到另一个类,例如可以使用 operator()
方法直接访问矩阵元素。因此,如果一个图像是 cv::Mat
实例且图像像素使用 uchar
类型表示,那么我们可以使用以下代码达到修改访问图像像素的目的:
cv::Mat_<uchar> img2(image);
img2(100, 100) = 0;
由于 cv::Mat_
元素的类型是在创建变量时声明的,operator()
方法在编译时就知道要返回哪种类型,因此,使用 operator()
方法可以得到与 at()
方法完全相同的结果。
完整代码 (saltimage.cpp
) 如下所示:
#include
#include
#include
#include
// 在图像中添加噪声
void salt(cv::Mat image, int n) {
// 随机数生成器
std::default_random_engine generator;
std::uniform_int_distribution<int> randomRow(0, image.rows - 1);
std::uniform_int_distribution<int> randomCol(0, image.cols - 1);
int i,j;
for (int k=0; k<n; k++) {
// 生成随机图像坐标
i= randomCol(generator);
j= randomRow(generator);
if (image.type() == CV_8UC1) { // 灰度图像
// 单通道 8 bit 图像
image.at<uchar>(j,i)= 255;
} else if (image.type() == CV_8UC3) { // 彩色图像
// 3通道图像
image.at<cv::Vec3b>(j,i)[0]= 255;
image.at<cv::Vec3b>(j,i)[1]= 255;
image.at<cv::Vec3b>(j,i)[2]= 255;
// 或简写为
// image.at(j, i) = cv::Vec3b(255, 255, 255);
}
}
}
// 用于添加噪声的另一函数,cv::Mat_ 仅适用于单通道图像
void salt2(cv::Mat image, int n) {
// 必须为灰度图像
CV_Assert(image.type() == CV_8UC1);
// 随机数生成器
std::default_random_engine generator;
std::uniform_int_distribution<int> randomRow(0, image.rows - 1);
std::uniform_int_distribution<int> randomCol(0, image.cols - 1);
// 使用 Mat_ 模板
cv::Mat_<uchar> img(image);
// 使用引用
// cv::Mat_& im2= reinterpret_cast&>(image);
int i,j;
for (int k=0; k<n; k++) {
i = randomCol(generator);
j = randomRow(generator);
// 添加噪声
img(j,i)= 255;
}
}
int main()
{
cv::Mat image= cv::imread("1.png",1);
// 添加噪声
salt(image,3000);
cv::namedWindow("Image");
cv::imshow("Image",image);
cv::waitKey();
// 测试 salt2 函数
image= cv::imread("1.png",0);
salt2(image, 500);
cv::imwrite("salt.png", image);
cv::namedWindow("Image");
cv::imshow("Image",image);
cv::waitKey();
return 0;
}
在大多数图像处理任务中,我们需要扫描图像的所有像素才能执行计算,由于需要访问大量像素,我们必须以高效的方法进行扫描。本节我们将介绍如何使用指针高效扫描图像,我们通过完成减少图像中的颜色数量(即色彩量化)示例来说明图像扫描过程。
彩色图像由三通道像素组成,这些通道中的每一个都对应于红色、绿色和蓝色三种基色之一的强度值。由于这些像素值都是 8
位无符号字符,因此颜色总数为 256 x 256 x 256
,超过 1600
万种颜色。因此,通常可以通过减少图像中颜色的数量来降低分析的复杂性。实现此目标的一种方法是将 RGB
空间细分为大小相等的立方体。例如,如果我们将每个维度中的颜色数量减少为原来的 1/8,那么可以得到共 32 x 32 x 32
种颜色。此时,原始图像中的每种颜色都会在新的颜色空间中分配一个新的颜色值,该值等于原始颜色值所属的立方体中心的值。
因此,基本的色彩量化(色彩量化即为减少图像中颜色数量的过程)算法很简单。如果 N
是缩减因子,则对于图像中的每个像素和该像素的每个通道,将值除以 N
(使用整数除法,舍弃余数);然后,将结果乘以 N
,此时获得的值与输入像素值之间的差值为 N
的倍数,然后,只需添加 N/2
即可获得 N
的两个相邻倍数间的中心位置。如果对每个 8
位通道值重复此过程,将获得共 256/N x 256/N x 256/N
个可能的颜色值。
(1) 减色函数的签名如下,函数需要提供图像和每个通道的缩减因子 div
作为参数:
void colorReduce(cv::Mat image, int div=64);
此函数使用原地处理,即输入图像的像素值被函数修改。
(2) 只需创建一个遍历所有像素值的双循环即可完成处理。第一个循环扫描每一行,获取行图像数据的指针:
for (int j=0; j<image.rows; j++){
// 获取行的地址
uchar* data=image.ptr<uchar>(j);
(3) 第二个循环遍历行指针的每一列,并使用上述方法减少颜色:
for (int i=0; i<nc; i++){
// 处理每个像素
data[i] = data[i]/div*div + div/2
}
}
(4) 通过加载图像并调用 colorReduce()
函数来测试该函数:
// 读取图像
image= cv::imread("1.png");
// 处理图像
colorReduce(image,64);
// 展示图像
cv::namedWindow("Image");
cv::imshow("Image",image);
完成代码( reduceColor.cpp
)如下所示:
#include
#include
#include
// 使用指针扫描图像
void colorReduce(cv::Mat image, int div=64) {
int nl = image.rows;
int nc = image.cols * image.channels();
for (int j=0; j<nl; j++) {
// 获取输入图像第j行地址
uchar* data = image.ptr<uchar>(j);
for (int i=0; i<nc; i++) {
data[i] = data[i]/div*div + div/2;
}
}
}
int main() {
// 读取图像
cv::Mat image = cv::imread("1.png");
colorReduce(image, 64);
cv::namedWindow("Image");
cv::imshow("Image", image);
cv::waitKey();
return 0;
}
编译并执行程序,可以得到以下结果:
在彩色图像中,图像数据缓冲区的前三个字节分别用于表示左上角像素的三色通道( BGR
通道);接下来的三个字节是第一行的第二个像素的三色通道值,依此类推。因此,宽度为 W
、高度为 H
的图像需要 W x H x 3uchars
的内存块。但是,出于效率原因,一行图像元素可以填充一些额外的像素,这是因为某些多媒体处理器芯片(例如 Intel MMX
架构)在图像行像素数为 4
或 8
的倍数时可以更有效地处理图像,这些额外的像素并不会被显示或保存;它们的确切值会被忽略。在 OpenCV
中,填充后的行长度称为有效宽度,如果图像没有填充额外的像素,则有效宽度等于实际图像宽度。我们可以使用 cols
和 rows
属性获取图像的宽度和高度;step
属性可以提供以字节数表示的有效宽度,即使图像是 uchar
以外的类型,step
仍可以提供一行像素中的字节数;像素元素的大小由 elemSize
方法提供(例如,对于三通道短整数矩阵 CV_16SC3
,elemSize
将返回 6
);channels()
方法可以返回图像中的通道数(灰度图像为 1
,彩色图像为 3
);total()
方法返回矩阵中的像素总数(即矩阵项)。
可以使用以下代码获取图像中每行的像素值数量:
int nc= image.cols * image.channels();
为了简化指针算术的计算,cv::Mat
类提供了一种直接获取图像行地址的方法,即 ptr()
方法,它是一个返回第 j
行地址的模板方法:
uchar* data= image.ptr<uchar>(j);
在处理像素时,我们可以等效地使用指针算法在列之间移动:
*data= *data/div*div + div2;
data++;
本节中介绍的减色函数仅提供了完成此任务的一种方法,我们还可以使用其他减色公式;为了函数具有通用性,可以为函数指定输入输出参数;通过考虑图像数据的连续性,还可以使图像扫描更加高效;最后,还可以使用常规的低级指针算法来扫描图像缓冲区。
在上一小节中,我们利用整数除法实现减色任务:
data[i]= (data[i]/div)*div + div/2;
除此之外,也可以使用模运算符计算减色后的颜色,使用该运算符得到最接近的 div
倍数(每通道减少因子):
data[i]= data[i] - data[i]%div + div/2;
或者使用按位运算符,实际上,如果我们将缩减因子限制为 2
的幂,即 div=pow(2,n)
,那么屏蔽像素值的前 n
位即可得到最接近的 div
的较低倍数:
// 用于舍入像素值的掩码
uchar mask= 0xFF<<n; // 例如 div=16, mask= 0xF0
颜色减少通过以下代码实现:
*data &= mask; // 掩码
*data++ += div>>1;
一般来说,按位运算相较其他运算更加高效,因此当效率优先时,推荐使用按位运算。
在以上色彩减少算法中,变换直接应用于输入图像,称为原地处理 (in-place transformation
),不需要额外的图像来保存输出结果,这样虽然可以节省内存使用量。然而,在一些应用中,用户希望保持原始图像的可用性,因此需要在调用该函数之前创建图像的副本。创建图像副本的最简单方法是调用 clone()
方法:
// 读取图像
image= cv::imread("boldt.jpg");
// 创建图像副本
cv::Mat imageClone= image.clone();
// 处理图像,原图像保持不变
colorReduce(imageClone);
// 展示结果图像
cv::namedWindow("Image Result");
cv::imshow("Image Result",imageClone);
可以通过定义一个带有输入和输出参数的函数来避免额外的重载,该函数为用户提供使用或不使用原地处理的选项:
void colorReduce(const cv::Mat &image, // 输入图像
cv::Mat &result, // 输出图像
int div=64);
在以上代码中,输入图像作为常量引用传递,这意味着该图像不会被函数修改;输出图像作为引用传递,以便调用函数可以得到调用修改后的输出参数。当使用就地处理时,将相同的图像指定为输入和输出:
colorReduce(image,image);
如果不使用原地处理,需要提供另一个 cv::Mat
实例:
cv::Mat result;
colorReduce(image,result);
关键是首先验证输出图像是否具有与输入图像的大小和像素类型相匹配的已分配数据缓冲区。验证过程封装在 cv::Mat
的 create()
方法中,这是必须使用新的大小和类型重新分配矩阵时使用的方法。如果矩阵已经具有指定的大小和类型,则不执行任何操作并且该方法直接返回而不涉及实例操作。因此,我们的函数从创建一个与输入图像相同大小和类型的矩阵开始:
result.create(image.rows,image.cols,image.type());
分配的内存块的大小为 total()*elemSize()
,然后使用两个指针完成循环:
for (int j=0; j<nl; j++) {
// 获取输入与输出图像第j行地址
const uchar* data_in = image.ptr<uchar>(j);
uchar* data_out = result.ptr<uchar>(j);
for (int i=0; i<nc*nchannels; i++) {
data_out[i] = data_in[i]/div*div + div/2;
}
}
在提供相同图像作为输入和输出的情况下,此函数将完全等同于上一小节中介绍的算法。如果提供另一张图像作为输出,则无论该图像是否在函数调用之前已分配,函数都可以正常运行。
在上一小节中,我们提到,出于效率原因,可以在每行的末尾用额外的像素填充图像。当图像未填充时,我们也可以将图像看作是一个 W x H
像素的一维连续阵列。我们可以使用 cv::Mat
方法 isContinuous()
检查图像是否被填充;如果图像不包含填充像素,则 isContinuous()
方法返回 true
。除此之外,我们还可以通过以下测试来检查矩阵的连续性:
// 检查图像中一行像素的字节数是否等于列数乘以像素大小(字节)
image.step == image.cols*image.elemSize();
为了完整起见,这个测试还应该检查矩阵是否只有一行像素,在这种情况下,根据定义该图像也是连续的。但通常推荐使用 isContinuous
方法来测试图像的连续性;在某些特定的处理算法中,可以通过利用图像的连续性在一个循环中处理图像:
void colorReduce(cv::Mat &image, int div=64) {
int nl = image.rows;
int nc = image.cols;
if (image.isContinuous()) {
// 无填充像素
nc = nc * nl;
nl = 1; // 1D 阵列
}
// 对于连续图像,只需执行一次循环
for (int j=0; j<nl; j++){
uchar* data = image.ptr<uchar>(j);
for (int i=0; i<nc; i++){
data[i] = data[i]/div*div +div/2;
}
}
}
当连续性测试结果表明图像不包含填充像素时,我们通过将宽度设置为 1
并将高度设置为 W x H
来消除外层循环。除此之外,我们还可以使用 reshape()
方法调整图像形状:
if (image.isContinuous()) {
// 无填充像素
image.reshape(
1, // 新图像通道数
1 // 新图像行数
)
}
int nl = image.rows; // 图像行数
int nc = image.cols * image.channels();
reshape()
方法无需任何内存复制或重新分配即可更改矩阵维度,其中第一个参数是新的通道数,第二个是新的行数,该方法会相应地重新调整列数。对于连续图像,内循环可以按顺序处理所有图像像素。
在 cv::Mat
类中,图像数据存储在无符号字符的内存块中,该内存块的第一个元素的地址由返回无符号字符指针的 data
属性给出。因此,要从图像的起始位置开始循环,可以使用以下代码:
uchar *data= image.data;
需要移动到下一行时,可以通过使用有效宽度移动行指针来完成:
data += image.step; // next line
step
属性可以返回图像中一行的总字节数(包括填充的像素),因此,可以通过以下方式获取第 j
行和第 i
列像素的地址:
// (j, i) 处的像素地址,即 &image.at(j, i)
data= image.data+j*image.step+i*image.elemSize();
但是,即使这是一种可行的方法,也并不建议使用这种方式访问像素。
在面向对象的编程中,循环数据集合通常是使用迭代器完成的。迭代器是专门为遍历集合的每个元素而构建的类,隐藏了如何迭代给定集合中每个元素的具体操作。信息隐藏原理的应用使扫描集合更容易、更安全;同时,无论使用什么类型的集合,迭代的形式都是相似的。标准模板库 (Standard Template Library
, STL
) 具有与其每个集合类相关联的迭代器类。而 OpenCV
同样提供了 cv::Mat
迭代器类,该类与 C++ STL
中的标准迭代器兼容。在本节中,我们将介绍如何使用迭代器扫描图像以完成减色任务。
cv::Mat
实例的迭代器对象可以通过首先创建一个 cv::MatIterator_
对象来获得。与 cv::Mat_
的情况一样,下划线表示这是一个模板子类。实际上,由于图像迭代器用于访问图像元素,因此在编译时必须知道返回类型:
cv::MatIterator_<cv::Vec3b> it;
或者,也可以使用 cv::Mat_
模板类中定义的迭代器类型:
cv::Mat_<cv::Vec3b>::iterator it;
接下来,我们将迭代器应用于颜色减少任务。
接下来,我们使用常见的开始和结束迭代器方法循环处理像素。
(1) 首先,获取迭代器的开始位置:
// 在初始位置获取迭代器
cv::Mat_<cv::Vec3b>::iterator it = image.begin<cv::Vec3b>();
(2) 然后,获取迭代器的结束位置:
// 获取结束位置
cv::Mat_<cv::Vec3b>::iterator itend = image.end<cv::Vec3b>();
无论扫描哪种类型的集合,使用迭代器始终需要遵循相同的模式。首先,使用适当的专用类创建迭代器对象,在以上代码中,我们使用 cv::Mat_
(或 cv::MatIterator_
) 完成创建;然后,获取在起始位置(在以上代码中为图像的左上角)处使用 begin()
方法初始化的迭代器,可以通过使用 cv::Mat
实例的 image.begin
获取起始位置。我们也可以在迭代器上使用算术运算,例如,如果希望从图像的第二行开始迭代,可以使用 image.begin
初始化 cv::Mat
迭代器。集合结束位置的获取方式类似,但需要使用 end()
方法,也可以在结束迭代器上使用算术运算,例如,如果希望在最后一行之前停止,则需要在迭代器到达 image.end
时停止。
(3) 接下来,循环迭代器直到结束位置:
// 循环所有像素
for ( ; it!= itend; ++it) {
一旦迭代器初始化完成,就可以创建循环遍历所有元素直到到达迭代终点,除了 for
循环外,我们也可以使用 while
循环:
while (it != itend) {
++it;
}
++
运算符用于移动到下一个元素,我们还可以指定更大的步长,例如,it+=10
将每隔 10
个像素进行一次处理。
(4) 最后,对像素应用颜色减少算法:
(*it)[0]= (*it)[0]/div*div + div/2;
(*it)[1]= (*it)[1]/div*div + div/2;
(*it)[2]= (*it)[2]/div*div + div/2;
由于我们正在处理彩色图像,以上代码中的迭代器会返回一个 cv::Vec3b
实例,使用解引用运算符 []
可以访问每个颜色通道元素。
在处理循环中,使用取值运算符 *
访问当前元素。使用该运算符可以进行读取(例如, element= *it;
)或写入(例如, *it= element;
)。如果希望得到对 const cv::Mat
的引用,或者需要当前循环不修改 cv::Mat
实例,也可以创建常量迭代器,这种情况下,声明如下:
cv::MatConstIterator_<cv::Vec3b> it;
或者也可以使用以下声明:
cv::Mat_<cv::Vec3b>::const_iterator it;
在以上代码中,迭代器的开始和结束位置是使用开始和结束模板方法获得的,我们也可以使用对 cv::Mat_
实例的引用来获取。这可以避免在 begin()
和 end()
方法中指定迭代器类型,因为这是在创建 cv::Mat_
引用时指定的:
cv::Mat_<cv::Vec3b> cimage(image);
cv::Mat_<cv::Vec3b>::iterator it= cimage.begin();
cv::Mat_<cv::Vec3b>::iterator itend= cimage.end();
像素是图像的组成元素,许多图像处理操作都需要逐像素进行处理,因此高效的进行像素访问和操作具有重要意义,特别是对于对实时性要求较高的操作更是如此。在本节中,我们重点介绍了如何访问、修改图像像素,为了高效的进行操作,还进一步学习了如何使用指针 Ptr
和迭代器 iterator
进行扫描图像执行像素操作。
OpenCV实战(1)——OpenCV与图像处理基础
OpenCV实战(2)——OpenCV核心数据结构
OpenCV实战(3)——图像感兴趣区域