こんな動画を見た
以下がこの動画の主張である。
- 人間の目は、同じ絶対量の明るさの変化を暗いときのほうが明るいときより強く感じるぜ!
- カメラは、単純に明るさをそのまま測るぜ!
- 人間が敏感な暗い部分のサンプリングを細かくするため、0~1で表した明るさの平方根を画像ファイルに記録し、表示するときに2乗するぜ!
- この形式の画像ファイルは表示するだけならいいけど、何も考えずに編集すると狂うぜ!
- たとえばぼかしを行う際、記録された値の平均をそのまま取ると、無駄に暗くなってしまうぜ!
- 世の中の多くの場面でこの頭の悪い処理が行われてるぜ!
- 記録された値を2乗してから平均を取り、その結果の平方根を取るといい感じになるぜ!
やってみた
動画で例に出ている、赤から緑へ変化する画像を生成してみた。
うまくいけば、中間は黄色になるはずである。
今回は、Pillow (PIL Fork) を用いて生成してみた。
生成を行うコード
from PIL import Image
from math import sqrt
width = 512
one_height = 64
left_color = (255, 0, 0)
right_color = (0, 255, 0)
out_file = "red_green_%dx%d.png" % (width, one_height)
img = Image.new("RGB", (width, one_height * 2))
def apply1(func, t):
return tuple([func(v) for v in t])
def apply2(func, t1, t2):
return tuple([func(v1, v2) for v1, v2 in zip(t1, t2)])
def to_max1(v):
return v / 255.0
def from_max1(v):
return round(v * 255)
def simple(ratio_of_2, v1, v2):
return v1 * (1 - ratio_of_2) + v2 * ratio_of_2
def use_sqrt(ratio_of_2, v1, v2):
return sqrt(simple(ratio_of_2, v1 * v1, v2 * v2))
lc1 = apply1(to_max1, left_color)
rc1 = apply1(to_max1, right_color)
for y in range(one_height):
for x in range(width):
ratio = x / (width - 1)
blend_simple = apply2(lambda v1, v2 : simple(ratio, v1, v2), lc1, rc1)
blend_sqrt = apply2(lambda v1, v2 : use_sqrt(ratio, v1, v2), lc1, rc1)
img.putpixel((x, y), apply1(from_max1, blend_simple))
img.putpixel((x, y + one_height), apply1(from_max1, blend_sqrt))
img.save(out_file)
生成結果は以下の画像になった。
上半分が何も工夫せずに計算を行った結果、下半分が2乗した値で計算を行った結果である。
比べてみると、上半分 (工夫なし) の中央付近が暗くなっていることがわかる。
今回の例では、両端の明るさは 0 および 1 なので、2乗しても変わらない。
真ん中の明るさは単純計算だと 0.5 となるが、この平方根をとると約 0.707 となり、約 1.41 倍の値を書き込むことになる。
一方、単純計算の 0.5 を2乗すると 0.25 となり、動画の主張 (画像ファイルに記録された値の2乗の明るさで表示される) が正しいとすれば、単純計算だと本来の半分の明るさになることがわかる。
RGB 色空間との関係
そういえば、リサイズを含む画像処理を行う際は JPEG 形式のデフォルトなどとして用いられる sRGB 空間だと暗くなるので、RGB 空間に変換してから処理しろ、という主張があった。
RGB と sRGB の変換は「単に2乗/平方根を取る」という単純なものではないはずである。
sRGB値を各種パラメータに変換あるいは逆変換する変換式一覧
によれば、0~1 の sRGB 値から 0~1 の RGB 値への変換は、以下の関数で行えるらしい。
f(x) = \left\{
\begin{array}{ll}
\frac{x}{12.92} & (x \leq 0.04045) \\
{\left(\frac{x + 0.055}{1.055}\right)}^{2.4} & (\textrm{otherwise})
\end{array}
\right.
この関数の逆関数を考えると、0~1 の RGB 値から 0~1 の sRGB 値への変換は、以下の関数で行えるはずである。
\begin{array}{l}
\\
f^{-1}(x) = \left\{
\begin{array}{ll}
12.92\,x & \left(x \leq \frac{0.04045}{12.92}\right) \\
1.055\,x^{5/12} - 0.055 & (\textrm{otherwise})
\end{array}
\right.
\end{array}
先ほどのプログラムに、これを用いた処理を追加し、再び画像を生成してみた。
上から、そのまま計算、2乗して計算、RGB に変換して計算、である。
生成を行うコード
from PIL import Image
from math import sqrt
width = 512
one_height = 64
left_color = (255, 0, 0)
right_color = (0, 255, 0)
out_file = "red_green_%dx%d_2.png" % (width, one_height)
img = Image.new("RGB", (width, one_height * 3))
def apply1(func, t):
return tuple([func(v) for v in t])
def apply2(func, t1, t2):
return tuple([func(v1, v2) for v1, v2 in zip(t1, t2)])
def to_max1(v):
return v / 255.0
def from_max1(v):
return round(v * 255)
def to_rgb(v):
if v <= 0.04045:
return v / 12.92
return pow((v + 0.055) / 1.055, 2.4)
def from_rgb(v):
if v <= 0.04045 / 12.92:
return v * 12.92
return 1.055 * pow(v, 5.0 / 12.0) - 0.055
def simple(ratio_of_2, v1, v2):
return v1 * (1 - ratio_of_2) + v2 * ratio_of_2
def use_sqrt(ratio_of_2, v1, v2):
return sqrt(simple(ratio_of_2, v1 * v1, v2 * v2))
def use_rgb(ratio_of_2, v1, v2):
return from_rgb(simple(ratio_of_2, to_rgb(v1), to_rgb(v2)))
lc1 = apply1(to_max1, left_color)
rc1 = apply1(to_max1, right_color)
for y in range(one_height):
for x in range(width):
ratio = x / (width - 1)
blend_simple = apply2(lambda v1, v2 : simple(ratio, v1, v2), lc1, rc1)
blend_sqrt = apply2(lambda v1, v2 : use_sqrt(ratio, v1, v2), lc1, rc1)
blend_rgb = apply2(lambda v1, v2 : use_rgb(ratio, v1, v2), lc1, rc1)
img.putpixel((x, y), apply1(from_max1, blend_simple))
img.putpixel((x, y + one_height), apply1(from_max1, blend_sqrt))
img.putpixel((x, y + one_height * 2), apply1(from_max1, blend_rgb))
img.save(out_file)
生成結果は以下の画像になった。
2乗した値で計算を行った場合より、RGB に変換して計算を行った場合のほうが若干明るくなっている。
先ほどの式に当てはめると、RGB 値 (計算結果) が 0.5 のとき、sRGB 値 (書き込む値) は約 0.735 となり、平方根の約 0.707 より大きくなっていることがわかる。
とはいえ、工夫をしない場合に書き込む値の 0.5 に比べると、差は小さい。
さらに、それぞれの計算方法における、データ上の値と計算に使う値の関係をグラフにしてみた。
生成方法 (gnuplot)
グラフの領域が正方形になるように、画像のサイズを調整した。
set terminal pngcairo size 531, 512
set output "color_plot.png"
set grid
set grid mxtics
set grid mytics
set xrange [0:1]
set yrange [0:1]
set xtics 0.2
set mxtics 2
set ytics 0.2
set mytics 2
set key top left
set xlabel "データ上の値"
set ylabel "計算に使う値"
plot x title "そのまま" linecolor "forest-green", \
x*x title "2乗" linecolor "red", \
x <= 0.04045 ? x/12.92 : ((x + 0.055) / 1.055) ** 2.4 title "RGB" linecolor "blue"
値を2乗することにより、そのまま計算するよりも RGB に変換したときに近い値が得られることがわかる。
すなわち、正確さより速さを重視したいときなどに、「2乗」は「RGB に変換」の近似として十分役立つ可能性がある。
各種ソフトウェアでの処理結果
画像の拡大
まず、左側が赤、右側が緑の、幅2ピクセルの画像を用意した。
この画像を各種ソフトウェアで幅256ピクセルに拡大し、処理結果を比較した。
補足
各ソフトウェアのバージョンは、以下の通りである。
ソフトウェア | バージョン |
---|---|
ペイント | 11.2311.30.0 |
GIMP | 2.10.36 |
JTrim | 1.53c |
AzPainter2 | 2.12 |
PictBear | 2.04 |
Photopea | 不明 (2024年2月27日に実験) |
paint.net | V5.0.12 (Stable 5.12.8735.38135) |
Krita | 5.2.2 |
FireAlpaca | 2.11.17 |
ImageMagick | 7.1.1-12 |
LibreOffice Draw | 7.5.7.1 |
Firefox | 123.0 |
Google Chrome | 122.0.6261.70 |
FireAlpaca で普通の補間ありの拡大を行う方法はわからなかった。
「変形」は、以下の手順で行った。
- 「編集 → キャンバスサイズ」で幅を 256 に設定する (「中央」を選択する)
- Ctrl + T を押し、レイヤーの変形モードに入る
- ドラッグでサイズを合わせる
- 「Ok」を押す
その結果、補間をかけることはできたが、左右の端がなぜか半透明になってしまった。
ImageMagick (colorspace 指定なし) は、以下のコマンドで変換を行った。
magick convert red_green_24.png -resize !256x24 red_green_24_magick_nocolorspace_256.png
ImageMagick (colorspace 指定あり) は、以下のコマンドで変換を行った。
magick convert red_green_24.png -colorspace rgb -resize !256x24 -colorspace srgb red_green_24_magick_withcolorspace_256.png
Firefox / Google Chrome (imgタグ) は、以下のように img
タグの width
属性の指定による拡大を行った。
<img src="red_green_24.png" width="256" height="24" alt="">
Firefox / Google Chrome (canvas) は、CanvasRenderingContext2D
の drawImage() メソッドを用いて描画を行った。
Google Chrome における品質の指定は、imageSmoothingQuality プロパティにより行った。
今回実験を行った多くのソフトウェアにおいて、処理結果中央の色は 127 付近の値となり、データ上の値をそのまま用いて計算しているらしいことがわかった。
「JTrim (Lanczos3)」「AzPainter2 (バイリニア)」「PictBear」は、色が切り替わる場所が中央からずれているが、色の中間地点ではやはり 127 付近の値となった。
そんな中、colorspace の指定を行った ImageMagick に加え、GIMP および「ガンマ補正を使用する」をオンにした paint.net でも、処理結果中央の色が 187 付近となり、RGB に変換して処理を行っていそうであることがわかった。
ぼかし
左半分が赤、右半分が緑の、幅256ピクセルの画像を用意した。
生成コード
from PIL import Image
width = 256
height = 24
left_color = (255, 0, 0)
right_color = (0, 255, 0)
out_file = "red_green_%dx%d_bin.png" % (width, height)
img = Image.new("RGB", (width, height))
for y in range(height):
for x in range(width):
img.putpixel((x, y), left_color if x < width // 2 else right_color)
img.save(out_file)
この画像に対し、各種ソフトウェアでぼかしを行い、結果を比較してみた。
補足
ソフトウェアのバージョンは、拡大の実験で使用したものと同じである。
GIMP の設定における「サイズ」とは Size X および Size Y (共通) のことであり、サイズ以外の設定は以下の通りである。
paint.net における半径以外の設定は以下の通りである。
GIMP および paint.net では処理結果の中央付近が黄色になっているが、今回実験を行った他のソフトウェアでは中央付近が暗くなってしまっている。
また、ソフトウェアによってはぼかしの強さに限界があること、および同じような値を設定してもソフトウェアによってぼかしの強さに差があることもわかった。
まとめ
- sRGB 色空間の画素値をそのまま計算に用いると結果が暗くなりやすいので、RGB 色空間に変換してから計算するべきである
- 「RGB 色空間に変換する」かわりに、0~1 の画素値を2乗することで近似できる
- GIMP、paint.net、ImageMagick は RGB 色空間での画像操作に対応しているようだが、対応せず暗い処理結果を生成してしまうソフトウェアも多い