【Python】画像の平行四辺形化アプリを作成する

画像の平行四辺形化を行うアプリの紹介ページアイキャッチ

Python で tkinter を用いて「画像を平行四辺形に変形するアプリ」を作成しましたので、このページでアプリの紹介・スクリプトの紹介・そしてそのスクリプトの解説をしていきたいと思います。

画像の平行四辺形化アプリの動作は下の動画ようになります。

 

この画像が歪んでいく様子に心地よさを感じる人もいるのではないでしょうか?

画像を平行四辺形化する動きを見るだけでも心地よいのですが、画像保存できるようにもしているため、平行四辺形化後の画像をキーノートやパワーポイントなどで使用することも可能です。

スクリプトでは tkinter だけでなく、PIL と NumPy も使用していますので、アプリを実行するためにはこれらのモジュールが必要になります。この点はご了承ください。

MEMO

このページの猫の画像はリズム727さんによる写真ACからの写真を使用させていただいています

作成する画像の平行四辺形化アプリ

では、まずは本ページで作成していく「画像の平行四辺形化アプリ」がどんなアプリであるかについて解説していきます。

アプリの外観

まず、アプリ起動時のウィンドウは下の図のようになります(アプリのウィンドウの背景が白色の場合は、キャンバスとの境目が分かりにくいので注意してください)。

画像のスキューアプリ起動時のウィンドウ

ウィンドウの左側には画像を表示するキャンバスウィジェットを配置しており、右側には画像を操作するためのウィジェットを配置しています。

画像を操作するためのウィジェットとは、具体的には、平行四辺形化を行う際の角度を調整するためのスケールウィジェットと、画像の読み込みや画像の保存を行うためのボタンウィジェットを配置しています。

また、画像を読み込んでいない時は、スケールと「画像保存」ボタンを無効化しており、スライダーの移動やボタンのクリックが効かないようにしています。

スポンサーリンク

画像の読み込み

画像の平行四辺形化を行うためには、まずは「画像読み込み」ボタンを押します。

するとファイル選択ダイアログが表示され、そこで指定されたファイルの画像がキャンバスに表示されます。

画像読み込み後にキャンバスに画像が表示される様子

キャンバスには平行四辺形化後の画像がキャンバス内に収まるように、画像がリサイズして描画されます(平行四辺形後の画像がキャンバスに収まるようにするため、最初は画像が小さめに表示されます)。

また、画像の読み込みを行った段階で無効化されていたウィジェットが有効化され、これらのウィジェットが操作可能になります。

画像の平行四辺形化

読み込んだ画像の平行四辺形化を行うためには、ウィンドウ右側のスケールウィジェットのスライダーを動かします。

スライダーを動かせば、そのスライダーの位置に応じた角度で平行四辺形化が実行され、実行後の画像がウィンドウ左側のキャンバスに表示されます(スライダーの位置を移動させる度に自動的に平行四辺形化が実行され、表示画像も変化するようになっています)。

スケールのスライダーを動かすと画像が平行四辺形化される様子

平行四辺形化を行うためのスケールは2つ用意しており、上側のスケールは「水平方向の角度」を設定するスケールになります。このスケールのスライダーの位置に応じ、下の図における h の角度を変化させて平行四辺形化が実行されます。

水平方向のスキュー角度の説明図

それに対し、下側のスケールは「垂直方向の角度」を設定するスケールになります。このスケールのスライダーの位置に応じ、下の図における v の角度を変化させて平行四辺形化が実行されます。

垂直方向のスキュー角度の説明図

両スケールともに設定可能な角度は -45 度 〜 45 度までとさせていただいています。

平行四辺形化後の画像の保存

また、「画像保存」ボタンをクリックすればファイル選択ダイアログが表示され、平行四辺形化後の画像の保存先ファイルパスを指定することができます。

ファイルパスを指定すれば、ボタンを押した時点のスケールのスライダーの位置に応じて “平行四辺形化した画像” がそのファイルパスに保存されることになります。

キャンバスにはキャンバス内に平行四辺形化後の画像が収まるようにリサイズしたものを表示しますが、画像保存時には読み込んだ画像そのもの(リサイズしていない画像)に対して平行四辺形化を行った結果が保存されるようにしています。

また、平行四辺形化により画像内に余白が出来るため、この余白は透明になるようにしています。

スキューにより生じた画像内の余白が透明になっている様子

基本的なアプリの説明は上記のようになります。

続いてスクリプトを紹介しますので、もちろんそのスクリプトを変更すれば上記と異なる動作を行わせたり、新たな機能を追加したりすることも可能です。

スポンサーリンク

画像の平行四辺形化アプリのスクリプト

画像の平行四辺形化アプリのスクリプトは下記のようになります。

画像の平行四辺形化アプリ
import tkinter
import tkinter.filedialog
from PIL import Image, ImageTk
import numpy
import math

WIDTH = 400
HEIGHT = 400


