Reactで、画像から代表的な色を取り出す方法

​​React​Canvas API,Playwright,TypeScript,カスタムフック

画像の代表的な色は、ブラウザだけで取り出せます。画像を小さな <canvas> に描き、getImageData() でピクセルの色を読み、似た色ごとに数えて、いちばん多かった色を代表色にします。取り出した色は、画像を載せるカードの背景などに使えます。

色を数える関数と、それを呼ぶカスタムフック useImageColors を作り、代表色をカードの背景にします。平均色を代表色にしない理由、色をまとめる段階の数による結果の違い、別のオリジンの画像で起きるエラーも、検証の結果で示します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。画像は ImageMagick で作った検証用のもの(2色に塗り分けた PNG、白い背景の PNG、背景が透明な PNG、色がなめらかに変わる模様の JPEG)で、写真は使っていません。Firefox と Safari、スマートフォンのブラウザ、sRGB 以外の色空間を持つ画像は試していません。

「白い背景」「透明な背景」「模様」「2色」のボタンを順に押すと、カードの背景が画像の代表色に変わり、その下に多い順の色と割合が並ぶ。透明な背景のピンクの円は、背景も同じピンクになるため見えなくなる
サンプル画像のボタンを4回押したときの表示の変化。カーソル、押した場所の輪、右上の回数、表示が変わった箇所を囲むピンクの枠は GIF 用に重ねたもの

平均色を代表色にしない理由

