Reactで、画像の明るさとコントラストを調整して保存する方法

​​React​Canvas API,CSS,Playwright,TypeScript

画像の明るさとコントラストをスライダーで変えるだけなら、<img> に CSS の filter: brightness(130%) contrast(100%) を付ければ、画面の見た目はすぐに変わります。ただ、CSS の filter は、画面に描くときの見た目を変えるだけなので、画像ファイルは元のままです。保存するときは、canvas の ctx.filter に同じ文字列を指定してから画像を描き、その canvas を PNG にします。

ここでは、明るさとコントラストを 0〜200% のスライダーで調整し、元の大きさのまま PNG で保存できるコンポーネント App を作りました。保存した PNG の画素の値を、仕様に書かれた式で計算した値と比べた結果もあわせて載せます。

補足

検証環境は 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 です。Playwright で Chromium を動かして画像を選び、スライダーの値を変えて保存した PNG を、Python(Pillow 10.2.0)で計算した値と1画素ずつ比べました。画像は写真ではなく、Pillow で描いた 600×400 の不透明な画像です。Firefox と Safari、スマートフォンのブラウザ、透明な部分を含む画像、写真のような大きな画像での処理の速さは試していません。

画面は CSS の filter、保存は canvas の ctx.filter で同じ文字列を使う

明るさとコントラストは、CSS の brightness() と contrast() で変えられます。canvas の ctx.filter も CSS の filter と同じ値を受け付けるので、文字列を作る関数を1つ用意して、画面と保存の両方で使います。

ts
// 明るさとコントラスト(どちらも % で、100 が元のまま)
export type Adjust = { brightness: number; contrast: number };

export const initialAdjust: Adjust = { brightness: 100, contrast: 100 };

// CSS の filter と canvas の ctx.filter の両方に使える文字列にする。左から順にかかる
export function toFilter({ brightness, contrast }: Adjust): string {
  return `brightness(${brightness}%) contrast(${contrast}%)`;
}

// 元の画像と同じ大きさの canvas に、フィルターをかけて描き、PNG の Blob にする
export function exportAdjusted(img: HTMLImageElement, adjust: Adjust): Promise<Blob> {
  const canvas = document.createElement('canvas');
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;
  const ctx = canvas.getContext('2d');
  if (!ctx) return Promise.reject(new Error('canvas が使えません'));
  ctx.filter = toFilter(adjust); // drawImage より前に指定する
  ctx.drawImage(img, 0, 0);
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('PNG にできません'))), 'image/png');
  });
}

exportAdjusted は、画面に出さない canvas を画像の元の大きさ(naturalWidth と naturalHeight)で作ります。画面で縮んで表示されていても、保存する画像は縮みません。検証では、幅 420px の画面で画像が 387.98×258.66px に縮んで表示されている状態で保存し、600×400 の PNG ができました。

ctx.filter は、drawImage() より前に指定します。検証では、drawImage() で描いたあとに ctx.filter = 'brightness(130%) contrast(100%)' を指定した canvas は、元の画像と全画素が同じでした。ctx.filter に値は入っていても、すでに描いた画像にはかかりません。

スライダーと保存ボタンのコンポーネント

App は、選んだ画像の URL と、明るさ・コントラストの値を state に持ちます。スライダーを動かすと state が変わり、<img> の style の filter が変わります。見た目は CSS で変えているので、JavaScript で画素を計算し直す処理は書いていません。

tsx
import { useEffect, useRef, useState } from 'react';
import type { ChangeEvent } from 'react';
import { exportAdjusted, initialAdjust, toFilter } from './adjust';
import type { Adjust } from './adjust';

