OpenCV计算机视觉编程篇二《操作像素》

OpenCV 虚幻 888℃ 0评论

2.1 简介

为了构建计算机视觉应用程序,我们需要学会访问图像内容,有时也要修改或创建图像。本 章将讲解如何操作图像的元素(即像素),你将学会如何扫描一幅图像并处理每一个像素,还将 学会如何进行高效处理,因为即使是中等大小的图像,也可能包含数十万个像素。

图像本质上就是一个由数值组成的矩阵。正因为如此,OpenCV 使用了 cv::Mat 结构来操作图像,这在第 1 章已经讲过。矩阵中的每个元素表示一个像素。对灰度图像(黑白图像)而言, 像素是 8 位无符号数(数据类型为 unsigned char),0 表示黑色,255 表示白色。

对彩色图像而言,需要用三原色数据来重现不同的可见色。这是因为人类的视觉系统是三原色的,视网膜上有三种类型的视锥细胞,它们将颜色信息传递给大脑。这意味着彩色图像的每个 像素都要对应三个数值。在摄影和数字成像技术中,常用的主颜色通道是红色、绿色和蓝色,因此每三个 8 位数值组成矩阵的一个元素。请注意,8 位通道通常是够用的,但有些特殊的应用程 序需要用 16 位通道(例如医学图像)。

第 1 章曾提到,OpenCV 也可以用其他类型的像素值来创建矩阵(或图像),例如整型(CV_32U 或 CV_32S)和浮点数(CV_32F)。这些类型非常有用,有的可以存储图像处理过程中的中间结果。大部分操作可以使用所有类型的矩阵,也有一些操作必须使用特定的类型或特定的通道数量。 因此,为了避免常见的编程错误,必须充分理解函数的先决条件。

本章将一直使用下面的彩色图像作为输入对象:

20201129102421859 - OpenCV计算机视觉编程篇二《操作像素》

2.2 访问像素值

若要访问矩阵中的每个独立元素,只需要指定它的行号和列号即可。返回的对应元素可以是单个数值,也可以是多通道图像的数值向量

2.2.1 准备工作

为了说明如何直接访问像素值,我们将创建一个简单的函数,用它在图像中加入椒盐噪声 (salt-and-pepper noise)。顾名思义,椒盐噪声是一个专门的噪声类型,它随机选择一些像素,把 它们的颜色替换成白色或黑色。如果通信时出错,部分像素的值在传输时丢失,就会产生这种噪声。这里只是随机选择一些像素,把它们设置为白色。

2.2.2 如何实现

创建一个接受输入图像的函数,在函数中对图像进行修改。第二个参数是需要改成白色的像素数量。

void salt(cv::Mat image, int n) {
 // C++11 的随机数生成器
 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 位图像
 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;
 }
 }
} 

这个函数使用一个简单的循环,执行 n 次,每次都把随机选择的像素设置为 255。这里用随 机数生成器生成像素的列 i 和行 j。请注意,这里使用了 type 方法来区分灰度图像和彩色图像。 对于灰度图像,把单个的 8 位数值设置为 255;对于彩色图像,需要把三个主颜色通道都设置为 255 才能得到一个白色像素。

现在你可以调用这个函数,并传入已经打开的图像。参考下面的代码:

// 打开图像
cv::Mat image= cv::imread("boldt.jpg",1);
// 调用函数以添加噪声
salt(image,3000);
// 显示结果
cv::namedWindow("Image");
cv::imshow("Image",image);

结果图像如下所示。

20201129102448859 - OpenCV计算机视觉编程篇二《操作像素》

2.2.3 实现原理

cv::Mat 类包含多种方法,可用来访问图像的各种属性:利用公共成员变量 cols 和 rows 可得到图像的列数和行数;利用 cv::Matat(int y,int x)方法可以访问元素,其中 x 是 列号,y 是行号。在编译时必须明确方法返回值的类型,因为 cv::Mat 可以接受任何类型的元 素,所以程序员需要指定返回值的预期类型。正因为如此,at 方法被实现成一个模板方法。在调 用 at 方法时,你必须指定图像元素的类型,例如:

image.at<uchar>(j,i)= 255; 

有一点需要特别注意,程序员必须保证指定的类型与矩阵内的类型是一致的。at 方法不会进行任何类型转换。

彩色图像的每个像素对应三个部分:红色通道、绿色通道和蓝色通道,因此包含彩色图像的 cv::Mat 类会返回一个向量,向量中包含三个 8 位的数值。OpenCV 为这样的短向量定义了一种 类型,即 cv::Vec3b。这个向量包含三个无符号字符(unsigned character)类型的数据。因此, 访问彩色像素中元素的方法如下所示:

image.at<cv::Vec3b>(j,i)[channel]= value;

channel 索引用来指明三个颜色通道中的一个。OpenCV 存储通道数据的次序是蓝色、绿色 和红色(因此蓝色是通道 0)。你也可以直接使用短向量,方法如下所示:

image.at<cv::Vec3b>(j, i) = cv::Vec3b(255, 255, 255);

