OpenGL.Shader:志哥教你寫一個濾鏡直播使用者端(12)視覺濾鏡:磨皮美白の雙邊濾波原理實現

2020-10-26 14:00:45

OpenGL.Shader:志哥教你寫一個濾鏡直播使用者端(12)

 

工作生活安排得太滿,放空了部落格一段時間,最近才有時間繼續整理濾鏡的學習。這篇帶來的是時下比較熱門的一個濾鏡效果磨皮——雙邊濾波的簡單學習。

一、何為雙邊濾波?

我們先來看看比較官方的解釋:雙邊濾波(Bilateral filter)是一種非線性的濾波方法,是結合影象的空間鄰近度和畫素值相似度的一種折衷處理,同時考慮空域資訊和灰度相似性,達到保邊去噪的目的。具有簡單、非迭代、區域性的特點。

第一次看可能不太理解 「非線性」 。其實在前面介紹的均值濾波 / 高斯濾波 都是屬於線性濾波,簡單理解為:針對一張圖片的所有畫素值,都是使用同一個濾波矩陣,按照固定比例的權重係數做折積。非線性濾波就是增加一個引數,判斷臨近畫素是否相似,使得折積過程中的權重係數不再是固定比例,而是動態按需調整。

結合以上這個白話文認識之後,接下來按照國際慣例,以雙邊濾波的公式入手。

這裡寫圖片描述


g(i, j) 代表輸出結果;
S(i, j)的是指以(i,j)為中心的(2N+1)(2N+1)的折積運算;N為折積矩陣的半徑長度
(k, l)代表範圍內的(多個)輸入點;f(k, l) 是點(k, l)對應的值。
w(i, j, k, l)代表經過兩個高斯函數計算出的值 (注意:這裡不是最終權值)

上述公式我們進行轉化,假設公式中w(i,j,k,l)為m,則有
這裡寫圖片描述

設 m1+m2+m3 … +mn = M,則有
這裡寫圖片描述
此時可以看到,這明顯是影象矩陣與核的折積運算了。其中m1/M代表的第一個點(或最後一個點,看後面如何實現)的權值,而影象矩陣與核通過折積運算元作加權和,最終得到輸出值。

接下來我們來討論最關鍵的w(i, j, k, l),其實w(i, j, k, l) = ws * wr。

 

這裡寫圖片描述

先說WS,空間臨近高斯函數,也叫定義域,仔細觀察其實就是高斯濾波的正太分佈模型。代表的是其在特定空間所執行的折積的數學模型,示意圖如下,如果我們在整個影象上都執行這個數學模型的折積,就是普通的高斯濾波。

       這裡寫圖片描述

 

 

 

這裡寫圖片描述

再說WR,畫素值相似度高斯函數,也叫值域或頻域,邏輯示意圖如下,其意思表示當前輸入點(k,l)的值f(k,l)    和    輸出點(i,j)的值f(i,j)的差值。差值越大,wr越小趨向於0;差值越小,wr越大趨向於1;如果 f(i,j) = f(k,l),wr=1。

重點理解:是比較當前點的值f(k,l)和輸入點的值f(i,j)的差值,在影象處理當中,就是比較座標為(i,j)的畫素值和座標為(k,l)的畫素值,從而判斷其邊緣是否相似。

這裡寫圖片描述

 

 二、GL當中的雙邊濾波

在OpenGL.Shader上實現雙邊濾波不在於演演算法,是在於思想。上篇介紹了多重FBO實現高斯濾波降維的運算,可能比較難理解。這一次實現雙邊濾波就從簡單中來,簡單中去。力求讓大家能明白其中的思想,拿到程式碼後是能 「自己改得動的」。

首先是頂點著色器:

attribute vec4 position; 
attribute vec4 inputTextureCoordinate; 
const int GAUSSIAN_SAMPLES = 9; 
uniform vec2 singleStepOffset; 
varying vec2 textureCoordinate; 
varying vec2 blurCoordinates[GAUSSIAN_SAMPLES]; 
 
