色覚シミュレーションは行列3本で書ける
赤の#e60012と緑の#009944は、P型(1型二色覚)のシミュレーションを通すと#545418と#919143になる。どちらもくすんだ黄色で、色相の差は残っていない。
CIE Labで測ると、赤緑軸のa*の差は127.6から2.6まで落ちる。残るのは明るさの差だけ。
手元で試せるように色覚シミュレーションを置いた。中身は3行3列の行列が3本あるだけで、コメント込みで90行に届かない。
変換の経路
やっていることは5段階。
- sRGBの各チャンネルを、ガンマ2.4の区分関数で線形に戻す
- 線形RGBをLMS(L・M・S三種の錐体の応答)へ移す
- 欠けている錐体の応答を、残る2つから引き直す
- LMSを線形RGBへ戻す
- sRGBのガンマを掛け直して0〜255の整数に丸める
コードにするとこれだけ。
export function simulate(rgb: Rgb, type: VisionType): Rgb {
const linear = [rgb.r, rgb.g, rgb.b].map((value) => toLinear(clamp(value, 0, 255) / 255))
const projected = multiply(PROJECTIONS[type], multiply(LINEAR_RGB_TO_LMS, linear))
const [r, g, b] = multiply(LMS_TO_LINEAR_RGB, projected).map((value) =>
clamp(Math.round(toSrgb(clamp(value, 0, 1)) * 255), 0, 255),
)
return { r, g, b }
}
3番目が本体で、型ごとに1本ずつ行列を持つ。
引き直す行列
const PROJECTIONS = {
protanopia: [
[0, 2.02344, -2.52581],
[0, 1, 0],
[0, 0, 1],
],
deuteranopia: [
[1, 0, 0],
[0.494207, 0, 1.24827],
[0, 0, 1],
],
tritanopia: [
[1, 0, 0],
[0, 1, 0],
[-0.395913, 0.801109, 0],
],
}
単位行列の1行だけを差し替えた形になっている。
P型はL錐体が無いので、1行目でLを捨てて2.02344 * M - 2.52581 * Sに置き換える。D型は2行目でMを、T型は3行目でSを同じように引き直す。触っていない行はそのまま通る。
出典はViénot, Brettel & Mollon(1999)とBrettel, Viénot & Mollon(1997)。二色覚の見え方を、LMS空間の1枚の平面への射影として近似する組み立て。
白が動かないことを先に確かめる
行列を写したあと、最初に見るのは無彩色。
白の線形RGB (1,1,1) をLMSに移すと (65.51785, 34.47819, 1.68136) になる。3本の投影に通しても、どの成分も元の値から動かない。相対差は最大で0.004%(T型のS成分)。
グレースケールも通した。0〜255の全256段を3型すべてに入れて、出力のr・g・bが入力からどれだけずれるかを測る。
protanopia 最大ずれ 0
deuteranopia 最大ずれ 0
tritanopia 最大ずれ 0
1段も動かない。行列は白を動かさないよう組んであるので、この性質が先に立つ。数字を1桁写し間違えるとまずグレーが色づくから、細かいデバッグに入る前にここで落ちる。
ガンマを飛ばした実装が出回っている
1段目の線形化を省いて、sRGBの0〜1をそのまま行列に入れている実装をよく見る。行列演算は線形な量に対して定義されているので、これは間違い。
どれくらいずれるか測った。各チャンネル15刻みの5832点で、正しい実装とガンマ無視版のチャンネル最大差を取る。
| 型 | 平均差 | 最大差 | 最大差が出た色 |
|---|---|---|---|
| P型 | 7.7 | 65 | #ff0000 |
| D型 | 12.8 | 72 | #ff0000 |
| T型 | 26.9 | 217 | #5a00ff |
純赤#ff0000のP型は、正しくは#5e5e0dだがガンマ無視版では#1d1d01になる。ほぼ黒。
厄介なのは無彩色で、こちらは0〜255の全段でぴったり一致する。差は0。白とグレーのテストだけ書いても、ガンマ忘れは1件も捕まらない。有彩色の既知値が要る。
赤と緑が潰れるところ
CIE Labで測ると、何が消えているかがはっきりする。赤#e60012と緑#009944で。
| ΔL* | Δa* | Δb* | ΔE(CIE76) | |
|---|---|---|---|---|
| 元 | 7.2 | 127.6 | 22.0 | 129.7 |
| P型 | 24.1 | 2.6 | 7.2 | 25.3 |
| D型 | 0.1 | 4.1 | 26.2 | 26.5 |
赤緑軸のa*が127.6から2.6・4.1まで落ちる。P型では代わりにL*の差が7.2から24.1へ広がるので、明るさの差が手掛かりとして残る。
D型はもっと悪い。L*の差が0.1しかない。WCAGのコントラスト比をカラーコード変換と同じ式で出すと、元の1.290に対してD型は1.004。明るさでも分けられない。残るのはΔb*の26.2、黄青方向のわずかな差だけ。
赤と緑で系列を塗り分けたグラフは、凡例と本体を色で結べなくなる。
青と黄は1ビットも動かない
面白いのは不動点があること。#0000ffと#ffff00は、3型すべてを通しても出力が入力と完全に一致する。
| 元の色 | P型 | D型 | T型 |
|---|---|---|---|
| #e60012(赤) | #545418 | #848400 | #a8a800 |
| #009944(緑) | #919143 | #838348 | #7070ff |
| #0000ff(青) | #0000ff | #0000ff | #0000ff |
| #ffff00(黄) | #ffff00 | #ffff00 | #ffff00 |
投影の平面がこの2色を通るように組まれているから。赤と緑ではなく青と黄で分けろという配色の定石は、行列の上ではこの不動点の話になる。
彩度を落としても効く。青#0068b7と黄#f2e700のΔEは元が146.6で、P型146.5・D型154.9・T型193.2。ほぼ目減りしない。
T型の潰れ方は別
T型はS錐体が欠けるので、赤緑ではなく別のところが潰れる。
#00ff00 → #bdbdff
#00ffff → #bdbdff
#00ff80 → #bdbdff
#ff0000 → #baba00
#ff00ff → #baba00
#ff0080 → #baba00
緑とシアンとその中間が、同じHEXに落ちる。赤とマゼンタとピンクも同じ。sRGB全体を各チャンネル17刻み(4096色)でサンプルすると、出力の異なり数は1491色まで減る。P型は3170色、D型は3668色なので、整数の丸めだけを見てもT型がいちばん激しい。
テストの既知値にマゼンタを選んだのはこの表のため。青はT型で1ビットも動かないので期待値にならず、緑は他の色と衝突していて回帰の当たりが弱い。
期待値は実装の外から取る
最後にテストの話。
シミュレーションの期待値を、実装の出力をコピーして作ると検算にならない。行列を打ち間違えたまま緑になるだけ。
なので期待値は、論文の行列から素朴に書き下した別のスクリプトの出力から取った。#ff0000のP型が#5e5e0d、#00ff00のD型が#dbdb29、#ff00ffのT型が#baba00。実装と独立に出した値なので、突き合わせに意味がある。
無彩色が動かないこと。有彩色の既知値と合うこと。0〜255の整数に収まること。この3つを押さえておけば、写し間違いはだいたい落ちる。