世界を動かす技術を、日本語で。

RGB値は255で正規化すべきか、それとも256で正規化すべきか?

概要

  • 画像処理における 整数と浮動小数点変換 の2つの手法を比較
  • 255除算(標準)256除算(代替) の違いと利点・欠点を解説
  • 極値の扱い・量子化誤差・実用上の影響について説明
  • どちらの手法を選ぶべきかの 結論と推奨 を提示
  • 量子化理論 や他の参考意見も簡潔に紹介

画像処理プログラムにおける整数-浮動小数点変換の2方式

  • 画像を 浮動小数点(float) に変換し、処理後に 8ビット整数 へ戻す処理が一般的

  • 主な変換方式は以下の2つ:

    • 標準方式(255除算)
      • 変換式:pixels = img / 255.0
      • 保存時:output = np.trunc(result * 255 + 0.5)
      • 特徴:0→0.0、255→1.0に正確にマッピング
    • 代替方式(256除算)
      • 変換式:pixels = (img + 0.5) / 256.0
      • 保存時:output = np.trunc(result * 256)
      • 特徴:0→0.00195…、255→0.998…にマッピングされ、両端値がピッタリ0や1にならない
  • どちらも最終的に clamp(0~255に丸め) して8ビット化

標準方式(255除算)の特徴と批判

  • 0と255が0.0と1.0 に正確に対応し、GPUや多くの標準実装で採用
  • 変換後の値は[0,1]範囲を 少しはみ出す
    • 例:0→0.0、255→1.0だが、実際のbin幅は両端が半分
  • 極値(0,255)が出現しづらい
    • 一様乱数でヒストグラムを取ると、0と255の出現頻度が他より半分
  • しかし、 元画像の再量子化(uint8→float→uint8) は損失なし
  • 極端値のbin幅が狭い問題は、 実用上あまり影響しない ケースが多い

代替方式(256除算)の特徴と利点・欠点

  • 各float値が整数のちょうど中間 に配置される
  • 例:128/256=0.5で、値の間隔が均等
  • dithering(ディザリング) やノイズ加算時に端値の特別扱いが不要
  • ただし、 0や255がfloatの0.0や1.0に一致しない ため、黒判定などが煩雑
  • 元画像が標準方式で保存されている場合、 精度を取り戻せない

量子化方式としての2手法の整理

  • mid-riser(標準:255除算)
    • 量子化式:k = trunc(x L)、復元:y_k = (k+0.5)/L
    • L=255の場合
  • mid-tread(代替:256除算)
    • 量子化式:k = trunc(x L + 0.5)、復元:y_k = k/L
    • L=256の場合
  • mid-riser は0→0.0、 mid-tread は0→0.00195…と、 0の扱い が異なる

量子化誤差と実用上の意味

  • 255除算は理論上、量子化誤差がわずかに大きい
    • 平均絶対誤差:1/1020(255除算)、1/1024(256除算)
  • しかし、 実際の画像がどう保存されたか に依存し、理論値ほどの差は出ない
  • 他人が作成した画像 を処理する場合、標準方式(255除算)が安全
  • 自分で保存・読込を完結 させる場合のみ、256除算のメリットを活かせる可能性

結論と推奨

  • 他人から提供された画像や標準的なワークフローでは255除算を推奨
  • 浮動小数点値の正確さや量子化誤差を極限まで気にする場合のみ256除算を検討
  • ただし、 保存・読込の両方を自分で管理できる場合 に限定
  • 方式を混在させると誤差やバグの原因 になるため、統一が重要

参考意見・関連資料

  • Jonathan Blowの2002年記事:mid-riser/mid-tread量子化の図解
  • Andrew Keslerの2015年記事:256除算のディザリング利点を主張
  • Wikipedia「Quantization(量子化)」:mid-riser/mid-treadの定義と数式
  • StackOverflowの議論:量子化誤差の理論値比較

まとめ

  • 画像処理で整数⇔float変換時は255除算が基本
  • 理論的には256除算も一考の価値あり
  • 現場の標準や互換性を最優先 に選択するのが無難

