Python+OpencvでPathfinder:Kingmakerのポートレートを作成する

## 概要
Owlcat gamesからD＆D派生のパスファインダーTRPGを元にしたCRPGが発売されている。

ゲームそのものは素晴らしい出来なのだが、いかんせんポートレートの数が少ない。
公開されてるModは趣味が偏ってるし、自前のポートレート作成を簡単にやりたいんじゃ。
ロードス島とかゴブスレ再現パーティーで冒険したい。

~~あとアンフェア難易度の癒しが欲しい~~

## 作業環境

ハード:
CPU : 11th Gen Intel(R) Core(TM) i7-11370H @ 3.30GHz 3.00 GHz
GPU : NVIDIA GeForce RTX 3070 Laptop (コードでは未使用)
RAM : 40.0GB

ソフト: 
OS :  windows10 64bit
pyenv : 2.64.8
python : 3.8.9
Visual Studio Code : 1.60.2


使用ライブラリ:
numpy : 1.21.2
opencv-python : 4.5.3.56

## ポートレートの仕様について

+ Fulllength, Medium, Smallの3枚の画像が必要
+ それぞれには既定かつ変更不可のサイズ、形式(PNG)がある
+ 画像のファイル名も変更不可  
+ 制限を満たすなら別々の画像から切り出してもよい

