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

このページにはプロモーションが含まれています

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

画像の「画像の線形変換の実演アプリ」の動作は下の動画ようになります。

 

ウィンドウ右側のスケールのスライダーの位置を変更することで、線形変換に用いる行列のパラメータを変更しながら画像の線形変換結果を確認することができます。

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

また、この「画像の線形変換の実演アプリ」は下記ページで紹介している「画像の平行四辺形化アプリ」と非常につくりが似ています。

画像の平行四辺形化を行うアプリの紹介ページアイキャッチ 【Python】画像の平行四辺形化アプリを作成する

スケールの数やアフィン変換時に使用する行列が異なるくらいの違いしかありません。

特にスクリプトの解説については重複する部分が多いため、スクリプトの解説においては上記の「画像の平行四辺形化アプリ」との違いを中心に解説させていただきたいと思います。

MEMO

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

作成する “画像の線形変換の実演アプリ”

では、まずは本ページで作成していく「画像の線形変換の実演アプリ」がどんなアプリであるかについて解説していきます。

本アプリで出来ること

本アプリで出来ることとは、アプリの名前から分かるように「画像の線形変換の実演」です。

画像の線形変換は「線形変換による座標変換に伴う画像の変形」といった方がより正確だとは思いますが、今回は単に「画像の線形変換」と呼ばせていただきます。

この「線形変換」とは、大雑把にいうと、座標に行列を掛けることで座標を変換することを言います。

座標として良く用いるのは下の図の左側の直交座標だと思いますが、線形変換では、この座標を行列によって右側のような異なる座標に変換します。

線形変換により座標が変換される様子

で、座標が変換されるので、それに伴って画像も下の図のように変形することになります。

線形変換により画像が変形する様子

どのように変形するかは、座標に掛ける行列の成分によって決まります。2次元座標を線形変換する際は2x2の行列が利用され、要は下記の abcd の値によって画像がどのように変形するかが決まることになります。

$$ \left ( \begin{array}{cc} a & b \\  c  & d  \end{array} \right ) $$

例えば a = 2b = 0c = 0d = 0.5 とすれば、画像の幅が 2 倍になり、画像の高さが 0.5 倍になります。つまり、これにより画像のリサイズが実現できることになります

線形変換により画像がリサイズされる様子

また、a = 1b = 1c = 0d = 1 とすれば、画像が平行四辺形に歪むことになります。これはスキューと呼ばれる処理になります。

線形変換により画像がスキューされる様子

こんな感じで行列の成分 abcd を変更することで、画像の様々な変形を実現することができます。

ただ、具体的にこれらの成分の値をどう変更すれば、画像がどのように変形されるかはイメージしにくいですよね?

今回紹介するアプリを利用すれば、この行列の成分 abcd を変更した際の画像の線形変換による変形を実演し、その結果を画像として視覚的に確認することができます。

ですので、行列の各成分をどのように変更すれば画像がどのように変形するかが直感的に理解できると思います!

ちなみに、線形変換について大雑把に解説させていただきましたが、下記ページではもうちょっと詳しく説明しているので、線形変換について詳しく知りたい方は下記ページを参照していただければと思います(C言語向けの解説ページですが、特に前半の線形変換の解説についてはプログラミング言語関係なく読めると思います)。

C言語で画像をアフィン変換

おすすめ参考書(PR)

また、線形変換について学ぶのであれば、下記の ゼロから学ぶ線形代数 がオススメです!

この本は、私を線形代数好きにしてくれた本であり、”直感的に” 行列や線形代数について理解することができます!

スポンサーリンク

アプリの外観

続いて、アプリの見た目や操作方法について解説していきます。

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

アプリ起動直後のウィンドウ

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

画像を操作するためのウィジェットとは、具体的には、行列の成分 abcd を調整するためのスケールウィジェットと、画像の読み込みや画像の保存を行うためのボタンウィジェットになります。

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

画像の読み込み

画像の線形変換を行うためには、まずは「画像読み込み」ボタンを押します。

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

選択した画像がキャンバスに描画される様子

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

青色と赤色の矢印は2つの座標軸を表しており、線形変換によって座標がどのように変換されていくかも分かるようにしています(画像等を扱う座標においては、縦軸の正方向は下方向になります。数学等で扱う座標とは向きが反対なので注意してください)。

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

