OpenCV複習(二) 操作像素

主要內容:

  • 存取像素值
  • 使用指針遍歷圖像
  • 使用迭代器遍歷圖像
  • 編寫高效的圖像遍歷循環
  • 遍歷圖像和鄰域操作
  • 進行簡單的圖像算數
  • 定義感興趣區域

引言

存取、修改和創建圖像上個博客簡單複習了一下。

這個博客主要是如何操作圖像的基本元素,就是像素。

必須得瞭解下高效的處理方法,因爲每張圖片的像素可能很多(數以萬計)。

本質上來說,一張圖像是由數值組成的矩陣,cv::Mat這個數據結構也是由此而來。

矩陣的每一個元素代表一個像素。

存取像素值

要存取像素值,需要在代碼中指定元素所在的行和列。程序會返回相應的元素。

若圖像是單通道的,返回是單個數值;如果圖像是多通道的,返回值則是一組向量(Vector)。

來通過一個椒鹽噪聲函數,體驗一下對像素值的操作吧。

實現方法

首先是圖像相加。當我們需要一些圖像特效或者在圖像上疊加信息時,就需要用到圖像加法。

我們通過調用函數cv::add ,更準確地說時cv::addWeighted來完成圖像加法,因爲我們需要地是加權和。函數調用如下:

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

作用原理

創建一個函數,它的第一個參數是一張圖片,該函數會修改此圖像。

爲達到這個目的,我們使用傳引用的參數傳遞方式,

第二個參數是我們設定的白色像素點的數目:

void salt(cv::Mat &image,int n)
{
for(int k = 0;k < n;k++)
    {
        //rand()是隨機數生成函數
        int i = rand()%image.cols;
        int j = rand()%image.rows;

        if(image.channels()==1)
            {
                image.at<uchar>(j,i) = 255;
            }
        else if(image.channels()==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;
            }
    }
}

上面的函數由單層循環構成。每次循環將一個隨機選取的像素值設置爲255。

隨機選取的像素行號i和列號j是通過一個隨機函數得到的。rand(),這裏爲啥用對255的求餘操作呢?想一想

接下來還得檢查下到底是灰度圖還是彩色圖,怎麼檢查呢?cv::Mat的類成員函數channels()。

對於灰度圖,怎麼賦值,彩色圖怎麼賦值?看看代碼想一想。

看看主函數中怎麼操作吧

//打開圖像
cv::Mat image = cv::imread("boldt.jpg");
//調用函數增加噪點
salt(image,3000);
//顯示圖像
cv::namedWindow("Image");
cv::imshow("Image",image);

作用原理?

類cv::Mat有若干成員函數可以獲取圖像的屬性。

公有成員變量cols和rows給出了圖像的寬和高。成員函數at(int y,int x)可以用來存取圖像元素。

但是必須在編譯期知道圖像的數據類型,因爲cv::Mat可以存放任意數據類型的元素。

這也是這個函數用模板來實現的原因。

意味着,當調用該函數時,需要使用以下方式指定數據類型:

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

注意:一定要保證指定的數據類型和矩陣中的數據類型相符合。at方法本身不會進行任何數據類型的轉換。

對於彩色圖像,每個像素由三個部分組成:紅、綠和藍三個通道。

因此,一個包含彩色圖像的cv::Mat會返回一個由三個八位數組成的向量。OpenCV將此類向量定義爲cv::Vec3b,即由三個 unsigned char組成的向量。

這解釋了爲什麼存取彩色圖像像素的代碼可以寫成如下形式:

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

其中,索引值channel標明瞭顏色通道號。

類似地,OpenCV還有二元素向量類型和四元素類型(cv::Vec2b和cv::Vec4b)。

OpenCV同樣擁有針對其他數據類型的向量類型,如s代表short,i代表int,f代表float,d代表double。

所有這些類型都是使用模板類cv::Vect<T,N>定義的,其中T代表類型,N代表向量中的元素個數。

擴展閱讀

有時候使用cv::Mat的成員函數會很麻煩,因爲返回值的類型必須通過在調用時通過模板參數指定。

因此,OpenCV提供了類cv::Mat_,它是cv::Mat的一個模板子類。

在事先知道矩陣類型的情況下,使用cv::Mat_可以帶來一些遍歷。

這個類額外定義了一些方法,但是沒有任何成員變量,所以此類的指針或者引用可以直接進行相互類型轉換。

這個類重載了操作符(),允許我們可以通過它直接存取矩陣元素。因此,假設有一個uchar型的矩陣,我們可以這樣寫:

cv::Mat_<uchar> im2 = image; //im2指向image
im2(50,100) = 0;//存取第50行,100列