class Skew:

	def __init__(self, master):
		'''アプリのコンストラクタ'''

		# 親ウィジェットの設定
		self.master = master

		# キャンバスのサイズの設定
		self.width = WIDTH
		self.height = HEIGHT

		self.createWidgets()

	def createWidgets(self):
		'''ウィジェットを作成する'''

		# 画像描画用のキャンバス
		self.canvas = tkinter.Canvas(
			self.master,
			width=self.width,
			height=self.height,
			bg="white",
			highlightthickness=0
		)
		self.canvas.grid(padx=10, pady=10, column=0, row=0)

		# 画像操作用のウィジェット配置用のフレーム
		frame = tkinter.Frame(
			self.master,
			width=200
		)
		frame.grid(padx=10, pady=10, column=1, row=0)

		# スケール配置用のフレーム
		scale_frame = tkinter.LabelFrame(
			frame,
			text="角度の調整"
		)
		scale_frame.pack(padx=10, pady=10)

		# ボタン配置用のフレーム
		button_frame = tkinter.LabelFrame(
			frame,
			text="画像の読み込みと保存"
		)
		button_frame.pack(padx=10, pady=10)

		# 水平方向の角度設定用スケール
		self.scale_h = tkinter.Scale(
			scale_frame,
			orient=tkinter.HORIZONTAL,
			state=tkinter.DISABLED,  # 無効化
			from_=-45,  # -45度から
			to=45,  # 45度まで
			resolution=1,  # 1度ずつ変化
			command=self.update,
			label="水平方向"
		)
		self.scale_h.pack()

		# 垂直方向の角度設定用スケール
		self.scale_v = tkinter.Scale(
			scale_frame,
			orient=tkinter.HORIZONTAL,
			state=tkinter.DISABLED,  # 無効化
			from_=-45,  # -45度から
			to=45,  # 45度まで
			resolution=1,  # 1度ずつ変化
			command=self.update,
			label="垂直方向"
		)
		self.scale_v.pack()

		# 画像読み込み用ボタン
		load_button = tkinter.Button(
			button_frame,
			text="画像読み込み",
			command=self.load
		)
		load_button.pack()

		# 画像保存用ボタン
		self.save_button = tkinter.Button(
			button_frame,
			text="画像保存",
			state=tkinter.DISABLED,  # 無効化
			command=self.save
		)
		self.save_button.pack()

	def load(self):
		'''画像の読み込みを行う'''

		# ファイル選択ダイアログ表示
		file_path = tkinter.filedialog.askopenfilename()

		if len(file_path) == 0:
			# ファイル選択がキャンセルされた場合
			return

		# 指定されたファイルの読み込み(モードはRGBAに強制的に変換)
		self.load_image = Image.open(file_path).convert("RGBA")

		# 平行四辺形化後の画像のサイズの最大サイズを設定
		max_rad = math.radians(45)
		add_width = round(self.load_image.height * math.tan(max_rad))
		skewed_width = self.load_image.width + add_width
		add_height = round(self.load_image.width * math.tan(max_rad))
		skewed_height = self.load_image.height + add_height

		# 平行四辺形化後の画像がキャンバス内に収まるようにリサイズ後のサイズを計算
		ratio = min(self.width / skewed_width, self.height / skewed_height)
		resize_size = (
			round(ratio * self.load_image.width),
			round(ratio * self.load_image.height)
		)

		# リサイズ後の画像をキャンバス描画用の画像とする
		self.canvas_image = self.load_image.resize(resize_size)

		# 平行四辺形化を行なってキャンバスに描画
		self.update(None)

		# スケールや保存ボタンを通常状態に設定
		self.scale_h.config(state=tkinter.NORMAL)
		self.scale_v.config(state=tkinter.NORMAL)
		self.save_button.config(state=tkinter.NORMAL)

	def update(self, _):
		'''キャンバスの画像を更新する'''

		# 現在設定されている角度を取得
		h = math.radians(self.scale_h.get())
		v = math.radians(self.scale_v.get())

		# 取得した角度で平行四辺形化を実行
		skewed_image = self.skew(self.canvas_image, h, v)

		self.canvas.delete("all")  # 一旦画像削除

		if skewed_image is not None:

			# # 平行四辺形化に成功した場合のみ画像をキャンバスに描画
			self.tk_image = ImageTk.PhotoImage(skewed_image)

			self.canvas.create_image(
				self.width / 2, self.height / 2,
				anchor=tkinter.CENTER,
				image=self.tk_image
			)

	def save(self):
		'''平行四辺形化した画像を保存する'''

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

		if len(file_path) == 0:
			# 選択されなかったら何もしない
			return

		# 現在のスキュー角度を取得
		h = math.radians(self.scale_h.get())
		v = math.radians(self.scale_v.get())

		# 取得した角度で平行四辺形化を実行
		skewed_image = self.skew(self.load_image, h, v)

		if skewed_image is not None:

			# 平行四辺形化に成功した場合のみ指定されたパスに保存
			skewed_image.save(file_path)

		else:
			print("平行四辺形化に失敗しました")

	def skew(self, image, h, v):
		'''imageを水平角度hと垂直角度vで平行四辺形化する'''

		# アフィン変換後の画像のサイズを求める
		add_width = abs(round(image.height * math.tan(h)))
		add_height = abs(round(image.width * math.tan(v)))
		size = (image.width + add_width, image.height + add_height)


		# 平行四辺形化用の行列を作成
		skew_matrix = numpy.array([
			[1, math.tan(h), 0],
			[math.tan(v), 1, 0],
			[0, 0, 1]
		])

		# 画像の平行移動量を計算
		th = add_width if h < 0 else 0
		tv = add_height if v < 0 else 0

		# 平行移動用の行列を作成
		translate_matrix = numpy.array([
			[1, 0, th],
			[0, 1, tv],
			[0, 0, 1]
		])

		# 2つの行列の積を取った結果をアフィン変換用の行列とする
		matrix = numpy.matmul(translate_matrix, skew_matrix)

		# アフィン変換用の行列の逆行列を作成する
		try:
			inv_matrix = numpy.linalg.inv(matrix)
		except numpy.linalg.LinAlgError:
			# 逆行列の作成に失敗したらNoneを返却
			return None

		# 逆行列を1次元の行列に変換する
		matrix_tupple = tuple(inv_matrix.flatten())

		# アフィン変換を行う
		skewed_image = image.transform(
			size=size,  # アフィン変換後に得られる画像のサイズ
			method=Image.AFFINE,  # 行う変換(アフィン変換)
			data=matrix_tupple,  # アフィン変換時に用いる行列
			resample=Image.BILINEAR,  # 補間アルゴリズム(双線形補間)
			fillcolor=(0, 0, 0, 0)  # 余白を埋める画素の値(透明)
		)

		# 平行四辺形化後画像を返却
		return skewed_image


