mieki256's diary



2025/12/06() [n年前の日記]

#1 [gimp][python] GIMP 3.0 + Python-FuでWindowsのファイル選択ダイアログを表示

無料で利用できる画像編集ソフト GIMP のファイル選択ダイアログは、OSが用意したファイル選択ダイアログを使わない独自実装なので、ちょっと不便なところがある。 *1

Windowsが標準で用意しているファイル選択ダイアログを使って画像を開くことができたら、どちらの問題も解決しそうだよなと…。

GIMP 2.2 の頃は、Windowsのファイル選択ダイアログを使ってファイルを開いたり保存できるプラグイン(fileopen.exe)が存在していたのだけど。GIMP 2.10.38 で試してみたらメニュー項目は2つ出るし、おそらく GIMP 3.0 では使えないだろうしで…。今でも使えるプラグインというわけではない…。

そこでふと、GIMP 3.0 + Python-Fu で、Windowsのファイル選択ダイアログを表示するプラグインを作れないものだろうかと気になり始めて、実験してみた。

環境は Windows11 x64 25H2 + GIMP 3.0.4 Portable Rev 2。あるいは Python 3.10.10 64bit。

Pythonでファイル選択ダイアログを表示する :

まずは Python でファイル選択ダイアログを表示できるか調べた。

ググったところ、一般的には、「Python でファイル選択ダイアログを表示したい? だったら tkinter のファイル選択ダイアログを使うのが一番簡単で確実でしょう」という話になっているらしい。

しかし、GIMP Windows版に同梱されてる Python に tkinter は同梱されてない。Python console 上で import tkinter と打っても「そんなモジュールは無いよ」と言われてしまう。こりゃ入ってないよな…。考えてみたらGIMP自体がGTKで実装されてるのだから、「GUIで表示したい? GTK使えよ。なんでTkが必要なんだよTkより高機能な Tool Kit が目の前にあるだろお前馬鹿じゃねえの」と言われるわな…。

ただ、GIMP Windows版同梱の Python にも、Python から .dll を呼び出して使える ctypes というモジュールが入ってた。これを使えばどうにかなるのでは…?

以下のページを参考にして動作確認してみた。環境は Windows11 x64 25H2 + Python 3.10.10 64bit。

_Python: ctypesパターン集 #Windows - Qiita
_How to get null terminated strings from a buffer? - Python Help - Discussions on Python.org


_04_showgetopenfilename.py
import ctypes
import os

# ファイルダイアログの動作を指定するための定数
OFN_ALLOWMULTISELECT = 0x00000200
OFN_FILEMUSTEXIST = 0x00001000
OFN_PATHMUSTEXIST = 0x00000800
OFN_EXPLORER = 0x00080000

# 受け取るファイルパスを格納するバッファのサイズ
BUFFER_SIZE = 32768


class OPENFILENAME(ctypes.Structure):
    """GetOpenFileNameW()を呼び出すために必要な構造体を定義"""

    _fields_ = [
        ("lStructSize", ctypes.c_uint32),
        ("hwndOwner", ctypes.c_void_p),
        ("hInstance", ctypes.c_void_p),
        ("lpstrFilter", ctypes.c_wchar_p),
        ("lpstrCustomFilter", ctypes.c_wchar_p),
        ("nMaxCustFilter", ctypes.c_uint32),
        ("nFilterIndex", ctypes.c_uint32),
        ("lpstrFile", ctypes.c_wchar_p),
        ("nMaxFile", ctypes.c_uint32),
        ("lpstrFileTitle", ctypes.c_wchar_p),
        ("nMaxFileTitle", ctypes.c_uint32),
        ("lpstrInitialDir", ctypes.c_wchar_p),
        ("lpstrTitle", ctypes.c_wchar_p),
        ("Flags", ctypes.c_uint32),
        ("nFileOffset", ctypes.c_uint16),
        ("nFileExtension", ctypes.c_uint16),
        ("lpstrDefExt", ctypes.c_wchar_p),
        ("lCustData", ctypes.c_void_p),
        ("lpfnHook", ctypes.c_void_p),
        ("lpTemplateName", ctypes.c_wchar_p),
        ("pvReserved", ctypes.c_void_p),
        ("dwReserved", ctypes.c_uint32),
        ("FlagsEx", ctypes.c_uint32),
    ]