Hackerたちの意見

定規があって、12インチまであるなら、13の目盛りの数ではなく、長さLで正規化すべきだよ。

そうだけど、>> 8はめっちゃ速いよね。

自分はバカだ。0って最初から始まるんじゃないの?

そのアナロジーにはちょっと混乱してる。 “定規”って、0から255までの256ポイントがある255インチの定規なの?それとも256インチの定規で256個の1インチのセグメントがあるってこと?L = 256×1になるの?

でも、数字がポイントを表しているのではなく、ポイント間の間隔を表しているって誰が言ったの?

正しい方法はスライドルールを使うことだよ。

+0.5の解決策を支持するよ。まず、端っこの半分のサイズの間隔が好きじゃないし、次に、255ベースの表現は通常SDR(HDRじゃない)画像だからね。RGBの値は、何らかの適応状態に対する輝度を表していて、日中のシーンでの「ゼロ」は「ゼロ輝度」じゃないんだ。最も明るいポイントの約0.001倍の明るさで、何百万もの光子があるから、ゼロとは程遠いよ。ある意味、私たちの目は滑らかなスケールでコントラストを感じていて、システムには絶対的なゼロは存在しないんだ。例えば、放送システムは歴史的にSDRの輝度範囲として16-235を使っていたよ。「ゼロが必要だ」と言う議論には偏りがあると思うけど、ほとんどのことにゼロは必要ないと思う。

両方の解決策は0.5を足すけど、違いはそのプロセスのどこで起こるかだね。

同意するよ。それに、0.0と1.0はディザ信号には実際には存在しないから、バイトは256で割る前に[0.5, 255.5]にマッピングされるべきだと思う。これで符号付き整数の非対称性も解決するし、符号付きバイトは128で割る前に[-127.5, 127.5]にマッピングされるんだ。音声DSPの人たちがもうやってるか気になるな。

ある意味、私たちの目は滑らかなスケールでコントラストを感じている。光の量をチェックして瞳孔を調整するための視覚センターがあるんだ。それは意図的に反応するようになってる。 > システムには絶対的なゼロは存在しない。もしかしたらあるかもね。「盲目」って呼ぶんじゃないかな。 > 放送システムは歴史的にSDRの輝度範囲として16-235を使っていた。主に完全にアナログシステムだったから、これらはすべて信号電圧に変換されるんだ。冗談でNTSCは「同じ色が二度と出ない」って呼ばれてたよ。すでに妥協されたシステムにさらに妥協を重ねたからね。

面白いアイデアだけど、なんか世界が揺れてる気がする。処理プログラムでは、昔は黒(0.0)と白(1.0)だったのが、今はとても暗いグレーととても明るいグレーになっちゃった。

画像処理やVFXのレンダリングにかなりの経験がある者として言わせてもらうと、色空間変換(古いSDR用のsRGB「リニア」rec709や、新しいフォーマット用のより広いガマットなど)はこの後に行われるから、ダイナミックレンジの「圧縮」は読み込み後に起こることを忘れてるかも。画像処理や合成の多くのワークフローでは、0がゼロを意味するという前提がある(正しくないことが多いけど)。だから、8ビットの場合、0uが0.0fに、255が1.0fにマッピングされるっていう仮定がよくある。0の値がちょっとだけ0.0を超えると、どこかのコードが0.0のハードしきい値を使って他の操作をマスクしてるからアーティファクトが出ちゃうんだよね。逆に、1.0のアルファの場合も、255の値がもう1.0fじゃなくなると、非常にわずかに透けて見えるオブジェクトができちゃう(これは特定の状況やピクセルをじっくり見る時にしか見えないことが多い)。(254が+0.5で1.0fになる時も同じことが起こるよ。)

