このページでは Python で画像のトリミング(クロップ・切り取り)を行う GUI アプリの作り方・サンプルスクリプトの説明をしていきたいと思います。
今回開発していくアプリの大枠は下記ページで解説している「画像回転アプリ」と同様のものになります。事前にコチラに目を通しておいていただけると、より解説がわかりやすくなると思います。
【Python】簡単な GUI 画像処理アプリ(画像回転アプリ)を作ってみる画像回転アプリとは異なり、マウス操作を受けつけたり、キャンバス上の座標を考慮して画像処理を行う必要があり、この辺りがこのアプリ開発のポイントになります。
開発するアプリ
今回開発するアプリについてまず解説します。
アプリの出来上がりは下のアニメーションのようになります。
アプリの画面
このページで紹介するアプリの画面は下図のようなものになります。
画像表示用にキャンバスを2つ、メッセージ表示用にラベルを1つ、ユーザーから指示を受け付けるためにボタンを1つ用意しています。
スポンサーリンク
アプリの動作
今回開発するアプリでは主に行うことは「画像の読み込み」と「画像のトリミング」です。
「画像の読み込み」に関しては下記ページと同じ動作かつ同じ作りになっていますので、このページではこの「画像の読み込み」に関しての解説は省略させていただきます。
【Python】簡単な GUI 画像処理アプリ(画像回転アプリ)を作ってみるアプリでは左側のキャンバスはマウスによって範囲を選択することができるようにしています。
マウスのボタンをクリックすることで範囲の選択が開始され、マウスを動かして選択範囲を変更し、マウスのボタンを離すと選択範囲が確定されます。
この選択範囲が確定されたタイミング(つまりマウスのボタンが離されたタイミング)で画像のトリミングを実行し、トリミング結果が右側のキャンバスに表示されます。
画像処理アプリのクラス設計
クラス設計に関しても下記ページの画像回転アプリとほぼ同じ構成になっています。
【Python】簡単な GUI 画像処理アプリ(画像回転アプリ)を作ってみるここでは画像回転アプリとの差分のみをクラス毎に解説していきます。
Model
クラス
Model
クラスにおける画像回転アプリ同様に画像を扱うクラスです。画像回転アプリとは異なり、画像の回転ではなく画像のトリミングを行う機能を提供します。
画像のトリミング
画像のトリミングは PIL
(Pillow
) の Image
クラスが提供する crop
メソッドを利用します。
crop
メソッドは画像の指定範囲のみを切り出すメソッドです。
image
が参照する PIL
(Pillow
) 画像の座標(x1
, y1
)から座標(x2
, y2
)の範囲をトリミングするためには、下記のように crop
メソッドを実行します。
# imageはPIL画像オブジェクトを参照
cropped_image = image.crop(
(x1, y1, x2, y2)
)
# cropped_imageがトリミングのPIL画像オブジェクトを参照するようになる
crop
メソッドのポイントは下記になります。
- 引数は4つの要素を持つタプルで指定
- 開始座標
x1
,y1
と終了座標x2
,y2
の4つの要素 - 原点(0, 0)は画像の左上とする
- 開始座標
- 座標は
x1
>x2
、y1
>y2
でなければダメ- 逆の関係の場合はトリミング後画像がサイズ 0 になる
- 終了座標はトリミングされない
- トリミング画像に含まれるのは(
x1
,y1
)から(x2 - 1
,y2 - 1
)のピクセルのみ
- トリミング画像に含まれるのは(
- トリミング後画像のサイズは幅
x2
–x1
、高さy2
–y1
になる
スポンサーリンク
View
クラス
View
クラスは画像回転アプリと比較して、画像の選択範囲を描画するように変更しています。
ウィジェットの作成と配置
今回作成するアプリでは下図のようにウィジェットを作成し、配置しています。ほぼ画像回転アプリと同じですが、画像処理ボタンを無くしています。
選択範囲の描画
このアプリでは画像のトリミングを行う範囲をマウスで選択できるようにしています。View
クラスではこの選択範囲の描画を行います。
選択範囲を指定したタプル selection
に基づいて下記のように tkinter Canvas
クラスが提供する create_rectangle
メソッドにより長方形を描画しています。
# canvas は左側のキャンバスを参照
canvas.create_rectangle(
selection[0],
selection[1],
selection[2],
selection[3],
outline="red",
width=3,
tag="selection_rectangle"
)
トリミング範囲が再度指定された際には、下記のように tag
名 selection_rectangle
から描画済みの長方形(の ID)を取得し、その長方形を削除してから、再度選択範囲の描画を行なっています。
# canvas は左側のキャンバスを参照
objs = canvas.find_withtag("selection_rectangle")
for obj in objs:
canvas.delete(obj)
Controller
クラス
Controller
クラスではマウスに対するイベントを受け付けるように変更しています。
これらのイベントを受け付けて、マウスに対するアクションに対してトリミング選択範囲の制御を行います。
- マウスボタンの押し下げ開始
"ButtonPress"
- マウスの移動
"Motion"
- マウスボタンの押し下げ終了
"ButtonRelease"
トリミング範囲を選択できるのは、左のキャンバスのみにしていますので、左のキャンバスに対して上記のイベントをバインドしてイベントの受付を行います。
例えばマウスボタンの押し下げ開始イベントを受け付けるために、下記を実行しています。
# left_canvasは左側のキャンバスを参照
self.view.left_canvas.bind(
"<ButtonPress>",
self.button_press
)
マウスボタンの押し下げ開始イベント
マウスボタンの押し下げが行われた際には、マウスボタンの押し下げが行われたキャンバス上の座標をトリミング選択範囲の始点として記録します。
マウスの移動
マウスが移動された際には、その移動先座標をトリミング選択範囲の終点として記録します。
マウスボタンの押し下げ終了
マウスボタンの押し下げが終了された際には、押し下げが終了した時点の座標をトリミング選択範囲の終点として確定します。
さらに Model
にその選択範囲で画像のトリミングを行うように処理の依頼を行います。
この際、キャンバス上の選択範囲の座標を画像上の座標に変換してから Model
への処理の依頼を行うようにしています。この座標の変換が、トリミングアプリのポイントの一つになります。
まず「キャンバス上の選択範囲の座標」は、前述の通りマウスの押し下げ開始時点の座標とマウス押し下げ終了時点の座標により決まります。
この「キャンバス上の選択範囲の座標」と「キャンバス上の座標の左上座標」より、「画像上の選択範囲の座標」を計算することができます。
下図は「キャンバス上の選択範囲の左上座標」を表すベクトル a と、「キャンバス上の画像の左上座標」を表すベクトル b から「画像上の選択範囲の左上座標」を表す x を求める様子になります。
要は「キャンバス上の選択範囲の座標」を「キャンバス上の画像の左上座標」から引いたものが、画像上の選択範囲となります。
「キャンバス上の画像の左上座標」は、下記により取得することができます。
create_image
で画像を描画指定したtag
名から ID を取得(Canvas.find_withtag
)- 取得した ID から
create_image
実行時に指定した座標を取得(Canvas.get_coords
)
create_image
実行時に anchor=tkinter.NW
で描画位置の基準をキャンバスの左上にしておけば、上記により描画した画像の左上座標を取得することができます。
ここまで説明してきた座標変換を行うソースコードは下記のようになります。
# left_canvas は左のキャンバスを参照
# selection は選択範囲の始点&終点の座標を参照
# 始点(selection[0], selection[1])
# 終点(selection[2], selection[3])
# 画像の描画位置を取得
objs = self.view.left_canvas.find_withtag("image")
if len(objs) != 0:
draw_coord = self.view.left_canvas.coords(objs[0])
# 選択範囲をキャンバス上の座標から画像上の座標に変換
x1 = self.selection[0] - draw_coord[0]
y1 = self.selection[1] - draw_coord[1]
x2 = self.selection[2] - draw_coord[0]
y2 = self.selection[3] - draw_coord[1]
描画の依頼
また Controller
は View
に対して画像の描画と選択範囲の描画の依頼を行います。これらの描画は 100ms 毎に周期的に依頼を行うようにしています。
選択範囲(を表す長方形)も 100ms 毎に描画されますので、マウスの動きにしたがって選択範囲を表す長方形がどんどん変化することも確認できると思います。
キャンバスのサイズが大きい、画像のサイズが大きい等の場合は、描画処理が 100ms では完了しない可能性もあります。その場合は適度に描画を行う感覚を 100ms から長くしてください。
画像トリミングアプリのサンプルスクリプト
ここまで説明してきた画像トリミングアプリのサンプルスクリプトが下記のようになります。
コメントをできるだけ記載していますので、スクリプトの処理の流れはコメント及び、ここまでの解説を参照していただければと思います。不明点などありましたら気軽にコメントください!
# -*- coding:utf-8 -*-
import tkinter
import tkinter.filedialog
from PIL import Image, ImageTk
class Model():
# 画像処理前か画像処理後かを指定
BEFORE = 1
AFTER = 2
def __init__(self):
# PIL画像オブジェクトを参照
self.before_image = None
self.after_image = None
# Tkinter画像オブジェクトを参照
self.before_image_tk = None
self.after_image_tk = None
def get_image(self, type):
'Tkinter画像オブジェクトを取得する'
if type == Model.BEFORE:
if self.before_image is not None:
# Tkinter画像オブジェクトに変換
self.before_image_tk = ImageTk.PhotoImage(self.before_image)
return self.before_image_tk
elif type == Model.AFTER:
if self.after_image is not None:
# Tkinter画像オブジェクトに変換
self.after_image_tk = ImageTk.PhotoImage(self.after_image)
return self.after_image_tk
else:
return None
def read(self, path):
'画像の読み込みを行う'
# pathの画像を読み込んでPIL画像オブジェクト生成
self.before_image = Image.open(path)
def round(self, value, min, max):
'valueをminからmaxの範囲に丸める'
ret = value
if(value < min):
ret = min
if(value > max):
ret = max
return ret
def crop(self, param):
'画像をクロップ'
if len(param) != 4:
return
if self.before_image is None:
return
print(param)
# 画像上の選択範囲を取得(x1,y1)-(x2,y2)
x1, y1, x2, y2 = param
# 画像外の選択範囲を画像内に切り詰める
x1 = self.round(x1, 0, self.before_image.width)
x2 = self.round(x2, 0, self.before_image.width)
y1 = self.round(y1, 0, self.before_image.height)
y2 = self.round(y2, 0, self.before_image.height)
# x1 <= x2 になるように座標を調節
if x1 <= x2:
crop_x1 = x1
crop_x2 = x2
else:
crop_x1 = x2
crop_x2 = x1
# y1 <= y2 になるように座標を調節
if y1 <= y2:
crop_y1 = y1
crop_y2 = y2
else:
crop_y1 = y2
crop_y2 = y1
# PIL Imageのcropを実行
self.after_image = self.before_image.crop(
(
crop_x1,
crop_y1,
crop_x2,
crop_y2
)
)
class View():
# キャンバス指定用
LEFT_CANVAS = 1
RIGHT_CANVAS = 2
def __init__(self, app, model):
self.master = app
self.model = model
# アプリ内のウィジェットを作成
self.create_widgets()
def create_widgets(self):
'アプリ内にウィジェットを作成・配置する'
# キャンバスのサイズ
canvas_width = 400
canvas_height = 400
# キャンバスとボタンを配置するフレームの作成と配置
self.main_frame = tkinter.Frame(
self.master
)
self.main_frame.pack()
# ラベルを配置するフレームの作成と配置
self.sub_frame = tkinter.Frame(
self.master
)
self.sub_frame.pack()
# キャンバスを配置するフレームの作成と配置
self.canvas_frame = tkinter.Frame(
self.main_frame
)
self.canvas_frame.grid(column=1, row=1)
# ボタンを8位するフレームの作成と配置
self.button_frame = tkinter.Frame(
self.main_frame
)
self.button_frame.grid(column=2, row=1)
# 1つ目のキャンバスの作成と配置
self.left_canvas = tkinter.Canvas(
self.canvas_frame,
width=canvas_width,
height=canvas_height,
bg="gray",
)
self.left_canvas.grid(column=1, row=1)
# 2つ目のキャンバスの作成と配置
self.right_canvas = tkinter.Canvas(
self.canvas_frame,
width=canvas_width,
height=canvas_height,
bg="gray",
)
self.right_canvas.grid(column=2, row=1)
# ファイル読み込みボタンの作成と配置
self.load_button = tkinter.Button(
self.button_frame,
text="ファイル選択"
)
self.load_button.pack()
# メッセージ表示ラベルの作成と配置
# メッセージ更新用
self.message = tkinter.StringVar()
self.message_label = tkinter.Label(
self.sub_frame,
textvariable=self.message
)
self.message_label.pack()
def draw_image(self, type):
'画像をキャンバスに描画'
# typeに応じて描画先キャンバスを決定
if type == View.LEFT_CANVAS:
canvas = self.left_canvas
image = self.model.get_image(Model.BEFORE)
elif type == View.RIGHT_CANVAS:
canvas = self.right_canvas
image = self.model.get_image(Model.AFTER)
else:
return
if image is not None:
# キャンバス上の画像の左上座標を決定
sx = (canvas.winfo_width() - image.width()) // 2
sy = (canvas.winfo_height() - image.height()) // 2
# キャンバスに描画済みの画像を削除
objs = canvas.find_withtag("image")
for obj in objs:
canvas.delete(obj)
# 画像をキャンバスの真ん中に描画
canvas.create_image(
sx, sy,
image=image,
anchor=tkinter.NW,
tag="image"
)
def draw_selection(self, selection, type):
'選択範囲を描画'
# typeに応じて描画先キャンバスを決定
if type == View.LEFT_CANVAS:
canvas = self.left_canvas
elif type == View.RIGHT_CANVAS:
canvas = self.right_canvas
else:
return
# 一旦描画済みの選択範囲を削除
self.delete_selection(type)
if selection:
# 選択範囲を長方形で描画
canvas.create_rectangle(
selection[0],
selection[1],
selection[2],
selection[3],
outline="red",
width=3,
tag="selection_rectangle"
)
def delete_selection(self, type):
'選択範囲表示用オブジェクトを削除する'
# typeに応じて描画先キャンバスを決定
if type == View.LEFT_CANVAS:
canvas = self.left_canvas
elif type == View.RIGHT_CANVAS:
canvas = self.right_canvas
else:
return
# キャンバスに描画済みの選択範囲を削除
objs = canvas.find_withtag("selection_rectangle")
for obj in objs:
canvas.delete(obj)
def draw_message(self, message):
self.message.set(message)
def select_file(self):
'ファイル選択画面を表示'
# ファイル選択ダイアログを表示
file_path = tkinter.filedialog.askopenfilename(
initialdir="."
)
return file_path
class Controller():
INTERVAL = 50
def __init__(self, app, model, view):
self.master = app
self.model = model
self.view = view
# マウスボタン管理用
self.pressing = False
self.selection = None
# ラベル表示メッセージ管理用
self.message = "ファイルを読み込んでください"
self.set_events()
def set_events(self):
'受け付けるイベントを設定する'
# キャンバス上のマウス押し下げ開始イベント受付
self.view.left_canvas.bind(
"<ButtonPress>",
self.button_press
)
# キャンバス上のマウス動作イベント受付
self.view.left_canvas.bind(
"<Motion>",
self.mouse_motion,
)
# キャンバス上のマウス押し下げ終了イベント受付
self.view.left_canvas.bind(
"<ButtonRelease>",
self.button_release,
)
# 読み込みボタン押し下げイベント受付
self.view.load_button['command'] = self.push_load_button
# 画像の描画用のタイマーセット
self.master.after(Controller.INTERVAL, self.timer)
def timer(self):
'一定間隔で画像等を描画'
# 画像処理前の画像を左側のキャンバスに描画
self.view.draw_image(
View.LEFT_CANVAS
)
# 画像処理後の画像を右側のキャンバスに描画
self.view.draw_image(
View.RIGHT_CANVAS
)
# トリミング選択範囲を左側のキャンバスに描画
self.view.draw_selection(
self.selection,
View.LEFT_CANVAS
)
# ラベルにメッセージを描画
self.view.draw_message(
self.message
)
# 再度タイマー設定
self.master.after(Controller.INTERVAL, self.timer)
def push_load_button(self):
'ファイル選択ボタンが押された時の処理'
# ファイル選択画面表示
file_path = self.view.select_file()
# 画像ファイルの読み込みと描画
if len(file_path) != 0:
self.model.read(file_path)
self.selection = None
# 選択範囲を表示するオブジェクトを削除
self.view.delete_selection(view.LEFT_CANVAS)
# メッセージを更新
self.message = "トリミングする範囲を指定してください"
def button_press(self, event):
'マウスボタン押し下げ開始時の処理'
# マウスクリック中に設定
self.pressing = True
self.selection = None
# 現在のマウスでの選択範囲を設定
self.selection = [
event.x,
event.y,
event.x,
event.y
]
# 選択範囲を表示するオブジェクトを削除
self.view.delete_selection(View.LEFT_CANVAS)
def mouse_motion(self, event):
'マウスボタン移動時の処理'
if self.pressing:
# マウスでの選択範囲を更新
self.selection[2] = event.x
self.selection[3] = event.y
def button_release(self, event):
'マウスボタン押し下げ終了時の処理'
if self.pressing:
# マウスボタン押し下げ終了
self.pressing = False
# マウスでの選択範囲を更新
self.selection[2] = event.x
self.selection[3] = event.y
# 画像の描画位置を取得
objs = self.view.left_canvas.find_withtag("image")
if len(objs) != 0:
draw_coord = self.view.left_canvas.coords(objs[0])
# 選択範囲をキャンバス上の座標から画像上の座標に変換
x1 = self.selection[0] - draw_coord[0]
y1 = self.selection[1] - draw_coord[1]
x2 = self.selection[2] - draw_coord[0]
y2 = self.selection[3] - draw_coord[1]
# 画像をcropでトリミング
self.model.crop(
(int(x1), int(y1), int(x2), int(y2))
)
# メッセージを更新
self.message = "トリミングしました!"
app = tkinter.Tk()
# アプリのウィンドウのサイズ設定
app.geometry("1000x430")
app.title("トリミングアプリ")
model = Model()
view = View(app, model)
controller = Controller(app, model, view)
app.mainloop()
スポンサーリンク
まとめ
このページでは画像トリミングアプリの作成方法の解説やサンプルスクリプトの紹介を行いました。
ここまで解説してきたように、画像トリミングアプリを作成するためには、下記のような処理が必要になります。
- 画像のトリミングを行う
- トリミング範囲を選択できるようにする
- 選択範囲を描画する
- 座標の変換を行う(キャンバス上から画像上へ)
必要な処理が多くなった分、プログラミングを行うことで身に付く力も多くなります。
是非このページを参考にして画像トリミングアプリの作成、さらにはトリミングアプリを応用したさらなるアプリ開発に挑戦してみてください!