Pillow 12.1.0 の PNG 保存が592.98msかかった話
この記事の見出し
Pillow 12.1.0 で6000x4000のランダム画像を扱うと、PNG 保存の中央値が592.98ms になる。
いちばん面食らったのはリサイズそのものではなく、1920px 幅へ縮小してから保存している PNG が、6000x4000 のまま保存した JPEG より重かった点だ。画素数を減らした側が重いという結果になった。
JPEG 保存は184.42ms なので、PNG 保存は3.22倍かかっている。画素数を減らしてから書き出すほうが軽く見えるはずで、この逆転はかなり直感に反する。測定日は2026年6月16日。この時点では、保存形式と素材の相性をまだ切り分けられていなかった。
タイムスタンプは2026-06-16T20:18:00。マシンは Surface Pro 11th Edition、SoC は Snapdragon X 12-core X1E80100、OS は Windows 11 Pro 10.0.26200 の ARM64 版。Python は3.12.10で、Pillow は12.1.0 を使った。
ランダム画像を同じ素材にした
6000x4000 のランダム画像を作り、同じ入力から処理ごとに3回ずつ時間を測った。比較したのは LANCZOS と BILINEAR の縮小、90度回転、グレースケール変換、JPEG 保存、PNG 保存だ。保存処理だけはファイル形式の圧縮が入るので、単純なメモリコピーとは別物として読む必要がある。
実際に見ていた処理は、だいたい次の形だ。記事側では説明用に短くしている。
from PIL import Image
img = Image.fromarray(random_rgb_6000x4000)
small_lanczos = img.resize((1920, 1280), Image.Resampling.LANCZOS)
small_bilinear = img.resize((1920, 1280), Image.Resampling.BILINEAR)
rotated = img.rotate(90, expand=True)
gray = img.convert("L")
img.save("out.jpg", quality=85)
small_lanczos.save("out.png")
元画像は2400万画素で、中央値を元画素数で割ると、LANCZOS は1画素あたり9.75ns、BILINEAR は3.92ns、90度回転は3.23ns、グレースケール変換は1.84ns になる。公平な比較ではなく、どの処理が支配的かをつかむためのざっくりした物差しだ。JPEG 保存は7.68ns、PNG 保存は24.71ns という元画像換算になる。PNG は実際には縮小後の画像を書いているので、この割り算だけでは公平な比較にならない。
保存だけが別の重さを持つ
3回分をそのまま置く。中央値だけを見ると見えなくなる揺れがあるため、runs_ms も残した。
| 処理 | runs_ms | median_ms |
|---|---|---|
| resize_lanczos_1920 | 215.54 / 293.73 / 233.89 | 233.89ms |
| resize_bilinear_1920 | 85.09 / 94.18 / 138.70 | 94.18ms |
| rotate_90 | 77.62 / 104.38 / 70.18 | 77.62ms |
| to_grayscale | 56.06 / 44.15 / 39.20 | 44.15ms |
| save_jpeg_q85 | 170.00 / 196.83 / 184.42 | 184.42ms |
| save_png_1920 | 623.08 / 572.05 / 592.98 | 592.98ms |
出力サイズは jpeg_bytes が18,107,176バイト、png_bytes が6,195,646バイトだった。注意したいのは、JPEG は6000x4000 のまま、PNG は1920x1280 に縮小後という条件の違いだ。バイト数だけ見ると PNG のほうが小さいが、縮小後の PNG は245万7600画素しかない。1画素あたりでは約2.52バイトになる。JPEG は2400万画素で18,107,176バイトなので、約0.75バイト。ランダムノイズ相手の PNG がかなり苦しいことは、この差だけでも見える。
LANCZOS と BILINEAR の差
リサイズは、LANCZOS が233.89ms、BILINEAR が94.18ms だった。差は2.48倍で、画質のために LANCZOS を選ぶ場面は多いが、サムネイルを大量に作る処理ではこの差がそのまま待ち時間へ乗る。
個人的には、ここがいちばん実運用へつながる数字だった。1本や2本の画像なら233.89ms でも気にならない。けれど1000本を処理するバッチでは、同じ縮小でも LANCZOS を選ぶだけで積み上がってしまう。BILINEAR の94.18ms が許せる見た目なら、先にそちらを試す価値がありそうだ。
ただし、今回の入力はランダム画像だった。写真、スクリーンショット、図版ではキャッシュの効き方も圧縮のされ方も変わってくる。Pillow の Resampling だけを見たいなら、保存とは切り離して測った今回の表で十分だ。画質と容量まで含めた判断には材料が足りない。
PNG が遅すぎる理由と測定の失敗
この測定は、PNG 保存の評価としては失敗だった。何を同じにして何を変えたのかをそろえないまま形式名だけを並べると、面白い逆転は出ても、次に自分が使える判断材料にはなりにくい。後から読み直して痛いところだ。
ランダムノイズは PNG にとってかなり悪い素材だった。PNG は近い色や繰り返しを利用して小さくする形式なので、隣の画素と関係が薄いランダム画像では圧縮が効きにくい。実際に png_bytes は6,195,646バイトまで膨らんだ。縮小後でこの大きさなら、圧縮器が楽をできていないと見るほうが自然だろう。
さらに悪いのは、JPEG と PNG で保存前の画素数をそろえていないことだ。JPEG は6000x4000 のまま、PNG は1920x1280 へ縮小後である。処理時間だけを見ると「小さい PNG が大きい JPEG より遅い」という面白い逆転になるが、これは形式差と入力差を混ぜていて、保存形式だけを比べたように読むとかなり危ない。比較実験としては間違いだった。気づくまで、かなり危ない読み方をしていたと思う。
この失敗に気づくまで、最初は PNG が単純に遅いという話に寄せそうになった。けれど jpeg_bytes と png_bytes を見比べると、少なくとも「PNG 保存は常にこのくらい遅い」と書くのは無理がある。スクリーンショットのように同じ色が広く続く画像なら、まったく違う結果になるはずだ。次に測るなら、写真、UI スクリーンショット、単色の多い図版を分ける。
回転とグレースケールは素直だった
90度回転は77.62ms、グレースケール変換は44.15ms だった。このあたりは驚くほどの話ではない。回転は画素の並べ替え、グレースケールは各画素の変換なので、メモリ帯域と実装の素直な勝負に見える。
2400万画素を44.15ms でグレースケール化するなら、元画像換算で1画素あたり1.84ns。90度回転の77.62ms は3.23ns。Surface Pro 11th Edition の Snapdragon X でこの程度なら、画像1枚の前処理としては軽い部類に入る。今回の表では PNG 保存の592.98ms や LANCZOS の233.89ms に比べると主役ではない。
使い分けをどう変えるか
手元のスクリプトで大量のサムネイルを作るなら、まず保存形式とリサイズ方式を分けて測っておきたい。LANCZOS と BILINEAR の2.48倍差は、見た目の許容ラインを決める材料になる。PNG 保存の592.98ms は強烈だが、この数字だけで PNG を避けるのは危ない。素材がランダムノイズだった影響が大きいからだ。今回の入力がランダムノイズだったせいで、PNG に不利な条件を自分で作っている。形式の比較をしたつもりで、実際には素材の圧縮しにくさも一緒に測ってしまった。
運用としては、写真系は JPEG を基準にし、UI 画像や透過が必要な素材だけ PNG を検討対象に回す。サムネイル生成では BILINEAR で下書きを作り、必要な箇所だけ LANCZOS を使う。そういう雑な二段構えでも、処理本数が増えると効く。毎回最高画質で全画像を通すより、先に用途で分けるほうが待ち時間を説明しやすい。
今回の収穫は、Pillow 12.1.0 が ARM64 Windows で普通に動いたことより、測り方の粗さが数字にそのまま出たことだった。次はランダムノイズではなく、実際にサイトで使う画像を混ぜて測り直してみる。