由於cv::Mat_的元素類型在創建實例的時候已經聲明,操作符()在編譯期就知道需要返回的數據類型。使用操作符()得到的返回值和使用cv::Mat的at方法得到的返回值完全一致,而且更加簡潔。

使用指針遍歷圖像

高效遍歷圖像非常重要。(因爲像素太多了)

我們先體驗下指針算數。(圖像遍歷循環方法)

準備工作

一個例子:減少圖像中地顏色數目??

直接截圖吧。

 

話說下面那個又乘以N,我有點看不懂。那就看代碼吧,

實現方法

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

用戶提供一個圖像和縮減因子。處理過程是In-place的,意味着輸入圖像的像素值會被此函數修改,

整個處理過程通過一個雙重循環來遍歷所有的像素值:

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);
//顯示圖像
cv::namedWindow("Image");
cv::imshow("Image",image);

作用原理:

在一個彩色圖像中,圖像數據緩衝區中的前三個字節對應圖像左上角像素的三個通道值,接下來的三個字節對應第一行的第二個像素,請以此類推。

注意OpenCVmore五年使用BGR通道順序。

一個寬爲W、高爲H的圖像需要由一個大小WxHx3個uchar構成的內存塊。

但是,出於效率的考慮,每行會填補一些額外像素。

這是因爲,如果行的長度是4或者8的倍數,一些多媒體處理芯片可以更高效地處理圖像。

每行的像素值數可以通過如下語句得到:

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

爲了簡化指針運算,cv::Mat提供了ptr函數可以得到 任意行的首地址。

ptr函數是一個模板函數,它返回第j行的首地址:

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

注意,在處理語句中,我們可以等效地使用指針運算符從一行移動到下一行,所以,我們也可以這麼寫:

*data++ = *data/div*div + div/2;

擴展

顏色縮減函數不知這一種。

我們再看一種更通用的版本,它允許用戶分別指定輸入和輸出圖像。

另外,圖像遍歷還可以通過利用圖像數據的連續性,使得整個過程更高效。

我們可以通過常規的指針運算來遍歷圖像。

直接用位運算的效率會很高,這裏有點不太懂~~~~

2. 使用輸入和輸出參數

上面我們寫的代碼,顏色變換是直接發發生在輸入圖像上的,稱之爲In-place變換。不需要額外的圖像來保存輸出結果,必要時候,這樣的做法可以節省內存。

但是,有時候我們並不希望原始圖像被改變。

這樣,我們需要再調用函數之前創建一份輸入圖像的拷貝。最簡單的創建一個圖像的“深拷貝”的方式是調用clone函數,如

//裝載圖像
image = cv::imread("boldt.jpg");
//克隆圖像
cv::Mat imageClone = image.clone();
//處理克隆圖像
//原始圖像保持不變
colorReduce(imageClone);
//顯示結果
cv::namedWindow("Image Result");
cv::imshow("Image Result",imageClone);

這種額外複製,可以通過一種實現技巧來避免。在這種實現中,給用戶選擇到底是否採用In-place的處理方式。函數的實現是這樣的:

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

注意,輸入圖像是通過常量引用傳遞的,這意味着這個圖像不會被函數修改。當選擇In-place的處理方式時,用戶可以將輸入輸出指定爲同一個變量:

colorReduce(image,image);

否則,用戶必須提供另外一個cv::Mat的實例,如:

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

注意,這裏必須檢查輸入圖像和輸出圖像的大小和元素類型一致。

cv::Mat的create成員函數內置了這個檢查操作。

如果需要根據新的尺寸和數據類型對一個矩陣進行重新分配,我們可以調用create成員函數。而且,如果新的指定的尺寸和數據類型與原有的一樣,create函數會直接返回,變更不會對本實例做任何更改。

所以,我們需要先調用create函數來創建一個與輸入圖像尺寸和類型相同的矩陣:

result.create(image.rows,image.cols,image.type());

注意:create函數創建的圖像的內存都是連續的,create函數不會對圖像進行填補。分配的內存大小爲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;i++)
        {
        //處理每個像素---------------
            data_out[i] = data_in[i]/div*div +div/2;
        //像素處理完成
        }//行處理結束
}

如果輸入輸出圖像是同一幅圖像,那麼上面代碼與我們之前的完全等價。

3. 高效遍歷連續圖像

怎樣利用圖像的連續性呢?

還是看代碼吧

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;
}
//對於連續圖像,本循環只執行一次
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;
        }
}
}

看了代碼,懂了什麼叫圖像的連續性。

 討論一下上面的代碼:通過isContinuous函數得知圖像沒有進行填補之後,我i們可以將寬設爲1,高度設爲WxH,從而消除外層循環。

注意我們也可以使用reshape方法來實現:

if(image.isContinuous())
{
    //no padded pixels
    image.reshape(1,image.cols*image.rows);
}
int nl = image.rows;//列數
int nc = image.cols*image.channels();

reshape不需要內存拷貝或者重新分配就能改變矩陣的維度。兩個參數分別爲新的通道數和新的行數。

矩陣的列數可以通過通過新的通道數和行數來自適應。

4. 底層指針運算

在類cv::Mat中,圖像數據以unsigned char形式保存在一塊內存中。這塊內存的首地址可以通過data成員變量得到。data是一個unsigned char型的指針,所以循環可以以如下方式進行:

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();

以上方式容易出錯。

使用迭代器遍歷圖像

面向對象編程中,遍歷數據通常是通過迭代器來完成的。

迭代器是什麼?

迭代器是一種特殊的類,它專門用來遍歷各個幾何中的各個元素 ,同時隱藏了在給定幾何上元素迭代的具體實現方式。

這種黑盒子做法使得遍歷集合更加容易。

此外,不管數據類型是什麼,我們都可以用相似的方式遍歷。

標準模板庫(STL)爲每個容器類都提供了迭代器。

OpenCV當然爲cv::Mat提供了與STL迭代器兼容的迭代器。

cv::Mat實例的迭代器可以通過創建一個cv::Mat Iterator_的實例來得到。

類似於子類cv::Mat_,下劃線意味着cv::Mat Iterator_是一個模板類。

之所以如此是由於通過迭代器來存取圖像的元素,就必須在編譯期知道圖像元素的數據類型。聲明方式如下:

cv::Mat Iterator_<cv::Vec3b> it;

另一種方式是使用定義在Mat_內部的迭代器類型:

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

兩種聲明迭代器的方法,仔細想一想。

這樣就可以通過常規的begin和end這兩個迭代器方法來遍歷所有像素了。

如果使用後一種聲明迭代器的方法,必須要使用對應的模板化版本(區代碼中體會吧)。

