Color programming tips with C++ samples: RGB24(16777216色)に対しHSL24は十分な分解能を持っているか?
今回は色の話。PCでは長らく16777216色を誇るRGB24がディスプレイはじめ色を取り扱う際の標準として利用されてきた。RGBなのかBGRなのかなどは今回の本題では無いのでスルーする。また、制限された環境におけるRGB16やRGB15であるとか65536色中同時256色であるとかシステムカラー固定8色であるとか、或いは一部特別な状況やデータ形式でRGB各14bitだとかHDRが…とかの話も今回は本題では無いのでスルーで(;´∀`)
『16777216色を誇るRGB24に対してHSL24は十分な分解能を持っているか?』
この問いに即答できるだろうか?ちょっと考えれば直ぐに答えが出るかもしれない。曰く、
YES
と。
確信を持ってYESと答えられたならどうぞ続きを読んで行って下さい。NOと答えられたなら読まなくても良いかも。でも本当かな?少しでも自信が無い方はどうぞ(´ω`)
RGB24 = R8G8B8 の情報量
色をR,G,Bの各色に分解し各8bitsの分解能で標本化して表す。通常はR,G,Bの各標本には単純に整数を用いる。よってRGB24が表す色の情報量は当然24bitsで、これは組み合わせの総数にすると pow(2,24) = 16,777,216 通り(≈色数)。
HSL の情報量
HSLを考える時、幾つかのパターンが考えられる。
H8S8L8 の情報量
RGB24に倣って最も単純に考え得る色のデータ構造はH8S8L8で各要素が整数のパターン。さて、これは本当に24bitsの色情報を持っているだろうか?
少なくともH8S8L8としては24bitsの情報量を持っている事は確かだ。でも、実際のPC上ではHSLをネイティブに扱うディスプレイデバイスは恐らく無くて、色が実際にディスプレイから発色されるまでのどこかでRGB24に変換されていると考えられる。これは本当に24bitsの、つまり16,777,216通りの色を扱えているだろうか?
この答えを求める為に HSL → RGB の色空間の変換アルゴリズムを知る必要がある。
ENのWikipediaのHSL色空間の記事は詳細で丁寧で、 HSL → RGB の変換アルゴリズムも掲載されている。これを基にC++で実装すると、
RGB24とHSL24は、
struct RGB24 { uint8_t r, g, b; };
struct HSL24 { uint8_t h, s, l; };
こんな具合にしておいて、変換アルゴリズムの実装は、
こんな具合に。
入力のHSL24では各8bitのフルレンジ[0-256)にH(色相≈色角度)、S(彩度)、L(明度)を割り当てる。変換アルゴリズムの参考としたENのWikipediaの記事ではHを弧度法で扱っているので [0-360)° を扱っているけれど、これは平面角の次元を持っていれば単位は何だって構わないので、 ラジアンを採用すれば [0-2π) rad でも構わない。
でも弧度法で0°から360°を1°の分解能として整数でカバーするには勿論8bitでは足りない(0..359の360通りの状態を表す為には log(360,2)≈8.492 bits の枠が必要)ので、弧度法を採用する意味は無い。また、ラジアンは少数を扱う必要があるので8bitで一般的な少数を扱う仕組みを組み込むのはちょっと現実的じゃない。それで例示した実装では H は 0..255 の整数に unorm (符号無し正規化値: 0.0-1.0)をマップする形で円周"度"みたいに扱っている。
また、この実装では変換アルゴリズムの内部で用いる浮動小数点数の分解能の都合でも変換による情報損失が発生する可能性があるから、とりあえず内部型の影響を変化させて観察しやすい様にテンプレート関数にしてみた。(実際にこれはIEEE754的に言うところのBinary32, Binary64, Binary128の3つのセットを使って実験してみたよ。)
と、言う訳で実験的に HSL24 → RGB24 が16777216色を出力できるのかやってみる。
実験ソース
先ほどの変換アルゴリズムに尾びれ背びれ胸びれなどを付けてみた。
最初に簡単な実装で…と思って書き始めてから、後でちょっとだけゴテゴテ付け加えてしまったのでちょっと設計的にどうなのと言う残念な気持ちになったであろう事は分かる。申し訳ない・w・;
内部型の浮動小数点数について
Makefileから3通りの実行ファイルが生成されます。 binary32 binary64 binary128 はそれぞれ変換アルゴリズムの内部型に float double __float128 を用いてビルドする様に仕込まれています。__float128はGCC4.6以降で使用できる拡張仕様で、気になった方は以下の日本語記事が参考になると思います。
.cxx にて ADOPT_BINARY32 ADOPT_BINARY64 ADOPT_BINARY128 の定義状態によりCPPで簡単なソースコードの特殊化が行われる様にしています。
Boost.GIL について
ちなみに Boost.GIL には HSL を扱うコードが今のところ含まれて居ません。また、このサンプルでは Boost.GIL はPNG形式の画像として結果を保存する為の libpng のラッパーとしてしか使っていません。(libpngとかCスタイル過ぎて使うだけでワヒャヒャヒャヒャヒャとかむず痒くなるよ!)。この程度の簡単な使い方であればLet's Boostの以下の記事を参考にすると良いです。
OpenMP について
ソフトウェア浮動小数点数となる __float128 を用いる場合、CPU組み込みで扱える float double に比べて著者環境でも凡そ 7 倍の計算時間が必要でした。具体的には float double が6秒弱で全体の処理を終えるのに対し __float128 は40秒少々を要しました。そこで、単純な処理ですから処理を平行化しておこう、となるわけです。
なるわけですが、C++11 threadを使うまでもない本当にちょこっと、の平行化なので気まぐれにOpenMPでプラグマ1行書いて済ませてみる事にしました。尾びれ背びれ胸びれと付けてしまったのでOpenMP採用に伴うコード量の増加は数行になってしまいましたが、OpenMPの基本はソースコード中で「①このコードブロックで並行処理させたいぞ!」と「②このforを適宜に分割して平行化してくれ!」を指示するだけでよしなに並行処理するバイナリーを生成してくれます。
-fopenmp を付けなければ通常はエラーや警告も出ずにOpenMPを使わないバイナリーも生成できますし、今回程度の緩やかな平行化のニーズにはぴったりです。ちょっとここ平行化しといたら嬉しいかな〜程度ではなく、やらなければいろいろと死んでしまうのだ!みたいな状況ではOpenMPでは平行処理の最適化の粒度やプログラマビリティが悪いけど、このお手軽さは良い・w・
OpenMPの入門の参考としては下記の日本語の記事が分かりやすく整理されていて良いかもしれません。
実験環境
- Softwares
- Hardwares
実験結果
duplicated
HSL24→RGB24の全単射の結果、重複したRGB値が得られていました。変換アルゴリズム内部の分解能に応じても結果が異なりました。重複数と失われた情報量は、
重複数 | 実質情報量[bits] | 色数[colors] | %損失 | |
---|---|---|---|---|
基準RGB24 | 0 | 24 | 16,777,216 | 0 |
binary32(387) | 537,899 | 23.952 988 | 16,239,317 | 3.206 1 |
binary32(SSE) | 577,789 | 23.949 439 | 16,199,427 | 3.443 9 |
binary32(SSE+387) | 835,358 | 23.926 316 | 15,941,858 | 4.979 1 |
binary64(387) | 581,879 | 23.949 075 | 16,195,337 | 3.468 3 |
binary64(SSE) | 676,795 | 23.940 595 | 16,100,421 | 4.034 0 |
binary64(SSE+387) | 646,115 | 23.943 342 | 16,131,101 | 3.851 1 |
binary128(387) | 717,945 | 23.936 903 | 16,059,271 | 4.279 3 |
binary128(SSE) | 717,945 | 23.936 903 | 16,059,271 | 4.279 3 |
binary128(SSE+387) | 717,945 | 23.936 903 | 16,059,271 | 4.279 3 |
こんな結果に。実行バイナリーの 387 SSE SSE+387 はGCCのオプション -mfpmath に対応します。こまけぇ〜事はいいんだよ!という方は(SSE)だけで見比べても構いません。これには計算精度の良し悪しの他に計算内容による偶発的な要素も関わりますので(´・ω・`)
ところでそもそも HSL24 は何色か?
3つのパラメーターを256通りに組み合わせられるのでHSL24のパラメーターの組み合わせの総数はRGB24のそれと同じで当然16777216通りです。RGB24では3つのパラメーターのうち1つでも異なる色同士は別の発色となるので16777216色です。HSL24ではどうでしょう?
色相 H の値を 0 rad = 0 ° → uint8_t(0) から 2π rad = 360 ° → uint8_t(255) とマッピングすれば 0 と 255 が同じ色相となってしまい、実装による損失となります。この点は 0 rad → uint8_t(0) から 2π → uint8_t(256) = uint8_t(0) とマッピングされる様に正しく実装すれば問題になりません。実装上は uint8_t では 256 は扱えないので、256という値を扱う部分が必要であれば、そこはuint_fast16_tを使うとかdoubleを使うなどして慎重に実装しましょう。
彩度 S と 明度 L は範囲の上下は文字通り両極端でループしませんので、こちらは 0 .. 255 に正規化してマッピングすれば良いでしょう。
さて、問題はこの S と L です。
彩度 S は 色相 H の発色の良さ、即ち彩度ですから、S=0の時はあらゆるHに対して同じ色を呈します。S=0の世界は灰色(グレースケール)の世界になります。よってHSL24ではS=0の時、HとLによる呈色の組み合わせはLのみに依存しL=0..255の256通りしかありません。(8+8=16)bits=65536通りの値の組み合わせの内、256通りの他は全て重複した同じ色を呈します。よってこの時点でHSL24はRGB24に対して65,280色少ない事になります。
明度 L は文字通り明るさで、L=0だと真っ暗で物体表面から光がカメラなり目なりへ届かない状態です。また、L=255だとカメラの光センサー素子なり目の受容体なりが完全に飽和してしまい色が分からない極めて眩しい状態です。つまり。L=0またはL=255の時は、あらゆるHとSの組み合わせに対して「真っ黒」または「真っ白」しか呈色しません。よってHSL24はRGB24に対して、 L=0 と L=255 の時、HとSによる各 65536 通りは各 1 通りしか呈色せず、Lを要因としても RGB24 に対し 65,535*2=131,070 色少ない事になります。
と、言う訳でHSL24は理論上RGB24に対し 65,280+131,070=196,350色少ない事になり、HSL24が呈色可能な色数は16,580,866色となります(HSL24=H8S8L8とした場合)。これはRGB24の持つ色空間よりHSL24の持つ色空間は 1.170 3 % 小さい事になります。
実際には今回のHSL24→RGB24の変換結果は3〜5%の損失が生じて居て、理論上の損失に加えて計算上の損失も大きく影響していた事が分かります。
記事が長くなって来たのでぷちまとめ
HSL24はRGB24に対して理論的に重複した呈色を含むため 1.1703% も色空間が狭い。加えてHSL24→RGB24の変換計算でも呈色の損失が生じ、全体としてRGB24に対してHSL24で呈色可能な色空間は3〜5%程度狭くなってしまう。
必要に応じて、対応策としてはHSL24のH、S、Lの各情報量をヘテロに構成(例えばH12S6L6とか)する方法、そもそもRGBより多い情報量をHSLの色情報の保持に割り当てる(例えばHSL32とかHSL96とか)が考えられる。それはまたおいおい記事にするかも。