SSE影象演演算法優化系列三十一:RGB2HSL/RGB2HSV及HSL2RGB/HSV2RGB的指令集優化-上。

2023-07-13 12:00:22

       RGB和HSL/HSV顏色空間的相互轉換在我們的影象處理中是有著非常廣泛的應用的,無論是是影象調節,還是做一些膚色演演算法,HSL/HSV顏色空間都非常有用,他提供了RGB顏色空間不具有的一些獨特的特性,但是由於HSL/HSV顏色空間的複雜性,他們之間的轉換的效率一直不是很高的,有一些基於定點演演算法的嘗試,對速度有一定的提升,但一個是提升不是特別的明顯,另外就是對結果的精度有一定的影響。

   對於這兩個演演算法的指令集優化,網路上就根本沒有任何資料,也沒有任何人進行過嘗試,我也曾經有想法去折騰他,但是初步判斷覺得他裡面有太多的分支了,應該用了指令集後也不會有多大的速度區別,所以一直沒有動手。 

      但是最近的一個朋友的潛在需求,然後我又對這個演演算法有些期待,重新動手拾起這個轉換過程,結果還是有所收穫,速度獲得了3到4倍的提升。、

      我們先來談談RGB到HSL或者HSV顏色空間的轉換優化

       這個網路上一大堆,我也就不浪費時間去重新整理,我直接分享一段程式碼和網址吧:

     參考網址:  http://www.xbeat.net/vbspeed

  這個文章給出的是VB6的程式碼,可以參考下。

  我們約定:RGB資料來源是unsigned char 型別, 有效範圍就是[0,255],而HSL/HSV都是浮點型,其中H的有效範圍時[0,6],S的有效範圍是[0,1], L/V的有效範圍也是[0,1]。

  經過我個人的整理和稍微優化,一個簡單的RGB2HSV程式碼如下所示:

void IM_RGB2HSV_PureC(unsigned char Blue, unsigned char Green, unsigned char Red, float &Hue, float &Sat, float &Val)
{
    int Min = IM_Min(Red, IM_Min(Green, Blue));
    int Max = IM_Max(Red, IM_Max(Green, Blue));
    if (Max == Min)
    {
        Hue = 0;
        Sat = 0;
    }
    else
    {
        int Delta = Max - Min;
        if (Max == Red)
            Hue = (float)(Green - Blue) / Delta;
        else if (Max == Green)
            Hue = 2.0f + (float)(Blue - Red) / Delta;
        else
            Hue = 4.0f + (float)(Red - Green) / Delta;

        //    實際上只有Max==Red時,方有可能Hue < 0 (對應Green < Blue),
        //    所以有的程式碼在Max == Red內再做判斷,對於C程式碼來說,這樣效率應該會高一點
        if (Hue < 0)  Hue += 6;
        Sat = (float)Delta / Max;
    }
    Val = Max * IM_Inv255;
}

  RGB2HSL的程式碼如下:

