豪鬼メモ

一瞬千撃

任意のRGB比率でグレースケール画像を作る

例の後処理自動化ツールにグレースケール変換機能を追加したのだが、その際にRとGとBの貢献比率を任意のパラメータとして指定できるグレースケール変換機能を実装してみた。
f:id:fridaynight:20180915032147j:plain


以前の記事でも述べたが、モノクローム写真を撮る際にカラーフィルタを適用すると写真の雰囲気をガラリと変えることができる。そして、カラーデジタルカメラのRAW記録でモノクロ写真を撮る場合にはカラーフィルタの選定を現像時にまで遅らせることができる。それはすなわちカラー画像をグレースケール画像に変換する際に、R(赤)とG(緑)とB(青)の各チャンネルからどのような比率で値を拾ってくるかを決めるということである。

ImageMagickでは、主にLuma602LuminanceとLuma709Luminanceというパラメータセットが使われる。前者は昔のテレビ放送やJPEGのYCbCr符号の輝度Yを算出する式で、すなわち 0.298839*R+0.586811*G+0.114350*B である。後者はハイビジョン放送で使われる式で、0.212656*R+0.715158*G+0.072186*Bだそうな。それらに基づく変換は -intensity luma601luminance または -intensity luma709luminance で輝度算出の式を指定してから、-colorspace gray でそれを適用して色空間を変えるだけで簡単にできる。-intensity に指定できる値は他にもあって、RGBの算術平均を取るAverageとか二乗平均平方根を取る RMS とかがある。任意比率でやろうとすると、-fx という汎用関数を使うオペレータがあって、-fx "0.5 * R + 0.3 * G + 0.2 * B" みたいに書けば実現できる。しかし、-fx オペレータは各ピクセルごとに数式を評価して適用するので、動作がめちゃくちゃ遅くて実用するには辛い。

どうしたもんかと考えたところ、チャンネル毎に -evaluate オペレータを適用してから-intensity Average オペレータで混合することで実現できると気づいた。-evaluate オペレータは各チャンネルの個々の値に加減乗除などの一定の演算を一気にかけるもので、他のチャンネルの値や座標などの外部情報を使えない代わりに、高速に動作する。各チャンネルの値を任意の比率に基づいて減らしてから、Averageモードでグレースケールに変換して、最後に減らした分だけ全体を増加させれば、任意のRGB比率でグレースケール画像が作れるはずだ。

Pythonで書くとこんな感じ。convert_to_grayscale_rec601 関数は組み込みのRec. 601比率でグレースケール画像を作る。convert_to_grayscale_with_ratios 関数は任意のRGB比率を指定してグレースケール画像を作る。RGBの比率は合計1である必要はなく、3, 5, 1.5 のような任意の非負の実数が指定できる。

import os
import subprocess
import sys

CMD_CONVERT = "convert"

def convert_to_grayscale_rec601(input_path, output_path):
  cmd = [CMD_CONVERT]
  cmd += [input_path]
  cmd += ["-depth", "16"]
  cmd += ["-colorspace", "rgb"]
  cmd += ["-intensity", "rec601luminance", "-colorspace", "gray"]
  cmd += ["-colorspace", "srgb"]
  cmd += [output_path]
  return subprocess.call(cmd)

def convert_to_grayscale_with_ratios(input_path, output_path, r, g, b):
  ratios = (r, g, b)
  max_val = float(max(ratios))
  ratios = [x / max_val for x in ratios]
  cmd = [CMD_CONVERT]
  cmd += [input_path]
  cmd += ["-depth", "16"]
  cmd += ["-colorspace", "rgb"]
  for channel in zip(tuple("rgb"), ratios):
    cmd += ["-channel", channel[0], "-evaluate", "multiply", "{:.6f}".format(channel[1])]
  cmd += ["+channel"]
  cmd += ["-set", "colorspace", "srgb", "-colorspace", "rgb"]
  cmd += ["-intensity", "average", "-colorspace", "gray"]
  cmd += ["-colorspace", "srgb", "-set", "colorspace", "rgb"]
  cmd += ["-evaluate", "multiply", "{:.6f}".format(3.0 / sum(ratios))]
  cmd += ["-colorspace", "srgb"]
  cmd += [output_path]
  return subprocess.call(cmd)

