2014-10-25

[OF]黑白吧!長頸鹿!(下)

為了避免自己一直欠著就忘了寫
還是早點把這個"黑白漫畫"(sketch)做一個了斷吧!
前情提要請參考上集:

===========分隔線===========

上集有提到,這兩篇的作法都是先有Photoshop的處理
再想辦法將這段Photoshop流成轉為程式
不同於上集參考網路教學,下集則是完全靠Art同事對影像處理的敏感度完成!
當初一步一步記下他用了photoshop哪些功能,再一步一步用程式實作來逼近
雖然有捨棄到一些步驟,但個人覺得結果比上集的更接近Comic一點!
老樣子,看看結果先...

為了方便比較,當然還是拿出相同的長頸鹿做為範例了!



相較於上集的作法,這次的功能相對起來更複雜了一些
就結果來看,並不像上次一樣是純粹的黑白影像,比較像是貼上網點之後的結果吧!!
只不過,跟上次那種隨便跑起來都60fps的情況比起來
這次的部份功能是會有效能問題要另外克服的!
相信丟到GPU去做會有更好的結果

回到重點
同樣的,用示意圖與條列步驟方式快速瞭解一下流程:

  1. 將原始影像轉為灰階
  2. 對灰階影像調整Levels(色階),得到調整後的影像L
  3. 對影像I套入Posterize(色調分離)得到影像G
  4. 對影像I做Edge Detection(邊緣偵測)
  5. 將影像G減去邊緣偵測的結果就可得到影像M
  6. 對影像M進行Smart Blur(智慧模糊)
接下來就針對每一個步驟來做簡單的介紹

1.將原始影像轉為灰階
最一開始的Gray Level就不提了,單純的灰階化!
雖然這個作法似乎能套用到彩色圖上(?)

2.對灰階影像調整Levels(色階),得到調整後的影像L
色階的功能是在於重新調整影像亮暗部份的分配
(一般來說其實多半都是用"曲線")
Photoshop的介面長這樣:


Photoshop中,中間的比例值範圍為9.99~0.01
為了方便,程式實作上就設為0.0~1.0
假設高低值分別是HL,中間比例為m,因此中間值M = ((H - L) * m)I為輸入的Intensity(灰階值)
那處理結果f(I)以公式表示為:


以程式的效能考量,一次把Intensity 0~255的對應的f(I)都先建在array中
在使用上就可以以Lookup table的方式快速得到對應的值
此部份的程式碼如下:

1:  void testApp::UpdateLevelsLookupTable()  
2:  {  
3:       const uint8 ucMiddleLevel_ = (uint8)floor(abs(_ucLevelsHigh - _ucLevelsLow) * _fLevelsMiddle);  
4:       const uint8 ucMiddleToLow_ = ucMiddleLevel_ - _ucLevelsLow;  
5:       const uint8 ucHighMiddleMiddle_ = _ucLevelsHigh - ucMiddleLevel_;  
6:       for(int idx_ = 0; idx_ < 256; ++idx_)  
7:       {  
8:            if(idx_ <= _ucLevelsLow)  
9:            {  
10:                 _aucLookupTable[idx_] = 0;  
11:            }  
12:            else if(idx_ >= _ucLevelsHigh)  
13:            {  
14:                 _aucLookupTable[idx_] = 255;  
15:            }  
16:            else  
17:            {  
18:                 if(idx_ < ucMiddleLevel_)  
19:                 {       
20:                      _aucLookupTable[idx_] = (uint8) floor((float)(idx_ - _ucLevelsLow)/ucMiddleToLow_ * 127);  
21:                 }  
22:                 else  
23:                 {  
24:                      _aucLookupTable[idx_] = (uint8) floor( (float)(idx_ - ucMiddleLevel_)/ucHighMiddleMiddle_ * 128 + 127);  
25:                 }  
26:            }  
27:       }  
28:  }  

調整完後的結果如下:


3.對影像I套入Posterize(色調分離)得到影像G
簡單來說,所謂的Posterize就是將原先0~255的值分成k等份的結果
若k = 2,就跟Threshold的結果一樣,會的到0, 255兩種值
若k = 3,則是分為0~84, 85~170, 171~255