app = tkinter.Tk()
skew = Skew(app)
app.mainloop()

画像の平行四辺形化アプリのスクリプトの解説

次は 画像の平行四辺形化アプリのスクリプト で紹介したスクリプトにおいて、ポイントになる点を解説していきたいと思います。

大きく分けて下記の5つの項目について解説していきます。

ウィジェットの作成

画像の平行四辺形化アプリのスクリプト において、ウィジェットの作成は createWidgets メソッドの中で行っています。

作成するウィジェットとウィジェットの配置

結構このメソッドの行数が多いですが、要は下の図のような配置になるように、ウィジェットの作成と配置を行なっています。

ウィンドウ上の各ウィジェットの配置

こういったちょっと複雑なレイアウトを実現するためにはフレームウィジェット or ラベルフレームウィジェットが便利です。

これらのウィジェットについては下記ページで解説していますので、詳しく知りたい方は下記ページをご参照しただければと思います。

フレームウィジェットの作り方の解説ページアイキャッチTkinterの使い方:フレームウィジェット(Frame)の使い方 ラベルフレームの使い方の解説ページアイキャッチTkinterの使い方:ラベルフレームウィジェット(LabelFrame)の使い方

また、他のウィジェットや配置に用いた gridpack メソッドについては下記のページで解説していますので、ウィジェットそのものやオプションの意味合い等を詳しく知りたい方は下記ページをご参照いただければと思います。

tkinterのキャンバスの作り方解説ページアイキャッチTkinterの使い方:キャンバスウィジェットの作り方 ボタンウィジェット作成解説ページのアイキャッチTkinterの使い方:ボタンウィジェット(Button)の使い方 スケールウィジェットの使い方の解説ページアイキャッチTkinterの使い方:スケール(Scale)の使い方 ウィジェット配置方法解説ページのアイキャッチTkinterの使い方:ウィジェットの配置(pack・grid・place)

各ウィジェット作成時のポイントになるのは、state オプションの指定と command オプションの指定だと思います。

state オプションの指定

今回作成するアプリでは、画像の読み込みが行われていない状態でスケールウィジェットのスライダーの位置の変更や画像保存ボタンのクリックが行われないよう、これらのウィジェット作成時(コンストラクタ実行時)には state=tkinter.DISABLED を指定するようにしています(「画像読み込み」ボタンを除いて)。

この state=tkinter.DISABLED を指定しておけば、それらのウィジェットが無効状態となり、スライダーの位置の変更やクリックを行えないようにすることができます。

ただ、ずっと無効状態だとウィジェット用意した意味がありませんので、画像の読み込み完了後に state=tkinter.NORMAL を指定してウィジェットを通常状態に戻します。これについては後述の 画像の読み込み で解説します。

command オプションの指定

また、スライダーやボタンウィジェット作成時(コンストラクタ実行時)には command オプションを指定し、下記の動作が行われた時に自動的にメソッドが実行されるように設定しています。

  • scale_hscale_v:スライダーの位置が変更された時に update メソッドを実行
  • load_button:左クリックされた時に load メソッドを実行
  • save_button:右クリックされた時に save ボタンを実行

といっても、load_button 以外はアプリ起動時には無効状態になっているため、画像読み込み後に上記の操作が行われた際に各メソッドが実行されるようになることになります。

スポンサーリンク

画像の読み込み

画像読み込みボタン(load_button)が左クリックされた時に実行されるのが、この画像の読み込みであり、この画像の読み込みは load メソッドで行っています。

この load メソッドで行っているのは下記の5つの処理となります。

  • ファイル選択ダイアログ表示
  • 選択された画像ファイルの読み込み
  • 読み込んだ画像のリサイズ
  • リサイズ後画像のキャンバスへの描画
  • ウィジェットの有効化

ファイル選択ダイアログ表示

load メソッドでは、まずファイル選択ダイアログを表示し、平行四辺形化を行いたい画像のファイルパス指定をユーザーから受け付けるようにしています。

このファイル選択ダイアログの表示を行なっているのは下記部分になります。

ファイル選択ダイアログの表示(読み込み)
# ファイル選択ダイアログ表示
file_path = tkinter.filedialog.askopenfilename()

if len(file_path) == 0:
	# ファイル選択がキャンセルされた場合
	return

askopenfilename 関数を実行すればファイル選択ダイアログが表示され、選択されたファイルのパスが返却されます。

ファイル選択ダイアログに関しては下記ページで解説していますので、詳しく知りたい方は下記ページを参照していただければと思います。

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

選択された画像ファイルの読み込み

次に、選択されたファイルパスの画像の読み込みを行なっています。

読み込みたいファイルのパスを引数に指定して PIL の Image.open を実行すれば、そのファイルを読み込んで画像オブジェクトを生成することができます。

ファイルの読み込み
# 指定されたファイルの読み込み(モードはRGBAに強制的に変換)
self.load_image = Image.open(file_path).convert("RGBA")

