配信ツリーのJPEG 10枚を7.01MBから1.87MBへ再エンコードした
今回やったこと / この記事について
運用中のWebサイト群において、公開配信ツリー(public/ 配下)に残っていた過大なJPEG画像10枚を検出し、URLとファイル名を一切変更せずに再エンコードを実施した。その結果、対象10枚の合計サイズを 7.01 MB から 1.87 MB(約73%削減)へと大幅に軽量化した。
本稿では、なぜ配信ツリーに肥大化した画像が紛れ込んだのか、なぜWebPへの変換ではなく「ファイル名維持のまま中身だけ再エンコード」を選択したのか、そして画質劣化を防ぐためのPSNR(ピーク信号対雑音比)による安全弁設計について記録する。
なぜ肥大化していたのか:「.pngという名前のJPEG」
静的サイトの運用では、記事のアイキャッチや本文挿図の軽量化を行う最適化スクリプトを配備している。通常のWebP画像のファイルサイズ中央値はおよそ115 KB程度に収まっている。
しかし、公開ツリーの容量を棚卸しした際、1枚あたり800 KB〜1 MB前後の過大な画像が複数混入していることが判明した。調査したところ、以下の事情が重なっていた。
- 拡張子と実データの乖離: ファイル名は
.pngで終わっているものの、実データはJPEG形式(先頭マジックナンバー\xff\xd8)であった。画像生成ツールの出力を拡張子だけ変更して配置した結果、拡張子ベースの検知フィルタをすり抜けていた。 - 高解像度・過剰な品質設定: 高いピクセル解像度・最高品質(Quality 100に近い設定)のまま書き出されており、Web配信用途としては過剰なデータ量を含んでいた。
- 前処理パイプラインのバイパス: 手動投入や例外的な配備手順により、通常の事前変換工程(WebP化・リサイズ)を通過せずに直接配置されていた。
ファイル名と拡張子を変えない制約
画像容量を削減する一般的な手段は、最新のWebPやAVIF形式へ変換し、適切な解像度へ縮小することである。しかし、既に公開されて一定期間が経過した記事に対してその対応を行うと、別の実害が生じる。
公開済み記事の画像URLを変更すると、以下のような問題が発生する。
- SNS等のOGPキャッシュの破損: 既にソーシャルメディアや外部チャットで共有されている投稿において、アイキャッチ画像のリンクが切れて表示が消える。
- 外部被リンク・RSSリーダーの切断: 画像直リンクを含む外部参照が404になる。
- 記事HTML側の書き換えリスク: 大量の過去記事Markdownを一括置換する必要が生じ、意図せぬ差分を生む危険がある。
「既存記事のURLを変えない」という原則を守るためには、拡張子(たとえそれが .png であっても)とパス名を1文字も変更せず、バイナリの中身だけをJPEG形式のまま再エンコードする手法を暫定対応として選択した。
また、対象ファイルが「名前は .png だが中身はJPEG」であるため、ファイル探索も拡張子のマッチングではなく、ファイル先頭2バイトを読み取ってマジックナンバーが b"\xff\xd8" に合致するかどうかで判定するロジックを採用した。
def is_jpeg(path: Path) -> bool:
try:
with path.open("rb") as fh:
return fh.read(2) == b"\xff\xd8"
except OSError:
return False
安全弁:削減率とPSNRによる品質保護
単に一括再エンコードを行うだけでは、再圧縮によって文字が潰れたり、既に圧縮済みの画像を二重に劣化させてしまうリスクがある。そのため、スクリプトには以下の4重の安全弁を実装した。
| 安全弁 | 閾値 / 条件 | 判定の理由 |
|---|---|---|
| dry-run 既定 | --apply 引数必須 | 誤実行による一括上書きを防止 |
| 最低削減率 | --min-saving 30% | 30%未満の微小な削減のために画質を損なうのを防ぐ |
| 画質指標 (PSNR) | --min-psnr 34.0 dB | テキストを含む図版など、再圧縮で崩れる画像を自動除外 |
| 容量増加の拒否 | after >= before | 圧縮アルゴリズムの特性で元より大きくなる場合はスキップ |
特にPSNR(Peak Signal-to-Noise Ratio)による判定を、機械的な差分量の目安として使った。元画像と再エンコード後画像をPillowで開き、RGB各チャンネルの二乗平均平方誤差(MSE)からデシベル値を算出する。
def psnr(a: Image.Image, b: Image.Image) -> float:
diff = ImageChops.difference(a, b)
total = count = 0
for channel in diff.split():
for value, freq in enumerate(channel.histogram()):
total += freq * value * value
count += freq
if count == 0:
return float("inf")
mse = total / count
return float("inf") if mse == 0 else 10 * math.log10(255 * 255 / mse)
この計算でPSNRが34 dBを下回った画像は、スクリプトによって自動的に処理対象から除外(見送り)される。PSNRは局所的な文字の見え方を単独で保証する指標ではないため、目視確認と併用する。
10枚の再圧縮結果と運用の教訓
スクリプトを品質パラメータ quality=85、min_saving=30、min_psnr=34.0 で実行した結果は以下の通りである。
現状 再圧縮 削減 PSNR パス
782K 233K 70% 40.1dB sites/.../featured.png
533K 146K 72% 38.8dB sites/.../featured.png
329K 72K 77% 41.5dB sites/.../featured.png
422K 106K 74% 39.2dB sites/.../featured.png
1001K 294K 70% 37.6dB sites/.../featured.png
772K 196K 74% 39.8dB sites/.../featured.png
776K 212K 72% 40.2dB sites/.../inline1.png
725K 181K 74% 39.9dB sites/.../inline2.png
1013K 275K 72% 38.4dB sites/.../r2-wp-media-fallback-concept.png
816K 196K 75% 39.1dB sites/.../thinned-colors-concept.png
10 件 書き込んだ: 7.01 MB → 1.87 MB
すべての対象画像でPSNRは37 dB以上を維持し、目視でも元画像との差異が判別できない品質を保ちながら、データサイズを約4分の1に削減できた。原本となる raw/ ディレクトリは保護対象として触らず、配信ツリーの公開アセットのみを更新した。
Webサービスの静的アセット運用では、画像を追加するフローを整えるだけでは不完全である。何らかの例外やミスによって配信ツリーに過大なデータが入り込む事態を想定し、「URLを変えずに安全弁付きで中身だけ軽量化できる点検ツール」を手元に常備しておくことが、運用健全性の維持に直結する。
試した環境は Python 3.12 および Pillow であり、外部の重い画像処理ライブラリに依存せず標準的な環境で完結している。
更新履歴
- 2026-09-19: 初稿。配信ツリーに残る過大JPEGの検出とPSNR安全弁を用いた再エンコード手順を記録。
訂正履歴
- なし。