还有类似的向量类型用来表示二元素向量和四元素向量(cv::Vec2bcv::Vec4b)。此 外还有针对其他元素类型的向量。例如,表示二元素浮点数类型的向量就是把类型名称的最后一个字母换成 f,即 cv::Vec2f。对于短整型,最后的字母换成 s;对于整型,最后的字母换成 i; 对于双精度浮点数向量,最后的字母换成 d。所有这些类型都用 cv::Vec 模板类定义,其 中 T 是类型,N 是向量元素的数量。

最后一个提示,你也许会觉得奇怪,为什么这些修改图像的函数在使用图像作为参数时,都采用了值传递的方式?之所以这样做,是因为它们在复制图像时仍共享了同一块图像数据。因此在需要修改图像内容时,图像参数没必要采用引用传递的方式。顺便说一下,编译器做代码优化 时,用值传递参数的方法通常比较容易实现。

2.2.4 扩展阅读

cv::Mat 类的定义采用了 C++模板,因此它的通用性很强。

cv::Mat_模板类

因为每次调用都必须在模板参数中指明返回类型,所以 cv::Mat 类的 at 方法有时会显得冗 长。如果已经知道矩阵的类型,就可以使用 cv::Mat_类(cv::Mat 类的模板子类)。cv::Mat_类 定义了一些新的方法,但没有定义新的数据属性,因此这两个类的指针或引用可以直接互相转换。 新方法中有一个 operator(),可用来直接访问矩阵的元素。因此可以这样写代码(其中 image 是一个对应 uchar 矩阵的 cv::Mat 变量):

// 用 Mat 模板操作图像
cv::Mat_<uchar> img(image);
img(50,100)= 0; // 访问第 50 行、第 100 列处那个值

在创建 cv::Mat_变量时,我们就定义了它的元素类型,因此在编译时就已经知道了 operator()的返回类型。使用操作符 operator()和使用 at 方法产生的结果是完全相同的, 只是前者的代码更简短。

2.3 用指针扫描图像

在大多数图像处理任务中,执行计算时你都需要对图像的所有像素进行扫描。需要访问的像 素数量非常庞大,因此你必须采用高效的方式来执行这个任务。本节和下一节将展示几种实现高效扫描循环的方法,本节将使用指针运算。

2.3.1 准备工作

为了说明图像扫描的过程,我们来做一个简单的任务:减少图像中颜色的数量。

彩色图像由三通道像素组成,每个通道表示红、绿、蓝三原色中一种颜色的亮度值,每个数值都是 8 位无符号字符类型,因此颜色总数为 256×256×256,即超过 1600 万种颜色。因此,为了降低分析的复杂性,有时需要减少图像中颜色的数量。一种实现方法是把 RGB 空间细分到大小相等的方块中。例如,如果把每种颜色数量减少到 1/8,那么颜色总数就变为 32×32×32。将旧图像中的每个颜色值划分到一个方块,该方块的中间值就是新的颜色值;新图像使用新的颜色值, 颜色数就减少了。

因此,基本的减色算法很简单。假设 N 是减色因子,将图像中每个像素的值除以 N(这里假 定使用整数除法,不保留余数)。然后将结果乘以 N,得到 N 的倍数,并且刚好不超过原始像素 值。加上 N / 2,就得到相邻的 N 倍数之间的中间值。对所有 8 位通道值重复这个过程,就会得到 (256 / N) × (256 / N) × (256 / N)种可能的颜色值。

2.3.2 如何实现

减色函数的签名如下:

void colorReduce(cv::Mat image, int div=64);

用户提供一幅图像和每个颜色通道的减色因子。这里的处理过程是就地进行的,也就是说, 函数直接修改了输入图像的像素值。2.3.4 节将介绍一个更为通用的签名,用于输入和输出参数。

处理过程很简单,只要创建一个二重循环遍历所有像素值,代码如下所示:

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;
 // 像素处理结束 ----------------
 } // 一行结束
 }
} 

可以用下面的代码片段测试这个函数:

// 读取图像
image= cv::imread("boldt.jpg");
// 处理图像
colorReduce(image,64);
// 显示图像
cv::namedWindow("Image");
cv::imshow("Image",image);

执行后得到下面的图像。

20201129102536443 - OpenCV计算机视觉编程篇二《操作像素》

2.3.3 实现原理

在彩色图像中,图像数据缓冲区的前 3 字节表示左上角像素的三个通道的值,接下来的 3 字节表示第 1 行的第 2 个像素,以此类推(注意 OpenCV 默认的通道次序为 BGR)。一个宽 W 高 H 的图像所需的内存块大小为 W×H×3 uchars。不过出于性能上的考虑,我们会用几个额外的像素来填补行的长度。这是因为,如果行数是某个数字(例如 8)的整数倍,图像处理的性能可能会提高,因此最好根据内存配置情况将数据对齐。当然,这些额外的像素既不会显示也 不被保存,它们的额外数据会被忽略。OpenCV 把经过填充的行的长度指定为有效宽度。如果图像没有用额外的像素填充,那么有效宽度就等于实际的图像宽度。我们已经学过,用 cols 和 rows 属性可得到图像的宽度和高度。与之类似,用 step 数据属性可得到单位是字节的有效宽度。即使图像的类型不是 uchar,step 仍然能提供行的字节数。我们可以通过 elemSize 方法(例如一个三通道短整型的矩阵 CV_16SC3,elemSize 会返回 6)获得像素的大小,通过 nchannels 方法(灰度图像为 1,彩色图像为 3)获得图像中通道的数量,最后用 total 方法返回矩阵中的像素(即矩阵的条目)总数

