
業務で繰り返し使えるQGISプラグインを、Claude CodeとQGIS MCPでサクッと作ってみた
先日、Claude CodeとQGIS MCPを使って、PyQGISを書かずにQGISを自然言語で操作してみました。
やってみて単発の地図操作は快適でした。ただ、業務では同じ作業を毎回繰り返します。現地を回って地点ごとに状態を記録し、最後に地図つきの点検レポートをPDFで提出する。この一連の流れを毎回自然言語で指示し直すのは、それはそれで面倒です。
こういう「繰り返す業務」は、QGISプラグインとして固めてしまう のが良さそうです。ボタンひとつで記録用レイヤーを用意し、点検ポイントを入力し、レポートを出力できれば、毎回同じ手順を組み立て直す必要がなくなります。
とはいえ、プラグインを自分で一から書くのはハードルが高い。そこで今回は、プラグインの実装そのものをClaude Codeに書かせて、QGIS MCPでリロードしながら動作確認して育てる という作り方を試してみます。
最初に結論
- 「現地調査の記録 → 地図つきPDFレポート出力」を行うQGISプラグインを、Claude Codeにコードを書かせて作れました
- QGIS MCP(
nkarasiak/qgis-mcp)のreload_pluginでプラグインをホットリロードできるので、「コードを直す → リロード → 動作確認」のループをQGISを再起動せずに高速に回せました - 一度プラグインにしてしまえば、次回以降はボタン操作だけで記録・レポート出力が完結します。業務で繰り返す作業ほどプラグイン化の効果が大きいです
- 一方で、UIのレイアウトやPDFの体裁は一発では決まらず、実際に動かして微調整の指示を往復する必要がありました
今回作るもの
作るのは、現地調査・点検業務向けのQGISプラグインです。次の機能を持たせます。
- 点検レイヤーの生成:ツールバーボタンから、規定スキーマ(
name/status/comment/photo/inspected_at)の点検ポイントレイヤーを一発生成 - 点検ポイントの記録:地図をクリックして点検ポイントを追加し、設備名・ステータス・コメント・写真をダイアログで入力。既存ポイントの上をクリックすれば編集・削除
- 写真の埋め込み:写真をドラッグ&ドロップで登録し、プロジェクトの中に持たせる
- ステータス色分け:点検ステータス(正常 / 要確認 / 異常)に応じて自動で色分け表示
- PDFレポート出力:地図+凡例+写真つきの点検結果一覧を規定レイアウトでPDFに書き出し
サンプルコード全体はGitHubで公開しています。
前提条件
- macOS
- QGIS 3.28 以上がインストール済みであること(QGIS 3.44で動作確認しています)
- uv がインストール済みであること(QGIS MCPサーバーを
uvxで起動するために使います) - Claude Code + QGIS MCP(
nkarasiak/qgis-mcp)がセットアップ済みで、疎通確認(ping)が取れていること - Amazon Location ServiceのAPIキーが設定済みであること(背景地図に使用)
背景地図には、Amazon Location Service を使います。商用利用可能な地図データを、料金体系が明確なマネージドサービスとして使えるため、業務での背景地図として利用できます。背景地図は今回作るプラグインでは追加しないので、前回記事と同じくAmazon Location Serviceプラグインの「Map」から、あらかじめプロジェクトに追加しておきます。
Amazon Location ServiceのAPIキー取得とプラグインのセットアップは、この記事を参考にしてください。
新しいプロジェクトディレクトリで始める場合は、次の2点に注意してください。
.mcp.jsonを置いただけではMCPサーバーは読み込まれません。設定後にClaude Codeを再起動します- QGIS側で
qgis_mcp_pluginを有効化し、サーバーを起動しておきます。これが済んでいないとreload_pluginを含むすべてのツールが疎通しません
QGIS MCPがプラグイン開発に効く理由
今回使う nkarasiak/qgis-mcp は、118個のMCPツールを備えています。
その中に、プラグイン開発を助けるツールが含まれているのが今回のポイントです。
| ツール | 用途 |
|---|---|
list_plugins |
インストール済みプラグインの一覧を取得 |
get_plugin_info |
特定プラグインの状態・情報を取得 |
reload_plugin |
プラグインをホットリロード(QGIS再起動不要) |
execute_code |
QGIS内で任意のPyQGISコードを実行(動作確認・検証用) |
通常は Plugin Reloader プラグインを入れて修正後のQGISプラグインを手動でリロードしますが、reload_plugin があれば、Claude Codeが 自分でコードを直してリロードし、execute_code で動作を確認する ところまで一気にできます。
QGISがプラグインを読み込むのはplugins ディレクトリ配下だけですが、そこで直接開発するとGit管理から外れます。プラグインのディレクトリはリポジトリ側に作り、plugins ディレクトリからシンボリックリンクを張っておきます。以降の編集はすべてリポジトリ側のファイルに対して行い、QGISはリンク経由でそれを読み込みます。
mkdir -p ~/<<YOUR_DIRECTORY>>/qgis-plugin-sample/qgis_inspection_report
ln -s ~/<<YOUR_DIRECTORY>>/qgis-plugin-sample/qgis_inspection_report \
~/Library/Application\ Support/QGIS/QGIS3/profiles/default/python/plugins/qgis_inspection_report
この構成での開発ループを図にすると次のようになります。
ただし、118個のツール定義はそれだけでコンテキストを圧迫します。.mcp.json の env に QGIS_MCP_TOOL_MODE=compound を指定すると、ツールが機能ごとにまとめられて27個になります。
"env": {
"QGIS_MCP_TOOL_MODE": "compound"
}
プラグイン開発で使うのは plugins(list_plugins / get_plugin_info / reload_plugin 相当)と code(execute_code 相当)で、どちらもcompoundモードでそのまま使えます。長いセッションになるプラグイン開発では、こちらを勧めます。
QGISプラグインの最小構成をおさらい
QGISプラグインは、最低限この2ファイルがあれば成立します。
qgis_inspection_report/
├── metadata.txt # プラグインのメタ情報(名前・バージョン・対応QGISなど)
└── __init__.py # classFactory() を持つエントリポイント
フォルダ名はそのままPythonのモジュール名になります。reload_plugin に渡すのもこの名前です。
metadata.txt は、公式ドキュメント(PyQGIS Developer Cookbook)で次の8項目が必須とされています。公式プラグインリポジトリへ公開するときにも必要になるので、最初から揃えておきます。
# ※ 必須項目のみ抜粋しています
[general]
name=Inspection Report
qgisMinimumVersion=3.28
description=現地調査の点検ポイントを記録し、地図つきPDFレポートを出力するプラグイン
about=点検ポイントレイヤーの生成、地図クリックによる点検記録、ステータス色分け、印刷レイアウトによるPDFレポート出力を行います。
version=0.1.0
author=rednes
email=528249+rednes@users.noreply.github.com
repository=https://github.com/rednes/qgis-plugin-sample
__init__.py の classFactory() がプラグイン本体のクラスを返します。本体クラスは initGui()(メニューやツールバーの登録)と unload()(後片付け)を実装します。
今回は機能が多いので、本体コードを main_plugin.py に、ダイアログUIを dialogs.py に、レポート出力を report.py に分ける構成でClaude Codeに書いてもらいました。
ステップ1:プラグインの骨組みを作らせる
まずは「動く空のプラグイン」をClaude Codeに作ってもらいます。ツールバーにボタンが1つ出るだけの骨組みです。作成先はリポジトリ側のディレクトリで、Claude Codeもそこを編集します。
> QGISプラグインを作りたいです。まずは動くだけの骨組みを作ってください。
プラグイン名は「Inspection Report」、対応QGISは3.28以上です。
QGISのツールバーにボタンを1つ出して、押したら「Inspection Report」というメッセージが
出るだけでOKです。
作成先はリポジトリ直下の qgis_inspection_report で、必要なファイル構成はおまかせします。
やりたいことだけ伝えれば、metadata.txt の必須項目、classFactory() の定義、initGui() でのツールバー登録と unload() での後片付けといったプラグインの作法は、指示しなくても埋まります。生成されたのは以下のようなコードです。
from qgis.PyQt.QtWidgets import QAction, QMessageBox
def classFactory(iface):
return InspectionReportPlugin(iface)
class InspectionReportPlugin:
def __init__(self, iface):
self.iface = iface
def initGui(self):
self.action = QAction("Inspection Report", self.iface.mainWindow())
self.action.triggered.connect(self.run)
self.iface.addToolBarIcon(self.action)
def unload(self):
self.iface.removeToolBarIcon(self.action)
del self.action
def run(self):
QMessageBox.information(None, "Inspection Report", "Inspection Report")
QGISのプラグインマネージャで有効化すると、ツールバーにボタンが出ます。ここから先は reload_plugin でリロードしながら機能を足していきます。

