Webでもモダンな色空間を使う:Labとコントラスト、色差を駆使してユーザビリティに優れた文字・背景色の組を使おう

# 概要

2017年現在、CSSではRGBによる色指定とHSLによる色指定が可能である。
しかし明度を上げたいだとか2色の色の差がどのぐらいかを調べたいといった用途には不足である。
そのため無知なデザイナーによる色の暴力が度々発生し、
CSSを実装するクリエイターやユーザーの眼は深刻なダメージを受けてきた。
これ以上、「ぼくのかんがえた最強の配色デザイン」で、
"遠目ではクールだけど見辛いだけのゴミページ"が量産されるのを放置するわけにはいかない。

幸いにもLab色空間による知見やWCAG2.0によるコントラストの指標は固まっている。
それらを使って色弱者にも見やすく、健常者にも眼の負荷が少なく、
長時間の閲覧に優しいページ作りの手助けになることを目的とする。

# はじめに

RGBやHSL、そして印刷業界で使われるCMYKのような色空間では、
光子やインクといった現実の物理量に即した実数を基にしているため、
それら色空間の立方体は機械の眼には揃って見えるが、人間の眼には歪んで見える。

逆に人間の眼に揃った立方体と認識される色空間を考えることもでき、
古くからL*a*b*(以降Lab)が使われている。
Lab色空間は染色やプラスチックの造形といった製造業では必須であり、
工業規格にもなっている。
むしろ工業製品の色指定でRGBが使われることは少ない。
また、コンピュータグラフィックの世界でもLab色空間は必須である。
Photoshopにおいても内部的な色処理のベースとなっているのはLabである。

色差を使って画像処理するときにはRGBとかYUVを使う!?
そんなことしたら
「ユーは白黒テレビが現役の時代からタイムスリップしてきたのかい？」
って言われちゃうよ。
なので……

**色をあれこれいじるときはLab色空間を使え**

# RGBとLabとの変換

```javascript:rgb_lab.js
/**
* sRGBからL*a*b*に変換する
* @type {Array[3]}
* @return {Array[3]}
*/
const rgb2lab = rgb => {
    const [r, g, b] = rgb.map(d => d / 255).map(d => d > 0.04045 ? Math.pow((d + 0.055) / 1.055, 2.4) : d / 12.92)
    const [x, y, z] = [
        ((r * 0.4124) + (g * 0.3576) + (b * 0.1805)) / 0.95047,
        ((r * 0.2126) + (g * 0.7152) + (b * 0.0722)),
        ((r * 0.0193) + (g * 0.1192) + (b * 0.9505)) / 1.08883
    ].map(d => d > 0.008856 ? Math.cbrt(d) : 7.787 * d + 16 / 116)
    return [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]
}
/**
* L*a*b*からsRGBに変換する
* @type {Array[3]}
* @return {Array[3]}
*/
const lab2rgb = lab => {
    const [l, a, b] = lab
    const y = l <= 8 ? (l * 100) / 903.3 : 100 * Math.pow((l + 16) / 116, 3)
    const y2 = l <= 8 ? 7.787 * (y / 100) + 16 / 116 : Math.cbrt(y / 100)
    const x = a / 500.0 + y2 <= 0.2069 ? 95.047 * (a / 500 + y2 - 16 / 116) / 7.787 : 95.047 * Math.pow(a / 500 + y2, 3)
    const z = y2 - b / 200.0 <= 0.2059 ? 108.883 * (y2 - b / 200 - 16 / 116) / 7.787 : 108.883 * Math.pow(y2 - b / 200, 3)
    const [x1, y1, z1] = [x, y, z].map(d => d / 100)
    return [
        (x1 * 3.2406) + (y1 * -1.5372) + (z1 * -0.4986),
        (x1 * -0.9689) + (y1 * 1.8758) + (z1 * 0.0415),
        (x1 * 0.0557) + (y1 * -0.2040) + (z1 * 1.0570)
    ].map(d => Math.min(1, Math.max(0, d > 0.0031308 ? (1.055 * Math.pow(d, 1.0 / 2.4)) - 0.055 : d * 12.92)) * 255)
}
```

# LabとLCHとの変換

さて、LabのLは輝度を表し、aとbは色相を表すデカルト座標である。
ところがこの空間では色を編集するときには都合が悪いので、
円柱座標であるLCH色空間へと変換する。
LCHではLはそのまま[0, 100]で写され、
(a, b)のノルムはCに、
偏角Hはラジアンではなくdeg[0, 360)で定義する。

