0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WCAG コントラスト比チェッカーを作る — 「相対輝度」と sRGB ガンマ、多くの自作実装が間違える所

0
Posted at

文字色と背景色の WCAG コントラスト比を計算して AA / AAA 合否を出すツールを作った。一見「RGB の明るさの比でしょ?」と思うが、コントラスト比は生の RGB でもチャンネル平均でもなく「相対輝度 (relative luminance)」から計算する。そして相対輝度を出すには各チャンネルの sRGB ガンマを戻す (線形化) 必要がある。ここを飛ばした自作チェッカーは中間グレーで間違った比を出す。実装の hinge はこの計算式と、大きいテキスト/通常テキストで閾値が違う点。完全ブラウザ完結。

🌐 デモ: https://sen.ltd/portfolio/color-contrast-checker/
📦 GitHub: https://github.com/sen-ltd/color-contrast-checker

スクリーンショット

なぜ単純な RGB 比ではダメか

「白 (255) と灰 (128) の比は 255/128 ≈ 2」——これは間違い。理由は 2 つ:

  1. ディスプレイは sRGB ガンマがかかっている。ピクセル値 128 は物理的な明るさの 50% ではなく、約 21% しかない。人間が感じる明るさを得るにはガンマを戻す必要がある。
  2. 人間の目はチャンネルごとに感度が違う。緑に最も敏感で、青に最も鈍い。だから単純平均ではなく重み付き和。

WCAG はこれを「相対輝度」として厳密に定義している。

hinge: 相対輝度の計算

手順は 4 ステップ:

// 1 + 2. 0..255 を 0..1 にして sRGB ガンマを戻す (線形化)
export function linearizeChannel(c255) {
  const c = c255 / 255;
  return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

// 3. 相対輝度 — 緑の重みが最大 (人間の目が緑に最も敏感)
export function relativeLuminance({ r, g, b }) {
  return 0.2126 * linearizeChannel(r)
       + 0.7152 * linearizeChannel(g)
       + 0.0722 * linearizeChannel(b);
}

// 4. コントラスト比
export function contrastRatio(c1, c2) {
  const l1 = relativeLuminance(c1), l2 = relativeLuminance(c2);
  const lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

ポイントを順に:

線形化の分岐 c <= 0.03928 ? c/12.92 : ((c+0.055)/1.055)^2.4 は sRGB の伝達関数の逆変換。暗い領域は線形、それ以上はべき乗。この曲線を戻さないと、中間グレーの輝度が大幅にずれる。

重み 0.2126 / 0.7152 / 0.0722 は人間の視感度 (luminosity function) を反映。緑が圧倒的に大きい。テストで確認:

test("green is brightest channel", () => {
  const g = relativeLuminance({ r: 0, g: 255, b: 0 }); // ≈ 0.7152
  const r = relativeLuminance({ r: 255, g: 0, b: 0 }); // ≈ 0.2126
  const b = relativeLuminance({ r: 0, g: 0, b: 255 }); // ≈ 0.0722
  assert.ok(g > r && r > b);
});

末尾の +0.05 は画面の環境光反射 (ambient flare) をモデル化したもの。これがあるおかげで、純黒 (L=0) と純白 (L=1) の比が (1+0.05)/(0+0.05) = 21 ちょうどになる。コントラスト比の上限 21:1 の正体。

test("black on white = 21", () => {
  approx(contrastRatio({r:0,g:0,b:0}, {r:255,g:255,b:255}), 21, 1e-2);
});

中間グレーで差が出る

線形化を飛ばした「なんちゃって実装」と正しい実装の差が一番出るのが中間グレー。#777777 を白背景で:

  • 正しい計算: 4.48:1 → 通常テキスト AA (4.5) をわずかに下回る
  • ガンマを戻さない素朴な計算: 比がずれて、AA を通過してしまうことがある

#777 on white が「AA ギリギリ不合格」なのは有名な事例なのでテストに入れた:

test("known pair: #777 on white ≈ 4.48", () => {
  const r = contrastRatio(parseColor("#777"), parseColor("#fff"));
  approx(r, 4.48, 0.05);
});

この 0.02 の差で AA 不合格になるかどうかが決まる。だから計算の正確さが効く。

閾値はテキストサイズで違う

WCAG の合否ラインは 1 つではない:

文脈 AA AAA
通常テキスト 4.5 7
大きいテキスト (18pt 以上 or 14pt 太字) 3 4.5
UI 部品・図形 3
export function wcagLevels(ratio) {
  return {
    normalAA: ratio >= 4.5,
    normalAAA: ratio >= 7,
    largeAA: ratio >= 3,
    largeAAA: ratio >= 4.5,
    uiAA: ratio >= 3,
  };
}

test("4.5 is the normal-AA / large-AAA boundary", () => {
  const l = wcagLevels(4.5);
  assert.ok(l.normalAA && l.largeAAA && !l.normalAAA);
});

4.5 という同じ比が「通常 AA」と「大 AAA」の両方の境界になっているのが面白い。デモでは 5 種類の判定を PASS/FAIL バッジで一覧表示する。

色のパース

#rgb / #rrggbb / rgb(r,g,b) を受ける:

export function parseColor(input) {
  let s = input.trim().toLowerCase();
  const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
  if (m) { /* rgb() 形式 */ }
  if (s.startsWith("#")) s = s.slice(1);
  if (/^[0-9a-f]{3}$/.test(s)) { /* #rgb を #rrggbb に展開 */ }
  if (/^[0-9a-f]{6}$/.test(s)) { /* #rrggbb */ }
  return null;  // 不正
}

#f80{r:255, g:136, b:0} のショートハンド展開も対応。不正な色は null を返して UI でエラー表示。

設計

contrast.js — 色パース、sRGB 線形化、相対輝度、比、WCAG 判定 (DOM-free, 32 tests)
app.js      — ライブプレビュー + 合否テーブル

contrast.js は DOM 非依存なので 32 個のテストが Node で走る。UI 側はネイティブの color picker とテキスト入力を相互同期し、背景/文字色をプレビューに即反映する。

試してみる

#777 を文字色、白を背景にすると 4.48 で「通常 AA は不合格、大きいテキストなら合格」が見える。少しずつ暗くして 4.5 を超える瞬間を探すと、アクセシブルな配色の感覚がつかめる。

まとめ

  • コントラスト比は 相対輝度から計算する。生 RGB やチャンネル平均ではない。
  • 相対輝度には sRGB ガンマの逆変換 (線形化) が必須。飛ばすと中間グレーで誤判定。
  • 重みは 0.2126 / 0.7152 / 0.0722 (緑最大)。人間の視感度を反映。
  • 比の式の +0.05 が環境光をモデル化し、上限を 21:1 にする。
  • 閾値はテキストサイズで違う (通常 AA 4.5 / 大 AA 3)。同じ 4.5 が「通常 AA」と「大 AAA」の境界。
  • #777 on white = 4.48 は「AA ギリギリ不合格」の有名事例。テストに入れて計算精度を守る。

これは SEN 合同会社の OSS ポートフォリオ #271 です。https://sen.ltd/portfolio/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?