PageSpeed Insightsがwebpかavifにして画像を軽くしろと言われるので、渋々ベンチをとってみた結果。JPEG, WebP, JPEG XL, AVIFで比較している。

比較では、画質を保ったままファイルサイズをどれだけ減らせるかに加えて、サーバーサイドでの変換にかかるCPU時間を重視した。本システムはリソースの限られた環境での運用を前提としており、変換が重いと画像のアップロードが終わるまでに時間がかかり、ストレスになるからだ。

以下に計測内容を残す。計測は3回行い、その中央値を出している。

変換に使った画像

形式 フォーマット 色数 画像サイズ ファイルサイズ(byte)
JPEG元画像 画質93・サブサンプリング4:2:0・ベースライン・EXIF・XMP有 24bit 4,000 x 3,000px 11,175,399
PNG元画像 埋め込みコメントあり 32bit 2,048 x 3,072px 7,636,570

計測に使った環境

Env Ver
CPU AMD Ryzen 5 8500G
メモリ DDR5-6000 16GB * 2
ストレージ Crucial P1 CT500P1SSD8JP
OS Ubuntu 26.04.1 LTS
ImageMagick 7.1.2-18 Q16 x86_64 23822
cjxl v0.11.1 [AVX3_DL,AVX2,SSE4,SSE2]
avifenc 1.3.0 (dav1d [dec]:1.5.3, libgav1 [dec]:0.20.0, aom [enc/dec]:v3.13.1)

サイズそのままで形式のみ変更

JPEGがサイズ削減率とCPU使用時間、画質の比率で最も優れており、他へ移る意義を見出すことができなかった。

JPEGの軽量化

スマホのカメラで撮影して作ったJPEGがどれだけ縮むか見たもの。JPEGからJPEGに再圧縮するだけでも劇的に減っている。EXIFは残してある。

画像全体を比較してもわかりづらいため、元画像の一部をクロップした比較用に切り抜いたJPEG元画像と、それに対応する変換結果の画像をすべてPNGで置いている。開いてスライドショーをめくっていくと差分が分かりやすいようにしている。

変換 設定 サイズ削減率 サイズ比率(JPEG基準) CPU時間(ms) CPU時間比率(JPEG基準) 画質
JPEG->JPEG 画質80・サブサンプリング4:2:0・プログレッシブ 88.8% 100% 217 100% ほぼ同一(AVIFに劣る)
JPEG->WebP 画質80・サブサンプリング4:2:0 92.4% 68.0% 707 325.8% のっぺりしている
JPEG->JPEG XL 画質80・サブサンプリングなし 92.5% 66.5% 6,041 2783.9% 若干色が薄い
JPEG->AVIF 画質80・サブサンプリング4:2:0・速度6/10 90.9% 81.4% 2,939 1354.4% 限りなく同一(JPEGに勝る)
JPEG→JPEGが劇的に減っている理由

JPEGからJPEGにしても普通に考えれば容量はさほど変わらないはずだが、これには理由がある。これはimagemagickの出力時にXMP-GDepthとXMP-GImageが欠落している関係だ。

試しに元のJPEGから純粋に画像を引き抜いたところ2,516,842バイトしかなかった。元が11,175,399バイトなのを差し引くと8,658,557バイトがXMP-GDepthとXMP-GImageに使われていることが分かる。

この二つのフィールドはAndroidのカメラによって作成されるもので、JPEGで保存した写真を劣化させずに再編集するためにボケ情報が保存されているらしく、それらのデータが容量を食っているらしい。これは後からぼかしの強さやピント位置を調整し直すためのもので、要は写真の中にもう1枚写真が丸ごと入っているから大きいわけだ。

つまりこのデータは単純に画像を表示する分には不要で、マスタデータとして保持するために必要な情報であるから、ブログなどの表示用途では外してしまって構わない。

ちなみにこのデータがある状態では、ぼかしがない状態の素の画像と、ぼかしをかけるためのマスクデータ、ぼかし加工のされた画像が内部にあり、以下のコマンドで切り出すことができる。

# XMP-GDepth(ぼかしデータ
exiftool -b -DepthImage src.jpeg > depth.jpeg
# XMP-GImage(真の写真
exiftool -b -ImageData src.jpeg > gimage.jpg
# ぼかしデータと真の写真を掛け合わせた写真データ(編集済の写真)
exiftool -xmp:all= -o no-xmp.jpg src.jpeg

