豪鬼メモ

一瞬千撃

sRGB空間で画像処理するべからず

みんな大好きImageMagickは、入力画像のデフォルトの色空間はsRGBだとみなす一方、各種の画像加工のオペレータは対象となるデータの色空間がRGBであることを前提としている。したがって、色空間を明示的に変換しないで処理を行うと結果がおかしなことになるのだ。本家の記事にも書いてあるが、たとえリサイズであっても色空間の影響を受けてしまう。

リサイズを「convert old.jpg -resize 400x300 new.jpg」とかやるように書いているサイトが多い。それも間違いではないが、最適ではない。これからは「convert old.jpg -colorspace rgb -resize 400x300 new.jpg」ってやってほしい。
f:id:fridaynight:20180901161935j:plain


人間の視覚の特性として、暗いところの輝度の差には敏感だが、明るいところの輝度の差には鈍感だというのがある。暗い部屋で豆電球を点けると明るさが分かるが、明るい屋外で豆電球を点けても光っているんだかいないんだかわからない。人間の感覚特性が対数的であるという経験則はヴェーバー・フェヒナーの法則として知られる。

それもあって、多くの画像フォーマットでは、線形RGBの値をsRGBまたはその変種の非線形スケールに変換して記録している。sRGBでは、低輝度(<=0.0031308)の値は定数(12.92)倍して線形に変換し、中輝度以降の値はガンマ2.4してから1.055を掛けて0.055を引いた非線形値に変換している。0.0031308はどちらの式でも0.04045になるので連続的だ。JPEGなどの圧縮フォーマットではさらにそのsRGB値をYCbCrなどの色空間に変換して保存するわけだが、その画像を復元する際には逆変換を順次行ってRGB値を得ることになる。RGBとsRGBの間の変換と逆変換をPythonで書くとこんな感じ。

def rgb_to_srgb(value, quantum_max=1.0):
  if value <= 0.0031308:
    return value * 12.92
  value = float(value) / quantum_max
  value = (value ** (1.0 / 2.4)) * 1.055 - 0.055
  return value * quantum_max

def srgb_to_rgb(value, quantum_max=1.0):
  value = float(value) / quantum_max
  if value <= 0.04045:
    return value / 12.92
  value = ((value + 0.055) / 1.055) ** 2.4
  return value * quantum_max

例えば、16ビットの元画像があったとして、仮にRGB空間のまま8ビットで保存するとなると、0から255までの値は全て0になり、256から511までの値は全て1になってしまうなど、人間が敏感なはずの暗部の情報欠損が激しい。しかし、sRGBに変換すると状況が改善する。0から205(= 65535 * 0.0031308)までの値は12.92を掛けて0から2648.6に変換してから8ビットに落とされて0から10までとなり、206から255まではガンマ変換で2661.3から3243.9に変換してから8ビットに落とされて10から12までとなるので、合わせて13階調が残る。256から511まではガンマ変換と8ビット化により12から21までになり、9階調が残る。512から767までは21から28までの7階調が残る。もし最暗部もガンマ変換してしまうと階調を多く割り当てすぎる嫌いがあるが、あまりに暗い領域は人間にも視認できないしノイズに埋もれて有用な情報が感知されていないだろうから、線形変換を混ぜて0から255に13階調を割り当てるという作戦は妥当だろう。このあたりの話は以前の記事にも書いた。一方で、暗部の階調を豊富にした分、明部の階調は少し犠牲になる。64952から65535までは8ビットだと全て255となってしまう。しかし、64952と65535の0.88%の差を弁別できる人間は多くないだろう。

ちなみに、ImageMagickやその他の画像加工ソフトウェアに典型的な16ビットの作業空間において上述のガンマ変換をかけると、最明部65279から65535までの256階調は、65422から65534までの113階調に圧縮される。つまり、最明部256階調に限定して言えば作業空間上でRGBとsRGBの変換をしただけで1.17ビットくらいの情報欠損が起きていることになる。しかし、最終出力がJPEGなら8ビットだし、そのままディスプレイに表示するにしてもハイエンド製品で10ビットまでなので、少なくとも作業空間上での情報欠損はあまり気にしなくて良さそうだ。