```javascript:lab_lch.js
/**
* L*a*b*からL*C*H*に変換する
* @type {Array[3]}
* @return {Array[3]}
*/
const lab2lch = lab => {
    const norm = (x, y) => Math.sqrt(x * x + y * y)
    const rad2deg = rad => rad * 180 / Math.PI
    return [lab[0], norm(lab[1], lab[2]), rad2deg(Math.atan2(lab[2], lab[1]))]
}
/**
* L*C*H*からL*a*b*に変換する
* @type {Array[3]}
* @return {Array[3]}
*/
const lch2lab = lch => {
    const rad = lch[2] * Math.PI / 180
    return [lch[0], lch[1] * Math.cos(rad), lch[1] * Math.sin(rad)]
}
```

# コントラストの計算

### コントラストの意味
コントラストとはウェブ・コンテンツ・アクセシビリティ・ガイドライン(WCAG)2.0で
規定されている2色間の明暗の差の指標となる数値のことで、
最小値は同色の1.0、最大値は白と黒の21.0と定義されている。
実はコントラストがある程度あれば色弱者にも視認性が良いことがわかっている。
というかコントラストと視認性の関係は健常者も色弱者もほぼ同じである。
これは配色デザインにおいては統一的に考えることができるのでありがたい。

### 必要とされるコントラスト値の目安
2色を背景色+フォントカラーとして用いるときのコントラストは
通常5.0以上を確保することとされている。最悪4.5程度はキープしたい。
ただし特大フォントを使った見出しなどであれば3.0まで低下しても許容される。
といってもこれは最低限の指標であるため、
ロゴなどでコントラスト3.0の配色だと、
パット見で認識できずによくわからないデザインという烙印を押されてしまう。
それ以下を採用してしまったら……そのデザイナーはユーザーに刺されても擁護できない。

### グレーなどの薄色文字を使うことの害悪
例えば上部にposition: fix;のヘッダーがあって10ほどのメニューがあるUIを考えよう。
このとき背景は#ffffffとして、
アクティブなメニューを#000000、非アクティブなメニューを#808080なんかで描写したら
ユーザーは次に使いたいメニューを選択する時にいらいらさせられるだろう。
そのメニューがパット見で認識できないからユーザーの動作が一旦ストップする。
クリエイターには残念に思うかもしれないが、
たとえ毎日使いたいと思える素晴らしいWebサービスであったとしても、
ユーザーはあなたのWebサイトのメニューの配置なんて覚えたくないのだ。
クリエイターがメニューのテキストを見づらくして隠し、
ユーザーがメニューと位置との対応を覚えることを強いられると、
ユーザーの短期記憶を阻害されて快適なサイト閲覧に支障をきたす。
それはユーザーの満足度を下げて、いずれは競合他者のサービスに移ってしまうなどにつながりかねない。

ECサイトで会社情報や法律で表示しなければならない文言を薄い文字で書いたらどうだろうか。
ユーザーは購買には不要な文字が目に入らないから嬉しいのだろうか。
逆である。
ユーザーにはそのサイトがやましいことをしているので
企業情報を見づらくして隠蔽工作を図っているのではないかと感じられる。
結局ユーザーが情報を得るのを困難にすると、そのサイトへの信用度は落ちるのだ。

```javascript:contrast.js
/**
* RGB2色のコントラストを求める
* @type {Array[3], Array[3]}
* @return {float}
*/
const contrast = (rgb1, rgb2) => {
    const g = d => d <= 0.03928 ? d / 12.98 : Math.pow((d + 0.055) / 1.055, 2.4)
    const rColorL = C => 0.2126 * g(C[0] / 255.0) + 0.7152 * g(C[1] / 255.0) + 0.0722 * g(C[2] / 255.0)
    result = [rColorL(rgb1) + 0.05, rColorL(rgb2) + 0.05].sort()
    return result[1] / result[0]
}
```

# 色差の計算

色差はLab色空間上で計算すべきである。
だって人間の眼から見て色がどれだけ違うのかを調べたいんでしょう？
(人ではない何かに対してなど、そうでないなら別ですが……)