しかし切り出した画像を見てもそこまで大きくないので不思議だなと思ったら、XMPフィールドには文字列しか入らないので、真の写真はBase64エンコードされた文字列として格納されているらしい。そらでかくなるわ!

恐らく元々JPEGの仕様として存在したものではなく、拡張フィールドにバイナリを無理やり押し込んだ結果こうなっているのだろう。

PNGの軽量化

メタデータは落ちている。

画像全体を比較してもわかりづらいため、元画像の一部をクロップした比較用に切り抜いたPNG元画像と、それに対応する変換結果の画像をすべてPNGで置いている。開いてスライドショーをめくっていくと差分が分かりやすいようにしている。

変換先 設定 サイズ削減率 サイズ比率(JPEG基準) CPU時間(ms) CPU時間比率(JPEG基準) 画質
PNG->JPEG 画質80・サブサンプリング4:2:0・プログレッシブ 91.4% 100% 207 100% ほぼ同一(AVIFに劣る)
PNG->WebP 画質80・サブサンプリング4:2:0 95.6% 51.7% 569 274.9% のっぺりしている
PNG->JPEG XL 画質80・サブサンプリングなし 93.8% 71.6% 3,897 1882.6% 若干色が暗い
PNG->AVIF 画質80・サブサンプリング4:2:0・速度6/10 95.8% 49.4% 4,317 2085.5% 限りなく同一(JPEGに勝る)

サムネイル生成

全て幅を320pxに落としている。

サムネイル生成ではCPU使用時間がすべて誤差であるため、処理の負荷の割に、JPEGと同じファイルサイズで、JPEGより高い画質の得られるAVIFを採用する意義があることが分かった。

JPEG

変換先のリンクを開いてスライドショーをめくっていくと差分が分かりやすいようにしている。EXIFは落としてある。

変換先 設定 サイズ削減率 サイズ比率(JPEG基準) CPU時間(ms) CPU時間比率(JPEG基準) 画質
JPEG->JPEG 画質80・サブサンプリング4:2:0・プログレッシブ 99.9% 100% 281 100% 悪い
JPEG->WEBP 画質80・サブサンプリング4:2:0 99.9% 72.4% 286 101.8% のっぺりしている
JPEG->JPEG XL 画質80・サブサンプリングなし 99.9% 78.0% 327 116.4% 若干悪い
JPEG->AVIF 画質80・サブサンプリング4:2:0・速度6/10 99.9% 100.0% 311 110.7% 非常に良い

PNG

変換先のリンクを開いてスライドショーをめくっていくと差分が分かりやすいようにしている。

変換先 設定 サイズ削減率 サイズ比率(JPEG基準) CPU時間(ms) CPU時間比率(JPEG基準) 画質
PNG->JPEG 画質80・サブサンプリング4:2:0・プログレッシブ 99.6% 100% 493 100% ノイズが目立つ
PNG->WEBP 画質80・サブサンプリング4:2:0 99.7% 78.2% 503 102.0% JPEGよりノイズが少ない
PNG->JXL 画質80・サブサンプリングなし 99.7% 81.4% 600 121.7% ややノイズがある
PNG->AVIF 画質80・サブサンプリング4:2:0・速度6/10 99.6% 92.0% 569 115.4% 非常に鮮明

実行時のコマンド

原寸用

変換方法 出力サイズ(KB) 合計CPU時間(ms)
magick src.jpeg -quality 80 -sampling-factor 2x2,1x1,1x1 -interlace Plane out_jpg_jpg_A.jpg 1,223.4 217
magick src.jpeg -quality 80 out_jpg_webp_A.webp 832.2 707
cjxl src.jpeg out_jpg_jxl_A.jxl -q 80 --lossless_jpeg=0 813.9 6,041
avifenc -q 80 -y 420 -s 6 --ignore-xmp src.jpeg out_jpg_avif_A.avif 995.4 2,939
magick src.png -quality 80 -sampling-factor 2x2,1x1,1x1 -interlace Plane out_png_jpg_A.jpg 640.7 207
magick src.png -quality 80 out_png_webp_A.webp 331.3 569
cjxl src.png out_png_jxl_A.jxl -q 80 458.9 3,897
avifenc -q 80 -y 420 -s 6 src.png out_png_avif_A.avif 316.6 4,317

サムネ用

サムネ用の画像は、事前に元画像を幅320pxへリサイズしてPNGに保存しているため、リサイズの処理時間は含んでいない。