void IM_RGB2HSL_PureC(unsigned char Blue, unsigned char Green, unsigned char Red, float &Hue, float &Sat, float &Val)
{
    int Min = IM_Min(Red, IM_Min(Green, Blue));
    int Max = IM_Max(Red, IM_Max(Green, Blue));
    int Sum = Max + Min;
    if (Max == Min)
    {
        Hue = 0;
        Sat = 0;
    }
    else
    {
        int Delta = Max - Min;
        if (Max == Red)
            Hue = (float)(Green - Blue) / Delta;
        else if (Max == Green)
            Hue = 2.0f + (float)(Blue - Red) / Delta;
        else
            Hue = 4.0f + (float)(Red - Green) / Delta;

        if (Hue < 0)  Hue += 6;

        if (Sum <= 255)
            Sat = (float)Delta / Sum;
        else
            Sat = (float)Delta / (510 - Sum);
    }
    Val = Sum * IM_Inv510;
}

   比較兩個不同的模型的程式碼可以發現,他們對於H分量的定義是相同的,對於V/L分量一個使用了最大值,一個使用了最大值和最小值的平均值,對於S分量,大家都考慮了最大值和最下值的差異,只是一個和最大值做比較,一個是和最大值和最小值之和做比較,整體來說,RGB2HSV模型相對來說簡單一些,計算量也少一些。

  可以看到,無論是RGB2HSL還是RGB2HSV,求H的過程都有非常多的判斷和分支語句,而且整體考慮除零錯誤(Max == Min)還有一些其他的特殊判斷, 正如我在博文中多次提到,指令集裡沒有分支跳轉的東西,這些跳轉是非常不利於指令集優化。指令集裡要實現這樣的東西,只有兩個辦法:

  1、想辦法把所有分支跳轉用一些奇技淫巧合併到一起,用一個語句來表達他。

  2、對所有分支語句的結果都計算出來,然後使用相關的Blend進行條件合併。

  仔細的分析上面的C程式碼,我是沒有想到什麼特別好的技巧把色相部分的三個分支合併為一個語句。憑個人的感覺,只能使用第二種方式。 

  為了描述方便,我先貼出RGB2HSV演演算法一個比較簡單的SIMD指令集優化的結果:

 1 void IM_RGB2HSV_SSE_Old(__m128i Blue, __m128i Green, __m128i Red, __m128 &Hue, __m128 &Sat, __m128 &Val)
 2 {
 3     __m128i Max = _mm_max_epi32(Red, _mm_max_epi32(Green, Blue));    //    R/G/B的最大值Max
 4     __m128i Min = _mm_min_epi32(Red, _mm_min_epi32(Green, Blue));    //    R/G/B的最小值Min
 5     __m128i Delta = _mm_sub_epi32(Max, Min);                        //    最大值和最小值之間的差異Delta = Max - Min
 6 
 7     __m128 MaxS = _mm_cvtepi32_ps(Max);
 8     __m128 DeltaS = _mm_cvtepi32_ps(Delta);
 9 
10     Sat = _mm_divz_ps(DeltaS, MaxS);                        //    S = Delta / Max, 注意有了除零的例外處理,同時如果Max == Min, Delta就為0, S也返回0,是正確的
11     Val =  _mm_mul_ps(MaxS, _mm_set1_ps(IM_Inv255));        //    V = Max / 255;
12 
13     __m128 Inv = _mm_divz_ps(_mm_set1_ps(1), DeltaS);
14 
15     //if (Max == Red)
16     //    Hue = (float)(Green - Blue) / Delta;
17 
18     __m128 HueR = _mm_mul_ps(_mm_cvtepi32_ps(_mm_sub_epi32(Green, Blue)), Inv);
19 
20     //else if (Max == Green)
21     //    Hue = 2.0f + (float)(Blue - Red) / Delta;
22 
23     __m128i Mask = _mm_cmpeq_epi32(Max, Green);
24     __m128 HueG = _mm_add_ps(_mm_set1_ps(2.0f), _mm_mul_ps(_mm_cvtepi32_ps(_mm_sub_epi32(Blue, Red)), Inv));
25 
26     Hue = _mm_castsi128_ps(_mm_blendv_epi8(_mm_castps_si128(HueR), _mm_castps_si128(HueG), Mask));
27 
28     //else
29     //    Hue = 4.0f + (float)(Red - Green) / Delta;
30     Mask = _mm_cmpeq_epi32(Max, Blue);
31     __m128 HueB = _mm_add_ps(_mm_set1_ps(4.0f), _mm_mul_ps(_mm_cvtepi32_ps(_mm_sub_epi32(Red, Green)), Inv));
32 
33     Hue = _mm_castsi128_ps(_mm_blendv_epi8(_mm_castps_si128(Hue), _mm_castps_si128(HueB), Mask));
34 
35     //    if (H < 0)    H += 6;    其實這個主要是針對Max == R的情況會出現
36     Hue = _mm_blendv_ps(Hue, _mm_add_ps(Hue, _mm_set1_ps(6)), _mm_cmplt_ps(Hue, _mm_setzero_ps()));
37 
38 }

  說明: IM_RGB2HSV_SSE函數中的Blue、Green、Red三個__m128i 變數中儲存的是4個32位元的顏色分量,而不是16個顏色。 

  第三、第四、第五行求Max\Min\Delta這些過程沒有什麼難以理解的。第七、第八行只是把整形轉換為浮點型(注意SSE指令也是強型別的哦,必須自己手動轉換型別)。

  第十、第十一行直接就求出了Sat和Val分量, Val不難理解,Sat在對應的C程式碼中是分了Max == Min及Max != Min兩種狀況,當Max == Min時,為0,否則,要使用(Max - Min) / Max, 其實這裡不用做判斷直接統一使用 (Max - Min) / Max即可,因為Max == Min時, Max - Min也是0, 但是唯一需要注意的就是如果Max = Min = 0時, Max也為0, 0 / 0 在數學時不容許的,在計算上也會有溢位錯誤,所以這裡使用了一個自定義的_mm_divz_ps函數,實現當除數為0時,返回0的結果。這樣就可以剝離掉這個分支語句了。

  複雜的是Hue分量的計算,從第十三行開始一直到最後都是關於他的優化。 

  第13行,我們先計算出1.0f / Delta,注意這裡也是使用的_mm_divz_ps函數。

  第16行我們先按照公式計算出當Max == Red時Hue的結果。

  第23行我們比較Max和Green是否相等,注意這裡也是使用的32位元int型別的比較。

  第24行按照公式計算出當Max == Green時,Hue分量的結果。

  第25行則對這兩個結果進行混合,這裡的混合有很多編碼上的技巧,因為我們兩次計算的HueR和HueG都是__m128型別,而我們的比較是用的整形的比較,返回的是__m128i型的資料,而_mm_blendv_ps的混合需要的__m128的比較結果,但是如果直接將Mask強制轉換為浮點型別,作為_mm_blendv_ps的引數,將會產生不正確的結果。那麼解決方案有2個:

  一、使用浮點型別的比較,即將Blue\Green分量先轉換為__m128型,然後使用_mm_cmpeq_ps進行比較,這樣增加幾條型別轉換函數。

  二、就是使用本例的程式碼,使用_mm_blendv_epi8 + _mm_castps_si128進行混合,表面上看多了3次cast的過程,似乎更為耗時,但是實際上cast系列的語句只是個語法糖,編譯後他不產生任何組合指令。他只是讓編譯器認為他是另外一個型別的資料型別了,這樣就可以編譯了,實際上__m128、__m128i這些東西在硬體上都是儲存在XMM暫存器上的,暫存器本身不分資料型別。

  第30和31行也是類似的到裡,對那些Max == Blue分量的結果進行混合。

  第36行則是對Hue < 0的特殊情況進行處理。也沒有什麼特別複雜的。 

  我們對一副5000*5000大小的24位元影象(填充的亂資料)進行測試,普通C語言的耗時約為114ms,上述SIMD優化的耗時約為 49ms,提速比接近2.2倍。

  實際上上述SIMD指令優化的程式碼還有一定的優化空間,我們注意到為了計算HueR\HueG\HueB,我們進行了3次浮點版本的乘法和加法。但是如果我們把這個乘法和加法的部分單獨提出來,每次都進行相應的混合,那麼只需要最後進行一次乘法和加法即可以了,這樣增加了混合的次數,但是減少了計算的次數,而混合指令其實都是通過位運算實現的,相對來說非常快,具體的程式碼如下所示:

 1 void IM_RGB2HSV_SSE(__m128i Blue, __m128i Green, __m128i Red, __m128 &Hue, __m128 &Sat, __m128 &Val)
 2 {
 3     __m128i Max = _mm_max_epi32(Red, _mm_max_epi32(Green, Blue));    //    R/G/B的最大值Max
 4     __m128i Min = _mm_min_epi32(Red, _mm_min_epi32(Green, Blue));    //    R/G/B的最小值Min
 5     __m128i Delta = _mm_sub_epi32(Max, Min);                        //    最大值和最小值之間的差異Delta = Max - Min
 6 
 7     __m128 MaxS = _mm_cvtepi32_ps(Max);
 8     __m128 DeltaS = _mm_cvtepi32_ps(Delta);
 9 
10     Sat = _mm_divz_ps(DeltaS, MaxS);                        //    S = Delta / Max, 注意有了除零的例外處理,同時如果Max == Min, Delta就為0, S也返回0,是正確的
11     Val = _mm_mul_ps(MaxS, _mm_set1_ps(IM_Inv255));        //    V = Max / 255;
12 
13     //    SIMD沒有跳轉方面的指令,只能用Blend加條件判斷來實現多條件語句。注意觀察三種判斷的情況可以看成是一個Base(0/120/240)加上不同的Diff乘以Inv。
14     //    以Max == B為基礎,這樣做的好處是:當Max == Min時,H是要返回0的,但是如果按照C語言的那個混合順序,則最後判斷Max == B時成立,則H返回的是4,那麼為了返回正確的結果
15     //    就還要多一個_mm_blendv_epi8語句,注意這裡隱藏的一個事實是Max == Min時,G - B, B - R, R - G其實都是為0的,那麼類似這樣的 (float)(G - B) / Delta * 60結果也必然是0。
16 
17     //  if (Max == bB)
18     //        H = 4.0f + (float)(R - G) / Delta;
19 
20     __m128i Base = _mm_set1_epi32(4);
21     __m128i Diff = _mm_sub_epi32(Red, Green);
22 
23     //if (Max == G)
24     //        H = 2.0f + (float)(B - R) / Delta;
25 
26     __m128i Mask = _mm_cmpeq_epi32(Max, Green);
27     Base = _mm_blendv_epi8(Base, _mm_set1_epi32(2), Mask);        //    當Mask為真時,_mm_blendv_epi8返回第二個引數的值,否則返回第一個引數的值
28     Diff = _mm_blendv_epi8(Diff, _mm_sub_epi32(Blue, Red), Mask);
29 
30     // if (Max == R)
31     //        H = (float)(G - B) / Delta;
32     Mask = _mm_cmpeq_epi32(Max, Red);
33     Base = _mm_blendv_epi8(Base, _mm_setzero_si128(), Mask);
34     Diff = _mm_blendv_epi8(Diff, _mm_sub_epi32(Green, Blue), Mask);
35 
36     __m128 Inv = _mm_divz_ps(_mm_set1_ps(1), DeltaS);                            //    1 / Delta,注意有了除零的例外處理
37 
38                                                                                 //    H = Base + Diff * Inv
39     Hue = _mm_add_ps(_mm_cvtepi32_ps(Base), _mm_mul_ps(_mm_cvtepi32_ps(Diff), Inv));
40 
41     //    if (H < 0)    H += 6;    其實這個主要是針對Max == R的情況會出現
42     Hue = _mm_blendv_ps(Hue, _mm_add_ps(Hue, _mm_set1_ps(6)), _mm_cmplt_ps(Hue, _mm_setzero_ps()));
43 
44 }

  通過這種方式優化大概還能獲取15-25%的效能提升。

  當然,這裡可能還有一部分空間可以考慮,即我們使用的是32位元int型別的比較,一次只能比較4個數,另外諸如_mm_max_epi32這樣的計算,對於原始的影象資料來說,都可以使用epi8來做的,這樣一次性就是可以獲取16個畫素的資訊,而不是8位元,但是這樣做面臨的問題就是後面要做多次資料型別轉換。這些轉換的耗時和比較的耗時孰重孰輕暫時還沒有結論,有興趣的讀者可以自行測試下。

  如果您看懂了RGB2HSV的SSE程式碼,那麼RGB2HSL你覺得還會有難度嗎,希望讀者可以自行編碼實現。

  下一篇將著重講述HSL2RGB及HSV2RGB空間的優化,那個的優化難度以及優化的提速比相對來講要比RGB2HSL和RGB2HSL更為複雜和有效。

  本文的測試程式碼可從下述連結獲取: https://files.cnblogs.com/files/Imageshop/RGB2HSV.rar?t=1689216617&download=true

  如果想時刻關注本人的最新文章,也可關注公眾號或者新增本人微信:  laviewpbt