void main() 

    gl_Position = position; 
    textureCoordinate = inputTextureCoordinate.xy; 
     
    int multiplier = 0; 
    vec2 blurStep; 
    for (int i = 0; i < GAUSSIAN_SAMPLES; i++) 
    { 
        multiplier = (i - ((GAUSSIAN_SAMPLES - 1) / 2)); 
        blurStep = float(multiplier) * singleStepOffset; 
        blurCoordinates[i] = inputTextureCoordinate.xy + blurStep; 
    } 
}

雙邊濾波和之前的高斯濾波基本一樣,也是取9個取樣點, 直接聲名一個vec2的singleStepOffset偏移步長。計算輸入點前四步和後四步的頂點。沒啥好說的,接著重點看片元著色器。

uniform sampler2D SamplerY; 
uniform sampler2D SamplerU; 
uniform sampler2D SamplerV; 
uniform sampler2D SamplerRGB; 
mat3 colorConversionMatrix = mat3( 
                   1.0, 1.0, 1.0, 
                   0.0, -0.39465, 2.03211, 
                   1.13983, -0.58060, 0.0); 
vec3 yuv2rgb(vec2 pos) 

   vec3 yuv; 
   yuv.x = texture2D(SamplerY, pos).r; 
   yuv.y = texture2D(SamplerU, pos).r - 0.5; 
   yuv.z = texture2D(SamplerV, pos).r - 0.5; 
   return colorConversionMatrix * yuv; 

// yuv轉rgb
const lowp int GAUSSIAN_SAMPLES = 9; 
varying highp vec2 textureCoordinate; 
varying highp vec2 blurCoordinates[GAUSSIAN_SAMPLES]; 
uniform mediump float distanceNormalizationFactor; 
 