放送システムは歴史的に16-235を使用していた 8ビットの場合、16は7.5IREにマッピングされていて、これはよく理解されている合法的な黒だよ。235をマッピングするってことは、ピークを110IREにマッピングしたってこと。これは0-120IREスケールに基づいてる。これが変なことになるのは、ビデオの放送制限が100IREで、クロマが110IREに達することを許可していたから。だから、白の値を235に制限しようとすると、それは放送安全よりも高くなっちゃう。もちろん、今はNTSCの放送制限なんて誰も気にしてないけど。だけど、今でもストリーミング用に取り込まれた「放送マスター」としてマークされた規格外のテープを見かけることがある。それを見ると本当にイライラするし、今はさらに悪化してる。みんなVTRのTBCを正しく調整するためのスコープすら持ってないから。

投稿はRGBに焦点を当てているけど、離散的な表現と連続的な表現の間でマッピングされる信号の種類に関わらず、同じ量子化の問題が存在する。0フォトンの表現が問題なのではなく、バイトに保存される情報を最大化することが重要なんだ。理想的には、バイト値0を過小利用するべきではないし、何を表しているかに関わらず、0番目のバケットに割り当てられるべきデータにバイアスを加えるべきではない(明るさの範囲を均等に表すために、明るいから超明るいまでの色空間を持っているかもしれない)。

エッジでは半分のサイズじゃないよ、ネガティブブラックが気にならない限り。

255.0を掛けて、オプションでディザを足して(三角形のやつでOK)、その後FPUにデフォルトのIEEE 754の四捨五入モードで丸めさせるべきだよ。こんなクレイジーな0.5のことはなしでね。 :-)

面白い記事だったなぁ。しばらく考えたことがなかったテーマだし、ゲーム開発の時にピクセルアートを整数値で描かなきゃいけなかったことを思い出したよ。ゲームロジックは浮動小数点計算を使ってるのにね。+0.5みたいな工夫をして、特に動いてるカメラの時に見栄えが悪くならないようにしたりもした。下にリンクされてるジョナサン・ブロウの2002年の記事も楽しめたよ。最初の記事のビジュアライゼーションが、より深く掘り下げる時にすごく役立った。

色の値が何を意味するかの問題は、コンポーネントごとに8ビットある場合にはほとんど重要じゃない。分母が255か256かの違いは微小なエラーを生むだけで、色の認識がすごく良くて画面に近づかないと違いがわからないし、モニターやスマホの画面はたぶんキャリブレーションされてないから、誰も気にしないよね。マイコンで8色出力ピン(赤3、緑3、青2)を使ってVGA信号を生成する時には、色の値の意味がすごくリアルになる。このセットアップでは、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用の8ビットカラーのダブルバッファ320x240フレームバッファのVGA出力のコードはここにあるよ: https://github.com/moefh/pico-vga-8bit-demo

ガンマ補正を忘れてるよ。0-255の範囲の値を電圧に変換する前に、PCは通常その値を2.2乗するんだ。これによって、小さい値と大きい値の違いがはっきりするんだよ:2^2.2 = 4.595、255^2.2 = 196,964.699。

つまり、純粋なグレーは見られないってことだね。最も近いグレーでも青や黄色の色味が少し入ってる。方向によって違うんだ。OMG、子供の頃、静電気のCRTディスプレイを見つめて、端の方に薄い青や黄色のラインが見えたのを思い出す。なんであんなのが出るのか、特に青と黄色なのは何でだろうってずっと不思議だった。やっとわかった!(少なくとも、あの特定のアーティファクトが同じ理由によるものだと仮定すればだけど)