以上是k = 7的結果,可以看到原先背景的細節通通被規類為相同顏色
這個效果主要就是用來製造海報效果的,詳細的可以參考wiki

程式的部份跟Levels一樣,都是採用Lookup table的方式實作,
篇幅考量,這裡就不列程式碼了

4.對影像I做Edge Detection(邊緣偵測)
說到Edge Detection,這就是一門可以多花個幾篇好好介紹的大學問了
簡單一句話描述就是:
尋找Intensity變化強烈的地方
一般來說都是透過gradient(梯度)來去判斷,因此就會牽扯到微分...
講到這裡我相信不是對Image Processing有興趣的人應該半放棄了
不過不用擔心,我們有強大的OpenCV,它會幫我們省去掉裡頭的技術細節!

Canny Edge
一般來說講到Edge Detection,直覺想到的就是最多人使用的Canny Edge
它有著低錯誤率、最小化的邊緣等優點
另外因為知明度高,相對著網路上的資源就很多
OpenCV的官網就有一篇教學在講怎麼使用[連結]
先實際看一下程式碼的部份:

1:  blur( oMatChangeLevelsImg_, oMatCanny_, cv::Size(3,3));  
2:  cv::Canny(oMatCanny_, oMatCanny_, _fCannyThreshold, _fCannyThreshold * _fThresholdRadio);            

1:去除雜訊
為了去除雜訊,一般的都會先對影像做一次模糊運算,這裡與官方的教學一樣使用3x3的Kernel

2:計算Canny Edge
OpenCV提供的Canny Edge共有三個參數可以控制,分別是低、高 Threshold以及計算gradient時用的Kernel Size
Canny在判斷Edge時會用H, L兩個Threshold來判斷,這個技術稱為Hysteresis
gradient高於H的會被判斷為Edge,低於L會被視為Not Edge,介在H跟L中間的則處在一種待確定的狀態
若待確定的點與判斷為Edge的點有所相連,就會被視為Edge,反之,就被歸列為Not Edge

以下是做完Canny Edge的結果:

可以看到Canny Edge在尋找邊緣這件事情上處理的很好,大部份的Edge也有被連接上。
但做為漫畫風的描邊來說,這些Edge有點太細了
我的解法是透過Dilation(膨脹)的方式將線變粗,關於Dilation請參考往這邊走
至於結果好不好...我想在看到我還試了別的方法就知道結果並不是想像中的滿意
這點到下一個階段再討論

Sobel Edge
Sobel Edge是一種計算gradient,並根據計算的數值決定一個Threshold來找出Edge的方法
(其實OpenCV的Canny中,計算gradient就是用Sobel Edge)
因為沒有後續的處理,所以Sobel Edge找出來的相對起來就比較雜,也比較不連續
實際透過OpenCV的程式碼如下:

1:  void testApp::SobelEdgeDetection(cv::Mat& oMat)  
2:  {  
3:       int ddepth = CV_16S;  
4:         
5:       // Generate grad_x and grad_y  
6:       cv::Mat grad_x, grad_y;  
7:       cv::Mat abs_grad_x, abs_grad_y;  
8:    
9:       // Gradient X  
10:       cv::Sobel( oMat, grad_x, ddepth, 1, 0, 3, _fSobelScale, _fSobelDelta, cv::BORDER_DEFAULT );  
11:       cv::convertScaleAbs( grad_x, abs_grad_x );  
12:    
13:       // Gradient Y  
14:       Sobel( oMat, grad_y, ddepth, 0, 1, 3, _fSobelScale, _fSobelDelta, cv::BORDER_DEFAULT );  
15:       convertScaleAbs( grad_y, abs_grad_y );  
16:    
17:       // Total Gradient (approximate)  
18:       cv::addWeighted( abs_grad_x, 0.5, abs_grad_y, 0.5, 0, oMat );  
19:  }  

