【Python】動画を GIF に変換するアプリを作成

「動画をGIFに変換するアプリを作成」ページのアイキャッチ

このページでは Python で「動画をアニメーション GIF に変換するアプリ」を作成する方法およびそのアプリのサンプルスクリプトについて解説していきます。

動画を GIF に変換するウェブサイトやフリーソフトもあったりしますが、Python なら OpenCV2 と PIL(Pillow)さえ入れておけば自分で簡単に作成することができちゃいます!

動作確認環境

このページで紹介するサンプルスクリプトは下記環境で動作確認を行っています

  • OS:macOS Catalina
  • Python:3.8
  • Tkinter:8.6
  • NumPy:1.18.2
  • OpenCV2:4.2.0
  • Pillow:7.1.1

作成するアプリ

今回作成するアプリは下の図のようなものになります。

トリミング領域を指定する様子

アプリの画面

アプリの画面は下記のようなウィジェットから構成されます。

動画をGIFに変換するアプリのウィジェット

アプリの動作

このアプリでは、ボタンを押したりマウス操作により下記を行うことができます。

  • 動画を読み込む
  • 作成する GIF の設定を受け付ける
  • 指定されたフレームの画像表示を行う
  • 動画を設定に従って GIF に変換する

動画を読み込む

「動画選択」ボタンをクリックすることで、動画ファイル(.MOV と .MP4)の選択を受け付け、選択された動画を読み込みます。

作成する GIF の設定を受け付ける

画面右側にあるテキストフィールドに値を入力したりチェックボックスにチェックすることで作成する GIF の設定を行うことができます。

GIFの設定画面

またキャンバス内をクリックしながらマウスを動かすことでトリミングを行い、選択した範囲のみを GIF 化することも可能です。

トリミング領域の指定

具体的に設定可能な項目は下記のようになります。

  • 開始フレーム:
    • GIF 化するフレームの開始
  • 終了フレーム:
    • GIF 化するフレームの終了
  • フレーム間隔:
    • GIF 化するフレームの間隔
  • FPS:
    • 1秒間に表示するフレームの数
      (フレーム間隔を考慮しない)
  • ループ回数:
    • GIF アニメのループ回数
      (0 以下を指定すれば無限ループ)
  • 最大幅:
    • GIF の幅の最大値(超える場合は自動的に縮小)
  • 最大高さ:
    • GIF の高さの最大値(超える場合は自動的に縮小)
  • ディザ有効:
    • GIF 化時にディザを行うかどうかの設定
  • トリミング領域:
    • GIF 化する領域(マウスで選択して設定)

少しこれらの設定について詳細を説明しておきます。

まず前提として、動画は簡単に言うと、複数枚の画像(フレームと言います)を連続して表示することで実現されます。

例えば下記の10枚のフレームから構成される動画があるとしましょう。

動画がフレームから構成される様子

「開始フレーム」と「終了フレーム」を設定することで、GIF アニメーションに含ませるフレームを限定することができます。

例えば「開始フレーム」を “1”、「終了フレーム」を “8” とした場合は、下の図で太枠で示したフレームのみがアニメーション GIF に含まれることになります。

開始フレームと終了フレームの指定

また「FPS」を設定することで、1秒間あたりに表示するフレームの枚数を設定することができます。例えば「FPS」を “4” に設定した場合は、1秒間あたりに4枚のフレームが表示されることになります。

FPSの設定

つまり各フレームが 250ms ずつ表示されることになります。

さらに「フレーム間隔」を設定することで、アニメーション GIF に含ませるフレームを間引くことが可能です。

例えば「フレーム間隔」を “2” に設定した場合は、2枚のうち1枚のみが GIF アニメーションに含まれるようになります。

フレーム間隔の設定

「フレーム間隔」を “1” を超える値に設定すると、フレームが間引かれるため、1秒間に表示されるフレーム数は「FPS」で設定した数よりも少なくなります。

また「トリミング領域」をマウス操作で指定することで、特定の領域のみアニメーション GIF に含ませることができます(指定しない場合は全体が GIF 化される)。

トリミング領域の設定

さらに「最大幅」と「最大高さ」を指定すれば、GIF 化する領域の幅と高さが「最大幅」「最大高さ」を超える場合に「最大幅」「最大高さ」に収まるように自動的にリサイズを行います(縦横同アスペクト比でリサイズ)。

最大高さと最大幅の設定

こんな感じで様々の設定を行うことができます。特に「最大幅」「最大高さ」「フレーム間隔」あたりの設定は、作成するアニメーション GIF のサイズを小さくしたい場合に便利だと思います。

指定されたフレームの画像表示を行う

さらに「表示フレーム」に正数を指定して「フレーム表示」ボタンをクリックすることで、その指定したフレームの画像をキャンバスに描画することも可能です。

表示フレームの設定

このフレーム表示を行いながら開始フレームと終了フレームを指定することで、より所望のアニメーション GIF を作りやすくなると思います。

動画を設定に従って GIF に変換する