いや、「代替」アプローチは7ビットの例では変に見えるね。1.0はビン7の右側にあるけど、0.0はビン0の左側にある。標準的なアプローチは、サンプルが中心にあることを前提にしてるんだ。つまり、ゼロは真っ黒で、プラス(マイナスも!)の不確実性があって、ビン7もそう。もし強い露出によるクリッピングがない歪みのない強度のサンプリングが行われているなら、ビン7は1.0を中心にした可能性のある値の範囲を表すことになる。これは半分のサイズの区間ではないんだ。 > これは、[0,1]の範囲の浮動小数点値を整数に戻すとき、極端なビンは他のビンの半分の幅を持つことを意味している。画像サンプルのどんな解釈においても、最大値255を歪みとして解釈する余地がある。つまり、任意の高い値からのクリッピングだ。0.5をシフトしても、255が1.0に近い強度を表しているのか(歪みなし)、それとも外れ値の強度37.49(厳しくクリップされた)を表しているのか分からないという問題は解決しない。逆の可能性もある。つまり、極端なビンにはバイアスがかかる可能性がある。信号が制限されていてビンの完全なサンプリング範囲が機能していないか、信号が圧倒的で、範囲外の値がクリップされて含まれているかもしれない。この問題を回避する唯一の方法は、最高値を「クリップされた値」を表すカナリアにすることだ。つまり、255は「クリップされたデータ」を意味し、254以下だけがクリップされていない信号のサンプリングになる。機械生成の画像(例えば3Dレンダリング)は255の値を避け、カメラセンサーは技術的な画像を撮影する際にそれが発生しないようにキャリブレーションされている。

この問題は切り捨ての使用から生じてるよね?それによって、正確に1.0だけが255ビンに入ることが保証されるから、結果的に256ビンが255ビンに減ることになる。(示されたようにランダムな数を使うと、1.0は保証されない。)でも、利用可能なビンを埋めるようにスケールしないのはなぜ?つまり、trunc(result * 255.999)みたいに?

でも、利用可能なビンを埋めるようにスケールしないのはなぜ?つまり、trunc(result * 255.999)みたいに?それは記事で話されているミッドライザー階段量子化器の半分だね。(もう半分は逆のものを考えること。)(私はそれをmin(floor(x * 256), 255)として実装するよ。)

ここには「0から255まで256ステップがある」という仮定に誤りがあるよ。それは真実じゃない。8ビットで表現できるのは256の値で、0(黒)から255(純白)までの255ステップ(その値の間のスペース)がある。だから、255で割ることは問題じゃない。もちろん、128は半分のグレーじゃないし、0-255の範囲にはないし、量子化された8ビット値はほとんど常にsRGBにあり、リニアな知覚空間にはない。この混乱は、現代のAPIでサンプリング位置を指定する際にも起こることで、位置が座標で指定されてピクセルの中心ではない。

黒をゼロとして保つ必要を考慮すると、実際の解決策は2つだけだよ。「rgb / 255.0」と「rgb / 256.0」だ。どちらも異なるトレードオフがあるから、自分の好みで選んでね。(8ビットのディスプレイ信号を使ってるなら、OSが選んだマッピングの値に合わせる必要があるから、RGB値はそのまま通過するようにしないとね。)

これは本当に考えさせられる記事だった。自分の個人的な前提に挑戦しなきゃいけなかった。電気工学のバックグラウンドからすると、「二種類の量子化器」の説明には反対だな。数学的には厳密だけど、実際のシステムには基づいてない。ADCでは、常に±1/2 LSBの量子化の不確実性がある。転送特性は常にミッドトレッドサンプリングだし、反例には出会ったことがない。これはバイポーラでもユニポーラでも同じ。最も低いコードは負の電圧リファレンスで、最も高いコードは正のリファレンス。転送特性のプロットは、著者が示したように、最も高いビンと最も低いビンが実質的に1/2LSBの幅になることを示す。ユニポーラシステムでは、これが中間電圧を正確に表現できない結果になる、つまりグレー問題だ。バイポーラシステムでは、0VがミッドトレッドのN/2値になるけど、それが「256の範囲」を持つって意味じゃない。だから、(VREF+ - VREV-) * k / (2^N - 1)を使うことにするよ。つまり、255での正規化には賛成だ。これはフェンスポストエラーと同じで、Nの値があっても、範囲はN-1しかない。値より範囲が少ない場合、その範囲の1つを2つの値の間に分配する必要があるから、1/2LSBの範囲の端点ができるんだ。