ステップ2:点検ポイントを記録できるようにする
骨組みのボタン1つを、「点検レイヤー作成」と「点検ポイント記録」の2つに変えます。入力する・見る・直す・消すが一続きの作業なので、記録まわりはまとめて作ってしまいます。
> このプラグインに、点検レイヤーを生成する機能と点検ポイントを記録する機能を追加してください。
骨組みのボタンは「点検レイヤー作成」「点検ポイント記録」の2つに置き換えます。
「点検レイヤー作成」は、設備名・ステータス・コメント・写真・点検日時を記録できる
点検ポイント用のレイヤーを作ります。緯度経度で記録し、ファイルには保存せず
QGISのプロジェクト内に持つ一時的なレイヤーでかまいません。
「点検ポイント記録」を押したあとは、地図をクリックすると点検ポイントを記録できるように
してください。クリックした場所に既存のポイントがあればその内容を編集、なければ新規追加です。
編集のときだけダイアログに削除ボタンを出してください。
修正できたら reload_plugin でリロードして動作確認してください。
レイヤーの中身は、この指示からClaude Codeが name / status / comment / photo / inspected_at の属性を持つEPSG:4326のメモリレイヤーとして組み立ててくれました。Claude Codeがコードを追記し、reload_plugin でリロードして動作を確認します。うまくいかなければ get_plugin_info や execute_code で状態を調べ、自分で直します。ここで「QGISを再起動せずにリロードで確認できる」のが効いてきて、修正のループが速く回りました。

