1. 程式人生 > >解析opencv中Box Filter的實現並提出進一步加速的方案(原始碼共享)

解析opencv中Box Filter的實現並提出進一步加速的方案(原始碼共享)

轉自:http://www.cnblogs.com/Imageshop/p/5053013.html

Box Filter,最經典的一種領域操作,在無數的場合中都有著廣泛的應用,作為一個很基礎的函式,其效能的好壞也直接影響著其他相關函式的效能,最典型莫如現在很好的EPF濾波器:GuideFilter。因此其優化的檔次和程度是非常重要的,網路上有很多相關的程式碼和部落格對該演算法進行講解和優化,提出了不少O(1)演算法,但所謂的0(1)演算法也有優劣之分,0(1)只是表示執行時間和某個引數無關,但本身的耗時還是有區別。比較流行的就是累積直方圖法,其實這個是非常不可取的,因為稍大的圖就會導致溢位,這裡我們解析下 opencv的相關程式碼,看看他是如何進行優化的。

      首先找到opencv的程式碼的位置,其在\opencv\sources\modules\imgproc\src\smooth.cpp中。

   

     Box Filter 是一種行列可分離的濾波,因此,opencv也是這樣處理的,先進行行方向的濾波,得到中間結果,然後再對中間結果進行列方向的處理,得到最終的結果。

     opencv 行方向處理的相關程式碼如下:

複製程式碼
template<typename T, typename ST>
struct RowSum :
        public BaseRowFilter
{
    RowSum( int _ksize, int _anchor ) :
        BaseRowFilter()
    {
        ksize = _ksize;
        anchor = _anchor;
    }

    virtual void operator()(const uchar* src, uchar* dst, int width, int cn)
    {
        const T* S = (const T*)src;
        ST* D = (ST*)dst;
        int i = 0, k, ksz_cn = ksize*cn;

        width = (width - 1)*cn;
        for( k = 0; k < cn; k++, S++, D++ )
        {
            ST s = 0;
            for( i = 0; i < ksz_cn; i += cn )
                s += S[i];
            D[0] = s;
            for( i = 0; i < width; i += cn )
            {
                s += S[i + ksz_cn] - S[i];
                D[i+cn] = s;
            }
        }
    }
};
複製程式碼

  這個程式碼考慮了多個通道以及多種資料型別的情況,為了分析方便我們重寫下單通道時的核心程式碼。

複製程式碼
for(Z = 0, Value = 0; Z < Size; Z++)    
    Value += RowData[Z];
LinePD[0] = Value;

for(X = 1; X < Width; X ++)
{
    Value += RowData[X + Size - 1] - RowData[X - 1];    
    LinePD[X] = Value;               
}
複製程式碼

  上述程式碼中RowData是指對某一行畫素進行擴充套件後的畫素值,其寬度為Width + Radius + Radius, Radius是值濾波的半徑, 而程式碼中Size = 2 * Radius + 1,LinePD即所謂的中間結果,考慮資料型別能表達的範圍,必須使用 int型別。

      對於每行第一個點很簡單,直接用for計算出行方向的指定半徑內的累加值。而之後所有點,用前一個累計值加上新加入的點,然後去除掉移出的哪一個點,得到新的累加值。這個方法在很多文獻中都有提及,並沒有什麼新鮮之處,這裡不多說。

     按照正常的思維,在列方向的處理應該和行方向完全相同,只是處理的方向的不同、處理的資料來源的不同以及最後需要對計算結果多一個歸一化的過程而已。事實也是如此,有很多演算法都是這樣做的,但是由於CPU構架的一些原因(主要是cache miss的增加),同樣的演算法沿列方向處理總是會比沿行方向慢一個檔次,解決方式有很多,例如先對中間結果進行轉置,然後再按照行方向的規則進行處理,處理完後在將資料轉置回去。轉置過程是有非常高效的處理方式的,藉助於SSE以及Cache優化,能實現比原始兩重for迴圈快4倍以上的效果。還有一種方式就正如opencv中列處理過程所示,這正是下面將要重點描述的。

      在opencv的程式碼中,並沒有直接沿列方向處理,而是繼續沿著行的方向一行一行處理,我先貼出我自己翻譯的有關純C的程式碼進行解說:

複製程式碼
    for (Y = 0; Y < Size - 1; Y++)            //    注意沒有最後一項哦                      
    {
        int *LinePS = (int *)(Sum->Data + ColPos[Y] * Sum->WidthStep);
        for(X = 0; X < Width; X++)    ColSum[X] += LinePS[X];
    }

    for (Y = 0; Y < Height; Y++)
    {
        unsigned char* LinePD    = Dest->Data + Y * Dest->WidthStep;    
        int *AddPos              = (int*)(Sum->Data + ColPos[Y + Size - 1] * Sum->WidthStep);
        int *SubPos              = (int*)(Sum->Data + ColPos[Y] * Sum->WidthStep);

        for(X = 0; X < Width; X++)
        {
            Value = ColSum[X] + AddPos[X];
            LinePD[X] = Value * Scale;                    
            ColSum[X] = Value - SubPos[X];
        }
    }
