【Python】画像のトリミング(クロップ・切り取り)アプリを作ってみる【GUI】

画像トリミングアプリの開発方法の解説ページアイキャッチ

このページでは Python で画像のトリミング(クロップ・切り取り)を行う GUI アプリの作り方・サンプルスクリプトの説明をしていきたいと思います。

今回開発していくアプリの大枠は下記ページで解説している「画像回転アプリ」と同様のものになります。事前にコチラに目を通しておいていただけると、より解説がわかりやすくなると思います。

簡単な画像処理アプリ開発の解説ページアイキャッチ【Python】簡単な GUI 画像処理アプリ(画像回転アプリ)を作ってみる

画像回転アプリとは異なり、マウス操作を受けつけたり、キャンバス上の座標を考慮して画像処理を行う必要があり、この辺りがこのアプリ開発のポイントになります。

開発するアプリ

今回開発するアプリについてまず解説します。

アプリの出来上がりは下のアニメーションのようになります。

アプリの完成イメージ

アプリの画面

このページで紹介するアプリの画面は下図のようなものになります。

アプリの画面構成

画像表示用にキャンバスを2つ、メッセージ表示用にラベルを1つ、ユーザーから指示を受け付けるためにボタンを1つ用意しています。

アプリの動作

今回開発するアプリでは主に行うことは「画像の読み込み」と「画像のトリミング」です。

「画像の読み込み」に関しては下記ページと同じ動作かつ同じ作りになっていますので、このページではこの「画像の読み込み」に関しての解説は省略させていただきます。

簡単な画像処理アプリ開発の解説ページアイキャッチ【Python】簡単な GUI 画像処理アプリ(画像回転アプリ)を作ってみる

アプリでは左側のキャンバスはマウスによって範囲を選択することができるようにしています。

トリミング範囲を選択する様子

マウスのボタンをクリックすることで範囲の選択が開始され、マウスを動かして選択範囲を変更し、マウスのボタンを離すと選択範囲が確定されます。

この選択範囲が確定されたタイミング(つまりマウスのボタンが離されたタイミング)で画像のトリミングを実行し、トリミング結果が右側のキャンバスに表示されます。

選択範囲内の画像のみが表示される様子

画像処理アプリのクラス設計

クラス設計に関しても下記ページの画像回転アプリとほぼ同じ構成になっています。

簡単な画像処理アプリ開発の解説ページアイキャッチ【Python】簡単な GUI 画像処理アプリ(画像回転アプリ)を作ってみる

ここでは画像回転アプリとの差分のみをクラス毎に解説していきます。

Model クラス

Model クラスにおける画像回転アプリ同様に画像を扱うクラスです。画像回転アプリとは異なり、画像の回転ではなく画像のトリミングを行う機能を提供します。

画像のトリミング

画像のトリミングは  PILPillow) の Image クラスが提供する crop メソッドを利用します。

crop メソッドは画像の指定範囲のみを切り出すメソッドです。

image が参照する PILPillow) 画像の座標(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 > x2y1 > y2 でなければダメ
    • 逆の関係の場合はトリミング後画像がサイズ 0 になる
  • 終了座標はトリミングされない
    • トリミング画像に含まれるのは(x1, y1)から(x2 - 1, y2 - 1)のピクセルのみ
  • トリミング後画像のサイズは幅 x2x1、高さ y2y1 になる

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"
)

トリミング範囲が再度指定された際には、下記のように tagselection_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]

描画の依頼

また ControllerView に対して画像の描画と選択範囲の描画の依頼を行います。これらの描画は 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()

まとめ

このページでは画像トリミングアプリの作成方法の解説やサンプルスクリプトの紹介を行いました。

ここまで解説してきたように、画像トリミングアプリを作成するためには、下記のような処理が必要になります。

  • 画像のトリミングを行う
  • トリミング範囲を選択できるようにする
  • 選択範囲を描画する
  • 座標の変換を行う(キャンバス上から画像上へ)

必要な処理が多くなった分、プログラミングを行うことで身に付く力も多くなります。

是非このページを参考にして画像トリミングアプリの作成、さらにはトリミングアプリを応用したさらなるアプリ開発に挑戦してみてください!

コメントを残す

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