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?

画像の明度をCanvasで解析してWCAG相対輝度を計算!どんな壁紙でも文字が読める「動的テキスト反転」の実装

0
Posted at

壁紙をユーザーが自由にカスタマイズできるダッシュボードを作っていると、必ず直面するUIの難問がある。

「背景に真っ白な雪景色の写真や、淡いパステル調のイラストを設定されたとき、文字が背景に溶けて読めなくなる」 という問題だ。

image.png

ダークテーマ前提で作られたダッシュボードの文字色は基本的に「白(#ffffffやslate-100)」だ。暗い宇宙の写真や夜景の壁紙であれば美しいグラスモフィズム(すりガラス効果)が映えるが、ユーザーが明るいビーチや白いオフィスの写真をアップロードした瞬間、視認性は完全に崩壊する。

CSSの mix-blend-mode: difference で反転させる手法も試したが、写真の木々や波打ち際で文字の色がまだらにチラつき、可読性はかえって悪化した。一律に黒い半透明の座布団(バックドロップ)を敷き詰める手法も、せっかくの美しい壁紙を常に黒く濁らせてしまうため採用したくない。

求められたのは、**「現在表示されている壁紙が明るいか暗いかをプログラムで自動判定し、明るいときだけヘッダーや検索バーの文字色をすっとダーク(#0f172a)へ切り替える」**という動的なコントラスト制御だった(Issue #24)。

自作の新しいタブダッシュボード「ZenithTab」に組み込んだ、オフスクリーンCanvasとWCAG 2.1相対輝度計算による適応型UIの実装テクニックを公開する。


ステップ1:オフスクリーンCanvasで16×16にダウンサンプリング

フルHD(1920×1080)や4Kの壁紙画像をそのままピクセル走査して輝度を計算しようとすると、数百万ピクセルのループ処理が発生し、メインスレッドが数フレーム確実に固まる。壁紙が切り替わるたびに画面がカクついては本末転倒だ。

そこで活用するのが、HTML5 Canvasによる超軽量なダウンサンプリングである。

画面に表示されないオフスクリーンCanvasを作成し、画像をわずか 16 × 16 ピクセルの極小サイズに縮小して描画する。ブラウザの描画エンジンが持つネイティブな縮小アルゴリズムを利用して、画像全体の平均的な色味をブラウザ自身にギュッと凝縮させるのだ(src/services/wallpaperLuminance.ts)。

const SAMPLE_SIZE = 16; // 16x16 = 256ピクセル。平均値を取るには十分すぎる精度

export function imageLuminance(url: string): Promise<number | null> {
  const cached = imageCache.get(url);
  if (cached) return cached;

  const task = new Promise<number | null>((resolve) => {
    const img = new Image();
    // クロスオリジン画像のピクセルを読み戻すために必要
    // (Unsplash は緩い CORS ヘッダーを返す。ローカルや data: URL には不要)
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      try {
        const canvas = document.createElement('canvas');
        canvas.width = SAMPLE_SIZE;
        canvas.height = SAMPLE_SIZE;
        const ctx = canvas.getContext('2d', { willReadFrequently: true });
        if (!ctx) { resolve(null); return; }

        // 16x16のキャンバスに画像を丸ごと縮小描画
        ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
        const { data } = ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE);

        let sum = 0;
        const pixels = data.length / 4;
        for (let i = 0; i < data.length; i += 4) {
          sum += relativeLuminance(data[i], data[i + 1], data[i + 2]);
        }
        resolve(pixels > 0 ? sum / pixels : null);
      } catch {
        // Tainted canvas(CORS 非対応のホスト)やデコード失敗: null で「暗い」既定に倒す
        resolve(null);
      }
    };
    img.onerror = () => resolve(null);
    img.src = url;
  });

  imageCache.set(url, task);
  return task;
}

走査するピクセル数はたったの256個。処理にかかる時間は1ミリ秒以下であり、体感ラグは完全にゼロになる。

見落としがちな2つの罠

このコードで地味に重要なのが crossOrigin = 'anonymous'try/catch だ。

外部ホストの画像を Canvas に描いた後で getImageData を呼ぶと、そのホストが CORS ヘッダーを返していない限り「汚染された(tainted)Canvas」として SecurityError が投げられる。Unsplash のような CDN は許可ヘッダーを返すので crossOrigin を付ければ読めるが、ユーザーが任意の URL を壁紙に指定した場合は読めないこともある。その場合は例外を握りつぶして null を返し、**判定不能なら従来どおりの白文字(ダーク扱い)**にフォールバックする。「分からないなら何もしない」が、ここでの安全側だ。

