[Python] GIF画像を保存するときの減色について(PIL.Image.quantize)

WordPressやHTMLで作ったWebページに動画を入れようと考えると、アニメーションGIFなら普通の画像と同じ手順で簡単に入れられるので、本サイトでは列車の編成写真や画面操作の説明などにアニメーションGIFをよく使っています。そのとき必要になるのが256色への減色処理(色の量子化)ですが、PythonのPillowにはいくつか方法があるので、実際の画像で試してみました。

また、本ページの最後にはアニメーションGIFのチラつきを防止する方法についても記しておきます。

Pillowを使えばGIFの作成はとても簡単

オリジナルのPNG画像
オリジナルのPNG画像は2018年5月に撮影した500 TYPE EVAを使用

フルカラーの写真からGIF(またはアニメーションGIF)を作るときは256色に減色する処理が必要ですが、PythonのPillowを使うとGIF形式で保存するだけで減色処理が暗黙のうちに行われます。

from PIL import Image
image = Image.open('/path/to/fullcolor-image.png')
image.save('/path/to/p-mode-image.gif')
フルカラーのPNG画像から作ったGIF画像(デフォルト)

上の結果を見ると、概ねいい感じに256色に変換されているように見えますが、運転席下の緑色がブロック状の模様になっているのが気になります。そこで、減色処理を変えることでこの部分がどうなるか試してみました。

Image.quantizeを使って減色処理の方法を色々試してみる

上のコードでは減色処理は明示的に行っていませんが、Image.quantizeを使えば減色処理を明示的に行うことができます。

# imageは上のコードと同じPNG画像
image.quantize(method=Image.Quantize.MEDIANCUT).save('/path/to/mediancut.gif')
image.quantize(method=Image.Quantize.MAXCOVERAGE).save('/path/to/maxcoverage.gif')
image.quantize(method=Image.Quantize.FASTOCTREE).save('/path/to/fastoctree.gif')

methodパラメータにはImage.Quantizeの以下の値を指定できます。品質、速度、特徴・用途例は、AIによるまとめ(参考)です。

method名称品質速度特徴・用途例
MEDIANCUTメディアンカット★★★★☆★★☆☆☆バランス良い。写真・自然画像に最適
MAXCOVERAGE最大被覆★★★☆☆★★☆☆☆主要色優先。ロゴやイラスト向き
FASTOCTREE高速オクトツリー★★☆☆☆★★★★★高速処理が必要な用途向け

なお、Pillowのドキュメントを読むとLIBIMAGEQUANTという値もありますが、私の環境(macOS上でuvを使って導入したPython 3.12)では有効になっていませんでしたので割愛します。

写真で減色処理をいろいろ試した場合

上記のコードで出力したGIF画像は以下のとおりです。上から順にMEDIANCUTMAXCOVERAGEFASTOCTREEで出力しています。

method=Image.Quantize.MEDIANCUTで出力したGIF画像
method=Image.Quantize.MAXCOVERAGEで出力した画像
method=Image.Quantize.FASTOCTREEで出力した画像

MEDIANCUTquantizeを明示的に使わずに作ったGIFファイルと同じ結果です。MEDIANCUTはバランスよく写真・自然画像に最適とのことでしたが、他の方法と比べると、車体の上から下にかけてのグラデーションは滑らかです。しかし、左上の屋根の赤い部分の色がオリジナルとは異なっていることに気付きました。運転席下の緑色の部分も気になります。

MAXCOVERAGEはグラデーションがくっきりと階段状に分かれてしまいました。写真には使わないほうが良さそうです。

FASTOCTREEは高速処理向けで品質は期待できないと思っていましたが、屋根の赤色はオリジナルに近い色合いで、緑色の部分もMEDIANCUTよりは気にならない印象です。思ったほど悪くないなと思いました。

アプリのスクリーンショットで減色処理をいろいろ試した場合

自分がアニメーションGIFで作成したいものは、写真の動画よりもアプリの操作画面の説明で使う機会が多いと考えていますので、次はiPhoneのスクリーンショットで減色処理を試してみました。順に、オリジナル(PNG画像)、MEDIANCUTMAXCOVERAGEFASTOCTREEの画像です。

オリジナルのPNG画像
MEDIANCUT
MAXCOVERAGE
FASTOCTREE

オリジナルのPNG画像と比べるとMEDIANCUTは他の方法よりも色が変わってしまうのですが、交互に見比べない限り分からない程度ではないでしょうか。グラデーションが滑らかで違和感が少ないと感じがします。

FASTOCTREEはオリジナルの色に近いと思います。M4 Pro MacBook Proのディスプレイで目視判断していますので、他のディスプレイでは違った見え方になる可能性はあります。

参考までに、減色方法によるファイルサイズの違い

それぞれ良し悪しはあるものの、用途からするとMEDIANCUTがもっとも良さそうです。

しかし、いろいろ試していくうちに、それぞれの方法ではGIF画像のファイルサイズがかなり違うことに気付きました。今回試した画像ファイルのファイルサイズをまとめると以下のとおりです。

画像オリジナルMEDIANCUTMAXCOVERAGEFASTOCTREE
新幹線の写真484KB163KB107KB128KB
アプリの画面159KB80KB40KB41KB

今どきのネット回線やストレージ容量なら画像ファイルサイズを気にする必要はないだろうと思っていますが、アプリ画面のスクリーンショットではMEDIANCUTMAXCOVERAGEで2倍の差が生じているので、GIF画像(アニメーションGIF)を多用すると、Webブラウザの表示速度に影響しそうです。

おまけ:アニメーションGIFのチラつきを防止する方法

GIFは全フレームで共通の256色パレットを使用します。そのためフレームごとにquantizeすると、フレーム間でパレットが変わって色がチカチカする(いわゆる「色ぶれ」)現象が起きます。

これを防ぐために、いったん全フレームを結合した画像を作ってquantizeし、そのパレットを使って各フレームをquantizeします。以下はPillowを使って実装したPythonのスクリプトの例です。

from PIL import Image

# フレームを読み込む(例として frame_000.png, frame_001.png ...)
frames = [Image.open(f"frame_{i:03}.png").convert("RGBA") for i in range(10)]

# まず全フレームを1枚に結合してパレット抽出用のマスター画像を作る
# ※単純に横に連結する例
width, height = frames[0].size
combined = Image.new("RGBA", (width * len(frames), height))

for i, frame in enumerate(frames):
    combined.paste(frame, (i * width, 0))

# パレット作成(256色に量子化)
palette_image = combined.quantize(colors=256, method=Image.Quantize.MEDIANCUT)

# 各フレームを共通パレットで量子化
quantized_frames = []
for frame in frames:
    pal_frame = frame.quantize(
        palette=palette_image, 
        dither=Image.Dither.FLOYDSTEINBERG
    )
    quantized_frames.append(pal_frame)

# GIFとして保存
quantized_frames[0].save(
    "out.gif",
    save_all=True,
    append_images=quantized_frames[1:],
    loop=0,
    duration=100,  # 各フレーム100ms
    optimize=False,
    disposal=2,    # 前のフレームを消去して次を描画(透過扱い)
)

以下は全フレームで共通パレットを使って生成したアニメーションGIFの例です。1両ごとに様々な色でラッピングされた現美新幹線ですが、このアニメーションGIFに色のチラつきは起きていないと思います。

タイトルとURLをコピーしました