Python
PyQt
QGIS

QGIS3でpythonプラグインを作ってみた その5 地図をクリックして地物を選択する

QGIS3でpythonプラグインを作る その5

目標

前回作ったプラグインに、地図(マップキャンバス)をクリックして地物の選択ができる機能を追加する。

押さえたいところは

  • マップキャンバスのイベントを取得する方法
  • クリックイベントから座標を取得する方法
  • 座標から対象レイヤの地物を検索する方法
  • 一番近い点だけを選択状態にする方法

下準備

  • ポイントのシェープファイルがレイヤ登録されたQGISプロジェクトを用意。ダウンロードはこちら
  • 前回作ったプラグインを有効にしておく。GitHubのリポジトリはこちら
  • QGISのPluginReloaderプラグインを有効にしておく(任意)

GUI編集

QGIS付属の「Qt Designer with QGIS 3.0.0 custom widgets」を使ってGUIを編集します。
目標だけならボタン1個でいいのですが属性を表示するエディットラインも付けておきます。
ui2.png

物理名 論理名 備考
btn1 地物カウントボタン その2で使用
アクティブレイヤの地物数を表示
cmb レイヤコンボ その3で使用
選択したレイヤの地物数を表示
lineEditLat 緯度入力 その4で使用
ポイント追加する緯度を入力
lineEditLon 経度入力 その4で使用
ポイント追加する経度を入力
btnAdd ポイント追加ボタン その4で使用
ポイントを追加
btnMove ポイント移動ボタン その4で使用
ポイントを移動
btnDel ポイント削除ボタン その4で使用
ポイントを削除
btnCanvasEvent キャンバスイベント切替ボタン マップキャンバスイベントの切替
checkableをTrueに設定してトグルボタンとして使用
lineEditId id入力 地物属性idを表示
readOnlyをTrue
lineEditName name入力 地物属性nameを表示
readOnlyをTrue

マップキャンバスのイベントを取得する

他のプラグインがどのように実装しているか調査する

マップキャンバスのイベントを取得する方法は手探りなので他のプラグインを覗いて調べてみました。
参考にしたのはこの2つです。
GeoCoding [GitHub]
Shape Tools [GitHub]


GeoCodingでは以下のようにClickToolのインスタンスをマップキャンバスのマップツールにセットしています。

GeoCoding.py
        ct = ClickTool(self.iface,  self.reverse_action);
        self.previous_map_tool = self.iface.mapCanvas().mapTool()
        self.iface.mapCanvas().setMapTool(ct)

ClickToolはマップツールを継承したクラスでリリース(マウスアップ)イベントをオーバーライドしています。
コンストラクタでコールバック用のメソッドを受け取ってイベント時に実行する仕組みですね。

Utils.py
class ClickTool(QgsMapTool):
    def __init__(self,iface, callback):
        QgsMapTool.__init__(self,iface.mapCanvas())
        self.iface      = iface
        self.callback   = callback
        self.canvas     = iface.mapCanvas()
        return None

    def canvasReleaseEvent(self,e):
        point = self.canvas.getCoordinateTransform().toMapPoint(e.pos().x(),e.pos().y())
        self.callback(point)
        return None

Shape Toolsは以下のようにgeodesicMeasureToolのインスタンスをマップキャンバスのマップツールにセットしています。

shapeTools.py
    def initGui(self):
...
        # Initialize Geodesic Measure Tool
        self.geodesicMeasureTool = GeodesicMeasureTool(self.iface, self.iface.mainWindow())
...
    def measureTool(self):
        self.measureAction.setChecked(True)
        self.canvas.setMapTool(self.geodesicMeasureTool)

geodesicMeasureToolもマップツールを継承したクラスでプレス(クリック)イベントとムーブイベントをオーバーライドしています。
こちらは画面を持っていてクラスの中でイベント処理をしています。

