QGIS3.x系向けプラグイン作成手順や各種処理の実装方法について

# はじめに
オープンソースGISソフトウェア、いわゆるFOSS4Gの定番ソフト「QGIS」でプラグイン（拡張機能）を作成しました。QGISの最新バージョンは3.8で、3.x系はPython3を採用するなど後方互換性が一部失われ、2.x系のコードは動きません。ウェブ上にはプラグインの作成等についての記事が様々ありますが、その多くが（特に日本語記事は）2.x系に関してでした。情報が少なく苦労した点も多々あったため、本記事を投稿する事で少しでもQGISやFOSS4Gへの貢献になればと思います。なお、誤り等ご指摘あればコメント頂ければ幸いです。

# QGISプラグインの概要
- QGISはC++のGUIフレームワーク「Qt」で書かれています。
- プラグイン開発には「PyQt」というQtのPythonラッパーを用いるため、言語はPythonを使用します。
- プラグインを動作させる際「QGIS Python API」を呼び出します。

したがって、QGISプラグインを作成するためにはQtとQGIS Python APIに関する知識が必要になります。また、標準で「numpy」が使用出来ます。外部ライブラリの導入も可能なようですが、それを前提としたQGISプラグインは使用者の環境を制限します（使用者がライブラリをインストールする必要がある）。

