「アップロードは2MBまで」「添付は1MB以内で」——サイズ制限に合わせて画像を小さくしたいとき、画質を手で少しずつ変えて何度も書き出す…のは地味に面倒です。
そこで、目標のファイルサイズ以下に収まる「いちばん高い画質」を二分探索で自動的に見つける方法をまとめます。ブラウザ(Canvas)だけででき、ファイルをサーバーに送らずに完結します。
ゴール:「◯KB以下」を指定したら、その範囲でいちばんきれいな画像を自動で書き出せるようになること。
発想:画質を二分探索する
JPEG/WebP は書き出し時の「画質(quality)」でファイルサイズが変わります。画質を上げるほどサイズは大きくなるので、「目標サイズ以下に収まる最大の画質」を二分探索で詰めていけます。
10回ほど繰り返せば、画質 0〜1 の範囲を十分な精度で絞り込めます(毎回半分に狭まるため)。
実装(Canvas + toBlob)
まずは toBlob を Promise で扱えるようにして、画質を二分探索します。
function loadImage(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = reject;
img.src = url;
});
}
function toBlob(canvas, type, quality) {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}
// targetKB以下に収まる、いちばん高画質なBlobを返す
async function compressToTargetKB(file, targetKB, type = "image/jpeg") {
const targetBytes = targetKB * 1024;
const img = await loadImage(file);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingQuality = "high";
let scale = 1;
for (let attempt = 0; attempt < 8; attempt++) {
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 画質を二分探索
let lo = 0.1, hi = 1.0, best = null;
for (let i = 0; i < 10; i++) {
const mid = (lo + hi) / 2;
const blob = await toBlob(canvas, type, mid);
if (blob.size <= targetBytes) {
best = blob; // 収まった → もっと高画質を狙う
lo = mid;
} else {
hi = mid; // 超過 → 画質を下げる
}
}
if (best) return best; // 目標内に収まった
scale *= 0.8; // 最低画質でもダメ → 解像度を落として再挑戦
}
// 最後の手段
return await toBlob(canvas, type, 0.1);
}
使い方はこれだけです。
const blob = await compressToTargetKB(file, 500); // 500KB以下に
// あとはダウンロードやアップロードに使う
ポイントは 画質探索がダメなら解像度も落とす二段構えにしていること。画質を最低にしても目標を超える(=そもそも画素数が多すぎる)場合に、幅を段階的に縮めて再探索します。
注意点
- 画質とサイズは「ほぼ」単調。 エンコーダの都合で厳密な単調増加ではありませんが、実用上は単調とみなして問題ありません。神経質に最適解を求めるより、十分に近い解を速く得るのが目的です。
-
PNGにはqualityが効かない。 PNGは可逆圧縮なので
toBlobの画質引数を無視します。サイズを詰めたいなら JPEG か WebP を使います(WebPはさらに小さくなりやすい)。 -
回数はトレードオフ。 二分探索の回数(ここでは10回)を増やすほど精度は上がりますが、その分
toBlobの実行回数が増えます。10回前後が実用的です。 - 透過画像。 透過を保ちたいなら WebP。JPEGにすると背景が黒(や白)になります。
Python でやる場合(Pillow)
サーバー側でも考え方は同じで、quality を二分探索します。
from PIL import Image
import io
def compress_to_target_kb(path, target_kb, fmt="JPEG"):
img = Image.open(path).convert("RGB")
target = target_kb * 1024
lo, hi, best = 10, 95, None
while lo <= hi:
mid = (lo + hi) // 2
buf = io.BytesIO()
img.save(buf, format=fmt, quality=mid)
if buf.tell() <= target:
best = buf.getvalue() # 収まった → 高画質を狙う
lo = mid + 1
else:
hi = mid - 1 # 超過 → 画質を下げる
return best
こちらも、最低画質でも収まらない場合は img.thumbnail() などで縮小してから再探索すると確実です。
まとめ
- 「目標サイズ以下でいちばんきれい」は、画質の二分探索で自動化できる
- ブラウザなら
canvas.toBlob(cb, type, quality)を Promise 化して探索 - 画質を詰めきってもダメなら解像度を落とす二段構えにすると確実
- PNGはqualityが効かないので、サイズを詰めるなら JPEG / WebP
- Python でも
qualityの二分探索で同じことができる(Pillow)
「◯MB以下にして」を、手作業ではなく仕組みで解けると気持ちいいです。参考になれば嬉しいです。