色覚シミュレーター(アップロードした画像や配色が、1型・2型・3型色覚の人にどう見えるかを再現するツール)を実装した。中心のロジックは30行程度で、線形代数の行列演算1回とガンマ補正だけで成り立っている。
3行まとめ
- 色覚シミュレーションは、Machado, Oliveira & Fernandes(2009)の3x3変換行列を線形RGB空間で掛けるだけで実装できる。sRGB⇄線形RGBの変換(ガンマ補正)を挟むのが要点
- 変換行列は各行の合計がほぼ1になるよう設計されており、これにより白・黒・グレーなどの無彩色は理論上どの型でも保存される。この性質はテストコードで明示的に担保している
- 画像モードと配色(パレット)モードの2つを用意し、UI側では世代番号(generation counter)でアップロード連打時のレースコンディションを防いでいる
なぜ色覚シミュレーションが必要か
日本人男性の約5%、女性の約0.2%が何らかの色覚特性を持つとされ、特に1型(P型・赤系が弱い)・2型(D型・緑系が弱い)が多数を占める。グラフの色分け・路線図・資料のハイライト表示などを「赤と緑を隣接させない」「明度差もつける」といったカラーユニバーサルデザインの観点でチェックしたい場面は多いが、自分の目では確認できない。そこで健常色覚の人が「この配色は1型・2型の人にどう見えるか」を確認できるツールを作った。
Machado(2009)モデル — 二色覚を線形代数で近似する
色覚シミュレーションの核は、Machado, Oliveira & Fernandes(2009)が提案した変換行列。severity 1.0(完全な二色覚)の状態を、線形RGB空間での3x3行列変換として近似する。
export type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia'
// Machado, Oliveira & Fernandes (2009) の severity 1.0(二色覚)変換行列。
// 行優先(row-major)。線形RGB空間に適用する。
const MATRICES: Record<ColorBlindnessType, readonly number[]> = {
// 1型(P型): 赤の錐体(L錐体)が欠損
protanopia: [
0.152286, 1.052583, -0.204868,
0.114503, 0.786281, 0.099216,
-0.003882, -0.048116, 1.051998,
],
// 2型(D型): 緑の錐体(M錐体)が欠損
deuteranopia: [
0.367322, 0.860646, -0.227968,
0.280085, 0.672501, 0.047413,
-0.011820, 0.042940, 0.968881,
],
// 3型(T型): 青の錐体(S錐体)が欠損
tritanopia: [
1.255528, -0.076749, -0.178779,
-0.078411, 0.930809, 0.147602,
0.004733, 0.691367, 0.303900,
],
}
行列の各行の合計はどれもほぼ1になるよう設計されている。これは「無彩色(グレー、白、黒)は変換しても変わらない」という性質を保証するための制約で、色覚特性による見え方の違いは彩度・色相の変化として現れ、明度の基準がずれることはない、というモデルの前提を反映している。
sRGB↔線形RGBの変換を挟む理由
行列を直接sRGBの0-255値に掛けてはいけない。sRGBはガンマ補正がかかった非線形の値なので、そのまま行列演算をすると物理的に正しくない結果になる。なので、いったん線形RGB空間に変換してから行列を掛け、結果をまたsRGBに戻す。
// sRGB(0-255) → 線形RGB(0-1)。sRGBの逆ガンマ補正。
function srgbToLinear(c: number): number {
const n = c / 255
return n <= 0.04045 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4)
}
// 線形RGB(0-1) → sRGB(0-255)。ガンマ補正して 0-255 にクランプ。
function linearToSrgb(c: number): number {
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
return Math.max(0, Math.min(255, Math.round(v * 255)))
}
0.04045・0.0031308という閾値と、それぞれの区間で式が変わる構造はsRGB規格そのものの定義。低輝度域は線形(ノイズ対策)、それ以外は2.4乗のガンマカーブという2区間構成になっている。ここを端折って単純なsRGB値に行列を掛けると、色の変化が実際の見え方と乖離する。
実装 — ピクセル単位の変換
1色の変換(simulateRgb)と、画像全体のピクセル配列を一括変換する関数(simulateColorBlindness)を分けている。
export function simulateRgb(
r: number, g: number, b: number, type: ColorBlindnessType,
): [number, number, number] {
const m = MATRICES[type]
const lr = srgbToLinear(r)
const lg = srgbToLinear(g)
const lb = srgbToLinear(b)
const or = m[0] * lr + m[1] * lg + m[2] * lb
const og = m[3] * lr + m[4] * lg + m[5] * lb
const ob = m[6] * lr + m[7] * lg + m[8] * lb
return [linearToSrgb(or), linearToSrgb(og), linearToSrgb(ob)]
}
export function simulateColorBlindness(
data: Uint8ClampedArray, type: ColorBlindnessType,
): Uint8ClampedArray {
const out = new Uint8ClampedArray(data.length)
for (let i = 0; i < data.length; i += 4) {
const [r, g, b] = simulateRgb(data[i], data[i + 1], data[i + 2], type)
out[i] = r
out[i + 1] = g
out[i + 2] = b
out[i + 3] = data[i + 3] // アルファ値はそのまま保持
}
return out
}
Uint8ClampedArrayはCanvasのImageData.dataと同じ形式(RGBA各1バイトのフラット配列)なので、getImageDataで取り出した画像データをそのまま渡し、変換結果をまたputImageDataで書き戻せる。元の配列は変更せず新しい配列を返す設計にしているため、元画像との比較表示がしやすい。
行和=1という性質をテストで担保する
Machado行列が「行和≈1」という性質を持つことは理論上の話でしかないので、実装側でも実際にテストして保証している。
it('白は全ての型で白のまま(変換行列の行和が1)', () => {
for (const t of TYPES) {
const [r, g, b] = simulateRgb(255, 255, 255, t)
expect(r).toBe(255)
expect(g).toBe(255)
expect(b).toBe(255)
}
})
it('無彩色(グレー)はほぼ変化しない', () => {
for (const t of TYPES) {
const [r, g, b] = simulateRgb(128, 128, 128, t)
// 行和が1なのでグレーは保存される(丸め誤差±2まで許容)
expect(Math.abs(r - 128)).toBeLessThanOrEqual(2)
expect(Math.abs(g - 128)).toBeLessThanOrEqual(2)
expect(Math.abs(b - 128)).toBeLessThanOrEqual(2)
}
})
グレーのテストで±2の誤差を許容しているのは、srgbToLinear→行列演算→linearToSrgbという3段階の浮動小数点演算と、最後のMath.roundによる丸めが積み重なるため。理論値ぴったりの一致を期待するとテストが壊れやすくなるので、丸め誤差の範囲を明示的に許容するのが現実的な落としどころ。
画像モードと配色モードの2つのUI
ツールは画像アップロードと、パレット(16進カラーコードの配列)入力の2モードを用意している。配色モードは1色ずつsimulateRgbを呼ぶだけで完結する。
function simHex(hex: string, type: ColorBlindnessType): string {
const rgb = hexToRgb(hex)
if (!rgb) return hex
const [r, g, b] = simulateRgb(rgb.r, rgb.g, rgb.b, type)
return rgbToHex(r, g, b)
}
デザインカンプの配色案やグラフの色分けだけを素早く確認したいときは画像を用意する必要がなく、こちらのモードの方が手早い。
アップロード連打への対策 — 世代番号ガード
画像処理は非同期でそこそこ時間がかかるため、処理中に別のファイルが再アップロードされるケースを考慮する必要がある。ここでは「世代番号(generation counter)」を使ってガードしている。
// アップロードごとに増える世代番号。処理中に別ファイルが投入されても
// 古い処理の結果で新しい結果を上書きしないためのガード。
const genRef = useRef(0)
アップロードのたびにgenRef.currentをインクリメントし、非同期処理が完了した時点で「自分が最新の世代か」を確認してからsetStateする。ファイルを連続で差し替えたときに、先に投げた重い処理が後から終わって新しい結果を上書きしてしまう、というよくある非同期処理のバグを避ける単純な仕組み。
まとめ
- 色覚シミュレーションは、Machado(2009)の3x3行列を線形RGB空間で掛けるだけで実装できる。sRGB⇄線形RGBの変換(ガンマ補正)を挟むのが要点
- 行列の「行和≈1」という性質を、白・黒・グレーが保存されることのテストとして明示的に保証しておくと、変換ロジックの信頼性を担保しやすい
-
Uint8ClampedArrayはCanvasのImageDataとそのまま互換なので、ピクセル単位の色変換処理はgetImageData/putImageDataと素直に組み合わせられる
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理などの開発者向けツールを 90 個以上公開中。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。