複製程式碼

      上述程式碼中定義了一個ColSum用於儲存每行某個位置處在列方向上指定半徑內各中間元素的累加值,對於第一行,做特殊處理,其他行的每個元素的處理方式和BaseRowFilter裡的處理方式很像,只是在書寫上有所區別,特別注意的是對第一行的累加時,最後一個元素並沒有計算在內,這個處理技巧在下面的X迴圈裡有體現,請大家仔細體味下。

     上述程式碼這樣做的好處是,能有效的減少CPU的cache miss,但是總的計算量是沒有改變的,因此能有效的提高速度。

     針對PC,在opencv內部,其在列方向上採用了SSE進行進一步的優化,我們貼出其處理uchar型別的程式碼:

View Code

  程式碼比較多,我稍微精簡下(注意,精簡後的是不可執行的,只是更清晰的表達了思路)。

複製程式碼
virtual void operator()(const uchar** src, uchar* dst, int dststep, int count, int width)
{
    int i;
    int* SUM;
    bool haveScale = scale != 1;
    double _scale = scale;
    if( width != (int)sum.size() )
    {
        sum.resize(width);
        sumCount = 0;
    }

    SUM = &sum[0];
    if( sumCount == 0 )
    {
        memset((void*)SUM, 0, width*sizeof(int));
        for( ; sumCount < ksize - 1; sumCount++, src++ )
        {
            const int* Sp = (const int*)src[0];
            i = 0;
           
            for( ; i <= width-4; i+=4 )
            {
                __m128i _sum = _mm_loadu_si128((const __m128i*)(SUM+i));
                __m128i _sp = _mm_loadu_si128((const __m128i*)(Sp+i));
                _mm_storeu_si128((__m128i*)(SUM+i),_mm_add_epi32(_sum, _sp));
            }
            for( ; i < width; i++ )
                SUM[i] += Sp[i];
        }
    }
    else
    {
        src += ksize-1;
    }

    for( ; count--; src++ )
    {
        const int* Sp = (const int*)src[0];
        const int* Sm = (const int*)src[1-ksize];
        uchar* D = (uchar*)dst;

        i = 0;
        const __m128 scale4 = _mm_set1_ps((float)_scale);
        for( ; i <= width-8; i+=8 )
        {
            __m128i _sm  = _mm_loadu_si128((const __m128i*)(Sm+i));
            __m128i _sm1  = _mm_loadu_si128((const __m128i*)(Sm+i+4));

            __m128i _s0  = _mm_add_epi32(_mm_loadu_si128((const __m128i*)(SUM+i)),
                                         _mm_loadu_si128((const __m128i*)(Sp+i)));
            __m128i _s01  = _mm_add_epi32(_mm_loadu_si128((const __m128i*)(SUM+i+4)),
                                          _mm_loadu_si128((const __m128i*)(Sp+i+4)));

            __m128i _s0T = _mm_cvtps_epi32(_mm_mul_ps(scale4, _mm_cvtepi32_ps(_s0)));
            __m128i _s0T1 = _mm_cvtps_epi32(_mm_mul_ps(scale4, _mm_cvtepi32_ps(_s01)));

            _s0T = _mm_packs_epi32(_s0T, _s0T1);

            _mm_storel_epi64((__m128i*)(D+i), _mm_packus_epi16(_s0T, _s0T));

            _mm_storeu_si128((__m128i*)(SUM+i), _mm_sub_epi32(_s0,_sm));
            _mm_storeu_si128((__m128i*)(SUM+i+4),_mm_sub_epi32(_s01,_sm1));
        }
        for( ; i < width; i++ )
        {
            int s0 = SUM[i] + Sp[i];
            D[i] = saturate_cast<uchar>(s0*_scale);
            SUM[i] = s0 - Sm[i];
        }

        dst += dststep;
    }
}
複製程式碼

      在行方向上,ColSum每個元素的更新相互之間是沒有任何關係的,因此,藉助於SSE可以實現一次性的四個元素的更新,並且上述程式碼還將第一行的特殊計算也用SSE實現了,雖然這部分計算量是非常小的。

     在具體的SSE細節上,對於uchar型別,雖然中間結果是用int型別表達的,但是由於SSE沒有整形的除法指令(是否有?),因此上面借用了浮點數的乘法SSE指令實現,當然就多了_mm_cvtepi32_ps 以及 _mm_cvtps_epi32這樣的型別轉換的函式。如果有整形除法,那就能更好了。

     SSE的實現,無非也就是用_mm_loadu_si128載入資料,用_mm_add_epi32, _mm_mul_ps之類的函式進行基本的運算,用_mm_storeu_si128來儲存資料,並沒有什麼特別複雜的部分,注意到考慮到資料的普遍性,不一定都是16位元組對齊的,因此在載入和儲存是需要使用u方面的函式,其實現在的_mm_loadu_si128和_mm_load_si128在處理速度上已經沒有特別明顯的區別了。

      注意到在每個SSE程式碼後面,總還有部分C程式碼,這是因為我們實際資料寬度並不一定是4的整數倍,未被SSE處理的部分必須使用C實現,這其實對讀者來說是非常好的事情,因為我們能從這部分C程式碼中搞明白上述的SSE程式碼是幹什麼的。

  以上就是opencv的Box Filter實現的關鍵程式碼,如果讀者去看具體細節,opencv還有針對手機上的Neon優化,其實這個和SSE的意思基本是一樣的。

  那是否還有改進的空間呢,從我的認知中,在對垂直方向的處理上,應該沒有了,但是水平方向呢, SSE有沒有用武之地,經過我的思考我認為還是有的。

  在行方向的計算中,這個for迴圈是主要的計算。