画像の線形変換

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

スライダーを動かせば、そのスライダーの位置に応じて行列の成分が設定され、その行列を用いて線形変換が実行されます。

そして、その線形変換した結果の画像がウィンドウ左側のキャンバスに表示されます(スライダーの位置を移動させる度に自動的に線形変換が実行され、表示画像も変化するようになっています)。

線形変換後の画像がキャンバスに描画される様子

またスケールのスライダーを動かせば、それに応じて青色と赤色の矢印も更新され、座標軸が変化していく様子も確認できるようになっています。

線形変換を行うためのスケールは4つ用意しており、上側から順番に、下記の行列における abcd を設定するスケールとなっています。このスケールのスライダーの位置に応じて行列の成分が設定され、設定後の行列により線形変換が実行されます。

$$ \left ( \begin{array}{cc} a & b \\  c  & d  \end{array} \right ) $$

デフォルトでは、全スケールともに設定可能な値は -2  〜 2 とさせていただいています(0.1 刻みで変更可能)。もちろん、後述で紹介するスクリプトを変更すれば、これらの設定可能な値も変更可能です。

スポンサーリンク

線形変換後の画像の保存

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

ファイルパスを指定すれば、ボタンを押した時点のスケールの設定値に応じた線形変換が実行され、線形変換後の画像がそのファイルパスに保存されることになります。

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

ですので、キャンバスに表示される画像はリサイズによりぼやける可能性がありますが、保存される画像はキャンバスに表示される画像よりも綺麗になります。

また、線形変換により画像内に余白が出来る場合があり、この場合は余白を透明になるようにしています。

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

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

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

画像の線形変換の実演アプリのスクリプト

ここまで紹介してきた「画像の線形変換の実演アプリ」のスクリプトは下記のようになります。

画像の線形変換の実演アプリ
import tkinter
import tkinter.filedialog
from PIL import Image, ImageTk
import numpy

WIDTH = 400
HEIGHT = 400

MAX_VALUE = 2


