メインコンテンツへスキップ
#ツール比較 約9分で読めます

カラーコード変換ツールを自作してOKLCHの変換行列を書いたら、HSLの「明度」がどれだけ当てにならないか数値でわかった

HEX・RGB・HSL・OKLCH相互変換ツールを自作した記録。OKLCHの変換行列を実装したら、HSLで同じ明度50%の黄色と青が知覚明度96.8%と45.2%に割れる現実が数値で見えた。WCAGコントラスト比4.5:1の実装と意味も。

このブログのダークテーマを調整していたとき、色選びの手順がずっと非効率だった。DevToolsでHSLのスライダーを動かして、良さそうな色をHEXでコピーして、コントラストが足りているか別のチェッカーで確認して、を行ったり来たり。しかも最近はCSSでoklch()を見かける機会が増えたのに、oklch(60.6% 0.219 292.7)と書かれても頭の中でどんな色か再生できない。

変換とコントラスト判定とパレット確認が1画面でできれば済む話なので、カラーコード変換ツールを自作した。HEX・RGB・HSL・HWB・OKLCHの相互変換、WCAGコントラスト比のAA/AAA判定、明度と配色のパレット生成まで、全部ブラウザ内のJavaScriptで完結する。この記事は、その実装、特にOKLCHへの変換を自分で書いたことで見えたものの記録だ。

HEX⇄RGBは変換ですらない。本題はOKLCH

作り始めてすぐわかるが、HEXとRGBの変換は「変換」と呼ぶのも大げさで、16進数と10進数の読み替えでしかない。#8b5cf6は8b=139、5c=92、f6=246、つまりrgb(139, 92, 246)と同じ数値の別表記だ。HSLも三角関数すら要らない場合分けで書ける。

本題はOKLCHだった。CSS Color Level 4で入った色空間で、Bjorn Ottossonが2020年に発表したOKLabの円筒座標版。変換はワンステップでは書けず、パイプラインになる。

sRGB(0-255) → ガンマ補正を外してリニアRGB → 3x3行列でLMS錐体応答へ
→ 立方根 → 3x3行列でOKLab(L, a, b) → 直交座標を極座標にしてLCh

実装したコードの骨格はこうだ(ツールで実際に動いているもの)。

const srgbToLinear = (v) => {
  v /= 255;
  return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};

function rgbToOklch(r, g, b) {
  const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
  const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
  const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
  const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
  const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
  const A = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
  const B = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
  const C = Math.sqrt(A * A + B * B);
  let H = Math.atan2(B, A) * 180 / Math.PI;
  if (H < 0) H += 360;
  return { L, C, H };
}

行列の係数はOttosson本人が公開している値をそのまま使う。書いていて面白かったのは、この行列がLMS、つまり人間の網膜にある3種の錐体細胞の応答を近似している点だ。HSLがRGB値の最大・最小から機械的に算出されるのに対して、OKLabは「人間の目がどう感じるか」のモデルを経由している。この差が、次の話に直結する。

hsl(60 100% 50%)とhsl(240 100% 50%)は「同じ明度」ではない

実装が動いた日に、まず黄色と青を入れてみた。HSL表記ではどちらも明度50%の色だ。

  • hsl(60 100% 50%) = #ffff00oklch(96.8% 0.211 109.8)
  • hsl(240 100% 50%) = #0000ffoklch(45.2% 0.313 264.1)

同じ「明度50%」のはずが、知覚明度では96.8%と45.2%。黄色はほぼ白に近い明るさで、青は中間より暗い。倍以上の開きがある。画面に並べれば誰でも「黄色の方が明るい」とわかることだが、それが数値としてこれだけ乖離しているのは、自分で変換を書いて初めて実感した。

原因はHSLのLの定義にある。(max + min) / 2、つまりRGB成分の最大値と最小値の平均でしかなく、人間の目が緑に敏感で青に鈍いという感度の偏り(相対輝度の係数でいうとGが0.7152、Bはわずか0.0722)を完全に無視している。#ffff00はRとGが全開だから明るく見え、#0000ffは感度の低いBしか光っていないから暗く見える。HSLのLはこの現実を1ミリも反映しない。

だからHSLのLは「その色が読みやすいか」の判断材料にならない。白文字を載せてみると、#ffff00背景はコントラスト比1.07:1で完全に読めず、#0000ff背景は8.59:1でAAAまで通る。同じL=50%でこれだ。ツールにはこの2色を貼るだけで、OKLCH値とコントラスト判定が同時に出るので、疑う人は検算してみてほしい。