変換方法 出力サイズ(KB) 合計CPU時間(ms)
magick mid_jpg_C.png -quality 80 -sampling-factor 2x2,1x1,1x1 -interlace Plane out_jpg_jpg_C.jpg 12.3 281
magick mid_jpg_C.png -quality 80 out_jpg_webp_C.webp 8.9 286
cjxl mid_jpg_C.png out_jpg_jxl_C.jxl -q 80 9.6 327
avifenc -q 80 -y 420 -s 6 mid_jpg_C.png out_jpg_avif_C.avif 12.3 311
magick mid_png_C.png -quality 80 -sampling-factor 2x2,1x1,1x1 -interlace Plane out_png_jpg_C.jpg 31.2 493
magick mid_png_C.png -quality 80 out_png_webp_C.webp 24.4 503
cjxl mid_png_C.png out_png_jxl_C.jxl -q 80 25.4 600
avifenc -q 80 -y 420 -s 6 mid_png_C.png out_png_avif_C.avif 28.7 569

確認用スクリプトと集計結果の全データ

詳細な全集計データには、本記事書いたデータの元ネタを格納している。

また以下に集計で使ったスクリプトも置いている。

確認用スクリプトのソースコード
#!/usr/bin/env bash
# 画像ダイエット ベンチマーク
# 使い方: src.jpeg / src.png と同じフォルダで  bash bench.sh
# 結果:   result.tsv(ファイル名・サイズKB・実時間ms・CPU時間ms・CPU使用率%)→ Excel の「測定結果」シート A1 に貼り付け
#         各コマンドのエラー出力は bench.log に残る
# Copyright Lycolia Rizzim All rights reserved.
# https://lycolia.info/

set -u

SRC_JPG=src.jpeg
SRC_PNG=src.png
RUNS=${RUNS:-3}        # 各コマンドを何回測るか(中央値を記録)。例: RUNS=5 bash bench.sh
OUT=result.tsv
LOG=bench.log

printf 'ファイル\tサイズ(KB)\t実時間(ms)\tCPU時間(ms)\tCPU使用率(%%)\n' > "$OUT"
: > "$LOG"

kb() { awk -v s="$(stat -c %s "$1")" 'BEGIN{printf "%.1f", s/1024}'; }

# measure 出力ファイル コマンド...
# bash の time で 実時間・user・sys を取り、CPU時間 = user + sys、CPU使用率 = CPU時間 / 実時間
measure() {
  local out=$1; shift
  local tf rows=() i line real user sys rc
  tf=$(mktemp)
  for ((i = 0; i < RUNS; i++)); do
    rm -f "$out"
    local TIMEFORMAT=$'%3R %3U %3S'
    { time "$@" >>"$LOG" 2>&1; rc=$?; } 2>"$tf"
    if (( rc != 0 )); then
      printf '%s\tERROR\t\t\t\n' "$out" >> "$OUT"
      echo "失敗: $out(bench.log を確認)" >&2
      rm -f "$tf"; return
    fi
    read -r real user sys < "$tf"
    rows+=( "$(awk -v r="$real" -v u="$user" -v s="$sys" 'BEGIN{printf "%d %d", r*1000, (u+s)*1000}')" )
  done
  rm -f "$tf"
  # 実時間の中央値の回を採用(CPU時間も同じ回の値)
  line=$(printf '%s\n' "${rows[@]}" | sort -n -k1,1 | sed -n "$(( (RUNS + 1) / 2 ))p")
  read -r real cpu <<< "$line"
  local pct
  pct=$(awk -v r="$real" -v c="$cpu" 'BEGIN{ if (r > 0) printf "%.0f", c / r * 100; else print "" }')
  printf '%s\t%s\t%s\t%s\t%s\n' "$out" "$(kb "$out")" "$real" "$cpu" "$pct" >> "$OUT"
  echo "完了: $out  実${real}ms  CPU${cpu}ms (${pct}%)" >&2
}

# 入力ファイルのサイズ(時間は空欄)
printf '%s\t%s\t\t\t\n' "$SRC_JPG" "$(kb "$SRC_JPG")" >> "$OUT"
printf '%s\t%s\t\t\t\n' "$SRC_PNG" "$(kb "$SRC_PNG")" >> "$OUT"

JPG_OPT=(-quality 80 -sampling-factor 2x2,1x1,1x1 -interlace Plane)
AVIF_OPT=(-q 80 -y 420 -s 6)

