Reactで、画像の明るさの分布をヒストグラムで表示する方法

​​React​Canvas API,Playwright,SVG,TypeScript

画像の明るさの分布(ヒストグラム)は、画像を canvas に描いて getImageData() で画素の値を読み、明るさ 0〜255 ごとに画素の数を数えれば作れます。数えた結果は、256本の棒を並べた SVG にします。この記事のコードでは、透明な画素を数えないこと、大きな画像は縮めてから数えること、縦軸を対数に切り替えられるようにすることの3つを入れています。

補足

検証環境は 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 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright で画像を選んで表示を確かめています。試す画像は、SVG で描いた絵を Chromium で JPEG・PNG にしたもので、カメラで撮った写真ではありません。時間は headless の Chromium で測った値です。Firefox、Safari、スマートフォンのブラウザ、写真の向き(Exif の回転の情報)、sRGB より広い色域の画像、Web Worker で数える方法は試していません。

ファイルを選ぶボタン、選んだ画像、明るさの棒グラフ、「縦軸を対数にする」のチェックボックス、平均と真っ黒・真っ白の割合が縦に並んだ画面。夜の絵を選ぶと棒の山が左の暗い側に移り、白い背景の絵を選ぶと右端の 255 の棒が高くなり、ほとんどの棒は見えなくなる。チェックボックスにチェックを入れると、低かった棒も見えるようになる。画像を選んだときはグラフの枠と下の数字が、チェックを入れたときはグラフの枠だけが、変わった瞬間にピンクになる
「夜の絵を選ぶ」「白い背景の絵を選ぶ」「縦軸を対数にする」の3つの操作。変わったグラフの枠や数字が、その瞬間だけピンクになる

画素を読んで、明るさごとに数える関数

数える処理は、React から切り離して関数にしておきます。

ts
export type Histogram = {
  bins: Uint32Array; // 明るさ 0〜255 ごとの画素の数
  count: number; // 数えた画素の数(透明な画素は除く)
  mean: number; // 明るさの平均
};

// 赤・緑・青の値に、目の感じ方に合わせた重みをかけて足す(0〜255)
export function luma(r: number, g: number, b: number): number {
  return Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
}

export function countBrightness(data: Uint8ClampedArray): Histogram {
  const bins = new Uint32Array(256);
  let count = 0;
  let sum = 0;
  for (let i = 0; i < data.length; i += 4) {
    if (data[i + 3] === 0) continue; // 透明な画素は数えない
    const y = luma(data[i], data[i + 1], data[i + 2]);
    bins[y]++;
    sum += y;
    count++;
  }
  return { bins, count, mean: count ? sum / count : 0 };
}

// 長い辺が maxSide 以下になるように縮めて描き、画素の値を読む
export async function readPixels(file: Blob, maxSide = 512): Promise<ImageData> {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
  const width = Math.max(1, Math.round(bitmap.width * scale));
  const height = Math.max(1, Math.round(bitmap.height * scale));
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d')!;
  ctx.drawImage(bitmap, 0, 0, width, height);
  bitmap.close();
  return ctx.getImageData(0, 0, width, height);
}

getImageData() が返す ImageData の data には、1画素につき赤・緑・青・アルファ(不透明度)の4つの値が、0〜255 の整数で順に並んでいます(MDN のチュートリアル「Pixel manipulation with canvas」)。countBrightness() は、この配列を4つずつ進みながら明るさを計算し、256個の箱(bins)のどれかに1を足します。

画像は createImageBitmap() で読み込み、drawImage() で canvas に描いたあとは close() で手放します。readPixels() が縮めて描く理由は、あとの節で測った結果とあわせて書きます。

明るさは、緑を重く、青を軽くして計算する

赤・緑・青の3つの値を単純に平均すると、色によって見た目の明るさが違うことを表せません。検証環境で luma() と単純な平均を比べたところ、次のようになりました。

色 luma() 3つの値の平均
赤 rgb(255,0,0) 54 85
緑 rgb(0,255,0) 182 85
青 rgb(0,0,255) 18 85
灰色 rgb(128,128,128) 128 128

単純な平均では、赤も緑も青も同じ 85 になります。luma() では、緑がいちばん明るく、青がいちばん暗くなります。灰色のように3つの値がそろっているときは、どちらも同じ値です。