もう1つ、キャッシュに入れているのは計算結果ではなく Promise そのもの だ。同じ URL について複数のコンポーネントが同時に問い合わせても、画像のデコードと走査は1回しか走らない。


ステップ2:WCAG 2.1に準拠した「相対輝度」の厳密な計算

ピクセルのRGB値を取り出した後、やってはいけないのが「単純に (R + G + B) / 3 で平均を出す」ことだ。

人間の目は、緑色の光に対して非常に敏感であり、青色の光には鈍感という特性を持っている。さらに、ディスプレイに表示されるsRGBカラーはガンマ補正がかかっており、数値と物理的な光の強さは比例していない。

ウェブアクセシビリティ標準(W3C WCAG 2.1)で定義されている**相対輝度(Relative Luminance)**の公式に忠実に従って計算する。

// sRGBのガンマ補正を解き、リニアな光の強さに変換する
function srgbToLinear(channel: number): number {
  const c = channel / 255;
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

// WCAG 2.1 相対輝度計算式 (0 = 漆黒, 1 = 純白)
export function relativeLuminance(r: number, g: number, b: number): number {
  // 人間の目の視感度特性に合わせた重み付け
  return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
}

この公式を通すことで、「見た目として人間がどれくらい眩しく感じているか」を表す真の輝度値(0.0〜1.0)が得られる。


ステップ3:CSSグラデーション壁紙への対応

ZenithTabでは、写真だけでなく「CSSグラデーション」も壁紙として選択できる。

グラデーションは画像ファイルではないため、Canvasに描画する代わりにCSS文字列を正規表現で構文解析する。

const HEX_COLOR = /#([0-9a-f]{3}|[0-9a-f]{6})\b/gi;
const RGB_COLOR = /rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})/gi;

export function parseCssColors(css: string): [number, number, number][] {
  const colors: [number, number, number][] = [];

  // #fff や #1e293b などのHEXカラーを抽出
  for (const match of css.matchAll(HEX_COLOR)) {
    const hex = match[1].length === 3
      ? match[1].split('').map((c) => c + c).join('')
      : match[1];
    colors.push([
      parseInt(hex.slice(0, 2), 16),
      parseInt(hex.slice(2, 4), 16),
      parseInt(hex.slice(4, 6), 16),
    ]);
  }

  // rgb(255, 255, 255) などのRGBカラーを抽出
  for (const match of css.matchAll(RGB_COLOR)) {
    colors.push([Number(match[1]), Number(match[2]), Number(match[3])]);
  }

  return colors;
}

export function gradientLuminance(css: string): number | null {
  const colors = parseCssColors(css);
  if (colors.length === 0) return null;
  return colors.reduce((sum, [r, g, b]) => sum + relativeLuminance(r, g, b), 0) / colors.length;
}

グラデーションの各カラーストップから色を抽出し、その平均輝度を算出することで、写真壁紙とまったく同じスケールで明暗を判定できるようにした。


ステップ4:オーバーレイを考慮した「実効輝度」としきい値の決定

ここで最後の難関が現れる。