デフォルトで入っている説明ファイル
![1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/3bdb7377-eb20-99e4-4734-f1b534ecd124.png)
[Nexus Modに公開されているポートレートModの一例](https://www.nexusmods.com/pathfinderkingmaker/mods/5?tab=files)
![2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/3d064eda-ba12-fc6e-d808-a941c09a3ec7.png)

[ポートレートの追加方法はここを参照](https://www.himajin-block30.com/entry/2021/05/22/063158)



## 機能要件
必須機能:

+ 1枚の入力画像をサイズごとに切り出して保存する
+ GUIで選択範囲確認しながら操作したい

あると便利な機能:

*  画像のリサイズ >> 大きすぎる画像を小さくしたり、小さい画像を大きくしたり 
*  画像を切り出す枠のリサイズ >> 枠が固定だとはみ出たりするかも

## 実装
```python
import cv2
import numpy as np
import os
import glob


def imread(filename, flags=cv2.IMREAD_COLOR, dtype=np.uint8):
    """
    https://qiita.com/SKYS/items/cbde3775e2143cad7455参照
    エラー吐いてくれるように変更

    Parameters
    ----------
    filename : str
        ファイル名.
    flags : int or cv2 flag, optional
        読み込み色の指定. The default is cv2.IMREAD_COLOR.
    dtype : np.type, optional
        読み込み時のデータ型指定.
        C言語規格なのでnpから渡す. The default is np.uint8.

    Returns
    -------
    img : 2d array
        読み込み画像.

    """

    n = np.fromfile(filename, dtype)
    img = cv2.imdecode(n, flags)
    return img


def imwrite(filename, img, params=None):
    """


    Parameters
    ----------
    filename : str
        ファイル名.
    img : 2d array
        書き込みたい画像データ.
    params :  optional
        imencodeに渡すパラメータ.形式やクオリティの指定. The default is None.

        ex)
            params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
            quality: int
                1~100まで.高いほど高品質できれい.



    Returns
    -------
    bool

    """

    ext = os.path.splitext(filename)[1]
    result, n = cv2.imencode(ext, img, params)

    if result:
        with open(filename, mode="w+b") as f:
            n.tofile(f)
        return True
    else:
        return False


class application:
    def __init__(self, img_size=(954, 1792), result_dir="result"):
        """
        初期設定

        Parameters
        ----------
        img_size : tuple(int, int), optional
            ウィンドウ表示する画像のサイズ.
            自分のモニター画面サイズより少し小さくすると見やすい.
            The default is (954, 1792). >> 1080*1920モニター
        result_dir : str, optional
            . The default is "result".

        Returns
        -------
        None.

        """

        self.org_img = None
        self.img = None
        self.resize_img = None
        self.savedir = None
        self.result_dir = result_dir

        self.drawing = False
        self.setup = False
        self.mode = "Fulllength"

        self.IMG_SIZE = img_size
        GREEN = (0, 255, 0)
        RED = (0, 0, 255)
        BLUE = (255, 0, 0)

        self.COLORPALET = {
            "Small": GREEN,
            "Medium": RED,
            "Fulllength": BLUE,
        }
        self.RECT_SIZE = {
            "Small": None,
            "Medium": None,
            "Fulllength": None,
        }
        self.SIZE = {
            "Small": (185, 242),
            "Medium": (330, 432),
            "Fulllength": (692, 1024),
        }

    def resize(self):
        """
        IMG_SIZEの設定値と入力画像サイズを比べて、モード指定後リサイズする
        画面の方が大きい >> 元画像を拡大する >> cv2.INTER_CUBIC
        画像の方が大きい >> 元画像を縮小する >> cv2.INTER_AREA

        拡大縮小時のモード指定について(opencv tutorial 画像の幾何変換)
        https://whitewell.sakura.ne.jp/OpenCV/py_tutorials/py_imgproc/py_geometric_transformations/py_geometric_transformations.html#geometric-transformations

        self.fは枠サイズの倍率　fxに変えてもいい

        Returns
        -------
        bool
            DESCRIPTION.

        """
        Y, X = self.IMG_SIZE
        y, x = self.org_img.shape[:2]
        fx = Y / y
        if Y > y:
            mode = cv2.INTER_CUBIC
        elif y > Y:
            mode = cv2.INTER_AREA
        else:
            return False
        self.f = 0.92  # fx
        self.resize_img = cv2.resize(
            self.org_img,
            dsize=None,
            fx=fx,
            fy=fx,
            interpolation=mode,
        )
        self.img = self.resize_img.copy()
        return True

    def draw_rect(self):
        """
        枠の描画関数

        Returns
        -------
        None.

        """

        self.img = self.resize_img.copy()
        size_x, size_y = self.SIZE[self.mode]
        rect_x, rect_y = (
            int(self.ix + size_x * self.f) + 1,
            int(self.iy + size_y * self.f) + 1,
        )

        self.RECT_SIZE[self.mode] = (self.ix, self.iy, rect_x, rect_y)
        for rect_mode in self.RECT_SIZE.keys():
            if self.RECT_SIZE[rect_mode] is not None:
                x, y, xx, yy = self.RECT_SIZE[rect_mode]
                cv2.rectangle(
                    self.img,
                    (x, y),
                    (xx, yy),
                    self.COLORPALET[rect_mode],
                    1,
                )

    def rect_func(self, x):
        """
        run関数でself.setupがTrueになっていなければ実行しない.

        Parameters
        ----------
        x : TYPE
            DESCRIPTION.

        Returns
        -------
        None.

        """
        if self.setup:
            self.f = cv2.getTrackbarPos("scope", "track bar") / 1000
            self.draw_rect()

    def img_resize(self, x):
        self.f_r = cv2.getTrackbarPos("resize", "image") / 1000
        self.resize_img = cv2.resize(
            self.org_img,
            dsize=None,
            fx=self.f_r,
            fy=self.f_r,
            interpolation=cv2.INTER_AREA,
        )
        self.img = self.resize_img.copy()

    def saveimg(self):
        """
        画像をPNGで出力する.
        allでフラグチェックし、3枚そろっていなければ警告文と足りない枠サイズを表示する.

        Returns
        -------
        None.

        """
        if all([self.RECT_SIZE[_key] for _key in self.RECT_SIZE.keys()]):
            self.make_savedir(self.savedir)
            for rect_mode in self.RECT_SIZE.keys():
                x, y, xx, yy = self.RECT_SIZE[rect_mode]
                convert_rect = self.SIZE[rect_mode]
                if convert_rect[0] > xx - x:
                    mode = cv2.INTER_CUBIC
                else:
                    mode = cv2.INTER_AREA
                convert_img = cv2.resize(
                    self.resize_img[y:yy, x:xx],
                    convert_rect,
                    interpolation=mode,
                )
                imwrite(os.path.join(self.savedir, f"{rect_mode}.png"), convert_img)
        else:
            print("Save command must be selected in 3 modes.")
            [
                print(f"None select : {_key}")
                for _key in self.RECT_SIZE.keys()
                if self.RECT_SIZE[_key] is None
            ]

    def make_savedir(self, dirname):
        os.makedirs(
            dirname,
            exist_ok=True,
        )

    def mouse(self, event, x, y, flags, param):
        """
        マウスコールバック関数
        イベントタイプや座標を取得し他の処理へ渡す

        Parameters
        ----------
        event :
            opencvのマウス動作タイプ
        x : int
            イベント時のx座標.
        y : int
            イベント時のy座標.
        flags :
            key同時押し操作フラグの論理和.
        param :
            コールバック関数へ渡される引数.
            参照渡しされているので渡し元の変数を書き換える.
            クラス構造で設計しているので未使用

        Returns
        -------
        None.

        """
        if event == cv2.EVENT_LBUTTONDOWN:
            self.drawing = True
            self.ix, self.iy = x, y
            self.draw_rect()
        elif event == cv2.EVENT_MOUSEMOVE:
            if self.drawing == True:
                self.ix, self.iy = x, y
                self.draw_rect()
        elif event == cv2.EVENT_LBUTTONUP:
            self.drawing = False

    def run(self, img_path):
        """
        処理したい画像1枚のパスを渡すと、画像を表示する.
        画像中の任意位置をクリックすることで切り出し位置の指定ができる.

        key操作:
            s : Small枠の指定モード. 枠:緑色
            m : Medium枠の指定モード. 枠:赤色
            f : Fulllength枠の指定モード. 起動直後の初期設定モード. 枠:青色
            1 : save
            r : 枠の指定、リサイズ用トラックバー等の全リセット、初期化
            q or ESC : 終了

        track bar操作:
            track　bar は1000で割った値を倍率として切り出し枠の調整を行う.
            ただし、現在指定しているモードの枠のみに影響する.

        Parameters
        ----------
        img_path : str

        Returns
        -------
        None.

        """
        self.org_img = imread(img_path)
        self.img = self.org_img.copy()
        self.savedir = os.path.join(
            self.result_dir, os.path.splitext(os.path.basename(img_path))[0]
        )
        cv2.namedWindow("image")
        cv2.namedWindow("track bar")
        cv2.setMouseCallback("image", self.mouse)
        cv2.createTrackbar("scope", "track bar", 920, 3000, self.rect_func)
        self.resize()
        self.setup = True

        while 1:
            cv2.imshow("image", self.img)
            k = cv2.waitKey(1) & 0xFF
            if k == 27 or k == ord("q"):
                break
            elif k == ord("s"):
                self.mode = "Small"
            elif k == ord("m"):
                self.mode = "Medium"
            elif k == ord("f"):
                self.mode = "Fulllength"
            elif k == ord("1"):
                self.saveimg()
            elif k == ord("r"):
                self.RECT_SIZE = {
                    "Small": None,
                    "Medium": None,
                    "Fulllength": None,
                }
                self.resize()
        cv2.destroyAllWindows()


def extcheck(string, exts=("jpeg", "jpg", "png", "bmp")):
    """
    globで集めたファイルの拡張子チェック関数.
    指定はすべて小文字で行う.

    Parameters
    ----------
    string : str
        ファイル名.
    exts : tuple(str, ...) or list(str, ...), optional
        抜き出したい拡張子名をタプル、または配列で指定する.
        The default is ("jpeg", "jpg", "png", "bmp").

    Returns
    -------
    bool

    """
    for ext in exts:
        if string.lower().endswith(ext):
            return True
    return False


if __name__ == "__main__":

    _files = glob.glob(r"requests\**", recursive=True)
    files = [file for file in _files if extcheck(file)]

    for file in files:
        app = application()
        app.run(file)
        del app
```

## 使い方
```python
        key操作:
            s : Small枠の指定モード. 枠:緑色
            m : Medium枠の指定モード. 枠:赤色
            f : Fulllength枠の指定モード. 起動直後の初期設定モード. 枠:青色
            1 : save
            r : 枠の指定、リサイズ用トラックバー等の全リセット、初期化
            q or ESC : 終了 (複数画像ある場合は次の画像を自動表示)

        track bar操作:
            track　bar は1000で割った値を倍率として切り出し枠の調整を行う.
            ただし、現在指定しているモードの枠のみに影響する.
```
1. 適当な画像をrequestsフォルダに入れる(複数可)  
[https://hobbyjapan.co.jp/dd/column/column180228_02.html](https://hobbyjapan.co.jp/dd/column/column180228_02.html)
![soth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/3122f176-40db-fc82-ab3b-203db77bef6e.png)  
D&D公式から借用したエルフ大好きなソス卿

1. スクリプトを実行するとimageとtrack barが立ち上がる  
![4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/838c2ae9-0ad7-cc3d-66c4-531996f6ed2c.png)

1. 適当なimage上でクリックするとFulllength枠（青色）が表示される
![5.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/09b98df2-f079-5240-f300-03098c833727.png)
1. ドラッグで位置調整する
![6.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/509d9768-a6bc-a244-e8bf-8cafa7d28434.png)

1. image内に枠が収まるようtrackbarのscopeを調整する
![7.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/4a4bd159-0284-cfd2-842d-f71d36e3ac45.png)

1. キーボードでmを押すとmedium枠に切り替わるので同様に操作する
![8.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/2a2e1726-9e06-f914-b038-3f25a31d74c5.png)

1. キーボードでsを押すとsmall枠に切り替わるので同様に操作する
![9.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/b3bfc22d-ace3-4076-d2d7-127597dff5d8.png)

1. やり直したい場合、各モードを切り替えてクリックすると枠の位置を調整できる
1. 最初からやりなおしたい場合はrキーを押す
1. 1キーを押すとresultフォルダにデータが出力される
![10.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/6a253469-2428-f008-a683-ccd927bb3e9a.png)

1. C:\Users\AppData\LocalLow\Owlcat Games\Pathfinder Kingmaker\Portraitsにフォルダを置く
![11.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/cdbdcaad-9c2b-09bc-e7d1-6743c14a4f39.png)

1. ゲームを起動してポートレートを選択する
![12.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2236483/a7598d9e-39bf-275b-ce48-293d502c167c.png)




## 参考リンク
+ [Python OpenCV の cv2.imread 及び cv2.imwrite で日本語を含むファイルパスを取り扱う際の問題への対処について](https://qiita.com/SKYS/items/cbde3775e2143cad7455)
+ [【Pathfinder: Kingmaker】カスタムポートレート追加方法](https://www.himajin-block30.com/entry/2021/05/22/063158)