往復しながら決まっていったのは、次のあたりです。
- 追加と編集を1つのツールにまとめる:ボタンを分けず、クリック位置に既存ポイントがあるかどうかで分岐させます。「入力する」と「直す」を同じ操作でこなせます
- ヒット判定はピクセル基準にする:クリック位置を中心とした許容矩形で地物を検索し、実距離が半径内にあるもののうち最も近いものを選ぶ方式にしました。許容半径を12pxとピクセルで決めておくと、縮尺を変えても操作感が変わりません
- 点検レイヤーは1つに保つ:「点検レイヤー作成」を押すたびにレイヤーが増えると、記録先とレポートの出力対象が曖昧になります。既に点検レイヤーがあればそれをアクティブにするだけにして、二重作成を防ぎます
- 削除はダイアログの第3の戻り値にする:
QDialogButtonBoxのDestructiveRoleに「削除」ボタンを置き、確認を挟んでからdone()に独自の戻り値を渡します。OK / キャンセル / 削除の3分岐をexec_()の戻り値だけで扱えます
追加と編集の分岐(簡略版)
# ※ 要点を抜粋した簡略版です
HIT_TOLERANCE_PX = 12 # クリック位置を「既存ポイントの上」とみなす半径
def record_point(self, canvas_point):
"""既存ポイントの上なら編集、そうでなければ追加する。"""
layer = self.inspection_layer()
point = self._to_layer_crs(layer, canvas_point)
feature = self._feature_at(layer, point, canvas_point)
if feature is None:
self._add_point(layer, point)
else:
self._edit_point(layer, feature)
def _feature_at(self, layer, point, canvas_point):
"""クリック位置の許容範囲内にある最も近いポイントを返す。"""
tolerance = self._tolerance_in_layer_units(layer, point, canvas_point)
rect = QgsRectangle(
point.x() - tolerance, point.y() - tolerance,
point.x() + tolerance, point.y() + tolerance,
)
nearest, nearest_distance = None, None
for feature in layer.getFeatures(rect):
distance = feature.geometry().asPoint().distance(point)
# 矩形の角は半径より遠いので、実距離でも絞り込む
if distance > tolerance:
continue
if nearest_distance is None or distance < nearest_distance:
nearest, nearest_distance = feature, distance
return nearest
def _tolerance_in_layer_units(self, layer, point, canvas_point):
"""許容半径(ピクセル)をクリック位置周辺のレイヤーCRS距離に換算する。"""
# キャンバスCRSの縮尺は位置で変わりうるので、原点ではなくクリック位置で測る
to_map = self.iface.mapCanvas().getCoordinateTransform()
pixel = to_map.transform(canvas_point)
tolerance = 0.0
for dx, dy in ((HIT_TOLERANCE_PX, 0), (0, HIT_TOLERANCE_PX)):
edge = self._to_layer_crs(
layer, to_map.toMapCoordinates(pixel.x() + dx, pixel.y() + dy)
)
tolerance = max(tolerance, edge.distance(point))
return tolerance
写真はプロジェクトに埋め込む
写真をローカルパスで属性に持たせると、元ファイルを移動・削除しただけで表示が壊れます。プロジェクトファイルを同僚に渡しても写真は付いていきません。
そこで、写真は320pxに縮小してPNGのbase64データURI(data:image/png;base64,...)に変換し、属性値そのものとして埋め込む方式にしました。マップチップやレポートのHTMLフレームはブラウザエンジン(Qt WebKit)で描画されるため、<img src> にデータURIをそのまま書けば、QGISのバージョンに依存せず表示できます。
なお、QGIS 3.40以降はQGIS本体もデータURIを画像パスとして解釈できるようになっており、ラスターマーカーやレイアウトの画像アイテムでも同じ属性値を使えます。
> 写真は属性にパスを持たせるのではなく、320pxに縮小した PNG の base64 データURIに
変換して属性値に埋め込んでください。
入力ダイアログには点線枠のドロップ領域を置き、画像ファイルのドラッグ&ドロップで
登録できるようにします。編集時は既存の写真をその領域にプレビュー表示してください。
ドロップ領域はファイルURL(mimeData().hasUrls())だけでなく、他アプリから画像そのものがドロップされる場合(mimeData().hasImage())にも対応させています。編集時に既存写真をプレビューしておくと、何の画像かわからないまま差し替える事故を防げます。
引き換えに、1枚あたり約110KBが属性値に載ります。点数が増えるとプロジェクトファイルが膨らむ点は、運用で見込んでおく必要があります。
写真をデータURIに変換する(簡略版)
# ※ 要点を抜粋した簡略版です
THUMBNAIL_MAX_SIZE = 320
DATA_URI_PREFIX = "data:image/png;base64,"
def image_to_data_uri(image, max_size=THUMBNAIL_MAX_SIZE):
"""QImage を縮小し、PNG の base64 データURIに変換する。"""
if image.isNull():
return ""
thumbnail = image.scaled(
max_size, max_size, Qt.KeepAspectRatio, Qt.SmoothTransformation
)
buffer_data = QByteArray()
buffer = QBuffer(buffer_data)
buffer.open(QIODevice.WriteOnly)
thumbnail.save(buffer, "PNG")
buffer.close()
return DATA_URI_PREFIX + bytes(buffer_data.toBase64()).decode("ascii")
def file_to_data_uri(path, max_size=THUMBNAIL_MAX_SIZE):
"""画像ファイルを読み込み、データURIに変換する(読めなければ空文字)。"""
return image_to_data_uri(QImage(path), max_size)
地図上で中身を確認する
記録した内容は、地図上で確認できないと使いにくそうです。マップチップと吹き出しラベルの2つを、レイヤー生成時に仕込みます。
マップチップ は、地物にマウスを重ねたときに出るポップアップです。setMapTipTemplate() にHTMLを渡すだけで、設備名・ステータス・コメント・点検日時・写真を出せます。写真は埋め込みデータURIなので <img src="[% "photo" %]"/> と書けます。ただし、アクティブレイヤにしか表示されず、マップツールが未設定だと出ません。
吹き出しラベル は、設備名とステータスを地図上に常時表示するものです。QgsBalloonCallout を QgsPalLayerSettings.setCallout() に渡し、placement を AroundPoint、dist を8mm程度にしてポイントから離すと、引き出し線が見えるようになります。点が密集すると衝突して間引かれるので、点数が多い場合は縮尺による出し分けが要ります。