ポイントは convert("RGBA") により生成する画像オブジェクトのモードを "RGBA" に設定するところです。

前述の通り、画像の平行四辺形化を行うと画像に余白が生じますので、その余白を透明にするため、透明度が設定可能なモードである "RGBA" に変換するようにしています。

スキューにより生じた画像内の余白が透明になっている様子 

読み込んだ画像のリサイズ

続いて、画像をキャンバスに描画する際に、キャンバスから画像がはみ出ないように画像のリサイズを行なっています。

ここで注意が必要なのが、”平行四辺形化後” の画像がキャンバスからはみ出ないように、画像のリサイズを行う必要がある点です。

平行四辺形化を行うと画像のサイズが元画像に対して大きくなります。ですので、元画像がキャンバスからはみ出ないようにリサイズしたとしても、平行四辺形化を行うと画像がキャンバスからはみ出る可能性があります。

平行四辺形化した画像がキャンバスからはみ出てしまう様子

具体的には、平行四辺形化後の画像は、元画像に対して幅が下記の add_width ピクセル、さらに高さが下記の add_height ピクセル増加します。width は元画像の幅、height は元画像の高さ、さらに h は水平方向の角度、v は垂直方向の角度となります(abs は絶対値を求める関数)。

  • add_widthheight * abs(tan(h))
  • add_heightwidth * abs(tan(v))

これらの増加量は、元画像のサイズからはみ出る部分の直角三角形の「底辺の長さ」と「斜辺の傾き」から求めることができます。

例えば、水平方向の角度が h の場合、下の図の青枠のような直角三角形が元画像のサイズからはみ出ることになります。

幅の増加量の求め方1

さらに、この直角三角形において、斜辺の傾きは三角関数を利用すれば tan(h) で求めることができます(理論的には add_width / height でも求められるが、現状 add_width の値が分かっていないので無理)。

平行四辺形化を行なった時の幅の増加量の求め方2

さらに、直角三角形における斜辺の傾きとは、底辺の長さが 1 増えた時の高さの増加量と考えられます。

したがって、底辺の長さが height なのであれば、直角三角形の高さは height * tan(h) ということになりますね!

で、この直角三角形の高さは平行四辺形化した時の画像の幅の増加量 add_width ですので、結局は先ほど示した上記の式により、この増加量が求められることになります(求めるのが幅なので abs 関数で絶対値をとるようにしています)。

高さの増加量に関しても同様で、垂直方向の角度が v の場合、下の図の青枠のような直角三角形が元画像のサイズからはみ出ることになりますので、斜辺の傾き tan(v) に底辺の長さ width を掛けた結果が高さの増加量 add_height となります。

平行四辺形化を行なった時の高さの増加量の求め方

さらに、今回は角度は -45 度 〜 45 度に制限していますので、h = 45 かつ v = 45 の時にサイズの増加量が最大になります(h = -45 かつ v = -45 でも同様に最大となります)。

ですので、この時のサイズを求め、そのサイズとキャンバスのサイズの比から拡大率を求めて画像のリサイズをしてやれば、角度が -45 度 〜 45 度であれば必ず画像がキャンバス内に入りきるようにすることができます。

上記の考え方に基づいて画像のリサイズを行なっているのが、load メソッドの下記部分となります。tan 関数に指定する角度の単位がラジアンであることに注意してください。

画像のリサイズ
# 平行四辺形化後の画像のサイズの最大サイズを設定
max_rad = math.radians(45)
add_width = round(self.load_image.height * math.tan(max_rad))
skewed_width = self.load_image.width + add_width
add_height = round(self.load_image.width * math.tan(max_rad))
skewed_height = self.load_image.height + add_height

# 平行四辺形化後の画像がキャンバス内に収まるようにリサイズ後のサイズを計算
ratio = min(self.width / skewed_width, self.height / skewed_height)
resize_size = (
	round(ratio * self.load_image.width),
	round(ratio * self.load_image.height)
)

# リサイズ後の画像をキャンバス描画用の画像とする
self.canvas_image = self.load_image.resize(resize_size)

リサイズ後画像のキャンバスへの描画

続いてリサイズ後画像のキャンバスへの描画を行なっています。

ただこれに関しては、後述の 画像の更新 を行う update メソッドを実行しているだけですので、ここでの解説は省略させていただきます。

ウィジェットの有効化

最後に行っているのがウィジェットの有効化です。

ウィジェットの作成 で解説したように、画像が読み込まれるまではスケールや画像保存ボタンは無効状態にしています。

ここまでの load メソッドの処理により、画像の読み込みや画像のキャンバスへの描画が完了していますので、load メソッドの最後の下記部分で各ウィジェットの状態を通常状態に設定しています(config メソッドでは後からウィジェットのオプションを再設定することができます)。

ウィジェットの有効化
# スケールや保存ボタンを通常状態に設定
self.scale_h.config(state=tkinter.NORMAL)
self.scale_v.config(state=tkinter.NORMAL)
self.save_button.config(state=tkinter.NORMAL)

これ以降、スケールのスライダーの位置変更時や画像保存ボタンの左クリック実行時に、command オプションで指定したメソッドが実行されるようになります。

画像の更新

スケールのスライダーの位置が変更された際に実行されるのが update メソッドです。

この update メソッドでは、スケールで設定された角度に応じて平行四辺形化を実行し、キャンバスの画像を平行四辺形化実行後の画像に更新する処理を行なっています。

スライダーの位置の取得

update メソッドでは、まず最初に水平方向の角度と垂直方向の角度をスケールウィジェットから取得しています(スライダーの位置を取得)。