実際のUIでは、壁紙画像の上にCSSのフィルター(brightness())や、暗い半透明オーバーレイ(bg-slate-950#020617 をアルファ合成したもの)が薄く重なっている。ユーザーの目に最終的に届く「実効輝度」は、生の写真の輝度よりも暗くなるのだ。

このアルファブレンドを考慮して実効輝度を補正する。

// オーバーレイ色 bg-slate-950 (#020617) の相対輝度
const OVERLAY_LUMINANCE = 0.0027;

export function effectiveLuminance(base: number, brightness: number, overlayOpacity: number): number {
  // brightness() フィルターはチャンネル値をスケールする(輝度のスケールで近似)
  const lit = Math.min(1, Math.max(0, base * Math.max(0, brightness)));
  const alpha = Math.min(1, Math.max(0, overlayOpacity));
  // 通常のアルファ合成
  return lit * (1 - alpha) + OVERLAY_LUMINANCE * alpha;
}

グラデーション壁紙には brightness() フィルターを掛けていないので、そのときは brightness = 1 として渡す。「判定に使う値」と「実際に描画している値」を一致させることが、判定の信頼性そのものだ。

文字色を反転させる「しきい値(Threshold)」の導出

白文字(L = 1.0)と、濃いスレート色(#0f172a, L ≈ 0.01)のWCAGコントラスト比がちょうど等しくなる背景輝度は、数学的にはおよそ L ≈ 0.20 付近だ。

しかし、写真には木漏れ日や雲などの「部分的に明るいノイズ」が含まれる。境界線ギリギリの0.20で切り替えると、「だいたい暗いが所々に明るい部分がある写真」で黒文字側に倒れやすくなる。

そこで、少し余裕(ヘッドルーム)を持たせて 0.25 を境界線として設定した。

export const LIGHT_BACKDROP_THRESHOLD = 0.25;

/** 判定不能 (null) は暗い=従来どおりの見た目 */
export function classifyBackdrop(luminance: number | null, threshold = LIGHT_BACKDROP_THRESHOLD): BackdropTone {
  return luminance !== null && luminance >= threshold ? 'light' : 'dark';
}

実効輝度が0.25以上であれば「明るい背景(light)」、0.25未満であれば「暗い背景(dark)」と判定する。


適用は React ではなく CSS 変数で

判定ロジックは resolveBackdropTone() に集約され、壁紙を描画している WallpaperBackground コンポーネントが壁紙の切り替わりごとに呼び出す。ここで採った設計判断が、結果を各コンポーネントに props やフックで配るのではなく、<html> 要素の data-backdrop 属性1つに書くというものだ。

// WallpaperBackground.tsx
useEffect(() => {
  const root = document.documentElement;
  if (!adaptiveText) {           // 設定でオフにしていれば属性を外すだけ
    delete root.dataset.backdrop;
    return;
  }
  let cancelled = false;
  void resolveBackdropTone({ url, isGradient, brightness, overlayOpacity }).then((tone) => {
    if (!cancelled) root.dataset.backdrop = tone;
  });
  return () => { cancelled = true; };
}, [adaptiveText, target]);

受け取る側は CSS 変数だ(src/index.css)。

:root {
  /* 壁紙の上に直接乗る文字の色。既定は「暗い壁紙」用 */
  --on-wallpaper: 255 255 255;        /* white */
  --on-wallpaper-muted: 203 213 225;  /* slate-300 */
  --on-wallpaper-faint: 148 163 184;  /* slate-400 */
  --on-wallpaper-accent: 186 230 253; /* sky-200 */
  --glass-alpha: 0.4;
}

:root[data-backdrop="light"] {
  --on-wallpaper: 15 23 42;           /* slate-900 */
  --on-wallpaper-muted: 51 65 85;     /* slate-700 */
  --on-wallpaper-faint: 71 85 105;    /* slate-600 */
  --on-wallpaper-accent: 7 89 133;    /* sky-800 */
  --glass-alpha: 0.7;                 /* ガラスの座布団も少し濃くする */
}

.text-on-wallpaper { color: rgb(var(--on-wallpaper)); }
.bg-glass { background-color: rgb(15 23 42 / var(--glass-alpha)); }

ヘッダー、ページ切り替えタブ、検索バーのピルなど「壁紙の上に直接乗る要素」は text-on-wallpaper 系のクラスを使うだけでよく、判定結果を知る必要すらない。React ツリーの外に描画されるポータルメニューにも同じ変数が届く。transition-colors を添えておけば、壁紙が切り替わったときに文字色が滑らかに白から濃紺へ反転する。

image.png

判定結果はタブのメモリ内で URL をキーにキャッシュされるため、同じタブ内で壁紙設定を弄り直しても再計算は走らない。新しいタブを開けば改めて1回だけ計算するが、16×16 の走査なので実質ゼロコストだ。この機能は設定画面の「壁紙の明るさに合わせて文字色を調整」チェックボックスでオフにもできる。


細部に宿るアクセシビリティの思想

ユーザーが選ぶ壁紙は千差万別だ。どんな写真が設定されても「読める」状態を維持することは、単なるデザインのこだわりを超えて、ツールの根幹たるアクセシビリティの責務である。

CSSのフィルターやハードコードされた文字色に頼るのではなく、描画されている光の強さを数学的・客観的に測定して適応させる。判定できないときは「何もしない」に倒す。そして結果は CSS 変数として1か所に書き、UI 側は判定の存在を意識しない。

「どんな環境でも道具としての機能美を損なわない」。その体験は、こうした目立たないピクセル処理とアクセシビリティ標準の積み重ねによって作られている。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

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?