ttkbootstrap レイヤー機能付きの画像表示キャンバスを作ってみた

始まり

ペイントソフトに搭載されているレイヤー機能などをtkinterCanvasで使えるようにしていきます。


きっかけ

本機能(ウィジェット)は、恋愛ADVに特化したOCR開発のデータセット作成フローで使用することを想定しています。

開発中の画面たち

簡易的なウィジェットは以前作ったのですが、画像の拡大ができなかったり、背景画像と文字画像の差分描画ができなかったり、幾つか不便な点がありました。そのため、Font Viewerで文字画像を作成して→スクショして→ペイントソフトに貼り付けて差分描画、という2つのソフトを併用して作業していました。一旦は作業できる環境整備が最優先だったので、当時は受けれていましたが、時間が経つとやっぱり不満です。一番の不満は内製化した気でいるのに外部ソフトに依存している点です。

このような背景があったので、ブレンドモードを指定したレイヤー合成スケール変更機能付きのキャンバスが欲しかったのです。

要件

一般的なペイントソフトの機能を実装すると果てしない気がするので要件は以下に抑えて作っていきます。

  • 機能や挙動、レイアウトはpaint.netを参考
    • 仕様を考えるの面倒&操作感は普段使っているソフトに合わせたい
  • レイヤーは画像のみ扱うことを想定
  • レイヤーの削除は非対応
    • 使用先で削除することがないので要らない
  • ブレンドモードは通常と差分の2種類
    • 恋愛ADVのフォント設定を調べるためのツールに使用する性質上、差分描画さえあれば十分なため
  • 拡大・縮小表示
  • レイヤーウィンドウ
    • 差分の切り替えとかのプロパティ表示に必要なウィンドウ
  • スクロール系の対応

実装

コード行数がそれなりにあり片っ端から説明はするのは面倒なので、手こずった箇所と自分向けメモだけ書いておきます。アウトプットは重要だけどそっちに時間取られ過ぎてコードを書く時間を削るのはなんか好かんのです。

以下は必要最小限のサンプルです。説明はコード内コメントで十分だと思うので割愛。

import tkinter as tk
import ttkbootstrap as ttk
from ttkbootstrap.constants import *

import numpy as np
import numpy.typing as npt
from PIL import Image
from typing import Optional

from reinlib.ttkbootstrap.rein_image_layer_canvas import LayerId, ImageLayerCanvas


class TestViewer(ttk.Window):
    """App
    """
    def __init__(self) -> None:
        super().__init__("Test Viewer", iconphoto=None, minsize=(896, 504))

        self.tk_image_layer_canvas = ImageLayerCanvas(self)
        self.tk_image_layer_canvas.grid()

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

        # 背景と文字のレイヤーID
        # 画像の差し替え等はレイヤーIDを元に操作する
        self.bg_lid:Optional[LayerId] = None
        self.font_lid:Optional[LayerId] = None

        self.set_background_image(np.array(Image.open(r"F:\ReinVisionOCR\craft\0.jpg").convert("RGBA")))
        self.set_text_image(np.array(Image.open(r"F:\ReinVisionOCR\craft\0.jpg").convert("RGBA")))

    def set_background_image(self, image:npt.NDArray[np.uint8]) -> None:
        # if   : 新規レイヤーの作成
        # else : 既存レイヤーの画像を更新
        if self.bg_lid is None:
            self.bg_lid = self.tk_image_layer_canvas.add_new_layer_with_image(image)
        else:
            self.tk_image_layer_canvas.update_layer_image(self.bg_lid, image)

        # 画像を差し替えた場合や新規レイヤーを追加した場合は update_scene_color を叩く
        self.tk_image_layer_canvas.update_scene_color()

    def set_text_image(self, image:npt.NDArray[np.uint8]) -> None:
        # if   : 新規レイヤーの作成
        # else : 既存レイヤーの画像を更新
        if self.font_lid is None:
            self.font_lid = self.tk_image_layer_canvas.add_new_layer_with_image(image)
        else:
            self.tk_image_layer_canvas.update_layer_image(self.font_lid, image)

        # 画像を差し替えた場合や新規レイヤーを追加した場合は update_scene_color を叩く
        self.tk_image_layer_canvas.update_scene_color()


if __name__ == "__main__":
    app = TestViewer()
    app.mainloop()

OpenCV (cv2) の使用は意図的に避けています