for tag in jpg png; do
  if [[ $tag == jpg ]]; then src=$SRC_JPG; else src=$SRC_PNG; fi

  # 前処理(B: メタデータ除去 / C: さらに幅320px)
  measure "mid_${tag}_B.png" magick "$src" -strip "mid_${tag}_B.png"
  measure "mid_${tag}_C.png" magick "$src" -strip -resize '320x>' "mid_${tag}_C.png"

  # JPEG
  measure "out_${tag}_jpg_A.jpg" magick "$src" "${JPG_OPT[@]}" "out_${tag}_jpg_A.jpg"
  measure "out_${tag}_jpg_B.jpg" magick "mid_${tag}_B.png" "${JPG_OPT[@]}" "out_${tag}_jpg_B.jpg"
  measure "out_${tag}_jpg_C.jpg" magick "mid_${tag}_C.png" "${JPG_OPT[@]}" "out_${tag}_jpg_C.jpg"

  # WebP
  measure "out_${tag}_webp_A.webp" magick "$src" -quality 80 "out_${tag}_webp_A.webp"
  measure "out_${tag}_webp_B.webp" magick "mid_${tag}_B.png" -quality 80 "out_${tag}_webp_B.webp"
  measure "out_${tag}_webp_C.webp" magick "mid_${tag}_C.png" -quality 80 "out_${tag}_webp_C.webp"

  # JXL
  if [[ $tag == jpg ]]; then
    measure "out_jpg_jxl_A.jxl" cjxl "$src" out_jpg_jxl_A.jxl -q 80 --lossless_jpeg=0
    measure "out_jpg_jxl_L.jxl" cjxl "$src" out_jpg_jxl_L.jxl          # ロスレス再圧縮
  else
    measure "out_png_jxl_A.jxl" cjxl "$src" out_png_jxl_A.jxl -q 80
  fi
  measure "out_${tag}_jxl_B.jxl" cjxl "mid_${tag}_B.png" "out_${tag}_jxl_B.jxl" -q 80
  measure "out_${tag}_jxl_C.jxl" cjxl "mid_${tag}_C.png" "out_${tag}_jxl_C.jxl" -q 80

  # AVIF
  measure "out_${tag}_avif_A.avif" avifenc "${AVIF_OPT[@]}" --ignore-xmp "$src" "out_${tag}_avif_A.avif"
  measure "out_${tag}_avif_B.avif" avifenc "${AVIF_OPT[@]}" "mid_${tag}_B.png" "out_${tag}_avif_B.avif"
  measure "out_${tag}_avif_C.avif" avifenc "${AVIF_OPT[@]}" "mid_${tag}_C.png" "out_${tag}_avif_C.avif"
done

echo "結果を $OUT に書き出しました(Excel の「測定結果」シート A1 に貼り付け)" >&2

あとがき

JPEG XLはEdgeが対応していないので、どのみち対応しないのだが、これまでの画像フォーマットのすべてを過去にするみたいな大層な謳い文句を見かけたので計測することにした。

WebPは昔計測したことがあり、画質のわりに容量が小さくならないことは知っていたが、計測したデータを残していなかったので、今回データを残す意味合いで測ることにした。

本命はJPEGのメタデータを削ってどこまで減らせるかと、AVIFを活用できるかが焦点だった。結果としてよいデータが取れたので良かったと思う。

またJPEGのファイル構造が知れたことはいい勉強になったし、今回の記事でテキストリンクに紐づいた画像をポップアップ表示させる必要が出たので、その改修ができたのも収穫だった。詳しくはまたリリースノートの記事を作って書くが、実はテキストリンクをポップアップさせるのは前からやりたかったのだが、脳裏から消えていた。

あとどうでもいい話として、過去にStableDiffusionのベンチマークで作ってたうちの子が劇的に麗しくなったのが見れたのもいい収穫だった。何せベンチマークする時くらいしか見てなかったので…。

ひとまずは次期バージョンでJPEGの不要なXMP落としとavifサムネイルに対応していきたい。レンタルサーバー向け対応はどうするかなぁ…。恐らくさくらのレンサバでもやろうと思えば対応自体はできそうな気はするが…。例えば利用者がavifencを自前ビルドするなど…w(そもそも今このLyndbaumのユーザーは私しかいないはずなのでどうでもいい話ではあるが…w)