Python で tkinter を用いて「画像の線形変換の実演アプリ」を作成しましたので、このページでアプリの紹介・スクリプトの紹介・そしてそのスクリプトの解説をしていきたいと思います。
画像の「画像の線形変換の実演アプリ」の動作は下の動画ようになります。
ウィンドウ右側のスケールのスライダーの位置を変更することで、線形変換に用いる行列のパラメータを変更しながら画像の線形変換結果を確認することができます。
スクリプトでは tkinter だけでなく、PIL と NumPy も使用していますので、アプリを実行するためにはこれらのモジュールが必要になります。この点はご了承ください。
また、この「画像の線形変換の実演アプリ」は下記ページで紹介している「画像の平行四辺形化アプリ」と非常につくりが似ています。
【Python】画像の平行四辺形化アプリを作成するスケールの数やアフィン変換時に使用する行列が異なるくらいの違いしかありません。
特にスクリプトの解説については重複する部分が多いため、スクリプトの解説においては上記の「画像の平行四辺形化アプリ」との違いを中心に解説させていただきたいと思います。
Contents
作成する “画像の線形変換の実演アプリ”
では、まずは本ページで作成していく「画像の線形変換の実演アプリ」がどんなアプリであるかについて解説していきます。
本アプリで出来ること
本アプリで出来ることとは、アプリの名前から分かるように「画像の線形変換の実演」です。
画像の線形変換は「線形変換による座標変換に伴う画像の変形」といった方がより正確だとは思いますが、今回は単に「画像の線形変換」と呼ばせていただきます。
この「線形変換」とは、大雑把にいうと、座標に行列を掛けることで座標を変換することを言います。
座標として良く用いるのは下の図の左側の直交座標だと思いますが、線形変換では、この座標を行列によって右側のような異なる座標に変換します。
で、座標が変換されるので、それに伴って画像も下の図のように変形することになります。
どのように変形するかは、座標に掛ける行列の成分によって決まります。2次元座標を線形変換する際は2x2の行列が利用され、要は下記の a
・b
・c
・d
の値によって画像がどのように変形するかが決まることになります。
$$ \left ( \begin{array}{cc} a & b \\ c & d \end{array} \right ) $$
例えば a = 2
・b = 0
・c = 0
・d = 0.5
とすれば、画像の幅が 2
倍になり、画像の高さが 0.5
倍になります。つまり、これにより画像のリサイズが実現できることになります
また、a = 1
・b = 1
・c = 0
・d = 1
とすれば、画像が平行四辺形に歪むことになります。これはスキューと呼ばれる処理になります。
こんな感じで行列の成分 a
・b
・c
・d
を変更することで、画像の様々な変形を実現することができます。
ただ、具体的にこれらの成分の値をどう変更すれば、画像がどのように変形されるかはイメージしにくいですよね?
今回紹介するアプリを利用すれば、この行列の成分 a
・b
・c
・d
を変更した際の画像の線形変換による変形を実演し、その結果を画像として視覚的に確認することができます。
ですので、行列の各成分をどのように変更すれば画像がどのように変形するかが直感的に理解できると思います!
ちなみに、線形変換について大雑把に解説させていただきましたが、下記ページではもうちょっと詳しく説明しているので、線形変換について詳しく知りたい方は下記ページを参照していただければと思います(C言語向けの解説ページですが、特に前半の線形変換の解説についてはプログラミング言語関係なく読めると思います)。
C言語で画像をアフィン変換おすすめ参考書(PR)
また、線形変換について学ぶのであれば、下記の ゼロから学ぶ線形代数 がオススメです!
この本は、私を線形代数好きにしてくれた本であり、”直感的に” 行列や線形代数について理解することができます!
スポンサーリンク
アプリの外観
続いて、アプリの見た目や操作方法について解説していきます。
アプリ起動時のウィンドウは下の図のようになります(アプリのウィンドウの背景が白色の場合は、キャンバスとの境目が分かりにくいので注意してください)。
ウィンドウの左側には画像を表示するキャンバスウィジェットを配置しており、右側には画像を操作するためのウィジェットを配置しています。
画像を操作するためのウィジェットとは、具体的には、行列の成分 a
・b
・c
・d
を調整するためのスケールウィジェットと、画像の読み込みや画像の保存を行うためのボタンウィジェットになります。
また、画像を読み込んでいない時はスケールと画像保存ボタンを無効化しており、スライダーの移動やボタンのクリックが効かないようにしています。
画像の読み込み
画像の線形変換を行うためには、まずは「画像読み込み」ボタンを押します。
するとファイル選択ダイアログが表示され、そこで指定されたファイルの画像がキャンバスに表示されます。
キャンバスには、線形変換後の画像がキャンバス内に収まるように画像がリサイズして描画されます(線形変換後の画像がキャンバスに収まるようにするため、最初は画像が小さめに表示されます)。
青色と赤色の矢印は2つの座標軸を表しており、線形変換によって座標がどのように変換されていくかも分かるようにしています(画像等を扱う座標においては、縦軸の正方向は下方向になります。数学等で扱う座標とは向きが反対なので注意してください)。
また、画像の読み込みを行った段階で無効化されていたウィジェットが有効化され、これらのウィジェットが操作可能になります。
画像の線形変換
読み込んだ画像の線形変換を行うためには、ウィンドウ右側のスケールウィジェットのスライダーを動かします。
スライダーを動かせば、そのスライダーの位置に応じて行列の成分が設定され、その行列を用いて線形変換が実行されます。
そして、その線形変換した結果の画像がウィンドウ左側のキャンバスに表示されます(スライダーの位置を移動させる度に自動的に線形変換が実行され、表示画像も変化するようになっています)。
またスケールのスライダーを動かせば、それに応じて青色と赤色の矢印も更新され、座標軸が変化していく様子も確認できるようになっています。
線形変換を行うためのスケールは4つ用意しており、上側から順番に、下記の行列における a
・b
・c
・d
を設定するスケールとなっています。このスケールのスライダーの位置に応じて行列の成分が設定され、設定後の行列により線形変換が実行されます。
$$ \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 = 1
・b = tan(h)
・c = tan(v)
・d = 1
とした時の線形変換を行うことで、画像の平行四辺形化を実現しています。
で、この b
と c
の値を決める時に角度 h
と v
が必要なので、それをスケールウィジェットで設定できるようにしています。
ですので、「画像の線形変換の実演アプリ」と上記ページで紹介している「画像の平行四辺形化アプリ」とはほぼ同じ作りであり、大きく異なるのは下記の2点くらいだと思います。
- スケールの数
- 画像の線形変換の実演アプリ:
- 4つ(
a
・b
・c
・d
設定用)
- 4つ(
- 画像の平行四辺形化アプリ:
- 2つ(角度
h
・v
設定用)
- 2つ(角度
- 画像の線形変換の実演アプリ:
- 線形変換時の行列
- 画像の線形変換の実演アプリ:
- $$ \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_VALUE
〜 MAX_VALUE
までの値を 0.1
刻みでのみ設定できるよう、下記のオプションを指定しています(MAX_VALUE
はスクリプトの先頭付近で設定しているグローバル変数で、デフォルトは 2
になります)。
from_
:-MAX_VALUE
to
:MAX_VALUE
resolution
:0.1
使用している各種ウィジェットや配置に用いた grid
や pack
メソッドについては下記のページで解説していますので、ウィジェットそのものやオプションの意味合い等を詳しく知りたい方は下記ページをご参照いただければと思います。
また、スケールウィジェットやボタンウィジェットを作成する際には、state
オプションの指定による “アプリ起動時のスケールと画像保存ボタンの無効化” および、command
オプションの指定による “ウィジェットが操作された時に実行するメソッドの設定” を行なっています。
これらの詳細についてはそれぞれ 画像の平行四辺形化アプリ の解説ページの下記の節で解説していますので、必要に応じて参照していただければと思います。
state
オプションの指定:command
オプションの指定:
画像の読み込み
画像読み込みボタン(load_button
)が左クリックされた時に実行されるのが、この画像の読み込みであり、この画像の読み込みは load
メソッドで行っています。
この画像の読み込みに関しては、”画像のリサイズ” と “スケールの初期値の設定” を除けば平行四辺形化アプリとほぼ同じ作りになっていますので、これら以外の解説については下記を参照していただければと思います。
読み込んだ画像のリサイズ
読み込んだ画像のリサイズに関してもほぼ平行四辺形アプリと同じなのですが、リサイズ後の画像の計算方法が異なるため、その点について解説していきます。
今回作成する画像の線形変換実演アプリにおいては、”線形変換後” の画像がキャンバスからはみ出ないように、画像のリサイズを行います。
線形変換を行うと画像のサイズが元画像に対して大きくなります。ですので、元画像がキャンバスからはみ出ないようにリサイズしたとしても、線形変換を行うと画像がキャンバスからはみ出る可能性があります。
そのため、スケールで設定可能な値から「線形変換後に最大となるサイズ」を求め、そのサイズから逆算し、線形変換後の画像がキャンバスに収まるように画像のリサイズを行うようにしています。
具体的には、線形変換後の最大の画像の幅 max_width
と最大の画像の高さ max_height
は、下記の行列演算により求めることができます(width
と height
は、それぞれ読み込んだ画像の幅と高さとなります)。
$$ \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 ) $$
行列の成分 a
〜 d
は共にスケールで設定可能な値が MAX_VALUE
であるため、結局は max_width
と max_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 ) $$
所謂「単位行列」という行列になるように、a
・b
・c
・d
の設定を行なっています。これにより、画像読み込み直後には、読み込んだ画像のままの形状で画像をキャンバスに描画することができます(リサイズは行なっていますが)。
このスケールウィジェットの初期値の設定を行なっているのが、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_VALUE
が 1
となる時に “キャンバスの幅 or 高さ” の小さい方の長さになるようにしています。
これにより、行列の成分 a
・b
・c
・d
を変更することで矢印の長さも変化し、矢印が長くなった方向に座標が広がっていく様子も確認できるようになります。
これらの矢印はキャンバスの 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引数に指定する値を行列の成分 a
・b
・c
・d
から求めることで、線形変換後の座標軸の描画を実現しています。
また、create_line
は名前の通り線を描画するメソッドになりますが、arrow
をオプションを指定することで線を矢印にすることが可能です。
create_line
のような図形を描画するメソッドや、arrow
のような図形描画時に指定するオプションに関しては下記ページでまとめていますので、詳しく知りたい方は下記ページを参照していただければと思います。
スポンサーリンク
画像の保存
画像の保存は save
メソッドで行っており、この save
は画像保存ボタンが押された際に実行されるメソッドになります。
この save
メソッドは、画像の平行四辺形化アプリのものとほぼ同じですので、解説については下記を参照していただければと思います。
画像の線形変換
最後に、本アプリのメイン機能とも言える画像の線形変換について解説していきます。
この画像の線形変換は 画像の線形変換の実演アプリのスクリプト における affine
メソッドで実行しています。この affine
メソッドは update
メソッドと save
メソッドから実行されていますので、要はスケールのスライダーの位置の変更や画像保存ボタンクリック時に実行されることになります。
また、affine
メソッドの引数の意味合いは下記のようになっています。
image
:線形変換を行う対象の PIL 画像オブジェクトa
:行列のa
b
:行列のb
c
:行列のc
d
:行列のd
で、この affine
メソッドも、下記で解説している画像の平行四辺形化アプリの skew
メソッドと作りが似ています。
前述の通り、画像の平行四辺形化も線形変換で実現可能であり、この線形変換は PIL における transform
メソッドにより実行可能です。
ただ、transform
メソッドの引数に指定する size
と data
が平行四辺形化の時とは異なります。
また、画像の平行四辺形化の時とは少し異なった方法で画像の平行移動を行っています。
ですので、ここからは、平行四辺形化の時とは異なる下記の3つに絞って解説をさせていただきたいと思います。
transform
メソッドへの引数size
の指定transform
メソッドへの引数data
の指定- 画像の平行移動
ちなみに画像の平行移動に関しては、平行四辺形化の時はちょっとイレギュラーな方法で実現しており、このページで紹介するものの方が一般的な平行移動の方法になると思います。
transform
メソッドへの引数 size
の指定
まず、アフィン変換を行う transform
メソッドの引数 size
に関しては、画像の読み込み で解説したように線形変換を実行すると画像のサイズが変化しますので、この変換後の画像のサイズを指定する必要があります。
具体的には、行列の成分 a
・b
・c
・d
に対し、線形変換後の画像は下記のような幅 transformed_width
と高さ transformed_height
に変化します(width
と height
は線形変換前の画像の幅と高さになります)。
transformed_width
:abs(a) * width + abs(b) * height
transformed_height
:abs(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次元タプルへの変換は、下記の処理を行うことで実現することができます。
# アフィン変換用の行列の逆行列を作成する
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
メソッドでは画像の左上を原点としてアフィン変換が行われるので、a
・b
・c
・d
が負の値の場合、画像が負の方向に広がってしまい画像が途切れることになってしまいます。
これは、画像を平行移動して画像の中心を原点の位置に移動させ、
さらに、平行移動後の画像に対して線形変換を実行し(画像の中心を原点として線形変換が実行される)、
その後に、線形変換後の画像の左上が、平行移動前&線形変換前の画像の左上の位置に来るように平行移動してやることで解決することができます。
より具体的には、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]
])
また、アフィン変換用の行列として平行移動用の行列と線形変換用の行列との積の結果を準備してやれば、平行移動と線形変換を一度のアフィン変換で同時に実行することが可能です(この辺りは 画像の平行四辺形化(画像の平行四辺形化アプリ) で解説しています)。
今回は、下記の順番で処理を行うため、
pre_translate_matrix
での平行移動transform_matrix
での線形変換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
に指定してやれば、線形変換前後に並行移動が行われれるようになります。
そしてこれにより、a
・b
・c
・d
が負の値であっても画像の途切れを防ぐことができるようになります。
以上をまとめると、画像の途切れは、先ほど示した 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の使い方:Canvasクラスで図形を描画する今回は解説の中で「線形変換」「行列」「ベクトル」「行列式」という線形代数関連の言葉がたくさん出てきましたが、これらを知っておくと、特に画像を扱うプログラミングで役に立つ可能性が非常に高いです。
おすすめ書籍(PR)
記事内でも紹介しましたが、特に線形変換や行列式に関して学ぶのであれば、下記の ゼロから学ぶ線形代数 がオススメです!ちょっと古い本ですが、”直感的に” 理解することを重視した書籍であり、線形代数について直感的に理解したいのであれば、この本も読んでみると良いと思います!