Python で tkinter を用いて「画像を平行四辺形に変形するアプリ」を作成しましたので、このページでアプリの紹介・スクリプトの紹介・そしてそのスクリプトの解説をしていきたいと思います。
画像の平行四辺形化アプリの動作は下の動画ようになります。
この画像が歪んでいく様子に心地よさを感じる人もいるのではないでしょうか?
画像を平行四辺形化する動きを見るだけでも心地よいのですが、画像保存できるようにもしているため、平行四辺形化後の画像をキーノートやパワーポイントなどで使用することも可能です。
スクリプトでは tkinter だけでなく、PIL と NumPy も使用していますので、アプリを実行するためにはこれらのモジュールが必要になります。この点はご了承ください。
Contents
作成する画像の平行四辺形化アプリ
では、まずは本ページで作成していく「画像の平行四辺形化アプリ」がどんなアプリであるかについて解説していきます。
アプリの外観
まず、アプリ起動時のウィンドウは下の図のようになります(アプリのウィンドウの背景が白色の場合は、キャンバスとの境目が分かりにくいので注意してください)。
ウィンドウの左側には画像を表示するキャンバスウィジェットを配置しており、右側には画像を操作するためのウィジェットを配置しています。
画像を操作するためのウィジェットとは、具体的には、平行四辺形化を行う際の角度を調整するためのスケールウィジェットと、画像の読み込みや画像の保存を行うためのボタンウィジェットを配置しています。
また、画像を読み込んでいない時は、スケールと「画像保存」ボタンを無効化しており、スライダーの移動やボタンのクリックが効かないようにしています。
スポンサーリンク
画像の読み込み
画像の平行四辺形化を行うためには、まずは「画像読み込み」ボタンを押します。
するとファイル選択ダイアログが表示され、そこで指定されたファイルの画像がキャンバスに表示されます。
キャンバスには平行四辺形化後の画像がキャンバス内に収まるように、画像がリサイズして描画されます(平行四辺形後の画像がキャンバスに収まるようにするため、最初は画像が小さめに表示されます)。
また、画像の読み込みを行った段階で無効化されていたウィジェットが有効化され、これらのウィジェットが操作可能になります。
画像の平行四辺形化
読み込んだ画像の平行四辺形化を行うためには、ウィンドウ右側のスケールウィジェットのスライダーを動かします。
スライダーを動かせば、そのスライダーの位置に応じた角度で平行四辺形化が実行され、実行後の画像がウィンドウ左側のキャンバスに表示されます(スライダーの位置を移動させる度に自動的に平行四辺形化が実行され、表示画像も変化するようになっています)。
平行四辺形化を行うためのスケールは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)の使い方また、他のウィジェットや配置に用いた grid
や pack
メソッドについては下記のページで解説していますので、ウィジェットそのものやオプションの意味合い等を詳しく知りたい方は下記ページをご参照いただければと思います。
各ウィジェット作成時のポイントになるのは、state
オプションの指定と command
オプションの指定だと思います。
state
オプションの指定
今回作成するアプリでは、画像の読み込みが行われていない状態でスケールウィジェットのスライダーの位置の変更や画像保存ボタンのクリックが行われないよう、これらのウィジェット作成時(コンストラクタ実行時)には state=tkinter.DISABLED
を指定するようにしています(「画像読み込み」ボタンを除いて)。
この state=tkinter.DISABLED
を指定しておけば、それらのウィジェットが無効状態となり、スライダーの位置の変更やクリックを行えないようにすることができます。
ただ、ずっと無効状態だとウィジェット用意した意味がありませんので、画像の読み込み完了後に state=tkinter.NORMAL
を指定してウィジェットを通常状態に戻します。これについては後述の 画像の読み込み で解説します。
command
オプションの指定
また、スライダーやボタンウィジェット作成時(コンストラクタ実行時)には command
オプションを指定し、下記の動作が行われた時に自動的にメソッドが実行されるように設定しています。
scale_h
とscale_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_width
:height * abs(tan(h))
add_height
:width * abs(tan(v))
これらの増加量は、元画像のサイズからはみ出る部分の直角三角形の「底辺の長さ」と「斜辺の傾き」から求めることができます。
例えば、水平方向の角度が h
の場合、下の図の青枠のような直角三角形が元画像のサイズからはみ出ることになります。
さらに、この直角三角形において、斜辺の傾きは三角関数を利用すれば tan(h)
で求めることができます(理論的には add_width / height
でも求められるが、現状 add_width
の値が分かっていないので無理)。
さらに、直角三角形における斜辺の傾きとは、底辺の長さが 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_h
と scale_v
)の両方に対して get
メソッドを実行し、両方向の角度を取得するようにしています。
これを行なっているのが、update
メソッドの下記部分になります。平行四辺形化を実行する際に必要になる角度の単位はラジアンであるため、ここで角度の取得と同時にラジアンへの変換を行っています(math.radians
で度をラジアンに変換できます)。
# 現在設定されている角度を取得
h = math.radians(self.scale_h.get())
v = math.radians(self.scale_v.get())
スケールのスライダーの位置(スケールの設定値)の取得については下記ページで解説していますので、詳しく知りたい方は下記ページを参照していただければと思います。
Tkinterの使い方:スケール(Scale)の使い方平行四辺形化の実行
続いて、先ほど取得した角度を用いてキャンバス描画用の画像 canvas_image
を skew
メソッドの実行により平行四辺形化しています。
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
メソッドにより実現することができます。
この 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
) を θ
回転させた位置の座標となります(原点からの距離を変えずに回転)。
つまり、行列を掛けることで回転後の座標を求めることができます。で、これを応用して、回転前の座標の画素を全て回転後の座標にコピーしてやれば、全ての画素が回転後の位置に移動し、結果的に画像全体が回転することになります。
こんな感じで、行列を掛けることで求まる位置に各画素を移動させる(プログラム的にはコピーする)ことで、画像の変形や平行移動を行うのがアフィン変換です。
アフィン変換が面白いのは、行列の成分を変更してやれば、いろんな画像の変形や平行移動が行えるところです。具体的には、下の行列における a
〜 f
を変更することで、さまざまな画像の変形や平行移動が実現できます。
$$ \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 ) $$
もう少し詳しく説明すると、まず上記の行列演算を行えば、X
と Y
を下記の式で表すことができます。
X = x + y * tan(h)
Y = y + x * tan(v)
上式において、例えば v
を 0
に固定すれば tan(v)
は 0
になりますので、上記の行列を用いてアフィン変換を行うことで (x
, y
) の座標の画素が (x + y * tan(h), y)
の位置に移動することになります。
つまり、元の画像の画素が横方向に y * tan(h)
シフトした位置に移動することになりますので、y
が増えるごとに画素が大きくシフトすることになり、下の図のように画像が傾くことになります。
同様に h
を 0
に固定して上記の行列でアフィン変換を行えば、(x
, y
) の座標の画素が (x, y + x * tan(v))
の位置に移動することになり、今度は元画像の各画素が縦方向に x * tan(v)
だけ移動しますので、先ほどとは異なった方向に傾いた画像を得ることができます。
両方向の角度が 0
の場合は、図示するのが難しいので省略しますが、この場合でも同様に画像が傾いて平行四辺形化されることになります。
つまり、画像の平行四辺形化は、上記のような行列を用いてアフィン変換を行うことで実現することができます。
ただ、実際に transform
メソッドを実行する際には、単に上記のような行列を引数に指定するのではなく、逆行列をさらに1次元のタプルに変換したものを指定する必要があったり、画像がはみ出ないように平行移動を行う必要もあったりするので、そのあたりのお作法も交え、ここから transform
メソッドによって平行四辺形化を行う具体的な方法を解説していきたいと思います。
transform
メソッド
まず、アフィン変換を行うことができる transform
メソッドの引数について説明していきます。
transform
メソッドの引数は下記のようになります。
transform(self, size, method, data=None, resample=NEAREST, fill=1, fillcolor=None):
transform
メソッドはアフィン変換以外の変換も行うことができるようですが、アフィン変換を行うことを前提とした場合、各引数の意味合いは下記のようになります。
size
:アフィン変換後の画像のサイズmethod
:Image.AFFINE
固定data
:アフィン変換時に使用する行列の逆行列をタプル化したものresample
:アフィン変換時の補間処理に用いるアルゴリズムfill
:おそらく、アフィン変換時は使用されない(ソースコードから判断)fillcolor
:生じた余白を埋めるための色
次は、特にアフィン変換により平行四辺形化を行う際に重要となる、引数 size
と引数 data
について補足していきます。
引数 size
の指定
まず size
に関しては、平行四辺形化実行後の画像のサイズを指定する必要があります。
画像の読み込み で解説したように、平行四辺形化を実行すると画像のサイズが大きくなりますので、このサイズの増加分を考慮して size
を指定する必要があることになります。
具体的には、水平方向の角度を h
、垂直方向の角度を v
とすれば、平行四辺形化実行後に画像サイズは下記の分だけ増加することになります。
add_width
:height * abs(tan(h))
add_height
:width * 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次元タプルへの変換は、下記の処理を行うことで実現することができます。
# アフィン変換用の行列の逆行列を作成する
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_width
(height * abs(tan(h))
)
- 縦方向:
add_height
(width * abs(tan(v))
)
で、実はこの平行移動もアフィン変換の一種であり、これも transform
メソッドにより実現可能です。
ただし、平行四辺形化を行った後に平行移動を行っても、平行四辺形化後の時点で既に画像が途切れてしまっているため、途切れた後の画像を平行移動することになってしまいます。
したがって、この平行移動は平行四辺形化と同時に行う必要があります。
アフィン変換においては、こういった複数の処理は、変換時に指定する行列に、各々の処理を行うための行列の積の演算結果を指定することで、一度に実行することが可能です。
したがって、平行四辺形化を行うための行列と平行移動を行うための行列を用意し、さらに、これらの行列の積の演算を行えば、平行四辺形化と平行移動を同時に実行するための行列を得ることができます。
あとは、その行列の逆行列の算出および一次元のタプル化を行った結果を 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 を利用すれば、今回紹介したアプリのように、スケールのスライダーでパラメータを変更しながら画像の変化を確認できるので、適切なパラメータを決めるときなどに非常に便利です。
今回はスケールで角度を設定できるようにしましたが、スケールで他のパラメータを変更するようにすることで様々なパラメータ調整を実現できますので、画像処理だけでなく、いろんな場面でのパラメータの検証に応用できるのではないかと思います!
また、今回はアフィン変換を行うために行列などが出てきたため、数学が苦手な人には難しい内容だったかもしれません…。行列をはじめとする線形代数の知識は、特に画像を扱う際に必要になることも多いので、行列の知識は持っておくと今後役に立つ可能性が高いです。
おすすめ書籍(PR)
ちなみに私の線形代数のオススメ書籍は下記の ゼロから学ぶ線形代数 です。私を線形代数好きにしてくれた本であり、「直感的に行列や線形代数について理解したい人」にオススメです!
また、このページでは画像の平行四辺形化に特化したアプリを紹介しましたが、下記ページではもうちょっと一般化して線形変換による画像の変形を実演するアプリを紹介していますので、こちらも興味のある方は是非読んでみてください!
このページで紹介したアプリとほとんど同じような作り方で作成することができます!
【Python】画像の線形変換の実演アプリを作成する