Python：pngをgifに変換する（アニメーション・透過対応）

# やりたいこと
* Pythonを使って、pngをgifに変換したい
* 透過に対応したい
* apng（アニメーションpng）ならgifアニメーションに変換したい

# 環境
* Windows11 64bit
* Python 3.10.5

Pythonのインストールは済んでいる状態とします。

# pngとgifの違い
どちらも画像ファイルの拡張子です。
違いについて軽く確認しておきます。

* gif
	* 8ビットカラー（256色）
	* 特定のピクセルを透過指定し、背景画像が透けて見えるようにできる
	* 半透明は表現できない

* png
	* 3種類のタイプが存在する
		* PNG-8：8ビットカラー（256色）
		* PNG-24：24ビットカラー（約16,77万色）
		* PNG-32：24ビットカラー＋8ビットのアルファチャンネル
	* 8ビットのアルファチャンネルによって半透明にできる

# pngからgifへの変換
PythonのPillowというライブラリを使用します。
Pillowとは、画像ファイルの読み込み・操作・保存の機能を提供するフリーのライブラリです。
Python Imaging Library（略称PIL）の後継であり、PILは開発が停止していますが、Pillowは2022年8月現在も開発が進められています。

Pillowリファレンス：[Pillow (PIL Fork) 9.2.0 documentation](https://pillow.readthedocs.io/en/stable/)
PILのwiki：[Python Imaging Library - Wikipedia](https://ja.wikipedia.org/wiki/Python_Imaging_Library#cite_note-pillow-4)

もしPillowをインストールしていない場合は、コマンドプロンプト上で以下のコマンドを実行し、インストールしておいてください。

```cmd:コマンドプロンプト
pip install pillow
```

## 静止画pngの変換
静止画の透過png画像を、透過gif画像に変換するサンプルコードです。

```python:python
# パッケージをインポートする
from PIL import Image

# pngを取得する
img_png = Image.open(r'C:\test\filename.png')

# RGBAに変換する
img_png = img_png.convert('RGBA')

# RGBAのalpha（透過）を取得する
alpha = img_png.getchannel('A')

# alphaのマスクを取得する
mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0)

# gifへ変換するために減色する
img_gif = img_png.quantize(colors=256)

# gifにマスクを貼り付ける
img_gif.paste(im=255, mask=mask)

# 透過gifをエクスポートする
img_gif.save(r'C:\test\filename.gif', transparency=255)
```

### 処理内容
処理内容について説明します。

* パッケージをインポートする

	```python:python
	# パッケージをインポートする
	from PIL import Image
	```

	PillowパッケージからImageモジュールをインポートします。
	インポートするパッケージの名前は `Pillow` ではなく `PIL` なので注意です。
	Imageモジュールはファイルから画像を読み込んだり新しい画像を作成できるモジュールです。

* pngを取得する

	```python:python
	# pngを取得する
	img_png = Image.open(r'C:\test\filename.png')
	```

	指定したパスのファイルを取得します。文字列の前に「r」をつけることによって、「\」のエスケープシーケンスが行われず、そのまま文字列として扱われるようになります。

* RGBAに変換する

	```python:python
	# RGBAに変換する
	img_png = img_png.convert('RGBA')
	```

	`img_png.convert('RGBA')` で、画像をRGBAに変換します。RGBAとは、透過マスク付きの色を表す形式です。
	
	R：赤（Red）
	G：緑（Green）
	B：青（Blue）
	A：透明度（Alpha）

	上記の4つの組み合わせで色を表現します。

* RGBAのalpha（透過）を取得する

	```python:python
	# RGBAのalpha（透過）を取得する
	alpha = img_png.getchannel('A')
	```
	
	`.getchannel('A')` で、アルファチャンネルの画像を取得し、変数 `alpha` に代入します。

* alphaのマスクを取得する

	```python:python
	# alphaのマスクを取得する
	mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0)
	```

	8ビット（256色）のアルファチャンネルから、128以下をすべて255にそれ以外を0に変換し、変数 `mask` に代入します。

* gifへ変換するために減色する

	```python:python
	# gifへ変換するために減色する
	img_gif = img_png.quantize(colors=256)
	```

	png画像を256色に変換します。

* gifにマスクを貼り付ける

	```python:python
	# gifにマスクを貼り付ける
	img_gif.paste(im=255, mask=mask)
	```

	256色に変換した画像の255ピクセルにマスクを貼り付けます。

* 透過gifをエクスポートする

	```python:python
	# 透過gifをエクスポートする
	img_gif.save(r'C:\test\filename.gif', transparency=255)
	```

	指定したパスにgifをエクスポートします。
	`transparency` は、255ピクセルを透過色に指定するという意味です。

## アニメーションpngの変換
透過apng（アニメーションpng）を、透過gifアニメーションに変換するサンプルコードです。

```python:python
# パッケージをインポートする
from PIL import Image, ImageSequence

# pngを取得する
img_png = Image.open(r'C:\test\filename.png')

# gifアニメーション格納用変数を定義する
imgs_gif = []

# フレームの数だけループする
for frame in ImageSequence.Iterator(img_png):
	# RGBAに変換する
	frame = frame.convert('RGBA')

	# RGBAのalpha（透過）を取得する
	alpha = frame.getchannel('A')

	# alphaのマスクを取得する
	mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0)

	# gifへ変換するために減色する
	img_gif = frame.quantize(colors=256)

	# gifにマスクを貼り付ける
	img_gif.paste(im=255, mask=mask)

	# 透過後のデータを格納する（参照渡しではなく値渡し）
	imgs_gif.append(img_gif.copy())

# apngの再生回数の情報を取得する
loop = img_png.info['loop']

# 透過gifをエクスポートする
imgs_gif[0].save(r'C:\test\filename.gif', save_all=True, append_images=imgs_gif[1:], loop=loop, optimize=False, transparency=255, disposal=2)
```

### 処理内容
処理内容について説明します。
[静止画pngの変換](#静止画pngの変換)で説明した内容については省きます。

* パッケージをインポートする

	```python:python
	# パッケージをインポートする
	from PIL import Image, ImageSequence
	```

	PillowパッケージからImageとImageSequenceモジュールをインポートします。
	ImageSequenceモジュールはアニメーションのフレームを処理できるモジュールです。

* gifアニメーション格納用変数を定義する

	```Python:Python
	# gifアニメーション格納用変数を定義する
	imgs_gif = []
	```

	アニメーションから1枚1枚の画像をリストで格納するための変数を定義します。

* フレームの数だけループする

	```Python:Python
	# フレームの数だけループする
	for frame in ImageSequence.Iterator(img_png):
	```

	`ImageSequence.Iterator(img_png)` で、アニメーションのフレームごとの画像をリストで取得します。
	取得したフレームごとの画像を1枚ずつ変換するために、ループします。

* 透過後のデータを格納する（参照渡しではなく値渡し）

	```Python:Python
	# 透過後のデータを格納する（参照渡しではなく値渡し）
	imgs_gif.append(img_gif.copy())
	```

	`imgs_gif.append()` で、透過gifに変換した画像ファイルをリストの末尾に追加します。
	リストへは値渡しします。 `.copy()` をつけないと参照渡しになります。
	参照渡しは変数の値そのものではなく、変数のメモリ番地を渡します。ループで同じ変数を使いまわすため、次のループで値が書き換わった場合、すでに代入済みの変数の値も変化してしまうので、それを防ぐために値渡しで代入する必要があります。

* apngの情報を取得する

	```Python:Python
	# apngの再生回数の情報を取得する
	loop = img_png.info['loop']
	```

	アニメーションpngのループ回数を取得します。loop=0は無限ループです。

* 透過gifをエクスポートする

	```Python:Python
	# 透過gifをエクスポートする
	imgs_gif[0].save(r'C:\test\filename.gif', save_all=True, append_images=imgs_gif[1:], loop=loop, optimize=False, transparency=255, disposal=2)
	```

	指定したパスにgifをエクスポートします。
	それぞれの引数の内容は以下です。

	`save_all` ：Trueの場合、画像のすべてのフレームを保存します。Falseの場合、最初のフレームのみ保存します。
	`append_images` ：追加のフレームとして追加する画像のリストを指定します。
	`loop` ：gifアニメーションをループする回数を指定します。
	`optimize` ：Trueの場合、未使用の色を削除してパレットの圧縮を試みます。
	`transparency` ：透過色を適用するピクセルを指定します。
	`disposal` ：フレームの処理方法を指定します。2を指定することで、フレームごとに背景色へ戻してから描画します。disposalを指定しないと、前フレームの画像が残り続けます。

## 実行ファイルにドロップしたpngの変換
コマンドライン引数に渡したpngファイルを、gifファイルに変換するサンプルコードです。
サンプルコードをテキストファイルに貼り付け、拡張子を「.py」にして保存すれば使用できます。

おおまかな処理内容は以下です。
* 「.py」にpngをドラッグ＆ドロップするとpngファイルと同じディレクトリにgifファイルをエクスポートする
* 静止画とアニメーションの両方対応可能

```python:python
# パッケージをインポートする
import sys, os
from tkinter import messagebox
from PIL import Image, ImageSequence

# 受け取ったコマンドライン引数の数だけループする
for fp in sys.argv[1:]:
	# パス + 拡張子に分解して取得する
	root, ext = os.path.splitext(fp)

	# もし拡張子がpng以外なら処理を終えて次のループへ
	if ext != '.png':
		messagebox.showinfo('警告', '処理できません。対応する拡張子は png のみです。')
		continue

	# pngを取得する
	img_png = Image.open(fp)

	# pngがアニメかどうかで分岐する
	if img_png.is_animated:
		######## pngがアニメのとき ########
		# gifアニメーション格納用変数を定義する
		imgs_gif = []

		# フレームの数だけループする
		for frame in ImageSequence.Iterator(img_png):
			# RGBAに変換する
			frame = frame.convert('RGBA')

			# RGBAのalpha（透過）を取得する
			alpha = frame.getchannel('A')

			# alphaのマスクを取得する
			mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0)

			# gifへ変換するために減色する
			img_gif = frame.quantize(colors=256)

			# gifにマスクを貼り付ける
			img_gif.paste(im=255, mask=mask)

			# 透過後のデータを格納する（参照渡しではなく値渡し）
			imgs_gif.append(img_gif.copy())

		# apngの再生回数の情報を取得する
		loop = img_png.info['loop']

		# 透過gifをエクスポートする
		imgs_gif[0].save(root + '.gif', save_all=True, append_images=imgs_gif[1:], loop=loop, optimize=False, transparency=255, disposal=2)

	else:
		######## pngが静止画の時 ########
		# RGBAに変換する
		img_png = img_png.convert('RGBA')
		
		# RGBAのalpha（透過）を取得する
		alpha = img_png.getchannel('A')

		# alphaのマスクを取得する
		mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0)

		# gifへ変換するために減色する
		img_gif = img_png.quantize(colors=256)

		# gifにマスクを貼り付ける
		img_gif.paste(im=255, mask=mask)

		# 透過gifをエクスポートする
		img_gif.save(root + '.gif', transparency=255)

	# 処理終了のメッセージを出す
	messagebox.showinfo('メッセージ', 'gifをエクスポートしました。')
```

### 処理内容
処理内容について説明します。
[静止画pngの変換](#静止画pngの変換)と[アニメーションpngの変換](#アニメーションpngの変換)で説明した内容については省きます。

* パッケージをインポートする

	```python:python
	# パッケージをインポートする
	import sys, os
	from tkinter import messagebox
	from PIL import Image, ImageSequence
	```

	パッケージをインポートします。それぞれのパッケージの特性は以下です。

	`sys` ：コマンドライン引数をうけとるため使用します。
	`os` ：パスから拡張子と拡張子以外へ分解するために使用します。
	`messagebox` ：メッセージボックスを出すために使用します。
	`Image` ：画像処理をするために使用します。
	`ImageSequence` ：フレーム処理をするために使用します。

* 受け取ったコマンドライン引数の数だけループする

	```python:python
	# 受け取ったコマンドライン引数の数だけループする
	for fp in sys.argv[1:]:
	```

	受け取ったコマンドライン引数の数だけループします。
	`sys.argv[0]` は自分自身のパスを返すため、1以降をループします。

* パス + 拡張子に分解して取得する

	```python:python
	# パス + 拡張子に分解して取得する
	root, ext = os.path.splitext(fp)
	```

	コマンドライン引数へ渡したファイルのパスを、拡張子とそれ以外に分解します。
	`C:\test\filename.png` の場合、 `C:\test\filename` と `.png` に分解します。

* もし拡張子がpng以外なら処理を終えて次のループを実行する

	```python:python
	# もし拡張子がpng以外なら処理を終えて次のループを実行する
	if ext != '.png':
		messagebox.showinfo('警告', '処理できません。対応する拡張子は png のみです。')
		continue
	```

	コマンドライン引数へ渡したファイルがpng以外の場合、処理を終了します。

* pngがアニメーションかどうかで分岐する

	```python:python
	# pngがアニメーションかどうかで分岐する
	if img_png.is_animated:
	```

	`img_png.is_animated` は、フレームが2枚以上（アニメーション）のとき、Trueを返します。静止画かアニメーションかで処理を分岐します。

* 処理終了のメッセージを出す

	```Python:Python
	# 処理終了のメッセージを出す
	messagebox.showinfo('メッセージ', 'gifをエクスポートしました。')
	```

	メッセージボックスを表示します。

# さいごに
apngからgifアニメーションに変換する方法がわからなくて苦労しました。
Pythonはあまり慣れていないので、書き方のお作法がよくわかっておらず、処理内容の説明も自信ないです。動くものは作れたので満足しています。

# 参考にしたサイト
[Python で PNG から 透過 GIF を生成する](https://qiita.com/motodimago/items/ac85e64dd602eeae7429)
[Image Module - Pillow (PIL Fork) 9.2.0 documentation](https://pillow.readthedocs.io/en/stable/reference/Image.html#module-PIL.Image)
[Python Tips: アニメーション GIF から静止画をまとめて抽出したい - Life with Python](https://www.lifewithpython.com/2017/10/python-extract-gif-frames.html)
[Pythonで画像の減色をする](https://water2litter.net/rum/post/python_pillow_quantize/)
[Python, Pillowで透過png画像を作成するputalpha | note.nkmk.me](https://note.nkmk.me/python-pillow-putalpha/)
[Python, PillowでアニメーションGIFを作成、保存 | note.nkmk.me](https://note.nkmk.me/python-pillow-gif/)
[python — プログラムでPythonでビデオまたはアニメーションGIFを生成しますか？](https://www.web-dev-qa-db-ja.com/ja/python/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E3%81%A7python%E3%81%A7%E3%83%93%E3%83%87%E3%82%AA%E3%81%BE%E3%81%9F%E3%81%AF%E3%82%A2%E3%83%8B%E3%83%A1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3gif%E3%82%92%E7%94%9F%E6%88%90%E3%81%97%E3%81%BE%E3%81%99%E3%81%8B%EF%BC%9F/957916038/)
[[備忘録] PythonのPILで透過GIFを作る時に後ろの画像を表示させない方法 - Qiita](https://qiita.com/sizumita/items/cf3beb85b80ab9f9d451)