void main() 

    lowp vec4 centralColor;  // 輸入中心點畫素值 
    lowp float gaussianWeightTotal;  // 高斯權重集合
    lowp vec4 sampleSum;  // 折積和
    lowp vec4 sampleColor; // 取樣點畫素值
    lowp float gaussianWeight; // 取樣點高斯權重
    lowp float distanceFromCentralColor; 
     
    centralColor = vec4(yuv2rgb(blurCoordinates[4]), 1.0); 
    gaussianWeightTotal = 0.22; 
    sampleSum = centralColor * 0.22; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[0]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.03 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[1]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.07 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
    
    sampleColor = vec4(yuv2rgb(blurCoordinates[2]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.12 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[3]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.17 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[5]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.17 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[6]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.12 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[7]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.07 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    sampleColor = vec4(yuv2rgb(blurCoordinates[8]), 1.0); 
    distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
    gaussianWeight = 0.03 * (1.0 - distanceFromCentralColor); 
    gaussianWeightTotal += gaussianWeight; 
    sampleSum += sampleColor * gaussianWeight; 
     
    gl_FragColor = sampleSum / gaussianWeightTotal; 
}

看著很長的一段shader程式碼,其實都是一套模板程式碼。 先看頭尾兩部分程式碼:

centralColor = vec4(yuv2rgb(blurCoordinates[4]), 1.0); 
gaussianWeightTotal = 0.22; 
sampleSum = centralColor * 0.22; 

// ... ...

gl_FragColor = sampleSum / gaussianWeightTotal 

如果我們略去外圍8個取樣點的折積,輸出值=輸入中心點的畫素值,保持原輸入的影象效果。接著我們加入取樣點折積,以中心點外一個singleStepOffset的blurCoordinates[3] 和 blurCoordinates[5]為例,分析取樣點程式碼邏輯:

sampleColor = vec4(yuv2rgb(blurCoordinates[3]), 1.0); 
distanceFromCentralColor = min(distance(centralColor, sampleColor) * distanceNormalizationFactor, 1.0); 
gaussianWeight = 0.17 * (1.0 - distanceFromCentralColor); 
gaussianWeightTotal += gaussianWeight; 
sampleSum += sampleColor * gaussianWeight; 

第一行程式碼,獲取取樣點畫素值。
(重點)第二行程式碼 利用GLSL內建函數disatance計算兩個vec2/3/4的距離,也可以理解為兩個變數的差值,distance函數可以用來計算兩個顏色的相似程度,結果越大,兩個顏色間的差異越大,結果越小,兩個顏色間的差異越小。然後乘以自定義的distanceNormalizationFactor差值量化因子。怎麼理解這個因子?其實就是一個修正引數,使其可以動態改變其效果範圍,不明白可以看以下的動圖。

這裡說一個GLSL的偵錯技巧,把需要偵錯的值直接輸出到gl_FragColor顯示,效果用眼觀察就最直接了。

gl_FragColor = vec4( min(distance(centralColor, sampleColor)*distanceNormalizationFactor, 1.0),   0.0, 0.0, 1.0);

// 偵錯.gif


(次重點)第三行程式碼,結合第二行的程式碼理解,利用GLSL內建函數min,根據雙邊濾波的數學意義,求出取樣點和中心輸入點的值的差值後,歸一化為一個相似度distanceFromCentralColor,但是注意一點的是,取樣點和中心輸入點的畫素值越接近,distanceFromCentralColor越趨向於0,高斯權重越接近原始值。
所以參與折積的權重 =  原高斯權重*(1.0 - distanceFromCentralColor)
第四第五行程式碼,把參與折積的權重歸併,進行折積運算。

剩下就是複寫幾個GpuBaseFilter的函數,詳情請參考 https://github.com/MrZhaozhirong/NativeCppApp                      /src/main/cpp/gpufilter/filter/GpuBilateralBlurFilter.hpp

    void setAdjustEffect(float percent) {
        // 動態調整色值閾值引數
        mThreshold_ColorDistanceNormalization = range(percent*100.0f, 10.0f, 1.0f);
    }

    void onDraw(GLuint SamplerY_texId, GLuint SamplerU_texId, GLuint SamplerV_texId,
                void* positionCords, void* textureCords)
    {
        if (!mIsInitialized)
            return;
        glUseProgram(getProgram());
        // 把step offset的步伐直接用vec2表示,其值直接輸入1/w,1/h
        glUniform2f(mSingleStepOffsetLocation, 1.0f/mOutputWidth, 1.0f/mOutputHeight);
        glUniform1f(mColorDisNormalFactorLocation, mThreshold_ColorDistanceNormalization);
        // 繪製的模板程式碼
        glVertexAttribPointer(mGLAttribPosition, 2, GL_FLOAT, GL_FALSE, 0, positionCords);
        glEnableVertexAttribArray(mGLAttribPosition);
        glVertexAttribPointer(mGLAttribTextureCoordinate, 2, GL_FLOAT, GL_FALSE, 0, textureCords);
        glEnableVertexAttribArray(mGLAttribTextureCoordinate);

        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, SamplerY_texId);
        glUniform1i(mGLUniformSampleY, 0);
        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D, SamplerU_texId);
        glUniform1i(mGLUniformSampleU, 1);
        glActiveTexture(GL_TEXTURE2);
        glBindTexture(GL_TEXTURE_2D, SamplerV_texId);
        glUniform1i(mGLUniformSampleV, 2);
        // onDrawArraysPre
        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
        glDisableVertexAttribArray(mGLAttribPosition);
        glDisableVertexAttribArray(mGLAttribTextureCoordinate);
        glBindTexture(GL_TEXTURE_2D, 0);
    }

 

再說一個知識點:

在GpuGaussianBlurFilter的時候,高斯折積核是從外部程式碼傳入到Shader的,其值是

convolutionKernel = new GLfloat[9]{
                0.0947416f, 0.118318f, 0.0947416f,
                0.118318f,  0.147761f, 0.118318f,
                0.0947416f, 0.118318f, 0.0947416f,
        };

在 GpuGaussianBlurFilter2,高斯核不在由外部傳入,是直接寫在Shader當中,其值是:
0.05,0.09,0.12,0.15,0.18,0.15,0.12,0.09,0.05

在這次GpuBilateralBlurFilter,不知道小夥伴有沒留意,其高斯核也是直接寫在Shader當中,其值是:
0.03,0.07,0.12,0.17,0.22,0.17,0.12,0.07,0.03

我一步步的把核心值的權重提高,降低邊緣的權重,想想是為什麼?