さて、画像ファイルのメタデータにそのRGB値の色空間が明示してあるとは限らないので、ImageMagickはsRGB空間だとみなして読み込む。一方で、読み込んだ結果のRGB各チャネルの値は何の変換もしないまま作業空間に保持する。しかし、-level オペレータや -resize オペレータなどのほとんどの画像処理では、RGB値が線形空間にあることを前提としたアルゴリズムを実装している。したがって、画像データを読み込んだら、各種画像処理のオペレータを実行する前に、色空間をRGBに変換してやる必要がある。それには -colorspace オペレータを使う。色空間の名前は大文字と小文字を区別しないので、rgbでもRGBでもOK。

convert input.jpg -colorspace rgb {other operators} output.jpg

そして、もう一つ重要なのが、TIFF形式でデータを書き出す場合、出力の前に明示的にsRGB空間に基づくデータに変換することだ。JPEGはsRGBが前提の形式なのでsRGBに暗黙的に変換されるのだが、TIFFはそうではない。なので、TIFF出力で色空間をsRGBに直さないと、やたら暗い画像が生成されてしまう。これはなかなか気づかない罠だ。

convert input.tif -colorspace rgb {other operators} -colorspace srgb output.tif

なお、TIFFなどの入力画像の色空間が自明でない形式で、しかも実際のデータがRGBでない場合には、「-set colorspace」で色空間を指示してから、RGBへの変換を行う必要がある。例えばYUVを保持したTIFF画像を加工する場合、以下のようになる。

convert input.tif -set colorsspace yuv -colorspace rgb {other operators} -colorspace yuv output.tif

RGBを記録したTIFFをそのまま処理するならば、色空間の処理は必要ない。逆に言えば、色空間の指定をしなくていいのはこのケースだけだ。

convert input.tif {other operators} output.tif

この話は、慣れた人には当たり前のことなんだろうけど、多くのユーザは見落としてしまうだろう。本来は全ての画像処理オペレータについて使い方を知る前に、色空間の扱いについて熟達せねばならないのだ。マニュアルの最初の最初に赤文字で書いてあってもおかしくないほど重要。


リサイズくらいだったら色を変えるわけじゃないから色空間は関係ないと思うかもしれないが、そうではない。冒頭に書いた本家の記事にもあるように、-resize オペレータは入力画像の幾つかの画素を混合して出力画像の新しい画素を作り出すコンボリューション演算を伴うので、その画素混合の際の加算処理で色空間の影響を受けるのだ。例えば [A, B, C, D] という一次元のデータをリサイズして [O, P] を作るとして、OはAとBの平均値を、P'はCとDの平均値を取るというフィルタ(pixel averaging)を想定する。Aが0.25、Bが0.75だとすると、Oは0.5になる。これがRGB空間ならそのまま理解すれば良いが、sRGB空間だとするとおかしなことになる。sRGBのAの値が0.25なら、その真の輝度は0.0508だ。Bの値が0.75なら、その真の輝度は0.5225だ。真の輝度の平均は0.2866で、そのsRGB値は0.5717になるべきだ。しかし実際には0.5という画素が生成されてしまうので、結果としてやや暗い画像が生成されることになる。元画像で輝度差が大きい画素が隣り合っていると、暗い方に引っ張られてしまうということだ。それでも多くの場合で全体の印象は変わらないが、例えば星空の写真をsRGB空間のままリサイズすると小さな星が消える現象に悲しむことになる。実際の -resize オペレータはもうちょい賢いLanczosなるフィルタを使っているらしいのだが、sRGB空間では暗い方に引っ張られるという傾向は同じだ。

輝度を調整するガンマ補正でももちろん影響がある。輝度0.1の画素にガンマ補正2.0を掛けることを考える。sRGBにおける0.1の真の輝度は0.01だ。そのガンマ2.0は0.1なので、sRGBに直した0.3493が生成されるべきだ。しかし実際には0.3162の画素が生成されるので、意図したよりも暗い画像が生成されることになる。その他、元画像の輝度値を利用する全てのオペレータは、色空間の影響を受けてしまう。