def get_openfilename(initial_dir=None, filters=None):
    """ファイルダイアログを開いて複数ファイルを選択"""

    comdlg32 = ctypes.WinDLL("comdlg32")
    comdlg32.GetOpenFileNameW.restype = ctypes.c_bool
    comdlg32.GetOpenFileNameW.argtypes = (ctypes.POINTER(OPENFILENAME),)

    ofn = OPENFILENAME()
    lenFilenameBufferInChars = BUFFER_SIZE
    buf = ctypes.create_unicode_buffer(lenFilenameBufferInChars)

    ofn.lStructSize = ctypes.sizeof(OPENFILENAME)

    # ファイル種類のフィルターを設定
    if filters:
        ofn.lpstrFilter = "\0".join(filters) + "\0\0"
    else:
        ofn.lpstrFilter = "All files {*.*}\0*.*\0\0"

    if initial_dir:
        ofn.lpstrInitialDir = initial_dir

    ofn.lpstrFile = ctypes.cast(buf, ctypes.c_wchar_p)
    ofn.nMaxFile = lenFilenameBufferInChars
    ofn.lpstrTitle = "Select file"
    ofn.Flags = (
        OFN_ALLOWMULTISELECT | OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST
    )

    ret = comdlg32.GetOpenFileNameW(ofn)
    files = []
    if ret:
        # 得られたファイルパスを分割してリストにする
        s = buf[:].rstrip("\0")
        path = s[: ofn.nFileOffset].rstrip("\0")
        filenames = s[ofn.nFileOffset :].split("\0")
        files = [os.path.abspath(os.path.join(path, f)) for f in filenames]

        # リストの形でファイルパス群を返す
        return files

    return []


def main():
    filters = [
        "All files {*.*}",
        "*.*",
        "Text {*.txt}",
        "*.txt",
    ]

    homedir = os.path.expanduser("~")
    print(f"Initial Directory : {homedir}")

    files = get_openfilename(initial_dir=homedir, filters=filters)
    if files:
        print(files)
    else:
        print("Cancel.")


if __name__ == "__main__":
    main()

実行すると、見慣れたファイル選択ダイアログが開く。

04_showgetopenfilename_ss01.png


複数ファイルを選択すれば、リストの形でファイルパス群を得ることもできる。

>python.exe 04_showgetopenfilename.py

Initial Directory : C:\Users\USERNAME
['D:\\home2\\AppliData\\Desktop\\tmp\\images\\bg_1280x720\\a_bg_1280x720_0.png', 'D:\\home2\\AppliData\\Desktop\\tmp\\images\\bg_1280x720\\a_bg_1280x720_1.png', 'D:\\home2\\AppliData\\Desktop\\tmp\\images\\bg_1280x720\\a_bg_1280x720_2.png']

これで、Python + ctypes を使ってWindowsのファイル選択ダイアログを開くことは十分に可能と分かった。後はコレを GIMP 3.0 の Python-Fu にできるかどうか…。

余談。ファイル選択ダイアログ( GetOpenFileNameW() )から返ってきたファイルパス群は「\0」を区切り文字として繋がった状態で返されるのだけど、分割の仕方が分からなくて結構悩んだ。前述の解説ページで分割処理のサンプルが提示されていて助かった…。

GIMP 3.0 のPython-Fuスクリプトを作成 :

GIMP 3.0 の Python-Fuスクリプトの書き方を調べないといけない。GIMP 3.0 の Python-Fu は、GIMP 2.x の Python-Fu から仕様が激変したらしいので、どう書けばいいのやら…。

以下のページが参考になった。ありがたや。

_4. A Python plug-in writing Tutorial
_GIMP3 / Python プログラミング試行錯誤日誌: GIMP3 Python プラグイン登録サンプルテンプレート - 省型旧形国電の残影を求めて
_GIMP3 / Python プログラミング試行錯誤日誌: どこから手を付けたらいいのか? - 省型旧形国電の残影を求めて

とりあえず自分も、Hello World と表示するだけのPython-Fuスクリプトをコピペして動作確認してみた。環境は Windows11 x64 25H2 + GIMP 3.0.4 Portable samj版。

_m256-helloworld.py
import sys

import gi

gi.require_version("Gimp", "3.0")
from gi.repository import Gimp

gi.require_version("GimpUi", "3.0")
from gi.repository import GimpUi

gi.require_version("Gio", "2.0")
from gi.repository import Gio

from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Gegl
from gi.repository import GObject


# 他のクラスと重ならないユニークな名前をつけること