#### 参考サイト
- [QGISでOpenCVリベンジ - Qiita](https://qiita.com/Zero_Kohaku/items/f69f00a656edcfea6328)


# プラグイン作成手順
## QGISプラグイン内部の構造
Plugin Builderで作成されたひな形は以下のような構造になっています（module nameを「test_plugin」とした場合）。
![スクリーンショット 2019-08-03 15.29.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/531a8ae9-c709-921f-ee0b-5fe76bd703ea.png)

- メインスクリプトは「test_plugin.py」です。
- test_plugin~~.uiはQtのGUI構成ファイルです。
- resources.qrcは事前にpyrcc5により.pyファイルに変換する必要があります（後述）。
- README.txtとREADME.htmlはPlugin Builder使用者向けなので、配布時には削除しましょう。
- その他のファイルは基本的に編集する必要はありません（Plugin BuilderでメタデータやGUIのタイプを適切に入力した場合）。

### recouces.qrcの処理
PyQt5をインストールした上で

```
pyrcc5 -o resources.py resources.qrc
```

とすると、同階層にresources.pyが出力されます。


## 作成準備
### ひな形等の準備
QGISプラグインには必須のファイルやクラス・メソッド等があり、既存のプラグインを参考にイチから書いても良いのですが、ひな形を自動生成してくれる「Plugin Builder 3」というプラグインが存在します。また、QGISが起動中のコードの変更は再起動しないと反映されません。その手間を省ける「Plugin Reloader」というプラグインも存在します。それぞれQGISプラグイン開発には必須のプラグインです。導入方法や使用方法は難しくありませんし、以下のサイトがあるため割愛します。

####参考サイト
- [Plugin Builder 3](https://plugins.qgis.org/plugins/pluginbuilder3/)
- [Plugin Reloader](https://plugins.qgis.org/plugins/plugin_reloader/)
- [QGISプラグインの開発(4) - 合同会社 緑ＩＴ事務所](https://midoriit.com/2017/03/qgis%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%AE%E9%96%8B%E7%99%BA4.html)
- [意外に簡単!? QGISプラグイン開発 - SlideShare](https://www.slideshare.net/tk01k/qgis-53808116)


### GUIを作成
前述のとおり、QGIS上のGUIはQtにより実装されています。フルスクラッチでも書けない事はありませんが「Qt Desiner」を使えば（QGISにバンドルされている）、GUI上でQGISプラグインGUIを作成出来ます。作成した.uiを、プラグインフォルダ内の.uiと置き換えましょう。

#### 参考サイト
- [PyQt5とpython3によるGUIプログラミング［６］ - Qiita](https://qiita.com/kenasman/items/765457d440b923f4e555)

## 処理の実装
これまでの手順により、QGISプラグインのモックが完成しました。後は処理を実装していけば完成です。プラグインの読み込み時には、メインスクリプト内のrun()が実行されます。

```python:test_plugin.py
class TestPlugin:
#(中略)
    def run(self):
        """Run method that performs all the real work"""

        # Create the dialog with elements (after translation) and keep reference
        # Only create GUI ONCE in callback, so that it will only load when the plugin is started
        if self.first_start == True:
            self.first_start = False
            self.dlg = TestPluginDialog()

        # show the dialog
        self.dlg.show()
        # Run the dialog event loop
        result = self.dlg.exec_()
        # See if OK was pressed
        if result:
            # Do something useful here - delete the line containing pass and
            # substitute with your code.
            pass
```
この例では、ダイアログを表示する処理のみ記述されています。GUIパーツと関数を接続したり、クリックイベントの設定など、プラグイン読み込み時の処理はすべてここに記述します。以下に様々な処理の実装方法を列挙します。

-----
### 各種処理の実装方法
#### GUIとの連携
GUIパーツ（ウィジェット）を取得して、関数と接続します。

```Python:sample1.py
#親ウィンドウの取得(dialog, dockwidget)
#通常、ひな形の時点で宣言済みです
from .test_plugin_dialog import TestPluginDialog
self.dlg = TestPluginDialog()
```

各ウィジェットを取得する処理の前に、.uiファイルの中身を一部見てみましょう。

```html:sample
<widget class="QSlider" name="threshold_slider">
 <property name="orientation">
  <enum>Qt::Horizontal</enum>
 </property>
</widget>
```
ウィジェットを取得するためには、widgetのname属性を指定する必要があります。
したがって以下のようになります。

```Python:sample2.py
#親ウィンドウ内のGUIパーツの取得
sample_slider = self.dlg.threshold_slider
#QSliderの設定
#10〜90の値を10刻みで取り、初期値は50
sample_slider.setMinimum(10)
sample_slider.setMaximum(90)
sample_slider.setSingleStep(10)
sample_slider.setValue(50)
```
参考：[QSlider Class](https://doc.qt.io/qt-5/qslider.html)

-----
#### クリックイベント
マップキャンバスのクリックイベントを取得して、コールバック関数を設定します。
まずQgsMapToolを継承したクラスを宣言します。

```Python:click_sample1.py
from qgis.core import *
from qgis.gui import *
from qgis.PyQt.QtCore import QPoint

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

    def canvasPressEvent(self,e):
        self.drugging = True
        point = QPoint(e.pos().x(),e.pos().y())
        self.callback(point) #クリックした点をQPointとしてコールバック関数に渡す
        return None

    def canvasMoveEvent(self,e):
        if self.drugging == False:
            return None
        point = QPoint(e.pos().x(),e.pos().y())
        self.callback(point)
        return None

    def canvasReleaseEvent(self,e):
        point = QPoint(e.pos().x(),e.pos().y())
        self.callback(point)
        self.drugging = False
        return None
```
定義したクラスを呼び出し、既存の関数と接続します。

```Python:click_sample2.py
from .click_sample1 import ClickTool
        ct = ClickTool(self.iface,  self.click_action) #クリック時にclick_action関数が実行される
        self.previous_map_tool = self.iface.mapCanvas().mapTool()
        self.iface.mapCanvas().setMapTool(ct) #マップキャンバスに対しクリックイベントを設定

    #引数にpointを設定
    def click_action(self, point):
        #各種処理

```
参考：[QGIS3でpythonプラグインを作ってみた その5 地図をクリックして地物を選択する - Qiita](https://qiita.com/ozo360/items/7ab448c57eac5140f2fe)

-----
#### プロジェクト内のレイヤーを取得
```python:get_layers_sample.py
layers = QgsProject.instance().mapLayers()
#layersのうちベクターレイヤーのみをすべて取得する
vector_layers = []
for key, layer in layers.items():
    if layer.type() == QgsMapLayer.VectorLayer:
        vector_layers.append((layer.name(),key))
        #レイヤーへのアクセスにはそれぞれに付与されているIDが必要となる
        #ここでは、keyがレイヤーごとのID
```
参考：[QGIS3でpythonプラグインを作ってみた その3 QComboBoxとレイヤ取得について](https://qiita.com/ozo360/items/2c20e304b63b59f07a54)

-----
#### 新規レイヤーを追加（メモリレイヤー）
```Python:memory_layer_sample.py
#WGS84のベクターレイヤーを生成
output = QgsVectorLayer('Polygon?crs=epsg:4326&field=MYNYM:integer&field=MYTXT:string', 'new_layer', 'memory')
#プロジェクトに追加
QgsProject.instance().addMapLayer(output)
```

-----
#### 地物をベクターレイヤーに追加
```Python:feature_append_sample.py
#memory_layer_sample.pyを拡張
#WGS84のベクターレイヤーを生成
output = QgsVectorLayer('Polygon?crs=epsg:4326&field=MYNYM:integer&field=MYTXT:string', 'new_layer', 'memory')
output_provider = output.dataProvider() #地物追加はdataProviderを経由
output_provider.addFeature(feature) #featureはQgsFeature
QgsProject.instance().addMapLayer(output) #プロジェクトに追加

#追加する地物が複数の場合
output_provider.addFeatures(features) #featuresは[QgsFeature]
```

-----
#### バッファ、ディゾルブ、簡素化など
バッファと簡素化

```python:buffer_simplify.py
#featureというQgsFeatureに対し実行する例
buffered = feature.geometry().buffer(0.0001, 1)
simplified = feature.geometry().simplify(0.0001)

buffered_feature = QgsFeature()
buffered_feature.setGeometry(buffered)

simplified_feature = QgsFeature()
simplified_feature.setGeometry(simplified)

#buffer()もsimplify()も引数・返り値がともにQgsGeometryなので以下のように連結可能
buffered_and_simplified = feature.geometry().buffer(0.0001, 1).simplify(0.0001)
```

ディゾルブ（=Union,レイヤー同士の結合のこと）

```python:dissolve.py
unioned = QgsFeature()
unioned.setGeometry(QgsGeometry().unaryUnion(geos))
#geosは[QgsGeometry]
```

-----
#### レイヤーの追加・削除イベント

```python:layer_added_removed
#プロジェクトにレイヤーが追加されたときに、layer_added()を実行
QgsProject.instance().layersAdded.connect(self.layer_added)
#プロジェクトからレイヤーが削除されたときに、layer_removed()を実行
QgsProject.instance().layersRemoved.connect(self.layer_removed)
```

-----
#### マップキャンバス上のポイントを座標に変換
```python:convert_point.py
#マップキャンバス上のピクセル(0, 0)を、プロジェクトCRSの座標に変換
point = self.iface.mapCanvas().getCoordinateTransform().toMapPoint(0, 0)
```
前述したクリックイベントの取得と連携させると、クリックした点の座標を取得できます

```python:click_sample2.py
def click_action(self, point):
    converted = self.iface.mapCanvas().getCoordinateTransform().toMapPoint(point.x(), point.y())
```

-----
#### マップキャンバスの画像出力
ファイルとして出力する場合は以下のようにシンプルに書けます。

```python:export_image.py
self.iface.mapCanvas().saveAsImage(filepath) #filepathは保存先の絶対パス
```

しかし画像を解析する場合はメモリ上で出力したいです。
以下のような処理になります。

```python:image_on_memory.py
#マップキャンバスをそのまま、メモリ上で出力
image = QImage(mapSettings.outputSize(), QImage.Format_RGB32)
p = QPainter()
p.begin(image)
mapRenderer = QgsMapRendererCustomPainterJob(mapSettings, p)
mapRenderer.start()
mapRenderer.waitForFinished()
p.end()
```
mapSettingsを変更する事で出力画像のサイズ等を変更出来ます。

-----
#### マップキャンバスの再描画
```python:refresh_canvas.py
self.iface.mapCanvas().refreshAllLayers()
#既存レイヤーに地物を追加した際などに使用
```

-----
#### マップキャンバスのPDF出力
PrintLayoutの作成、各種パーツの作成・設置、PDF出力という手順になります。
[EZPrinter](https://qiita.com/Kanahiro/items/4b82537c702fdd74ed7d)という地図を簡単にPDF出力出来るプラグインを作ったので、そのソースコードが一番わかりやすいと思います…たぶん

```python
#プリントレイアウトの作成
project = QgsProject.instance()
printLayout = QgsPrintLayout(project)
printLayout.initializeDefaults()
printLayout.setUnits(QgsUnitTypes.LayoutMillimeters)

#以下の処理は、1ページ目の用紙サイズを設定しています
page = printLayout.pageCollection().pages()[0]
page.setPageSize(QgsLayoutSize(paperWidth, paperHeight))
```

```python
#マップを作成・設置
projectMap = QgsLayoutItemMap(printLayout)
projectMap.updateBoundingRect()
projectMap.setRect(QRectF(CONSTANTS.PAPER_MARGINS['left'], CONSTANTS.PAPER_MARGINS['top'], mapWidth, mapHeight)) #マップのサイズ
projectMap.setPos(CONSTANTS.PAPER_MARGINS['left'], CONSTANTS.PAPER_MARGINS['top']) #マップを設置する（紙上の）位置
projectMap.setFrameEnabled(True)
projectMap.setExtent(QgsRectangle(coordinates["topLeft"], coordinates["bottomRight"])) #マップに描画する領域（座標）
projectMap.attemptSetSceneRect(QRectF(CONSTANTS.PAPER_MARGINS['left'], CONSTANTS.PAPER_MARGINS['top'], mapWidth, mapHeight))

#色々処理したマップをプリントレイアウトに設置
printLayout.addItem(projectMap)
```

```python

exporter =  QgsLayoutExporter(outputPrintLayout)
#PDFとして出力
pdf_settings = exporter.PdfExportSettings()
exporter.exportToPdf(filepath, pdf_settings)
#画像として出力
img_settings = exporter.ImageExportSettings()
exporter.exportToImage(filepath, img_settings)
```

#### Processing
QGISには、様々な処理が出来る「プロセッシングツール」というものが既存で用意されています。
C++で書かれているものが多いようです。プラグイン内でもそれらを呼び出し実行する事が可能です。以下は「マルチパートにシングルパートに」という処理を実装した例です。

```python:processing_sample.py
import processing

#mem_layerは複数の地物をもつマルチパートのレイヤー
single_part_layer = processing.run('qgis:multiparttosingleparts', {'INPUT':mem_layer,'OUTPUT':'memory:'})
single_features = single_part_layer['OUTPUT'].getFeatures()
#出力結果は['OUTPUT']というkeyで取得
```

参考：[QGIS/python/plugins/processing/algs/help/qgis.yaml - GitHub](https://github.com/qgis/QGIS/blob/master/python/plugins/processing/algs/help/qgis.yaml)
公式リポジトリです。呼び出し可能なプロセッシングツールが列挙されています。


# 各種リファレンス
- [Qt 5.13](https://doc.qt.io/qt-5/index.html)
- [QGIS Python API Documentation Project](https://qgis.org/pyqgis/master/index.html)
- [GDAL/OGR Python API](https://gdal.org/python/) 
- [Python GDAL/OGR Cookbook 1.0 documentation](https://pcjericks.github.io/py-gdalogr-cookbook/geometry.html#calculate-union-between-two-geometries)
- [QGIS/python/plugins/processing/algs/help/qgis.yaml - GitHub](https://github.com/qgis/QGIS/blob/master/python/plugins/processing/algs/help/qgis.yaml)
- [Learn QGIS: Your step-by-step guide to the fundamental of QGIS 3.4, 4th Edition - Google Books](https://books.google.co.jp/books?id=UH18DwAAQBAJ&pg=PA206&hl=ja&source=gbs_toc_r&cad=3#v=onepage&q&f=false)