RGBHSV変換を実装してみた

Wikipediaの変換式とかを見ながら実装してみた。*1

まずRGB->HSV

float3 rgb2hsv(float3 rgb)
{
    float3  hsv;
    float   rgb_max, rgb_min;
    float   diff;
    float   base;
    float   div1;
    float   div2;

    rgb_max = max(max(rgb.x, rgb.y), rgb.z);
    rgb_min = min(min(rgb.x, rgb.y), rgb.z);
    if(rgb_max == rgb.x){
        diff = rgb.y - rgb.z;   // G-B
        base = (rgb.y < rgb.z)? 360.0f : 0.0f;
    }
    if(rgb_max == rgb.y){
        diff = rgb.z - rgb.x;   // B-R
        base = 120.0f;
    }
    if(rgb_max == rgb.z){
        diff = rgb.x - rgb.y;   // R-G
        base = 240.0f;
    }

    div1 = (rgb_max == rgb_min)? 1.0f : (rgb_max - rgb_min);
    div2 = (rgb_max > 0.0f)? rgb_max : 1.0f;
    hsv.x = 60.0f * diff / div1 + base;
    hsv.y = (rgb_max - rgb_min) / div2;
    hsv.z = rgb_max;

    return hsv;
}


次。HSV->RGB

float3 hsv2rgb(float3 hsv)
{
    float3  rgb;
    int     Hi;
    float   f;
    float   p;
    float   q;
    float   t;

    Hi = fmod(floor(hsv.x / 60.0f), 6.0f);
    f = hsv.x / 60.0f - Hi;
    p = hsv.z * (1.0f - hsv.y);
    q = hsv.z * (1.0f - f * hsv.y);
    t = hsv.z * (1.0f - (1.0f - f) * hsv.y);

    if(Hi == 0){
        rgb.x = hsv.z;
        rgb.y = t;
        rgb.z = p;
    }
    if(Hi == 1){
        rgb.x = q;
        rgb.y = hsv.z;
        rgb.z = p;
    }
    if(Hi == 2){
        rgb.x = p;
        rgb.y = hsv.z;
        rgb.z = t;
    }
    if(Hi == 3){
        rgb.x = p;
        rgb.y = q;
        rgb.z = hsv.z;
    }
    if(Hi == 4){
        rgb.x = t;
        rgb.y = p;
        rgb.z = hsv.z;
    }
    if(Hi == 5){
        rgb.x = hsv.z;
        rgb.y = p;
        rgb.z = q;
    }

    return rgb;
}

途中のif文の羅列は本来ならHiでswitch-caseするところだけどそっちだと10命令以上処理が増えてしまう。われわれ人間はHiの条件がどれか1つしか同時に成立しない(かつ、どれか1つは必ず成立する)ことを分かっているのでif文の羅列に変換。

単純にRGB値をHSVに変換して何もせず元に戻した値をテクスチャへ書き込むって処理なら54命令になった。
正直そんなに軽い処理ではない。劇重ってほどひどくもないけど。


で、ここからが今回のメイン。
相互変換できるようになったけど、「floatで計算誤差は大丈夫?」って思うわけで検証してみた。
まず机上?でC++で動かして誤差を確認してみた。

int main(void)
{
    const float     div = 1.0f / 255.0f;
    for(int r=0;r<256;r++){
        for(int g=0;g<256;g++){
            for(int b=0;b<256;b++){
                float3  val(r*div, g*div, b*div);
                mycmp256(
                        val,
                        hsv2rgb(rgb2hsv(val))
                    );
            }
        }
    }
}

シェーダは0.0〜1.0でRGBの0〜255を表現するので1/255ずつ増やしながらすべての組み合わせで誤差を確認してみた。float3とかはC++には無いので独自classで擬似ってみた。(ソースは省略)


比較ルーチンはこんな感じ。

bool cmp_float1(float lhs, float rhs)
{
    // 許容する最小誤差。0.0f なら誤差が少しでもあればひっかかる
    // 差分が出る儀る場合に調整する
    const float     e = 0.0f;//1.0f/128.0f;

    return ((lhs-e) <= rhs) && ((lhs+e) >= rhs);
}
bool cmp_float3(const float3 &lhs, const float3 &rhs)
{
    return
        cmp_float1(lhs.x, rhs.x) &&
        cmp_float1(lhs.y, rhs.y) &&
        cmp_float1(lhs.z, rhs.z);
}
bool cmp256(const float3 &lhs, const float3 &rhs)
{
    // float -> UNORM への変換誤差をシミュレートするために 255 倍する
    return cmp_float3(255.0f * lhs, 255.0f * rhs);
}
void printdiff256(const float3 &lhs, const float3 &rhs)
{
    printf("(%d, %d, %d) -> %s\n",
            (int)(255.0f * lhs.x),
            (int)(255.0f * lhs.y),
            (int)(255.0f * lhs.z),
            (255.0f * lhs - 255.0f * rhs).toString().c_str()    // RGB値(0〜255)での差分を表示する
        );
}
void mycmp256(const float3 &lhs, const float3 &rhs)
{
    if(!cmp256(lhs, rhs)){
        printdiff256(lhs, rhs);
    }
}

で、実行すると

(0, 1, 115) -> (0.0000000000000000, -0.0000079870223999, 0.0000000000000000)
(0, 1, 116) -> (0.0000000000000000, 0.0000011324882507, 0.0000000000000000)

みたいな感じでわらわらと出力される。
e = 1.0f/128.0f とすると何も出力されない。
float <-> UNORM 変換は 255 の乗算除算なので、半分の128でひっかからないなら原理的には誤差が見えないはず。

で、試しにどこまで大丈夫か許容誤差を引き上げていくと
e = 1.0f / pow(2.0f, 13.0f) まで(2^-13)は大丈夫で2^-14でちらほらと出力された。

ということで、計算誤差は 2^-13 っぽい。*2

ただ、誤差は+方向にも−方向にも出ていて単純に255倍して少数以下を破棄すると異なるRGB値(0〜255)となってしまう。次回以降でROPのfloat->UNORM変換が単純な「切り捨て」ではなく、「近い数値へ丸め」となっているかどうかを検証する。

*1:Wikipediaに載っているのは値の範囲が0.0〜1.0のもの。0〜255に対応する式は計算式中に255の数値が入っていて内容は同じ。適当にググれば見つかるはず

*2:比較ルーチンもfloatを使っているので厳密な誤差ではなく、比較ルーチンも含んだ全体の誤差かもしれないけど