export function App() {
  const [url, setUrl] = useState<string | null>(null);
  const [adjust, setAdjust] = useState<Adjust>(initialAdjust);
  const imgRef = useRef<HTMLImageElement>(null);

  // 前の画像の URL は、次の画像に替えたときに解放する
  useEffect(() => {
    if (!url) return;
    return () => URL.revokeObjectURL(url);
  }, [url]);

  function pick(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setUrl(URL.createObjectURL(file));
    setAdjust(initialAdjust);
  }

  function change(key: keyof Adjust) {
    return (e: ChangeEvent<HTMLInputElement>) => setAdjust({ ...adjust, [key]: Number(e.target.value) });
  }

  async function save() {
    const img = imgRef.current;
    if (!img) return;
    const blob = await exportAdjusted(img, adjust);
    const href = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = href;
    a.download = 'adjusted.png';
    a.click();
    URL.revokeObjectURL(href);
  }

  return (
    <main>
      <label className="pick">
        画像を選ぶ
        <input type="file" accept="image/*" onChange={pick} />
      </label>
      {url && (
        <>
          <label className="slider">
            <span>明るさ:{adjust.brightness}%</span>
            <input type="range" min={0} max={200} value={adjust.brightness} onChange={change('brightness')} />
          </label>
          <label className="slider">
            <span>コントラスト:{adjust.contrast}%</span>
            <input type="range" min={0} max={200} value={adjust.contrast} onChange={change('contrast')} />
          </label>
          {/* 画面では CSS の filter で見た目だけを変える */}
          <img ref={imgRef} src={url} alt="調整中の画像" className="preview" style={{ filter: toFilter(adjust) }} />
          <div className="buttons">
            <button type="button" onClick={() => setAdjust(initialAdjust)}>
              元に戻す
            </button>
            <button type="button" onClick={save}>
              PNG で保存
            </button>
          </div>
        </>
      )}
    </main>
  );
}

選んだファイルは、URL.createObjectURL() で URL にして <img> に表示します。使い終わった URL は、URL.revokeObjectURL() を呼んで解放します。ここでは、useEffect のクリーンアップ関数で、画像を替えたときに前の URL を解放しています。

保存では、imgRef で表示中の <img> を exportAdjusted に渡します。できた Blob を URL にして、download 属性を付けたリンクをクリックし、adjusted.png という名前でダウンロードさせます。検証では、明るさ・コントラストを6通りに変えて保存ボタンを押し、どれも adjusted.png という名前でダウンロードされました。

CSS は次のとおりです。画像は max-width: 100% で画面の幅に収めています。