ステップ3:ステータス色分けを組み込む
記録した点検ステータスを、地図上でひと目で判別できるように色分けします。正常=緑、要確認=黄、異常=赤という定番の配色を、レイヤー生成時に自動で適用させます。
> 点検レイヤーに、status で色分けする分類シンボルを自動で適用してください。
「正常」は緑、「要確認」は黄、「異常」は赤です。
分類シンボル(カテゴリ分けレンダラー)をプラグイン側で設定しておくことで、ポイントを追加するたびにステータスに応じた色が自動で付きます。異常箇所が地図上で赤く浮かび上がり、優先度が直感的にわかります。
配色は入力ダイアログの選択肢・地図の色分け・レポートの3か所から参照するので、定義を1つのモジュールにまとめておくとずれません。

ステップ4:PDFレポート出力ボタンを付ける
最後に本命の機能です。「レポート出力」ボタンを押したら、点検結果を1枚の紙面にまとめてPDFに書き出します。紙面には次を載せます。
- タイトル(出力日時と点検地点数)
- 地図(ステータスで色分けした点検ポイント+背景地図)
- 凡例(ステータスの色の意味)
- 点検結果の一覧表(写真・設備名・ステータス・コメント・点検日時)
PDF出力はQGISネイティブの 印刷レイアウト(Print Layout) を使います。地図・凡例・表・画像を1枚の紙面に配置してPDFに書き出す機能で、QGIS本体に含まれているため外部ライブラリは不要です。
> このプラグインに「レポート出力」ボタンを追加してください。
押したらA4横の印刷レイアウトを作り、タイトル・地図・凡例・点検結果の一覧表を配置して、
指定パスにPDFを書き出します。
一覧表は左端に写真列を置き、異常・要確認の地点を上に並べてください。
写真は異常時だけでなく、正常だった証拠としても残します。そのため一覧には写真の有無にかかわらず全地点を載せ、写真がない行は「写真なし」と表示します。並び順は異常・要確認を上にし、A4横1枚に収まるように最大20行で打ち切っています。
QgsLayoutItemHtml を ManualHtml モードで使い、HTMLの表を自前で組むと、左端の写真列と各行が同じ高さで並びます。属性のデータURIを <img src="..."> にそのまま書けるので、一時ファイルへ展開する処理も要りません。