8~14:計算X方向與Y方向的Gradient
cv::Sobel,第一個參數為Input Image,第二個則為Output Image,第三個則為Output Image的格式
接下來三個為X order, Y order, Kernel Size,其中 Kernel只支援奇數
前兩者會決定是對那個計算Gradient,對X方向就設為1, 0,反之為0, 1
_fSobelScale與_fSobelDelta,則會影響計算出來的值。
最後的cv::BORDER_DEFAULT,則是用來決定影像周圍部份的計算方式
cv::convertScaleAbs,是將CV_16S的grad_x & grad_y 重新調整為CV_8UC,這樣才能繪出

以下是做完Sobel的結果:


5.將影像G減去邊緣偵測的結果就可得到影像M
由於Edge Detection的結果都是邊緣部份接近255, 非邊緣部份接近0
因為處理方式就是將做完Posterize的影像與Edge Detection的結果相減
這樣原圖中邊緣的部份就會被加黑

首先是Canny Edge的結果:(左邊是單純用Canny, 右邊則用Dilation加粗線條)

再來是Sobel Edge的結果:

純粹以結果來看,Sobel Edge是比較符合當初在製作時所想要的
Canny Edge因為處理出比較細膩的結果,反而有點像是硬加上去的描邊
Sobel Edge則就相較是把整體所有細小的邊緣都加強了
(當然也可能是我個人的參數調整不當)

6.對影像M進行Smart Blur(智慧模糊)
終於來到最後一步的Smart Blur,這是當時卡最久的一關
因為並沒有找到哪個模糊演算法真的叫做這個名字XD
花了許多時間Search才發現,最接近的應該是Bilateral Filter(雙通濾波器)
又是一個可以解釋上好幾篇幅的東西...這裡就交給前人吧!感謝逍遙文工作室
簡單描述一下,它是一種可以保持邊緣,模糊內容的模糊演算法
這點滿符合我們在漫畫風的需求,"整片的網點填色+清楚的黑邊邊緣"

程式碼的部份,再次感謝偉大的OpenCV:

1:  cv::Mat oMatSmoothPosterResult_;  
2:  int iTmp_ = _iBlurLevel * 2 + 1;  
3:  cv::bilateralFilter(oMatPosterResult_, oMatSmoothPosterResult_, iTmp_, iTmp_*2, iTmp_ /2);  

關於bilateralFilter的參數,筆者是參考網路上其他人的用法來改的(當初沒有記下連結Orz)
筆者只能肯定,這樣設定的話_iBulrLevel要大於3左右才開始有效果
但是到6~7左右,整個fps會大幅掉落
詳細介紹可以參考OpenCV的官方文件

以下是Sobel Edge套入Bilateral Filter的結果,_iBlurLevel = 6:


可以看到背景上細小的雜訊都不見了,長頸路臉上也更均勻,但邊緣的銳利依然在
完整程式碼的部份請拉到最下面喔!!

總結:
相信明眼人都看得出來,
上集的結果雖然沒那麼漂亮,但處理的部份相對單純,需要調整的參數也不多
較為適合做Real-time的呈現
下集的作法,結果好上許多
但套用上最後的Smart Blur,大約只有10~15 fps,應該還要花上許多功夫去做效能調整
不過最主要的,還是這之中有太多參數要調整
光是Level的調整就要花上許多功夫了
因此比較適合做單張的運算

===========分隔線===========

總算完成這個系列了(也不過上下兩集)
原本其實打算專心解決Kinect V2,但想到"黑白吧!長頸鹿!"背後的程式都快要滿週年了
就下定決定好好的寫完它
中間就像是影像處理小複習一樣的感覺
每次這種時候都會開始後悔當初不好好上課,實作也沒有好好搞懂
但...人生嘛

===========分隔線===========

