主要內容:
- 存取像素值
- 使用指針遍歷圖像
- 使用迭代器遍歷圖像
- 編寫高效的圖像遍歷循環
- 遍歷圖像和鄰域操作
- 進行簡單的圖像算數
- 定義感興趣區域
引言
存取、修改和創建圖像上個博客簡單複習了一下。
這個博客主要是如何操作圖像的基本元素,就是像素。
必須得瞭解下高效的處理方法,因爲每張圖片的像素可能很多(數以萬計)。
本質上來說,一張圖像是由數值組成的矩陣,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));