geodesicMeasureTool.py
class GeodesicMeasureTool(QgsMapTool):
    def __init__(self, iface, parent):
        QgsMapTool.__init__(self, iface.mapCanvas())
        self.iface = iface
        self.canvas = iface.mapCanvas()
        self.measureDialog = GeodesicMeasureDialog(iface, parent)
...
    def canvasPressEvent(self, event):
        '''Capture the coordinates when the user click on the mouse for measurements.'''
        if not self.measureDialog.isVisible():
            self.measureDialog.show()
            return
        if not self.measureDialog.ready():
            return
        pt = event.mapPoint()
        button = event.button()
        canvasCRS = self.canvas.mapSettings().destinationCrs()
        if canvasCRS != epsg4326:
            transform = QgsCoordinateTransform(canvasCRS, epsg4326, QgsProject.instance())
            pt = transform.transform(pt.x(), pt.y())
        self.measureDialog.addPoint(pt, button)
        if button == 2:
            self.measureDialog.stop()

    def canvasMoveEvent(self, event):
        '''Capture the coordinate as the user moves the mouse over
        the canvas.'''
        if self.measureDialog.motionReady():
            try:
                pt = event.mapPoint()
                canvasCRS = self.canvas.mapSettings().destinationCrs()
                if canvasCRS != epsg4326:
                    transform = QgsCoordinateTransform(canvasCRS, epsg4326, QgsProject.instance())
                    pt = transform.transform(pt.x(), pt.y())
                self.measureDialog.inMotion(pt)
            except:
                return

というわけでマップキャンバスのイベントを取得するには

  • QgsMapToolを継承したクラスを用意してイベントをオーバーライドする。
  • self.iface.mapCanvas().setMapTool()に処理したいマップツール継承クラスをセットする。

で良いようです。意外と簡単でした。

テストしてみましょう。

test_plugin.py
from qgis.gui import QgsMapTool
...
    def dlgUpdate(self):
        if self.first_flg:
...
            self.dlg.btnCanvasEvent.toggled.connect(self.btnCanvasEventToggled)
...
    def btnCanvasEventToggled(self, checked):
        if checked:
            self.dlg.btnCanvasEvent.setText('ON')
            # 元のマップツールを退避
            self.previous_map_tool = self.iface.mapCanvas().mapTool()
            cmt = CustomMapTool(self.iface, self.CanvasPress)
            self.iface.mapCanvas().setMapTool(cmt)

        else:
            self.dlg.btnCanvasEvent.setText('OFF')
            # 元のマップツールに戻す
            self.iface.mapCanvas().setMapTool(self.previous_map_tool)

    def CanvasPress(self, event):
        QMessageBox.information(self.dlg, 'info', 'press')

class CustomMapTool(QgsMapTool):
    def __init__(self, iface, callback):
        QgsMapTool.__init__(self, iface.mapCanvas())
        self.callback = callback

    def canvasPressEvent(self, event):
        # コールバック関数を実行
        self.callback(event)

プラグインをリロードしてから実行してキャンバスイベント切替ボタンをクリックしてマップツールを切り替えます。
マップキャンバスをクリックするとメッセージが表示されました。
もう一度キャンバスイベント切替ボタンをクリックしてマップツールを元に戻してからもう一度マップキャンバスをクリックするとメッセージは出なくなりました。
大丈夫そうです。

クリックイベントから座標を取得する方法

Shape Toolsの上記引用箇所にすでに答えが載っていますね。
こんな感じで取れました。

        pt = event.mapPoint()
        lon = pt.x()
        lat = pt.y()

座標から対象レイヤの地物を検索する方法

クリックした地点をピンポイントで検索するAPIは無いので、クリックした地点に矩形を作ってその矩形に含まれる地物を検索することになります。
QGIS2.18にあったmaplayer.select(rect)は無くなったのでmaplayer.selectByRect(rect,SelectBehaviour)を使います。
では矩形を作って検索してみましょう。

