我們知道,opencv是一個非常優秀的計算機視覺圖像處理算法庫,它給我們封裝了好多基本的圖像處理算法,免去了讓我們重複造輪子的麻煩,今天我就用傳統算法,根據實際工程項目,手把手教你做一個最典型的産品缺陷檢測項目案例,雖然這個案例與實際生産還存在一定的差距,但是這個檢測流程已經很接近實際生産了。
我們先看一下測試結果:
這個檢測的主要需求就是,根據視訊流中流水線上的産品,通過每一幀圖像,檢測出每個産品的缺陷屬性,并把有缺陷的産品給标注出來,并注明缺陷類型。
項目開始之前,我先請大家安裝一下opencv庫,具體安裝教程請參考我上一篇文章:手把手教你安裝OpenCV與配置環境
在項目開始之前,我們先思考一下整個檢測流程的架構:
1,首先我們要抓取視訊流中的每一幀圖像
2,在抓取得每一張圖像中,首先要定位出這張圖檔中的每一個産品,找出這件産品的邊緣
3,分析所抓取每個産品的屬性,分析其缺陷類型的特征,通過算法形式來對缺陷進行歸類。
上面就是整個檢測的基本流程,其實,在利用傳統算法進行檢測的時候,基本流程不會差别太大,所用算子也基本大緻相同。在講解這個檢測方法之前,我先來講解幾個我們經常用到的算子:
findContours():
這個函數是查找整張圖檔中所有輪廓的函數,這個函數很重要,在處理圖像分割尤其是查找整個圖像中邊緣分割時候少不了它,是以我重點說明一下,首先它的函數原型是這樣的:
// 查找整張圖檔的所有輪廓
findContours(InputOutputArray image,
OutputArrayOfArrays contours,
OutputArray hierarchy,
int mode,
int method,
Point offset=Point());
我先來講解一下這個函數的主要參數:
1),image代表輸入的圖像矩陣;
2),第二個參數contours代表輸出的整張圖檔的所有輪廓,由于輪廓坐标是一組二維坐标構成的一組資料集,是以,它的聲明是一個嵌套了一層坐标資料集向量的向量:
//參數contours的聲明
vector<vector<Point>> contours;
3),第三個參數hierarchy顧名思義,字面的意思是層級關系,它代表各個輪廓的繼承關系,也是一個向量,長度和contours相同,每個元素和contours的元素相對應,hierarchy的每一個元素是一個包含四個整形數的向量,也即:
//參數hierarchy的聲明
//Vec4i 代表包含四個元素的整形向量
vector<Vec4i> hierarchy;
那麼這裡的hierarchy究竟代表什麼意思呢,其實對于一張圖檔,findContours()查找出所有的 個輪廓後,每個輪廓都會有自己的索引, ,而hierarchy[i]裡面的四個元素 分别代表第 個輪廓:後一個輪廓的序号、前一個輪廓的序号、子輪廓的序号、父輪廓的序号,有嚴格的順序。
為了更加直覺說明,請看下面一張圖檔:
上面圖檔中,是一張圖檔中的所有輪廓,分别用序号0、1、2、3來表示,其中0有兩個子輪廓,分别是1和3,而1則有一個父輪廓也有一個子輪廓,分别是0與2,它們的繼承關系基本上就是這樣,我們再做進一步的分析:
0号輪廓沒有同級輪廓與父輪廓,但由兩個子輪廓1與3,根據前面提到的四個元素的順序分别是:下一輪廓、前一輪廓、子輪廓、父輪廓,是以其輪廓繼承關系向量hierarchy為 ,這裡的-1表示無對應關系,而第三個元素1則代表0号輪廓的一個子輪廓為1;
同樣,1号輪廓有同級輪廓3(按照索引是後一輪廓,但無前一輪廓),也有子輪廓2,父輪廓0,是以它的繼承關系向量hierarchy為 ;
按照同樣的方法,我們還可以得到2号輪廓的繼承關系向量hierarchy為: ;3号輪廓繼承關系為 .
4),第四個參數mode,代表定義輪廓的檢索方式:
取值一:CV_RETR_EXTERNAL,隻檢測最外圍輪廓,包含在外圍輪廓内的内圍輪廓被忽略。
取值二:CV_RETR_LIST,檢測所有的輪廓,包括内圍、外圍輪廓,但是檢測到的輪廓不建立等級關系,彼此之間獨立,沒有等級關系,這就意味着這個檢索模式下不存在父輪廓或内嵌輪廓,是以hierarchy向量内所有元素的第3、第4個分量都會被置為-1.
取值三:CV_RETR_CCOMP,檢測所有的輪廓,但所有輪廓隻建立兩個等級關系,外圍為頂層,若外圍内的内圍輪廓還包含了其他的輪廓資訊,則内圍内的所有輪廓均歸屬于頂層。
取值四:CV_RETR_TREE,檢測所有輪廓,所有輪廓建立一個等級樹結構。外層輪廓包含内層輪廓,内層輪廓還可以繼續包含内嵌輪廓。
5),第五個參數method,用來定義輪廓的近似方法:
取值一:CV_CHAIN_APPROX_NONE,儲存物體邊界上所有連續的輪廓點到contours向量内。
取值二:CV_CHAIN_APPROX_SIMPLE,僅儲存輪廓的拐點資訊,把所有輪廓拐點處的點儲存入contours向量内,拐點與拐點之間直線段上的資訊點不予保留。
取值三:CV_CHAIN_APPROX_TC89_L1;
取值四:CV_CHAIN_APPROX_TC89_KCOS,取值三與四兩者使用teh-Chinl chain 近似算法。
6),第六個參數,Point類型的offset參數,這個參數相當于在每一個檢測出的輪廓點上加上該偏移量,而且Point還可以是負數。
在實際使用中,我們需要根據實際情況來標明合适的參數,比如第五個參數,如果你使用CV_CHAIN_APPROX_SIMPLE這個參數,那麼傳回的坐标向量集是僅包含拐點資訊的,但是在上一篇文章 手把手教你編寫傅裡葉動畫 為了得到輪廓的完整坐标資料,我必須使用CV_CHAIN_APPROX_NONE參數。請大家在實際使用過程中根據自己的實際情況來選擇合适的參數
再來說下moments這個函數,其實moment在實體學中表示“力矩”的感念,“矩”在實體學中是表示距離和實體量乘積的實體量,表示物體的空間分布(至于漢語為什麼把它翻譯成矩這個字,我也不太明白,不知道是不是李善蘭翻譯的)。而在數學中,“矩”是機率論中的一個名詞,它的本質是數學期望,我們從它的定義中就可以看出:
假設 是離散随機變量, 為常數, 為正整數,如果 存在,則稱 為 關于 的 如果 時,稱它為 階原點矩;如果 時,稱它為 階中心距。我們知道數學期望的計算公式是
其中 是 的機率密度,從這個公式中看出,無論是在實體上,還是在數學上,矩都有種某個量與“距離”乘積的概念,是以,我們再直覺一點,直接把“矩”抽象成為下面一個公式: 當
有了這方面知識,我們再回顧一下 圖像進行中的矩,對于圖像中的矩是圖像像素強度的某個特定權重平均(矩),或者是這樣的矩的函數,因為圖像是二維的,對于二進制有界函數 它的 階矩為: 如果把圖像看成是一塊品質密度不均勻的薄闆,其圖像的灰階分布函數 如零階矩表示它的總品質;一階矩表示它的質心;二階矩又叫慣性矩,表示圖像的大小和方向,當然,對于圖像來說,它的像素坐标是離散的,是以積分可用求和來計算,圖像的零階矩:
顯然,對于二值化圖像,由于 非 即 圖像的零階矩表示區域内的像素點數,也即區域的面積。
當 時,它的一階矩變為:它是橫坐标
同樣,當 時,它的一階矩變為:它是縱坐标 像素值的乘積,如果是二值化圖像,每個像素點所有縱坐标的和。
根據實體意義,我們還可以得出輪廓的質心計算公式: 其中
為了獲得矩的不變特征,往往采用中心矩或者歸一化的中心距,在圖像中中心矩的定義為:
Moments moments(
InputArray array,
bool binaryImage = false
);
第一個參數是輸入一個數組,也就是我們上面計算出來的contours向量,第二個參數預設為false.
我們再來看整個程式的編寫方法:
首先我們使用opencv中的VideoCapture類來進行讀取視訊流,并抓取視訊中的每一幀圖像:
Mat img; //定義圖像矩陣
VideoCapture cap("1.mp4");
cap.read(img);
這樣我們便把圖像讀取到img變量中了,當然,為了叙述友善,這隻是其中的部分代碼,實際你在運作過程中為了不斷讀取,外面還要嵌套個while(true)的循環。
讀取到一幀圖像後,我們需要對這張圖像進行一些預處理,一般情況下,無論是傳統算法還是深度學習算法,預處理的方法基本上都是一樣的,比如灰階轉換,圖像濾波,二值化處理,邊緣提取等等。在這裡也一樣,我們先對抓取到的圖像進行灰階轉換,灰階圖像有諸多好處,比如它隻包含亮度特征,而且一般情況下它是單通道的,256色調色闆,一個像素占隻用一個位元組,儲存起來非常整齊等等。
我們最終要的預處理結果是将其轉化為二值化圖像,二值化圖像是灰階圖像的一種特殊情況,它的像素亮度隻有0與255兩種情況,也就是非黑既白,在圖像進行中,二值圖像占有非常重要的地位,比如,圖像的二值化有利于圖像的進一步處理,使圖像變得簡單,使後面的計算量大大減少,而且還能能凸顯出感興趣的目标的輪廓。
灰階處理的算子接口為:
void cv::cvtColor
(InputArray src,
OutputArray dst,
int code,
int dstCn = 0)
這個函數第一個參數src為輸入圖像,第二個參數dst為輸出圖像,第三個參數為需要轉換的色彩空間,一般我們選取CV_BGR2GRAY即可,第四個參數預設為0.
灰階轉化後,我們再進行二值化處理,這個算子比較簡單,函數原型為:
void cv::threshold
(InputArray src,
OutputArray dst,
double thresh,
double maxval,
int type)
同樣,這個函數前兩個參數分别為輸入、輸出參數;第三個參數thresh為設定的門檻值;第四個參數maxval為設定的最大值,一般選取255;第五個參數type為門檻值類型,表示當灰階值大于(或小于)門檻值時将該灰階值賦成的值,其中最常用的有兩個,分别是THRESH_BINARY,THRESH_BINARY_INV:
如果是THRESH_BINARY的話,表示當圖像亮度值大于門檻值thresh的話,将其設定為maxval,否則設定為0: 而 THRESH_BINARY_INV 則恰恰相反,當亮度大于門檻值的時候,将其設定為0,否則設定為maxval: 我們再回到視訊中,先截取一張圖檔,我們使用下面梁行代碼先後對其進行灰階、二值化處理:
//灰階處理
cvtColor(img, grayImage, CV_BGR2GRAY);
//二值化處理
threshold(grayImage, binImage,
128, 255, CV_THRESH_BINARY);
下面第二、第三分别是灰階、二值化處理後的結果,可見當原圖經過二值化分割後,圖像增強明顯,明暗分離,更重要的是原本模糊的劃痕變得非常清晰,特征非常明顯,這對我們下一步提取劃痕輪廓進而做進一步的分析帶來非常大的友善:
經過預處理後,下面我們就要想辦法如何判定一件産品是具有劃痕或者某種缺陷的,觀察上面第三章圖檔,最直覺的差別就是不良的産品中附帶黑色長條斑點,如果我們對整個圓區域進行輪廓查找的話,它的輪廓數量應該大于0才對,反之等于0. 但是首先我們要知道如何定位每一個産品的位置,并找出它的輪廓,是以,我們先對每一張預處理後的binImage圖像進行輪廓查找:
//定義輪廓點集
vector<vector<Point>> contours;
//定義繼承關系
vector<Vec4i> hierarchy;
//進行輪廓查找
findContours(binImage,
contours,
hierarchy,
CV_RETR_EXTERNAL,
CV_CHAIN_APPROX_NONE,
Point(0, 0));
注意,在首次查找的時候,我們關心的是整個産品的輪廓,或者産品的質心坐标在哪裡,是以我們隻需要抓取最外輪廓即可,是以第四個參數我們設定為CV_RETR_EXTERNAL,上面已經解釋過了,意為隻檢查最外圍輪廓,目的是為了方面後面從原圖中截取産品輪廓。為了友善後面計算,也為了計數友善,我先定義了一個産品類型的結構體myproduct,裡面包含外接Rect,質心cx,xy,還有對應索引index. 為了去除重複,每次計算每個輪廓的位置,再根據目前幀裡面的所有産品坐标與上一幀圖像中産品裡面的坐标進行對比,如果有兩個産品在上下兩幀中偏移的距離很小,那麼我們可以認為它是同一件産品,否則就是新出現的産品,就需要放入我們的product容器裡面,這其實在實際抓拍檢測過程中,比較常用的去重方法:
for (int i = 0; i < contours.size(); i++)
{
double area = contourArea(contours[i]);
//小于18000我們認為是幹擾因素
if (area > 18000)
{
Moments M = moments(contours[i]); //計算矩
double center_x = M.m10 / M.m00;
double center_y = M.m01 / M.m00;
Rect rect = boundingRect(contours.at(i));
bool isNew = true;
//必須保證産品完全出來
if (center_x > 100)
{
for (int i = 0; i < product.size(); i++)
{
double h = abs(center_x - product[i].cx);
double v = abs(center_y - product[i].cy);
if(h < 25 && v < 25)
{
isNew = false;
//說明不是新的,那就要更新一下
product[i].cx = center_x;
product[i].cy = center_y;
product[i].rect = rect;
}
}
if (isNew)
{
myproduct p;
p.cx = center_x;
p.cy = center_y;
p.rect = rect;
idx++;
p.index = idx;
product.push_back(p);
}
}
}
函數計算後,product裡面就包含了我們所要目前幀中的所有産品對象,包括質心、外接矩形等等,然後根據最大外接矩形的坐标,再回到原圖中img中,把這個矩形包含的産品輪廓給裁剪出來,做進一步分析,裁剪的方法很簡單,我們根據上面程式傳回的rect,直接在原圖像img中進行裁剪即可,即可得到右邊小圖像:
Mat roi = img(rect);
拿到上圖右邊截取的小圖像roi, 對其再做進一步的分析,進而能夠抓取到缺陷劃痕的輪廓,方法還是先灰階處理再二值化操作,然後查找輪廓。但注意,這次查找輪廓的對象是我們截取的roi區域,為了分析roi區域内所有的缺陷,我們必須要找出對象中的所有輪廓,是以findcontours參數中mode參數我們就不能使用CV_RETR_EXTERNAL了,而要改用CV_RETR_TREE:
Mat grayImage;
Mat binImage;
cvtColor(roi, grayImage, CV_BGR2GRAY); //灰階處理
threshold(grayImage,
binImage,
128, 255,
CV_THRESH_BINARY); //二值化處理
vector<vector<Point>> contours;
vector<Vec4i> hierarchy;
//檢測出roi内所有輪廓
findContours(binImage, contours,
hierarchy,
CV_RETR_TREE,
CV_CHAIN_APPROX_NONE,
Point(0, 0));
這次傳回的contours其實還包含單個産品的最外輪廓,顯然不是我們想要的,對其進行适當過濾,過濾的方法很簡單,還是根據面積來進行判斷,進而再把缺陷部分給提取出來,我們可以用下面兩行代碼,将提取到的輪廓全部繪制出來:
//最後一個參數如果為負值CV_FILLED則為填充内部
//第三個參數如果為-1則繪制所有的輪廓
drawContours(roi,
contours,
-1,
Scalar(0, 0, 255), 0);
下一步我們就要想辦法把産品内部的一個劃痕輪廓給單獨提取出來,也就是找一把“剪刀”,将它最大外接矩形給剪切出來,同時為了防止劃痕周圍顔色的幹擾,我們還需要将剩餘部分塗成黑色,方法很簡單,我們需要單獨定義一個mask掩模,再使用fillPoly函數将這塊區域填充到我們的mask上面即可。我們看一下這個函數的原型:
//填充多邊形函數
void fillPoly(InputOutputArray img,
InputArrayOfArrays pts,
const Scalar& color,
int lineType = LINE_8,
int shift = 0,
Point offset = Point());
第一個參數img是輸入的參數,也就是我們的mask, 第二個參數是輪廓點資料集,第三個參數為填充的顔色。但是這裡邊需要注意,第二個參數如果我們直接把上面那張提取輪廓得到的劃痕輪廓資料集contours[index]輸入進去是不行的,會報錯,我嘗試了多次,發現可能fillPoly函數第二個參數隻接收vector<vector<Point>>類型的資料集,是以我們在使用之前需要把劃痕的最大外接矩形使用findContours函數再做一次輪廓查找,得到缺陷輪廓contours_defect,方法跟上面的一樣,事先先灰階處理,再門檻值分割。
//定義掩膜,CV_8UC1代表單通道
Mat mask = Mat::zeros(grayImage.size(),
CV_8UC1);
//把缺陷輪廓填充掩膜
fillPoly(mask,
contours_defect,
Scalar(255, 255, 255));
這裡面在定義mask的時候,盡量避免使用白色背景,因為我發現在白色背景使用findContours的時候會把整個背景圖檔的外框也當成一個輪廓,無形之中給我們帶來了不必要的麻煩。填充後的mask我們再與原圖中的缺陷外接矩形輪廓做位與運算,最終将原圖中的缺陷輪廓摳出來:
//圖像按位與運算
bitwise_and(grayImage, mask, result);
上圖中第三幅就是我們要的摳圖效果,裡面的灰色圖案就是劃痕形狀圖像背景是純黑色,為我們下一步分析免去了諸多幹擾。
接下來就是如何分析這張缺陷了,并把缺陷類型給标注出來。我們再看一下另外一種缺陷,比如掉漆的缺陷:
根據肉眼判斷,顯然左側的掉漆缺陷背景顔色更黑一點,這是他們兩種缺陷的最大差別,是以我們可以采用灰階直方圖的算法對其進行分析,進而判斷是何種類型。所謂灰階直方圖,就是以橫坐标為像素的亮度,縱坐标為對應像素的數量,将整張圖檔對應的像素分布給計算出來,顯然,如果是掉漆缺陷的話,它的低亮度像素值(偏向黑色)占的比重更多一些,而如果是劃痕的話,整體像素亮度區間會分布的高一些,我們就按照這個方法來定義缺陷的類型。在opencv中計算灰階直方圖的算法已經給我們封裝好了,我們來看一下函數的原型:
//計算灰階直方圖
void calcHist(
const Mat* images, //輸入圖像指針
int nimages, //輸入圖像的個數
const int* channels, //需要統計的第幾通道
InputArray mask, //掩膜
OutputArray hist, //輸出的直方圖數組
int dims, //需要統計直方圖通道的個數
const int* histSize, //直方圖分成區間個數指針
const float** ranges, //統計像素值的區間
bool uniform = true, //是否對得到的直方圖數組進行歸一化處理
bool accumulate = false); //在多個圖像時,是否累計計算像素值得個數
用這個函數計算後,我們需要稍微再加一點分析,将分布直方圖對應像素出現的機率給計算出來即可:
MatND hist;
//256個,範圍是0,255.
const int histSize = 256;
float range[] = { 0, 255 };
const float *ranges[] = { range };
const int channels = 0;
calcHist(&result, 1, &channels,
cv::Mat(), hist, 1,
&histSize, &ranges[0]);
float *h = (float*)hist.data;
double hh[256];
double sum = 0;
for (int i = 0; i < 256; ++i)
{
hh[i] = h[i];
sum += hh[i];
}
最後根據出現的機率區間來對缺陷進行類型定義,值得一提的是,下面兩個門檻值需要根據實際情況進行調整:
double hist_sum_scratch = 0;
double hist_sum_blot = 0;
for (int i = 90; i < 135; i++)
{
hist_sum_scratch += hh[i];
}
hist_sum_scratch /= sum;
for (int i = 15; i < 90; i++)
{
hist_sum_blot += hh[i];
}
hist_sum_blot /= sum;
int type = 0;
if (hist_sum_scratch > 0.1)
{
type = 1; //劃痕類型
}
if (hist_sum_blot > 0.3)
{
type = 2; //掉漆類型
}
好了,上面就是整個項目的檢測流程,在這裡我們使用的是傳統算法,其實在利用傳統算法進行缺陷檢測基本流程都差不多,最終無非是根據像素的分布的特征進行分類,在實際場景比較單一的情況下使用起來它的魯棒性還算可以,比如這個視訊裡面,流水線顔色背景是黑色的,場景模式也比較單一,用起來不會出大問題。但是如果一旦遇到實際場景比較複雜的情況下,傳統算法用起來就它的局限性就非常大,舉個例子,你敢保證所有的缺陷都是這兩種,你敢保證明際生産情況下所有缺陷類型直方圖分布嚴格按照自己規定的門檻值進行的?顯然實際場景的複雜程度要遠遠超過我們的所能想到的,這個時候就要用到深度學習算法進行檢測了,深度學習算法相比傳統算法有諸多優點,諸如它抛開了像素層面的繁瑣分析,隻要前期資料量夠大,它就能适應各種複雜的環境等等。關于利用深度學習算法進行缺陷檢測方法,我會在後面的篇章中進行講解,感謝大家關注。另外,由于篇幅限制,源代碼我沒附完整,但是如果你想要的話,可以關注我的公衆号,在的對話框内直接回複001三個字,我會把源代碼與素材都發送給你,友善你下去學習研究。
—THE END—