ピクセルの色を足して割る平均色は、計算は簡単ですが、画像のどこにもない色になることがあります。左の6割を水色(#00c8e8)、右の4割をピンク(#d85878)に塗った画像で試したところ、平均色は #569bbb というくすんだ青になりました。代表色として期待するのは、面積の大きい水色のほうです。

そこで、似た色を同じ箱に入れて数え、いちばん多い箱の色を代表色にします。同じ画像では、代表色は #00c8e8(59%)、2番目は #d85878(39%)になりました。残りの2%は #7989a9 です。幅600pxの画像を64pxに縮めると、左から360pxの境目は38.4px目にあたり、1つのピクセルの中に入ります。縮めた画像の上から10行目で境目の前後を読むと、x=38 の1ピクセルだけが #7989a9 で、両側は元の水色とピンクでした。

画像を縮めて描き、色を箱に分けて数える

色を数える処理は、React に依存しない関数にまとめました。getImageColors() は読み込み済みの <img> を受け取り、代表色(dominant)、平均色(average)、多い順の色(palette)を返します。

ts
export type RGB = { r: number; g: number; b: number };
export type Swatch = { color: RGB; ratio: number };
export type ImageColors = { dominant: RGB; average: RGB; palette: Swatch[] };

type Options = {
  size?: number; // 縮めたときの長い辺(px)
  levels?: number; // R・G・B をそれぞれ何段階にまとめて数えるか
  count?: number; // palette に返す色の数
};

// 画像を長い辺が size px 以下になるように縮めて描き、色を数える
export function getImageColors(
  img: HTMLImageElement,
  { size = 64, levels = 4, count = 5 }: Options = {},
): ImageColors {
  const scale = Math.min(1, size / Math.max(img.naturalWidth, img.naturalHeight));
  const w = Math.max(1, Math.round(img.naturalWidth * scale));
  const h = Math.max(1, Math.round(img.naturalHeight * scale));
  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d')!;
  ctx.drawImage(img, 0, 0, w, h);
  // 別のオリジンの画像を CORS なしで描いたあとは、ここで SecurityError になる
  const { data } = ctx.getImageData(0, 0, w, h);

  // R・G・B をそれぞれ levels 段階にまとめ、levels³ 個の箱で数える。箱ごとに元の色の合計も持つ
  const step = (v: number) => Math.floor((v * levels) / 256); // 0〜255 → 0〜levels-1
  const bins = new Map<number, { n: number; r: number; g: number; b: number }>();
  let n = 0, r = 0, g = 0, b = 0;
  for (let i = 0; i < data.length; i += 4) {
    if (data[i + 3] < 128) continue; // 透明に近いピクセルは数えない
    const pr = data[i], pg = data[i + 1], pb = data[i + 2];
    const key = (step(pr) * levels + step(pg)) * levels + step(pb);
    const bin = bins.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
    bin.n++; bin.r += pr; bin.g += pg; bin.b += pb;
    bins.set(key, bin);
    n++; r += pr; g += pg; b += pb;
  }
  if (n === 0) throw new Error('色のあるピクセルがありません(全体が透明です)');

  const mean = (s: { n: number; r: number; g: number; b: number }): RGB => ({
    r: Math.round(s.r / s.n), g: Math.round(s.g / s.n), b: Math.round(s.b / s.n),
  });
  const palette = [...bins.values()]
    .sort((x, y) => y.n - x.n)
    .slice(0, count)
    .map((bin) => ({ color: mean(bin), ratio: bin.n / n }));
  return { dominant: palette[0].color, average: mean({ n, r, g, b }), palette };
}

drawImage() に描く先の幅と高さを渡すと、画像は縮んで描かれます。getImageData() が返す data は、1ピクセルにつき R・G・B・A の4つの数が並んだ配列なので、4つずつ進めて読みます。

箱の色には、箱の範囲の中央の色ではなく、箱に入ったピクセルの元の色の平均を使います。こうすると、水色だけの部分からは元の #00c8e8 がそのまま返ります。

透明に近いピクセルを飛ばしているのは、背景が透明な PNG のためです。ピンクの円だけを描いた 400×400 の PNG を 64×64 に縮めて読むと、アルファが128未満のピクセルが2,832個(69%)あり、そのうち2,739個は RGBA がすべて0でした。アルファの確認を外して数えると、いちばん多いのは #000000(67%)で、透明な部分が黒として数えられました。アルファを確認する getImageColors() では、代表色は #d85878(100%)です。

色をまとめる段階は、粗いほうが結果が安定する

levels は、R・G・B をそれぞれ何段階にまとめるかを決める値です。細かくすると似た色が別々の箱に分かれ、どの箱にも少しのピクセルしか入らなくなります。色がなめらかに変わる模様の画像(800×533)で、levels を変えて数えた結果が次の図です。

模様の画像を、各色16段階・8段階・4段階で数えた多い順の5色。16段階ではどれも1%、8段階では4〜7%、4段階ではオレンジの #e19c61 が22%で最も多い
同じ画像を、まとめる段階の数を変えて数えた結果(getImageColors の出力をそのまま並べたもの)

16段階(箱4,096個)では、上位5色がどれも1%で、どの色が1位になるかは偶然に近くなります。同じ色の範囲・同じ乱数の種で作った 4000×3000 の画像では、16段階のとき、縮める大きさによって1位がオレンジ(#e99758)になったり紫(#783777)になったりしました。4段階(箱64個)では、800×533 と 4000×3000 のどちらの画像でも、長い辺を32px・64px・256px のどれにしても、1位は #e19c61 前後のオレンジで、割合も21〜22% でした。

縮める大きさは、処理時間に大きく影響します。4000×3000 の画像で getImageColors() を5回ずつ呼んだ中央値は、次のとおりでした(検証環境の Chromium での値)。

長い辺 処理時間 代表色
64px(既定値) 0.3 ms #e19c61(21%)
256px 2.4 ms #e29c61(21%)
1024px 75.5 ms #e29c61(21%)
4000px(縮めない) 725.2 ms #e39d61(20%)

縮めなくても代表色はほとんど変わらず、時間だけが増えました。代表色を1つ選ぶだけなら、既定の64px で足ります。

画像の読み込みから色を数えるまでをフックにする

React のコンポーネントからは、画像の URL を渡すだけで結果が返るようにします。useImageColors() は、useEffect の中で Image を作って読み込み、decode() で描ける状態になるのを待ってから、getImageColors() を呼びます。

ts
import { useEffect, useState } from 'react';
import { getImageColors, type ImageColors } from './imageColors';

type Result = { src: string; colors: ImageColors } | { src: string; error: string };
export type ImageColorsState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'done'; colors: ImageColors }
  | { status: 'error'; error: string };

export function useImageColors(src: string | null): ImageColorsState {
  const [result, setResult] = useState<Result | null>(null);

  useEffect(() => {
    if (!src) return;
    let ignore = false;
    const img = new Image();
    img.crossOrigin = 'anonymous'; // 別のオリジンの画像は CORS で読み込む
    img.src = src;
    img
      .decode()
      .then(() => {
        if (ignore) return;
        try {
          setResult({ src, colors: getImageColors(img) });
        } catch (e) {
          setResult({ src, error: e instanceof Error ? `${e.name}: ${e.message}` : String(e) });
        }
      })
      .catch((e: unknown) => {
        if (!ignore) setResult({ src, error: `画像を読み込めません(${e instanceof Error ? e.name : e})` });
      });
    return () => {
      ignore = true; // 結果が届く前に src が変わったら、古い結果は捨てる
    };
  }, [src]);

  if (!src) return { status: 'idle' };
  if (result?.src !== src) return { status: 'loading' };
  return 'colors' in result ? { status: 'done', colors: result.colors } : { status: 'error', error: result.error };
}

結果は、どの src に対する結果かと一緒に state に入れます。いまの src の結果がまだなければ「読み込み中」とみなすので、src が変わったときに state を空に戻す処理は要りません。

ignore は、公式ドキュメントにある、Effect でデータを取得するときの書き方と同じ考え方です。画像の応答を0.8秒遅らせ、結果が届く前に別の画像を選ぶ操作で確かめました。ignore があるときは、あとで選んだ画像の結果が表示されたままでした。ignore の確認を外した版では、あとで選んだ画像の結果がいったん表示されたあと、先に選んだ画像の結果が遅れて届いて state を上書きしました。その結果は src が違うので表示には使われず、画面は「色を調べています…」のまま止まりました。

代表色をカードの背景にして、文字の色を選ぶ

フックを使うコンポーネントでは、代表色をカードの背景にし、その上の文字を黒か白のうち読みやすいほうにします。どちらが読みやすいかは、WCAG の相対輝度からコントラスト比を計算して比べ、比の大きいほうを選びます。

ts
export const toHex = ({ r, g, b }: RGB) =>
  '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');

