配信ツリーのJPEG 10枚を7.01MBから1.87MBへ再エンコードした

  • #画像最適化
  • #JPEG
  • #パフォーマンス
  • #静的サイト
  • #自動化
配信ツリーの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前後の過大な画像が複数混入していることが判明した。調査したところ、以下の事情が重なっていた。

  1. 拡張子と実データの乖離: ファイル名は .png で終わっているものの、実データはJPEG形式(先頭マジックナンバー \xff\xd8)であった。画像生成ツールの出力を拡張子だけ変更して配置した結果、拡張子ベースの検知フィルタをすり抜けていた。
  2. 高解像度・過剰な品質設定: 高いピクセル解像度・最高品質(Quality 100に近い設定)のまま書き出されており、Web配信用途としては過剰なデータ量を含んでいた。
  3. 前処理パイプラインのバイパス: 手動投入や例外的な配備手順により、通常の事前変換工程(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=85min_saving=30min_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安全弁を用いた再エンコード手順を記録。

訂正履歴

  • なし。