用下面的代码可获得每一行中像素值的个数:

int nc= image.cols * image.channels();

为了简化指针运算的计算过程,cv::Mat 类提供了一个方法,可以直接访问图像中一行的 起始地址。这就是 ptr 方法,它是一个模板方法,返回第 j 行的地址:

uchar* data= image.ptr<uchar>(j);

请注意,我们也可以在处理语句中采用另一种等价的做法,即利用指针运算从一列移到下一 列。因此可以使用下面的代码:

*data++= *data/div*div + div2;

2.3.4 扩展阅读

前面介绍的减色函数只是完成任务的一种方法,也可以采用其他的减色算法。要想使函数更加通用,就要允许指定不同的输入和输出图像。另外,考虑到图像数据的连续性,扫描的速度还可以提高。最后,也可以使用低层次指针运算来扫描图像缓冲区。下面分别讨论这几点。

  1. 其他减色算法

在前面的例子中,减色功能的实现是利用了整数除法的特性,即取不超过又最接近结果的整 数,代码如下所示:

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; // 加上 div/2
// 这里的+也可以改用“按位或”运算符

一般来说,使用位运算的代码运行效率很高,因此在效率为重时,位运算是不二之选。

  1. 使用输入和输出参数

前面的减色函数直接在输入图像中进行了转换,这称为就地转换。这种做法不需要额外的图像来输出结果,可以减少内存的使用。但是有的程序不希望对原始图像进行修改,这时就必须在调用函数前备份图像。请注意,对图像进行深复制最简单的方法是使用 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);

注意,输入图像是一个引用的 const,表示这幅图像不会在函数中修改。输出图像是一个引用参数,在函数中会被修改,并且返回给调用这个函数的代码。如果需要就地处理,可以在输入 和输出参数中用同一个 image 变量:

colorReduce(image,image); 

否则就可以提供一个 cv::Mat 实例:

cv::Mat result;
colorReduce(image,result);

这里的关键是先检查输出图像,验证它是否分配了一定大小的数据缓冲区,以及像素类型与 输入图像是否相符——所幸 cv::Matcreate 方法中已经包含了这个检查过程。当你用新的 大小和像素类型重新分配矩阵时,就要调用 create 方法。如果矩阵已有的大小和类型刚好与指 定的大小和类型相同,这个方法就不会执行任何操作,也不会修改实例,而只是直接返回。

因此,函数中首先要调用 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;
 // 像素处理结束 ----------------
 } // 一行结束
}

如果输入和输出参数用了同一幅图像,这个函数就与本节前面的版本完全等效。如果输出用 了另一幅图像,不管在调用函数前是否已经分配了这幅图像,函数都会正常运行。

最后需要注意的是,这个函数的参数类型也可以用 cv::InputArraycv::OutputArray。 这样得到的结果是一样的,但在参数类型的选择上提供了更大的灵活性,详见第 1 章。

  1. 对连续图像的高效扫描

前面解释过,为了提高性能,可以在图像的每行末尾用额外的像素进行填充。有趣的是,在去掉填充后,图像仍可被看作一个包含 W×H 像素的长一维数组。用 cv::MatisContinuous 方法可轻松判断图像有没有被填充。如果图像中没有填充像素,它就返回 true。我们还能这样测试矩阵的连续性:

// 检查行的长度(字节数)与“列的个数×单个像素”的字节数是否相等
image.step == image.cols*image.elemSize();

为确保完整性,测试时还需要检查矩阵是否只有一行;如果是,这个矩阵就是连续的。但是 不管哪种情况,都可以用 isContinuous 方法检查矩阵的连续性。在一些特殊的处理算法中, 你可以充分利用图像的连续性,在单个(更长)循环中处理图像。处理函数就可以改为:

void colorReduce(cv::Mat image, int div=64) {
 int nl= image.rows; // 行数
 // 每行的元素总数
 int nc= image.cols * image.channels();
 if (image.isContinuous()) {
 // 没有填充的像素
 nc= nc*nl;
 nl= 1; // 它现在成了一个一维数组
 }
 int n= staic_cast<int>(
 log(static_cast<double>(div))/log(2.0) + 0.5);
 // 用来截取像素值的掩码
 uchar mask= 0xFF<<n; // 如果 div=16, 那么 mask= 0xF0
 uchar div2 = div >> 1; // div2 = div/2
 // 对于连续图像,这个循环只执行一次
 for (int j=0; j<nl; j++) {
 uchar* data= image.ptr<uchar>(j);
 for (int i=0; i<nc; i++) {
 *data &= mask;
 *data++ += div2;
 } // 一行结束
 }
} 

如果连续性测试结果表明图像中没有填充像素,我们就把宽度设为 1,高度设为 W×H,从而 去除外层的循环。注意,这里还需要用 reshape 方法。本例中需要这样写:

if (image.isContinuous())
{
 // 没有填充像素
 image.reshape(1, // 新的通道数
 1); // 新的行数
}
int nl= image.rows; // 行数
int nc= image.cols * image.channels();