class LinearTransformation:

	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)

		# a設定用スケール
		self.scale_a = tkinter.Scale(
			scale_frame,
			orient=tkinter.HORIZONTAL,
			state=tkinter.DISABLED,  # 無効化
			from_=-MAX_VALUE,  # -MAX_VALUEから
			to=MAX_VALUE,  # MAX_VALUEまで
			resolution=0.1,  # 0.1ずつ変化
			command=self.update,
			label="a"
		)
		self.scale_a.pack()

		# b設定用スケール
		self.scale_b = tkinter.Scale(
			scale_frame,
			orient=tkinter.HORIZONTAL,
			state=tkinter.DISABLED,  # 無効化
			from_=-MAX_VALUE,  # -MAX_VALUEから
			to=MAX_VALUE,  # MAX_VALUEまで
			resolution=0.1,  # 0.1ずつ変化
			command=self.update,
			label="b"
		)
		self.scale_b.pack()

		# c設定用スケール
		self.scale_c = tkinter.Scale(
			scale_frame,
			orient=tkinter.HORIZONTAL,
			state=tkinter.DISABLED,  # 無効化
			from_=-MAX_VALUE,  # -MAX_VALUEから
			to=MAX_VALUE,  # MAX_VALUEまで
			resolution=0.1,  # 0.1ずつ変化
			command=self.update,
			label="c"
		)
		self.scale_c.pack()

		# d設定用スケール
		self.scale_d = tkinter.Scale(
			scale_frame,
			orient=tkinter.HORIZONTAL,
			state=tkinter.DISABLED,  # 無効化
			from_=-MAX_VALUE,  # -MAX_VALUEから
			to=MAX_VALUE,  # MAX_VALUEまで
			resolution=0.1,  # 0.1ずつ変化
			command=self.update,
			label="d"
		)
		self.scale_d.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_width = MAX_VALUE * (self.load_image.width + self.load_image.height)
		max_height = MAX_VALUE * (self.load_image.width + self.load_image.height)

		# 線形変換後の画像がキャンバス内に収まるようにリサイズ後のサイズを計算
		ratio = min(self.width / max_width, self.height / max_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_a.config(state=tkinter.NORMAL)
		self.scale_b.config(state=tkinter.NORMAL)
		self.scale_c.config(state=tkinter.NORMAL)
		self.scale_d.config(state=tkinter.NORMAL)
		self.save_button.config(state=tkinter.NORMAL)

		# 画像読み込み直後は単位行列にしておく
		self.scale_a.set(1)
		self.scale_b.set(0)
		self.scale_c.set(0)
		self.scale_d.set(1)

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

		# 現在設定されている値を取得
		a = self.scale_a.get()
		b = self.scale_b.get()
		c = self.scale_c.get()
		d = self.scale_d.get()

		# 取得した係数で線形変換を実行
		transformed_image = self.affine(self.canvas_image, a, b, c, d)

		self.canvas.delete("all")  # 一旦図形削除

		if transformed_image is not None:
			# 線形変換後の画像が取得できた場合

			# 線形変換後の画像をキャンバスに描画
			self.tk_image = ImageTk.PhotoImage(transformed_image)

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

		# ベクトル (a, c) の線を描画するための座標を計算
		unit_length = min(self.width / 2, self.height / 2)
		x1 = self.width / 2
		y1 = self.height / 2
		x2 = x1 + unit_length * a / MAX_VALUE
		y2 = y1 + unit_length * c / MAX_VALUE

		# ベクトル (a, c) を描画
		self.canvas.create_line(
			x1, y1, x2, y2,
			fill="blue",
			arrow=tkinter.LAST,
			arrowshape=(20, 20, 10),
			width=4
		)

		# ベクトル (b, d) の線を描画するための座標を計算
		x1 = self.width / 2
		y1 = self.height / 2
		x2 = x1 + unit_length * b / MAX_VALUE
		y2 = y1 + unit_length * d / MAX_VALUE

		# ベクトル (b, d) を描画
		self.canvas.create_line(
			x1, y1, x2, y2,
			fill="red",
			arrow=tkinter.LAST,
			arrowshape=(20, 20, 10),
			width=4
		)

	def save(self):
		'''線形変換した画像を保存する'''

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

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

		# 現在設定されている値を取得
		a = self.scale_a.get()
		b = self.scale_b.get()
		c = self.scale_c.get()
		d = self.scale_d.get()

		# 取得した係数でアフィン変換を実行
		transformed_image = self.affine(self.load_image, a, b, c, d)

		if transformed_image is not None:

			# 線形変換に成功した場合のみ指定されたパスに保存
			transformed_image.save(file_path)

		else:
			print("線形変換に失敗しました")


	def affine(self, image, a, b, c, d):
		'''imageを成分a,b,c,dの行列で線形変換する'''

		# 線形変換後の画像のサイズを求める
		transformed_width = round(abs(a) * image.width + abs(b) * image.height)
		transformed_height = round(abs(c) * image.width + abs(d) * image.height)
		size = (transformed_width, transformed_height)

		# 線形変換用の行列を作成
		transform_matrix = numpy.array([
			[a, b, 0],
			[c, d, 0],
			[0, 0, 1]
		])

		# 線形変換前の平行移動用の行列を作成
		pre_translate_matrix = numpy.array([
			[1, 0, -image.width / 2],
			[0, 1, -image.height / 2],
			[0, 0, 1]
		])

		# 線形変換後の平行移動用の行列を作成
		post_translate_matrix = numpy.array([
			[1, 0, transformed_width / 2],
			[0, 1, transformed_height / 2],
			[0, 0, 1]
		])

		# 3つの行列の積を取った結果をアフィン変換用の行列とする
		tmp_matrix = numpy.matmul(transform_matrix, pre_translate_matrix)
		matrix = numpy.matmul(post_translate_matrix, tmp_matrix)

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

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

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

		# 線形変換後画像を返却
		return affined_image


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

画像の線形変換の実演アプリのスクリプトの解説

次は 画像の線形変換の実演アプリのスクリプト で紹介したスクリプトにおいて、ポイントになる点を解説していきたいと思います。

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

ただし、ページの最初にも説明したように、今回の「画像の線形変換の実演アプリ」の作りは下記ページで紹介している「画像の平行四辺形化アプリ」と非常に似ています。

画像の平行四辺形化を行うアプリの紹介ページアイキャッチ 【Python】画像の平行四辺形化アプリを作成する

2つのアプリで画像に対して実行する処理は異なりますが、結局どちらも画像に対して線形変換を行なっています。

平行四辺形化アプリでは、この線形変換の時に下記の行列を用いています。

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

要は、a = 1b = tan(h)c = tan(v)d = 1 とした時の線形変換を行うことで、画像の平行四辺形化を実現しています。

で、この bc の値を決める時に角度 hv が必要なので、それをスケールウィジェットで設定できるようにしています。

ですので、「画像の線形変換の実演アプリ」と上記ページで紹介している「画像の平行四辺形化アプリ」とはほぼ同じ作りであり、大きく異なるのは下記の2点くらいだと思います。

  • スケールの数
    • 画像の線形変換の実演アプリ:
      • 4つ(abc d 設定用)
    • 画像の平行四辺形化アプリ:
      • 2つ(角度 hv 設定用)
  • 線形変換時の行列
    • 画像の線形変換の実演アプリ:
      • $$ \left ( \begin{array}{cc} a & b \\  c  & d  \end{array} \right ) $$
    • 画像の平行四辺形化アプリ:
      • $$ \left ( \begin{array}{cc} 1 & \tan(h)  \\  \tan (v)  & 1 \end{array} \right ) $$

そのため、ここからのスクリプトの解説においては「画像の平行四辺形化」と異なる点についてのみ解説し、同じ点に関しては上記の「画像の平行四辺形化アプリ」の解説ページを参照する形で解説させていただくようにさせていただきたいと思います。

上記の2つの違いを認識し、さらに「平行四辺形化」を「線形変換」に置き換えて読んでいただければ、おそらく解説内容は理解していただけると思います。

スポンサーリンク

ウィジェットの作成

では、スクリプトの解説をしていきたいと思います。

まず、画像の線形変換の実演アプリのスクリプト において、ウィジェットの作成は createWidgets メソッドの中で行っています。

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

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

ウィンドウ上に作成と配置が行われるウィジェット

複雑なレイアウトを実現するため、本アプリではラベルフレームウィジェットを利用しています。

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

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

また、各スケールウィジェットにおいては、-MAX_VALUEMAX_VALUE までの値を 0.1 刻みでのみ設定できるよう、下記のオプションを指定しています(MAX_VALUE はスクリプトの先頭付近で設定しているグローバル変数で、デフォルトは 2 になります)。

  • from_-MAX_VALUE
  • toMAX_VALUE
  • resolution0.1

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

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

また、スケールウィジェットやボタンウィジェットを作成する際には、state オプションの指定による “アプリ起動時のスケールと画像保存ボタンの無効化” および、command オプションの指定による “ウィジェットが操作された時に実行するメソッドの設定” を行なっています。

これらの詳細についてはそれぞれ 画像の平行四辺形化アプリ の解説ページの下記の節で解説していますので、必要に応じて参照していただければと思います。

画像の読み込み

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

この画像の読み込みに関しては、”画像のリサイズ” と “スケールの初期値の設定” を除けば平行四辺形化アプリとほぼ同じ作りになっていますので、これら以外の解説については下記を参照していただければと思います。

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

読み込んだ画像のリサイズに関してもほぼ平行四辺形アプリと同じなのですが、リサイズ後の画像の計算方法が異なるため、その点について解説していきます。

今回作成する画像の線形変換実演アプリにおいては、”線形変換後” の画像がキャンバスからはみ出ないように、画像のリサイズを行います。

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

線形変換後の画像がキャンバスからはみ出てしまう様子

そのため、スケールで設定可能な値から「線形変換後に最大となるサイズ」を求め、そのサイズから逆算し、線形変換後の画像がキャンバスに収まるように画像のリサイズを行うようにしています。

具体的には、線形変換後の最大の画像の幅 max_width と最大の画像の高さ max_height は、下記の行列演算により求めることができます(widthheight は、それぞれ読み込んだ画像の幅と高さとなります)。

$$ \left ( \begin{array}{c} max\_width \\ max\_height \end{array} \right ) = \left ( \begin{array}{cc} max\_a &  max\_b \\  max\_c  & max\_d  \end{array} \right ) \left ( \begin{array}{c} width \\ height \end {array} \right ) $$

この行列演算の結果は下記のようになります。

$$ \left ( \begin{array}{c} max\_width \\ max\_height \end{array} \right ) =  \left ( \begin{array}{c} max\_a * width + max\_b * height \\ max\_c * width + max\_d * height \end {array} \right ) $$

行列の成分 ad は共にスケールで設定可能な値が MAX_VALUE であるため、結局は max_widthmax_height は下記のように計算することができます。

  • max_width = MAX_VALUE * (width + height)
  • max_height = MAX_VALUE * (width + height)

行列の各成分の上限値を個別に設定する場合は計算式を変更する必要がありますが、基本は行列演算を解くことで最大サイズを求めることができると思います。

上記の考え方に基づいて画像のリサイズを行なっているのが、load メソッドの下記部分となります。

画像のリサイズ
# 線形変換後の画像のサイズの最大サイズを設定
max_width = MAX_VALUE * (self.load_image.width + self.load_image.height)
max_height = MAX_VALUE * (self.load_image.width + self.load_image.height)

# 線形変換後の画像がキャンバス内に収まるようにリサイズ後のサイズを計算
ratio = min(self.width / max_width, self.height / max_height)
resize_size = (
	round(ratio * self.load_image.width),
	round(ratio * self.load_image.height)
)

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

スケールの初期値設定

load メソッドの最後では、行列の各成分が下記となるようにスケールウィジェットの初期値を設定するようにしています。

$$ \left ( \begin{array}{cc} 1 & 0 \\  0  & 1  \end{array} \right ) $$

所謂「単位行列」という行列になるように、abcd の設定を行なっています。これにより、画像読み込み直後には、読み込んだ画像のままの形状で画像をキャンバスに描画することができます(リサイズは行なっていますが)。

このスケールウィジェットの初期値の設定を行なっているのが、load メソッドの下記部分となります。

スケールの初期値設定
# 画像読み込み直後は単位行列にしておく
self.scale_a.set(1)
self.scale_b.set(0)
self.scale_c.set(0)
self.scale_d.set(1)

画像の更新

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

この update メソッドでは、スケールで設定された値に応じて線形変換を実行し、キャンバスの画像と矢印を線形変換後のものに更新する処理を行なっています。

といっても、線形変換の実行に関しては、後述で紹介する affine メソッドを実行するだけであり、基本的な作りは下記の画像の平行四辺形化アプリの 画像の更新 とほぼ同じ作りになっています。

ただ、座標軸を表す矢印を描画する点が異なるので、その点について解説していきたいと思います。

座標軸を表す矢印の描画

前述の通り、線形変換では座標の変換が行われます。この座標の変換がどのように行われているかが分かりやすくするために、座標軸を表す青色と赤色の矢印の描画を行なっています。

線形変換後の画像がキャンバスに描画される様子

具体的には、線形変換時に用いる下記の行列において、

$$ \left ( \begin{array}{cc} a & b \\  c  & d  \end{array} \right ) $$

キャンバスの中心からベクトル (a, c) とベクトル (b, d) の方向に矢印を描画するようにしています(これらのベクトルは基底ベクトルと呼ばれます)。

この矢印の描画により、縦軸と横軸の方向が線形変換により変化していく様子が確認できるようになります(画像を扱う際に用いる縦軸の正方向は下方向になるので注意してください)。

縦軸と横軸の方向が変化していく様子

矢印の長さに関しては、ベクトル (a, c) とベクトル (b, d) の長さに応じて変化するようにしており、ベクトルの長さ / MAX_VALUE1 となる時に “キャンバスの幅 or 高さ” の小さい方の長さになるようにしています。

これにより、行列の成分 abcd を変更することで矢印の長さも変化し、矢印が長くなった方向に座標が広がっていく様子も確認できるようになります。

縦軸と横軸の長さが変化していく様子

これらの矢印はキャンバスの create_line メソッドにより描画することができ、具体的には update メソッドの下記部分で矢印の描画を行なっています。

座標軸を表す矢印の描画
# ベクトル (a, c) の線を描画するための座標を計算
unit_length = min(self.width / 2, self.height / 2)
x1 = self.width / 2
y1 = self.height / 2
x2 = x1 + unit_length * a / MAX_VALUE
y2 = y1 + unit_length * c / MAX_VALUE

# ベクトル (a, c) を描画
self.canvas.create_line(
	x1, y1, x2, y2,
	fill="blue",
	arrow=tkinter.LAST,
	arrowshape=(20, 20, 10),
	width=4
)

# ベクトル (b, d) の線を描画するための座標を計算
x1 = self.width / 2
y1 = self.height / 2
x2 = x1 + unit_length * b / MAX_VALUE
y2 = y1 + unit_length * d / MAX_VALUE

# ベクトル (b, d) を描画
self.canvas.create_line(
	x1, y1, x2, y2,
	fill="red",
	arrow=tkinter.LAST,
	arrowshape=(20, 20, 10),
	width=4
)

create_line メソッドの第1引数と第2引数には線の始点となる座標、第3引数と第4引数には線の終点となる座標を指定します。

特に第3引数と第4引数に指定する値を行列の成分 abcd から求めることで、線形変換後の座標軸の描画を実現しています。

また、create_line は名前の通り線を描画するメソッドになりますが、arrow をオプションを指定することで線を矢印にすることが可能です。

create_line のような図形を描画するメソッドや、arrow のような図形描画時に指定するオプションに関しては下記ページでまとめていますので、詳しく知りたい方は下記ページを参照していただければと思います。

tkinterキャンバスに図形を描画する方法解説ページのアイキャッチ Tkinterの使い方:Canvasクラスで図形を描画する

スポンサーリンク

画像の保存

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

この save メソッドは、画像の平行四辺形化アプリのものとほぼ同じですので、解説については下記を参照していただければと思います。

画像の線形変換

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

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

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

  • image:線形変換を行う対象の PIL 画像オブジェクト
  • a:行列の a
  • b:行列の b
  • c:行列の c
  • d:行列の d

で、この affine メソッドも、下記で解説している画像の平行四辺形化アプリの skew メソッドと作りが似ています。

前述の通り、画像の平行四辺形化も線形変換で実現可能であり、この線形変換は PIL における transform メソッドにより実行可能です。

ただ、transform メソッドの引数に指定する sizedata が平行四辺形化の時とは異なります。

また、画像の平行四辺形化の時とは少し異なった方法で画像の平行移動を行っています。

ですので、ここからは、平行四辺形化の時とは異なる下記の3つに絞って解説をさせていただきたいと思います。

  • transform メソッドへの引数 size の指定
  • transform メソッドへの引数 data の指定
  • 画像の平行移動

ちなみに画像の平行移動に関しては、平行四辺形化の時はちょっとイレギュラーな方法で実現しており、このページで紹介するものの方が一般的な平行移動の方法になると思います。

transform メソッドへの引数 size の指定

まず、アフィン変換を行う transform メソッドの引数 size に関しては、画像の読み込み で解説したように線形変換を実行すると画像のサイズが変化しますので、この変換後の画像のサイズを指定する必要があります。

具体的には、行列の成分 abcd に対し、線形変換後の画像は下記のような幅 transformed_width と高さ transformed_height に変化します(widthheight は線形変換前の画像の幅と高さになります)。

  • transformed_widthabs(a) * width + abs(b) * height
  • transformed_heightabs(c) * width + abs(d) * height

したがって、transform メソッドの引数 size には、上記で求めた幅と高さをタプルにしたものを指定すれば良いことになります。

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

線形変換後の画像サイズの計算
# 線形変換後の画像のサイズを求める
transformed_width = round(abs(a) * image.width + abs(b) * image.height)
transformed_height = round(abs(c) * image.width + abs(d) * image.height)
size = (transformed_width, transformed_height)

transform メソッドの引数 data の指定

続いて transform メソッドの引数 data に関して考えていきたいと思います。

今回は、ここまでも説明してきた通り、下記の行列を用いて線形変換を行います。

$$ \left ( \begin{array}{cc} a & b \\  c  & d  \end{array} \right ) $$

この行列を用いて線形変換を行うため、まずは下記の numpy 配列を作成します。

線形変換を行うための行列
# 線形変換用の行列を作成
transform_matrix = numpy.array([
	[a, b, 0],
	[c, d, 0],
	[0, 0, 1]
])

2x2の行列ではなく、3x3の行列を作成していますが、これは後述で説明する平行移動を実現するためです。

また、画像の平行四辺形化(画像の平行四辺形化アプリ) でも解説している通り、transform メソッドの引数 data には上記のような行列をそのまま指定するのではなく、用意した行列から逆行列を求め、さらにそれを1次元のタプルに変換してから指定する必要があります。

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

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

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

numpy.linalg.inv が引数で指定した逆行列を求める関数になります。

逆行列の作成に失敗して例外 numpy.linalg.LinAlgError を発生させる可能性があるので、その場合は None を返却して関数を終了するようにしています。おそらく、この例外は引数に指定した行列の行列式が 0 となる or 0 に限りなく近くなる場合に発生するものだと思います。

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

線形変換の実行

以上を踏まえると、線形変換を行うメソッド affine は下記のように記述することができます。 

線形変換の実行
def affine(self, image, a, b, c, d):
	'''imageを成分a,b,c,dの行列で線形変換する'''

	# 線形変換後の画像のサイズを求める
	transformed_width = round(abs(a) * image.width + abs(b) * image.height)
	transformed_height = round(abs(c) * image.width + abs(d) * image.height)
	size = (transformed_width, transformed_height)

	# 線形変換用の行列を作成
	transform_matrix = numpy.array([
		[a, b, 0],
		[c, d, 0],
		[0, 0, 1]
	])

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

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

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

	# 線形変換後画像を返却
	return affined_image

一応この affine メソッドでも線形変換は行えるのですが、画像の平行四辺形化(画像の平行四辺形化アプリ) の時と同様に対応が不十分で、スケールの設定値を負の値にすると画像が途切れてしまうことになります。

スケールを負の値に設定すると画像が途切れてしまう様子

次は、この画像の途切れを 画像の平行四辺形化(画像の平行四辺形化アプリ) の時と同様、平行移動を行うことで解決していきたいと思います。

画像の平行移動

transform メソッドでは画像の左上を原点としてアフィン変換が行われるので、abcd が負の値の場合、画像が負の方向に広がってしまい画像が途切れることになってしまいます。

画像が負の方向に広がって画像が途切れてしまう様子

これは、画像を平行移動して画像の中心を原点の位置に移動させ、

線形変換前に画像を平行移動する様子

さらに、平行移動後の画像に対して線形変換を実行し(画像の中心を原点として線形変換が実行される)、

平行移動後の画像に対して線形変換を行う様子

その後に、線形変換後の画像の左上が、平行移動前&線形変換前の画像の左上の位置に来るように平行移動してやることで解決することができます。

線形変換後の画像を平行移動する様子

より具体的には、1回目の平行移動では横方向と縦方向それぞれに対し、下記の分だけ移動を行います。

  • 横方向:-線形変換前の画像の幅 / 2
  • 縦方向:-線形変換前の画像の高さ / 2

平行移動もアフィン変換で実現することができ、この平行移動を実現するためには、下記の行列を用いることになります。

線形変換前の平行移動用の行列
# 線形変換前の平行移動用の行列を作成
pre_translate_matrix = numpy.array([
	[1, 0, -image.width / 2],
	[0, 1, -image.height / 2],
	[0, 0, 1]
])

さらに、2回目の平行移動では横方向と縦方向それぞれに対し、下記の分だけ移動を行います。

  • 横方向:+線形変換後の画像の幅 / 2
  • 縦方向:+線形変換後の画像の高さ / 2

この平行移動を実現するためには、下記の行列を用いることになります。

線形変換後の平行移動用の行列
# 線形変換後の平行移動用の行列を作成
post_translate_matrix = numpy.array([
	[1, 0, transformed_width / 2],
	[0, 1, transformed_height / 2],
	[0, 0, 1]
])

また、アフィン変換用の行列として平行移動用の行列と線形変換用の行列との積の結果を準備してやれば、平行移動と線形変換を一度のアフィン変換で同時に実行することが可能です(この辺りは 画像の平行四辺形化(画像の平行四辺形化アプリ) で解説しています)。

今回は、下記の順番で処理を行うため、

  1. pre_translate_matrix での平行移動
  2. transform_matrix での線形変換
  3. post_translate_matrix での平行移動

NumPy で行列の積の演算を行う matmul関数を用いて、下記のようにアフィン変換用の行列 matrix を生成することになります。

行列の積演算
# 3つの行列の積を取った結果をアフィン変換用の行列とする
tmp_matrix = numpy.matmul(transform_matrix, pre_translate_matrix)
matrix = numpy.matmul(post_translate_matrix, tmp_matrix)

あとは、この matrix の逆行列を求め、さらに一次元のタプルに変換したものを transform メソッドの引数 data に指定してやれば、線形変換前後に並行移動が行われれるようになります。

そしてこれにより、abcd が負の値であっても画像の途切れを防ぐことができるようになります。

以上をまとめると、画像の途切れは、先ほど示した affine メソッドを下記のように変更することで防ぐことができます(変更後の affine メソッドは、画像の線形変換の実演アプリのスクリプト で示した affine メソッドと同じものになります)。

平行移動と線形変換を行うように変更
def affine(self, image, a, b, c, d):
	'''imageを成分a,b,c,dの行列で線形変換する'''

	# 線形変換後の画像のサイズを求める
	transformed_width = round(abs(a) * image.width + abs(b) * image.height)
	transformed_height = round(abs(c) * image.width + abs(d) * image.height)
	size = (transformed_width, transformed_height)

	# 線形変換用の行列を作成
	transform_matrix = numpy.array([
		[a, b, 0],
		[c, d, 0],
		[0, 0, 1]
	])

	# 線形変換前の平行移動用の行列を作成
	pre_translate_matrix = numpy.array([
		[1, 0, -image.width / 2],
		[0, 1, -image.height / 2],
		[0, 0, 1]
	])

	# 線形変換後の平行移動用の行列を作成
	post_translate_matrix = numpy.array([
		[1, 0, transformed_width / 2],
		[0, 1, transformed_height / 2],
		[0, 0, 1]
	])

	# 3つの行列の積を取った結果をアフィン変換用の行列とする
	tmp_matrix = numpy.matmul(transform_matrix, pre_translate_matrix)
	matrix = numpy.matmul(post_translate_matrix, tmp_matrix)

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

	# 以降は "線形変換の実行" で示したものから変更なし

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

まとめ

このページでは、Python で作成した「画像の線形変換の実演アプリ」の紹介・アプリのスクリプトの紹介・スクリプトの解説を行いました!

座標に対して行列を掛けることで線形変換を行うことでき、さらにそれにより画像を変形することができます。

ただ、具体的にどのような行列を使えばどのように画像が変形されるかがちょっとイメージ湧きにくいので、今回は実際に行列の成分を変更しながら変形後の画像を確認できるアプリを作ってみました!

今回は線形変換を例としてアプリを作成しましたが、参考書や教科書だけだとイメージが湧きにくいことも、今回の例のように具体的な結果の実演を行うことでイメージが湧きやすくなり、もっと深く理解できると思いますし、イメージが湧くことで更に楽しく学べると思います!

また、自身の理解が正しいかどうかを確認するのにも使えますので、気になる処理や数式があれば、ぜひ今回紹介したような実演アプリの作成に挑戦してみてください!

特に今回のように、パラメータを調整しながら動作を確認したいような場合は、Python の場合は tkinter のスケールウィジェットが便利だと思います。下記ページで使い方を紹介しているので、使い方を知りたい方は読んでみてください。

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

また、結果の表示にはキャンバスへの図形の描画が便利ですので、こちらも詳しく知りたい方は下記ページをぜひ読んでみてください!

tkinterキャンバスに図形を描画する方法解説ページのアイキャッチ Tkinterの使い方:Canvasクラスで図形を描画する

今回は解説の中で「線形変換」「行列」「ベクトル」「行列式」という線形代数関連の言葉がたくさん出てきましたが、これらを知っておくと、特に画像を扱うプログラミングで役に立つ可能性が非常に高いです。

おすすめ書籍(PR)

記事内でも紹介しましたが、特に線形変換や行列式に関して学ぶのであれば、下記の ゼロから学ぶ線形代数 がオススメです!ちょっと古い本ですが、”直感的に” 理解することを重視した書籍であり、線形代数について直感的に理解したいのであれば、この本も読んでみると良いと思います!

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