cv::VideoCaptureで動画から読んだRGB画素値の誤差について

:::note info
この記事は[OpenCV Advent Calendar 2022](https://qiita.com/advent-calendar/2022/opencv)の8日目の記事です．
:::

# 自己紹介的なやつ

ふと思い立って，5年ぶりにQiitaアカウントにアクセスしてOpenCV ACを書きます．（自分がアカウント放置してた間，Qiitaがプチ炎上したりしてユーザ離れが進んだらしいですねぇ…）

自分はIplImage時代を知る古老の世代で，今時のCV業界の標準である機械学習やらPythonやらを全く使っていない完全なポンコツです．ここ数年はなぜかC言語（注：C++じゃなく，malloc()とかrealloc()とかする原始的なC言語）で組み込みのコードを書く羽目になったり，生のsocketでTCP通信のコードを書く羽目になったりして，コンピュータビジョンのプログラムは書いておらず，したがって最近はOpenCVもほぼ触っていません．

以前に何度か書いたOpenCV ACは基本的に「OpenCV使ってたら，しょーもないトラップにハマった」的な小ネタでしたが，今回もそういう系っちゃそういう系で，動画デコードした画像のRGB値が不正確な場合があるという話です．今回は動画デコードの話を紹介しますが，本質的にエンコードでも全く同じ話がありますし，さらにいえば（OpenCVの実装がどうなのかは知りませんが，一般論で）Jpeg画像などにも同じ話があるかと思います．

# はじめに

自作プログラム内で動画再生する際にcv::VideoCaptreを使っている人は多いと思います．cv::VideoCaptureにはバックエンドがいろいろありますが，一般的にはFFmpegバックエンドが呼ばれるケースが多いはずです（LinuxだとGStreamerも使われてますが）．…ということで，OpenCVの話のふりをして実はFFmpegの話（正確にはOpenCVのFFmpegバックエンドの実装の話）です．よって，動画ファイル再生だけでなくRTSPなどの通信プロトコルで動画を受信する場合にも全く同じ問題があるはずです．

自分が以前にFFmpegの情報を探していた時にネットで見かけたサンプルコードも全て同じトラップを踏んでいた気がしますし，わりと世間に蔓延している実装上の落とし穴（平たく言えばバグ）だろうと思います．

なお，問題点を指摘するのが主題で，直し方についてはかなり手抜きですが，ご容赦を．

# 動画のフレームについて

古のMpegの時代から，動画圧縮の普遍的な前提として，エンコード・デコードに使うフレームはRGBではなく，YUVというデータ形式に変換してからUとVの解像度を落としたデータが使われています．

この「YUVに変換してUVを低解像度化」という画像変換は，実はJpeg圧縮でも全く同様の処理が使われています．人間の視覚には「明度変化への空間解像度が高く，色変化への空間解像度が低い」という特性があるため，その特性に合わせて，RGBを明度(Y)と色(UV)の情報へ変換してから色情報の解像度を落とすという処理を前処理として入れることで，より圧縮の効率を良くすることを目的としています．

この明度と色の視覚特性上の解像度の違いは，例えば「[白黒写真、色付き線を引いただけでカラー写真に？　ふしぎな錯視が発見され話題](https://nlab.itmedia.co.jp/nl/articles/1908/10/news009.html)」などで実感できます．

一口にYUV形式と言っても，「UVの解像度をどれくらい下げるか」と「YUVの3種類の画素をどう配置するか」について膨大なバリエーションがあります．ちょっと検索したらLinuxのV4Lの「[YUV Formats](https://www.linuxtv.org/downloads/v4l-dvb-apis-old/yuv-formats.html)」というページには20~30種類くらい出ていました．昔ネットでYUVフォーマットを調べていた時には，映像通信などの世界でさらに多数のバリエーションを見たような気がします．しかし，実用的には，昨今の標準的な動画コーデックである H264 (AVC) や H265 (HEVC) でエンコードされた動画データを見ると普通はYUV420かYUV422あたりになっていると思います．（なおH266(VVC)とかAV1については知りません．）

ただし，例えばエンコード済データが「YUV420でっせ」と名乗っていたとしても，実際に圧縮や伸張する際のフレームのデータ形式はコーデックの実装に依存するので（この辺りの実装をする場合は）注意が必要です．うろ覚えですが，たしか FFmpegのソフトウェアデコーダやx264,x265などのソフトウェアエンコーダはYUV420で入出力していた気がしますが，IntelやNVIDIAのHWコーデックはNV12で入出力するのが一般的です．（NV12やNV21などのデータ形式は画素配置が異なるだけで，含まれる情報自体はYUV420と同じなのですが，まぁハードウェア的にそっちの方が使いやすいんでしょうね．）

もちろん，使用するコーデックによってはYUV411のように更にUV画素数を削った形式などを使うケースもあるかもしれませんが，本稿で紹介する話はYUVのフォーマット（YUVの解像度および画素配置）とは無関係ですので，ここでは「動画で使うフレームの画素値はRGBではなくYUVである」ということだけ認識しておいてください．

# RGBとYUVの変換モデル

YUVの解像度とか画素配置の話は無視するものとして，RGBとYUVの間の数値変換について調べます．この数値変換は単純な線形変換で記述されています．どういう式かというと，…書くのが面倒なので[Wikipediaの記事](https://ja.wikipedia.org/wiki/YUV#RGB%E3%81%8B%E3%82%89%E3%81%AE%E5%A4%89%E6%8F%9B)あたりを参照してください．

リンク先によると，

- YUV (PAL, SECAM)
- ITU-R BT.601 / ITU-R BT.709 (1250/50/2:1)
- ITU-R BT.709 (1125/60/2:1)

の3種類の定義（color space，色空間）があります．そう，YUVとRGBの変換式は1種類ではなく，複数の色空間モデルがあるのです．現状で広く使われているのはこの3種類ということだと思いますが，実際にはこれら以外にも（有名どころだとBT.2020など）あります．

この3つの規格は線形変換の係数が微妙に異なるだけなのですが，なぜ似たようなYUV変換の標準規格が複数あるのかというと，表示デバイスがCRT，液晶，有機ELと進化してきた過程で物理的に出せる色域も拡大したので，時代に合わせて新しい規格が追加されてきたということのようです．（ネットでは「BT.601がSD世代でBT.709がHD世代」みたいな説明も見受けられます）．そう聞くと，まぁしょうがないかという気になりますね．

当然，エンコードする際のRGB→YUV変換処理とデコードする際のYUV→RGB変換処理には共通の色空間を使う必要があります．もしもデコード処理でエンコードと異なる色空間を使ってRGBへ変換すると，本来の映像とは若干異なる色合いになります．しかし，**根本的に色がおかしくなったり映像が破綻したりするわけではないので，もし変換モデルを間違えていても気づかないかもしれません**．

さて，先述の3つの色空間モデルですが，現実的には現在の動画圧縮で必要な色空間はBT.601とBT.709の2種類が多いと思います（もちろん，それ以外の色空間の出番が無いわけではないですが）．ということで，これら2つの定義をよく見ると，この2つはRGBから（YUVではなく）YCbCrに変換するように定義されています．この変換式をもう少し掘り下げてみると，

- RGB値は8bit整数ではなく0.0~1.0の浮動小数点数で定義
- それらRGBを0.0~1.0のYと，-0.5~0.5のCb,Crという値に変換

となっています．RGBもYUVも実際には8bit整数で扱うわけですが，RGBとYは1/255倍とか255倍とかでスケールすれば8bit整数と浮動小数点数の相互変換ができるので問題ないでしょう．Cb,Crは0.5のオフセットをしてから同様にスケールすることで8bitのU,Vとの相互変換ができます．ということで，（諸々の計算誤差を無視すれば）理屈としては完全に可逆な変換モデルですすし，（SIMD化とか面倒なことを言わなければ）実装も難しいことはありません．

なお本稿と直接の関係は無いですが，OpenCVのcv::cvtColor()にもRGBとYUVの相互変換処理が実装されているので，YUVはお馴染みという方も多いかと思います．OpenCVに実装されているのはBT.601なので，BT.709などは扱えないことに注意が必要です．

# 動画のYUVデータの確認

FFmpeg に同梱のコマンド ffprobe を使ってYUV色空間の確認をすることができます．例えば [https://inter-stream.jp/interstream_support/ems/08_05.html](https://inter-stream.jp/interstream_support/ems/08_05.html)にある[bun33s.mp4](http://docs.evostream.com/sample_content/assets/bun33s.mp4)を調べてみると

```
$ ffprobe.exe -hide_banner bun33s.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'bun33s.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf56.40.100
  Duration: 00:00:33.04, start: 0.000000, bitrate: 943 kb/s
  Stream #0:0[0x1](eng): Video: h264 (Constrained Baseline) (avc1 / 0x31637661), yuv420p(tv, smpte170m/smpte170m/bt709, progressive), 480x270, 824 kb/s, 25 fps, 25 tbr, 19200 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
  Stream #0:1[0x2](eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 127 kb/s (default)
    Metadata:
      handler_name    : SoundHandler
      vendor_id       : [0][0][0][0]
```

と表示されて，``yuv420p(tv, smpte170m/smpte170m/bt709, progressive)``とわかります．一方，同ページのサンプル[sintel1m720p.mp4](http://docs.evostream.com/sample_content/assets/sintel1m720p.mp4)を調べると，

```
$ ffprobe.exe -hide_banner sintel1m720p.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'sintel1m720p.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    creation_time   : 1970-01-01T00:00:00.000000Z
    title           : Sintel Trailer
    artist          : Durian Open Movie Team
    encoder         : Lavf52.62.0
    copyright       : (c) copyright Blender Foundation | durian.blender.org
    description     : Trailer for the Sintel open movie project
  Duration: 00:00:52.21, start: 0.000000, bitrate: 1165 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1280x720, 1033 kb/s, 24 fps, 24 tbr, 24 tbn (default)
    Metadata:
      creation_time   : 1970-01-01T00:00:00.000000Z
      handler_name    : VideoHandler
      vendor_id       : [0][0][0][0]
  Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 126 kb/s (default)
    Metadata:
      creation_time   : 1970-01-01T00:00:00.000000Z
      handler_name    : SoundHandler
      vendor_id       : [0][0][0][0]
```

と表示されて，``yuv420p(progressive)``となっていて，色空間の定義が不明です（この場合は完全に不明なので運任せということになってしまいますが，まぁ世間にはこういうデータもちょいちょい見かけます）．

色空間が不明な場合はしょうがないとして，指定されていた場合にはちゃんと正しく変換する必要があります．

# サンプル

BT.601とBT.709について，RGB→YUV→RGBという変換をそれぞれ試すと以下のようになります（静止画に見えますが，motion gifです）．どちらも正しく元のRGBに戻されているので，違いはわかりません．

![movie00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105294/6f4cf3b1-e2de-05ab-a133-8e44486b7159.gif)

一方，RGB→YUVとYUV→RGBで異なる方式を使って変換すると元に戻らないため，それぞれ以下のようになります．主に色合いが変わってしまい，チラつきが見えます．

![movie01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105294/dc12b1d8-6f72-88e3-0df4-60ffd225528e.gif)

# YUVからRGBへの変換処理の実装

YUVに色空間の定義が複数あることがわかったところで，実際にOpenCVのFFmpegバックエンド内部でデコーダ出力のYUVフレームからRGBフレームに変換している処理を見てみましょう．現時点の最新リリース版4.6.0の[videoio/src/cap_ffmpeg_impl.hpp](https://github.com/opencv/opencv/blob/4.6.0/modules/videoio/src/cap_ffmpeg_impl.hpp)を見ると

```cap_ffmpeg_impl.hpp: 1589行目以降
        // Some sws_scale optimizations have some assumptions about alignment of data/step/width/height
        // Also we use coded_width/height to workaround problem with legacy ffmpeg versions (like n0.8)
        int buffer_width = context->coded_width, buffer_height = context->coded_height;

        img_convert_ctx = sws_getCachedContext(
                img_convert_ctx,
                buffer_width, buffer_height,
                (AVPixelFormat)sw_picture->format,
                buffer_width, buffer_height,
                AV_PIX_FMT_BGR24,
                SWS_BICUBIC,
                NULL, NULL, NULL
                );
```

で色空間変換（＋画像スケール変換もできる）をする``SwsContext *img_convert_ctx``を生成しています．引数は入出力の解像度と画像フォーマット，そしてスケール変換のアルゴリズム（bicubicが指定されているが，この場合はスケール変換しないので何を指定しても構わない）です．出力フォーマットがRGB24ではなくBGR24というあたりにOpenCVみを感じますね！

その後，同じファイルの100行ほと先の部分

```cap_ffmpeg_impl.hpp: 1632行目以降
 sws_scale(
            img_convert_ctx,
            sw_picture->data,
            sw_picture->linesize,
            0, context->coded_height,
            rgb_picture.data,
            rgb_picture.linesize
            );
```

で``AVFrame *sw_picutre``から``AVFrame *rgb_picture``へと変換しています．

このAVFrameという構造体ですが，[enum AVColorSpace AVFrame::colorspace](https://ffmpeg.org/doxygen/5.1/structAVFrame.html#a9262c231f1f64869439b4fe587fe1710)として色空間の情報を持っています．例えば``AVCOL_SPC_SMPTE170M``だった場合はBT.601で，``AVCOL_SPC_BT709``だった場合はBT.709です．あるいは，もっと別の色空間が指定されているかもしれません．

さて，もう一度コードをよく見てみましょう．

…あれ？この一連のコードって，``sw_picture->colorspace``を参照してないような？

そう，実はこのコードだとYUV色空間の指定がされず，デフォルトのモデル（…が何なのか不明ですが）で変換されてしまいます．では，これをどう修正すれば良いでしょうか．

まず，（これは本質ではないですが）colorspaceを``sw_picture``から取得するのも良いですが，自分はデコーダから取ってくる方法の方が好みです．デコードに利用したコーデックのコンテキストは，このソースファイル内では``AVCodecContext *context``です．したがって，``sw_picutre->colorspace``ないしは``context->colorspace``を調べれば良いことがわかります．それらの色空間の情報を``img_convert_ctx``に反映する方法を

　
　
　
　
　
***<font color="blue">突然ですが，ここで残念なお知らせがあります．</font>***　
　
　
　
　

:::note warn
**さっき「YCbCr（浮動小数点数）からYUV（8bit整数）への変換は（Cr,Cbは0.5のオフセットを加味して）255倍すれば良い」と説明したな？**
:::
　
　
　
　
　

:::note alert
**<font color="red">あれは嘘だ</font>**
:::


# tvとpcの違い?

先ほどのffprobeの出力で``yuv420p(tv, smpte170m/smpte170m/bt709, progressive)``と表示されていましたが，よく見ると'tv'というキーワードがあります．これこそが真の(?)トラップです．

RGB↔YCbCr（YUVではなくYCbCr）の色空間変換モデルであるBT.601とBT.709については述べましたが，実は，それぞれに対して，さらにYCbCr(float)↔YUV(8bit)の変換処理には2種類のスケールモデル（color rangeと呼ばれているらしい）：

- pc / Jpeg / full range
- tv / SDI / Mpeg / limited range

があるのです．YCbCrデータからYUVへ変換する際，どちらで変換しても出力YUVは8bitですが，前者は値の範囲が0~255なのに対して，後者は8bitの中の16~235(Y)および16~240(UV)という狭い範囲しか使いません．限られた8bitの中でわざわざ上下を捨てて狭い範囲で量子化するって，ちょっと意味がわからないですよね．

変換時にcolor rangeを誤った場合は，例えば暗部に注目すると，「本来の明度は0の領域が，明度16に持ち上げられてしまう」とか，逆に「本来は明度1~16の画素が全て明度0になって真っ黒に潰されてしまう」みたいなことが起きるわけで，色空間を誤った時よりもかなり目立ちます．しかし，これも**根本的に色がおかしくなったり映像が破綻したりするわけではないので，もし変換モデルを間違えていても「なんか妙に暗部が持ち上がった動画だな」くらいに思って気づかないかもしれません**．

わざわざ量子化レベルを落とす'tv'規格の存在理由を理解するためには，前世紀の事情を知る必要があります．昭和の時代にはまだ地デジも液晶TVも無く，TVはアナログ信号の電波を受信してブラウン管で表示していました．当時のご家庭では，巨人戦の中継を見たいお父さんとプログラムを書きたいお兄さんのTV画面の奪い合いが…　などということは無く，PCにはPC専用のモニタが必要でありました．

前記した2つの規格に'pc'と'tv'という通称が設定されていることからもわかるように，Jpegは最初からPCのデジタル表示（あるいは計算機で処理する入出力データとしての用途）が前提だったようですが，MpegはTVをサポートする必要（需要？）があったようです．つまり，Mpegのデコード結果からDA変換された映像信号を表示することが想定されていたようですね．

今の皆さんはご存じないでしょうが（自分もよく知らない時代の話です），実はアナログTVの時代のカラー映像の信号はRGBではなくYCbCrだったそうです．というのは，さらに昔の白黒TVの時代からカラーTVへの移行期に，「既存の白黒映像のY信号と，そこに付加するカラー情報のCbCr信号」という形で白黒TVとカラーTVで信号の互換性を持たせるのが都合が良かったらしい，というような昔話をかつて聞いたことがあるような無いような…

そして，アナログ信号はデジタルの世界と違って全てが連続的な（滑らかにつながった）信号です．例えばデジタルで最小値0と最大値255が連続するようなギャップの大きい信号があった場合，アナログ信号では（たとえば[マクニカさんのところの図](https://www.macnica.co.jp/business/semiconductor/articles/133367_img3.png)に描かれているような）オーバーシュートが起きて本来のレンジ(0.0~1.0)を超えるノイズが発生してしまいます．つまり明度や色が大きく変動する場所（デジタル画像処理でいう「エッジ」）では，アナログ信号の原理的な特性で**常に**強烈なノイズが発生して明度や色がおかしくなるわけです．そこで，アナログ信号処理が安全な範囲内に収まるように（そうすれば後処理で対処できる）マージンを持たせたのがこの「上下を捨てて幅を狭める」という規格…ということらしいです．

動画のYUVフレームの話に戻ると，以上のような理由で，実際にはRGB-YUVの変換モデルとしては

- BT.601のpcスケール
- BT.601のtvスケール
- BT.709のpcスケール
- BT.709のtvスケール
- BT.ほにゃららのpcスケール
- BT.ほにゃららのtvスケール
　　：

と，それぞれの色空間モデルに対して，カラーレンジ2種類をサポートする必要があります．

「そんなこと言ったって，量子化を削る'tv'方式なんていう無駄な方式は今の時代には誰も使ってないんじゃないの？」と思った貴方，さっき紹介したデータを思い出してください．'tv'でしたよね？現在でも，サンプルとして配布されるデータにも普通にうっかり（おそらく意図的なものではないと思う）使われちゃうくらいには一般的な存在です．

# サンプルふたたび

先ほどと同様，BT.601とBT.709それぞれのpcとtvの計4種類について，RGB→YUV→RGBと処理した結果が以下の動画です．4枚あるmotion gifなのですが，やはり違いはわかりませんね．

![movie10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105294/86151424-ea7f-6452-6afd-b2ad51eaf039.gif)

一方，RGB→YUVとYUV→RGBで異なるモデルを使った場合（12種類）は以下のようになります．pcとtvの誤りによって色空間の誤りよりも誤差が目立つことがわかります．色空間の誤りの場合と異なり，白やグレーの部分にも影響が大きいです．

![movie11.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105294/636d0097-2bac-ffe7-69fd-ee810aaa1ff5.gif)

# 正しい実装方法

先述したようにcap_ffmpeg_impl.hppの1589行目で``SwsContext *img_convert_ctx``を生成していますが，その生成後に，これも先述した``AVCodecCotnext *context``あるいは``AVFrame *sw_picture``から変数``colorspace``および``color_range``を参照して

```
if(sws_setColorspaceDetails(img_convert_ctx,
                            sws_getCoefficients(context->colorspace), // input colorspace
                            (context->color_range == AVCOL_RANGE_JPEG ? 1 : 0), // input color range
                            sws_getCoefficients(AVCOL_SPC_RGB), // output colorspace 
                            0, // output color range (ignored in RGB case)
                            0, (1<<16), (1<<16)) < 0){
  // error handling
}
```

みたいにすれば直る…はずなのですが，自分は試していません．（なぜならば自分がOpenCVを使ってないから．もちろん生のFFmpegを使ったコードではテスト済です．）

なお，上記の最後の3つの引数``0``，``(1<<16)``，``(1<<16)``はネットを徘徊して探し当てた設定ですが，[オンラインマニュアル](https://ffmpeg.org/doxygen/5.1/group__libsws.html#ga541bdffa8149f5f9203664f955faa040)を見ても「何のこっちゃ」という感じで，これらの数値が具体的に何をどうしているのかはわかっていません（無責任）．

# あとがき

いかがでしたか？今回はYUV色空間について調べてみました．

YUV色空間の問題はけっこう面倒で，たとえば[ブラウザ上での色の再現性の問題](https://gigazine.net/news/20210528-browser-different-colors/)なんて話もあったりします．ちなみにGoogleがChrome用に開発している[libyuv](https://chromium.googlesource.com/libyuv/libyuv/)では[convert_argb.h](https://chromium.googlesource.com/libyuv/libyuv/+/refs/heads/main/include/libyuv/convert_argb.h)を見るとBT.601，BT.709，BT.2020それぞれのpc，tvが実装されているようなので，そちらを使うのも良いかもしれません（自分は使ったことないですが）．

ということで，このOpenCVのFFmpegバックエンドに潜む問題は動画を扱う人にはそこそこ影響あるバグだと思います．なぜか今まで誰も気付かなかったようですが，これを修整したら皆が（それとは気付かずに）幸せになれるはずなので，誰か頑張ってください！（例えば，明日のAdvent Calendarの担当者の方とか，いかがでしょうかね？）…と，ほぼ名指しで丸投げして本稿を終えます．

:::note info
[OpenCV Advent Calendar 2022](https://qiita.com/advent-calendar/2022/opencv)の明日の記事は[@tomoaki_teshima](https://qiita.com/tomoaki_teshima )さんの予定です．
:::