重みの 0.2126、0.7152、0.0722 は、WCAG の相対輝度(relative luminance)の定義で、sRGB の色に使われている係数です。ただし WCAG では、0〜255 の値をいったん光の強さに比例する値に変換してから重みをかけます。この記事の luma() はその変換をせず、0〜255 の値にそのまま重みをかけています。こうすると、灰色の画素は、その値と同じ位置(rgb(128,128,128) なら 128)の棒に入ります。

透明な画素は、数えずに飛ばす

背景が透明な PNG を読むと、透明な画素は、どれも rgba(0,0,0,0) になっていました。赤・緑・青がどれも 0 なので、そのまま数えると明るさ 0(真っ黒)の画素として数えられます。

検証環境で、背景が透明な 800×600 の PNG(猫の形とボールだけを描いたもの)を元の大きさのまま読みました。480,000 画素のうち、透明な画素は 362,957(75.6%)で、そのすべてが赤・緑・青とも 0 でした。透明な画素も数えると、明るさ 0 の棒に 362,990 画素(75.6%)が入ります。猫の色の明るさ 72 の棒は 103,621 画素です。縦軸は 0 の棒に合わせて決まるので、猫の棒は 0 の棒の3割ほどの高さになります。絵の中に真っ黒な部分はほとんどないのに、真っ黒(0)の棒がいちばん高く出ます。

countBrightness() のように、アルファが 0 の画素を飛ばすと、数えた画素は 117,043 になり、そのうち明るさ 0 は 33 画素だけでした。平均は 82.3 でした。割合を出すときも、全体の画素の数ではなく、数えた画素の数(count)で割ります。

縮めてから数えると速くなり、平均はほとんど変わらない

画素を1つずつ数えるので、画像が大きいほど時間がかかります。そこで readPixels() では、長い辺が 512px 以下になるように縮めてから読んでいます。

検証環境で、4000×3000(1,200万画素)の JPEG で、縮める大きさを変えながら readPixels() と countBrightness() を7回ずつ続けて動かしました。画像の読み込みを始めてから数え終わるまでの時間の中央値を測っています。

長い辺 数えた画素 時間 明るさの平均 元の大きさとの違い
元のまま(4000) 12,000,000 259.1ms 195.4 —
1024 786,432 76.7ms 195.1 4.8%
512 196,608 59.7ms 195.1 4.8%
256 49,152 56.8ms 195.1 4.7%
128 12,288 56.0ms 195.1 5.9%

「元の大きさとの違い」は、明るさごとの割合を元の大きさのときと比べ、差の絶対値を256本ぶん足して2で割ったものです。0% なら2つの分布がまったく同じで、4.8% なら、2つの分布で重ならない部分が全体の 4.8% あるという意味です。割合で 4.8% ぶんを別の棒へ移すと、元の分布と同じになります。平均は 195.4 と 195.1 で、ほとんど変わりませんでした。

512 より小さくしても、時間はあまり縮みませんでした。同じ画像を createImageBitmap() で読み込むだけの時間を別に測ると、56.9ms でした。この記事のコンポーネントでは、512 を既定の値にしています。

1200×800 の JPEG では、元のままで 16.5ms、長い辺 512 で 9.0ms でした。この画像では、長い辺 512 のときの元の大きさとの違いが 7.7% で、4000×3000 の画像より大きくなりました。いちばん多い明るさも、元のままでは 198、縮めると 172 に変わりました。平均は 195.9 と 195.7 です。縮めても、分布の形がいつもそのまま残るわけではありません。

棒グラフを SVG で描くコンポーネント

tsx
import { useEffect, useId, useState } from 'react';
import { countBrightness, readPixels, type Histogram } from './histogram';

const W = 256; // 横は明るさ 0〜255 に1本ずつ
const H = 100;

function barsPath(bins: Uint32Array, log: boolean): string {
  const f = log ? Math.log1p : (v: number) => v;
  const max = Math.max(...Array.from(bins, f), 1);
  let d = '';
  bins.forEach((v, i) => {
    const h = (f(v) / max) * H;
    if (h > 0) d += `M${i} ${H}h1v${-h}h-1z`;
  });
  return d;
}

const percent = (n: number, total: number) => `${((n / total) * 100).toFixed(1)}%`;

