PageSpeed Insightsがwebpかavifにして画像を軽くしろと言われるので、渋々ベンチをとってみた結果。JPEG, WebP, JPEG XL, AVIFで比較している。
比較では、画質を保ったままファイルサイズをどれだけ減らせるかに加えて、サーバーサイドでの変換にかかるCPU時間を重視した。本システムはリソースの限られた環境での運用を前提としており、変換が重いと画像のアップロードが終わるまでに時間がかかり、ストレスになるからだ。
以下に計測内容を残す。計測は3回行い、その中央値を出している。
変換に使った画像
計測に使った環境
| 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)