QGISのプラグインの実装例

概要

QGISプラグインの実装例を紹介します。ここでは、プラグインを起動すると以下のようなウィンドウが開き、指定したレイヤのCRSを変更するプラグインを実装します。

出典:政府統計の総合窓口(e-Stat)(https://www.e-stat.go.jp/

このような機能はQGISの標準機能として既にあるので車輪の再発明ではありますが、レイヤやCRSを指定するUIの実装や、レイヤオブジェクトの操作など、プラグイン開発においてよく必要となる部分なので紹介したいと思います。

使用環境

OS Windows 11
QGIS 3.28.7 LTR
Python 3.9.5

プラグイン開発に必要なファイルについて

プラグインに必要なファイルについては下記ドキュメントに記載されています。

上記ページにも記載がありますが、プラグインに必要なファイル一式が揃ったテンプレートの作成にはPlugin Builderが利用できます。Plugin Builderの使い方や生成されたファイルの中身については下記で解説されています。

実装するプラグインのファイル構成

今回実装するプラグインはPlugin Builderのテンプレートを参考にしつつ、アイコンファイルやリソースファイルは省略し、以下のようなファイル構成としました。

📂test_plugin
 ├─📄metadata.txt
 ├─📄__init__.py
 ├─📄plugin.py
 ├─📄my_dialog.py
 └─📄my_dialog_base.ui

各ファイルの内容については、以下で説明します。

metadata.txt

metadata.txt にはプラグインの詳細画面などに表示するメタデータini ファイル形式で記述します。

[general]
name=Test Plugin
qgisMinimumVersion=3.0
description=this is test plugin
about=this is test plugin
version=1.0
author=Hoge
email=hoge@example.com
repository=http://example.com

上記は必須項目だけ入力していますが、他にも様々な項目が記述できます。例えば、アイコンファイル icon.png をフォルダに含め、以下のように記述することでアイコン付きのプラグインにすることができます。

icon=icon.png

その他、 metadata.txt に記述可能な項目は下記のページで解説されています。

__init__.py

__init__.py は作成したプラグインフォルダをモジュールとして認識させるのに必要なファイルです。ここではプラグインQGISに読み込まれた際に呼び出される処理として、 classFactory() 関数を定義する必要があります。後述する plugin.pyTestPlugin クラスのインスタンスを返す処理を以下のように記述します。

def classFactory(iface):
    from .plugin import TestPlugin

    return TestPlugin(iface)

plugin.py

plugin.pyプラグインのメイン処理となる部分をクラスとして実装します。具体的には、メニューやツールバーへのプラグインの追加処理、後述するダイアログの初期化処理、プラグイン呼び出し時のコールバック処理等を記述します。このクラスで実装する __init__(), initGui(), unload() の3つの関数はQGISから呼び出しが行われる必須の関数です。

import configparser
import pathlib
from collections.abc import Callable
from typing import Optional

from qgis.core import Qgis
from qgis.gui import QgisInterface
from qgis.PyQt.QtCore import QObject
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction

from .my_dialog import MyDialog


class TestPlugin:
    def __init__(self, iface: QgisInterface):
        self.iface = iface

        # メタデータの読み込み
        self.module_dir = pathlib.Path(__file__).parent
        self.metadata = configparser.ConfigParser()
        self.metadata.read(self.module_dir.joinpath("metadata.txt"), encoding="utf-8")
        self.plugin_name = self.metadata["general"]["name"]

        # unload関数によりメニューやツールバーから削除するアクションの参照用
        self.actions = []

        # ダイアログの型アノテーション
        self.dialog: MyDialog

    def add_action(
        self,
        icon_path: str,
        text: str,
        callback: Callable[[], None],
        enabled_flag=True,
        add_to_menu=True,
        add_to_toolbar=True,
        status_tip: Optional[str] = None,
        whats_this: Optional[str] = None,
        parent: Optional[QObject] = None,
    ) -> QAction:
        icon = QIcon(icon_path)
        action = QAction(icon, text, parent)
        action.triggered.connect(callback)
        action.setEnabled(enabled_flag)

        if status_tip is not None:
            action.setStatusTip(status_tip)

        if whats_this is not None:
            action.setWhatsThis(whats_this)

        if add_to_toolbar:
            self.iface.addToolBarIcon(action)

        if add_to_menu:
            self.iface.addPluginToMenu(self.plugin_name, action)

        self.actions.append(action)
        return action

    def initGui(self):
        # メニューやツールバーへの追加
        self.add_action(
            icon_path=":images/themes/default/propertyicons/plugin.svg",
            text="Test plugin",
            callback=self.run,
            parent=self.iface.mainWindow(),
        )

        # ダイアログの初期化
        self.dialog = MyDialog(self.iface.mainWindow())

    def unload(self):
        for action in self.actions:
            self.iface.removePluginMenu(self.plugin_name, action)
            self.iface.removeToolBarIcon(action)

    def run(self):
        # アクティブレイヤをデフォルトの処理対象としてダイアログを表示
        self.dialog.show(self.iface.activeLayer())

        # ダイアログがOKボタン押下以外で閉じられた時は何もしない
        result = self.dialog.exec()
        if result != 1:
            return

        # 対象レイヤがない時は何もしない
        if self.dialog.target_layer is None:
            return

        # 完了メッセージの表示
        layer_name = self.dialog.target_layer.name()
        crs_id = self.dialog.target_layer.crs().authid()
        self.iface.messageBar().pushMessage(
            title="Info",
            text=f"レイヤ {layer_name} を {crs_id} に設定しました。",
            level=Qgis.Info,
        )

クラスに実装した各関数の内容を以下で説明します。

__init__() 関数

以降の処理に用いる各変数の初期化を行っています。メニュー表示用として self.plugin_name に代入するプラグイン名については metadata.txtプラグイン名を利用するため、標準ライブラリの configparser を用いて metadata.txt の項目を取得しています。

self.module_dir = pathlib.Path(__file__).parent
self.metadata = configparser.ConfigParser()
self.metadata.read(self.module_dir.joinpath("metadata.txt"), encoding="utf-8")
self.plugin_name = self.metadata["general"]["name"]
add_action() 関数

プラグイン呼び出しのための表示をメニューやツールバーに追加するユーティリティ関数で、次の initGui() で呼び出します。

この関数はQGISから呼び出しが行われる関数ではないので関数名や引数は任意に変更できます。ここでは、Plugin Builderのテンプレートと同様に以下のような引数を設定しています。

引数 説明
icon_path アイコンのパスを指定します。リソースファイルからビルドしたアイコンの場合は :/plugins/test_plugin/icon.png のようなパスを指定します。
text メニューに追加されるアイコンの説明テキストです。
callback アイコンをクリックした際に呼び出されるコールバック関数を指定します。
enabled_flag True を指定することでアクションが有効になります。
add_to_menu True を指定することでメニューへアイコンが追加されます。
add_to_toolbar True を指定することでツールバーへアイコンが追加されます。
status_tip アイコンにマウスホバーした際にステータスバーに表示するテキストです。
whats_this ウィジェットの説明文です。ただし、現在のQGISでは基本的に使用されていない機能なので、指定する必要はないです。
parent 追加するアクションの親となるウィジェットを指定します。
initGui() 関数

プラグインの読み込みの際にQGISから呼び出される関数で、メニューやツールバーへのプラグインの追加とダイアログの初期化を行っています。

add_action() ではアイコンとして icon_path=":images/themes/default/propertyicons/plugin.svg" を指定しています。これは、QGISのプリセットとして利用できるsvgファイルで、利用可能なアイコンは下記のページに一覧にされています。

unload() 関数

プラグインを無効化した際にQGISから呼び出される関数で、メニューやツールバープラグインの削除を行っています。

run() 関数

プラグインで実装した機能を呼び出す際のコールバック関数で、initGui()add_action() の引数として指定します。この関数が呼び出されると、次に実装するダイアログを表示します。ダイアログで行う処理が正常に終了したら、メッセージバーにメッセージを表示します。

my_dialog.py

my_dialog.py はダイアログを定義するクラスを実装しています。後述の my_dialog_base.ui をベースクラスとして読み込み、ダイアログのロジックをここで実装しています。

import pathlib
from typing import Optional

from qgis.core import QgsMapLayer
from qgis.PyQt import uic
from qgis.PyQt.QtWidgets import QDialog


class MyDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.target_layer: Optional[QgsMapLayer] = None

        # uiファイルをベースクラスとしてダイアログを初期化
        ui_file = pathlib.Path(__file__).parent.joinpath("my_dialog_base.ui")
        ui_class, _ = uic.loadUiType(ui_file, self)
        self.base_ui = ui_class()
        self.base_ui.setupUi(self)

        # コンボボックスのレイヤが変更された時のイベントを設定
        self.base_ui.LayerComboBox.layerChanged.connect(self.layerChanged)

    def show(self, target_layer: Optional[QgsMapLayer]):
        # ダイアログが表示された時の処理で、コンボボックスに表示するレイヤを初期化する
        self.base_ui.LayerComboBox.setLayer(target_layer)
        self.layerChanged()
        super().show()

    def layerChanged(self):
        # コンボボックスのレイヤが変更された時の処理で、変数に格納するレイヤとコンボボックスに表示するCRSを変更する
        self.target_layer = self.base_ui.LayerComboBox.currentLayer()
        if not self.target_layer is None:
            self.base_ui.CrsComboBox.setCrs(self.target_layer.crs())

    def accept(self):
        # ダイアログでOKが押下された時の処理で、対象レイヤのCRSを変更する
        if not self.target_layer is None:
            self.target_layer.setCrs(self.base_ui.CrsComboBox.crs())
        super().accept()

    def reject(self):
        # ダイアログでキャンセルが押下された時の処理
        super().reject()

my_dialog_base.ui

my_dialog.py で使用するダイアログのベースクラスを定義しています。このuiファイルはQGISにバンドルされている Qt Designer で編集可能で、ドラッグアンドドロップ等の操作によりウィジェットの追加、配置が可能です。

my_dialog_base.uiXML形式で以下のように記述されます。

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
    <class>MyDialogBase</class>
    <widget class="QDialog" name="MyDialogBase">
        <property name="geometry">
            <rect>
                <x>0</x>
                <y>0</y>
                <width>600</width>
                <height>300</height>
            </rect>
        </property>
        <property name="windowTitle">
            <string>Test Plugin</string>
        </property>
        <widget class="QLabel" name="LayerComboBoxLabel">
            <property name="geometry">
                <rect>
                    <x>30</x>
                    <y>30</y>
                    <width>340</width>
                    <height>30</height>
                </rect>
            </property>
            <property name="text">
                <string>CRSを変更するレイヤー</string>
            </property>
        </widget>
        <widget class="QgsMapLayerComboBox" name="LayerComboBox">
            <property name="geometry">
                <rect>
                    <x>30</x>
                    <y>60</y>
                    <width>540</width>
                    <height>30</height>
                </rect>
            </property>
        </widget>
        <widget class="QLabel" name="CrsComboBoxLabel">
            <property name="geometry">
                <rect>
                    <x>30</x>
                    <y>120</y>
                    <width>340</width>
                    <height>30</height>
                </rect>
            </property>
            <property name="text">
                <string>設定するCRS</string>
            </property>
        </widget>
        <widget class="QgsProjectionSelectionWidget" name="CrsComboBox">
            <property name="geometry">
                <rect>
                    <x>30</x>
                    <y>150</y>
                    <width>540</width>
                    <height>30</height>
                </rect>
            </property>
        </widget>
        <widget class="QDialogButtonBox" name="ButtonBox">
            <property name="geometry">
                <rect>
                    <x>30</x>
                    <y>240</y>
                    <width>540</width>
                    <height>30</height>
                </rect>
            </property>
            <property name="orientation">
                <enum>Qt::Horizontal</enum>
            </property>
            <property name="standardButtons">
                <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
            </property>
        </widget>
    </widget>
    <customwidgets>
        <customwidget>
            <class>QgsMapLayerComboBox</class>
            <extends>QComboBox</extends>
            <header>qgsmaplayercombobox.h</header>
        </customwidget>
        <customwidget>
            <class>QgsProjectionSelectionWidget</class>
            <extends>QWidget</extends>
            <header>qgsprojectionselectionwidget.h</header>
        </customwidget>
    </customwidgets>
    <resources />
    <connections>
        <connection>
            <sender>ButtonBox</sender>
            <signal>accepted()</signal>
            <receiver>MyDialogBase</receiver>
            <slot>accept()</slot>
            <hints>
                <hint type="sourcelabel">
                    <x>20</x>
                    <y>20</y>
                </hint>
                <hint type="destinationlabel">
                    <x>20</x>
                    <y>20</y>
                </hint>
            </hints>
        </connection>
        <connection>
            <sender>ButtonBox</sender>
            <signal>rejected()</signal>
            <receiver>MyDialogBase</receiver>
            <slot>reject()</slot>
            <hints>
                <hint type="sourcelabel">
                    <x>20</x>
                    <y>20</y>
                </hint>
                <hint type="destinationlabel">
                    <x>20</x>
                    <y>20</y>
                </hint>
            </hints>
        </connection>
    </connections>
</ui>

connections タグでは、ButtonBox のボタン押下時のシグナルに対して実行する MyDialogBase の関数 accept(), reject() をスロットに指定しています。そのため、ButtonBox のボタン押下時に my_dialog.pyMyDialog で定義した accept(), reject() が実行されるようになっています。

プラグインの動作確認

プラグインに必要なファイルとしては以上で、test_plugin フォルダをプラグインディレクト%APPDATA%\QGIS\QGIS3\profiles\default\python\plugins に保存し、QGIS上で有効化することで使用できます。

実際に動作を確認します。ここでは、サンプルデータとして政府統計の総合窓口(e-Stat)(https://www.e-stat.go.jp/)の令和2年国勢調査人口集中地区境界データを使用します。

レイヤを追加し、プラグインを起動すると、元々のCRSとして EPSG:6668 が表示されています。

CRSを EPSG:6677 に変更し、OKを押します。

CRSが変更され、完了したことを知らせるメッセージバーが表示されました。

あとがき

以上でプラグインの実装が完了しました。実装した機能としては車輪の再発明ですが、プラグインの基本的なファイル構成や、レイヤやCRSを指定するUIの実装、レイヤオブジェクトの操作等、プラグイン開発に必要な事項が一通り確認できました。

また、プラグイン開発で必要となる開発環境をVSCodeで構築する方法について以下で紹介しています。