「GIF保存」ボタンをクリックすれば、動画を GIF に変換することができます。このアプリのメイン機能ですね!

動画を GIF に変換する際に、作成する GIF の設定を受け付けるで説明した設定を読み込んで、その設定に基づいて GIF に変換を行います。

この変換された GIF は指定されたファイルパスに保存されます。

アプリのクラス設計

ではここからはアプリをどのように開発しているかについて解説していきたいと思います。

各クラスは「Model」と「View」と「Controller」の3つに分かれます。

Model」は動画や画像を扱うクラスで、「View」はアプリの見た目や描画を行うクラスです。「Controller」はユーザーからのイベント(ボタンクリック・マウス操作など)を受け付け、必要に応じて「Model」や「View」に処理の依頼を行うクラスになります。

Model クラス

Model クラスは下記の機能を提供します。

  • 動画オブジェクトの生成(create_video
  • 画像オブジェクトの生成(create_image
  • 動画情報の取得(get_videoinfo
  • 画像オブジェクトの取得(get_image
  • GIF への変換と保存(save_gif

動画オブジェクトの生成(create_video

Model クラスのオブジェクトは指定されたパスの動画から動画オブジェクトの生成を行います。

具体的には OpenCV2 の VideoCapture クラスのインスタンスの生成を行います。

動画オブジェクトの生成
self.video = cv2.VideoCapture(path)

画像オブジェクトの生成(create_image

また Model クラスのオブジェクトは、指定されたフレーム(num)を動画オブジェクトから読み込み、画像オブジェクトの生成を行います。

本アプリでは、この画像オブジェクトはキャンバス描画用の画像になります。

特定のフレームの読み込みは OpenCV2 の VideoCapture クラスが提供する set メソッドと read メソッドにより実現することができます。

フレームの読み込み
# 指定されたフレームにセット
ret = self.video.set(cv2.CAP_PROP_POS_FRAMES, num)

# 指定されたフレームを読み込み
ret, frame = self.video.read()

さらに、このフレームを下記で PIL 画像オブジェクトに変換しています。

PIL画像オブジェクトへの変換
# PIL イメージに変換
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(rgb_frame)

この変換については下記ページで解説していますので、詳しく知りたい方は読んでみてください。

画像オブジェクト変換方法の解説ページアイキャッチ【Python】PIL ⇔ OpenCV2 ⇔ Tkinter 画像オブジェクトの相互変換

最後に、この画像オブジェクトを指定されたサイズ(主にキャンバスのサイズに合わせて)にリサイズも行います。

動画情報の取得(get_videoinfo

Model クラスのオブジェクトは動画オブジェクトから動画の情報を取得する機能も提供します。

動画の情報は OpenCV2 の VideoCapture クラスが提供する get メソッドにより取得することが可能です。

例えばフレームの幅を取得するためには下記のように get メソッドを実行します。

動画情報の取得
video_info["width"] = int(self.video.get(cv2.CAP_PROP_FRAME_WIDTH))
フレーム数

どうも私の環境だとフレーム数がうまく取得できなかった(実際よりも大きい値が取得されてしまう)ため、実際にフレームが読み込めるかどうかでフレーム数を決定しています

詳細はサンプルスクリプトをご参照ください

画像オブジェクトの取得(get_image

Model クラスのオブジェクトが持つ画像オブジェクトを取得する機能も提供しています。

PIL 画像オブジェクトを Tkinter 用のものに変換し、変換後の画像オブジェクトを返却します。

GIF への変換と保存(save_gif

アプリのメイン機能である動画のアニメーション GIF への変換および GIF の保存も Model クラスが実行します。UI で設定された GIF の設定に基づいて変換を行います(GIF の設定は引数で渡される)。

アニメーション GIF ってどうやって作るの?と言う方は、是非下記のページをご参照ください。

PythonでのアニメーションGIF作成方法解説ページのアイキャッチPython PIL(Pillow)でアニメーション GIF を作成する方法

要は「アニメーションに含ませるフレームの用意」と「PIL.Image.Image クラスの save メソッドの実行」によりアニメーション GIF を作成することができます。

「アニメーションに含ませるフレームの用意」は、「開始フレーム」から「終了フレーム」に対し、「フレーム間隔」で下記を繰り返すことで実行しています(開始フレーム、終了フレーム、フレーム間隔は UI で設定されたもの)。

  • 動画オブジェクトからフレームの読み込み
  • フレームを PIL 画像オブジェクトに変換
  • 画像オブジェクトをトリミング
  • 画像オブジェクトをリサイズ
  • 画像オブジェクトの量子化(ディザ無効時のみ)
  • リストの末尾に画像オブジェクトを追加

フレームの取得は画像オブジェクトの生成(create_imageで解説した方法で実行できます。

またトリミングに関しては下記ページで、

画像トリミングアプリの開発方法の解説ページアイキャッチ【Python】画像のトリミング(クロップ・切り取り)アプリを作ってみる【GUI】

ディザ に関しては下記ページで解説していますので、詳しく知りたい方はこちらも読んでみてください。

PythonでのアニメーションGIF作成方法解説ページのアイキャッチPython PIL(Pillow)でアニメーション GIF を作成する方法

「アニメーションに含ませるフレームの用意」が終わったら、duration(各画像を表示する時間[ms])を「FPS」と「フレーム間隔」から計算し、save メソッドによりアニメーション GIF への変換および保存を行っています。

GIFの保存
# framesは画像オブジェクトの参照リスト
frames[0].save(
	path,
	save_all=True,
	append_images=frames[1:],
	duration=duration,
	loop=loop_num,
)

View クラス

View クラスは下記の機能を提供します。

  • ウィジェットの作成と配置(create_widgets
  • 動画情報の描画(draw_videoinfo
  • 画像の描画(draw_image
  • メッセージの描画(draw_message
  • 選択範囲の描画(draw_selection
  • ファイル選択画面表示(select_open_fileselect_save_file

ウィジェットの作成と配置(create_widgets

まず View クラスのオブジェクトはウィジェットの作成と配置を行います。

具体的には下図のようにウィジェットの配置を行なっています(一部省略しています)。

アプリの画面設計

たくさん Frame を配置していますが、これは全体レイアウトを整えるためです。

動画情報の描画(draw_videoinfo

また videoinfo_label ラベルに対して動画情報の描画も行います(といっても動画情報は Controller から渡されるので文字列を描画するだけ)。

画像の描画・メッセージの描画・選択範囲の描画

他にもアプリ画面に対して様々な描画も行います。この辺りは下記ページで解説していますのでこちらをご参照ください。

画像トリミングアプリの開発方法の解説ページアイキャッチ【Python】画像のトリミング(クロップ・切り取り)アプリを作ってみる【GUI】

ファイル選択画面表示(select_open_fileselect_save_file

またファイルを選択する際には、ファイル選択画面の表示も行います。

ファイル選択画面の表示については下記ページで解説していますので詳しく知りたい方はかきを参照してください。

ファイル選択画面表示の解説ページアイキャッチPython でファイル選択画面を表示する

ちなみに「動画選択」ボタンクリック時に選択できるファイルは .MOV と .MP4 に限定しています。

もし他の種類の動画を選択したい場合は Controller クラスの下記部分を編集してください。おそらく OpenCV2 で対応している動画ファイルであれば、.MOV や .MP4 同様にアニメーション GIF への変換が出来ると思います。

選択可能ファイル拡張子の設定
def push_load_button(self):
		'動画選択ボタンが押された時の処理'

		file_types = [
			("MOVファイル", "*.mov"),
			("MP4ファイル", "*.mp4"),
		]

Controller クラス

Controller は下記を行うクラスです。

  • イベントの受け付け(set_events
  • 「動画選択」ボタンクリック時の処理(push_load_button
  • 「GIF保存」ボタンクリック時の処理(push_save_button
  • 「フレーム表示」ボタンクリック時の処理(push_display_button
  • マウス操作時の処理(button_pressbutton_releasemouse_motion
  • 定期的な処理(timer

イベントの受け付け(set_events

Controller クラスの主な責務の1つがユーザーからのイベントの受け付けになります。ですので、View が設置したウィジェットに対してイベントの受け付け(bind)を行います。

イベント受け付けを詳しく知りたい方は是非下のページも読んでみてください。

tkinter解説ページのアイキャッtPython で Tkinter を使ってめちゃくちゃ簡単に GUI アプリを作る

「動画選択」ボタンクリック時の処理(push_load_button

「動画選択」ボタンクリックイベントが発生した際には Contoller クラスのオブジェクトは主に下記の処理を実行します。

  • View にファイル選択画面の表示を依頼(select_open_file
  • Model に動画オブジェクトの生成を依頼(create_video
  • Model に画像オブジェクトの生成を依頼(create_image
  • Model に動画情報の取得を依頼(get_videoinfo
  • 動画情報をテキストボックスに反映
  • View に動画情報の表示を依頼(draw_videoinfo

「GIF保存」ボタンクリック時の処理(push_save_button

「GIF保存」ボタンクリックイベントが発生した際には Contoller クラスのオブジェクトは主に下記の処理を実行します。

  • View にファイル選択画面の表示を依頼(select_save_file
  • UI での設定情報を取得して「GIF保存情報」を作成
  • Model にGIF への変換と保存を依頼(save_gif

「フレーム表示」ボタンクリック時の処理(push_display_button

「フレーム表示」ボタンクリックイベントが発生した際には Contoller クラスのオブジェクトは主に下記の処理を実行します。

  • UI から「表示フレーム」を取得
  • Model に画像オブジェクトの生成を依頼(create_image

マウス操作時の処理(button_pressbutton_releasemouse_motion

マウス操作時にはトリミング領域と使用するために選択範囲の記憶を行います。

トリミングアプリ開発でも同様のことを行っていますので、詳細を知りたい方は下記を参考にしてください。

画像トリミングアプリの開発方法の解説ページアイキャッチ【Python】画像のトリミング(クロップ・切り取り)アプリを作ってみる【GUI】

定期的な処理(timer

また Contoller クラスのオブジェクト一定間隔毎(100ms 毎)に下記の処理を実行します。

  • View に画像の描画を依頼(draw_image
  • View にメッセージの描画を依頼(draw_message
  • View に選択範囲の描画を依頼(draw_selection

スポンサーリンク

アプリのサンプルスクリプト

ここまで説明してきたアプリのサンプルスクリプトは下記になります。エラー処理などはあまりしていませんので、その辺りは運用でカバーしていただければと思います…。

動画->GIFへの変換アプリ
import tkinter
import tkinter.filedialog
from PIL import Image, ImageTk
import cv2


class Model():

	def __init__(self):

		# 動画オブジェクト参照用
		self.video = None

		# PIL画像オブジェクト参照用
		self.image = None

		# Tkinter画像オブジェクト参照用
		self.image_tk = None

		# 表示フレーム描画時の拡大率
		self.draw_ratio = 1

	def create_video(self, path):
		'動画オブジェクトの生成を行う'

		# pathの動画から動画オブジェクト生成
		self.video = cv2.VideoCapture(path)

	def create_image(self, size, num):
		'動画のnumフレーム目の画像を作成'

		# まだ動画を読み込んでいない場合のエラー処理
		if not self.video:
			return

		# 指定されたフレームにセット
		ret = self.video.set(cv2.CAP_PROP_POS_FRAMES, num)
		if not ret:
			return

		# 指定されたフレームを読み込み
		ret, frame = self.video.read()
		if not ret:
			return

		# PIL イメージに変換
		rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
		pil_image = Image.fromarray(rgb_frame)

		# 指定サイズに合わせて画像をリサイズ

		# 拡大率を計算
		ratio_x = size[0] / pil_image.width
		ratio_y = size[1] / pil_image.height

		if ratio_x < ratio_y:
			self.draw_ratio = ratio_x
		else:
			self.draw_ratio = ratio_y

		# リサイズ
		self.image = pil_image.resize(
			(
				int(self.draw_ratio * pil_image.width),
				int(self.draw_ratio * pil_image.height)
			)
		)


	def get_image(self):
		'Tkinter画像オブジェクトを取得する'

		if self.image is not None:
			# Tkinter画像オブジェクトに変換
			self.image_tk = ImageTk.PhotoImage(self.image)
		return self.image_tk

	def get_videoinfo(self):
		'動画の情報を取得する'

		if self.video is None:
			return None

		video_info = {}

		# opencv2で動画の情報を取得
		video_info["width"] = int(self.video.get(cv2.CAP_PROP_FRAME_WIDTH))
		video_info["height"] = int(self.video.get(cv2.CAP_PROP_FRAME_HEIGHT))
		video_info["fps"] = self.video.get(cv2.CAP_PROP_FPS)

		# なぜかフレーム数がうまく取得できないので
		# 実際に読み込めるかどうかでフレーム数を判断
		frame_num = int(self.video.get(cv2.CAP_PROP_FRAME_COUNT))

		for n in range(frame_num - 1, -1, -1):
			# 取得するフレームを設定
			self.video.set(cv2.CAP_PROP_POS_FRAMES, n)

			# フレームを取得
			ret, frame = self.video.read()
			if ret:
				break

		# nまで読み込めたのでフレーム数はn+1
		video_info["num_frame"] = n + 1

		return video_info

	def save_gif(self, path, save_info=None):
		'動画をGIFアニメーションに変換して保存'

		# save_infoからGIF保存時の設定を取得
		if save_info is not None:
			max_width = save_info["width"]
			max_height = save_info["height"]
			select = save_info["select"]
			loop_num = save_info["loop"] - 1
			dither = save_info["dither"]
			interval = save_info["interval"]
			frame_range = save_info["range"]
			fps = save_info["fps"]
		else:
			return

		# GIF保存する画像縦横範囲の設定
		if select:
			# 選択範囲を保存するように設定
			resized_select = (
				# リサイズされた画像に対して選択されているはずなので、
				# ここで拡大率を考慮してリサイズ前座標に変換する
				int(select[0] / self.draw_ratio),
				int(select[1] / self.draw_ratio),
				int(select[2] / self.draw_ratio),
				int(select[3] / self.draw_ratio)
			)
		else:
			# 画像全体を保存するように設定
			resized_select = (
				0,
				0,
				self.video.get(cv2.CAP_PROP_FRAME_WIDTH) - 1,
				self.video.get(cv2.CAP_PROP_FRAME_HEIGHT) - 1
			)

		# 選択範囲の縦横サイズを取得
		width = resized_select[2] - resized_select[0] + 1
		height = resized_select[3] - resized_select[1] + 1

		# 最大サイズを超えないようにリサイズ

		# 拡大率を計算
		ratio_x = max_width / width
		ratio_y = max_height / height

		if width > max_width or height > max_height:
			# 最大サイズに収まるように拡大率を設定
			# 拡大率は縦横同じとする
			if ratio_x < ratio_y:
				ratio = ratio_x
			else:
				ratio = ratio_y
		else:
			# 最大サイズにすでに収まっている場合はリサイズしない
			ratio = 1

		# リサイズ後の縦横サイズを設定
		resized_width = int(width * ratio)
		resized_height = int(height * ratio)

		# 開始フレームと終了フレームを取得
		start = frame_range[0]
		end = frame_range[1] + 1

		# 空のリストを作成(フレーム参照よう)
		frames = []

		# 1フレームずつ取得しながらGIFに保存する画像をリストに追加
		for i in range(start, end, interval):
			# 進捗を出力
			print(str((i - start) // interval) +
				  " / " + str((end - start) // interval))

			# 取得するフレームを設定
			self.video.set(cv2.CAP_PROP_POS_FRAMES, i)

			# フレームを取得
			ret, frame = self.video.read()
			if ret:

				# フレームからPIL画像オブジェクトを作成
				rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
				pil_image = Image.fromarray(rgb_frame)

				# 画像を選択範囲でトリミング
				cropped_pil_image = pil_image.crop(resized_select)

				# 画像を最大サイズに収まるようにリサイズ
				resized_pil_image = cropped_pil_image.resize(
					(
						resized_width,
						resized_height
					),
					resample=Image.LANCZOS
				)

				# ディザOFFの場合は事前にパレットカラーに変換しておく
				if not dither:
					final_pil_image = resized_pil_image.quantize(method=0)
				else:
					final_pil_image = resized_pil_image

				# 処理し終わったフレームをリストに追加
				frames.append(final_pil_image)

		# duration(ms)の計算
		spf = 1 / fps
		duration = 1000 * spf * interval

		# ループ回数を考慮してアニメーションGIFとして保存
		if loop_num > 0:
			frames[0].save(
				path,
				save_all=True,
				append_images=frames[1:],
				duration=duration,
				loop=loop_num,
			)
		elif loop_num == 0:
			frames[0].save(
				path,
				save_all=True,
				append_images=frames[1:],
				duration=duration,
			)
		else:
			frames[0].save(
				path,
				save_all=True,
				append_images=frames[1:],
				duration=duration,
				loop=0
			)


class View():

	def __init__(self, app, model):

		self.master = app
		self.model = model

		# アプリ内のウィジェットを作成
		self.create_widgets()

	def create_widgets(self):
		'アプリ内にウィジェットを作成・配置する'

		# キャンバスのサイズ
		canvas_width = 1000
		canvas_height = 600

		# キャンバスとボタンを配置するフレームの作成と配置
		self.main_frame = tkinter.Frame(
			self.master
		)
		self.main_frame.pack()

		# ラベルを配置するフレームの作成と配置
		self.sub_frame = tkinter.Frame(
			self.master
		)
		self.sub_frame.pack()

		# キャンバスを配置するフレームの作成と配置
		self.canvas_frame = tkinter.Frame(
			self.main_frame
		)
		self.canvas_frame.grid(column=1, row=1)

		# ユーザ操作用フレームの作成と配置
		self.operation_frame = tkinter.Frame(
			self.main_frame
		)
		self.operation_frame.grid(column=2, row=1)

		# 動画情報ラベルを配置するフレームの作成と配置
		self.videoinfo_frame = tkinter.Frame(
			self.operation_frame
		)
		self.videoinfo_frame.pack(pady=10)

		# 設定入力ウィジェットを配置するフレームの作成と配置
		self.setting_frame = tkinter.Frame(
			self.operation_frame
		)
		self.setting_frame.pack(pady=10)

		# フレーム表示用ウィジェットを配置するフレームの作成と配置
		self.display_frame = tkinter.Frame(
			self.operation_frame
		)
		self.display_frame.pack(pady=10)

		# 実行ボタンを配置するフレームの作成と配置
		self.exec_frame = tkinter.Frame(
			self.operation_frame
		)
		self.exec_frame.pack(pady=10)

		# 1つ目のキャンバスの作成と配置
		self.canvas = tkinter.Canvas(
			self.canvas_frame,
			width=canvas_width,
			height=canvas_height,
			bg="#E0E0E0",
		)
		self.canvas.grid(column=1, row=1)

		# メッセージ表示ラベルの作成と配置

		# メッセージ更新用
		self.message = tkinter.StringVar()

		self.message_label = tkinter.Label(
			self.sub_frame,
			textvariable=self.message
		)
		self.message_label.pack()

		# 動画情報ラベルの作成と配置
		self.videoinfo = tkinter.StringVar()

		self.videoinfo_label = tkinter.Label(
			self.videoinfo_frame,
			textvariable=self.videoinfo
		)
		self.videoinfo_label.pack()

		# 保存開始フレーム用テキストボックスの作成と配置
		self.start_label = tkinter.Label(
			self.setting_frame,
			text="開始フレーム"
		)
		self.start_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.start_label.grid(column=1, row=1)
		self.start_entry.grid(column=2, row=1)

		# 保存終了フレーム用テキストボックスの作成と配置
		self.end_label = tkinter.Label(
			self.setting_frame,
			text="終了フレーム"
		)
		self.end_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.end_label.grid(column=1, row=2)
		self.end_entry.grid(column=2, row=2)

		# フレームの間隔用テキストボックスの作成と配置
		self.interval_label = tkinter.Label(
			self.setting_frame,
			text="フレーム間隔"
		)
		self.interval_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.interval_label.grid(column=1, row=3)
		self.interval_entry.grid(column=2, row=3)

		# FPS用テキストボックスの作成と配置
		self.fps_label = tkinter.Label(
			self.setting_frame,
			text="FPS"
		)
		self.fps_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.fps_label.grid(column=1, row=4)
		self.fps_entry.grid(column=2, row=4)

		# ループ回数用テキストボックスの作成と配置
		self.loop_label = tkinter.Label(
			self.setting_frame,
			text="ループ回数"
		)
		self.loop_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.loop_label.grid(column=1, row=5)
		self.loop_entry.grid(column=2, row=5)

		# 最大幅用テキストボックスの作成と配置
		self.width_label = tkinter.Label(
			self.setting_frame,
			text="最大幅"
		)
		self.width_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.width_label.grid(column=1, row=6)
		self.width_entry.grid(column=2, row=6)

		# 最大高さ用テキストボックスの作成と配置
		self.height_label = tkinter.Label(
			self.setting_frame,
			text="最大高さ"
		)
		self.height_entry = tkinter.Entry(
			self.setting_frame,
		)
		self.height_label.grid(column=1, row=7)
		self.height_entry.grid(column=2, row=7)

		# ディザ無効設定用チェックボックスの作成と配置
		self.dither_info = tkinter.BooleanVar()
		self.dither_check = tkinter.Checkbutton(
			self.setting_frame,
			variable=self.dither_info,
			text="ディザ有効"
		)
		self.dither_info.set(True)
		self.dither_check.grid(column=1, row=8)

		# 表示フレーム設定用テキストボックスの作成と配置
		self.display_label = tkinter.Label(
			self.display_frame,
			text="表示フレーム"
		)
		self.display_entry = tkinter.Entry(
			self.display_frame,
		)
		self.display_label.grid(column=1, row=1)
		self.display_entry.grid(column=2, row=1)

		# 表示ボタンの作成と配置
		self.display_button = tkinter.Button(
			self.display_frame,
			text="フレーム表示"
		)
		self.display_button.grid(column=2, row=2)

		# ファイル読み込みボタンの作成と配置
		self.load_button = tkinter.Button(
			self.exec_frame,
			text="動画選択"
		)
		self.load_button.pack()

		# ファイル書き込みボタンの作成と配置
		self.save_button = tkinter.Button(
			self.exec_frame,
			text="GIF保存"
		)
		self.save_button.pack()

	def draw_image(self):
		'画像をキャンバスに描画'

		image = self.model.get_image()

		if image is not None:
			# キャンバス上の画像の左上座標を決定
			sx = (self.canvas.winfo_width() - image.width()) // 2
			sy = (self.canvas.winfo_height() - image.height()) // 2

			# キャンバスに描画済みの画像を削除
			objs = self.canvas.find_withtag("image")
			for obj in objs:
				self.canvas.delete(obj)

			# 画像をキャンバスの真ん中に描画
			self.canvas.create_image(
				sx, sy,
				image=image,
				anchor=tkinter.NW,
				tag="image"
			)

	def draw_selection(self, selection):
		'選択範囲を描画'

		# 一旦描画済みの選択範囲を削除
		self.delete_selection()

		if selection:
			# 選択範囲を長方形で描画
			self.canvas.create_rectangle(
				selection[0],
				selection[1],
				selection[2],
				selection[3],
				outline="red",
				width=3,
				tag="selection_rectangle"
			)

	def delete_selection(self):
		'選択範囲表示用オブジェクトを削除する'

		# キャンバスに描画済みの選択範囲を削除
		objs = self.canvas.find_withtag("selection_rectangle")
		for obj in objs:
			self.canvas.delete(obj)

	def draw_message(self, message):
		self.message.set(message)

	def draw_videoinfo(self, message):
		self.videoinfo.set(message)

	def select_open_file(self, file_types):
		'オープンするファイル選択画面を表示'

		# ファイル選択ダイアログを表示
		file_path = tkinter.filedialog.askopenfilename(
			initialdir=".",
			filetypes=file_types
		)
		return file_path

	def select_save_file(self, defaultextension):
		'保存するファイル選択画面を表示'

		# ファイル選択ダイアログを表示
		file_path = tkinter.filedialog.asksaveasfilename(
			initialdir=".",
			defaultextension=defaultextension
		)
		return file_path


class Controller():

	INTERVAL = 50

	def __init__(self, app, model, view):
		self.master = app
		self.model = model
		self.view = view

		self.num_frame = 0

		# マウスボタン管理用
		self.pressing = False
		self.selection = None

		# ラベル表示メッセージ管理用
		self.message = "ファイルを読み込んでください"

		self.set_events()

	def set_events(self):
		'受け付けるイベントを設定する'

		# キャンバス上のマウス押し下げ開始イベント受付
		self.view.canvas.bind(
			"<ButtonPress>",
			self.button_press
		)

		# キャンバス上のマウス動作イベント受付
		self.view.canvas.bind(
			"<Motion>",
			self.mouse_motion,
		)

		# キャンバス上のマウス押し下げ終了イベント受付
		self.view.canvas.bind(
			"<ButtonRelease>",
			self.button_release,
		)

		# 動画選択ボタン押し下げイベント受付
		self.view.load_button['command'] = self.push_load_button

		# GIF保存ボタン押し下げイベント受付
		self.view.save_button['command'] = self.push_save_button

		# フレーム表示ボタン押し下げイベント受付
		self.view.display_button['command'] = self.push_display_button

		# 画像の描画用のタイマーセット
		self.master.after(Controller.INTERVAL, self.timer)

	def timer(self):
		'一定間隔で画像等を描画'

		# 動画1フレーム分をキャンバスに描画
		self.view.draw_image()

		# トリミング選択範囲をキャンバスに描画
		self.view.draw_selection(
			self.selection,
		)

		# ラベルにメッセージを描画
		self.view.draw_message(
			self.message
		)

		# 再度タイマー設定
		self.master.after(Controller.INTERVAL, self.timer)

	def push_load_button(self):
		'動画選択ボタンが押された時の処理'

		file_types = [
			("MOVファイル", "*.mov"),
			("MP4ファイル", "*.mp4"),
		]

		# ファイル選択画面表示
		file_path = self.view.select_open_file(file_types)

		# 動画ファイルの読み込み
		if len(file_path) != 0:
			self.model.create_video(file_path)

			# 先頭フレームの表示
			self.num_frame = 0
			size = (
				self.view.canvas.winfo_width(),
				self.view.canvas.winfo_height()
			)
			self.model.create_image(size, self.num_frame)

			# 動画の情報を取得して表示
			video_info = self.model.get_videoinfo()
			self.videoinfo = "幅:" + str(video_info["width"]) + "\n" \
				+ "高さ:" + str(video_info["height"]) + "\n" \
				+ "フレーム数:" + str(video_info["num_frame"]) + "\n" \
				+ "FPS:" + str(video_info["fps"]) + "\n"

			# 動画の情報からテキストボックスに設定入力(Viewでやる方が良いか?!)
			self.view.start_entry.delete(0, tkinter.END)
			self.view.start_entry.insert(0, str(0))
			self.view.end_entry.delete(0, tkinter.END)
			self.view.end_entry.insert(0, str(video_info["num_frame"] - 1))
			self.view.fps_entry.delete(0, tkinter.END)
			self.view.fps_entry.insert(0, str(video_info["fps"]))
			self.view.loop_entry.delete(0, tkinter.END)
			self.view.loop_entry.insert(0, str(1))
			self.view.interval_entry.delete(0, tkinter.END)
			self.view.interval_entry.insert(0, str(1))
			self.view.width_entry.delete(0, tkinter.END)
			self.view.width_entry.insert(0, str(video_info["width"]))
			self.view.height_entry.delete(0, tkinter.END)
			self.view.height_entry.insert(0, str(video_info["height"]))

			# 動画情報の描画
			self.view.draw_videoinfo(self.videoinfo)

		self.selection = None

		# 選択範囲を表示するオブジェクトを削除
		self.view.delete_selection()

		# メッセージを更新
		self.message = "保存するGIFの設定を行ってGIF保存を押してください"

	def push_save_button(self):
		'GIF保存ボタンが押された時の処理'

		# ファイル選択画面表示
		file_path = self.view.select_save_file("gif")

		# 動画ファイルの読み込み
		if len(file_path) != 0:
			# 設定値を取得
			save_info = self.get_save_info()

			self.model.save_gif(file_path, save_info)

			self.message = "GIF保存が完了しました!!"

	def push_display_button(self):
		'フレーム表示ボタンが押された時の処理'

		frame_str = self.view.display_entry.get()
		if len(frame_str) != 0:
			self.num_frame = int(frame_str)
			size = (
				self.view.canvas.winfo_width(),
				self.view.canvas.winfo_height()
			)

			self.model.create_image(size, self.num_frame)

			# メッセージを更新
			self.message = "フレーム" + frame_str + "を表示しました"

	def button_press(self, event):
		'マウスボタン押し下げ開始時の処理'

		# マウスクリック中に設定
		self.pressing = True

		self.selection = None

		# 現在のマウスでの選択範囲を設定
		self.selection = [
			event.x,
			event.y,
			event.x,
			event.y
		]

		# 選択範囲を表示するオブジェクトを削除
		self.view.delete_selection()

	def mouse_motion(self, event):
		'マウスボタン移動時の処理'

		if self.pressing:

			# マウスでの選択範囲を更新
			self.selection[2] = event.x
			self.selection[3] = event.y

	def button_release(self, event):
		'マウスボタン押し下げ終了時の処理'

		if self.pressing:

			# マウスボタン押し下げ終了
			self.pressing = False

			# マウスでの選択範囲を更新
			self.selection[2] = event.x
			self.selection[3] = event.y

			# 画像の描画位置を取得
			objs = self.view.canvas.find_withtag("image")
			if len(objs) != 0:
				draw_coord = self.view.canvas.coords(objs[0])

				# 選択範囲をキャンバス上の座標から画像上の座標に変換
				x1 = int(self.selection[0] - draw_coord[0])
				y1 = int(self.selection[1] - draw_coord[1])
				x2 = int(self.selection[2] - draw_coord[0])
				y2 = int(self.selection[3] - draw_coord[1])

				# メッセージを更新
				self.message = "(" + \
					str(x1) + ", " + str(y1) + ") - (" + \
					str(x2) + ", " + str(y2) + ")を選択中"

	def get_save_info(self):
		info = {}

		video_info = self.model.get_videoinfo()

		# 開始フレームの設定
		start_str = self.view.start_entry.get()
		if len(start_str) == 0:
			start_str = str(0)

		# 終了フレームの設定
		end_str = self.view.end_entry.get()
		if len(end_str) == 0:
			end_str = str(video_info["num_frame"] - 1)

		info["range"] = (
			int(start_str),
			int(end_str)
		)

		# フレーム間隔の設定
		interval_str = self.view.interval_entry.get()
		if len(interval_str) != 0:
			info["interval"] = int(interval_str)
		else:
			info["interval"] = 1

		# FPSの設定
		fps_str = self.view.fps_entry.get()
		if len(fps_str) != 0:
			info["fps"] = float(fps_str)
		else:
			info["fps"] = video_info["fps"]

		# ループ回数の設定
		loop_str = self.view.loop_entry.get()
		if len(loop_str) != 0:
			info["loop"] = int(loop_str)
		else:
			info["loop"] = 0

		# 画像の描画位置を取得
		if self.selection is not None:
			objs = self.view.canvas.find_withtag("image")
			if len(objs) != 0:
				draw_coord = self.view.canvas.coords(objs[0])

				# 選択範囲をキャンバス上の座標から画像上の座標に変換
				x1 = int(self.selection[0] - draw_coord[0])
				y1 = int(self.selection[1] - draw_coord[1])
				x2 = int(self.selection[2] - draw_coord[0])
				y2 = int(self.selection[3] - draw_coord[1])

			# 選択範囲の設定
			info["select"] = (
				x1, y1, x2, y2
			)
		else:
			info["select"] = None

		# ディザ有効の設定
		info["dither"] = self.view.dither_info.get()

		# 最大幅と最大高さの設定
		width_str = self.view.width_entry.get()
		if len(width_str) != 0:
			info["width"] = int(width_str)
		else:
			info["width"] = video_info["width"]

		height_str = self.view.height_entry.get()
		if len(height_str) != 0:
			info["height"] = int(height_str)
		else:
			info["height"] = video_info["height"]

		return info


app = tkinter.Tk()

app.title("動画 -> GIF 変換アプリ")

model = Model()
view = View(app, model)
controller = Controller(app, model, view)

app.mainloop()

実行すると下記のような画面が表示されます。

アプリ起動画面

「動画選択」ボタンを押せばファイル選択画面が表示されるので、動画ファイル(.MOV or .MP4)を選んでください。選んだ動画の先頭フレームがキャンバスに描画されます。

動画を読み込むとフレームが描画される様子

また「表示フレーム」のテキストボックスに数字を入力して「フレーム表示」ボタンをクリックすれば、その指定したフレームがキャンバスに描画されます。

表示フレーム設定により表示されるフレームが変化する様子

今度はキャンバス上をマウスをクリックしながら動かすことで範囲を指定したり、右側のテキストボックス等で作成する GIF の設定を行ってください。

トリミング領域を指定する様子

最後に「GIF保存」を押すと、ファイル選択画面が表示されますので、ファイル名を設定して Save ボタンを押せば、GIF への変換と保存が行われます。

メッセージラベルに「GIF保存が完了しました!!」と表示されれば完了です!

例えば下のような GIF ファイルが保存されます。

作成GIFの例

メモ

動画のフレーム数が多いと処理時間が長くなります

最初は「開始フレーム」「終了フレーム」「フレーム間隔」を設定してフレーム数を減らして GIF 保存を行うのが良いと思います

まとめ

このページでは Python で動画からアニメーション GIF に変換するアプリ開発の説明を行いました。ちょっとクラス設計がやっぱりイマイチな気はしていますが…。

動画から GIF に変換するフリーソフトやウェブアプリはもちろん既存のものがありますが、それを真似て自分で作ってみるのもプログラミングの勉強に良いと思います。自分の好きなようにカスタマイズできるところも良いですね。

動画を扱うアプリであれば画像処理だけでなく動画の勉強にもなると思います!

是非動画を活用したアプリ開発にも挑戦してみてください!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です