如果是用 reshape 方法修改矩阵的维数,就不需要复制内存或重新分配内存了。第一个参 数是新的通道数,第二个参数是新的行数。列数会进行相应的修改。

在这些实现方式中,内层循环按顺序处理图像中的所有像素。

  1. 低层次指针算法

    cv::Mat 类中,图像数据是存放在无符号字符型的内存块中的。其中 data 属性表示内 存块第一个元素的地址,它会返回一个无符号字符型的指针。如果要从图像的起点开始循环,你 可以用如下代码:

uchar *data= image.data;

利用有效宽度来移动行指针,可以从一行移到下一行,代码如下所示:

data+= image.step; // 下一行

step 属性可得到一行的总字节数(包括填充像素)。通常可以用下面的方法得到第 j 行、 第 i 列的像素的地址:

// (j,i)像素的地址,即&image.at(j,i)
data= image.data+j*image.step+i*image.elemSize();

然而,尽管这种处理方法在上述例子中能起作用,但是并不推荐使用

2.4 用迭代器扫描图像

在面向对象编程时,我们通常用迭代器对数据集合进行循环遍历。迭代器是一种类,专门用 于遍历集合的每个元素,并能隐藏遍历过程的具体细节。信息隐藏原则的应用,使扫描集合的过 程变得更加容易和安全。并且不管被用于哪种类型的集合,它都能提供类似的形式。标准模板库 (Standard Template Library,STL)对每个集合类都定义了对应的迭代器类,OpenCV 也提供了 cv::Mat 的迭代器类,并且与 C++ STL 中的标准迭代器兼容

2.4.1 准备工作

本节仍使用 2.3 节的减色程序作为例子。

2.4.2 如何实现

要得到 cv::Mat 实例的迭代器,首先要创建一个 cv::MatIterator_对象。跟 MARKDOWN_HASHd098624aa2ca3bb15af2fee6c97734beMARKDOWNHASH 类似,这个下划线表示它是一个模板子类。因为图像迭代器是用来访问图像元素的,所以必须在编译时就明确返回值的类型。可以这样定义彩色图像的迭代器:

cv::MatIterator_<cv::Vec3b> it; 

也可以使用在 Mat_模板类内部定义的 iterator 类型:

cv::Mat_<cv::Vec3b>::iterator it;

然后就可以使用常规的迭代器方法 beginend 对像素进行循环遍历了。不同之处在于它们仍然是模板方法。现在,减色函数可以这样编写:

void colorReduce(cv::Mat image, int div=64) {
 // div 必须是 2 的幂
 int n= staic_cast<int>(
log(static_cast<double>(div))/log(2.0) + 0.5);
// 用来截取像素值的掩码
 uchar mask= 0xFF<<n; // 如果 div=16, mask=0xF0
 uchar div2 = div >> 1; // div2 = div/2
 // 迭代器
 cv::Mat_<cv::Vec3b>::iterator it= image.begin<cv::Vec3b>();
 cv::Mat_<cv::Vec3b>::iterator itend= image.end<cv::Vec3b>();
 // 扫描全部像素
 for ( ; it!= itend; ++it) {
 (*it)[0]&= mask;
 (*it)[0]+= div2;
 (*it)[1]&= mask;
 (*it)[1]+= div2;
 (*it)[2]&= mask;
 (*it)[2]+= div2;
 }
} 

请注意,这里处理的是一个彩色图像,因此迭代器返回 cv::Vec3b 实例。你可以用取值运 算符[]访问每个颜色通道的元素。这里也可以使用 cv::Vec3b 的重载运算符,可简化为:

*it= *it/div*div+offset; 

短向量的元素运算都可以使用这种方法。

2.4.3 实现原理

不管扫描的是哪种类型的集合,使用迭代器时总是遵循同样的模式。

首先你要使用合适的专用类创建迭代器对象,在本例中是 MARKDOWN_HASH3468a2284c6123d4aa45d1b1dce005c0MARKDOWNHASH (或 cv::MatIterator)。

然后可以用 begin 方法,在开始位置(本例中为图像的左上角)初始化迭代器。对于彩色 图像的 cv::Mat 实例,可以使用 image.begin()。还可以在迭代器上使用数学计算,例如若要从图像的第二行开始,可以用 image.begin()+image.cols 初始化 cv::Mat 迭代器。获取集合结束位置的方法也类似,只是改用 end 方法。但是,用 end 方法得 到的迭代器已经超出了集合范围,因此必须在结束位置停止迭代过程。结束的迭代器也能使用数学 计算,例如你想在最后一行前就结束迭代,可使用 image.end()-image.cols

初始化迭代器后,建立一个循环遍历所有元素,到结束迭代器为止。典型的 while 循环就 像这样:

while (it!= itend) {
 // 处理每个像素 ---------------------
 ...
 // 像素处理结束 ---------------------
 ++it;
} 

你可以用运算符++移动到下一个元素,也可以指定更大的步幅。例如用 it+=10,对每 10 个像素处理一次。

最后,在循环内部使用取值运算符来访问当前元素,你可以用它来读(例如 element= it;)或写(例如*it= element;)。你也可以创建常量迭代器,用作对常量 cv::Mat 的引用, 或者表示当前循环不修改 cv::Mat 实例。常量迭代器的定义如下所示:

cv::MatConstIterator_<cv::Vec3b> it; 