class m256FirstPythonPlugin(Gimp.PlugIn):
    def do_query_procedures(self):
        # プロシージャブラウザで表示される名前。ユニークな名前にすること。
        return ["m256-plug-in-hello-world-python"]

    def do_set_i18n(self, name):
        """国際化に対応してるかどうかを返す"""
        return False

    def do_create_procedure(self, name):
        """プロシージャに登録するための設定"""

        procedure = Gimp.ImageProcedure.new(
            self, name, Gimp.PDBProcType.PLUGIN, self.run, None
        )

        # 対応画像形式
        procedure.set_image_types("*")

        # 画像を開いてなくてもメニューを有効にする
        procedure.set_sensitivity_mask(Gimp.ProcedureSensitivityMask.ALWAYS)

        # メニュー上に表示するラベル名
        procedure.set_menu_label("Hello World py")

        # メニューの場所
        procedure.add_menu_path("<Image>/Filters/Development/")

        # 説明
        procedure.set_documentation(
            "Display Hello World",  # 簡単な説明
            "My first Python 3 plug-in for GIMP 3.0",  # 詳細な説明
            name,
        )

        # 作成者、著作権者、作成日
        procedure.set_attribution("YOUR NAME", "your name", "2025/12/05")

        return procedure

    def run(self, procedure, run_mode, image, drawables, config, run_data):
        """処理内容"""

        # ここに実処理を書く

        Gimp.message("Hello world!")

        # 処理を実行して、成功した場合は以下を返す:
        return procedure.new_return_values(Gimp.PDBStatusType.SUCCESS, GLib.Error())


class GetDialog(Gtk.Dialog):
    """ダイアログ関係"""

    def __init__(self, parent, plugin):
        Gtk.Dialog.__init__(
            self, title="Dialog Title", parent=parent, flags=Gtk.DialogFlags.MODAL
        )


# プラグインとして登録する
Gimp.main(m256FirstPythonPlugin.__gtype__, sys.argv)

このスクリプトは m256-helloworld.py というファイル名なので、GIMPのプラグインフォルダに m256-helloworld というフォルダを作成して、その中に m256-helloworld.py を入れた。

GIMP を実行すると、フィルター → Development → Hello World py という項目が増えた。項目を選ぶと、エラーメッセージが表示されるウインドウ? エラーコンソール? に「Hello world!」とメッセージが表示された。

これで、GIMP 3.0用のPython-FuスクリプトをGIMPのメニューに追加する方法は分かった。後は実処理を書いていくだけ…。

GIMP 3.0 + Python-Fu でWindowsのファイル選択ダイアログを開く :

そんな感じで、GIMP 3.0 + Python-Fu を使って、Windows のファイル選択ダイアログを開いて画像を開くことができる処理を、ある程度は書けた。ある程度は。

GIMP 3.0 + Python-Fu で画像ファイルを開く記述は、以下のページを参考にさせてもらった。ありがたや。

_GIMP3 対応 画像ファイルをレイヤーマスクとして読み込むプラグイン - GIMP3 を便利に! プロジェクト - 省型旧形国電の残影を求めて
_Gimp 3 python migration guide
_GIMP3.0 Pythonで画像読み込み表示する | 家猫ミー 窓のプログラム

_m256-winopen.py
import sys

import gi

gi.require_version("Gimp", "3.0")
from gi.repository import Gimp

gi.require_version("GimpUi", "3.0")
from gi.repository import GimpUi

gi.require_version("Gio", "2.0")
from gi.repository import Gio

from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import Gegl
from gi.repository import GObject

import ctypes
import os
from pathlib import Path

WINOPEN_FILTERS = [
    "All files {*.*}",
    "*.*",
    "PNG",
    "*.png",
    "JPEG",
    "*.jpg;*.jpeg",
]

OFN_ALLOWMULTISELECT = 0x00000200
OFN_FILEMUSTEXIST = 0x00001000
OFN_PATHMUSTEXIST = 0x00000800
OFN_EXPLORER = 0x00080000

BUFFER_SIZE = 32768


