基於局部拉普拉斯金字塔的Edge-aware濾波器是在2011年由Adobe 公司的研究員Sylvain Paris(大神級人物,寫了很多文章)提出的,我在4年前曾經參考有關代碼實現過這個算法,但是速度也是非常慢的,所以當時也沒有繼續做深入的研究,前段時間做另外一個算法時仔細的研究了下高斯和拉普拉斯金子塔的優化,因此又抽時間仔細的分析了算法的論文和代碼,由於論文的理論部分還有一些我沒有想清楚,因此在這裏我只對研讀過程中涉及的代碼方面的優化做個解讀。
經過我最終的優化,處理1920 * 1024的彩色圖,在保證效果不會有明顯的瑕疵的取樣值的情況下,大概能獲得60ms的速度。
先分享下參考資料:
(1)論文 Local Laplacian Filters: Edge-aware Image Processing with a Laplacian Pyramid。
(2)論文 Fast Local Laplacian Filters: Theory and Applications
(3)函數:matlab 2017的locallapfilt函數。
(4)插件:Paint.net的Laplacian pyramid filter effect (可反編譯爲C#代碼)
我們先看下源作者給出的一個最原始的matlab的過程:
1 % naive O(N ^ 2) version for reference
2
3 G = gaussian_pyramid(I); % 由輸入圖像I建立高斯金字塔序列
4 L = laplacian_pyramid(zeros(size(I))); % 建立一個和圖像I大小相匹配的空的拉普拉斯金字塔
5 for lev0 = 1:length(L) - 1 % 對每一層金字塔
6 for y0 = 1 : size(G{ lev0 }, 1) % 遍歷每一層金字塔的高度
7 for x0 = 1 : size(G{ lev0 }, 2) % 遍歷每一層金字塔的寬度
8 g0 = G{ lev0 }(y0, x0, :); % 每一層高斯金字塔的每個數據
9 Iremap = r(I, g0); % 根據每個數據值,計算輸入圖像I對應的一個映射新圖像
10 Lremap = laplacian_pyramid(Iremap, lev0 + 1); % 計算這個新映射圖像的拉普拉斯金子塔
11 L{ lev0 }(y0, x0, :) = Lremap{ lev0 }(y0, x0, :); % 填空前面建立的空的拉普拉斯金字塔對應位置的值
12 end
13 end
14 end
15 L{ end } = G{ end };
16 R = reconstruct_laplacian_pyramid(L); % 根據拉普拉斯金字塔構建出結果圖像
原文核心的關於這段代碼的解釋如下(爲了不浪費空間,下面的圖片是從原文截圖,然後用PS再把短行拼接成長行的):
簡單的說,就是需要遍歷所有高斯金字塔圖像中的所有像素,根據每個像素的像素值,都由原圖和某個映射函數重新計算出一個和原圖一樣大小的圖像,然後計算該圖像的拉普拉斯金字塔,如上述代碼的第10行所示,注意此時的拉普拉斯金字塔只需要構建到對應的像素所在的高斯金字塔那一層就可以了,然後呢,取該像素位置所對應臨時金字塔的值作爲結果圖像在此位置的拉普拉斯金字塔值。當所有層的像素都計算完成後,結果圖的拉普拉斯金子塔就構建完成了,這個時候結果圖像就可以由拉普拉斯金字塔重構出。
由上述分析可見,直接實現這個過程將是非常耗時的,每一層金字塔的每一個係數都要靠構造一次拉普拉斯金字塔,如果圖像寬和高都爲N,則理論上說,所有的金字塔加起來是有N*N+N*N個像素的,這個時候就需要計算N*N大小圖像的拉普拉斯金字塔(N*N+N*N)次,會讓算法根本無法使用。在原始論文中,作者提到爲了計算某個位置的拉普拉斯金字塔值,並不需要計算整體的值,而只需要取某個局部區域來計算,可以將計算的複雜度降低到O(N * Log(N)),確實如此,但是這樣的過程依舊很慢很慢。
在沒有看Fast Local Laplacian Filters: Theory and Applications論文之前,我想到的關於此方法的優化手段非常有效,因爲對於常規8位圖像來說,其像素的可能值只有0到255之間的256個值,因此,在上述N*N+N*N次構建拉普拉斯金字塔的過程中,最多其實只會有256種不同結果,這也就意味着我們只需要構建256次拉普拉斯金字塔就可以得到所有的結果。大概的matlab代碼如下所示:
G = gaussian_pyramid(I);
L = laplacian_pyramid(zeros(size(I)));
for M = 0 : 255
Iremap = r(I, M);
Lremap = laplacian_pyramid(Iremap, length(L) + 1);
for lev0 = 1:length(L) - 1
for y0 = 1 : size(G{ lev0 }, 1)
for x0 = 1 : size(G{ lev0 }, 2)
if (G{ lev0 }(y0, x0, :) = M)
L{ lev0 }(y0, x0, :) = Lremap{ lev0 }(y0, x0, :)
end
end
end
end
end
L{ end } = G{ end };
R = reconstruct_laplacian_pyramid(L);
結合後面將要介紹的處理方法,利用C++和SSE處理一幅1920 * 1024(2M Pixel)的灰度圖,單線程大概的耗時在1200ms左右,而源作者的論文使用8核並行計算處理1M像素的圖都用了4000ms多,提速了很多倍,處理效果則是一摸一樣。
而論文Fast Local Laplacian Filters: Theory and Applications則做了更多的做工,他首先分析局部拉普拉斯算法和雙邊濾波的關係,然後分析了這個算法慢的主要原因,最後提出了他自己的解決方案,正如上面我們的所分析的,我們只需要做256次完整的拉普拉斯分解就可以了,而根據採樣定理,其實不一定要做這麼多次,只要多於某個採樣數值時,系統一樣可以穩定的輸出,而這個數值通常都要遠遠的小於256次,當然,我們是不太方便直接從圖像中找到這個最小的取樣數據的,但是經過摸索,一般來說大於12次以上效果都是不錯的,這篇論文的作者提出的相關參考代碼如下:
1 % INPUT
2 % I : input greyscale image
3 % r : a function handle to the remaping function
4 % N : number discretisation values of the intensity
5 % OUTPUT
6 % F : filtered image
7 function [F]=llf(I,sigma,fact,N)
8 [height width]=size(I);
9 n_levels=ceil(log(min(height,width))-log(2))+2;
10 discretisation=linspace(0,1,N);
11 discretisation_step=discretisation(2);
12 input_gaussian_pyr=gaussian_pyramid(I,n_levels);
13 output_laplace_pyr=laplacian_pyramid(I,n_levels);
14 output_laplace_pyr{n_levels}=input_gaussian_pyr{n_levels};
15 for ref=discretisation
16 I_remap=fact*(I-ref).*exp(-(I-ref).*(I-ref)./(2*sigma*sigma));
17 temp_laplace_pyr=laplacian_pyramid(I_remap,n_levels);
18 for level=1:n_levels-1
19 output_laplace_pyr{level}=output_laplace_pyr{level}+...
20 (abs(input_gaussian_pyr{level}-ref)<discretisation_step).*...
21 temp_laplace_pyr{level}.*...
22 (1-abs(input_gaussian_pyr{level}-ref)/discretisation_step);
23 end
24 end
25 F=reconstruct_laplacian_pyramid(output_laplace_pyr);
其中第19到22行即爲插值的過程,我們測試了一下對應的Matlab代碼,處理1M像素的圖,大概耗時在3800ms左右,我們知道matlab的代碼確實只適合教學,因此,優化的餘地是有很大的。
在優化前,我們還是定性的說下上面過程中涉及到的reampping Function,在原始的論文中,作者提到了這個函數起到了細節和邊緣調整的作用,對於高斯金字塔中的任一像素值g0,我們設定一個參數бr , 當原圖I中的像素i的值在g0附近時,我們認爲這些點屬於g0附近的細節,而遠離g0的部分則屬於邊緣,對細節和邊緣我們採用兩個不同的處理函數rd和re,一般要求rd和re必須是單調遞增函數,而且滿足,也及時要求rd和re在分界處是連續的。
一個典型的處理如下所示:
else
其中在做細節處理時,論文建議的fd和fe如下所示:
其對應的曲線如下所示:
簡單的分析下圖片的直觀認識吧,我們看看detail smoothing的曲線,在輸入爲g0時輸出爲g0,在小於g0的бr 範圍內,輸出是大於輸入的,而在大於g0的бr 範圍內,輸出是小於於輸入的。也就是說在g0附近的像素是朝向g0進一步靠近的,從而使得這一塊的細節都趨於一致,而在遠離g0的位置,像素未受到影響,這樣在整體的表現上如果g0在原始圖像中屬於某個平坦區域,則其周邊的像素也慢慢往g0靠近,如果他屬於邊緣,則周邊的像素基本未改變,這個時候通過金字塔則可以把這種改變通過領域的關係一步一步的代入到拉普拉斯係數中,這也是所謂的局部帶來的好處。在detail enhancement的增強中,則是一個相反的過程。
但在Fast Local Laplacian Filters: Theory and Applications一文配套的代碼中,我們看到作者並沒有使用上述曲線來處理,而是使用了這樣的一個函數:
其中sigma一般取值0.15左右,f爲用戶調節參數,但f小於0時,起到平滑作用,大於0時,起到細節增強作用。
注意上述公式中的i和g0都必須是歸一化後的值。
下左圖示出了此曲線(高斯曲線)和原始論文曲線(指數曲線)的差異,其中g0取值爲0.5。可見此曲線在整個定義域是非常光滑的,在遠離g0處並沒有像原始論文那樣對像素毫無影響,但影響也是非常小了。
上圖的右側部分爲f取不同值時的曲線分佈情況,我們注意到當f=-2或者f=4時的曲線出現了異常情況,他已經不符合原始論文提出的曲線是單調遞增函數的要求,此時圖像也會出現一些異常情況,後續會給出這個異常的結果圖。
那麼實際上我們還有很多其他的曲線可以使用,比如有關代碼裏列出如下的曲線函數:
%This is just a toy example!
function y=remapping_function(x)
% y=(x-0.1).*(x>0.1)+(x+0.1).*(x<-0.1); %smoothing
y=3.*x.*(abs(x)<0.1)+(x+0.2).*(x>0.1)+(x-0.2).*(x<-0.1); %enhancement
end
實際測試也是沒有問題的,也能達到類似的效果。
好了,下面我們來集中力量來實現上述新代碼的C++優化。首先,爲了較爲準確的實現這個過程,我們先把圖像數據轉換爲浮點數。但是我們可能不做歸一化的處理,即浮點的範圍還是控制在0和255之間。那麼金字塔建立的優化過程再次不在贅述,可以參考我的相關博客。核心的優化就在第16到22行代碼,我們先來處理第16行代碼:
I_remap=fact*(I-ref).*exp(-(I-ref).*(I-ref)./(2*sigma*sigma));
我們將他翻譯爲普通的C++代碼:
float Inv = 1.0f / 255.0f / 255.0f / (2 * sigma * sigma);;
for (int I = 0; I < GaussPyramid[0].Height * GaussPyramid[0].Width; I++)
{
float I = GaussPyramid[0][I], Diff = I - ref;
GaussPyramidT[I] = IM_ClampF(I + f * Diff * exp(-Diff * Diff * Inv), 0, 255);
}
GaussPyramid[0]其實就是原始輸入圖像,GaussPyramidT是臨時的高斯金字塔數據,IM_ClampF是個裁剪限幅函數,主要是爲了讓數據不產生溢出,實際測試好像不限幅也沒什麼大的問題。
這段代碼是非常耗時的,第一,他是對原始圖大小進行處理的,第二,exp的計算是個非常緩慢的過程,因此,我們嘗試了SSE處理。
int BlockSize = 4, Block = (GaussPyramid[0].Height * GaussPyramid[0].Width) / BlockSize;
for (int I = 0; I < Block * BlockSize; I += BlockSize)
{
__m128 V = _mm_loadu_ps(GaussPyramid[0] + I);
__m128 Diff = _mm_sub_ps(V, _mm_set1_ps(ref));
__m128 Exp = _mm_fexp_ps(_mm_neg_ps(_mm_mul_ps(_mm_mul_ps(Diff, Diff), _mm_set1_ps(Inv))));
__m128 Dst = _mm_add_ps(_mm_mul_ps(_mm_mul_ps(Diff, Exp), _mm_set1_ps(f)), V);
Dst = _mm_min_ps(_mm_max_ps(Dst, _mm_setzero_ps()), _mm_set1_ps(255.0f));
_mm_storeu_ps(GaussPyramidT[0] + I, Dst);
}
for (int I = Block * BlockSize; I < GaussPyramid[0].Height * GaussPyramid[0].Width; I++)
{
float I = GaussPyramid[0][I], Diff = I - ref;
GaussPyramidT[I] = IM_ClampF(I + f * Diff * exp(-Diff * Diff * Inv), 0, 255);
}
基本上是直接翻譯,其中有些函數爲自定義的,如下所示:
// 求負數
inline __m128 _mm_neg_ps(__m128 a)
{
return _mm_sub_ps(_mm_setzero_ps(), a);
}
// http://martin.ankerl.com/2007/10/04/optimized-pow-approximation-for-java-and-c-c/
// 快速的指數運算,精度一般
inline __m128 _mm_fexp_ps(__m128 x)
{
__m128i T = _mm_cvtps_epi32(_mm_add_ps(_mm_mul_ps(x, _mm_set1_ps(1512775)), _mm_set1_ps(1072632447)));
__m128i TL = _mm_unpacklo_epi32(_mm_setzero_si128(), T);
__m128i TH = _mm_unpackhi_epi32(_mm_setzero_si128(), T);
return _mm_movelh_ps(_mm_cvtpd_ps(_mm_castsi128_pd(TL)), _mm_cvtpd_ps(_mm_castsi128_pd(TH)));
}
我們測試發現,雖然使用的是精度一般版本的exp函數,但是對最終的結果影響並不大,但是速度的提升則非常明顯,而所謂的Clamp處理則可以直接使用min和max函數實現。
接着就是第18到22行代碼,我們直接翻譯爲普通C語言如下所示:
for (level = 1; level < n_levels - 1; level++)
{
for (int I = 0; I < (PryamidW[level] * PryamidH[level]; I++)
{
if (abs(GaussPyramid[level][I] - ref) < discretisation_step) // 插值計算,表示在有效的範圍內
{
LaplacePyramid[level][I] += (LaplacePyramidT[level][I] * (1 - abs(GaussPyramid[level][I] - ref) / discretisation_step));
}
}
}
重點是處理內部的循環。
因爲內部循環裏有條件判斷和跳轉,一般來說是不利於SSE處理的,如果要用SSE處理,其實施要點是找到某個參數,是的跳轉和不跳轉通過該參數能用同一個公式計算,我們觀察上式,但不符合那個判斷跳轉時,我們可以通過把LaplacePyramidT[level][I]這個變量用0值代替,這樣可保證結果不變。另外一個可以考慮的地方就是,如果存在較多的多數據同時不滿足條件的情況,可以使用_mm_movemask_ps的函數來做處理,如果他返回值爲0,我們可以不繼續後續的處理,否則,就統一處理,如下所示:
int BlockSize = 4, Block = (PryamidW[level] * PryamidH[level]) / BlockSize;
float *D = LaplacePyramid[level];
float *S = GaussPyramid[level];
float *T = LaplacePyramidT[level];
for (int I = 0; I < Block * BlockSize; I += BlockSize)
{
__m128 Abs = _mm_abs_ps(_mm_sub_ps(_mm_loadu_ps(S + I), _mm_set1_ps(ref)));
__m128 Cmp = _mm_cmplt_ps(Abs, _mm_set1_ps(Step));
if (_mm_movemask_ps(Cmp) != 0)
{
__m128 LT = _mm_blendv_ps(_mm_setzero_ps(), _mm_loadu_ps(T + I), Cmp);
_mm_storeu_ps(D + I, _mm_sub_ps(_mm_add_ps(_mm_loadu_ps(D + I), LT), _mm_mul_ps(LT, _mm_mul_ps(Abs, _mm_set1_ps(1.0f / discretisation_step)))));
}
}
for (int I = Block * BlockSize; I < PryamidW[level] * PryamidH[level]; I++)
{
// do something
}
其中用到的一些自定義函數如下:
// 浮點數據的絕對值計算
inline __m128 _mm_abs_ps(__m128 v)
{
static const int i = 0x7fffffff;
float mask = *(float*)&i;
return _mm_and_ps(v, _mm_set_ps1(mask));
}
通過以上優化方式,我們最終的測試結果表明針對2M大小的圖像,平均處理時間穩定在145ms左右(取樣數設置爲12),相對原先的情況已經有了非常大的提高。
在耗時組成方面,我們測試數據如下,臨時的高斯-拉普拉斯金字塔的構建85ms, 映射函數耗時30ms, 填充拉普拉斯金字塔數據30ms。可見大部分時間還是用在金字塔的處理上。
我們已經知道,整數版本的(8位或者32位的)金字塔的建立要比浮點版本快很多,特備是8位的金字塔數據,如果我們使用整數版本的效果會如何呢,我們進行了使用8位金子塔的版本進行了測試,當然,爲了精度,拉普拉斯金字塔的數據部分還是需要使用singed short類型來保存,測試的效果表明,整數版本的精度也是足夠的。如下圖所示:
原圖 浮點版本 8位金字塔版本
仔細對比,會有一點點的差異,但是基本上靠眼睛是區分不出來的。
但是,使用8位金字塔的第一個優勢是建立金字塔的速度非常快,第二,函數的映射部分,我們可以使用查找表的方式快速實現(取整數結果),第三,填充拉普拉斯金字塔這一塊使用相關整數處理的SSE指令,也可以一次性處理8個像素了,因此都大爲加速,綜合這三個優勢,我們最終的優化結果做到了2M像素60ms的處理速度。
在Fast Local Laplacian Filters: Theory and Applications一文中,作者也給出了他優化的處理速度,如下所示:
可見,同比,我的速度比他的實現快了很多很多倍,當然還是沒有趕上GPU的速度,但是也相差不大,比如我的速度折算到4M像素,需要120ms,他的GPU版本比如的快了2.5倍左右,但是我用的是單線程的,如果考慮多線程,還是有一定的提速空間(雖然笨算法不易多線程了)。
再者,我們還是來討論下映射函數的問題,前面講了,快速版本的代碼使用的映射函數並沒有使用原始論文的版本,所以我們嘗試把這個替換一下,得到的結論就是,原始版本的映射函數不適合插值使用,效果如下所示:
原圖 採樣數爲12(бr =0.16, alpha = 0.3)時的效果 採樣爲32(бr =0.16, alpha = 0.3)時的效果
直接實現的效果(256色階) 帶防止噪音擴大的效果
可見這個映射函數在採樣率較小時的效果是非常差的,具有明顯的色塊效果,而只有不進行任何下采樣時效果才較爲滿意。
而注意到上面的第四個圖,可以看到在原來平坦的區域裏出現很多不起眼的噪點,在原始論文裏作者也注意到了這個問題,他提出了一個解決方案,即限制最小的差異的放大程度,當我們需要增強細節時(alpha < 1),使用一個混合公式來修正函數fd的值。
式中的係數T值由abs(i-g0)/бr 決定,當該值小於0.01時,爲0,當大於0.02時,爲1,而介於兩者之間是使用一個平滑函數修正,這樣做的結果就是使得在和g0特別接近時,相關的像素不會得到修正,而噪音的引起也就是因爲這些特別相近的像素經過原來的處理後會變得差異很大引起的。所以這樣做就可以有效地避免該問題,一個常用的平滑函數如下所示:
inline float IM_SmoothStep(const float t)
{
return t * t * (3 - 2 * t); // powf(t, 2.0f) * powf(t - 2, 2.0f);
}
此時的修正曲線如下圖所示:
至於爲什麼原始論文的曲線不適合採樣,我分析可能還是因爲他的曲線是由兩個函數組合而成的,而中間的曲線部分在採樣時不能夠獲得足夠的取樣點,而造成數據丟失,而改進後的論文中的曲線是一條光滑連續的曲線,其曲率變化也非常自然,很少的取樣點就可以獲得較爲理想的效果。同時,我們也主要到高斯曲線的映射結果似乎不存在噪音過增強的現象,而且也不需要採用類似指數映射那種處理方式來減少該問題,如果採用,反而可能會對結果圖像帶來光暈。
接下來我們分析另外一個問題,現在我們推薦使用高斯曲線來進行數據的映射,當函數中f取值小於0時,是處於一個去燥或者說平滑圖像的作用,同時還能有效地保留邊緣,當f大於0時,起到了細節增強或者說銳化的作用,因此,f小於0時該濾波器的作用相當於一個保邊濾波器,比如他也可以有效地用在磨皮中(f = -1左右):
但是當f過分的小或者過分的大時,比如f=-2或f=4時,如上述曲線所示,此時的高斯曲線已經不是單調遞增函數了,此時的圖像會出現什麼效果,我們做了測試:
原圖 f = -2 f = 4
我們看看中間這幅圖,可以看到天空中的雲原先的白色已經變成了灰色了,但是整個圖像還是處於平滑的狀態,明顯處於一個結果錯誤的狀態,而後面一個圖的增強程度似乎很過,但是相對於中間的部分而言要稍微合理一點。
引起該結果的一個核心原因正如上述所言,映射函數出了問題,在此時的映射函數不同的兩個輸入,可以有相同的輸出。所以在使用高斯曲線時一定要注意這個問題。
其他細節:
在論文裏還提到其他的一些細節,比如說爲了提高速度,我們可以只對底層的若干層拉普拉斯金字塔做上述操作,而更高層的金字塔數據不做處理,也可以只對高層的拉普拉斯金字塔進行重構,而底層的不做處理,特別是底層的不錯處理,會極大的提高速度,畢竟第二層的數據只有第一層數據的1/4了,那效果如何了,我們下面給出了一些結果:
原圖 f =2, 0層金字塔不處理 f = 4, 0層金字塔不處理
可見這種修改雖能加快速度,但效果不好,雖有一定的細節增強效果,但0層的影響太強烈。在看另外一個效果:
原圖 0層不處理(f = -1) 0層處理(f = -1)
中間的結果是0層不處理的結果,我們驚喜的發現這個結果和簡單探討可牛影像軟件中具有膚質保留功能的磨皮算法及其實現細節 一文中的有幾分的相似,我們分析認爲當0層不處理時,原圖的紋理就保存原始0層的拉普拉斯金字塔中,而0層以後的數據都進行了平滑的處理,這樣數據在最後的疊合後就呈現了紋理保留(膚質保留)的功能,這也是所謂的巧合吧。
我們之前也描述幾篇保邊濾波器的文章,他們通常只具有保邊效果,而這裏的拉普拉斯濾波器確內在的可以實現保邊和細節增強的作用,而且改變使用不同的映射函數還可以實現tone mapping、style transefer等較爲高級的功能,不失爲一個開創性的算法,我個人覺得他從傳統的一些Edge-aware算法中能脫穎而出,利用最常見和簡單的金字塔算法實現通用的效果,真的有點類似當然何凱明的暗通道算法一樣。不過好像從作者發表該論文後,還少有作者進行進一步的算法拓展和應用,這也是比較鬱悶之處啊。
最後,我們還拿這個算法和其他算法的效果做個對比,來看看這個算法的強大。
原圖 USM銳化(半徑=20,Amount =200, Threshold = 0)
基於導向濾波的銳化(數量 = 200) 局部金字塔濾波(採樣數=12,f = 2)
可以看到,局部金字塔在邊緣保留這一塊做的特別的好。
對於醫學圖像,該算法也能很好的起到增強作用:
原始的渾濁圖像經過處理後變得非常清晰。
醫學圖像通常有很多都是16位的,該算法對16位依然有效,只是處理過程要稍作修改即可。
Demo下載地址:https://files.cnblogs.com/files/Imageshop/SSE_Optimization_Demo.rar