【Python】簡易的な仕分け機能付き画像ビューワー作ってみた

#背景
仕事で大量の画像を分類分けしないといけない。
フリーソフトを使えば効率よく仕分けられる気がするが、残念ながら弊社はフリーソフトは原則禁止。
ならば作ってしまえの精神で、**簡易的な仕分け機能付きの画像ビューワー**作ってみた。

#仕上がりイメージ
例：猫画像の中に紛れてしまった犬画像をラベル「1」をつけて抽出する。
![仕分けツールデモ_.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642673/8db3da4d-8fa1-2900-7f3a-84734b558dce.gif)

1. 「読み込み」ボタンを押したあと、フォルダを指定する。するとサブディレクトリを含めたディレクトリ配下の画像すべてを読み込まれる。
2. 「←」「→」で画像を送り、数字キーを押すと表示中の画像に押されたキー番号をラベリングする。
 (ファイル名の下にラベリング=入力したキー（数字）が表示される。ちなみに0を押すとラベリング解除）
3. 「仕分け実行」ボタンでラベリングした内容をもとにフォルダ分けを実行する。
4. その後、「フォルダを開く」ボタンを押すと読み込んだ画像ファイルのフォルダを開くことができる。

#コード全文
仕分けツールの本体はコチラ↓
<details><summary>**仕分けツール**（クリックしてコードを表示）</summary><div>

```py:仕分けツール.py
from file_walker import folder_walker  # 自作関数
from folder_selecter import file_selecter  # 自作関数

import os
import shutil
import subprocess
import tkinter as tk
from tkinter import Label, Tk, StringVar

from PIL import Image, ImageTk  # 外部ライブラリ


# ファイル読み込み - - - - - - - - - - - - - - - - - - - - - - - -
def load_file(event):

    global img_num, item, dir_name

    # ファイルを読み込み
    tex_var.set("ファイルを読み込んでいます...")
    dir_name = file_selecter(dir_select=True)
    if not dir_name == None:
        file_list = folder_walker(dir_name)

    # ファイルから読み込める画像をリストに列挙
    for f in file_list:
        try:
            img_lst.append(Image.open(f))
            filename_lst.append(f)
        except:
            pass

    # ウィンドウサイズに合わせてキャンバスサイズを再定義
    window_resize()

    # 画像変換
    for f in img_lst:

        # キャンバス内に収まるようリサイズ
        resized_img = img_resize_for_canvas(f, image_canvas)

        # tkinterで表示できるように画像変換
        tk_img_lst.append(ImageTk.PhotoImage(
            image=resized_img, master=image_canvas))

    # キャンバスの中心を取得
    c_width_half = round(int(image_canvas["width"]) / 2)
    c_height_half = round(int(image_canvas["height"]) / 2)

    # キャンバスに表示
    img_num = 0
    item = image_canvas.create_image(
        c_width_half, c_height_half,  image=tk_img_lst[0], anchor=tk.CENTER)
    # ラベルの書き換え
    tex_var.set(filename_lst[img_num])

    # 読み込みボタンの非表示
    load_btn.pack_forget()
    # 仕分け実行ボタンの配置
    assort_btn.pack()

# 次の画像へ - - - - - - - - - - - - - - - - - - - - - - - -
def next_img(event):
    global img_num

    # 読み込んでいる画像の数を取得
    img_count = len(tk_img_lst)

    # 画像が最後でないか判定
    if img_num >= img_count - 1:
        pass
    else:
        # 表示中の画像No.を更新して表示
        img_num += 1
        image_canvas.itemconfig(item, image=tk_img_lst[img_num])
        # ラベルの書き換え
        tex_var.set(filename_lst[img_num])
        # ラベリングを表示
        if filename_lst[img_num] in assort_dict:
            assort_t_var.set(assort_dict[filename_lst[img_num]])
        else:
            assort_t_var.set("")


# 前の画像へ - - - - - - - - - - - - - - - - - - - - - - - -
def prev_img(event):
    global img_num

    # 画像が最初でないか判定
    if img_num <= 0:
        pass
    else:
        # 表示中の画像No.を更新して表示
        img_num -= 1
        image_canvas.itemconfig(item, image=tk_img_lst[img_num])
        # ラベルの書き換え
        tex_var.set(filename_lst[img_num])
        # ラベリングを表示
        if filename_lst[img_num] in assort_dict:
            assort_t_var.set(assort_dict[filename_lst[img_num]])
        else:
            assort_t_var.set("")


# ウィンドウサイズからキャンバスサイズを再定義 - - - - - - - - - - - - - - - - -
def window_resize():

    image_canvas["width"] = image_canvas.winfo_width()
    image_canvas["height"] = image_canvas.winfo_height()


# キャンバスサイズに合わせて画像を縮小 - - - - - - - - - - - - - - - - - - - -
def img_resize_for_canvas(img, canvas, expand=False):

    size_retio_w = int(canvas["width"]) / img.width
    size_retio_h = int(canvas["height"]) / img.height

    if expand == True:
        size_retio = min(size_retio_w, size_retio_h)
    else:
        size_retio = min(size_retio_w, size_retio_h, 1)

    resized_img = img.resize((round(img.width * size_retio),
                              round(img.height * size_retio)))
    return resized_img

# 画像表示 - - - - - - - - - - - - - - - - - - - - - - - -
def image_show(event):
    img_lst[img_num].show()


# 画像に対しラベリング - - - - - - - - - - - - - - - - - - - - - - - -
def file_assort(event):

    if str(event.keysym) in ["1", "2", "3", "4", "5", "6", "7", "8", "9"]:
        assort_dict[filename_lst[img_num]] = str(event.keysym)
    elif str(event.keysym) == "0":
        del assort_dict[filename_lst[img_num]]

    # ラベリングを表示
    if filename_lst[img_num] in assort_dict:
        assort_t_var.set(assort_dict[filename_lst[img_num]])
    else:
        assort_t_var.set("")

    print(assort_dict[filename_lst[img_num]])


# フォルダ分け実行 - - - - - - - - - - - - - - - - - - - - - - - -
def assort_go(event):

    global f_dir

    for f in assort_dict:
        # 仕分け前後のファイル名・フォルダ名を取得
        f_dir = os.path.dirname(f)
        f_basename = os.path.basename(f)
        new_dir = os.path.join(f_dir, assort_dict[f])
        new_path = os.path.join(new_dir, f_basename)

        # ディレクトリの存在チェック
        if not os.path.exists(new_dir):
            os.mkdir(new_dir)
        # ファイルの移動実行
        shutil.move(f, new_path)
        # 各種ボタンの表示・非表示
        assort_btn.pack_forget()
        open_folder_btn.pack()

        print(new_path)

# フォルダーを開く - - - - - - - - - - - - - - - - - - - - - - - -
def folder_open(event):
    # パスをエクスプローラーで開けるように変換
    open_dir_name = f_dir.replace("/", "\\")
    # エクスプローラーで開く
    subprocess.Popen(['explorer', open_dir_name])
    # tkinterウィンドウを閉じる
    root.destroy()
 
    print(open_dir_name)


# メイン処理 -------------------------------------------------------
if __name__ == "__main__":

    # グローバル変数
    img_lst, tk_img_lst = [], []
    filename_lst = []
    assort_file_list = []
    assort_dict = {}

    img_num = 0
    f_basename = ""

    # tkinter描画設定
    root = tk.Tk()

    root.title(u"表示・仕分けツール")
    root.option_add("*font", ("Meiryo UI", 11))

    # 読み込みボタン描画設定
    load_btn = tk.Button(root, text="読み込み")
    load_btn.bind("<Button-1>", load_file)
    load_btn.pack()

    # キャンバス描画設定
    image_canvas = tk.Canvas(root,
                             width=640,
                             height=480)

    image_canvas.pack(expand=True, fill="both")

    # 仕分け結果表示
    assort_t_var = tk.StringVar()
    assort_label = tk.Label(
        root, textvariable=assort_t_var, font=("Meiryo UI", 14))
    assort_label.pack()

    # ファイル名ラベル描画設定
    tex_var = tk.StringVar()
    tex_var.set("ファイル名")

    lbl = tk.Label(root, textvariable=tex_var, font=("Meiryo UI", 8))
    lbl["foreground"] = "gray"
    lbl.pack()

    # 右左キーで画像送りする動作設定
    root.bind("<Key-Right>", next_img)
    root.bind("<Key-Left>", prev_img)
    # 「Ctrl」+「P」で画像表示
    root.bind("<Control-Key-p>", image_show)

    # 数字キーで仕分け対象設定
    root.bind("<Key>", file_assort)

    # 仕分け実行ボタン
    assort_btn = tk.Button(root, text="仕分け実行")
    assort_btn.bind("<Button-1>", assort_go)

    # フォルダを開くボタン
    open_folder_btn = tk.Button(root,text="フォルダーを開く")
    open_folder_btn.bind("<Button-1>", folder_open)

    root.mainloop()
```

</div></details>

フォルダを指定する動作、指定フォルダからファイルリストを取得する動作は作成した自作関数を用いた。
上記の仕分けツール.pyと同じフォルダ内に下記.pyファイルを格納しておく。

<details><summary>**file_selector.py**（クリックしてコードを表示）</summary><div>

```python:file_selector.py
import os
import sys

import tkinter as f_tk
from tkinter import filedialog

def file_selecter(ini_folder_path = str(os.path.dirname(sys.argv[0])), 
                                    multiple= False, dir_select = False):
    """
    ダイアログを開いて、ファイルやフォルダを選択する。
    初期フォルダを指定しなかった場合、ファイル自体のフォルダを開く。
    オプションでフォルダ選択、ファイル選択（複数・単一）を選択できる。

    Parameters
    ----------
    ini_folder_path : str
        初期に開くフォルダ。既定値は実行ファイルのフォルダパス
    multiple : bool
        ファイルを複数選択可能にするか否か。既定値はFalseで単一選択。
    dir_select : bool
        フォルダ選択モード。既定値はFalseでファイル選択モードに。
    """

    root_fileselect=f_tk.Tk()
    root_fileselect.withdraw()  # ウィンドウを非表示する

    if  os.path.isfile(ini_folder_path):
        ini_folder_path = os.path.dirname(ini_folder_path) # 初期フォルダ指定にファイル名が入っていた場合、ファイルのフォルダを返す

    if dir_select:
        select_item = f_tk.filedialog.askdirectory(initialdir=ini_folder_path)  # ディレクトリ選択モード

    elif multiple:
        select_item = f_tk.filedialog.askopenfilenames(initialdir=ini_folder_path)  # ファイル（複数）選択モード
    else:
        select_item = f_tk.filedialog.askopenfilename(initialdir=ini_folder_path)  # ファイル（単一）選択モード

    root_fileselect.destroy()

    if not select_item =="":
        return select_item

```

</div></details>

<details><summary>**file_walker.py**（クリックしてコードを表示）</summary><div>

```python:file_walker.py
import os
import pathlib

def folder_walker(folder_path, recursive = True, file_ext = ".*"):
    """
    指定されたフォルダのファイル一覧を取得する。
    引数を指定することで再帰的にも、非再帰的にも取得可能。

    Parameters
    ----------
    folder_path : str
        対象のフォルダパス
    recursive : bool
        再帰的に取得するか否か。既定値はTrueで再帰的に取得する。
    file_ext : str
        読み込むファイルの拡張子を指定。例：".jpg"のようにピリオドが必要。既定値は".*"で指定なし
    """

    p = pathlib.Path(folder_path)

    if recursive:
        return list(p.glob("**/*" + file_ext))  # **/*で再帰的にファイルを取得
    else:
        return list(p.glob("*" + file_ext))  # 再帰的にファイル取得しない
```

</div></details>


#勉強したこと
####Tkinterウィンドウの親子関係
フォルダの選択にもTkinterの`filedialog`を使用しているのだが、最初、フォルダを選択した後にメインウィンドウのrootを描画するようにしていた。そうすると、継承とかの関係なのか、メインのrootがうまく表示されなかったり、アクティブにならなかったりして苦労した。
結局、メインのrootを描画したあとにfiledialogを呼び出すようにしたら問題なくrootも描画できた。
####ガベージコレクションの概念
最初、キャンバスや`tk.StringVar()`を埋め込んだラベルの中身が一切表示されなくて焦った。
色々調べた結果、pythonにはガベージコレクションという概念があり、不要（=アクセスがない）と判断された変数の中身は自動的に削除されてしまう。結果、キャンバスがオブジェクトを参照しようとしたときに、その中身が無くなっているため、描画できないという事態になってしまっていた。
これを回避するためには、あらかじめグローバル変数として読み込むか、クラスのインスタンスにオブジェクトを読み込ませる方法がある。詳細は下記参照。
参考ページ：【Python】Tkinter で画像などのオブジェクトが描画できない時の対処法
https://daeudaeu.com/create_image_problem/
####tkinterウィジェットのパラメータを参照する
ウィジェットのパラメータを参照する方法を探すのに意外と苦労した。
結局、下記のように**ウィジェット名["パラメータ名"]**で取得すればよいだけだった。


```python

canvas_w = int(image_canvas["width"])
```

（今回の場合、ウィンドウサイズを可変にしたため、キャンバスの中心を割り出せるようキャンバスサイズを取得する必要があった。）

#所感・今後
シンプルな動作なので簡単に作れるだろうと思ったていたら、知識不足のため意外と苦労した。
今後は、今回作成したものをベースに、色々修正してより使い勝手のよいツールに仕上げていきたい。具体的には、

+ 設定ファイル（iniファイル）を読み込んで、ファイルを開く場所を記憶させたり、
ラベリングしたキーに基づいてフォルダ名を自動的にラベル名（例えば「01 犬」等）に変換する機能などを付けたい。
+ とりあえずのGUIで作ったので、もう少し使いやすいUIに修正したい。そのため、pack()で配置するのではなく、
grid()で配置できるようになりたい。
+ ズーム機能
+ そもそもpythonの使用許可をもらえてないので、上司に有用性を説明して使用許可を得る。~~（pythonぐらい無条件で使用許可出してくれよ。。。）~~

等。