void colorReduce(cv::Mat &image,int div=64)
{
//得到初始位置的迭代器
cv::Mat_<cv::Vec3b>::iterator it = image.begin<cv::Vec3b>();
//得到終止位置的迭代器
cv::Mat_<cv::Vec3b>::iterator itend = image.end<cv::Vec3b>();
//遍歷所有元素
for (;it!=itend;++i)
{
    //處理每個元素------
    (*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。每個顏色分量可以通過操作符[]得到。

討論下吧

使用迭代器遍歷任何形式的集合都遵循同樣的模式。

首先創建一個迭代器特化版本的實例。

在上面的代碼中,就是 cv::Mat_<cv::Vec3b>::iterator(或者cv::Mat  Iterator_<cv::Vec3b>).

然後,使用集合初始位置(在上面的代碼中,指的是圖像的左上角)的迭代器對其進行初始化。

初始位置的迭代器通常是通過begin獲得的。

對於一個cv::Mat的實例,我們可以通過image.begin<cv::Vec3b>()來得到圖像左上角位置的迭代器。

當然也可以通過迭代器運算。例如想從圖像的第二行開始:

image.begin<cv::Vec3b>()+image.rows來初始化迭代器。

end方法得到的迭代器其實已經超出了集合。

一旦迭代器初始化完成後,我們就可以創建一個遍歷所有元素知道終止位置的循環。

典型的while循環如下:

while(it!=itend)
{
//處理每個像素-----
...
//處理像素完成-----
++it;
}

操作符 ++ 用來將迭代器從當前位置移動到下一個位置。當然,可以選擇用更大的步長,比如 it+=10每次將迭代器移動10px。

在循環體內部,可以我們可以用解引用操作符*來讀寫當前元素。讀操作使用 element =*it,寫操作使用*it = element。

注意,如果操作對象是const cv::Mat,或者像強調當前循環不會對cv::Mat的實例進行修改,那就應該創建常量迭代器。常量迭代器聲明如下;

cv::Mat ConstIterator_<cv::Vec3b> it;

或者

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

對比下上面的普通迭代器的聲明,記住某些東西。

看下下面的代碼,領悟一些東西吧

cv::Mat_<cv::Vec3b> cimage = image;
cv::Mat_<cv::Vec3b>::iterator it = cimage.begin();
cv::Mat_<cv::Vec3b>::iterator itend = cimage.end();

編寫高效的圖像遍歷循環

關鍵詞,效率。

但是效率雖然重要,也不能影響性能和可維護性。(這部分先掠過)

遍歷圖像和鄰域操作

舉個例子,對圖像進行銳化。基於拉普拉斯算子(後面會學到)。

衆所周知,將一幅圖像減去它經過路普拉斯濾波之後的圖像,這幅圖像的邊緣部分將被放大,即細節部分更加銳利。這個銳化算子的計算方式如下:

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

實現方式

這次圖像處理不能以In-place方式進行了,必須提供一個輸出圖像。

圖像遍歷用到了三個指針:一個指向當前行,一個指向向上一行,一個指向向下一行。由於每個像素的計算都需要它的上下左右四個鄰居像素,所以不能對圖像的第一行、最後一行、第一列和最後一列進行計算。

且看下面循環體的代碼:

void sharpen(const cv::Mat &image,cv::Mat &result)
{
//如有必要則分配圖像
result.create(image.size(),image.type());
for(int j=1;j<image.rows-1;j++)
{
//除了第一行和最後一行以外的所有行
const uchar* previous = image.ptr<uchar>(j-1);  //上一行
const uchar* current = image.ptr<uchar>(j);  //當前行
const uchar* next = image.ptr<uchar>(j+1);  //下一行
uchar* output = result.ptr<uchar>(j);  //輸出行
for (int i = 1;i<image.cols-1;i++)
{
*output++ = cv::saturate_cast<uchar>(5*current[i]-current[i-1]-current[i+1]-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));
}
}

仔細品味下上面的代碼。

作用原理

爲了讀寫當前像素上下兩行的相鄰元素,必須同時定義額外的指針來指向上下兩行。

這兩個指針與當前行的指針同步增長,我們才能在遍歷時同時讀寫這三行像素。

模板函數cv::saturate_cast被用來對計算結果進行截斷。

應改變。

對於一個三通道的彩色圖像,我們需要使用cv::Scalar(a,b,c)來指定像素三個通道的目標值。

擴展閱讀

當計算是在像素鄰域上進行時,通常可以將其用一個核矩陣表示。核描述了牽扯到的像素在計算過程中是如何組合從而得到目標值的。

想象一下本例中的銳化濾波器。

將這麼一個核應用到圖像上,就是信號處理中卷積的基礎。一個核定義了一個圖像濾波器。由於濾波是一種常規的圖像處理方法,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效率更高。

進行簡單地圖像算數

作用原理

所有的二元算數函數工作方式都是一樣的,它接受兩個輸入變量和一個輸出變量。

在一些情況下,還需要指定權重作爲運算中的標量因子。

每種函數都有幾個不同的形式,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);

對於某些函數,可以指定一個圖像掩模:

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

理解下什麼是掩模。。。

如果指定了圖像掩模,那麼運算會只在掩模對應像素不爲null的像素上進行(掩模必須是單通道的)。

除了add之外,cv::subtract、cv::absdiff、cv::multiply和cv::divide函數也有幾種不同的變形。

OpenCV中還提供了位運算函數:cv::bitwise_and、cv::bitwise_or、cv::bitwise_xor、cv::bitwise_not。

cv::min和cv::max也很有用,它們用來找到矩陣中最小或者最大的像素值。

所有的運算都使用cv::saturate_cast來保證輸出圖像的像素值在合理範圍內(不會向上或者向下溢出)。

參與運算的圖像必須相同的大小和類型。

此外,還有一些只接受一個輸入的操作符,如cv::sqrt、cv::pow、cv::abs、cv::cuberoot、cv::exp和cv::log。

OpenCV幾乎擁有所有我們需要的圖像操作運算符。

定義感興趣區域

我們現在想把一張圖片放到另一張圖片上。

由於cv::add要求 兩個輸入圖像具有相同的尺寸,所以我們不能直接使用cv::add,二十需要在使用之前定義感興趣區域(ROI)。只要感興趣區域的大小與LOGO圖像的大小相同,cv::add就能夠工作。ROI的位置決定了LOGO圖像被插入的位置。

實現方法

首先要定義ROI。一旦定義之後,ROI就可以當作一個普通的cv::Mat實例來處理。關鍵之處是,ROI和它的父圖指向同一塊內存緩衝區。插入LOGO的操作可以通過如下代碼完成:

//定義圖像ROI
cv::Mat imageROI;
imageROI = image(cv::Rect(385,270,logo.cols,logo.rows));
//插入logo
cv::addWeighted(imageROI,1.0,logo,0.3,0.,imageROI);

理解下上面的代碼。

作用原理

定義ROI的一種方法是使用cv::Rect。顧名思義,cv::Rect表示一個矩形區域。指定矩形的左上角座標(構造函數的前兩個參數)和矩形的長寬(構造函數的後兩個參數)就可以定義一個矩形區域。

另一種定義ROI的方式是指定感興趣行或列的範圍(Range)。Range是值從起始索引到終止索引(不包含終止索引)的一段連續序列。cv::Range可以用來定義Range。

代碼:

cv::Mat imageROI = image(cv::Range(270,270+logo.rows),cv::Range(385,385+logo.cols));

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章