ReactでWebPとJPEGに変換して、大きさと画質を比べる方法
画像を canvas に描いて toBlob() に形式と品質を渡すと、ブラウザの中で JPEG や WebP に書き出せます。書き出した画像をもう一度読み込んで元の画素と比べれば、ファイルの大きさと一緒に「どれだけ元の画像から変わったか」を数値で並べられます。
ここでは、選んだ画像を JPEG・WebP・PNG で書き出し、大きさと PSNR(元の画像との画素の差の小ささを表す数値)を出すコンポーネント FormatCompare を作りました。AVIF も頼んでいますが、検証した Chromium では 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 です。.tsx を tsc で型チェックしてから esbuild でビルドし、Playwright で画像を選んで結果を読み取りました。比べた画像は、カメラで撮った写真ではなく、Pillow で描いた2枚です。1枚はグラデーションとぼかした円にノイズを混ぜた 1600×1200 の画像(以下「写真の代わりの画像」)、もう1枚は背景が透明で、はっきりした色の図形と細い線を描いた 800×600 の PNG(以下「図形の画像」)です。Firefox、Safari、スマートフォンのブラウザ、カメラで撮った写真は試していません。

toBlob() に形式と品質を渡して書き出す
encodeAll は、受け取った画像を指定した形式ごとに書き出し、書き出した Blob と PSNR を返す関数です。React には依存していません。
export type Encoded = {
requested: string; // 頼んだ形式
type: string; // 実際に書き出された形式
blob: Blob;
psnr: number; // 元の画像との近さ(dB)。大きいほど元に近く、完全に同じなら Infinity
};
// toBlob() を Promise にする
export function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('画像を書き出せませんでした'))), type, quality);
});
}
// 画像を白い下地の上に描いて、画素を読み出す(比べるときの土台をそろえるため)
async function readPixels(source: Blob): Promise<ImageData> {
const bitmap = await createImageBitmap(source);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, bitmap.width, bitmap.height);
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return ctx.getImageData(0, 0, canvas.width, canvas.height);
}
// RGB の差の二乗平均から PSNR を出す
export function psnr(a: ImageData, b: ImageData): number {
let sum = 0;
for (let i = 0; i < a.data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const d = a.data[i + c] - b.data[i + c];
sum += d * d;
}
}
const mse = sum / ((a.data.length / 4) * 3);
return mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / mse);
}
// 1枚の画像を、指定した形式ごとに書き出して、大きさと PSNR を返す
export async function encodeAll(file: Blob, types: string[], quality: number): Promise<Encoded[]> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const original = await readPixels(file);
const results: Encoded[] = [];
for (const requested of types) {
let target = canvas;
if (requested === 'image/jpeg') {
// JPEG は透明を持てないので、白い下地に描いた canvas から書き出す
target = document.createElement('canvas');
target.width = canvas.width;
target.height = canvas.height;
const c = target.getContext('2d')!;
c.fillStyle = '#fff';
c.fillRect(0, 0, target.width, target.height);
c.drawImage(canvas, 0, 0);
}
const blob = await canvasToBlob(target, requested, quality);
results.push({ requested, type: blob.type, blob, psnr: psnr(original, await readPixels(blob)) });
}
return results;
}
MDN の toBlob() の説明にあるとおり、2つ目の引数が形式、3つ目の引数が品質です。品質は 0〜1 の数で、image/jpeg や image/webp のように、画質を落として小さくする形式に使われます。
PSNR は、元の画像と書き出した画像の画素を R・G・B ごとに引き算し、その二乗の平均から出しています。数が大きいほど元の画像に近く、画素がすべて同じなら Infinity になります。比べる前に、どちらの画像も readPixels で白い下地の上に描いています。透明な部分を持つ画像でも、JPEG(透明な部分が白になる)と同じ条件で比べるためです。
書き出せない形式を頼むと PNG になる
toBlob() は、頼んだ形式で書き出せないときも失敗しません。HTML の仕様の、canvas の画像をファイルに書き出す節では、ブラウザは PNG には必ず対応し、対応していない形式を頼まれたら PNG で書き出すと決められています。
検証した Chromium では、image/avif・image/gif・image/bmp を頼むと、どれも image/png の Blob が返りました。
toBlob(image/avif, 0.8) → image/png、405 バイト
toBlob(image/gif, 0.8) → image/png、405 バイト
toBlob(image/bmp, 0.8) → image/png、405 バイト
そのため encodeAll は、頼んだ形式(requested)と、返ってきた Blob の type を分けて持たせています。拡張子を決めるときや、画面に形式の名前を出すときは、blob.type のほうを使います。
品質の値にも決まりがあります。HTML の仕様では、0〜1 の範囲に入っていない数を渡すと、品質を渡さなかったときと同じ扱いになります。検証では、JPEG に品質を渡さなかったとき、0.92 を渡したとき、1.5 を渡したときの大きさが同じでした。WebP では、品質を渡さなかったときと 0.8 を渡したときの大きさが同じでした。
toBlob(image/jpeg, undefined) → image/jpeg、1248 バイト
toBlob(image/jpeg, 0.92) → image/jpeg、1248 バイト
toBlob(image/jpeg, 1.5) → image/jpeg、1248 バイト
toBlob(image/jpeg, 1) → image/jpeg、2277 バイト
toBlob(image/webp, undefined) → image/webp、710 バイト
toBlob(image/webp, 0.8) → image/webp、710 バイト
toBlob(image/webp, 1) → image/webp、656 バイト
仕様では、品質を渡さなかったときの値はブラウザごとに決めてよいとされています。品質を比べたいときは、値を必ず渡しておくと、ブラウザの既定値に左右されません。
品質を変えるたびに書き出し直すコンポーネント
FormatCompare は、選んだ画像とスライダーの品質が変わるたびに encodeAll を呼び、結果を形式ごとに並べます。
import { useEffect, useState, type ChangeEvent } from 'react';
import { encodeAll, type Encoded } from './encodeImage';
const TYPES = ['image/jpeg', 'image/webp', 'image/avif', 'image/png'];
const LABELS: Record<string, string> = { 'image/jpeg': 'JPEG', 'image/webp': 'WebP', 'image/avif': 'AVIF', 'image/png': 'PNG' };
type Result = Encoded & { url: string };
const kb = (bytes: number) => `${(bytes / 1024).toFixed(1)} KB`;
const db = (value: number) => (value === Infinity ? '元と同じ' : `${value.toFixed(1)} dB`);
export function FormatCompare() {
const [file, setFile] = useState<File>();
const [quality, setQuality] = useState(0.8);
const [results, setResults] = useState<Result[]>([]);
const [error, setError] = useState('');
useEffect(() => {
if (!file) return;
let ignore = false;
// スライダーを動かしている間は書き出さず、止まってから 200ms 後に書き出す
const timer = setTimeout(() => {
encodeAll(file, TYPES, quality)
.then((encoded) => {
if (!ignore) setResults(encoded.map((e) => ({ ...e, url: URL.createObjectURL(e.blob) })));
})
.catch(() => {
if (!ignore) setError('この画像は読み込めませんでした');
});
}, 200);
return () => {
ignore = true;
clearTimeout(timer);
};
}, [file, quality]);
// 表示している URL は、次の結果に入れ替わったときに解放する
useEffect(() => () => results.forEach((r) => URL.revokeObjectURL(r.url)), [results]);
const handleFile = (e: ChangeEvent<HTMLInputElement>) => {
setFile(e.target.files?.[0]);
setResults([]);
setError('');
};
return (
<div className="compare">
<label>
画像を選ぶ
<input type="file" accept="image/*" onChange={handleFile} />
</label>
<label>
品質:{quality.toFixed(2)}
<input
type="range"
min={0.1}
max={1}
step={0.05}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
/>
</label>
{error && <p role="alert">{error}</p>}
{file && <p>元のファイル:{file.type}、{kb(file.size)}</p>}
<ul>
{results.map((r) => (
<li key={r.requested}>
<h3>{LABELS[r.requested]}</h3>
{r.type !== r.requested && <p role="alert">{r.requested} では書き出せず、{r.type} になりました</p>}
<p className="numbers">
{kb(r.blob.size)}(元の {Math.round((r.blob.size / file!.size) * 100)}%)、PSNR {db(r.psnr)}
</p>
<div className="zoom">
<img src={r.url} alt={`${LABELS[r.requested]} で書き出した画像の一部を拡大したもの`} />
</div>
<a href={r.url} download={`converted.${r.type.split('/')[1]}`}>
保存する
</a>
</li>
))}
</ul>
</div>
);
}
encodeAll は、4つの形式で書き出すたびに画素を読んで比べます。検証環境で、写真の代わりの画像を1回処理するのにかかった時間は、3回測って 729〜788ms でした。
--- 5b. photo.png で encodeAll を1回呼ぶのにかかった時間(ms、3回)---
734, 788, 729
そこで effect の中では、setTimeout で 200ms 待ってから書き出します。その前に値が変わったら、後片付けの clearTimeout で取り消します。後片付けでは、ignore も true にしています。前の値で始めた処理の結果を setResults に渡さないためです。これは、useEffect のリファレンスにある、effect でデータを取得する例と同じ書き方です。
表示に使う URL は、2つ目の effect で解放しています。この effect は results が変わるたびに動き、後片付けで前の results の URL を URL.revokeObjectURL() で解放します。品質を変えても、次の結果が出るまでは前の結果が表示されたままなので、その間は前の URL を残しておきます。そうしないと、待っている間は「保存する」のリンク先が、解放済みの URL のままになります。
検証では、写真の代わりの画像を選んで品質を 0.80 から 0.30 に変えました。変えた直後は 0.80 のときの JPEG の URL を fetch で読めて、結果が入れ替わったあとは読めなくなっていました。
品質を 0.30 にした直後(次の結果が出る前)に、0.80 のときの JPEG の URL を fetch で読むと: 読めた
--- 2. 品質を 0.30 にする ---
(中略)
JPEG
25.7 KB(元の 1%)、PSNR 36.6 dB
(中略)
結果が入れ替わったあとに、0.80 のときの JPEG の URL を fetch で読むと: 読めない(TypeError)
続けて、品質を 50ms おきに 0.20、0.50、0.90、1.00 と動かしてから5秒待ちました。表示は 1.00 の結果になり、その間に toBlob() が呼ばれたのは4回でした。4回は、1.00 のときに4つの形式を1回ずつ書き出した分です。途中の 0.20〜0.90 では書き出していません。
--- 3. 品質を 50ms おきに 0.20 → 0.50 → 0.90 → 1.00 と動かして、5秒待つ ---
(中略)
その間に toBlob() が呼ばれた回数: 4
拡大して見せる部分は、検証用のページで次の CSS を当てています。object-fit: none で画像を縮めずに表示し、枠からはみ出す部分を overflow: hidden で隠しています。object-position の初期値は 50% 50% なので、見えるのは画像の真ん中です。それを transform: scale(3) で3倍にしています。image-rendering: pixelated を付けると、拡大するときに隣の画素と混ぜずに広げるので、JPEG や WebP で崩れた画素がそのまま見えます。
.zoom { width: 100%; height: 150px; overflow: hidden; background: repeating-conic-gradient(#ddd 0 25%, #fff 0 50%) 0 0 / 16px 16px; }
.zoom img { display: block; width: 100%; height: 100%; object-fit: none; transform: scale(3); image-rendering: pixelated; }
背景の市松模様は、透明な部分を見分けるためのものです。冒頭の画面では、WebP は透明な部分に市松模様が見え、JPEG は白で塗られています。
同じ大きさなら WebP のほうが元の画像に近かった
品質を 0.1 から 1 まで変えて、2枚の画像を JPEG と WebP で書き出し、大きさと PSNR を測りました。品質 0.1〜0.95 の結果を、横軸を大きさ、縦軸を PSNR にして描いたのが次のグラフです。線が左上にあるほど、小さいファイルで元の画像に近いことを表します。

写真の代わりの画像では、大きさがほぼ同じ組み合わせで比べると、WebP の品質 0.7(26,934 バイト)は PSNR 38.03 dB、JPEG の品質 0.3(26,359 バイト)は 36.63 dB でした。
--- 6. photo.png の品質ごとの大きさ(バイト)と PSNR(dB)---
品質 JPEG バイト JPEG PSNR WebP バイト WebP PSNR
0.1 17331 31.43 13080 36.06
0.2 21619 35.31 14458 36.75
0.3 26359 36.63 15772 37.22
0.4 31634 37.27 17306 37.53
0.5 40208 37.74 18968 37.70
0.6 54402 38.02 21138 37.91
0.7 82606 38.52 26934 38.03
0.8 133731 39.12 52680 38.43
0.9 277112 40.27 256572 41.09
0.95 525663 42.46 476912 44.71
1 1414498 51.25 2030134 ∞
同じ品質の数どうしで比べると、品質 0.8 では JPEG の PSNR のほうが高く、ファイルは WebP の約2.5倍でした。同じ数を渡しても、形式によって大きさも PSNR も違います。形式どうしを比べるときは、品質の数をそろえるのではなく、大きさをそろえて比べます。
図形の画像では差がさらに大きく、品質 0.1 の WebP(8,946 バイト)でも PSNR は 29.63 dB で、品質 0.95 の JPEG(143,179 バイト)の 30.93 dB に近い値でした。冒頭の画面のとおり、JPEG では線や図形の縁のまわりに、元の画像にない細かい点が出ます。
--- 6. illust.png の品質ごとの大きさ(バイト)と PSNR(dB)---
品質 JPEG バイト JPEG PSNR WebP バイト WebP PSNR
0.1 20675 22.70 8946 29.63
(中略)
0.95 143179 30.93 41162 36.85
1 382364 54.91 7688 ∞
PNG(toBlob の image/png): 23940 バイト、元のファイル: 10280 バイト
WebP と JPEG の差は、2枚の画像でこれだけ違いました。どの形式と品質にするかは、扱う画像で測ってから決めてください。
品質 1 の WebP は、元の画像と画素が同じだった
検証した Chromium では、品質 1 で書き出した WebP は、2枚とも PSNR が Infinity(画素がすべて元と同じ)でした。品質 1 の JPEG は、写真の代わりの画像で 51.25 dB、図形の画像で 54.91 dB で、元の画像とは画素が違っていました。
品質 1 の WebP の大きさは、画像によって大きく違いました。写真の代わりの画像では 2,030,134 バイトで、品質 1 の JPEG(1,414,498 バイト)より大きくなりました。図形の画像では 7,688 バイトで、品質 1 の JPEG(382,364 バイト)よりはるかに小さく、toBlob() で書き出した PNG(23,940 バイト)の3分の1より小さくなりました。元の PNG ファイル(10,280 バイト)と比べても小さい値です。
toBlob() の PNG は、2枚とも元のファイルより大きくなりました。元の PNG は Pillow で保存したもので、同じ PNG でも、書き出す道具によって大きさが変わります。図形の画像の PNG の欄には「元の 233%」と表示されました。
透明な部分が残るのは WebP と PNG
図形の画像は、背景が透明なので、左上の隅も透明です。書き出した画像の左上の画素を R・G・B・A で読むと、JPEG は不透明な白、WebP と PNG は透明のままでした。
--- 5. illust.png を書き出した画像の左上の画素(R,G,B,A)---
{"image/jpeg":"255,255,255,255","image/webp":"0,0,0,0","image/png":"0,0,0,0"}
JPEG が白になったのは、encodeAll が白い下地に描いてから書き出しているためです。HTML の仕様では、透明を持てない形式に書き出すときは、不透明な黒の背景に重ねると決められています。白で塗っておかないと、透明だった部分は黒になります。
書き出した画像を保存する
結果ごとの「保存する」は、a 要素の download 属性にファイル名を入れたリンクです。href には、表示に使っているのと同じ Object URL を入れています。MDN の a 要素の説明にあるとおり、download 属性を付けると、ブラウザはリンク先を保存するものとして扱い、属性の値を保存するファイル名の候補にします。
拡張子は、頼んだ形式ではなく blob.type から決めています。中身が PNG なのに .avif という名前で保存されることを防ぐためです。検証で図形の画像の WebP を保存すると、converted.webp という名前の 24,570 バイトのファイルが保存されました。先頭の12バイトを文字として読むと、RIFF と WEBP が入っていました。大きさは、品質 0.8 で測った WebP と同じです。
WebP の「保存する」で保存されたファイル: {"name":"converted.webp","size":24570,"head":"RIFF._..WEBP"}





