YUVをちゃんと理解してからRGBにコンバートしましょうね

しばらく前に、Windows上でTextureMovie機能の実装をしました。TextureMovieというのは動画形式のファイルからフレームを取得し、デコードした結果をOpenGLやDirectX用のテクスチャへ焼きこんでこれらのレンダリングシステムを使って描画するものです。

ここで出たデコードを処理するのはデコーダーで、つまり、何かしらの形式の色情報をRGB(A)に変換するものです。世の中には既成のデコーダーが幾つかありますが、いろいろ事情があって自作という選択肢に至りました。

また、Windowsのことですから、MSが提供している低レベルAPIで何とかなるのかもしれないと考えました。そこで、ほぼRGBとCMYKしか知らない私がいろいろ調べていきました。もし、 色空間の変換やYUV形式に興味がありましたら 、是非お読みください。

まずやりたいことを一言でまとめると、movie.mp4 -> YUVバッファ -> RGBバッファ -> テクスチャという流れです。

最初にWindowsの標準機能であるMicrosoft Media Foundation Transform (MFT)についていろいろ調べました。悪戦苦闘の末、やっと「YUV」でのデータ出力までたどり着きました(MFTに関してはサンプルや資料が極めて少ないため、結構試行錯誤の連続で.mp4からYUVバッファーの出力までできました。それは別の回に改めて紹介することとし、ここでは割愛します)。

いま振り返ると、そこで現れた見知らぬ「YUV」という単語が実に意味深いです。
「YUVって何ぞや、RGBの従兄弟なの?」と思いながら、Wikipediaを開きました。

YUVYCbCrYPbPrとは、輝度信号Yと、2つの色差信号を使って表現される色空間。

とWikipedia先生がこう答えてくれました。

まだよくわからないため、さらにGoogle先生へ答えを求めました。

「人間の目は明るさの変化には敏感だが, 色の変化に は鈍感である」というわけで,色度を抑え、輝度により広い帯域やビット数を割くことにより、少ない損失で効率の良い伝送や圧縮を実現するフォーマット.
デジタル画像の圧縮CODECにおけるフォーマットという観点でまとめる.

つまり、人間の目をうまく欺いて、データの容量を削減するための圧縮フォーマットと考えればいいでしょう。

エンコード(圧縮をかける)したから、デコード(圧縮を解除する)と呼ばれた処理なんだとふっと思いながら、YUVからRGBに変換する方法を探し始めました。

ところが、YUVというのは一つの形式ではなく、幾つかの形式の総称だということに気づきました。MSDNから各形式についてこんな説明があります(以下、図を引用します)。

image04

正直、ぱっと見ただけでは最初に文字も図も今ひとつ分からなかったので、読み飛ばして分かるところまで先に進みました。

まず、そのページの後半にとても重要な計算式が書いてあります(同ページより引用)。

Converting 8-bit YUV to RGB888

From the original RGB-to-YUV formulas, one can derive the following relationships for BT.601.

Y = round( 0.256788 * R + 0.504129 * G + 0.097906 * B) +  16
U = round(-0.148223 * R - 0.290993 * G + 0.439216 * B) + 128
V = round( 0.439216 * R - 0.367788 * G - 0.071427 * B) + 128

Therefore, given:

C = Y - 16
D = U - 128
E = V - 128

the formulas to convert YUV to RGB can be derived as follows:

R = clip( round( 1.164383 * C                   + 1.596027 * E  ) )
G = clip( round( 1.164383 * C - (0.391762 * D) - (0.812968 * E) ) )
B = clip( round( 1.164383 * C +  2.017232 * D                   ) )

where clip() denotes clipping to a range of [0..255]. We believe these formulas can be reasonably approximated by the following:

R = clip(( 298 * C + 409 * E + 128) >> 8)
G = clip(( 298 * C - 100 * D - 208 * E + 128) >> 8)
B = clip(( 298 * C + 516 * D + 128) >> 8)

上記の変換から得たのをもう少し整理すると

R = clip(( 298 * (Y - 16) + 409 * (V - 128) + 128) >> 8)
G = clip(( 298 * (Y - 16) - 100 * (U - 128) - 208 * (V - 128) + 128) >> 8)
B = clip(( 298 * (Y - 16) + 516 * (V - 128) + 128) >> 8)

になります。

これを手に入れたら、あとはYUVの構造さえ分かれば、変換が可能になります。

次に、代表的なフォーマット毎に詳しい説明があります(同ページより引用)。

4:4:4 Formats, 32 Bits per Pixel

AYUV

image06

このフォーマットは輝度毎に色差信号をサンプリングし、アルファ情報もついています。最高クォリティの画質です。

4:2:2 Formats, 16 Bits per Pixel

YUY2

image09

UYVY

image08

