本文へ移動
myowchick

白の色相が89.88度になる — OKLCH変換の検算

白のOKLCHの色相は何度か。

彩度が0なんだから色相は決まらない、というのが正しい答えのはずだ。それでも数式どおりに書いたコードは89.8756度を返してくる。同じ白をCSS Color 4のサンプルコードの経路で流すと180.0000度になる。色は同じで、通った道が違うだけ。

カラーコード変換ツールにOKLCH表示を足すまでで、この89.8756度が何なのかを確かめるのにいちばん時間を使った。検算の手順と、そのあと出てきた色UIの罠を並べる。

期待値を実装から作らない

行列を3つ掛けるだけの変換にテストを書くとき、いちばん楽なのは実装を1回動かして出た数値をtoBeに貼ることだ。これをやるとテストは「実装が変わっていないこと」しか見なくなる。行列の1桁を打ち間違えたまま貼れば、間違いごと固定される。

OKLCHにはこれの逃げ道がある。sRGBからOKLabへ行く道が2本あるからだ。

実装は前者で書き、テストの期待値は後者で作る。同じ間違いが両方に入る確率は低いので、これで検算になる。

まず行列そのものを突き合わせた。XYZ経路の2枚を掛け合わせると、直接行列と同じものが出てくるはずだ。

const composed = mulM(XYZ_TO_LMS, LIN_SRGB_TO_XYZ)
// composed[0] = [0.4122214695, 0.5363325373, 0.0514459933]
// 実装の直接行列 = [0.4122214708, 0.5363325363, 0.0514459929]

9要素の差は最大で5.55e-9だった。Ottossonの記事が10桁で丸めた値を載せているので、そこで消えた分が残っている。

次に色で確かめる。原色と、ツールの初期値にしている#5fa97fを両経路で流した。

色CSS Color 4経路実装
#ff000062.7955% 0.2577 29.233962.7955% 0.2577 29.2339
#00ff0086.6440% 0.2948 142.495386.6440% 0.2948 142.4953
#0000ff45.2014% 0.3132 264.052045.2014% 0.3132 264.0520
#5fa97f67.4777% 0.0978 157.532667.4777% 0.0978 157.5326

小数4桁まで一致する。ここまで合っていれば行列の書き写しは間違っていない。念のためsRGBの16,777,216色を全部回して、OKLab座標での差の最大を測った。

max |ΔL| = 6.562e-9
max |Δa| = 1.141e-8
max |Δb| = 3.727e-8   at rgb(255, 255, 255)

最大が白で出ているのが引っかかった。ここから先が本題になる。

無彩色の色相は残差の向きでしかない

白のOKLabのa・bを、丸めずに出してみる。

CSS Color 4経路 : a = -4.996003610813204e-16   b = 0
実装(直接行列) : a =  8.095285553011422e-11   b = 3.727390762708893e-8

どちらも0のつもりの数字だ。Math.hypotを取れば5e-16と3.7e-8で、表示上はどちらも彩度0になる。

問題はMath.atan2(b, a)のほうで、これは大きさを見ずに向きだけを返す。CSS経路はbがちょうど+0でaが負なので、atan2(+0, -x)が定義どおりπを返して180度。実装のほうはbがaより3桁大きいので、ほぼ真上を向いて89.8756度になる。

灰色を256段そろえて、彩度のガードを外したまま色相を出すとこうなる。

rgb実装CSS Color 4経路
rgb(2, 2, 2)89.88180.00
rgb(48, 48, 48)89.88146.31
rgb(53, 53, 53)89.88225.00
rgb(212, 212, 212)89.88296.57
rgb(255, 255, 255)89.88180.00

CSS経路は256段のあいだに40通りの色相を出してきた。黒の0度を除いた39通りは146度から296度まで散っている。実装のほうはrgb(0, 0, 0)を除く255段が全部89.88度で、これはこれで気味が悪い。残差が入力に比例して伸びるので、向きだけが変わらないという話でしかない。

どちらの数字にも意味が無い。だから彩度がほぼ0のときは色相を0に潰す。

const ACHROMATIC_CHROMA = 1e-4

const chroma = Math.hypot(a, b)
if (chroma < ACHROMATIC_CHROMA) {
  return { l: lightness * 100, c: chroma, h: 0 }
}

しきい値を勘で置くと、こんどは本物の色の色相を捨てる側の事故が起きる。1677万色を全部回して、彩度が1e-4を下回る色を数えた。ちょうど256色で、内訳はr・g・bが揃った灰色だけだった。灰色側の彩度の最大は3.727e-8なので、しきい値の4桁下にいる。灰色でない色のうち彩度が最小なのはrgb(254, 255, 255)の1.0591e-3で、しきい値の10倍上にいる。表示するとoklch(99.7% 0.001 286.38)になり、彩度3桁の表示でも0.001が残って色相も生き残る。

しきい値の上下に1桁以上の隙間が空いているので、ここは1e-4で問題ない。

コントラスト比を四捨五入すると嘘になる