// WCAG の相対輝度とコントラスト比で、背景の上で読みやすい文字の色(黒か白)を選ぶ
function luminance({ r, g, b }: RGB) {
  const lin = (v: number) => {
    const c = v / 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
export function readableTextColor(bg: RGB) {
  const l = luminance(bg);
  const withBlack = (l + 0.05) / 0.05;
  const withWhite = 1.05 / (l + 0.05);
  return withBlack >= withWhite ? '#000000' : '#ffffff';
}

検証環境で計算したところ、ピンクの #d85878 は黒とのコントラスト比が 5.59、白とは 3.76 で、黒が選ばれました。濃い紫の #6c2f70 は黒と 2.28、白と 9.22 で、白が選ばれました。

コンポーネントでは、サンプル画像のボタンのほかに、手元の画像ファイルも選べるようにしています。ファイルは URL.createObjectURL() で blob URL にしてフックに渡し、別の画像に替わったら URL.revokeObjectURL() で解放します。

tsx
import { useEffect, useState } from 'react';
import { readableTextColor, toHex } from './imageColors';
import { useImageColors } from './useImageColors';

export function ColorCard({ samples }: { samples: { label: string; src: string }[] }) {
  const [src, setSrc] = useState<string | null>(samples[0]?.src ?? null);
  const [fileUrl, setFileUrl] = useState<string | null>(null);
  const state = useImageColors(src);

  // 選んだファイルの blob URL は、別の画像に替わったら解放する
  useEffect(() => {
    if (!fileUrl) return;
    return () => URL.revokeObjectURL(fileUrl);
  }, [fileUrl]);

  const choose = (next: string, isFile = false) => {
    setFileUrl(isFile ? next : null);
    setSrc(next);
  };

  const bg = state.status === 'done' ? state.colors.dominant : null;
  return (
    <div className="color-card">
      <div className="pickers">
        {samples.map((s) => (
          <button key={s.src} type="button" aria-pressed={src === s.src} onClick={() => choose(s.src)}>
            {s.label}
          </button>
        ))}
        <input
          type="file"
          accept="image/*"
          aria-label="画像ファイルを選ぶ"
          onChange={(e) => {
            const file = e.target.files?.[0];
            if (file) choose(URL.createObjectURL(file), true);
          }}
        />
      </div>

      <figure
        className="card"
        style={bg ? { background: toHex(bg), color: readableTextColor(bg) } : undefined}
      >
        {src && <img src={src} alt="選んだ画像" crossOrigin="anonymous" />}
        <figcaption role="status">
          {state.status === 'loading' && '色を調べています…'}
          {state.status === 'error' && state.error}
          {state.status === 'done' && `代表色 ${toHex(state.colors.dominant)}(平均色 ${toHex(state.colors.average)})`}
        </figcaption>
      </figure>

      {state.status === 'done' && (
        <ul className="palette" aria-label="多い順の色">
          {state.colors.palette.map(({ color, ratio }) => (
            <li key={toHex(color)}>
              <span className="chip" style={{ background: toHex(color) }} />
              {toHex(color)} {Math.round(ratio * 100)}%
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

ファイルを選んでから別のサンプルに戻す操作で、選んだファイルの blob URL が1回解放されたことを確かめました。StrictMode を付けた開発用ビルドでもサンプルを順に選び、React の警告やエラーが出ないことを確かめています。

いちばん多い色が、いつも背景に向いているとは限りません。白い背景の中央に水色の四角を置いた画像では、代表色は白(#ffffff、75%)で、水色の #04c9e8 は23%で2番目でした。ピンクの円だけの PNG では、背景も円と同じピンクになり、円が見えなくなりました(上の GIF の2回目の操作)。写っている物の色を使いたいときは、白に近い色を飛ばして palette の次の色を使う、背景の色を少し明るくする、といった調整が考えられます(この記事では試していません)。

別のオリジンの画像は、CORS で許可されていないと読めない

getImageData() は、別のオリジンから CORS の許可なしに読み込んだ画像を描いた canvas では SecurityError になります。MDN の別のオリジンの画像を canvas で使う方法の解説では、このような canvas を「tainted(汚染された)」と呼んでいます。

ページとは別のポートで画像を配信するサーバーを立て、Access-Control-Allow-Origin を付けるかどうかと、crossOrigin を付けるかどうかを変えて試した結果が次の表です。

サーバーの応答 crossOrigin 結果
Access-Control-Allow-Origin: * あり 'anonymous' 代表色 #00c8e8 が取れた
なし 'anonymous' decode() が EncodingError で失敗(画像そのものを読み込めない)
なし 付けない 画像は読み込めたが、getImageData() で SecurityError

crossOrigin を付けないと、画像は表示できても色は読めません。付けると CORS の要求になるので、サーバーが許可していなければ読み込みの段階で失敗します。useImageColors() で crossOrigin を付けているのは、許可しているサーバーの画像を読めるようにするためです。ユーザーが選んだファイルを blob URL にして渡した場合は、検証では、この制限を受けずに色を取り出せました。

別のサーバーの画像の色を取りたいときは、そのサーバーが Access-Control-Allow-Origin を返すように設定するか、画像を自分のサイトと同じオリジンから配信します。

​Posted by 86