このフォーマットはY0とY1が同じUV(U0、V0)を使っています。YUY2とUYVYの差はY、U、Vの並び順だけです。

4:2:0 Formats, 16 Bits per Pixel

※以下のメモリイメージ図はビデオフレームの横幅がY配列の列数と等しく、ビデオフレームの縦幅はY行列の行数と同じです(実際にメモリ上は一次配列扱い。同ページより引用)。

IMC1

image10

IMC3

image05

このフォーマットは四つのYが同じUVを使います。

特徴的な構造として、メモリ上配列でYの値を全部格納してから、Uの配列、Vの配列を置いていることが挙げられます。ただし、UとVの配列はYと同じ幅(行と列の比率、ここはややこしい)なので、ややメモリ利用効率が悪そうです。

また、IMC1とIMC3のメモリ構造はほぼ一緒です。唯一異なるのはUの配列とVの配列どちらを先に並べるかです。

例:

352 * 240のビデオフレームだと

Yの値は352個/行 * 240行(行:列 = 352 : 240 = 22/15)

UとVの値それぞれは176個/行 * 120行(行:列 = 176 : 120 = 22/15)

4:2:0 Formats, 12 Bits per Pixel

これらのフォーマットは全部四つのYが同じUVを使用しています。違いはメモリ上でのデータ格納方法だけです。

IMC2、IMC4

image03
(IMC2)

この形式の特徴はYの値を全部格納してからUとVの値を交替に格納することです。つまり一行の半分はUの値、残り半分はVの値になります。メモリをより効率的に利用できます。IMC2とIMC4の違いはU配列とV配列どちらが先に置かれるかです。また、IMC2はNV12以外で最も使われる形式だと書いてあります(つまり、NV12は最も使われているということで、これは扱いやすさゆえでしょう)。

YV12

image01

正直なところ最初にこの図を見て、IMC1とは何か違うのが分かりませんでした。その後に分かりやすい説明を見つけました(下図は英語版Wikipediaより引用)。

image00

なお、YUV420はYV12の別名です。

簡単にいうと、メモリ上でみた場合にY、U、Vの配列が全部繋がっています。

NV12

image07

同じく、分かりやすい説明が見つかりました(同上)。

image02

NV12のメモリ構造を見ると、最も頻繁に使われる理由が分かります。UVがセットで来るため、ループでとてもまわしやすい構造です。

Yが四つに対し、UVセット一つを使うので、とても自然なプログラムが書けます。

ここまで全部読んだら、一番最初の〇と×の図もちゃんと理解できるようになりました。×はluma = 輝度 = Y、〇はChroma = 色差 = UVということでした。

これで.mp4からテクスチャへの色変換の基礎知識が全部揃いました。計算式とメモリ上のデータ取得部分をプログラムへ組み込み、またMFTで試行錯誤して完成にこぎつけました。

最後に変換部分のコードを抜粋したものを掲載します:

#define CLIP(x) do{if(x < 0){x = 0;} else if(x > 255){x = 255;}} while(0)
#define CONVERT_R(Y, V)    ((298 * (Y - 16) + 409 * (V - 128) + 128) >> 8)
#define CONVERT_G(Y, U, V) ((298 * (Y - 16) - 100 * (U - 128) - 208 * (V - 128) + 128) >> 8)
#define CONVERT_B(Y, U)    ((298 * (Y - 16) + 516 * (U - 128) + 128) >> 8)

void ImplementationMovie::NV12ToRGB(u8* rgbBuffer, u8* yuvBuffer, int width, int height)
{
    u8* uvStart = yuvBuffer + width * height;
    u8 y[2] = { 0, 0 };
    u8 u = 0;
    u8 v = 0;
    int r = 0;
    int g = 0;
    int b = 0;
    for (int rowCnt = 0; rowCnt < height; rowCnt++)
    {
        for (int colCnt = 0; colCnt < width; colCnt += 2)
        {
            u = *(uvStart + colCnt + 0);
            v = *(uvStart + colCnt + 1);

            for (int cnt = 0; cnt < 2; cnt++)
            {
                y[cnt] = yuvBuffer[rowCnt * width + colCnt + cnt];

                r = CONVERT_R(y[cnt], v);
                CLIP(r);
                g = CONVERT_G(y[cnt], u, v);
                CLIP(g);
                b = CONVERT_B(y[cnt], u);
                CLIP(b);
                rgbBuffer[(rowCnt * width + colCnt + cnt) * 3 + 0] = (u8)r;
                rgbBuffer[(rowCnt * width + colCnt + cnt) * 3 + 1] = (u8)g;
                rgbBuffer[(rowCnt * width + colCnt + cnt) * 3 + 2] = (u8)b;
            }
        }

        uvStart += width * (rowCnt % 2);
    }
}

Kyo Shinyuu

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。