Python + Tkinterで連番画像ファイルを素早く切り抜くGUI画像トリミングツール

#はじめに
機械学習をはじめました。
固定サイズの画像をたくさん用意する必要がありましたが、自分の用途にあったツールがなかったのでさくっと作りました。
Pythonはあまり書き慣れてないので多目に見ていただけると嬉しいです。

#機能
- 指定ディレクトリ内に入っている連番画像ファイルを読み込み
- マウスで指定したフレームで画像をトリミングし
- トリミングした画像を指定された固定サイズ（例32x64）にリサイズして保存
- トリミングした座標の情報をCSVファイルに保存・起動時に読み込み

**操作としては『マウスでトリミングする範囲と位置を指定して、スペースキー押下』を繰り返すだけで次々とトリミング画像が作成できます。**便利！
固定サイズしか必要なかったので切り抜く大きさは固定にしています。
トリミングした座標を外部CSVファイルに保存しておき、起動時に読み込むことで、作業を中断した場合でも再開できます。

```text:crop.csv
image_0003.png,71,307,199,563
image_0002.png,158,131,286,387
image_0001.png,132,124,260,380
image_0005.png,142,160,270,416
```
![Screenshot from 2018-10-28 15-14-39.png](https://qiita-image-store.s3.amazonaws.com/0/243354/108c9c02-d01e-490c-fb1a-3731394ca728.png)
画面上部には表示中の画像ファイル名が表示されます。
既にトリミング済み情報がある場合は下部に`already cropped`と表示されます。
##ファイル構成（準備）
```
├── crop.py
├── doll
│   ├── input
        ├── image_0001.png
        ├── image_0002.png
        ├── ...
│   ├── output
├── crop.csv
```
画像のファイル名の末尾は連番で4桁の0埋めになっていることを前提としています。
ffmpegなどを利用して動画ファイルから連番画像を作成できます。
参考：[Qiita:ffmpegで連番画像から動画生成 / 動画から連番画像を生成 ~コマ落ちを防ぐには](https://qiita.com/livlea/items/a94df4667c0eb37d859f)
トリミングした画像は`output`ディレクトリに入力画像と同名で保存されます。
**注意：画像サイズは横`_canvas_width`・縦`_canvas_height`以下である必要があります**
（画像を縮小して表示すると座標の計算がいろいろやこしかったので）
画像サイズが大きいときは事前にリサイズしておく必要があります。（以下参考）

```python:resize.py
from PIL import Image
import os
import glob

max_width = 600.0
max_height = 600.0

file_list = glob.glob('./original/image_*.png')

for filepath in file_list:
	filename = os.path.basename(filepath)
	savefilepath = './input/'+ filename

	img = Image.open(filepath)
	w, h = img.size
	if h <= max_height and w <= max_width:
		#no need to crop, just copy file
		img.save(savefilepath)
	else:
		ratio = min(max_width/w, max_height/h)
		print(ratio)
		cw = int(w * ratio)
		ch = int(h * ratio)
		img_resize = img.resize((cw, ch))
		img_resize.save(savefilepath)

	print(savefilepath)
```

##キー操作
|キー|操作|
|---|---|
|→  |次の画像へ移動|
|←|前の画像へ移動|
|スペースキー|現在の画像をトリミング・リサイズして、次の画像へ移動|
|マウスホイール↑|トリミングするフレームのサイズを大きくする|
|マウスホイール↓|トリミングするフレームのサイズを小さくする|

#ソースコード
エラー処理とかはサボっています。
コメントの英語がエグいのであとで修正します。

```python:crop.py

# -*- coding: utf-8 -*-
from Tkinter import *
from PIL import Image
import os
import glob

parentdir = 'doll'
inputdir = 'input'
outputdir = 'output'
imgname_prefix = 'image_'
csvlogfilename = 'crop.csv'


_canvas_width = 600
_canvas_height = 600
_crop_width = 32
_crop_height = 64

#----------------------------------------------------------------------
class MainWindow():

    #----------------
    def __init__(self, main):
        # warning : image must be smaller than canvas size.
        # create image canvas 
        self.canvas = Canvas(main, width=_canvas_width, height=_canvas_height)
        self.canvas.grid(row=0, column=0, columnspan=2, rowspan=4)

        # load image list
        # image file name must be 'prefix + number(0000-9999) .png'
        self.file_list = []
        for i in range(10000):
            filepath = './' + parentdir + '/' + inputdir + '/' + imgname_prefix + ('%04d' % i) + '.png'
            outputpath = './' + parentdir + '/' + outputdir + '/' + imgname_prefix + ('%04d' % i) + '.png'
            if os.path.exists(filepath):
                self.file_list.append((i, filepath, outputpath))

        # load cropping frame information if csv file exists
        self.crop_frame_dic = {}
        self.csvfilepath = './' + parentdir + '/' + csvlogfilename
        if os.path.exists(self.csvfilepath):
            with open(self.csvfilepath) as f:
                for line in f:
                    elements = line.split(',')
                    #filename, sx, sy, ex, ey
                    self.crop_frame_dic[elements[0]] = (int(elements[1]), int(elements[2]), int(elements[3]), int(elements[4]))

        # load first image
        self.now_image_index = 0
        self.nowimage = PhotoImage(file=self.file_list[0][1])
        self.image_on_canvas = self.canvas.create_image(0, 0, anchor=NW, image=self.nowimage)

        # set callback function for key and mouse events
        self.canvas.bind('<Right>', self.right_key_pressed)
        self.canvas.bind('<Left>', self.left_key_pressed)
        self.canvas.bind('<space>', self.space_key_pressed)
        self.canvas.bind("<B1-Motion>", self.mouse_rclick_moving)
        #  mouse event with Windows OS
        root.bind("<MouseWheel>", self.mouse_wheel_moving)
        #  mouse event with Linux OS
        root.bind("<Button-4>", self.mouse_wheel_moving)
        root.bind("<Button-5>", self.mouse_wheel_moving)

        self.canvas.focus_set() #need to recive key events

        # set initial value of crop frame
        self.cropframe_width = 128
        self.cropframe_height = self.cropframe_width * _crop_height / _crop_width
        self.cropframe_centerposx = _canvas_width/2
        self.cropframe_centerposy = _canvas_height/2

        # draw crop frame 
        sx, sy, ex, ey = self.__getCropFrameCoordinate()
        self.crop_rectangle = self.canvas.create_rectangle(sx, sy, ex, ey, outline="blue", width=3)

        # filename label
        self.filenamelabel = Label(self.canvas, bg="black",fg="green",text=self.file_list[0][1],borderwidth=0,font=("",20))
        self.filenamelabel.place(anchor=NW)

        #already exist label
        self.alreadyexistlabel = Label(self.canvas, bg="black",fg="red",text="",borderwidth=0,font=("",20))
        self.alreadyexistlabel.place(relx=0.0, rely=1.0,anchor=SW)

        self.__imageRefresh()
    #----------------
    def right_key_pressed(self, event):
        # open next image
        self.now_image_index += 1
        if self.now_image_index >= len(self.file_list): 
            self.now_image_index = 0

        self.__imageRefresh()

    def left_key_pressed(self, event):
        # open previous image
        self.now_image_index -= 1
        if self.now_image_index < 0: 
            self.now_image_index = len(self.file_list) - 1

        self.__imageRefresh()

    def space_key_pressed(self, event):
        # save current crop image, output logfile, and open next image
        self.__addCropDic()
        self.__saveCropImage()
        self.__saveCropInfoToCSV()
        print("saved")

        self.now_image_index += 1
        if self.now_image_index >= len(self.file_list): 
            self.now_image_index = 0
        
        self.__imageRefresh()

    def mouse_rclick_moving(self, event):
        self.cropframe_centerposx = event.x
        self.cropframe_centerposy = event.y
        self.__cropRectangleRefresh()
            
    def mouse_wheel_moving(self, event):
        delta = 2
        delta_width = delta
        delta_height = delta * _crop_height / _crop_width 
        # respond to Linux or Windows wheel event
        if event.num == 5 or event.delta == -120:
            # mouse wheel down
            self.cropframe_width = max(0, self.cropframe_width - delta_width)
            self.cropframe_height = max(0, self.cropframe_height - delta_height)
        if event.num == 4 or event.delta == 120:
            # mouse wheel up
            self.cropframe_width += delta_width
            self.cropframe_height += delta_height
        self.__cropRectangleRefresh()

    #----------------
    def __imageRefresh(self):
        #change image
        self.nowimage = PhotoImage(file=self.file_list[self.now_image_index][1])
        self.canvas.itemconfig(self.image_on_canvas,image=self.nowimage)

        #check whether cropped image coordinate information already exists
        #ignore wheter cropped image file exists
        inputfilepath = self.file_list[self.now_image_index][1]
        imagefilename = os.path.basename(inputfilepath)
        if imagefilename in self.crop_frame_dic:
            #if cropping coordinate information exists, draw rectangle at the position based on the information
            sx, sy, ex, ey = self.crop_frame_dic[imagefilename]
            self.cropframe_width = ex - sx
            self.cropframe_height = ey - sy
            self.cropframe_centerposx = (sx + ex) / 2
            self.cropframe_centerposy = (sy + ey) / 2
            self.__cropRectangleRefresh()
        
        #update filename label
        self.filenamelabel["text"] = inputfilepath
        #update already cropped label
        if imagefilename in self.crop_frame_dic:
            self.alreadyexistlabel["text"] = "already cropped"
        else:
            self.alreadyexistlabel["text"] = ""
    def __cropRectangleRefresh(self):
        sx, sy, ex, ey = self.__getCropFrameCoordinate()
        self.canvas.coords(self.crop_rectangle, sx, sy, ex, ey)

    def __saveCropImage(self):
        im = Image.open(self.file_list[self.now_image_index][1])
        sx, sy, ex, ey = self.__getCropFrameCoordinate()
        im_crop = im.crop((sx, sy, ex, ey))
        im_crop_resized  = im_crop.resize((_crop_width, _crop_height))
        im_crop_resized.save(self.file_list[self.now_image_index][2], quality=95)

    def __addCropDic(self):
        originalfilename = os.path.basename(self.file_list[self.now_image_index][1])
        sx, sy, ex, ey = self.__getCropFrameCoordinate()
        self.crop_frame_dic[originalfilename] = (sx, sy, ex, ey)

    def __saveCropInfoToCSV(self):
        with open(self.csvfilepath, mode='w') as f:
            for filename, coordinate in self.crop_frame_dic.items():
                f.write(','.join([filename, str(coordinate[0]), str(coordinate[1]), str(coordinate[2]), str(coordinate[3])]) + "\n")
        print("csv saved")

    def __getCropFrameCoordinate(self):
        sx = self.cropframe_centerposx - self.cropframe_width/2
        sy = self.cropframe_centerposy - self.cropframe_height/2
        ex = self.cropframe_centerposx + self.cropframe_width/2
        ey = self.cropframe_centerposy + self.cropframe_height/2
        return sx, sy, ex, ey

#----------------------------------------------------------------------

root = Tk()
MainWindow(root)
root.mainloop()
```

#参考
・Python + Tkinter で作る、GUIな画像トリミングツール
https://qiita.com/MasahikoYasui/items/4bfdfab0ba27c7ca0620