或者:

cv::Mat_<cv::Vec3b>::const_iterator it;

2.4.4 扩展阅读

本节用 beginend 模板方法获得了迭代器的开始位置结束位置。2.2 节讲过,我们还可以用对 cv::Mat_实例的引用来获取迭代器的开始位置和结束位置,这样就不需要在 begin 和 end 方法中指定迭代器的类型了,因为在创建 cv::Mat_引用时迭代器类型已被指定。

2.5 编写高效的图像扫描循环

本章前面几节介绍了几种为处理像素而扫描图像的方法,本节就来比较一下这些方法的 效率

在编写图像处理函数时,你需要充分考虑运行效率。在设计函数时,你要经常检查代码的运 行效率,找出处理过程中可能使程序变慢的瓶颈。

但是有一点非常重要,除非确实必要,不要以牺牲代码的清晰度来优化性能。简洁的代码总是更容易调试和维护。只有对程序效率至关重要的代码段,才需要进行重度优化。

2.5.1 如何实现

OpenCV 有一个非常实用的函数可以用来测算函数或代码段的运行时间,它就是 cv::get TickCount(),该函数会返回从最近一次计算机开机到当前的时钟周期数。在代码开始和结 束时记录这个时钟周期数,就可以计算代码的运行时间。若想得到以秒为单位的代码运行时间, 可使用另一个方法 cv::getTickFrequency(),它返回每秒的时钟周期数,这里假定 CPU 的频率是固定的(对于较新的 CPU,频率并不一定是固定的)。为了获得某个函数(或代码段) 的运行时间,通常需使用这样的程序模板:

const int64 start = cv::getTickCount();
colorReduce(image); // 调用函数
// 经过的时间(单位:秒)
double duration = (cv::getTickCount()-start)/
 cv::getTickFrequency();

2.5.2 实现原理

本章的 colorReduce 函数有几种实现方式,此处将列出每种方式的运行时间,实际的数据 跟你使用的计算机有关(这里使用配置为 64 位 Intel Core i7、主频为 2.40 GHz 的计算机)。观察 运行时间的相对差距更有意义。此外,测试结果也跟生成可执行文件的具体编译器有关。我们 采用 320×240 的图像,测试减色操作的平均运行时间。测试时采用三种不同的配置。

  • (1) 处理器采用主频为 2.5 GHz 的 64位 Intel i5,编译器为 Windows 10 下的 Visual Studio 14 2015。
  • (2) 处理器采用主频为 3.6 GHz 的 64 位 Intel i7,编译器为 Ubuntu Linux 下的 gcc 4.9.2。
  • (3) MacBook Pro(2011 版),CPU 为 2.3 GHz 的 Intel i5,编译器为 clang++ 7.0.2。

首先比较 2.3.4 节描述的三种减色运算方法。

配置 1 配置 2 配置 3
整数运算 0.867 ms 0.586 ms 1.119 ms
模运算符 0.774 ms 0.527 ms 1.106 ms
位运算符 0.015 ms 0.013 ms 0.066 ms

有趣的是,使用了位运算符的方法要比其他方法快得多,而另外两种方法的运行时间非常接近。因此,要在图像循环中计算出结果,花些时间找出效率最高的方法十分重要,其净影响会非常明显。