ウィジェットを組み込んで運用しているビューアをcx_Freezeで実行形式にする際に都合が悪いので、OpenCVのインポートを意図的に避けて、Pillowとnumpyで頑張っています。

なにが都合悪いってシンプルにサイズが化け物です。インポートの有無で 100.0 MB も変わるのは流石に看過できなかったのです。

C++ / WPFで作りなさいよとかいう正論パンチは止めてください。
Pythonが好きなの。

OpenCVを含めると 100.0 MB ぐらい膨れる

レイヤーウィンドウ (Layers Window) の位置を固定化するの大変だった

本家(paint.net)と同様にレイヤーウィンドウは、スケール変更をしても位置が固定になるように実装していますが、これが地味に大変でした。

Layers Windowはコレのこと


x/yviewのスクロール対応に関わらずある条件でスケール変更をすると、レイヤーウィンドウが追随してしまうのです。

左が対応前、右が対応後


スクロールバーが端っこ(上下左右)にある状態でスケール変更をすると追随してしまうのです。
GIFだけで汲み取れた方は天才です。

それならば前後の移動量で打ち消せばいいじゃないと思うでしょう。それ自体に誤りはないのですが、算出方法が2種類あったのですよ。そのうちの1種類に全然気が付けなくて苦戦しました。

  1. スクロールバーをマウスで掴んで端っこまで移動させる
  2. マウス中央ボタンを押しながら端っこまで移動させる

1番の算出方法はThe差分算出って感じで余裕でした。

問題は2番です。2番の操作で端っこまで移動させた場合、画面では端っこなのですが、内部の値は0.99...で誤差が発生しているのです。そのため打ち消し移動しても微動しているんですよね。

左がスクロールバーを掴んだ挙動、右がマウス中央ボタンの挙動

誤差算出がコード上のepsって部分です。

マジで苦戦した。レイヤーウィンドウを別グリッドに配置すれば解決しなくてもいい問題ではあったのですが、このレイアウトかなり神っているので、どうしても持ってきたかったんですよね。

おわり!!!

作りたいものを作ってる時間がなにより楽しい。

ここまで作るならWPFで頑張った方が健全な気がしましたが、楽しかったので良しとします。

雑談

ここから先は年齢制限のかかっている作品を取り扱うページとなります。


表示する

天使騒々の抱き枕、第1弾~第6弾までを無事コンプリートしました。

毎月の密かな楽しみが終わってしまい、少し寂しいです。

ちなみに百里先生のビジュは髪結んでいる方が好みです。

予定通りコンプリート出来たことは嬉しいのですが、抱き枕本体を所有しておりませんの。


さて、寂しいと言えばALcotさんが終わりを迎えるらしいですね。

ALcotの最終作『CloverMemory's』はクラウドファンディングにて制作致します。4月13日(土)から5月31日(金)の間で受け付け予定。

Clover Day’s いい作品ですよね。

基本的にはオートプレイでコード書きながら遊んでいたスタイルな筆者さんですが、姉妹の片方があまりにもゆっくり喋るものですから、初めて手動で進めようかと思った作品のひとつです。

ADVは最初らへんに良作と巡り合うと沼っちゃう気がしますね。

来週の土日ですか。

Memory's ビジュで一目惚れする方はいらっしゃらなかったので、そこまでテンション高くないのですが、ALcotさんは王道系シナリオで筆者的には大好きなので、遊んだら好きにはなるんでしょうね。

誰がシナリオ書いているのかとか、なかのひととか、現実的なことはあまり直視したくないので詳細は確認しないタイプなのですが、たぶん最後なんだしやりたいこと全部やるっしょ。知らんけど。

創作の世界に現実を絡めるのあんまり好まないタイプ。だって現実世界ってねぇ。。。あれじゃん。。。別に筆者は楽しんでいる方と自負してるけれど、万人がエロゲーほどハッピーエンドに溢れてないし。。。

来週は適当にUEを出しにCFで選択したものの報告でもしますかね。


ちな、ゲーマーズのくくるさんをどうしようか、未だに悩んでおります。

悩んだらやった方がいいので、事実上結論は出ているのですが、楽しいことに悩む時間は幸せなので、もう暫く結論ありきで悩んでいようと思います。

設計で悩むのは楽しい反面、既存機能の互換性と納期がチラつくので、少し心労。