同じツールにコントラスト比の検査も置いてある。比の計算は配色トークンの検査に使っているsrc/lib/contrast.tsをそのまま呼ぶ。

表示のところで一度事故った。#006ffbを白の上に載せると、比はこうなる。

ratio    = 4.499888087779618
toFixed(2) → "4.50"
aaNormal   → false

「4.50:1」と大きく出しながら、すぐ下の表に「不適合」と書く画面ができあがる。読んだ人が疑うのは自分の目か、こっちの実装かのどちらかで、どっちに転んでも損しかない。

なので表示は切り捨てにした。

function floorToTwo(ratio: number): string {
  return (Math.floor(ratio * 100) / 100).toFixed(2)
}

これで#006ffbは4.49と出る。判定と食い違わない。

どのくらい起きるのかも数えておいた。白背景に対して前景を1677万色ぜんぶ試し、四捨五入した表示だけが基準ちょうどに化ける色を拾う。

基準化ける色の数例
3:113,242#0099ff(2.99978……)
4.5:19,074#006ffb(4.49988……)
7:14,066#003cf8(6.99578……)

背景を白に固定した数え方でこれだけある。灰色だけに絞っても#959595が2.9953で「3.00」と表示されるので、素朴に選びそうな色でも普通に踏む。

4.50という数字自体が揺れている

切り捨てにしたあとで、もう一段いやなことに気づいた。

WCAGの相対輝度の係数は0.2126・0.7152・0.0722で、これはsRGBからXYZへ行く行列のY行を4桁に丸めたものだ。丸める前の行は0.21263900587151027・0.715168678767756・0.07219231536073371になる。

どちらで計算しても比の差は最大9.631e-4しかない。それでも、白背景に対してAA・AAAの判定が割れる前景色が984件あった。最初に出てきたのがrgb(0, 111, 251)、つまり#006ffbだった。

WCAG の係数    : 4.499888087779618  → 4.5 未満なので不適合
丸める前の行列 : 4.500127079375269  → 適合

さっき切り捨てにした色と同じ色が出てきたのは偶然だけど、示していることは分かりやすい。境目に乗った色の判定は、定数をどこで丸めたかで裏返る程度の精度しか持っていない。

規範はWCAGの4桁の係数のほうなので、実装はそちらに従う。そのうえで、表示だけは絶対に上へ丸めない。4.4999を4.50と書いてしまうと、桁の都合で足りているように見えるだけの色が基準を通ったことになる。

ピッカーは#rrggbbしか持てない

ここからは変換ロジックの外側の話。

input type="color"の値はHTMLの仕様で「valid simple color」に縛られていて、これは小文字16進6桁の#rrggbbだけを指す。#abcもrgb(1 2 3)も入らず、渡すとサニタイズされて#000000になる。

テキスト欄のほうは#abcもrgb(1 2 3)もhsl(210, 50%, 40%)も受けるようにしてある。なので、ピッカーへ渡す前に必ず#rrggbbへ直す。

const hex = rgb === null ? PICKER_FALLBACK : formatHex(rgb)

rgbがnullなのは、テキスト欄がまだ色として読めていないときだ。#5fまで打った時点では読めない。この間ピッカーに出せる色は無いので、PICKER_FALLBACKの#000000を出しておく。

黒を出すと「黒が選ばれている」ようにも見えるので、そこは別で潰した。ピッカーの隣に色見本のマスを置いて、読めないあいだは入力欄にaria-invalidが付き、下にエラーが出る。ピッカーが黒でも、その黒に意味が無いことは周りから分かる。

打鍵のたびにrole="alert"を挿さない

そのエラー表示に、最初はrole="alert"を付けていた。レビューで指摘されて外した。

色欄は打鍵のたびに評価される。#5fa97fを打つと、#・#5・#5f・#5fa・#5fa9・#5fa97の6打鍵ぶんが全部「読めない」状態を通る。role="alert"はassertiveなライブリージョンなので、読み上げ中のものを止めて割り込む。つまり6打鍵ぶん割り込みが入り、いま打った文字の読み上げは全部潰される。

色を打ち終わるまで「色として読めない」と6回言われて、打った文字は1つも読まれない。

いまはただの<p>にして、読めていないことは入力欄のaria-invalidで伝えている。フォーカスがそこにある以上、入力欄に付いた状態のほうが素直に届く。

一方で、コピーボタンの結果はaria-live="polite"のままにした。あれは押した瞬間にしか変わらないし、読み上げの切れ目を待って構わない。同じライブリージョンでも、変化の頻度で置き方が変わる。

まとめ

  • 期待値を実装から作るとテストが検算にならない。OKLCHは経路が2本あるので、片方で期待値を作れる
  • 彩度がほぼ0のときの色相は捨てる。あそこに出る角度は残差の向きで、経路の丸め方が変われば180度でも89.88度でも296度でもなる
  • 基準に照らして合否を出す数字は切り捨てで表示する。境目の色は、どの定数を使うかで判定が裏返る程度の精度しかない
  • 打鍵のたびに評価される欄にrole="alert"を置かない。入力欄のaria-invalidで足りる
← 一覧へ戻る