RGB値は255で割って正規化すべきか、256で割るべきか?
(30fps.net)- 8ビット整数の色を浮動小数点に変換する際の、255で割る標準方式と0.5のバイアスを加えて256で割る代替方式の違い
- 255方式は整数の0を0.0、255を1.0にマッピングするため、黒と白を直接扱いやすく、GPUのUNORM-to-float変換方式とも一致する
- 256方式は
(img + 0.5) / 256.0によって各値を区間の中央に置くため、ディザリングのような処理で境界の扱いを単純化できるが、0が0.0にならないため、処理ロジックが8ビット入力に縛られる - 255方式では両端の区間幅が半分になるため、一様な
[0, 1]の乱数を再び8ビットへ丸めると、0と255は他の値の半分の頻度でしか現れないが、実際の画像の往復変換は損失なく動作する - 他人の画像を処理するなら255で正規化するのが正解で、保存・読み込みの両方を制御できる場合にのみ256方式を検討できる
問題設定
- 画像を受け取って浮動小数点に変換し、処理後に再び8ビット色として保存するプログラムでは、整数-浮動小数点変換方式が争点になる
- 2つのアプローチがある
- 標準方式(255で割る):
pixels = img / 255.0→ 処理 →output = np.trunc(result * 255 + 0.5) - 代替方式(256で割る):
pixels = (img + 0.5) / 256.0→ 処理 →output = np.trunc(result * 256) - どちらの場合も最終的な型変換前に値を0〜255へ制限する:
output.clip(0, 255).astype(np.uint8)
- 標準方式(255で割る):
- 標準方式は整数の0を0.0、255を1.0にマッピングし、GPUのUNORM-to-float変換方式と同じ
- 代替方式は0.5のバイアスを加えるため、整数の0は
0.5/256 = 0.001953125にマッピングされる- このため、この定数を知らなければ黒画素を検出できない
- 浮動小数点計算を行っていても、ロジックが8ビット入力に縛られる
- 標準方式では常に黒を0.0とみなせる
255.0への反論
- 標準方式を数直線上に描くと、やや奇妙に見える
-
両端に小さいビン(Bin, 区画)がある
- 標準式の両端のビンは [0,1] の範囲外にはみ出しており、範囲が「引き伸ばされた(stretched)」形になっている
- 浮動小数点を整数へ戻すとき、両端ビンの幅は他のビンの半分しかない
- アルゴリズムで極端値を出力しにくくなる
- 一様な [0,1] ノイズを生成して標準式で丸めると、0と255は他の整数の半分の頻度でしか発生しない
- 100万個の一様乱数のヒストグラムを見ると、0と255のビンが他より半分の高さであることを確認できる
- ただし、この極端値回避バイアスが実際に問題になる場面は思い浮かびにくい
- 元画像は依然として損失なく往復変換(uint8 → float → uint8)できる
- 0.0や1.0を少し外れた結果も正しいビンへ丸められ、出力分布は均される
- 例: 処理過程で色から0.005を引く場合、標準方式では黒が0未満まで下がり、代替方式では正のままだが、どちらの方式でも最終的には整数0が出力される
-
不正確さ
- 標準方式の浮動小数点値は正確ではない。たとえば
128/255.0 ≈ 0.501961だが、128/256.0 = 0.5 - 丸め誤差により浮動小数点値同士の間隔はごくわずかに変動するが、誤差は極めて小さく実用上の問題ではない
- 32ビット浮動小数点は23ビットの仮数を持ち、誤差は最下位ビット相当で
2⁻²³未満 - 相対誤差0.00001%は精密な画像処理でも無意味で、この不正確さは技術的問題ではなく美的な問題にすぎない
- 32ビット浮動小数点は23ビットの仮数を持ち、誤差は最下位ビット相当で
- 標準方式の浮動小数点値は正確ではない。たとえば
-
整数範囲に属さない値
- 代替方式は各浮動小数点値を2つの整数のちょうど中間に配置する
- 元の量子化値は分からないため、連続する2整数の平均点を使うのは妥当な折衷案といえる
- ディザリングに便利だという主張がある(Andrew Keslerの2015年ブログ記事「Converting Color Depth」)
- エッジケースを気にせずノイズを追加できる
- 一方、標準式の不自然な極端値は、ノイズ分布の一貫性を保つために慎重な処理が必要になる
- 代替方式は各浮動小数点値を2つの整数のちょうど中間に配置する
2種類の量子化器
- 2つのアプローチは、**一様スカラー量子化器(uniform scalar quantizer)**の2種類とみなせる
- Wikipediaの量子化の記事によれば、符号付き入力データの一様量子化器は2つの型に分類される
- mid-tread: 0を再構成レベル0にマッピングする(階段の踏面に相当)
- mid-riser: 0を分類しきい値0にマッピングする(階段の蹴上げに相当)
- Wikipediaは出典として1977年の論文(Allen Gresho, 「Quantization」)を引用している
- 量子化器の式(Lは出力レベル数。例: 256)
- mid-tread階段量子化器: エンコード
k = trunc(xL + 0.5)、デコードyₖ = k/L - mid-riser階段量子化器: エンコード
k = trunc(xL)、デコードyₖ = (k+0.5)/L
- mid-tread階段量子化器: エンコード
- これを両方式に当てはめると
- 標準式 = mid-tread(L=255)
- 代替式 = mid-riser(L=256)
- 標準方式は符号なし入力にmid-treadを使いながらL=255を選んだ組み合わせで、8ビット入力に最適とは言えない
- 両端を0.0と1.0にマッピングするという、プログラミング上の利便性を優先した選択
-
量子化誤差は大きいが、実際にはそうでもない
- 一様分布する実数 x∈[0,1] を8ビット整数へエンコードし、再び実数へ再構成するシステムであれば、標準式は帯域を無駄にしている
- 標準方式の表現可能範囲は
[-0.5/255, 255.5/255]で、[0,1] 入力に必要以上に広く、そのぶん再構成誤差が増える - StackOverflowユーザー Peter Mudrievskij の計算では、平均絶対誤差は分母255で
1/1020、分母256で1/1024となり、理論上は256で割る方がわずかに高精度
- 標準方式の表現可能範囲は
- しかし実際には、そのような再構成をしているわけではない
- 前提は8ビットRGB画像を読み込み、処理し、再保存することにあり、保存時の量子化方式は制御できず、失われた情報は永久に戻らない
- 画像が標準式で乗算・丸めされて保存されていたなら、読み込み時に256で割っても精度は復元できない
- 保存と読み込みの両方を制御できる場合にのみ、再構成誤差の小ささという主張に意味がある
- 他人の画像を代替式で読み込むと、むしろより大きな誤差を生む
- たいていは標準式で量子化されている可能性が高く、誤ったスケールでデコードすれば理論上は不正確になる
- 実際には色は絶対測定値ではないため、少し狭いレンジにわずかなオフセットを加えて処理する程度にすぎない
- 2つの量子化器のエンコード段階とデコード段階を混ぜてはいけない。壊れたコードになりやすい典型例である
- 一様分布する実数 x∈[0,1] を8ビット整数へエンコードし、再び実数へ再構成するシステムであれば、標準式は帯域を無駄にしている
結論
- 見知らぬ誰かから渡された画像を処理するなら、RGB値は255で正規化すべき
- 不正確な浮動小数点値や、抽象的な再構成誤差への懸念は、代替方式を選ぶ十分な理由にはならない
- 画像の保存と読み込みをどちらも制御でき、0を0にマッピングする必要がなく、処理コードが8ビットのダイナミックレンジに縛られても構わないなら、256で割ってわずかに高い精度を得られる
- ただし、同僚が標準式で画像を読み込んで計画を台無しにする可能性には注意
別の見解
- Jonathan Blowの2002年の記事では、mid-riserとmid-tread量子化器を名前なしで扱っており、図のアイデアの出典になっている
- Andrew Keslerの2015年ブログ記事は代替式を擁護している
- ただし比較対象が丸めなしの標準式であるため、分析の大半は無効になる
2件のコメント
Hacker Newsの意見
色の値が厳密に何を意味するかは、成分ごとに8ビットであればたいてい大した問題ではない。分母が255か256かの違いで生じる誤差はごく小さく、違いを見分けるには色覚が鋭く、しかも画面にかなり近づく必要があり、モニターやスマホ画面自体も普通は校正されていないからだ
ただし、マイクロコントローラでVGA信号を作り、色出力ピンが8本しかない(赤3、緑3、青2)場合はかなり厄介になる。このとき色の値は、VGAモニターに送るべき0V〜0.7Vの電圧レベルそのものになる
青チャネルは 0→0V、1→0.23V、2→0.47V、3→0.7V に対応し、赤/緑は 0→0V、1→0.1V、…、7→0.7V に対応する。両端を除くと、青の電圧は赤/緑の電圧とまったく一致しないため、純粋なグレーを見ることができず、最も近い色でも差の向きによって少し青みまたは黄みが混じる
さらに、青を他のチャネルと混ぜるほぼすべてのグラデーションもずれて見える。たとえば純粋な赤から純粋な白へ向かう線上の最も近い色は、ややオレンジや紫っぽく見える
Raspberry Pi Pico 2 で、ダブルバッファの 320x240 フレームバッファを使って8ビットカラーのVGA出力を行うコードはここにある: https://github.com/moefh/pico-vga-8bit-demo
こうすると小さい値と大きい値の差がずっと際立つ: 2^2.2 = 4.595, 255^2.2 = 196,964.699
30Hzで切り替わるなら、人間が少し青っぽい色と少し黄っぽい色の違いを見分けるのは難しそうだ
255を支持する論拠として、白黒画像という極端な例を見ればよい。1ビットでは 0 は黒、1 は白だ
0 は 0.0 に、1 は 1.0 に対応すべきだというのはかなり明白だ。白黒であって、明るい灰色(0.25)と暗い灰色(0.75)ではないからだ。つまり白黒画像は 2 ではなく 1 で正規化する
2ビットなら通常、0=黒、1=明るい灰色、2=暗い灰色、3=白なので、0.0、0.33、0.66、1.0 に対応させるのが自然だ。黒は黒、白は白であるべきで、間隔も等しいべきなので、3 で正規化する
この論理を8ビットまで延ばすと、255で正規化することになる。8ビットでは差はごく小さくなるとしても、黒は0.0、白は1.0であるべきだからだ
別の方法として8ビットで256正規化を使うと、出力範囲がビット数に応じて変わってしまう。1ビットなら [0.25, 0.75]、2ビットなら [0.125, 0.875] のようになる。普通ほしいのは、ビット数が増えるほどニュアンスが増えることであって、コントラストが変わることではない
本当に考えさせられる記事で、個人的に持っていた前提を見直すきっかけになった
電気工学の背景から見ると、記事にある「2種類の量子化器」という提示には同意しにくい。数学的には厳密でも、実際のシステムに基づいた説明ではない
ADC には常に本質的な ±1/2 LSB の量子化不確かさ がある。伝達特性は常に mid-tread サンプリングであり、少なくとも反例を見たことがない。これは双極性ADCでも単極性ADCでも同じだ
最低コードは負電圧基準で、最高コードは正電圧基準だ。伝達特性のグラフは、記事に示されているように最高/最低区間が実質的に 1/2 LSB 幅であることを示している
単極性システムでは中間電圧を正確に表現できず、言い換えればグレーの問題が生じる。双極性システムでは 0V が mid-tread の N/2 値になるが、だからといって「256個の区間」があることを意味するわけではない
だから私は引き続き (VREF+ - VREF-) * k / (2^N - 1) を使うつもりだ。つまり 255 正規化に賛成だ。結局のところ柵の支柱の数え間違いと同じで、値は N 個でも区間は N-1 個だ。値より区間が少ないなら、1つの区間を2つの値の間で分ける必要があり、そのため端点に 1/2 LSB の区間が生じる
126 から 127 への遷移は、正の全範囲の 1.5 LSB 手前の地点で起こる。1 LSB の差は 2/255=0.00784V ではなく、1/128=0.00781V の差を意味する
とはいえ、実際に電圧と不確かさが重要なら、この程度の差はたいていほとんど意味がない。基準電圧にはバイアスがあり、線形性誤差もある。1 LSB は 1/128 にも 2/255 にも正確には一致せず、補正用のパラメータが必要になる
これは科学計算でいうノード中心サンプルとセル中心サンプルの違いを、1次元で見たものに近い。値が区間の中央(あるいは三角形/四面体の中央)にあるのか、区間境界(あるいは三角形/四面体の頂点)にあるのかを決めなければならない
科学計算では、値をどう解釈すべきか分からないままデータ処理を始めるのはありえない。音声信号処理でも、整数ストリームしか受け取っていないなら、その整数がどのような表現意図を持つのか、たとえば mu-law エンコーディングなのか線形なのかを知らなければ元信号について計算できない。値に付いたメタデータがその答えを与えてくれることを期待する
しかし8ビットのピクセル値では、表現意図を伝えられる適切なファイル形式のメタデータがなければ宙に浮いてしまい、正解はない。記事の筆者が言うように、自分の用途でより良い結果が出る方を選ぶことを責めることはできないが、文脈のないビットは意味を損なう、という点は伝えられる
おおむねこんな形だ: Digital Number DN=0 は「NO_DATA」値として残し、DN が [1; 1;215-1] 範囲のとき、L2A SR 反射率値は L2A_SRi = (L2A_DNi + BOA_ADD_OFFSETi) / QUANTIFICATION_VALUE となる
https://sentiwiki.copernicus.eu/web/s2-products
ここには 0 から 255 までの 256段階があると仮定する誤りがある。実際には 8 ビットで表現できる値が 256 個あるのであって、0(黒)から 255(純白)までの間隔は 255 個である。
したがって 255 で割ること自体は問題ではない。もちろん 128 は正確な中間のグレーではないし、0〜255 の量子化された 8 ビット値はほぼ常に線形な知覚空間ではなく sRGB にある。
現代の API でサンプリング位置を扱うときにも、位置がピクセル中心ではなく座標で指定されるため、似たような混乱が生じる。
代数的に見れば答えは明確に f(x) -> [0, 255] である。
f(n * 0) == n * f(0) が成り立たないなら、おかしなことが起きる。たとえば f(x) -> [0, 255] なら f(0) + f(0) + f(0) = 0 + 0 + 0 = 0 = f(0) となる。
一方で f(x) -> [0.5/8, 7.5/8] なら f(0) + f(0) + f(0) = 0.5/8 + 0.5/8 + 0.5/8 = 1.5/8 != f(0) になる。
後者を選ぶと、x 側で行った計算と f(x) 側で行った計算が一致することを期待できない。つまり 代数的対応が壊れる。
+0.5 解法を支持したい。第一に端の半分サイズの区間が気に入らないし、第二に 255 ベースの表現は普通 HDR ではなく SDR 画像だからだ。
RGB 値はある適応状態に対する輝度を表しており、昼のシーンにおける「0」は「輝度 0」ではない。最も明るい点の約 0.001 倍にすぎず、光子は数百万個あるので 0 よりはるかに多い。
ある意味で目はコントラストを滑るような尺度として経験しており、システム内に絶対的な 0 はない。たとえば放送システムは歴史的に SDR 輝度範囲として 16〜235 を使っていた。「必ず 0 が必要だ」という論理にはバイアスが入ると見ており、多くの場合 0 は不要だと思う。
また、画像処理やコンポジットのワークフローのかなり多くは、正しいかどうかは別として 0 が 0 を意味すると仮定している。そのため 8 ビットでは 0u は 0.0f に、255 は 1.0f にマッピングされると見ることが多い。マスクやアルファで 0 値が 0.0 を少しでも上回ると、どこかのコードが 0.0 のハード閾値で別の処理をマスクしてアーティファクトが生じる。逆にアルファで 255 がもはや 1.0f でないなら、プリマルチプライ後にオブジェクトがごくわずかに透明になる。
同じことは、+0.5 のせいでマスキングで 254 が 1.0f になる場合にも起こりうる。
重要なのは光子 0 個を表現することではなく、1 バイトに保存される情報を最大化することだ。理想的にはバイト値 0 をあまり使わないようにしてはならないし、0 番目のバケットに入るべきデータにバイアスを加えてもならない。明るいから非常に明るいへ向かう色空間であっても、すべてのバイトが輝度範囲の同じ大きさの断片を表すべきだ。
定規が 12 インチまであるなら、定規上の点の数である 13 ではなく、長さ L で正規化すべきだ。
>> 8のほうがずっと速い。しばらく考えていなかった題材を扱っていて、楽しく読める記事だった。ゲーム開発で、ゲームロジックは浮動小数点の数学を使うのに ピクセルアート は整数座標に描かなければならなかった場面を思い出した。
いくつかの箇所で +0.5 に似たやり方を使って、見た目の違和感を減らそうとしていた。特に動くカメラがあるときはそうで、カメラのほうもスナップさせる必要があった。
下にリンクされている Jonathan Blow の 2002 年の記事 [1] も面白かった。最初の記事の可視化は、もっと深く入っていくときに非常に役立つ。
[1] https://web.archive.org/web/20240706043551/https://number-no...
Lobste.rs の意見
直感的でないなら、2ビットに劣化させたケースで考えるとよい。取りうる整数値が 0、1、2、3 しかないとき、整数→浮動小数点変換をすべて計算してみると、黒/白が黒/白でなくなったり、間隔が明らかに不均一になったりする妙な挙動を避けるには、0.0、0.33...、0.66...、1.0 になる
したがって逆変換は 4(2^2) ではなく、3 を掛ける方式になる
逆変換には 量子化(丸め) が必要で、まさにそこが対称性を壊す核心
0..=1 範囲の均一な実数グラデーションを作って 0、1、2、3 に量子化してみると、3 を掛けると結果が均一でないことが分かる。×3 の後に
round()を使うと 1 と 2 が過剰表現され、×3 の後にfloorやceilを使うと 0 や 3 が特異点のように押し込まれ、グラデーションが 4 色中 3 色しか使っていないように見える/3と×3のロジックは正確な数値を往復変換するときは問題なさそうに見えるが、中間値は丸めの選択に大きく左右され、データ処理を始めた瞬間に重要になる整数比率が均等になるのは (4-ε) を掛けて切り捨てる ときだけで、これは ×4、
floor(),clamp()と同じ。奇妙な 1 の差や ε の差の誤差のように感じられるが、直感的には最も見栄えのよい解法私にとって答えは常に「当然」 [0.0..255.0] だったが、どうやら皆にとって当然というわけではないらしい
記事では「両端」の区間がほかの区間の半分の容量しか持たないとしているが、この捉え方も正しくないと思う
[0..1] の外に値が存在しないなら、狭い区間に見えるのはレンダリング上の産物だ。範囲外の値がないという知識を持ったうえでバケットを切り落としているから、より狭く描画されているにすぎない
逆に [0..1] の外に値が存在するなら、その範囲は無限だ。記事は後者は認めているが前者は認めていない
前者を認めた瞬間に正しい動作は明白に見えるが、こういう記事が出てきたという事実自体が、客観的には「明白な」問題ではないことも意味している :D
0..<1 が整数 0 に行き、254>..255.0 が整数 255 に行くなら、128 が食われてしまう。おそらく 127.5..128.5 が 128 に行ってほしいはずだが、ではこの半分たちはどこへ行くのか?
128 を合わせるために全体を少しずらすと、0..0.99609375 が整数 0 にマッピングされる
round()を呼ぶことから生まれたように見える人々にはそのやり方がかなり自然に感じられるので、単純さゆえに標準になったのだと思う
pngcrushで圧縮はしました。あるいは画像の内容そのものが何かおかしいという意味でしょうか?