test_plugin.py
    def CanvasPress(self, event):
        # アクティブレイヤを取得
        act_layer = self.iface.activeLayer()
        if act_layer is None or act_layer.type() != QgsMapLayer.VectorLayer:
            return

        pt = event.mapPoint()
        # 検索バッファ
        buffer = 0.001
        rect = QgsRectangle(pt.x() - buffer,
                            pt.y() - buffer,
                            pt.x() + buffer,
                            pt.y() + buffer)
        # SelectBehaviourはデフォルト(省略可能)
        act_layer.selectByRect(rect)

プラグインをリロードして実行するとクリックした付近のポイントが選択されました。

一番近い点だけを選択状態にする方法

サンプルで使用しているレイヤは地物がはまばらなので問題ありませんが、実際のデータでは隣り合うデータがある場合に選択しようと思った地物以外が選択されてしまうこともあるでしょう。
そこでクリックした地点に一番近い地物だけを選択状態にする処理を入れてみます。

まずはテスト動作時にクリックしたら複数選ばれるように検索バッファを広げてみます。

buffer = 0.01

複数地物が選択されたらクリック地点との距離を計算して最も近い地物だけ選択状にします。

test_plugin.py
import math
...
        feat_list = act_layer.selectedFeatures()
        # 初期値を設定
        min = None
        fid = -1
        for feat in feat_list:
            geom = feat.geometry()
            pointxy = geom.asPoint()
            # 2点間の距離を計算
            d = math.sqrt((pointxy.x() - pt.x()) ** 2 + (pointxy.y() - pt.y()) ** 2)
            # 最短距離なら更新
            if min is None or d < min:
                min = d
                fid = feat.id()
                id = feat['id']
                name = feat['name']

        # 選択をクリア
        act_layer.removeSelection()

        if fid > -1:
            act_layer.select(fid)
            self.dlg.lineEditId.setText(str(id))
            self.dlg.lineEditName.setText(name)

プラグインをリロードして実行するとクリックした付近のポイントが選択されて属性が表示されました。
今回はかなり適当な場所をクリックしても最も近いポイントが選択されるようになったと思います。

動作確認

この記事のプラグインをGitHubに公開しました。
https://github.com/ozo360/TestPlugin/tree/part5

プラグインからアクティブレイヤの地物選択ができるようになりました。
目的達成です。

まとめ

マップキャンバスのイベントを取得するには
QgsMapToolを継承したクラスを用意してイベントをオーバーライドする。
self.iface.mapCanvas().setMapTool()に処理したいマップツール継承クラスをセットする。

マップツールは使い終わったら元に戻す。

クリックイベントからポイントを取得するにはevent.mapPoint()を使う。

レイヤの検索はmaplayer.select(rect)からmaplayer.selectByRect(rect,SelectBehaviour)に変更。

2点間の距離を計算方法

math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)

最後に

QGISの基本的操作をプラグインから操作できるようになったと思います。
困った時は今回の記事でやったように既存プラグインのソースを読むとヒントが見るかることが多いのでお勧めですし、必要ならがっつりコピーしても(ライセンス的にも)大丈夫です。
プラグイン操作に限定するため座標系について極力省いて説明をしています。実際には意識して作らないといけないことが多いかもしれないですけど、とりあえず気軽に作ってみたら何とかなるはず。

QGISプラグイン制作についての記事で、なにかリクエストがあればコメントいただけたら嬉しいです。
以上です。

関連記事

QGIS3でpythonプラグインを作ってみた その1 ベース作成
QGIS3でpythonプラグインを作ってみた その2 QButtonと選択レイヤ取得について
QGIS3でpythonプラグインを作ってみた その3 QComboBoxとレイヤ取得について
QGIS3でpythonプラグインを作ってみた その4 地物の追加編集削除について
QGIS3でpythonプラグインを作ってみた その5 地図をクリックして地物を選択する

本記事のライセンス

クリエイティブ・コモンズ・ライセンス
この記事は クリエイティブ・コモンズ 表示 4.0 国際 ライセンスの下に提供されています。