パレット生成で効いてくる。ただしOKLCHにも罠がある

この性質はパレット生成でそのまま効いてくる。ツールには選択色から補色・トライアドを作る機能を付けたが、実装は古典的なHSLの色相回転(Hに120度ずつ足す)にした。すると、たとえばhsl(258 90% 66%)の紫からトライアドを作ると、SとLは固定なのにOKLCHのLは60.2%→74%→86.7%とバラける。色相を回しただけで、知覚的な明るさが階段状にずれていくのがOKLCH表示で丸見えになる。

「ならパレットもOKLCHベースで作ればいいのでは」と当然思って試したが、ここに罠があった。OKLCHでLとCを固定して色相だけ回すと、sRGBで表現できない色が普通に発生する。OKLCHの空間はディスプレイのsRGB立体より広いので、はみ出した色をどう丸めるか(ガマットマッピング)を決めないと使えない。雑にクリップすると結局CやLが動いて「揃えたつもりの色」がずれる。一方HSLは定義上必ずsRGB内に収まる。なのでツールの初版は、生成はHSLで行い、OKLCH値を常時表示して明るさのズレを目で確認できる構成にした。知覚均一な色空間は、均一である代償として「画面に出せない色」を平気で指せる。これも書いてみるまで知らなかった。

実務での落とし所はシンプルで、色相をまたいで明るさを揃えたいときは、HSLのLではなくOKLCHのLを目安に個々の色を微調整する。CSSならoklch()を直接書いてしまえばいい。2023年に主要ブラウザの対応が出揃って、今は普通にBaselineだ。Tailwind CSS v4のデフォルトパレットもOKLCHで定義されるようになった。

コントラスト比4.5:1の実装と、このブログの紫が落ちた話

もうひとつの主役がWCAGのコントラスト比チェックで、実装自体は拍子抜けするほど短い。各色の相対輝度を出して、明るい方をL1、暗い方をL2として(L1 + 0.05) / (L2 + 0.05)。最大は白と黒の21:1で、通常サイズの文字はAAで4.5:1以上が基準になる。

実装上ひとつだけ引っかかったのが、相対輝度のガンマ補正の閾値がWCAG 2.xの定義では0.03928で、OKLCH変換で使ったsRGBの正式な値0.04045と微妙に違うこと。ほぼ同じ関数を2回書くことになって「コピペで済むのでは」と思ったら定数が食い違っていて調べる羽目になった。歴史的経緯による差で結果への影響は実質ないが、仕様に合わせてツールでは両方をそれぞれの定数で持っている。

で、出来上がったチェッカーに最初に食わせたのが、このブログのテーマカラーだ。アクセントの紫#8b5cf6を背景に白文字を載せると4.23:1で、AAの4.5:1にわずかに届かない。黒文字なら4.96:1で通る。ボタンの配色として何となく「紫地に白文字」を使いそうになるが、数値上はアウト側だった。逆に本文の文字色#b4a7deは背景#150829に対して8.67:1で、AAAの7:1も超えていることが確認できた。ダークテーマの調整でスライダーを往復していた頃に欲しかったのは、まさにこの即答だ。

アクセシビリティ対応としてのコントラスト基準の全体像(大きい文字は3:1でよい、などの条件分岐)はWCAGアクセシビリティの記事にまとめているので、基準側の話はそちらを参照してほしい。

変換を自分で書くと、色の解像度が上がる

カラーコード変換ツールは、HEX・rgb()・hsl()のどれを貼っても自動判別して5形式に変換し、透明度にも対応して、コントラスト判定とパレット生成まで1画面で出す。処理は全部ブラウザ内で、色が外部に送信されることはない。ブランドカラーのような出す前の色を貼っても安全なように、というのはdiffツールを作ったときと同じ方針だ。

正直、変換だけならライブラリを入れれば10分で終わる。それでも行列を手で書いた収穫は大きくて、「HSLの明度はRGBの最大最小の平均にすぎない」「OKLCHは網膜の錐体応答を経由している」「知覚均一な空間は画面外の色を指せる」という構造が頭に入った。今はCSSでoklch(75% 0.08 295)と見かけたら、だいたいどのあたりの色か読めるようになっている。色コードが読めるようになる一番の近道は、変換式を一度自分で書くことだった。

#カラーコード #OKLCH #CSS #コントラスト比 #自作ツール
シェア