```javascript:colorDiff.js
/**
* CIE 1976 L*a*b* 空間におけるユークリッド色差を求めます
* @type {Array[3], Array[3]}
* @return {float}
*/
const colorDiffLab = (lab1, lab2) => {
    const norm = (x, y, z) => Math.sqrt(x * x + y * y + z * z)
    return norm(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2])
}

/**
* CIE 1976 L*a*b* 空間におけるCIEDE2000色差を求めます
* @type {Array[3], Array[3]}
* @return {float}
*/
const colorDiffCIEDE2000 = (lab1, lab2) => {
    const norm = (x, y) => Math.sqrt(x * x + y * y)
    const tolerance_zero = x => Math.abs(x) < 0.00000001
    const cosd = deg => Math.cos(deg * Math.PI / 180)
    const sind = deg => Math.sin(deg * Math.PI / 180)
    const reg_fqatan = deg => deg >= 0 ? deg : deg + 360.0
    const fqatan = (y,x) => reg_fqatan(Math.atan2(y,x) * 180.0 / Math.PI)
    const f7 = x => x < 1.0 ? Math.pow(x / 25.0, 3.5) : 1.0 / Math.sqrt(1.0 + Math.pow(25.0 / x, 7.0))
    const [L1, a1, b1] = lab1
    const [L2, a2, b2] = lab2
    const epsilon = 0.000000001
    const C1ab = norm(a1, b1)
    const C2ab = norm(a2, b2)
    const Cab = (C1ab + C2ab) / 2.0
    const G = 0.5 * (1.0 - f7(Cab))
    const a1_ = (1.0 + G) * a1
    const a2_ = (1.0 + G) * a2
    const C1_ = norm(a1_, b1)
    const C2_ = norm(a2_, b2)
    const h1_ = tolerance_zero(a1_) && tolerance_zero(b1) ? 0.0 : fqatan(b1, a1_)
    const h2_ = tolerance_zero(a2_) && tolerance_zero(b2) ? 0.0 : fqatan(b2, a2_)
    const dL_ = L2 - L1
    const dC_ = C2_ - C1_
    const C12 = C1_ * C2_
    let dh_ = 0.0
    if (!tolerance_zero(C12)) {
        const tmp = h2_ - h1_
        if (Math.abs(tmp) <= 180.0 + epsilon) {
            dh_ = tmp
        }
        else if (tmp > 180.0) {
            dh_ = tmp - 360.0
        }
        else if (tmp < -180.0) {
            dh_ = tmp + 360.0
        }
    }
    const dH_ = 2.0 * Math.sqrt(C12) * sind(dh_ / 2.0)
    const L_ = (L1 + L2) / 2.0
    const C_ = (C1_ + C2_) / 2.0
    let h_ = h1_ + h2_
    if (!tolerance_zero(C12)) {
        const tmp1 = Math.abs(h1_ - h2_)
        const tmp2 = h1_ + h2_
        if (tmp1 <= 180.0 + epsilon) {
            h_ = tmp2 / 2.0
        }
        else if (tmp2 < 360.0) {
            h_ = (tmp2 + 360.0) / 2.0
        }
        else if (tmp2 >= 360.0) {
            h_ = (tmp2 - 360.0) / 2.0
        }
    }
    const T = 1.0 - 0.17 * cosd(h_ - 30.0) + 0.24 * cosd(2.0 * h_)
    + 0.32 * cosd(3.0 * h_ + 6.0) - 0.2 * cosd(4.0 * h_ - 63.0)
    const dTh = 30.0 * Math.exp(-Math.pow((h_ - 275.0) / 25.0, 2.0))
    const L_2 = (L_ - 50.0) * (L_ - 50.0)
    const RC = 2.0 * f7(C_)
    const SL = 1.0 + 0.015 * L_2 / Math.sqrt(20.0 + L_2)
    const SC = 1.0 + 0.045 * C_
    const SH = 1.0 + 0.015 * C_ * T
    const RT = -sind(2.0 * dTh) * RC
    const kL = 1.0 // These are proportionally coefficients
    const kC = 1.0 // and vary according to the condition.
    const kH = 1.0 // These mostly are 1.
    const LP = dL_ / (kL * SL)
    const CP = dC_ / (kC * SC)
    const HP = dH_ / (kH * SH)
    return Math.sqrt(LP * LP + CP * CP + HP * HP + RT * CP * HP)
}
```

# テストなど

一応コードの軽いテストはしてあるが、使用は自己責任で行ってください。
実際に使うには浮動小数と整数の違いを吸収するコードも必要です。

```javascript:test.js
const test = () => {
    console.log(rgb2lab([6, 26, 4]), '[7, -12, 9]')
    console.log(rgb2lab([211, 96, 21]), '[54, 42, 58]')
    console.log(rgb2lab([255, 255, 11]), '[97, -21, 94]')
    console.log(lab2rgb(rgb2lab([6, 26, 4])), '[6, 26, 4]')
    console.log(lab2rgb(rgb2lab([211, 96, 21])), '[211, 96, 21]')
    console.log(lab2rgb(rgb2lab([255, 255, 11])), '[255, 255, 11]')
    console.log(lab2lch([6, 26, 14]), '[6, 30, 28]')
    console.log(lch2lab([6, 30, 28]), '[6, 26, 14]')
    console.log(contrast([6, 30, 28], [226, 130, 8]), '6.1398')
    console.log(colorDiffCIEDE2000([50, 2.6772, -79.7751], [50, 0, -82.7485]), '2.0424596801565738')
    console.log(colorDiffCIEDE2000([50, 3.1571, -77.2803], [50, 0, -82.7485]), '2.8615')
    console.log(colorDiffCIEDE2000([50, 2.50, 0], [73, 25, -18]), '27.1492')
}
test()
```