class OPENFILENAME(ctypes.Structure):
    """Windowsの標準ファイルダイアログ用の構造体を定義"""

    _fields_ = [
        ("lStructSize", ctypes.c_uint32),
        ("hwndOwner", ctypes.c_void_p),
        ("hInstance", ctypes.c_void_p),
        ("lpstrFilter", ctypes.c_wchar_p),
        ("lpstrCustomFilter", ctypes.c_wchar_p),
        ("nMaxCustFilter", ctypes.c_uint32),
        ("nFilterIndex", ctypes.c_uint32),
        ("lpstrFile", ctypes.c_wchar_p),
        ("nMaxFile", ctypes.c_uint32),
        ("lpstrFileTitle", ctypes.c_wchar_p),
        ("nMaxFileTitle", ctypes.c_uint32),
        ("lpstrInitialDir", ctypes.c_wchar_p),
        ("lpstrTitle", ctypes.c_wchar_p),
        ("Flags", ctypes.c_uint32),
        ("nFileOffset", ctypes.c_uint16),
        ("nFileExtension", ctypes.c_uint16),
        ("lpstrDefExt", ctypes.c_wchar_p),
        ("lCustData", ctypes.c_void_p),
        ("lpfnHook", ctypes.c_void_p),
        ("lpTemplateName", ctypes.c_wchar_p),
        ("pvReserved", ctypes.c_void_p),
        ("dwReserved", ctypes.c_uint32),
        ("FlagsEx", ctypes.c_uint32),
    ]


class m256WindowsOpenDialogPlugin(Gimp.PlugIn):
    """GIMPプラグイン部分"""

    def do_query_procedures(self):
        # プロシージャブラウザで表示される名前。ユニークな名前にすること。
        return ["m256-plug-in-windows-opendlg-python"]

    def do_set_i18n(self, name):
        """国際化に対応してるかどうかを返す"""
        return False

    def do_create_procedure(self, name):
        """プロシージャに登録するための設定"""

        procedure = Gimp.ImageProcedure.new(
            self, name, Gimp.PDBProcType.PLUGIN, self.run, None
        )

        # 対応画像形式
        procedure.set_image_types("*")

        # 画像を開いてなくてもメニューを有効にする
        procedure.set_sensitivity_mask(Gimp.ProcedureSensitivityMask.ALWAYS)

        # メニュー上に表示するラベル名
        procedure.set_menu_label("Win Open")

        # メニューの場所
        procedure.add_menu_path("<Image>/File/")

        # 説明
        procedure.set_documentation(
            "Open standard Windows file dialog",  # 簡単な説明
            "Open the file using the standard Windows file dialog",  # 詳細な説明
            name,
        )

        # 作成者、著作権者、作成日
        procedure.set_attribution("mieki256", "mieki256", "2025/12/05")

        return procedure

    def get_openfilename(self, initial_dir=None, filters=None):
        """Windowsの標準ファイルダイアログを使って複数ファイルを選択"""

        comdlg32 = ctypes.WinDLL("comdlg32")
        comdlg32.GetOpenFileNameW.restype = ctypes.c_bool
        comdlg32.GetOpenFileNameW.argtypes = (ctypes.POINTER(OPENFILENAME),)

        ofn = OPENFILENAME()
        lenFilenameBufferInChars = BUFFER_SIZE
        buf = ctypes.create_unicode_buffer(lenFilenameBufferInChars)

        ofn.lStructSize = ctypes.sizeof(OPENFILENAME)

        # ファイル種類を設定
        if filters:
            ofn.lpstrFilter = "\0".join(filters) + "\0\0"
        else:
            ofn.lpstrFilter = "All files {*.*}\0*.*\0\0"

        if initial_dir:
            ofn.lpstrInitialDir = initial_dir

        ofn.lpstrFile = ctypes.cast(buf, ctypes.c_wchar_p)
        ofn.nMaxFile = lenFilenameBufferInChars
        ofn.lpstrTitle = "Select file"
        ofn.Flags = (
            OFN_ALLOWMULTISELECT | OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST
        )

        # ファイル選択ダイアログを開く
        ret = comdlg32.GetOpenFileNameW(ofn)
        if ret:
            s = buf[:].rstrip("\0")
            path = s[: ofn.nFileOffset].rstrip("\0")
            filenames = s[ofn.nFileOffset :].split("\0")
            files = [os.path.abspath(os.path.join(path, f)) for f in filenames]

            # ファイルパスをリストで返す
            return files

        return []

    def load_image(self, filepath: str):
        """画像を読み込み"""

        # "\\" は問題があるらしいので "/" に置換
        p = Path(filepath)
        filepath_unix = p.as_posix()

        # 画像を読み込む。この段階ではGIMPウインドウに表示されていない
        file = Gio.File.new_for_path(filepath_unix)
        image = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, file)

        if image:
            # 画像バッファに元ファイル名を反映させたいが上手く行かない
            # if not image.set_file(file):
            #     Gimp.message(f"Failed set_file(). {file.get_basename()}")

            # GIMPウインドウ上に画像を表示
            display = Gimp.Display.new(image)

            # 何故か編集された画像として扱われてしまうのでフラグをクリア
            image.clean_all()

            # 画面を更新
            Gimp.displays_flush()
        else:
            Gimp.message(f"Failed to load {file.get_basename()}")

        return image, display

    def run(self, procedure, run_mode, image, drawables, config, run_data):
        homedir = os.path.expanduser("~")
        files = self.get_openfilename(initial_dir=homedir, filters=WINOPEN_FILTERS)
        if files:
            for path in files:
                self.load_image(path)
        # else:
        #     Gimp.message("Cancel.")

        # 処理を実行して成功した場合は以下を返す
        return procedure.new_return_values(Gimp.PDBStatusType.SUCCESS, GLib.Error())