export function ImageHistogram() {
  const [file, setFile] = useState<File | null>(null);
  const [url, setUrl] = useState('');
  const [hist, setHist] = useState<Histogram | null>(null);
  const [log, setLog] = useState(false);
  const id = useId();

  useEffect(() => {
    if (!file) return;
    const objectUrl = URL.createObjectURL(file);
    setUrl(objectUrl);
    let ignore = false; // 読み終わる前に別の画像を選んだら、古い結果は捨てる
    readPixels(file).then((img) => {
      if (!ignore) setHist(countBrightness(img.data));
    });
    return () => {
      ignore = true;
      URL.revokeObjectURL(objectUrl);
    };
  }, [file]);

  return (
    <div className="histogram">
      <p>
        <label htmlFor={`${id}-file`}>画像</label>
        <input id={`${id}-file`} type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
      </p>
      {url && <img src={url} alt="選んだ画像" />}
      {hist && (
        <>
          <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img"
            aria-label={`明るさの分布。平均 ${Math.round(hist.mean)}`}>
            <path d={barsPath(hist.bins, log)} fill="#00c8e8" />
          </svg>
          <p className="axis"><span>0(黒)</span><span>255(白)</span></p>
          <p>
            <label>
              <input type="checkbox" checked={log} onChange={(e) => setLog(e.target.checked)} />
              縦軸を対数にする
            </label>
          </p>
          <p className="stats">
            平均 {Math.round(hist.mean)}/真っ黒(0){percent(hist.bins[0], hist.count)}/真っ白(255){percent(hist.bins[255], hist.count)}
          </p>
        </>
      )}
    </div>
  );
}

state に持つのは、選んだファイル、画面に出す画像の URL、数えた結果、縦軸を対数にするかどうかです。

グラフの SVG は、viewBox を横 256、縦 100 にしています。明るさ 0〜255 の棒が、横に1ずつの幅で並びます。barsPath() は、256本の棒を1つの path の d にまとめた文字列を返します。<rect> を256個並べるのではなく、path を1つだけ描きます。preserveAspectRatio="none" を付けているので、CSS で幅を 100%、高さを 120px にすると、縦と横が別々に伸びて枠いっぱいに描かれます。

画像を選ぶと、useEffect の中で URL.createObjectURL() で <img> に渡す URL を作り、readPixels() で数え始めます。クリーンアップでは、URL.revokeObjectURL() で前の URL を解放します。検証環境で、画像を選び直したあとに前の URL を fetch() で読もうとすると、読めませんでした。

読み込みは非同期なので、読み終わる前に別の画像を選ぶことがあります。そのため、React の公式ドキュメントにある、Effect でデータを取得するときの書き方と同じように、クリーンアップで ignore を true にし、古い画像の結果を state に入れないようにしています。検証環境で 4000×3000 の画像を選んですぐに夜の絵を選ぶと、表示されたのは夜の絵の結果(平均 59)でした。

検証環境で、昼の絵、夜の絵、白い背景の絵を選ぶと、平均はそれぞれ 196、59、213 になりました。白い背景の絵では、真っ白(255)が 73.4% でした。

1本だけ高い棒があるときは、縦軸を対数にする

白い背景の絵のように、同じ明るさの画素がとても多い画像では、その1本に合わせて縦軸が決まり、ほかの棒がほとんど見えなくなります。

上下2段に並べた棒グラフ。上の「縦軸そのまま」では、左寄りに細い棒が1本、右寄りにごく低い棒が1本、右端に高い棒が1本見えるだけで、ほかはほぼ空白。下の「縦軸を対数にした」では、左寄りと右寄りに山があり、そのあいだにも低い棒が途切れずに並び、右端の棒がいちばん高い
白い背景の絵(800×600 の JPEG を長い辺 512 に縮めて数えたもの)の明るさの分布を、縦軸そのままと対数で描いた2通り

検証環境で、白い背景に猫とボールを描いた 800×600 の JPEG を、長い辺 512 に縮めて数えたところ、いちばん多いのは 255(白い背景)の 144,229 画素、次が 72(猫の色)の 40,308 画素、その次が 177(ボールの色)の 3,772 画素でした。画素のある明るさは 193 通りありましたが、高さ 100 のグラフで高さが 0.5 以上になる棒は 6 本だけでした。

barsPath() の log に true を渡すと、棒の高さを Math.log1p()(1 を足してから自然対数を取る関数)で計算します。画素が 0 のときは高さも 0 になり、1以上なら少しでも高さが出ます。同じ画像で縦軸を対数にすると、高さが 0.5 以上の棒は 193 本になり、画素のある明るさがすべて見えるようになりました。

ただし対数にすると、144,229 画素の棒と 3,772 画素の棒の高さの差が、見た目では小さくなります。どの明るさがどれだけ多いかは、縦軸そのままのグラフで確かめます。真っ黒(0)と真っ白(255)の割合は、グラフの下の数字にも出しています。

​Posted by 86