def main(args):
  if len(args) == 2:
    input_path, output_path = args
    rv = convert_to_grayscale_rec601(input_path, output_path)
  elif len(args) == 5:
    input_path, output_path, r, g, b = args
    rv = convert_to_grayscale_with_ratios(input_path, output_path, float(r), float(g), float(b))
  else:
    print("usage: convert_into_grayspace input output [red green blue]")
    sys.exit(1)
  return rv

if __name__ == "__main__":
  sys.exit(main(sys.argv[1:]))

この実装を経ていくつかの点を学んだ。前提として、-intensity で指定できるRec601LuminanceとRec601Lumaは挙動が違うことを理解する必要がある。前者はRGBの各値を線形空間で加算する 0.298839R + 0.586811G + 0.114350B という設定だ。後者はRGBの各値をガンマ2.4のsRGB空間で加算する 0.298839R' + 0.586811G'+ 0.114350B' という設定だ。係数が同じでも、文字にプライムが付いているだけで結果が全然違う。以前の記事にも書いたが、ガンマで明るくした空間で画素の混合を行うと、暗い方に引っ張られて結果が暗くなってしまう。RGBチャンネルの文脈で言えば、原色に近いほど暗くなりやすくなるということだ。なので、特別な意図がない場合、RGB空間の画像を普通にモノクロームにしたいなら、Rec601LuminanceかRec709Luminanceを使うべきだ。