実は、スケールのスライダーの位置変更時にメソッドが実行された際には、スライダーの位置(スケールで設定されている値)が引数として渡されます。

ただし、ここで渡されるのはスライダーの位置が変更されたスケールウィジェットの値のみとなります。つまり、水平方向の角度 or 垂直方向の角度の一方しか引数で渡されません。

その一方で、平行四辺形化を行うためには両方向の傾きの角度が必要なため、引数で受け取った値は使用せず、各スライダー(scale_hscale_v)の両方に対して get メソッドを実行し、両方向の角度を取得するようにしています。

2つのスケールから値を取得して平行四辺形化を実行する様子

これを行なっているのが、update メソッドの下記部分になります。平行四辺形化を実行する際に必要になる角度の単位はラジアンであるため、ここで角度の取得と同時にラジアンへの変換を行っています(math.radians で度をラジアンに変換できます)。

角度の取得
# 現在設定されている角度を取得
h = math.radians(self.scale_h.get())
v = math.radians(self.scale_v.get())

スケールのスライダーの位置(スケールの設定値)の取得については下記ページで解説していますので、詳しく知りたい方は下記ページを参照していただければと思います。

スケールウィジェットの使い方の解説ページアイキャッチTkinterの使い方:スケール(Scale)の使い方

平行四辺形化の実行

続いて、先ほど取得した角度を用いてキャンバス描画用の画像 canvas_imageskew メソッドの実行により平行四辺形化しています。

skew メソッドについては、後述の 画像の平行四辺形化 で解説します。

平行四辺形化後の画像の描画

update メソッドの最後に平行四辺形化後の画像のキャンバスへの描画を行なっています。

これを行なっているのが下記部分になります。skewed_image は平行四辺形化後の PIL 画像オブジェクトです(skew メソッドの返却オブジェクト)。

後述の 画像の平行四辺形化 で解説する skew メソッドは平行四辺形化に失敗した際に None を返却するようにしているため、その場合はキャンバスへの画像の描画をスキップするようにしています。

キャンバスへの画像の描画
self.canvas.delete("all")  # 一旦画像削除

