Reactで、画像の明るさとコントラストを調整して保存する方法
画像の明るさとコントラストをスライダーで変えるだけなら、<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つ用意して、画面と保存の両方で使います。
// 明るさとコントラスト(どちらも % で、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 で画素を計算し直す処理は書いていません。
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% で画面の幅に収めています。
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% に上げ、最後に「元に戻す」を押したときの様子です。

明るさは値をかけ算し、コントラストは真ん中の灰色から引き離す
Filter Effects の仕様では、brightness() と contrast() は、R・G・B の値(0〜1)を次の式で置き換える処理として書かれています。amount は、100% を 1 とした値です。
brightness(amount):値 × amountcontrast(amount):(値 − 0.5) × amount + 0.5
明るさは値に同じ倍率をかけるので、0 は 0 のままで、ほかの値は、倍率が 1 より大きければ大きく、小さければ小さくなります。コントラストは、真ん中の 0.5(0〜255 なら約 128)を中心に、値を引き離したり近づけたりします。仕様では、計算の結果は色として使える範囲に収められるので、0〜1 からはみ出た値は 0 か 1 になります。
保存した PNG のいちばん下にある、黒から白へ変わる灰色の帯で、元の値と結果の値を読み取ると、次のようになりました。
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
ファイル名の数字は、明るさとコントラストの % です。これを線にしたのが次の図です。

明るさを上げたり、コントラストを上げたりすると、明るい側の値が 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 でした。
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%) は別の結果になります。
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 までしか上がりません。

この記事の toFilter は、明るさ→コントラストの順に並べています。画面の CSS と保存の canvas で同じ関数を使っているので、表示と保存で順番が食い違うことはありません。
Safari の ctx.filter は、設定を有効にしないと使えない
MDN の ctx.filter のページにある対応表では、Chrome は 52、Firefox は 49 から対応しています。Safari は 18 からですが、「Canvas Filters」という設定を有効にした場合だけ、と書かれています。この記事の保存の処理は、この ctx.filter に頼っています。