PDFレポート出力の抜粋(簡略版)
# ※ 要点を抜粋した簡略版です(要素の座標・サイズは調整前提)
def build(self):
self.layout = QgsPrintLayout(self.project)
self.layout.initializeDefaults()
self.layout.pageCollection().page(0).setPageSize(
"A4", QgsLayoutItemPage.Landscape
)
self._add_title()
self._add_map()
self._add_legend()
self._add_table()
return self.layout
def _add_table(self):
# 属性テーブルではなくHTMLで組み、左端に写真列を置く
table = QgsLayoutItemHtml.create(self.layout)
self.layout.addMultiFrame(table)
table.setContentMode(QgsLayoutItemHtml.ManualHtml)
table.setHtml(self._build_html())
table.loadHtml()
# 地図の下から紙面の下端までを表に割り当てる
table_y = MARGIN + 14 + MAP_HEIGHT + 6
frame = QgsLayoutFrame(self.layout, table)
frame.attemptSetSceneRect(
QRectF(MARGIN, table_y, LEFT_WIDTH, PAGE_HEIGHT - table_y - MARGIN)
)
table.addFrame(frame)
def _build_row(self, feature):
photo = feature["photo"]
if is_embedded_photo(photo):
# 属性値がデータURIなので、そのまま img の src に置ける
photo_cell = '<td class="photo"><img src="%s"/></td>' % photo
else:
photo_cell = '<td class="photo empty">写真なし</td>'
...
紙面の配置は1回の指示では要素が重なりがちで、「表を地図の下に移動して」「凡例を右に」といった微調整の指示を何度か往復しました。
やってみてわかったこと
うまくいったこと
- プラグインの骨組みから機能追加まで をClaude Codeに任せられた。
metadata.txtやclassFactoryの作法を覚えていなくても、指示だけで動くプラグインができる reload_pluginによるホットリロード が効く。「コードを直す → リロード →execute_codeで確認」のループをQGIS再起動なしで高速に回せた- 繰り返し作業がボタン操作になる。一度プラグインにすれば、次回以降は記録もレポート出力もボタンで完結する。単発の自然言語操作と違い、業務で毎回使える資産になる
- PDF出力はQGISネイティブの印刷レイアウトなので、追加ライブラリなしで完結する
つまづいたこと
- UIとPDFの体裁は一発では決まらない。ダイアログの並びやレイアウトの余白・重なりは、実際に動かして「ここを直して」と往復する必要があった
- 一覧表は1ページに収まる件数で打ち切っている。今回は最大20行に固定したので、点検地点がそれ以上ある場合は複数ページへの分割やエリアごとの出力が要る。業務で使うなら、1回の点検で扱う地点数を先に見積もっておきたい
- 写真の持ち方はレポートの作り方まで波及する。ローカルパスで持つと配布時に壊れ、データURIで埋め込むと属性値が膨らむ。しかも一覧表に写真を並べられるかどうかは、この選択に依存する。先に決めておくほど手戻りが少ない
「何を作るか」の設計は人間の仕事
Claude Codeはコードを書いてリロードして動作確認までしてくれますが、「どんな属性を記録すべきか」「レポートに何を載せるべきか」という 業務要件の設計 は人間が決める必要があります。前回書いた「GISを理解する必要はなくならない」と同じで、今回も「 業務で何が必要かを理解する必要 」はなくなりません。
逆に言えば、要件さえ言語化できれば、そこから先の実装はかなり任せられる、という手応えでした。
注意点
- 破壊的なツール(
execute_code/remove_layer/delete_featuresなど)にはdestructiveのアノテーションが付いており、Claude Codeなどのクライアントは実行前に許可を求めます。サーバー側でも確認を挟む二重確認の仕組みがありますが、こちらはデフォルトで無効です。ツールを無人で実行するクライアントで使う場合はQGIS_MCP_AUTO_CONFIRM=0を指定して有効にしてください - ソケットはデフォルトでlocalhostにバインドされ認証はありません。共有マシンで使う場合は
QGIS_MCP_TOKENで共有シークレットを設定できます - 作ったプラグインを配布・公開する場合は、
metadata.txtに正しいライセンスや作者情報を記載してください
おわりに
前回のQGIS MCPブログの続編として、「現地調査の記録 → 地図つきPDFレポート出力」を行うQGISプラグインを、Claude Codeにコードを書かせて作ってみました。
単発の地図操作を毎回指示するのではなく、繰り返す業務をプラグインとして固めてしまうと、次回以降はボタン操作だけで済みます。しかもそのプラグイン自体を、reload_plugin でリロードしながらClaude Codeに育てさせられるのは、開発体験としてかなり快適でした。「プラグイン開発はハードルが高い」という印象が、だいぶ軽くなった気がします。
一方で、UIやPDFの体裁の微調整は自然言語だけだと往復が発生します。「叩き台を一気に作ってもらい、仕上げは人間が微調整する」という分担が現実的だと感じました。
今回は設備点検を例にしましたが、道路の破損箇所・災害の被害状況・店舗の巡回チェックなど、「地点ごとに記録して一覧レポートにする」業務であれば同じプラグインの形が応用できます。繰り返す作業ほど、プラグイン化の効果は大きいです。ぜひ試してみてください。
このブログがどなたかのお役に立てれば幸いです。