if skewed_image is not None:

	# # 平行四辺形化に成功した場合のみ画像をキャンバスに描画
	self.tk_image = ImageTk.PhotoImage(skewed_image)

	self.canvas.create_image(
		self.width / 2, self.height / 2,
		anchor=tkinter.CENTER,
		image=self.tk_image

平行四辺形化は PIL を用いて実行するため、ここまで PIL の画像オブジェクトを用いて処理を行なってきましたが、PIL の画像オブジェクトはそのままではキャンバスに描画できません。

そのため、キャンバスに描画する前に PIL の画像オブジェクトを tkinter の画像オブジェクトに変換する必要があります。

この変換を行なっているのが、上記の ImageTk.PhotoImage() 実行部分になります。

この画像オブジェクトの変換については下記ページで詳しく解説していますので、必要に応じて下記ページを参照していただければと思います。

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

また、キャンバスに描画された画像は削除しないとずっとキャンバス上に残り続けています。そのため、キャンバスの delete メソッドを利用してキャンバス上の全図形を削除してから画像の描画を行うようにしています。

これを忘れると、スライダーの位置が変更されるたびにキャンバス上の画像がどんどん増えていくことになるので注意してください。

delete メソッド実行後、キャンバスの create_image メソッドの実行により平行四辺形化後の画像のキャンバスへの描画を行なっています。

create_image メソッドの第1引数と第2引数には画像の描画位置の座標を指定します。

さらに、anchor には描画する画像の基準位置を指定します。anchor=tkinter.CENTER を指定すれば、画像の中心が基準位置となり、画像の中心が第1引数と第2引数で指定した座標と一致するように画像の描画行われます。

すなわち、上記のように create_image メソッドを実行することで、画像がキャンバスの中心に描画されることになります。

画像の保存

画像の保存は save メソッドで行っており、この save は画像保存ボタンが押された際に実行されるメソッドになります。

ファイル選択ダイアログ表示

save メソッドでは、まずファイル選択ダイアログを表示し、保存先のファイルパスの指定をユーザーから受け付けるようにしています。

ファイル選択ダイアログの表示(保存)
# ファイル選択ダイアログ表示
file_path = tkinter.filedialog.asksaveasfilename(
	defaultextension="png"
)

if len(file_path) == 0:
	# 選択されなかったら何もしない
	return

asksaveasfilename 関数を実行すればファイル選択ダイアログが表示され、指定されたファイルのパスが返却されます。

今回は、平行四辺形化で発生した画像の余白の画素を透明にするため、保存先ファイルパスの拡張子にデフォルトで .png が設定されるよう、defaultextension="png" を指定するようにしています(PNG ファイルは透明度が設定可能)。

もし .jpg など、透明度が設定不可なファイル形式の拡張子が指定されるとアプリでエラーが発生するので注意してください。

また、前述でも紹介しましたが、ファイル選択ダイアログに関しては下記ページで解説していますので、詳しく知りたい方は下記ページを参照していただければと思います。

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

スライダーの位置の取得と平行四辺形化の実行

続いてスライダーの位置の取得と平行四辺形化の実行を行なっていますが、これに関しては 画像の更新 で説明した update メソッドとほぼ同じ処理を行なっています。

ただし、ここで平行四辺形化を行う対象となる画像は 画像の読み込み で読み込んだ元画像(load_image)となります。update メソッドではキャンバス描画用にリサイズした画像(canvas_image)に対して平行四辺形化を行なっているので、ここが異なります。

画像の保存

最後に、下記でファイル選択ダイアログで指定されたファイルパスに画像の保存を行なっています。

画像の保存
if skewed_image is not None:

	# 平行四辺形化に成功した場合のみ指定されたパスに保存
	skewed_image.save(file_path)

else:
	print("平行四辺形化に失敗しました")

skewed_image が平行四辺形化後の PIL 画像オブジェクトで、これがNone の場合は平行四辺形化に失敗したということなので、その場合は画像保存を行わないようにしています。

スポンサーリンク

画像の平行四辺形化

最後に、本アプリのメイン機能とも言える画像の平行四辺形化について解説していきます。

この画像の平行四辺形化は 画像の平行四辺形化アプリのスクリプト における skew メソッドで実行しています。この skew メソッドは update メソッドと save メソッドから実行されていますので、要はスケールのスライダーの位置の変更や画像保存ボタンクリック時に実行されることになります。

また、skew メソッドの引数の意味合いは下記のようになっています。

  • image:平行四辺形化を行う対象の PIL 画像オブジェクト
  • h:水平方向の角度(単位はラジアン)
  • v:垂直方向の角度(単位はラジアン)

画像の平行四辺形化の実現

まず前提として、画像の平行四辺形化そのものズバリを実行してくれるような関数やメソッドは PIL には存在しません(おそらく)。

ただし、画像の平行四辺形化はアフィン変換により実現することができます。PIL では transform メソッドによりアフィン変換を行うことができますので、画像の平行四辺形化も transform メソッドにより実現することができます。

MEMO

この transform メソッドの引数や使い方については、下記ページを参考にさせていただいています

平行四辺形化以外のアフィン変換の例も示されていますので、興味がある方は読んでみると勉強になると思います!

https://imagingsolution.net/program/python/pillow/transform_affine/

画像に対するアフィン変換とは、簡単に言えば行列を利用して画像の変形や画像の平行移動を行うことを言います。もうちょっと具体的に言えば、画像の各座標の画素を、行列演算により求まる座標に移動させることで画像の変形や画像の平行移動が行われます。

例えば回転もアフィン変換の一種で、回転を行う際には下記の行列を用いてアフィン変換が行われます。

$$ \left ( \begin{array}{ccc} \cos (\theta) & -\sin (\theta) & 0 \\  \sin (\theta)  & \cos (\theta) & 0 \\ 0 & 0 & 1 \end{array} \right ) $$

例えば、下の図のように、ある座標 (x, y) に回転を行うための行列を掛ければ、別の座標 (X, Y) を求めることができ、この座標 (X, Y) は座標 (x, y) を θ 回転させた位置の座標となります(原点からの距離を変えずに回転)。

行列演算により回転後の座標が求まる様子

つまり、行列を掛けることで回転後の座標を求めることができます。で、これを応用して、回転前の座標の画素を全て回転後の座標にコピーしてやれば、全ての画素が回転後の位置に移動し、結果的に画像全体が回転することになります。

アフィン変換により各座標が回転後の位置に移動して画像が回転する様子

こんな感じで、行列を掛けることで求まる位置に各画素を移動させる(プログラム的にはコピーする)ことで、画像の変形や平行移動を行うのがアフィン変換です。

アフィン変換が面白いのは、行列の成分を変更してやれば、いろんな画像の変形や平行移動が行えるところです。具体的には、下の行列における af を変更することで、さまざまな画像の変形や平行移動が実現できます。

$$ \left ( \begin{array}{ccc} a & b & e \\  c  & d & f \\ 0 & 0 & 1 \end{array} \right ) $$

今回行う平行四辺形化においては、下記の行列を使用します。

$$ \left ( \begin{array}{ccc} 1 & \ tan(h) & 0 \\  \tan (v)  & 1 & 0 \\ 0 & 0 & 1 \end{array} \right ) $$

つまり、この行列を用いてアフィン変換を行えば、座標 (x, y) の画素が下記の行列演算で求められる座標 (X, Y) に移動した画像を得ることができます。

$$ \left ( \begin{array}{c} X \\ Y \\ 1 \end{array} \right ) = \left ( \begin{array}{ccc} 1 & \tan (h) & 0 \\  \tan (v)  & 1 & 0 \\ 0 & 0 & 1 \end{array} \right ) \left ( \begin{array}{c} x \\ y \\ 1 \end {array} \right ) $$

もう少し詳しく説明すると、まず上記の行列演算を行えば、XY を下記の式で表すことができます。

  • X = x + y * tan(h)
  • Y = y + x * tan(v)

上式において、例えば v0 に固定すれば tan(v)0 になりますので、上記の行列を用いてアフィン変換を行うことで (x, y) の座標の画素が (x + y * tan(h), y) の位置に移動することになります。

つまり、元の画像の画素が横方向に y * tan(h) シフトした位置に移動することになりますので、y が増えるごとに画素が大きくシフトすることになり、下の図のように画像が傾くことになります。

画像の各座標がyに応じて横方向にy*tan(h)だけシフトする様子

同様に h0 に固定して上記の行列でアフィン変換を行えば、(x, y) の座標の画素が (x, y + x * tan(v)) の位置に移動することになり、今度は元画像の各画素が縦方向に x * tan(v) だけ移動しますので、先ほどとは異なった方向に傾いた画像を得ることができます。

画像の各座標がxに応じて横方向にx*tan(v)だけシフトする様子

両方向の角度が 0 の場合は、図示するのが難しいので省略しますが、この場合でも同様に画像が傾いて平行四辺形化されることになります。

つまり、画像の平行四辺形化は、上記のような行列を用いてアフィン変換を行うことで実現することができます。

ただ、実際に transform メソッドを実行する際には、単に上記のような行列を引数に指定するのではなく、逆行列をさらに1次元のタプルに変換したものを指定する必要があったり、画像がはみ出ないように平行移動を行う必要もあったりするので、そのあたりのお作法も交え、ここから transform メソッドによって平行四辺形化を行う具体的な方法を解説していきたいと思います。

transform メソッド

まず、アフィン変換を行うことができる transform メソッドの引数について説明していきます。

transform メソッドの引数は下記のようになります。

transformの引数
transform(self, size, method, data=None, resample=NEAREST, fill=1, fillcolor=None):

transform メソッドはアフィン変換以外の変換も行うことができるようですが、アフィン変換を行うことを前提とした場合、各引数の意味合いは下記のようになります。

  • size:アフィン変換後の画像のサイズ
  • methodImage.AFFINE 固定
  • data:アフィン変換時に使用する行列の逆行列をタプル化したもの
  • resample:アフィン変換時の補間処理に用いるアルゴリズム
  • fill:おそらく、アフィン変換時は使用されない(ソースコードから判断)
  • fillcolor:生じた余白を埋めるための色

次は、特にアフィン変換により平行四辺形化を行う際に重要となる、引数 size と引数 data について補足していきます。

引数 size の指定

まず size に関しては、平行四辺形化実行後の画像のサイズを指定する必要があります。

画像の読み込み で解説したように、平行四辺形化を実行すると画像のサイズが大きくなりますので、このサイズの増加分を考慮して size を指定する必要があることになります。

具体的には、水平方向の角度を h、垂直方向の角度を v とすれば、平行四辺形化実行後に画像サイズは下記の分だけ増加することになります。

  • add_widthheight * abs(tan(h))
  • add_heightwidth * abs(tan(v))

したがって、平行四辺形化前の画像のサイズに対して上記の増加量を足してやれば、平行四辺形化実行後の画像サイズを求めることができます。

このサイズを求める処理を行なっているのが、skew メソッドにおける下記部分になります。

平行四辺形化後の画像サイズの計算
# アフィン変換後の画像のサイズを求める
add_width = abs(round(image.height * math.tan(h)))
add_height = abs(round(image.width * math.tan(v)))
size = (image.width + add_width, image.height + add_height)

引数 data の指定

続いて引数 data に関して考えると、まず平行四辺形化を行うための行列は下記となります(numpy 配列として行列を作成しています)。

平行四辺形化を行うための行列
# 平行四辺形化用の行列を作成
skew_matrix = numpy.array([
	[1, math.tan(h), 0],
	[math.tan(v), 1, 0],
	[0, 0, 1]
])

ただし、transform メソッドには上記のような行列をそのまま指定するのではなく、用意した行列から逆行列を求め、さらにそれを1次元のタプルに変換してから引数に指定する必要があります。

この逆行列の作成と1次元タプルへの変換は、下記の処理を行うことで実現することができます。

逆行列の作成と1次元タプルへの変換
# アフィン変換用の行列の逆行列を作成する
try:
	inv_matrix = numpy.linalg.inv(skew_matrix)
except numpy.linalg.LinAlgError:
	# 逆行列の作成に失敗したらNoneを返却
	return None

# 逆行列を1次元の行列に変換する
matrix_tupple = tuple(inv_matrix.flatten())

numpy.linalg.inv が引数で指定した逆行列を求める関数になります。逆行列の作成に失敗して例外 numpy.linalg.LinAlgError を発生させる可能性があるので、その場合は None を返却して関数を終了するようにしています。

上記の matrix_tupple を transform メソッドの引数 data に指定してやれば、transform メソッドの中で画像の平行四辺形化が行われ(正確にはアフィン変換が行われ)、実行後の画像を返却値として得ることができます。

平行四辺形化の実行

以上を踏まえると、平行四辺形化を行うメソッド skew は下記のように記述することができます。 

平行四辺形化の実行
def skew(self, image, h, v):
	'''imageを水平角度hと垂直角度vで平行四辺形化する'''

	# アフィン変換後の画像のサイズを求める
	add_width = abs(round(image.height * math.tan(h)))
	add_height = abs(round(image.width * math.tan(v)))
	size = (image.width + add_width, image.height + add_height)

	# 平行四辺形化用の行列を作成
	skew_matrix = numpy.array([
		[1, math.tan(h), 0],
		[math.tan(v), 1, 0],
		[0, 0, 1]
	])

	# アフィン変換用の行列の逆行列を作成する
	try:
		inv_matrix = numpy.linalg.inv(skew_matrix)
	except numpy.linalg.LinAlgError:
		# 逆行列の作成に失敗したらNoneを返却
		return None

	# 逆行列を1次元の行列に変換する
	matrix_tupple = tuple(inv_matrix.flatten())

	# アフィン変換を行う
	skewed_image = image.transform(
		size=size,  # アフィン変換後に得られる画像のサイズ
		method=Image.AFFINE,  # 行う変換(アフィン変換)
		data=matrix_tupple,  # アフィン変換時に用いる行列
		resample=Image.BILINEAR,  # 補間アルゴリズム(双線形補間)
		fillcolor=(0, 0, 0, 0)  # 余白を埋める画素の値(透明)
	)

	# 平行四辺形化後画像を返却
	return skewed_image

一応この skew メソッドでも平行四辺形化は行えるのですが、実はまだ対応が不十分で、角度を負の値にすると画像の左側 or 画像の上側が途切れてしまうことになります。

平行四辺形化した画像が途切れてしまう様子

次は、この画像の途切れの原因と解決方法について解説していきたいと思います。

画像の平行移動の同時実行

transform メソッドでは画像の左上を基準にアフィン変換が行われるので、角度が負の値の場合は画像は左側 or 上側に広がってしまい、その場合下の画像のように画像が途切れてしまうことになります。

角度が負の値の場合に画像が画像領域外にはみ出てしまう様子

この現象は、角度が負の値の場合、左側 or 上側に広がってしまった分、平行四辺形化後の画像を平行移動してやることで解決することができます。

平行移動により画像の途切れを解決する様子

この時、平行移動は下記で示す長さの分だけ行います。要は、平行四辺形化により左方向 or 上方向にはみ出るのは画像のサイズの増加分ですので、それを吸収する形で平行移動を行います(画像のサイズの増加に関しては 画像の読み込み で解説していますので、忘れてしまった方は 画像の読み込み を参照してください。

  • 横方向:
    • add_widthheight * abs(tan(h))
  • 縦方向:
    • add_heightwidth * abs(tan(v))

で、実はこの平行移動もアフィン変換の一種であり、これも transform メソッドにより実現可能です。

ただし、平行四辺形化を行った後に平行移動を行っても、平行四辺形化後の時点で既に画像が途切れてしまっているため、途切れた後の画像を平行移動することになってしまいます。

したがって、この平行移動は平行四辺形化と同時に行う必要があります。

アフィン変換においては、こういった複数の処理は、変換時に指定する行列に、各々の処理を行うための行列の積の演算結果を指定することで、一度に実行することが可能です。

2つのアフィン変換を同時に実行する様子

したがって、平行四辺形化を行うための行列と平行移動を行うための行列を用意し、さらに、これらの行列の積の演算を行えば、平行四辺形化と平行移動を同時に実行するための行列を得ることができます。

あとは、その行列の逆行列の算出および一次元のタプル化を行った結果を transform メソッドの引数 data に指定してやれば、画像が途切れることなく正常に画像の平行四辺形化を行うことができます。

行列の積の演算は、NumPy を利用した場合 matmul 関数を実行することで実現できますので、skew メソッドを下記のように変更すれば、上記のように平行四辺形化および平行移動を行うことができるようになります(変更後の skew メソッドは、画像の平行四辺形化アプリのスクリプト で示した skew メソッドと同じものになります)。

平行移動と平行四辺形化を行うように変更
def skew(self, image, h, v):
	'''imageを水平角度hと垂直角度vで平行四辺形化する'''

	# アフィン変換後の画像のサイズを求める
	add_width = abs(round(image.height * math.tan(h)))
	add_height = abs(round(image.width * math.tan(v)))
	size = (image.width + add_width, image.height + add_height)


	# 平行四辺形化用の行列を作成
	skew_matrix = numpy.array([
		[1, math.tan(h), 0],
		[math.tan(v), 1, 0],
		[0, 0, 1]
	])

	# 画像の平行移動量を計算
	th = add_width if h < 0 else 0
	tv = add_height if v < 0 else 0

	# 平行移動用の行列を作成
	translate_matrix = numpy.array([
		[1, 0, th],
		[0, 1, tv],
		[0, 0, 1]
	])

	# 2つの行列の積を取った結果をアフィン変換用の行列とする
	matrix = numpy.matmul(translate_matrix, skew_matrix)

	# アフィン変換用の行列の逆行列を作成する
	try:
		inv_matrix = numpy.linalg.inv(matrix)
	except numpy.linalg.LinAlgError:
		# 逆行列の作成に失敗したらNoneを返却
		return None

	# 以降は "平行四辺形化の実行" で示したものから変更なし

この変更により、画像が途切れることなく画像の平行四辺形化が行うことができるようになり、このページの最初に動画で紹介したアプリが完成したことになります!

まとめ

このページでは、Python で作成した「画像を平行四辺形に変形するアプリ」の紹介・アプリのスクリプトの紹介・スクリプトの解説を行いました!

実際動かして画像の平行四辺形化を行なってみると、画像が歪んでいく様子に楽しさを感じれるのではないかと思います。

tkinter を利用すれば、今回紹介したアプリのように、スケールのスライダーでパラメータを変更しながら画像の変化を確認できるので、適切なパラメータを決めるときなどに非常に便利です。

今回はスケールで角度を設定できるようにしましたが、スケールで他のパラメータを変更するようにすることで様々なパラメータ調整を実現できますので、画像処理だけでなく、いろんな場面でのパラメータの検証に応用できるのではないかと思います!

また、今回はアフィン変換を行うために行列などが出てきたため、数学が苦手な人には難しい内容だったかもしれません…。行列をはじめとする線形代数の知識は、特に画像を扱う際に必要になることも多いので、行列の知識は持っておくと今後役に立つ可能性が高いです。

ちなみに私の線形代数のオススメ書籍は下記の ゼロから学ぶ線形代数 です。私を線形代数好きにしてくれた本であり、「直感的に行列や線形代数について理解したい人」にオススメです!

また、このページでは画像の平行四辺形化に特化したアプリを紹介しましたが、下記ページではもうちょっと一般化して線形変換による画像の変形を実演するアプリを紹介していますので、こちらも興味のある方は是非読んでみてください!

このページで紹介したアプリとほとんど同じような作り方で作成することができます!

【Python】画像の線形変換の実演アプリを作成する

同じカテゴリのページ一覧を表示

コメントを残す

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