ImageMagickで画像を生成する際にも色空間の指定は重要だ。まず、色空間のことを何も考えないで画像を生成してみる。-size でキャンバスサイズを指定して、その後に色を指定して画面を塗るのだが、今回はグラデーションを作っていて、それからテキストを幾つか重ねて描画する。出力はTIFFで書き出している。

convert -size 400x300 "gradient:#222222-#dddddd" \
  -pointsize 80 -font Times-Roman \
  -gravity northwest -fill "#880000" -annotate +20+20 "TEST" \
  -gravity center -fill "#008800" -annotate 0 'TEST' \
  -gravity southeast -fill "#000088" -annotate +20+20 'TEST' \
  test-default.tif

TIFFだとブラウザで閲覧できないこともあるので、出力をJPEGに再変換した例を載せる。キャンバスに色を塗って生成した時点では色空間はsRGBになっていて、その色指定はsRGB空間に適応されて適切な輝度で描画される。文字を書く-annotate オペレータはsRGB空間にRGB空間の値を生成するので、ガンマ1/2.4だけ暗くなる。そして書き出し時にはsRGB色空間のまま出力される。したがって、背景は適切で、文字だけ暗くなる。
f:id:fridaynight:20180910212106j:plain

次に、キャンバスを生成した直後に作業用色空間をRGB色空間に変換する。しかし、書き出し前にsRGBに変換はしないでおく。

convert -size 400x300 "gradient:#222222-#dddddd" \
  -colorspace rgb \
  -pointsize 80 -font Times-Roman \
  -gravity northwest -fill "#880000" -annotate +20+20 "TEST" \
  -gravity center -fill "#008800" -annotate 0 'TEST' \
  -gravity southeast -fill "#000088" -annotate +20+20 'TEST' \
  test-rgb-proc.tif

すると、文字が適切に描画されるようになるが、書き出し時に全てがガンマ1/2.4だけ暗くなる。ただし、この出力画像がsRGBでなくRGBのデータだと分かって使う場合には、これで適切である。
f:id:fridaynight:20180910213651j:plain

最後に、RGBで描画処理を行った上で、sRGBに変換して、保存する。

convert -size 400x300 "gradient:#222222-#dddddd" \
  -colorspace rgb \
  -pointsize 80 -font Times-Roman \
  -gravity northwest -fill "#880000" -annotate +20+20 "TEST" \
  -gravity center -fill "#008800" -annotate 0 'TEST' \
  -gravity southeast -fill "#000088" -annotate +20+20 'TEST' \
  -colorspace srgb \
  test-rgb-proc-srgb-output.tif

背景も文字も適切な輝度で描画され、そして適切な色空間で保存される。こうして初めてsRGBとして所望の絵が得られる。繰り返しになるが、出力がJPEGの場合には出力前の色空間の変換は省略できる。ただ、省略しない方が精神衛生上はいいかもしれない。
f:id:fridaynight:20180910213737j:plain


sRGBのJPEG画像やTIFF画像を扱う場合には、単にそれをRGBに変換してから処理すればよい。しかし、画像にsRGB以外のプロファイルを設定している場合には、話がもうちょっと複雑になる。例えばAdobe RGBは全ての輝度域でガンマ2.4を適用して、低輝度域の線形扱いがない。ProPhoto RGBでは、全ての輝度域にガンマ1.6が適用される。よって、ファイルに保存されている各画素の輝度はプロファイルによってかなり異なったものになる。-colorspace オペレータは、もし対象画像にプロファイルが設定されていれば、それを参照して、適切な変換を行ってくれる。しかし、その変換でRGBになってしまった状態では、元のプロファイルが何だったかという情報はもう残っていない。したがって、出力を書き出す前に、プロファイル情報を補ってあげないといけない。そのためには予めプロファイルを抽出して別のファイルとして保存しておいて、加工後にそれを適用することになる。プロファイルの適用は -profile オペレータで行うのだが、それはsRGB色空間のデータを前提としているので、その前にRGB色空間のデータをsRGB色空間に変換せねばならない。以上をまとめると、ICCプロファイル付きのファイルを加工する場合、以下のような手順になる。

convert input.jpg input.icc
convert input.jpg -colorspace rgb {other operators} -colorspace srgb -profile input.icc output.jpg

