Python コードで画像に枠線をつけるコードを使って GUI アプリにしてみた。

前回作成した「Python コードで画像に枠線をつける」Python コードを毎回実行するのが面倒になったので、 GUI アプリにしてみました。

前回の記事は下記からご覧ください。

https://qiita.com/IoriGunji/items/742aa978433bebed0788

Github にプロジェクトを公開しています。
よろしければご利用ください。

https://github.com/IoriGunji/Imageliner

# 環境

今回の環境は下記のとおりです。

## 開発端末

- **OS**： Windows 11 Pro
- **CPU**： Intel Core i7-9750H
- **メモリ**： 64GB
- **GPU**： NVIDIA GeForce RTX 2070 Max-Q

## Python 環境

- **Python**： 3.11.0
- **ライブラリ**：
  - pillow
  - webcolors
  - tkinterdnd2
  - pyinstaller

# コードの紹介

画像に枠線をつけるコードは前回と同じものになります。

https://qiita.com/IoriGunji/items/742aa978433bebed0788#%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89

## ライブラリのインストール

ライブラリのインストールはお使いの環境にあわせて行ってください。

### pip の場合

```shell
pip install pillow
pip install webcolors
pip install tkinterdnd2
pip install pyinstaller
```

### poetry の場合

事前に `pyproject.toml` の Python バージョンを修正する必要があります。

```diff
[tool.poetry.dependencies]
- python = "^=3.11"
+ python = ">=3.11,<3.13"
```

修正を行い、上書き保存したら、モジュールのインストールを行います。

```shell
poetry add pillow
poetry add webcolors
poetry add tkinterdnd2
poetry add pyinstaller
```

:::note info
Poetry について
Poetry の詳細は、下記の記事を参考にしてください。
https://qiita.com/IoriGunji/items/290db948c11fdc81046a
:::

## ソースコード

Python のソースコードは下記のとおりです。
`image_liner.py` については前回のコードを参考にしてください。

https://qiita.com/IoriGunji/items/742aa978433bebed0788#%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89

```gui.py
import sys
import re
import configparser
import tkinter as tk
import tkinter.messagebox as mb
from tkinterdnd2 import DND_FILES, TkinterDnD
from imageliner import image_liner

def main():
    config = read_config()
    gui(config)

# GUI ウィンドウ
def gui(config):
    # ウィンドウ設定
    root = TkinterDnD.Tk()
    root.title('ImageLiner')
    root.geometry('330x100')
    default_color = root.cget("bg")

    # 変数宣言
    color = tk.StringVar()
    color.set(config['color'])
    size = tk.StringVar()
    size.set(config['size'])

    # ボーダー設定
    border_frame = tk.Frame(root, height=80, width=150, pady=10, padx=10, relief=tk.SOLID, bg=default_color, bd=1)
    border_frame.place(x=10, y=10)

    border_color_label = tk.Label(border_frame, text='Color：')
    border_color_label.place(x=0, y=10)
    border_color_input = tk.Entry(border_frame, textvariable=color, readonlybackground='white', justify=tk.CENTER)
    border_color_input.configure(validate='key', vcmd=(border_color_input.register(pre_validation_color), '%s', '%P'))
    border_color_input.place(x=50, y=10, width=60, height=21)

    border_size_label = tk.Label(border_frame, text='Size  ：')
    border_size_label.place(x=0, y=35)
    border_size_input = tk.Entry(border_frame, textvariable=size, readonlybackground='white', justify=tk.CENTER)
    border_size_input.configure(validate='key', vcmd=(border_size_input.register(pre_validation_size), '%s', '%P'))
    border_size_input.place(x=50, y=35, width=60, height=21)
    border_size_unit = tk.Label(border_frame, text='px')
    border_size_unit.place(x=110, y=35)

    # ドロップエリア
    drop_frame = tk.Frame(root, height=80, width=150, pady=10, padx=10, relief=tk.SOLID, bg=default_color, bd=1)
    drop_frame.drop_target_register(DND_FILES)
    drop_frame.dnd_bind('<<Drop>>', lambda e: drop(e.data, color.get(), size.get()))
    drop_frame.place(x=170, y=10)

    drop_label = tk.Label(drop_frame, text='* Drop image file here')
    drop_label.place(x=65, y=30, anchor=tk.CENTER)

    # 各種タイトル
    border_label = tk.Label(root, text='Border settings')
    border_label.place(x=20, y=0)
    dorp_label = tk.Label(root, text='Drop files')
    dorp_label.place(x=180, y=0)

    # 閉じるボタンの制御
    root.protocol("WM_DELETE_WINDOW", lambda: exit(color.get(), size.get()))

    # GUI描画
    root.mainloop()

# 画像処理
def drop(files, color, size):
    try:
        validation_color(color)
        validation_size(size)
    except Exception as e:
        mb.showerror('Error', e)
        return

    files = dnd2_parse_files(files)
    color = '#' + color
    size = int(size)

    for file in files:
        for ext in image_liner.IMAGE_EXTS:
            if re.search(f'\.{ext}$', file):
                image_liner.image_edit(file, size, color)

# DnD2 ファイルパスの解析
def dnd2_parse_files(files_str):
    start = 0
    length = len(files_str)
    files = []
    while start < length:
        if files_str[start] == '{':
            end = files_str.find('}', start+1)
            file = files_str[start+1 : end]
            start = end + 2
        else:
            end = files_str.find(' ', start)
            if end < 0:
                file = files_str[start:]
                start = length
            else:
                file = files_str[start : end]
                start = end + 1
        files.append(file)
    return files

# 終了処理
def exit(color, size):
    try:
        validation_color(color)
        validation_size(size)
    except Exception as e:
        print(e)
        sys.exit()
    save_config(color, size)
    sys.exit()

# バリデーションチェック
def pre_validation_color(before_word, after_word):
    return (len(after_word) <= 6 or len(after_word) == 0)

def pre_validation_size(before_word, after_word):
    return ((after_word.isdecimal() or after_word == '') and (len(after_word) <= 4 or len(after_word) == 0))

def validation_color(color):
    if len(color) != 6:
        raise ValueError("Color code is not 6 digits!")
    if not re.match(r'^[0-9a-fA-F]{6}$', color):
        raise ValueError("Color code is invalid!")

def validation_size(size):
    if not size.isdecimal():
        raise ValueError("Size is not a number!")
    if int(size) <= 0:
        raise ValueError("Size is invalid!")

# 設定の読み込み
def read_config():
    config = configparser.ConfigParser()
    config.read('config.ini', encoding='utf-8')
    default = config['DEFAULT']
    configs = {
        'color': default.get('color') if default.get('color') != None else ''
        , 'size': default.get('size') if default.get('size') != None else ''
    }
    return configs

# 設定の保存
def save_config(color, size):
    config = configparser.ConfigParser()
    config.set('DEFAULT', 'color', color)
    config.set('DEFAULT', 'size', size)
    with open('config.ini', 'w', encoding='utf-8') as configfile:
        config.write(configfile)

if __name__ == "__main__":
    main()
```

