🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
## OpenCV 学习(像素操作 Manipuating the Pixels) OpenCV 虽然提供了许多类型的图像处理函数,可以对图像进行各种常见的处理,但是总会有些操作时没有的,这时我们就需要自己来操纵像素,实现我们需要的功能。今天就来讲讲 OpenCV 进行像素级操作的几种方法,并做个比较。 在 OpenCV 中,图像用矩阵来表示,对应的数据类型为 cv::Mat 。 cv::Mat 功能很强大,矩阵的元素可以为字节、字、浮点数、数组等多种形式。对于灰度图像,每个像素用一个 8 bit 字节来表示,对彩色图像,每个像素是一个三个元素的数组,分别存储 **BGR** 分量,这里大家没看错,就是 BGR 而不是 RGB,每个像素三个字节,第一个字节是蓝色分量,别问我为啥设计成这样,我也不知道。 ### 访问单个像素 (at 函数) cv::Mat 类有个 at(int y, int x) 方法,可以访问单个像素。但是我们知道cv::Mat 可以存储各种类型的图像。在调用这个函数时必须要指定返回的像素的类型,因为 at 函数是模板函数。 如果是灰度图,我们知道像素是以无符号字符型变量的形式存储的。那么要像下面这样访问。 ~~~ image.at<uchar>(j,i)= value; ~~~ 如果图像是24位真彩色的,那么可以这样: ~~~ image.at<cv::Vec3b>(j,i)[channel]= value; ~~~ 下面是个简单的例子,打开一副彩色图像,在上面随机的添加一些噪声。原始图像如下: ![这里写图片描述](https://box.kancloud.cn/2016-04-26_571f1db4085f8.jpg "") 核心的代码: ~~~ cv::Mat image = cv::imread("Q:\\test.jpg", CV_LOAD_IMAGE_COLOR); for(int k = 0; k < 1000; k++) { int i = rand() % image.cols; int j = rand() % image.rows; image.at<cv::Vec3b>(i, j)[0] = 255; image.at<cv::Vec3b>(i, j)[1] = 255; image.at<cv::Vec3b>(i, j)[2] = 255; } ~~~ 处理后的图像如下: ![这里写图片描述](https://box.kancloud.cn/2016-04-26_571f1db436d46.jpg "") 像上面这样每次用 at 函数时都指定类型很繁琐。这时可以利用 cv::Mat 的派生类,cv::Mat_ 类,这个类是模板类。在建立这个类的实例时就要指定类型,之后就无需每次使用时再指定类型了。下面是个例子。 ~~~ cv::Mat_ <cv::Vec3b> ima = image; cv::namedWindow("Origin image", cv::WINDOW_NORMAL); cv::imshow("Origin image", image); for(int k = 0; k < 1000; k++) { int i = rand() % ima.cols; int j = rand() % ima.rows; ima(i, j)[0] = 255; ima(i, j)[1] = 255; ima(i, j)[2] = 255; } ~~~ 这个代码处理后的效果是相同的。 上面的程序中有个 ~~~ ima = image; ~~~ 这里又涉及到 OpenCV 的一个特性,就是普通的矩阵拷贝操作都是所谓的浅拷贝。也就是说这样操作后 ima 和 image 共享相同的图像数据。 如果我们想要真正的复制图像数据。这时可以用 clone() 方法。类似下面的代码: ~~~ cv::Mat_ <cv::Vec3b> ima = image.clone(); ~~~ 这样之后 ima 和 image 就完全独立了。 ### 利用指针遍历图像像素 经常,我们的算法需要遍历图像的全部像素。这时用 at 函数就会很慢。更高效的访问图像像素的方式是利用指针。 简单的说,我们通常是去获得一行像素的头指针。如果图像是灰度的,则类似这样操作。 ~~~ uchar* data = image.ptr<uchar>(j); ~~~ 如果图像是 24 位彩色的,则可以这样: ~~~ cv::Vec3b * data = image.ptr<cv::Vec3b> (j); ~~~ 实际上,即使是彩色图像,也可以用一个 uchar 型指针去指向。只要我们自己去计算要访问的像素相对行首的位置偏移是多少。比如下面的函数,可以处理灰度图像,也能处理彩色图像,作用是缩减图像中使用到的颜色。 ~~~ void colorReduce(cv::Mat &image, int div=64) { int nl = image.rows; // number of lines // total number of elements per line int nc = image.cols * image.channels(); for (int j = 0; j < nl; j++) { // get the address of row j uchar* data= image.ptr<uchar>(j); for (int i = 0; i < nc; i++) { // process each pixel --------------------- data[i]= data[i] / div * div + div / 2; // end of pixel processing ---------------- } } // end of line } ~~~ 利用默认参数应用于我们的测试图像后得到的结果如下: ![这里写图片描述](https://box.kancloud.cn/2016-04-26_571f1db4667b6.jpg "") 上面的代码中有这么一行,是用来计算一行像素有多少个字节的。当然这个前提是每个channel 占用一个字节。 ~~~ int nc= image.cols * image.channels(); ~~~ 如果每个 channel 占用多个字节的话,上面的公式就是错误的了,这时我们可以这样计算。 ~~~ int nc = image.cols * image.elemSize(); ~~~ image.elemSize() 得到的是每个像素的字节数。乘以一行有多少个像素,正好就是一行有多少个字节。 上面的例子中,我们用了两重循环来遍历图像中的每一个像素。实际上,因为我们对每个像素进行的操作是相同的,我们根本不需要确定某个像素是哪一行的。因此上面的代码还可以进一步优化,只用一个循环来完成。 但是这时我们要特别注意,有些图像所占的内存空间是不连续的。在一行像素结束后,可能会空出一小块内存。之所以会这样是因为有些 CPU 的指令对数据有内存对其要求。这样虽然浪费了一些空间,但是运算起来会更快速。 图像所占内存是否是连续的可以利用 isContinuous() 来得到。如果是连续的则可以用一个循环将所有像素都处理完。下面是代码,这个代码兼顾了内存连续与不连续两种情况,内存不连续时就退化为两重循环: ~~~ void colorReduce2(cv::Mat &image, int div=64) { int nl = image.rows; // number of lines int nc = image.cols * image.channels(); if (image.isContinuous()) { // then no padded pixels nc = nc * nl; nl = 1; // it is now a 1D array } // this loop is executed only once // in case of continuous images for (int j = 0; j < nl; j++) { uchar* data = image.ptr<uchar>(j); for (int i = 0; i < nc; i++) { // process each pixel --------------------- data[i] = data[i] / div * div + div / 2; // end of pixel processing ---------------- } // end of line } } ~~~ 如果我们要获得图像数据的首地址,还可以这样: ~~~ uchar *data = image.data; ~~~ 对于二维图像数据来说,每行图像所占据的字节数由成员变量 step 来存储。因此: ~~~ data += image.step; ~~~ 使得 data 指向下一行图像的内存首地址。 当然,上面这些操作都是比较低级的指针操作,不建议使用。 ### 利用 iterators 来遍历图像数据 C++ 的标准模板库(STL)中大量的使用到了 iterator。OpenCV 也模仿 STL 显示了自己的一套 iterator。 OpenCV 中设计了 cv::MatIterator_ 类,这个类与 cv::Mat_ 类似,也是模板类。将这个类实例化时需要指定具体的类型。比如下面的代码: ~~~ cv::MatIterator_<cv::Vec3b> it; ~~~ 另一种使用方法如下: ~~~ cv::Mat_<cv::Vec3b>::iterator it; ~~~ 如果我们只是用 iterator 来读取像素值而不改变它,则可以用常量型 iterator. ~~~ cv::MatConstIterator_<cv::Vec3b> it; cv::Mat_<cv::Vec3b>::const_iterator it; ~~~ 上面的例子用 iterator 重写后代码如下: ~~~ void colorReduce3(cv::Mat &image, int div=64) { // obtain iterator at initial position cv::Mat_<cv::Vec3b>::iterator it = image.begin<cv::Vec3b>(); // obtain end position cv::Mat_<cv::Vec3b>::iterator itend = image.end<cv::Vec3b>(); // loop over all pixels for ( ; it!= itend; ++it) { // process each pixel --------------------- (*it)[0] = (*it)[0] / div * div + div / 2; (*it)[1] = (*it)[1] / div * div + div / 2; (*it)[2] = (*it)[2] / div * div + div / 2; } } ~~~ 这种方式有利也有弊,最大的缺点是这个代码只能处理 24 位真彩色图像。优点是无需关注内存是否连续的问题了。相对来说,利用 iterator 的代码的运算速度比直接指针操作还是要稍微的慢一点。 ### 各种方法的速度比较 上面介绍了几种访问图像像素的方法,在不考虑效率的前提下,这些方法都很好,可以实现同样的功能。但是在计算机视觉应用场景中,计算效率(运行速度)经常是我们必须要考虑的关键因素。 因此这里专门用一个小节来比较各种方法的运行速度。 为了完整性,下面也给出了一个用 at函数访问像素的 colorReduce 函数。 ~~~ void colorReduce0(cv::Mat &image, int div=64) { int nl = image.rows; // number of lines int nc = image.cols; for (int j=0; j<nl; j++) { for (int i=0; i<nc; i++) { // process each pixel --------------------- 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; // end of pixel processing ---------------- } // end of line } } ~~~ 回顾一下我们实现的几种方法。 - colorReduce0(): 使用 at 函数访问像素 - colorReduce1(): 两重循环,用指针访问像素 - colorReduce2(): 当图像内存连续时用一重循环访问所有像素 - colorReduce3(): 用 iterator 访问像素 利用 cv::getTickCount() 来计算各个函数的运行时间。 - colorReduce0(): 240579 - colorReduce1(): 22363 - colorReduce2(): 21202 - colorReduce3(): 77573 结果一目了然,colorReduce0 运行的最慢,其次是 colorReduce3。 colorReduce1 和 colorReduce2 相差的不多。因此,我们写程序时,应尽可能的采用 colorReduce2 或 colorReduce1 这样的用法。