なんだよこの仕様はと思う。外部ファイルとしてプロファイルを退避するのも面倒だが、事後のプロファイルの適用をsRGBデータに対して行うってのは直感に反するので、ちゃんと扱えているユーザの方がむしろ少ないんじゃないかと思う。


話は少し逸れるが、昨今のカメラやディスプレイの進化を考えると、もはやsRGBで現像するのは勿体無い。例えばこの画像をMacbook Proやその他の広色域ディスプレイ(Adobe RGB対応、IDC-P3対応)で見てほしい。左がsRGBで、右がProPhoto RGBプロファイルの現像例だ。同じ画像をもとにLightroomで書き出している。滑り台の緑の色が全然違うのがわかるかと思う。

f:id:fridaynight:20180910214641j:plain:w300 f:id:fridaynight:20180910214649j:plain:w300

現在市場にある多くのブラウザはICCプロファイルに対応しているので表示に関しては問題ない。しかし、プロファイル付きの画像を加工処理するにあたっては、常にプロファイルの持ち回しの問題が付きまとう。それを嫌って私も以前はsRGBを主に使っていたが、やはり勿体無いと思って改心した。最近はDisplay P3プロファイルで現像している。ProPhoto RGBは現時点ではオーバースペックすぎるし、ProPhoto RGBのガンマ1.6はsRGBのガンマ2.4と乖離しすぎていて、もしプロファイルが剥がされた時に見られたものじゃなくなる。実際、このはてなブログではプロファイル付きの画像を表示できるのだが、それからサイドバーなどに表示する際のサムネイルを生成する際に、なぜかプロファイルを剥ぎ取ってしまうのだ。Google Photosが閲覧用の縮小画像を生成する際にも同じ問題がある。アホかと。サムネイルのデータ量を減らしたいのは分かる。しかし、プロファイルを剥ぎ取るんだったら、各画素のデータの値もsRGBに変換しないとダメゼッタイ。

で、Display P3はガンマ特性がsRGBと全く一緒で、色域だけIDC-P3相当に広くなっているもので、加工の際にsRGBと同じ点だけ留意していればよいので楽だ。sRGB比で緑だけが鮮やかになるAdobe RGBと違って緑と赤が両方鮮やかになるのも良い。IDC-P3対応ディスプレイで表示するのに必要十分なスペックだし、もしプロファイルが剥がされた場合でも、色が薄くなるだけで、視認性はそんなに落ちない。このブログの多くの写真はDisplay P3プロファイル付きで、鮮やかだねと言ってもらえることがたまにある。一方でサイドバーのサムネイルはプロファイルが剥がされて色が薄くなっているけれども、気づかない人がほとんどだろう。つか、各サイトの開発者各位におかれては、後生だからプロファイル付きの画像を壊さないでいただきたい。ちなみに、ImageMagickでプロファイル付きの画像を正しくsRGBに変換してプロファイルを剥ぎ取ったサムネイルを作る手順は、以下のものだ。

convert input.jpg -auto-orient -profile srgb.icc -colorspace rgb -resize 256x256 -strip output.jpg

プロファイル付きの画像に -profile オペレータを使うと、画像データを新しいプロファイルのものに変換した上で、新しいプロファイルを付与してくれる。プロファイルがない場合には画像データの変更はせずにプロファイルだけが付けられるのだが、いずれにせよそのプロファイルは -strip オペレータで捨てられてしまう。EXIFの画像回転情報を失う前に-auto-orientで向きを正立させるのも重要だ。

この手順だとsRGBのICCプロファイルが必要なのだが、convert some_srgb_image.jpg srgb.icc とかやって既存のsRGBプロファイル付き画像からエクスポートしてもいいし、Ubuntuとかのディストリビューションだったら apt-get install icc-profiles とかすると /usr/share/color/icc の下あたりに各種プロファイルがインストールされるので、それを使ってもいい。


まとめ。お手軽かつ全自動で柔軟な画像処理ができるImageMagickは偉大なんだけど、レイヤーが低すぎて全くもってエンドユーザ向きではない。スパルタンなことは分かっていたことだけど、なかなかですよこれは。