## ディレクトリ構成

ディレクトリ構成は下記のようになっています。

└ プロジェクトディレクトリ
　└ imageliner 
　　├ gui.py
　　└ image_liner.py

## 使い方

1. `gui.py` を実行すると下記のような GUI が表示されます。
   ![imageliner_gui_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212972/0be9e6bc-596e-71f5-972e-6f59389d9270.png)

1. 枠線の設定を行います。
   - `Color` にカラーコードを指定します。（例：`808080`）
   - `Size` に枠線の太さを指定します。（例：`1`）

1. `Drop files` の枠線内に加工したい画像をドラッグドロップします。
1. 変換された画像は、元画像と同一ディレクトリ内の `./resized` フォルダに出力されます。

# PyInstaller

PyInstaller で exe ファイルにビルドする方法を紹介します。

## hook-tkinterdnd2.py のダウンロード

今回はモジュールに TkinterDnD2 を利用しているため PyInstaller を利用する場合は、 hook-tkinterdnd2.py が必要になります。
Github からダウンロードして利用してください。

https://github.com/pmgagne/tkinterdnd2

`hook-tkinterdnd2.py` を `gui.py` や `image_liner.py` のスクリプトと同一のディレクトリに保存します。

## ディレクトリ構成

ディレクトリ構成は下記のようになっています。

└ プロジェクトディレクトリ
　└ imageliner 
　　├ gui.py
　　├ image_liner.py
　　└ hook-tkinterdnd2.py

## PyInstaller の実行

PyInstaller で実行ファイルを作成する場合は、次のオプションを追加します。

```shell
--additional-hooks-dir [hook-tkinterdnd2.py が保存されているディレクトリのパス]
```

### pip の場合

```powershell
pyinstaller ./imageliner/gui.py --name ImageLiner --onefile --noconsole --collect-data tkinterdnd2 --additional-hooks-dir ./imageliner
```

### Poetry の場合

```powershell
poetry run pyinstaller ./imageliner/gui.py --name ImageLiner --onefile --noconsole --collect-data tkinterdnd2 --additional-hooks-dir ./imageliner
```



# 参考文献

下記のサイトを参考にさせて頂きました。

https://magicode.io/taraku3/articles/20c53c1f06cf4131b452271f214a73de

https://teratail.com/questions/226925