次に任意のRGB比率を自分で指定するにあたってAvergeモードを使う場合のことを考える。ここでも注意すべきは、輝度の変換式が (R' + G' + B') / 3.0 であり、すなわち R,G,B にプライムが付いていてsRGB空間で扱われてしまうということだ。したがって、線形空間においてRGBの平均値を取るのには使えない。そしてなぜか線形の (R + G + B) / 3.0 というモードは用意されていない。そのワークアラウンドとして、上記実装ではsRGBのガンマ補正を逆にかけて暗い画像を生成してから、それに対するsRGB空間(=線形RGB空間)でのAverage変換を適用して、その結果にsRGBによるガンマ補正をかけて元に戻している。逆にガンマ補正するには、RGB空間のデータに対して -set colorspace srgb としてsRGBであると認識させてからそれに -colorspace rgb をかけて線形RGBに変換すればよい。グレースケール変換後に元の空間に戻すには、-colorspace srgb としてガンマ補正した後に、-set colorspace rgb として線形RGBであると認識させればよい。

実装上の工夫はもう一つある。-evaluate で各チャンネルの値を比率に応じて変える際に、各々の係数を全ての係数の最大値で割って、1が最大になるように調整していることだ。ImageMagickデフォルトのQ16実装ではuint16で値を持つので、multiplyで1以上の値を指定すると作業空間上で結果が65535を超えてオーバーフローを起こす(HDRIを有効にするとfloatで持つのでその限りではない)。したがって係数が1以下であることは必須で、また量子化誤差を最小化するためには1に近いほど望ましい。例えば 0.298839R + 0.586811G + 0.114350B は0.509259R + 1.0G + 0.194866B という風に読み換えることになる。いずれにせよ、RとGとBの係数を合計した値で3を割ったものが、グレースケール変換とガンマ補正を施した後の画像の輝度を元に戻すために必要な倍率だ。

こんなややこしいことをしている実装は本当に妥当なのか心配になる。よって、確認してみよう。組み込みのRec601Luminanceと、任意の変換式として 0.298839R + 0.586811G + 0.114350B を指定した画像が同じであれば、問題ないアルゴリズムと実装になっていると言えそうだ。

$ ./convert_to_grayscale.py input.jpg output-real-rec601.jpg
$ ./convert_to_grayscale.py input.jpg output-pseudo-rec601.jpg 0.298839 0.586811 0.114350

結果はこちら。左が組み込みのRec601Luminanceの結果で、右が任意比率換算倍・逆ガンマ補正・Average変換・ガンマ補正・任意比率逆換算による結果だ。完全に一致しているように見える。量子化誤差によって完全に同じデータになっているわけではないはずだが、これの弁別ができる人間は居まい。

f:id:fridaynight:20180916220550j:plain:w300 f:id:fridaynight:20180916220602j:plain:w300

せっかくなので、レッドフィルタ(R:G:B=8:2:1)、オレンジフィルタ(R:G:B=4:2:1)、イエローフィルタ(R:G:B=2:2:1)、グリーンフィルタ(R:G:B=1:2:1)を模した変換例を挙げてみる。

$ ./convert_to_grayscale.py input.jpg output-actual-821.jpg 8 2 1
$ ./convert_to_grayscale.py input.jpg output-actual-421.jpg 4 2 1
$ ./convert_to_grayscale.py input.jpg output-actual-221.jpg 2 2 1
$ ./convert_to_grayscale.py input.jpg output-actual-121.jpg 1 2 1

この中では、グリーンフィルタ(右端)がもっともRec. 601比率に近く、冒頭のカラー写真の印象に最も近い結果になっている。一方で、レッドフィルタ(左端)は青い空や緑の滑り台が極端に暗くなって、現実的離れした絵になっている。

f:id:fridaynight:20180916222834j:plain:w150 f:id:fridaynight:20180916222844j:plain:w150 f:id:fridaynight:20180916222854j:plain:w150 f:id:fridaynight:20180916222903j:plain:w150

以前にも述べたが、RGB比率をそのまま指定するだけでは実際のモノクロフィルムとカラーフィルタを組み合わせたシミュレーションにはなっていない。モノクロフィルムの製品ごとの分光感度特性をRGB比で表してから、それにカラーフィルタの分光透過特性をRGB比で表したものを掛けるのが望ましい。ここでは仮にRec. 601に近いR:G:B=3:5:2というフィルムを想定して、それに上記と同じフィルタをかけて考えてみる。すると、レッドフィルタは1.0:0.41:0.08、オレンジフィルタは1.0:0.83:0.166、イエローフィルタは0.6:1.0:0.2、グリーンフィルタは0.3:1.0:0.2ということになる。

こうすると、より現実的な結果が得られるような気がする。各カラーフィルタの一般的な意図により合う感じになっているのではないだろうか。この例の場合は、滑り台を降りて水しぶきを上げている子を目立たせたいので、オレンジフィルタの例が最善だと思う。

f:id:fridaynight:20180916225031j:plain:w150 f:id:fridaynight:20180916225041j:plain:w150 f:id:fridaynight:20180916225050j:plain:w150 f:id:fridaynight:20180916225059j:plain:w150

cram_image スクリプトには既にこの機能が組み込んであって、以下のようにフィルタを指定してグレースケール変換を行うことができる。フィルタにはgray(Rec. 601)、gray-709(Rec. 709)、gray-r(レッドフィルタ)、grey-o(オレンジフィルタ)、gray-y(イエローフィルタ)、gray-g(グリーンフィルタ)、gray-b(ブルーフィルタ)がある。

$ cram_image input.tif --destination ~/photos -filter gray-o

オレンジフィルタに加えて、モノクロ化の際の常套手段である、主要被写体の輝度を中間点としたコントラストアップおよび白飛び防止のレベル補正をかけてみよう。仕上げに長辺1800ピクセルにリサイズして、アンシャープマスクも少々かけて、出力形式をJPEGにする。

$ cram_image input.tif --destination ~/photos -filter gray-o --level 0 105 --contrast 5 38 --resize 1800 0 --unsharp 0.8 0.5 --output_format jpg

そもそも構図がいまいちというか、風景を撮りたいのか子供の表情を撮りたいのかが中途半端になっているので、多少色をいじったところで印象的な写真になるわけではない。でも、敢えてモノクロ化するのであれば、こういうリッチコントラスト的な写真を私は好む。
f:id:fridaynight:20180917105630j:plain

まとめ。ImageMagickでも任意のRGB比率のグレースケール変換をするには、-evaluate と -intensity average を使ってごにょごにょするとよい。それにしても、なんでLinearAverageみたいなモードがないのか不思議だ。sRGB空間で平均を取るなんて、素人には意味がわからない。もちろん、なんか理由はあるのだろうけども。

ETSUMI メタルインナーフード 46mm Panasonic LUMIX G 20mm/F1.7専用 ブラック E-6309

ETSUMI メタルインナーフード 46mm Panasonic LUMIX G 20mm/F1.7専用 ブラック E-6309