RGB値は255で正規化すべきか、それとも256で正規化すべきか?
(30fps.net)- RGB正規化で見慣れない画像ファイルを処理して再び8ビットで保存する一般的な状況では、255で割る標準方式が適している
- 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は他の値の半分の頻度で現れるが、実際の画像の往復変換は損失なく動作する - 256方式は理論上、平均絶対誤差が
1 / 1024となり、255方式の1 / 1020より小さいが、すでに255方式で量子化された画像を誤ったスケールで読むと、かえって誤差を増やす
問題設定
画像処理プログラムは8ビット画像を浮動小数点に変換し、処理を行ったあと、再び8ビット色として保存する
2つの変換方式は次のとおり
# 標準: 255で割る
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
# 代替: 0.5を足して256で割る
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
どちらの方式でも、最終変換の前に値を0〜255に制限する
output_8bit = output.clip(0, 255).astype(np.uint8)
標準方式は整数0を0.0、255を1.0にマッピングし、GPUのUNORM-to-float変換方式と同じである
代替方式では0は 0.5 / 256 = 0.001953125 にマッピングされるため、黒ピクセルを検出するにはこの定数を知っている必要がある
255で割る標準方式の特性
標準方式では、[0, 1] 範囲内で両端の値の区間が他の区間より実質的に半分の幅になる
一様な [0, 1] 乱数を作り、trunc(result * 255 + 0.5) で丸めると、0と255は他の整数より半分の頻度で現れる
しかし元の8ビット画像は、uint8 → float → uint8 の往復変換で損失なく戻る
また、処理結果が0.0や1.0をわずかにはみ出しても、クランプと丸めによって正しい整数区間に入ることがある
たとえば浮動小数点の色から 0.005 を引くと、標準方式の黒は負になるが、最終結果はそれでも整数0になる
trunc(255 * (-0.005) + 0.5) = 0
浮動小数点精度と区間中央への配置
255方式の値は、一部が正確には表現されない
たとえば 128 / 255.0 ≈ 0.501961 だが、128 / 256.0 = 0.5 である
この差は32ビット浮動小数点の23ビット仮数における最下位ビットレベルの丸め誤差であり、その大きさは 2^-23 より小さい
したがって、この不正確さは実際の技術的問題というより、美的な問題に近い
256方式では、各浮動小数点値を2つの整数のちょうど中央に置く
この性質は、元の量子化値が正確に何だったか分からないときに、連続する2つの整数の平均点を使う妥協案とみなせる
Andrew Keslerの2015年の記事 “Converting Color Depth” では、この方式はディザリングでノイズを加える際に境界処理をあまり気にしなくてよくなるとしている
一方、標準方式の両端区間では、ノイズ分布を一貫して保つために慎重な処理が必要になる
量子化の観点
両方式は一様スカラー量子化器(uniform scalar quantizer)とみなせる
Wikipediaのquantization説明) では、signed input dataの一様量子化器は主にmid-riserとmid-treadに分けられる
mid-treadは0値の再構成レベルを持ち、mid-riserは0値の分類しきい値を持つ
対応する式は次のとおり
| 方式 | エンコード | デコード |
|---|---|---|
| mid-tread | k = trunc(x L + 0.5) |
y_k = k / L |
| mid-riser | k = trunc(x L) |
y_k = (k + 0.5) / L |
標準方式は L=255 を使うmid-tread形式であり、代替方式は L=256 を使うmid-riser形式である
標準方式は0.0と1.0に両端を合わせるプログラミング上の利便性を得る代わりに、8ビット入力に最適な区間配置とは異なる
再構成誤差と実際の画像処理
一様分布の実数 x ∈ [0, 1] を8ビット整数にエンコードし、再び実数として再構成するシステムを自分で設計するなら、256方式のほうが理論上は高精度である
標準方式の表現可能範囲は [-0.5 / 255, 255.5 / 255] となり、[0, 1] に本当に必要なものより区間間隔が広くなる
StackOverflowユーザー Peter Mudrievskijの計算 によれば、平均絶対誤差は255で割る方式で 1 / 1020、256で割る方式で 1 / 1024 である
しかし、すでに保存された8ビットRGB画像を読み込んで処理する状況では、保存時に失われた情報は復元されない
画像が255を掛けて丸める方式で量子化されていたなら、読み込み時に256で割っても精度は戻らない
他人が作成した画像の多くは標準方式で量子化されている可能性が高いため、代替の式で読むと理論上は誤ったスケール係数を使うことになる
実際には、色は絶対測定値のように振る舞わないため、わずかに小さい範囲と小さいオフセットで処理する結果になる
2つの量子化器のエンコード段階とデコード段階を混在させると、壊れたコードになる
結論
見慣れない人が提供した画像を処理するなら、RGB値は255で正規化すべきである
浮動小数点値が正確ではないことや、抽象的な再構成誤差が大きいように見えることだけを理由に256方式を選ぶ根拠は弱い
画像の保存と読み込みの両方を自分で制御でき、0が0にマッピングされる必要がなく、処理コードが8ビットのダイナミックレンジに縛られても構わないなら、256で割ってわずかに高い理論精度を狙うことはできる
1件のコメント
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で圧縮はしました。あるいは画像の内容そのものが何かおかしいという意味でしょうか?