for(X = 1; X < Width; X ++)
{
    Value += RowData[X + Size - 1] - RowData[X - 1];    
    LinePD[X] = Value;               
}

       本人認為雖然這裡的操作是前後依賴的,全域性無法並行化,但是觀察這一行程式碼:

Value += RowData[X + Size - 1] - RowData[X - 1];    

      其中RowData[X + Size - 1] - RowData[X - 1]; 並不是前後依賴的,是可以並行的,因此如果提前快速的計算出所有的差值,那麼提速的空間還比較大,即需要提前計算出下面的函式:

 for(X = 0; X < (Width - 1); X++)
     Diff[X] = AddPos[X] - SubPos[X];

  這裡用SSE實現則非常簡單了:

複製程式碼
        unsigned char *AddPos = RowData + Size * Channel;
        unsigned char *SubPos = RowData;
        X = 0;                    //    注意這個賦值在下面的迴圈外部,這可以避免當Width<8時第二個for迴圈迴圈變數未初始化            
        __m128i Zero = _mm_setzero_si128();
        for (; X <= (Width - 1) * Channel - 8; X += 8)
        {
            __m128i Add = _mm_unpacklo_epi8(_mm_loadl_epi64((__m128i const *)(AddPos + X)), Zero);        
            __m128i Sub = _mm_unpacklo_epi8(_mm_loadl_epi64((__m128i const *)(SubPos + X)), Zero);        
            _mm_store_si128((__m128i *)(Diff + X + 0), _mm_sub_epi32(_mm_unpacklo_epi16(Add, Zero), _mm_unpacklo_epi16(Sub, Zero)));        //    由於採用了_aligned_malloc函式分配記憶體,可是使用_mm_store_si128
            _mm_store_si128((__m128i *)(Diff + X + 4), _mm_sub_epi32(_mm_unpackhi_epi16(Add, Zero), _mm_unpackhi_epi16(Sub, Zero)));
        }
        for(; X < (Width - 1) * Channel; X++)
            Diff[X] = AddPos[X] - SubPos[X];
複製程式碼

  和列方向的SSE處理程式碼不同的是,這裡載入的是uchar資料,因此載入的函式就有所不同,處理的方式也有區別,上面幾個SSE函式各位查查MSDN就能理解其意思,還是很有味道的。

  經過這樣的處理,經過測試發現,速度能夠比opencv的版本在提高30%,也是額外的驚喜。

      再有的優化可能提速有限,比如把剩下的一些for迴圈內部分成四路等等。

     在我的I5桌上型電腦中,使用上述演算法對1024*1024的三通道彩色影象進行半徑為5的測試,進行100次的計算純演算法部分的耗時為800ms,如果是純C版本大概為1800ms;當半徑為200時,SSE版本約為950ms, C版本約1900ms;當半徑為400時,SSE版本約為1000ms, C版本約2100ms;可見,半徑增大,耗時稍有增加,這主要是由於演算法中有部分初始化的計算和半徑有關,但是這些都是不重要的。

      在不使用多執行緒(雖然本演算法非常適合多執行緒計算),不使用GPU,只使用單執行緒\CPU進行計算的情況下,個人覺得目前我這個程式碼是很難有質的超越的,從某個方面講,程式碼中的用於計算的時間佔用的時間比從記憶體等待資料的時間可能還要短,而似乎也沒有更好的演算法能進一步減少記憶體資料的訪問量。

      本人在這裡邀請各位高手對目前我優化的這個程式碼進行進一步的優化,希望高人不要謙虛。

  執行介面:

  

  本文的程式碼是針對常用的影象資料進行的優化處理,在很多場合下,需要對int或者float型別進行處理,比如GuideFilter,如果讀者理解了本文解讀的程式碼的原理,更改程式碼以便適應他們則是一件非常簡單的事情,如果您不會,那麼也請你不要給我留言,或者我可以有償提供,因為從本質上講我喜歡提供漁,而不是魚,漁具已經送給你了,你卻找我要魚,那隻能..............。

      本文原始碼下載地址(VS2010編寫):Box Filter 

 

 ****************************作者: laviewpbt   時間: 2015.12.17    聯絡QQ:  33184777 轉載請保留本行資訊**********************