for (int i=0; i<image.cols * image.channels(); i++) {
 *data &= mask;
 *data++ += div/2;

上面的代码需要反复计算每行的像素数量和 div/2 的结果。改进后的代码为:

int nc= image.cols * image.channels();
uchar div2= div>>1;
for (int i=0; i<nc; i++) {
 *(data+i) &= mask;
 *(data+i) += div2;

一般来说,需要重复计算的代码会比优化后的代码慢 10 倍。但是要注意,有些编译器能够对此类循环进行优化,仍会生成高效的代码。

2.4 节讨论了使用迭代器(以及位运算符)的减色函数,它的运行时间更长,在上述三种配置下,运行时间分别为 0.480 ms、0.320 ms 和 0.655 ms。使用迭代器的主要目的是简化图像扫描 过程,降低出错的可能性。

为了进行完整的测试,我们实现了用 at 方法访问像素的函数。这种实现方式的主循环如下所示:

for (int j=0; j<nl; j++) {
 for (int i=0; i<nc; i++) {
 image.at<cv::Vec3b>(j,i)[0]=
 image.at<cv::Vec3b>(j,i)[0]/div*div + div/2;
 image.at<cv::Vec3b>(j,i)[1]=
 image.at<cv::Vec3b>(j,i)[1]/div*div + div/2;
 image.at<cv::Vec3b>(j,i)[2]=
 image.at<cv::Vec3b>(j,i)[2]/div*div + div/2;
 } // 一行结束
} 

这种方法的运行速度较慢,分别为 0.925 ms、0.580 ms 和 1.128 ms。该方法应该在需要随机访问像素的时候使用,绝不要在扫描图像时使用。

即使处理的元素总数相同,使用较短的循环和多条语句通常也要比使用较长的循环和单条语句的运行效率高。与之类似,如果你要对一个像素执行 N 个不同的计算过程,那就在单个循环中执行全部计算,而不是写 N 个连续的循环,每个循环执行一个计算。

我们还做过连续性测试,针对连续图像生成一个循环,而不是对行和列运行常规的二重循环, 使运行速度平均提高了 10%。通常情况下,这种策略是非常好的,因为它会使速度明显提高。

2.5.3 扩展阅读

还有一个提高算法运行效率的方法是采用多线程,尤其是在使用多核处理器时。OpenMPIntel 线程构建模块(Threading Building Block,TBB)和 Posix 是比较流行的并发编程 API,用 于创建和管理线程。而且现在 C++11 本身就支持多线程。

2.6 扫描图像并访问相邻像素

在图像处理中经常有这样的处理函数,它在计算每个像素的数值时,需要使用周边像素的值。 如果相邻像素在上一行或下一行,就需要同时扫描图像的多行。本节将介绍实现方法。

2.6.1 准备工作

为了便于说明问题,我们将使用一个锐化图像的处理函数。它基于拉普拉斯算子(将在第 6 章讨论)。在图像处理领域有一个众所周知的结论:如果从图像中减去拉普拉斯算子部分,图像 的边缘就会放大,因而图像会变得更加尖锐

可以用以下方法计算锐化的数值:

sharpened_pixel= 5*current-left-right-up-down;

这里的 left 是与当前像素相邻的左侧像素,up 是上一行的相邻像素,以此类推。

2.6.2 如何实现

这里不能使用就地处理,用户必须提供一个输出图像。图像扫描中使用了三个指针,一个表示当前行、一个表示上面的行、一个表示下面的行。另外,因为在计算每一个像素时都需要访问 与它相邻的像素,所以有些像素的值是无法计算的,比如第一行、最后一行和第一列、最后一列 的像素。这个循环可以这样写:

void sharpen(const cv::Mat &image, cv::Mat &result) {
// 判断是否需要分配图像数据。如果需要,就分配
 result.create(image.size(), image.type());
 int nchannels= image.channels(); // 获得通道数
 // 处理所有行(除了第一行和最后一行)
 for (int j= 1; j<image.rows-1; j++) {
 const uchar* previous= image.ptr<const uchar>(j-1); // 上一行
 const uchar* current= image.ptr<const uchar>(j); // 当前行
 const uchar* next= image.ptr<const uchar>(j+1); // 下一行
 uchar* output= result.ptr<uchar>(j); // 输出行
 for (int i=nchannels; i<(image.cols-1)*nchannels; i++) {
 // 应用锐化算子
 *output++= cv::saturate_cast<uchar>(
 5*current[i]-current[i-nchannels]-
 current[i+nchannels]-previous[i]-next[i]);
 }
 }
 // 把未处理的像素设为 0
 result.row(0).setTo(cv::Scalar(0));
 result.row(result.rows-1).setTo(cv::Scalar(0));
 result.col(0).setTo(cv::Scalar(0));
 result.col(result.cols-1).setTo(cv::Scalar(0));
} 

注意这个函数是如何同时适应灰度图像和彩色图像的。如果我们在测试用的灰度图像上执行 该函数,将得到如下结果。

20201129102619900 - OpenCV计算机视觉编程篇二《操作像素》

2.6.3 实现原理

若要访问上一行和下一行的相邻像素,只需定义额外的指针,并与当前行的指针一起递增, 然后就可以在扫描循环内访问上下行的指针了。

在计算输出像素的值时,我们调用了 cv::saturate_cast 模板函数,并传入运算结果。 这是因为计算像素的数学表达式的结果经常超出允许的范围(即小于 0 或大于 255)。使用这个函数可把结果调整到 8 位无符号数的范围内,具体做法是把小于 0 的数值调整为 0,大于 255 的 数值调整为 255——这就是 cv::saturate_cast函数的作用。此外,如果输入参数是 浮点数,就会得到最接近的整数。可以在调用这个函数时显式地指定其他数据类型,以确保结果 在该数据类型定义的范围之内。

由于边框上的像素没有完整的相邻像素,因此不能用前面的方法计算,需要另行处理。这里简单地把它们设置为 0。有时也可以对这些像素做特殊的计算,但在大多数情况下,花时间处理 这些极少数像素是没有意义的。在本例中,我们用两个特殊的方法把边框的像素设置为了 0,它 们是 row 和 col。这两个方法返回一个特殊的 cv::Mat 实例,其中包含一个单行 ROI(或单列 ROI),具体范围取决于参数(第 1 章讨论过感兴趣区域)。这里没有进行复制,因为只要这个一 维矩阵的元素被修改,原始图像也会被修改。我们用 setTo 方法来实现这个功能,此方法将对 矩阵中的所有元素赋值,代码如下所示:

result.row(0).setTo(cv::Scalar(0)); 

这个语句把结果图像第一行的所有像素设置为 0。对于三通道彩色图像,需要使用 cv:: Scalar(a,b,c)来指定三个数值,分别对像素的每个通道赋值。

2.6.4 扩展阅读

在对像素邻域进行计算时,通常用一个核心矩阵来表示。这个核心矩阵展现了如何将与计算 相关的像素组合起来,才能得到预期结果。针对本节使用的锐化滤波器,核心矩阵可以是这样的:

20201129102701350 - OpenCV计算机视觉编程篇二《操作像素》

除非另有说明,当前像素用核心矩阵中心单元格表示。核心矩阵中的每个单元格表示相关像 素的乘法系数,像素应用核心矩阵得到的结果,即这些乘积的累加。核心矩阵的大小就是邻域的大小(这里是 3×3)。从这个描述可以看出,根据锐化滤波器的要求,水平和垂直方向的四个相 邻像素与1 相乘,当前像素与 5 相乘。在图像上应用核心矩阵不只是为了描述方便,它也是信 号处理中卷积概念的基础。核心矩阵定义了一个用于图像的滤波器。

鉴于滤波是图像处理中的常见操作,OpenCV 专门为此定义了一个函数,即 cv::filter2D。 要使用这个函数,只需要定义一个内核(以矩阵的形式),调用函数并传入图像和内核,即可返 回滤波后的图像。因此,使用这个函数重新定义锐化函数非常容易:

void sharpen2D(const cv::Mat &image, cv::Mat &result) {
 // 构造内核(所有入口都初始化为 0)
 cv::Mat kernel(3,3,CV_32F,cv::Scalar(0));
 // 对内核赋值
 kernel.at<float>(1,1)= 5.0;
 kernel.at<float>(0,1)= -1.0;
 kernel.at<float>(2,1)= -1.0;
 kernel.at<float>(1,0)= -1.0;
 kernel.at<float>(1,2)= -1.0;
 // 对图像滤波
 cv::filter2D(image,result,image.depth(),kernel);
} 

这种实现方式得到的结果与前面的完全相同(执行效率也相同)。如果处理的是彩色图像, 三个通道可以应用同一个内核。注意,使用大内核的 filter2D 函数是特别有利的,因为这时它 使用了更高效的算法。

2.7 实现简单的图像运算

图像就是普通的矩阵,可以进行加、减、乘、除运算,因此可以用多种方式组合图像。OpenCV 提供了很多图像算法运算符,本节将讨论它们的用法。

2.7.1 准备工作

我们使用算法运算符,将第二幅图像与输入图像进行组合。下图就是第二幅图像。

20201129102718926 - OpenCV计算机视觉编程篇二《操作像素》

2.7.2 如何实现

这里要把两幅图像相加。这种方法可以用于创建特效图或覆盖图像中的信息。我们可以使用 cv::add 函数来实现相加功能,但因为这次是想得到加权和,因此使用更精确的 cv::addWeighted 函数:

cv::addWeighted(image1,0.7,image2,0.9,0.,result); 

操作的结果是一个新图像。

20201129102734791 - OpenCV计算机视觉编程篇二《操作像素》

2.7.3 实现原理

所有二进制运算函数的用法都一样:提供两个输入参数,指定一个输出参数。有时还可以指 定加权系数,作为运算时的缩放因子。每个函数都可以有多种格式,cv::add 是典型的具有多 种格式的函数:

// c[i]= a[i]+b[i];
cv::add(imageA,imageB,resultC);
// c[i]= a[i]+k;
cv::add(imageA,cv::Scalar(k),resultC);
// c[i]= k1*a[i]+k2*b[i]+k3;
cv::addWeighted(imageA,k1,imageB,k2,k3,resultC);
// c[i]= k*a[i]+b[i];
cv::scaleAdd(imageA,k,imageB,resultC); 

有些函数还可以指定一个掩码:

// 如果(mask[i]) c[i]= a[i]+b[i];
cv::add(imageA,imageB,resultC,mask);

使用掩码后,操作就只会在掩码值非空的像素上执行(掩码必须是单通道的)。看一下 cv::subtractcv::absdiff、cv::multiply 和 cv::divide 等函数的多种格式。此外还有位运算符(对像素的二进制数值进行按位运算)cv::bitwise_and、cv::bitwise_or、 cv::bitwise_xorcv::bitwise_notcv::mincv::max 运算符也非常实用,它们能 找到每个元素中最大或最小的像素值。

在所有场合都要使用 cv::saturate_cast 函数(详情请参见 2.6 节),以确保结果在预定 的像素值范围之内(避免上溢或下溢)。

这些图像必定有相同的大小和类型(如果与输入图像的大小不匹配,输出图像会重新分配)。 由于运算是逐个元素进行的,因此可以把其中的一个输入图像用作输出图像。

还有运算符使用单个输入图像,它们是 cv::sqrtcv::powcv::abscv::cuberootcv::expcv::log。事实上,无论需要对图像像素做什么运算,OpenCV 几乎都有相应的函数

2.7.4 扩展阅读

对于 cv::Mat 实例或者实例中的个别通道,也可以使用普通的 C++运算符。下面两节将解 释如何实现。

  1. 重载图像运算符

OpenCV 的大多数运算函数都有对应的重载运算符,因此调用 cv::addWeighted 的语句也 可以写成:

result= 0.7*image1+0.9*image2;

这种代码更加紧凑也更容易阅读。这两种计算加权和的方法是等效的。特别指出,这两种方法都会调用 cv::saturate_cast 函数。

大部分 C++运算符都已被重载,其中包括位运算符&|^~和函数 minmaxabs

比较运算符<<===!=>>=也已被重载,它们返回一个 8 位的二值图像。此外还有矩阵乘法 m1*m2(其中 m1 和 m2 都是 cv::Mat 实例)、矩阵求逆 m1.inv()、变位 m1.t()、行列式 m1.determinant()、求范数 v1.norm()、叉乘 v1.cross(v2)、点乘 v1.dot(v2),等等。 在理解这点后,你就会使用相应的组合赋值符了(例如+=运算符)。

2.5 节讨论了一个减色函数,它使用循环来扫描图像的像素并对像素进行运算操作。利用本 节所学,可以使用针对输入图像的运算符简单地重写这个函数:

image=(image&cv::Scalar(mask,mask,mask))
 +cv::Scalar(div/2,div/2,div/2); 

由于被操作的是彩色图像,因此使用了 cv::Scalar。使用图像运算符可以简化代码、提高 开发效率,因此在大多数场合都应考虑采用。

  1. 分割图像通道

我们有时需要分别处理图像中的不同通道,例如只对图像中的一个通道执行某个操作。这当然可以通过图像扫描循环实现,但也可以使用 cv::split 函数,将图像的三个通道分别复制到 三个 cv::Mat 实例中。假设我们要把一张雨景图只加到蓝色通道中,可以这样实现:

// 创建三幅图像的向量
std::vector<cv::Mat> planes;
// 将一个三通道图像分割为三个单通道图像
cv::split(image1,planes);
// 加到蓝色通道上
planes[0]+= image2;
// 将三个单通道图像合并为一个三通道图像
cv::merge(planes,result);

这里的 cv::merge 函数执行反向操作,即用三个单通道图像创建一个彩色图像。

2.8 图像重映射

在本章的前面几节中,我们学习了如何读取和修改图像的像素值,最后一节来看看如何通过 移动像素修改图像的外观。这个过程不会修改像素值,而是把每个像素的位置重新映射到新的位 置。这可用来创建图像特效,或者修正因镜片等原因导致的图像扭曲。

2.8.1 如何实现

要使用 OpenCV 的 remap 函数,首先需要定义在重映射处理中使用的映射参数,然后把映射参数应用到输入图像。很明显,定义映射参数的方式将决定产生的效果。这里定义一个转换函 数,在图像上创建波浪形效果:

// 重映射图像,创建波浪形效果
void wave(const cv::Mat &image, cv::Mat &result) {
 // 映射参数
 cv::Mat srcX(image.rows,image.cols,CV_32F);
 cv::Mat srcY(image.rows,image.cols,CV_32F);
 // 创建映射参数
 for (int i=0; i<image.rows; i++) {
 for (int j=0; j<image.cols; j++) {
 // (i,j)像素的新位置
 srcX.at<float>(i,j)= j; // 保持在同一列
 // 原来在第 i 行的像素,现在根据一个正弦曲线移动
 srcY.at<float>(i,j)= i+5*sin(j/10.0);
 }
 }
 // 应用映射参数
 cv::remap(image, // 源图像
 result, // 目标图像
 srcX, // x 映射
 srcY, // y 映射
 cv::INTER_LINEAR); // 填补方法
} 

得到的结果如下所示。

20201129102809152 - OpenCV计算机视觉编程篇二《操作像素》

2.8.2 实现原理

重映射是通过修改像素的位置,生成一个新版本的图像。为了构建新图像,需要知道目标图 像中每个像素的原始位置。因此,我们需要的映射函数应该能根据像素的新位置得到像素的原始 位置。这个转换过程描述了如何把新图像的像素映射回原始图像,因此称为反向映射。在 OpenCV中,可以用两个映射参数来说明反向映射:一个针对 x 坐标,另一个针对 y 坐标。它们都用浮点数型的 cv::Mat 实例来表示:

// 映射参数
cv::Mat srcX(image.rows,image.cols,CV_32F); // x 方向
cv::Mat srcY(image.rows,image.cols,CV_32F); // y 方向

这些矩阵的大小决定了目标图像的大小。用下面的代码可以从原始图像获得目标图像中 (i,j)像素的值:

( srcX.at<float>(i,j) , srcY.at<float>(i,j) )

第 1 章展示过的图像翻转效果也可以用下面的映射参数创建:

// 创建映射参数
for (int i=0; i<image.rows; i++) {
 for (int j=0; j<image.cols; j++) {
 // 水平翻转
 srcX.at<float>(i,j)= image.cols-j-1;
 srcY.at<float>(i,j)= i;
 }
}

只需调用 OpenCV 的 remap 函数,即可生成结果图像:

// 应用映射参数
cv::remap(image, // 源图像
 result, // 目标图像
 srcX, // x 方向映射
 srcY, // y 方向映射
 cv::INTER_LINEAR); // 插值法

有趣的是,这两个映射参数包含的值是浮点数。因此,目标图像中的像素可以映射回一个非 整数的值(即处在两个像素之间的位置),这使我们可以随意定义映射参数,非常实用。例如在 前面的重映射例子中,我们用了一个 sinusoidal 函数进行转换,但这也导致必须在真实像素之 间插入虚拟像素的值。可以采用不同的方法实现像素插值,并且可用 remap 函数的最后一个参 数来表示选择了哪种方法。像素插值是图像处理中的一个重要概念,将在第 6 章讨论。

转载请注明:虚坏叔叔 » OpenCV计算机视觉编程篇二《操作像素》

喜欢 (0)

您必须 登录 才能发表评论!