class GetDialog(Gtk.Dialog):
    """ダイアログ関係"""

    # 今回は何もしていない

    def __init__(self, parent, plugin):
        Gtk.Dialog.__init__(
            self, title="Dialog Title", parent=parent, flags=Gtk.DialogFlags.MODAL
        )


# プラグインとして登録
Gimp.main(m256WindowsOpenDialogPlugin.__gtype__, sys.argv)

  • スクリプト名が m256-winopen.py なので、GIMPのプラグインフォルダに m256-winopen というフォルダを作成して、その中に m256-winopen.py をコピーする。
  • GIMPを実行すると、ファイル → WinOpen、という項目が増える。
  • Win Open を選ぶと、見慣れたWindowsのファイル選択ダイアログが表示される。複数ファイルを選択して開くこともできる。
  • Windowsのショートカットファイル(.lnk)をダブルクリックすれば、ショートカットファイルが指しているフォルダに移動できる。

問題点 :

一見それらしく動いたのだけど、問題が…。

  • GIMPの、ファイル → 開く/インポート、を選ぶと、元の画像ファイル名がちゃんと画像バッファにも反映される。
  • しかし、このスクリプト/プラグインで画像を開くと、画像バッファの名前が「名称未設定」(Untitled)になってしまう。

Gimp.Image.set_file() を使えばそのあたり設定できるかなと試してみたけれど、何度試しても、設定するタイミングを変えても、結果は false が返ってくる。つまり設定できてない。

Microsoft Copilot に解決策を尋ねてみたけど、.set_name() だの .set_filename() だの、存在しないメソッド名を平気で提案してくる…。一体何の情報と混同してるのだか…。

ということで、一見それっぽく動いているようで実はダメダメな結果になってしまった。

でもまあ、ここまで動いたのだから、わざわざC/C++でプラグインを書かなくても Python-Fu でどうにかなりそうな気配は感じる…。あともうちょっとなんだけどなあ…。

GIMP 2.10.38でも少し試した :

GIMP 2.10.38 Portable の Python console上で、少し試してみた。Python経由で画像を開いた時に画像バッファ名はどうなるのか確認してみたい。

以下を打ちこんでみた。
from gimpfu import *
path = "D:\\hoge\\fuga\\piyo.png"
img = pdb.gimp_file_load(path, path)
disp = pdb.gimp_display_new(img)

画像バッファに元の画像のファイル名がちゃんと表示されてる…。GIMP 2.x は gimp_file_load() を使うだけで画像バッファ名もちゃんと設定されるっぽい。

ただ、開いただけでも編集作業中の画像として認識されてしまうのは GIMP 3.0 と同じっぽい。閉じるボタンをクリックして閉じようとしても「まだ保存してないよ?」と問い合わせのダイアログが開いてしまう。.clean_all() を呼べばそのあたりのフラグはクリアされるようではあるけど…。

とりあえず、GIMP 2.x と 3.0 では .file_load() の動作が違うらしい。

*1: ただ、自分の環境では、6000個のJpegファイルが入ってるフォルダ(HDD内)にアクセスしても、6秒ぐらいで一覧が表示された。一旦エクスプローラで該当フォルダにアクセスすると、メモリ上にファイル一覧情報がキャッシュされて次回からは速くなるのだろうか?

以上、1 日分です。

過去ログ表示

Prev - 2025/12 -
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31

カテゴリで表示

検索機能は Namazu for hns で提供されています。(詳細指定/ヘルプ


注意: 現在使用の日記自動生成システムは Version 2.19.6 です。
公開されている日記自動生成システムは Version 2.19.5 です。

Powered by hns-2.19.6, HyperNikkiSystem Project