完整的程式碼:
1:  void testApp::Dilation(cv::Mat& oResult)  
2:  {  
3:       cv::Mat pMatElement_ = cv::getStructuringElement( cv::MORPH_ELLIPSE, cv::Size( 2*_iDilationSize + 1, 2*_iDilationSize+1 ), cv::Point( _iDilationSize, _iDilationSize ) );  
4:       dilate( oResult, oResult, pMatElement_ );  
5:  }  
6:    
7:  void testApp::SobelEdgeDetection(cv::Mat& oMat)  
8:  {  
9:       int ddepth = CV_16S;  
10:         
11:       // Generate grad_x and grad_y  
12:       cv::Mat grad_x, grad_y;  
13:       cv::Mat abs_grad_x, abs_grad_y;  
14:    
15:       // Gradient X  
16:       cv::Sobel( oMat, grad_x, ddepth, 1, 0, 3, _fSobelScale, _fSobelDelta, cv::BORDER_DEFAULT );  
17:       cv::convertScaleAbs( grad_x, abs_grad_x );  
18:    
19:       // Gradient Y  
20:       Sobel( oMat, grad_y, ddepth, 0, 1, 3, _fSobelScale, _fSobelDelta, cv::BORDER_DEFAULT );  
21:       convertScaleAbs( grad_y, abs_grad_y );  
22:    
23:       // Total Gradient (approximate)  
24:       cv::addWeighted( abs_grad_x, 0.5, abs_grad_y, 0.5, 0, oMat );  
25:  }  
26:    
27:  void testApp::ComicFilterVerJordan()  
28:  {  
29:       //RGB to Gray  
30:       cv::Mat oMatGrayImg_;  
31:       cv::cvtColor(_oImage, oMatGrayImg_, CV_RGB2GRAY);  
32:    
33:       //Change Level  
34:       cv::Mat oMatChangeLevelsImg_ = oMatGrayImg_.clone();  
35:       for(int idxY_ = 0 ; idxY_ < oMatChangeLevelsImg_.rows; ++idxY_)  
36:       {  
37:            for(int idxX_ = 0; idxX_ < oMatChangeLevelsImg_.cols; ++idxX_)  
38:            {  
39:                 uint8 ucSourceValue_ = oMatChangeLevelsImg_.at<uint8>(idxY_, idxX_);  
40:                 oMatChangeLevelsImg_.at<uint8>(idxY_, idxX_) = _aucLookupTable[ucSourceValue_];  
41:            }  
42:       }  
43:    
44:       //Poster edges  
45:       //posterization  
46:       cv::Mat oMatPosterization_ = oMatChangeLevelsImg_.clone();  
47:       for(int idxY_ = 0 ; idxY_ < oMatPosterization_.rows; ++idxY_)  
48:       {  
49:            for(int idxX_ = 0; idxX_ < oMatPosterization_.cols; ++idxX_)  
50:            {  
51:                 uint8 ucSourceValue_ = oMatPosterization_.at<uint8>(idxY_, idxX_);  
52:                 oMatPosterization_.at<uint8>(idxY_, idxX_) = _aucPosterizationLookupTable[ucSourceValue_];  
53:            }  
54:       }  
55:    
56:       cv::Mat oMatPosterResult_ = oMatPosterization_;  
57:       //Find Edge  
58:       if(_bUseCannyEdge)  
59:       {  
60:            cv::Mat oMatCanny_;  
61:            blur( oMatChangeLevelsImg_, oMatCanny_, cv::Size(3,3));  
62:            cv::Canny(oMatCanny_, oMatCanny_, _dCannyThreshold, _dCannyThreshold * _dThresholdRadio);  
63:            this->Dilation(oMatCanny_);       
64:            oMatPosterResult_ -= oMatCanny_;  
65:       }  
66:         
67:       if(_bUseSobelEdge)  
68:       {  
69:            cv::Mat oMatSobel_ = oMatChangeLevelsImg_.clone();  
70:            this->SobelEdgeDetection(oMatSobel_);  
71:            oMatPosterResult_ -= oMatSobel_;  
72:       }  
73:    
74:       //Smart blur  
75:       if(_bUseBlur)  
76:       {            
77:            cv::Mat oMatSmoothPosterResult_;  
78:            int iTmp_ = _iBlurLevel * 2 + 1;  
79:            cv::bilateralFilter(oMatPosterResult_, oMatSmoothPosterResult_, iTmp_, iTmp_*2, iTmp_ /2);  
80:            _oResult = oMatSmoothPosterResult_;  
81:       }  
82:       else  
83:       {  
84:            _oResult = oMatPosterResult_;  
85:       }       
86:  }  

沒有留言:

張貼留言