css
body { margin: 0; font-family: "Noto Sans CJK JP", sans-serif; color: #1f2a30; background: #fff; }
main { padding: 16px; max-width: 620px; }
.pick { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 14px; }
.slider { display: flex; flex-direction: column; gap: 4px; margin: 0 0 10px; font-size: 14px; }
.slider input { width: 100%; max-width: 360px; }
.preview { display: block; max-width: 100%; height: auto; margin: 0 0 10px; }
.buttons { display: flex; gap: 8px; }
button { font: inherit; font-size: 14px; padding: 6px 14px; }

次の GIF は、明るさを 100% から 140% に、コントラストを 100% から 150% に上げ、最後に「元に戻す」を押したときの様子です。

選んだ画像は sample.png。明るさのつまみを右へ動かすと、明るさの数字が 100% から 140% まで変わり、空や地面が明るく白っぽくなる。次にコントラストのつまみを右へ動かして 150% にすると、空の下のほうが白く飛び、灰色の帯の白く飛ぶ範囲が左へ広がり、地面の緑が濃くなる。最後に「元に戻す」を押すと、どちらも 100% に戻り、元の色の画像になる
明るさとコントラストを上げてから、元に戻す様子。変わった数字は、その瞬間だけピンクにしている。カーソル、操作の表示、選んだ画像のファイル名は GIF 用に重ねたもので、ファイルの入力欄は GIF では隠している。GIF は色の数を減らして作ったため、空や灰色の帯に縞が出ている

明るさは値をかけ算し、コントラストは真ん中の灰色から引き離す

Filter Effects の仕様では、brightness() と contrast() は、R・G・B の値(0〜1)を次の式で置き換える処理として書かれています。amount は、100% を 1 とした値です。

  • brightness(amount):値 × amount
  • contrast(amount):(値 − 0.5) × amount + 0.5

明るさは値に同じ倍率をかけるので、0 は 0 のままで、ほかの値は、倍率が 1 より大きければ大きく、小さければ小さくなります。コントラストは、真ん中の 0.5(0〜255 なら約 128)を中心に、値を引き離したり近づけたりします。仕様では、計算の結果は色として使える範囲に収められるので、0〜1 からはみ出た値は 0 か 1 になります。

保存した PNG のいちばん下にある、黒から白へ変わる灰色の帯で、元の値と結果の値を読み取ると、次のようになりました。

text
download-130-100 の灰色の帯(元の値→結果): 0→0 64→83 128→166 192→249 255→255
download-70-100 の灰色の帯(元の値→結果): 0→0 64→44 128→89 192→134 255→178
download-100-150 の灰色の帯(元の値→結果): 0→0 64→32 128→128 192→224 255→255
download-100-50 の灰色の帯(元の値→結果): 0→63 64→95 128→127 192→159 255→191

ファイル名の数字は、明るさとコントラストの % です。これを線にしたのが次の図です。

2つのグラフ。上は明るさで、横軸が元の値、縦軸が結果の値。100% は斜めの直線。130% は傾きが急な直線で、元の値 197 あたりから上は 255 で横ばいになる。70% は傾きがゆるい直線で、元の値 255 のとき 178 になる。下はコントラストで、どの線も元の値 128 あたりで交わる。150% は傾きが急で、元の値 43 以下は 0、213 以上は 255 で横ばい。50% は傾きがゆるく、63 から 191 までの範囲に収まる
保存した PNG の灰色の帯から読み取った、元の値と結果の値。上が明るさ、下がコントラスト

明るさを上げたり、コントラストを上げたりすると、明るい側の値が 255 に張り付きます。明るさ 130% では、元の値が 197 以上の部分がすべて 255 になりました。検証に使った画像全体では、R・G・B の値のうち 255 のものが、元の画像の 0.1% から 17.7% に増えています。

注意

255 や 0 に張り付いた部分は、保存した PNG からは元の値に戻せません。もう一度調整し直すときは、保存した PNG ではなく元の画像から始めます。

検証では、保存した PNG の値と、上の式で計算して四捨五入した値との差は、明るさだけ・コントラストだけのときは最大 1、明るさ 70%・コントラスト 140% を組み合わせたときは最大 2 でした。画面に表示された画像(CSS の filter)と保存した PNG の差も、最大 2 でした。

text
download-130-100.png: (600, 400) 式と違う画素 183594 / 240000, 差の最大 1
download-70-140.png: (600, 400) 式と違う画素 230199 / 240000, 差の最大 2
preview-70-140.png と download-70-140.png: 違う画素 230199, 差の最大 2

brightness と contrast は、書いた順番にかかる

filter に関数を並べると、仕様では、書いた順番にかかります。前の関数の結果が、次の関数の入力になります。そのため、brightness(70%) contrast(140%) と contrast(140%) brightness(70%) は別の結果になります。

text
download-70-140 の灰色の帯(元の値→結果): 0→0 64→10 128→73 192→136 255→198
swapped-70-140 の灰色の帯(元の値→結果): 0→0 64→26 128→89 192→151 255→178
swapped-70-140.png と download-70-140.png: 違う画素 236320, 差の最大 20

明るさを先にかけると、暗くした値をコントラストで引き離すので、暗い部分がより黒に近づきます(元の値 64 が 10)。コントラストを先にかけると、最後に全体へ 0.7 をかけるので、いちばん明るい部分も 178 までしか上がりません。

上は brightness(70%) contrast(140%) の結果、下は contrast(140%) brightness(70%) の結果。上は地面の左側と灰色の帯の左側が黒くつぶれ、太陽の外側が濃い赤になっている。下は地面の左側の黒い部分が上より狭い。灰色の帯の右端は、上は明るい灰色、下はそれより暗い灰色で止まり、どちらも白にはならない
明るさ 70%・コントラスト 140% を、明るさが先の順(上)とコントラストが先の順(下)でかけた結果

この記事の toFilter は、明るさ→コントラストの順に並べています。画面の CSS と保存の canvas で同じ関数を使っているので、表示と保存で順番が食い違うことはありません。

Safari の ctx.filter は、設定を有効にしないと使えない

MDN の ctx.filter のページにある対応表では、Chrome は 52、Firefox は 49 から対応しています。Safari は 18 からですが、「Canvas Filters」という設定を有効にした場合だけ、と書かれています。この記事の保存の処理は、この ctx.filter に頼っています。

​Posted by 86