anorithのブログ

主にGIS系の記事を書く。

QGISプラグイン開発の勉強(2)

概要

参考

  • ChatGPT
    • GPT-4を使用しました。
    • 記事の最後に少しコメントを記載しています。

アイコン設定とショートカット設定

前回のプラグインフォルダをそのまま使用します。フォルダにアイコン用の画像を格納します。

アイコンには以下の画像を使用しました。DALL·E3に白背景のアイコンを描いてもらい、ノイズ除去のためvectorizer.aiで一度ベクター化&pngで再保存して、白背景部分をPythonで透過に処理してアイコンにしました(AIに頼りすぎでは・・・?)。

まずインポート部分にアイコン設定用のモジュールを追加します。

from PyQt5.QtWidgets import QAction
from PyQt5.QtGui import QIcon

次に__init__.pyinitGuiの箇所を以下のように書き換えます。

    def initGui(self):
        self.action = QAction('MyPluginAction', self.iface.mainWindow())

        icon_path = os.path.join(os.path.dirname(__file__), "icon.png")        
        self.action.setIcon(QIcon(icon_path))
        self.action.setShortcut("Ctrl+T")

        self.action.triggered.connect(self.run)
        self.iface.addToolBarIcon(self.action)

以下のようにアイコンが反映されました。オリジナルのアイコンだと一気にプラグイン作ってる感じが出ますね。

またショートカットCtrl+Tも使用してrunの処理が行えるようになっています。

画像保存プラグインの作成

インポート部分に必要なモジュールを追加します。

import os
from PyQt5.QtWidgets import QAction
from PyQt5.QtGui import QIcon
from qgis.core import QgsProject, QgsMapRendererParallelJob

runの中身を以下の内容をに書き換えます(ChatGPTの出力そのままです)。

    def run(self):
        # 現在のプロジェクトのファイルパスを取得
        project_path = QgsProject.instance().fileName()
        if not project_path:
            # プロジェクトが保存されていない場合は、処理を中止
            return
        
        # プロジェクトのディレクトリパスを取得
        project_directory = os.path.dirname(project_path)
        image_path = os.path.join(project_directory, "map_image.png")

        # 現在の地図の設定を取得
        map_settings = self.iface.mapCanvas().mapSettings()

        # レンダリングジョブを作成して地図を画像として保存
        job = QgsMapRendererParallelJob(map_settings)
        job.start()
        job.waitForFinished()
        image = job.renderedImage()

        image.save(image_path)

以下の状態でプラグインを実行します(アイコンクリックorCtrl+T)。

以下の画像がQGISプロジェクトと同じ箇所に保存されます。

思ったより簡単にできました。 画像保存の話は以前も書きましたが、こういう書き方もあるのだと勉強になりました(以前の記事:https://anorith.hatenablog.com/entry/2023/01/15/213632)。

ChatGPTについて

今回はChatGPT使って勉強してみましたが、ぶっちゃけブログ記事書く(見る)よりChatGPTに聞いた方がよくない?という気がしてきますね・・・。

チャットのログ:https://chat.openai.com/share/c5fea02b-6009-4b05-8317-bc11d9b643e7

ログの途中のショートカットのやりとりは別原因だったためスルーして下さい。

QGISプラグイン開発の勉強(1)

概要

参考

バージョン情報

  • QGISのバージョン: 3.22.14

プラグイン作成

最小構成のQGISプラグインを作ってプラグイン開発の勉強をしてみました。プラグイン作成は初めてです。上記の「qgis-minimal-plugin」を使用しました。

ネット上だとPlugin Builderを使った手順は多いですが、とりあえず触ってみる、という感じだけならこっちでも十分そうです(というか最初に手を付けるならこっちの方がいい気がします)。

とりあえずReadmeの手順に従ってプラグインフォルダの中にminimalフォルダを作成し、その中にmetadata.txt__init__.pyを作成します。

プラグインフォルダ:C:\Users\USER\AppData\Roaming\QGIS\QGIS3\profiles\default\python\plugins\minimal

両方中身もコピペしますが、__init__.pyはここにも書いておきます。

from PyQt5.QtWidgets import QAction, QMessageBox

def classFactory(iface):
    return MinimalPlugin(iface)


class MinimalPlugin:
    def __init__(self, iface):
        self.iface = iface

    def initGui(self):
        self.action = QAction('Go!', 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, 'Minimal plugin', 'Do something useful here')
  • classFactory__init__.pyに必須のクラスのようですね。
  • __init__, initGui, unloadプラグインクラスに必須のメソッドの様です。
  • initGuiプラグインが有効になったときに呼ばれるメソッドで、unloadプラグインが無効になったときに呼ばれるメソッドの様です。
  • どうやらここではGo!という名前(テキスト属性)でアクションを作成し、メッセージを表示するメソッドを紐づけてツールバーに追加するようです。

有効化してみます。

↓追加されました。アクションがプラグインツールバーに追加されたようです。

アクションをクリックすると以下のメッセージが表示されます。

とりあえず動作確認はできました。

変更の反映

自分でコードをいじって遊びたいのでコードの変更の反映方法を確認しておきます。

とりあえず「プラグインの管理とコンソール」で無効にして再度有効化すればよいようです。 initGUIの文字をGo?にしてみます。

    def initGui(self):
        self.action = QAction('Go?', self.iface.mainWindow())
        self.action.triggered.connect(self.run)
        self.iface.addToolBarIcon(self.action)

反映されました。

今のところQGISらしさが皆無なので、QGISのPythonAPIも使用してみます。 プラグイン開発2 · GIS実習オープン教材を参考にレイヤ数を表示してみます。

runの中身を以下のように変更して再度読み込みます。

    def run(self):
        cnt = self.iface.mapCanvas().layerCount()
        QMessageBox.information(None, 'Minimal plugin', f'layers: {cnt}')

レイヤ数が表示されました。

これだけで基本的なプラグインとして動かせるので色々遊べそうですね。

WSL2のDockerでmastodonを起動してみる(HTTPS)

概要

手順(メモ)

前回のブログ内容と以下リンクを参考にやりました。

https://gist.github.com/melroy89/6fe7d05bdc0cfd2153b77310abf62990

特に詰まることろはなかったので、メモ書き程度の内容を記載しておきます。

  • DBの準備

上記リンクの

docker-compose run --rm web bundle exec rails db:setup

の箇所は代わりに前回記事のマイグレーションのコマンドを実行した(違いは不明)。

  • 証明書の作成

証明書の作成はWSL上で以下のコマンドで実行。

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout nginx/ssl/server.key -out nginx/ssl/server.crt

参考にしたサイトがあったが忘れた(どこでも出てくるレベルのコマンドっぽいので実際に参考にしたサイトを後から探せなかった・・・)。

  • WSLのネットワークについて

WSLを再インストールした影響か、WSLから外部のネットワークへつながらなくなった。以下リンクを参考にしたところ解決した(mastodonの設定とは全く関係ない)。 - https://qiita.com/RIckyBan/items/87b558220d2bc5dd11cb

その他

HTTPS対応したことでアイコンや画像もちゃんと表示されるようになりました。

※画像はnijijourneyで生成したもの

WSL2のDockerでmastodonを起動してみる(HTTP)

概要

  • WSL上でDockerを使用してmastodonを起動
  • インターネットには公開しないローカルでのお試し用。
  • localhostへのアクセスとし、http通信のみで行う(SSLの設定はしない)。

手順

ソースコードのDL

作業用に適当なディレクトリを作成して作業します。

まずはGitHubからソースのダウンロードを行います。 この時点の最新の安定版である「stable-4.1」を使用します。

wget https://github.com/mastodon/mastodon/archive/refs/heads/stable-4.1.zip
unzip stable-4.1.zip
cd mastodon-stable-4.1

リポジトリごとDLしていますが、実際に使用するのは「.env.production.sample」ファイルと「docker-compose.yml」くらいです。

docker-compose.ymlの編集

dbの環境変数を「POSTGRES_USER: mastodon」「POSTGRES_DB: mastodon」「POSTGRES_PASSWORD: password」としてユーザーを設定します(ついでに「POSTGRES_HOST_AUTH_METHOD」も書き方を統一&「healthcheck」もちょっと修正)。

  db:
    restart: always
    image: postgres:14-alpine
    shm_size: 256mb
    networks:
      - internal_network
    healthcheck:
      # test: ['CMD', 'pg_isready', '-U', 'postgres']
      test: ['CMD', 'pg_isready', '-U', 'mastodon']
    volumes:
      - ./postgres14:/var/lib/postgresql/data
    environment:
      POSTGRES_HOST_AUTH_METHOD: trust
      POSTGRES_USER: mastodon
      POSTGRES_DB: mastodon
      POSTGRES_PASSWORD: password

HTTP通信をするためにコンテナ内部の設定ファイルを変更したいので「web」の実行時のコマンドに以下の内容を追加します。

sed 's/config.force_ssl = true/config.force_ssl = false/' /opt/mastodon/config/environments/production.rb > /opt/mastodon/config/environments/production.rb

また、環境変数にもHTTP通信用の設定を追加します。

    environment:
      HTTPS_LOCAL: 'false'

また、Dockerイメージのビルドを省略するために「build .」をコメントアウトします。「web」「streaming」「sidekiq」の3か所あります。

最終的に以下のようなdocker-composeファイルになります。

docker-compose.yml

version: '3'
services:
  db:
    restart: always
    image: postgres:14-alpine
    shm_size: 256mb
    networks:
      - internal_network
    healthcheck:
      # test: ['CMD', 'pg_isready', '-U', 'postgres']
      test: ['CMD', 'pg_isready', '-U', 'mastodon']
    volumes:
      - ./postgres14:/var/lib/postgresql/data
    environment:
      POSTGRES_HOST_AUTH_METHOD: trust
      POSTGRES_USER: mastodon
      POSTGRES_DB: mastodon
      POSTGRES_PASSWORD: password
  redis:
    restart: always
    image: redis:7-alpine
    networks:
      - internal_network
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
    volumes:
      - ./redis:/data

  # es:
  #   restart: always
  #   image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4
  #   environment:
  #     - "ES_JAVA_OPTS=-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true"
  #     - "xpack.license.self_generated.type=basic"
  #     - "xpack.security.enabled=false"
  #     - "xpack.watcher.enabled=false"
  #     - "xpack.graph.enabled=false"
  #     - "xpack.ml.enabled=false"
  #     - "bootstrap.memory_lock=true"
  #     - "cluster.name=es-mastodon"
  #     - "discovery.type=single-node"
  #     - "thread_pool.write.queue_size=1000"
  #   networks:
  #      - external_network
  #      - internal_network
  #   healthcheck:
  #      test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health || exit 1"]
  #   volumes:
  #      - ./elasticsearch:/usr/share/elasticsearch/data
  #   ulimits:
  #     memlock:
  #       soft: -1
  #       hard: -1
  #     nofile:
  #       soft: 65536
  #       hard: 65536
  #   ports:
  #     - '127.0.0.1:9200:9200'

  web:
    # build: .
    image: ghcr.io/mastodon/mastodon:v4.1.6
    restart: always
    env_file: .env.production
    command: bash -c "rm -f /mastodon/tmp/pids/server.pid; sed 's/config.force_ssl = true/config.force_ssl = false/' /opt/mastodon/config/environments/production.rb > /opt/mastodon/config/environments/production.rb; bundle exec rails s -p 3000"
    networks:
      - external_network
      - internal_network
    healthcheck:
      # prettier-ignore
      test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:3000/health || exit 1']
    ports:
      - '127.0.0.1:3000:3000'
    depends_on:
      - db
      - redis
      # - es
    volumes:
      - ./public/system:/mastodon/public/system
    environment:
      HTTPS_LOCAL: 'false'

  streaming:
    # build: .
    image: ghcr.io/mastodon/mastodon:v4.1.6
    restart: always
    env_file: .env.production
    command: node ./streaming
    networks:
      - external_network
      - internal_network
    healthcheck:
      # prettier-ignore
      test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1']
    ports:
      - '127.0.0.1:4000:4000'
    depends_on:
      - db
      - redis

  sidekiq:
    # build: .
    image: ghcr.io/mastodon/mastodon:v4.1.6
    restart: always
    env_file: .env.production
    command: bundle exec sidekiq
    depends_on:
      - db
      - redis
    networks:
      - external_network
      - internal_network
    volumes:
      - ./public/system:/mastodon/public/system
    healthcheck:
      test: ['CMD-SHELL', "ps aux | grep '[s]idekiq\ 6' || false"]

  ## Uncomment to enable federation with tor instances along with adding the following ENV variables
  ## http_proxy=http://privoxy:8118
  ## ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
  # tor:
  #   image: sirboops/tor
  #   networks:
  #      - external_network
  #      - internal_network
  #
  # privoxy:
  #   image: sirboops/privoxy
  #   volumes:
  #     - ./priv-config:/opt/config
  #   networks:
  #     - external_network
  #     - internal_network

networks:
  external_network:
  internal_network:
    internal: true

.env.productionの作成、設定

「.env.production.sample」をコピーして「.env.production」を作成します。

cp .env.production.sample .env.production

このファイルの設定に鍵の生成が必要なのですが、今回は公式とは別のlinuxserverが作成しているコンテナを使用します(生成さえできれば手段はなんでもよいので)。

「SECRET_KEY_BASE」「OTP_SECRET」に設定する値を生成するために以下のDockerイメージとコマンドを使用します。2回実行し表示された値をそれぞれ「SECRET_KEY_BASE」「OTP_SECRET」に設定します。

docker run --rm -it --entrypoint /bin/bash lscr.io/linuxserver/mastodon generate-secret

「VAPID_PRIVATE_KEY」「VAPID_PUBLIC_KEY」に設定する値を生成するために以下のコマンドを使用します。こちらはそのままコピペできる形で出力されます。

docker run --rm -it --entrypoint /bin/bash lscr.io/linuxserver/mastodon generate-vapid

その他以下のように設定します。

LOCAL_DOMAIN=localhost
REDIS_HOST=redis

DB_HOST=db
DB_USER=mastodon
DB_NAME=mastodon
DB_PASS=password

ES_ENABLED=false

S3_ENABLED=false

最終的には以下のようなファイルになります。

.env.production

# This is a sample configuration file. You can generate your configuration
# with the `rake mastodon:setup` interactive setup wizard, but to customize
# your setup even further, you'll need to edit it manually. This sample does
# not demonstrate all available configuration options. Please look at
# https://docs.joinmastodon.org/admin/config/ for the full documentation.

# Note that this file accepts slightly different syntax depending on whether
# you are using `docker-compose` or not. In particular, if you use
# `docker-compose`, the value of each declared variable will be taken verbatim,
# including surrounding quotes.
# See: https://github.com/mastodon/mastodon/issues/16895

# Federation
# ----------
# This identifies your server and cannot be changed safely later
# ----------
LOCAL_DOMAIN=localhost

# Redis
# -----
REDIS_HOST=redis
REDIS_PORT=6379

# PostgreSQL
# ----------
DB_HOST=db
DB_USER=mastodon
DB_NAME=mastodon
DB_PASS=password
DB_PORT=5432

# Elasticsearch (optional)
# ------------------------
ES_ENABLED=false
ES_HOST=localhost
ES_PORT=9200
# Authentication for ES (optional)
ES_USER=elastic
ES_PASS=password

# Secrets
# -------
# Make sure to use `rake secret` to generate secrets
# -------
SECRET_KEY_BASE=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
OTP_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# Web Push
# --------
# Generate with `rake mastodon:webpush:generate_vapid_key`
# --------
VAPID_PRIVATE_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
VAPID_PUBLIC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# Sending mail
# ------------
SMTP_SERVER=
SMTP_PORT=587
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=notifications@example.com

# File storage (optional)
# -----------------------
S3_ENABLED=false
S3_BUCKET=files.example.com
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_ALIAS_HOST=files.example.com

# IP and session retention
# -----------------------
# Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml
# to be less than daily if you lower IP_RETENTION_PERIOD below two days (172800).
# -----------------------
IP_RETENTION_PERIOD=31556952
SESSION_RETENTION_PERIOD=31556952

DBの設定とアセットのコンパイル

以下のコマンドでDBのマイグレーションを行います。大量のログが出力されるので少し待ちます。

docker-compose run --rm web rails db:migrate

次にアセットのコンパイルを行います(作成済みのDockerイメージを使用する場合は不要という情報もありましたが念のため・・・)。

docker-compose run --rm web rails assets:precompile

コンテナの起動

以下コマンドでコンテナを起動します。

docker-compose  up -d

以下のようなログが出ます。

mastodon-stable-41_db_1 is up-to-date
mastodon-stable-41_redis_1 is up-to-date
Creating mastodon-stable-41_web_1       ... done
Creating mastodon-stable-41_streaming_1 ... done
Creating mastodon-stable-41_sidekiq_1   ... done

「docker ps」でコンテナを確認すると以下のようになっています(直後なのでまだhealth: startingですが・・・)。

c7f09f791613   ghcr.io/mastodon/mastodon:v4.1.6   "/usr/bin/tini -- no…"   24 seconds ago       Up 21 seconds (health: starting)   3000/tcp, 127.0.0.1:4000->4000/tcp   mastodon-stable-41_streaming_1
abfb7420b861   ghcr.io/mastodon/mastodon:v4.1.6   "/usr/bin/tini -- bu…"   24 seconds ago       Up 20 seconds (health: starting)   3000/tcp, 4000/tcp                   mastodon-stable-41_sidekiq_1
911368ee133a   ghcr.io/mastodon/mastodon:v4.1.6   "/usr/bin/tini -- ba…"   24 seconds ago       Up 20 seconds (health: starting)   127.0.0.1:3000->3000/tcp, 4000/tcp   mastodon-stable-41_web_1
ee657ada8db7   postgres:14-alpine                 "docker-entrypoint.s…"   About a minute ago   Up About a minute (healthy)                                             mastodon-stable-41_db_1
d982b142e1a4   redis:7-alpine                     "docker-entrypoint.s…"   About a minute ago   Up About a minute (healthy)                                             mastodon-stable-41_redis_1

ここで「http://localhost:3000/」にアクセスするとmastodonの画面が表示されます。

※普通に表示されることもありますが、chromeの設定やキャッシュによってHTTPSにリダイレクトされ「ERR_SSL_PROTOCOL_ERROR」が出ることもあります。その場合はシークレットモードだとアクセスできます。

ユーザーの作成とログイン

このままだとログインできないのでユーザーを作成します。メールサーバーを設定しておらず通常のようにユーザーの新規作成ができないのでコンテナに入って無理やり作成します。

以下のコマンドでコンテナに入ります。

docker exec -it mastodon-stable-41_web_1 /bin/bash

コンテナ内で以下のコマンドでユーザーを作成します。「confirmed 」はメール認証を省略するためのオプションです。

bundle exec bin/tootctl accounts create admin_user --email admin@localhost --confirmed --role Admin

※メアドは適当です。example.comだとはじかれたのでlocalhostにしています。ついでにAdmin権限を与えていますが、あまり意味はないです。

パスワードが表示されるので上のメアドと表示されたパスワードを使用してログインします。

ログインすると以下のような画面になります。

投稿もできます。

ただしアイコン画像などはリンクがhttpsで設定されているためか表示されません。 httpで確認できるのはこの程度っぽいですね。

その他コメント

特にHTTPS通信を回避するところでめちゃくちゃ詰まりました。最初はオレオレ証明書でやろうとしたりもしてましたが、ごちゃごちゃしてきたので一旦http通信で起動する方針にしました。最終的には比較的するっといけましたが、途中無駄にDockerのネットワークの仕様など調べて疲れました・・・。https版の設定もできたら記事にしようと思います。

参考

登記所備付地図データをGeoPackageにしてQGISで読み込む

概要

  • 登記所備付地図をGeoPackageにしてQGISで読み込む
  • 変換はpython(mojxml2geojson, geopandas)を使用する

参考

DLしたxmlをgeojsonに変換

公式からデータをDLする。

Python環境にmojxml2geojsonをインストールして、以下のスクリプトでDLしたzipを解凍してgeojsonに変換する。 (ブログに載せるほどのスクリプトでもないが・・・)

import os
import glob
import shutil
import subprocess

input_file = R"D:\Geo\touki\47201-3600.zip"
out_dir = R"D:\Geo\touki_out"

# DLしたzipを解凍
input_name = os.path.basename(input_file).split(".")[0]
temp_dir = os.path.join(os.path.dirname(input_file), input_name)
shutil.unpack_archive(input_file, temp_dir)

# 中身のzipを解凍
zip_path_list = glob.glob(os.path.join(temp_dir, "*.zip"))
xml_dir = os.path.join(out_dir, "xml", input_name)
for zip_path in zip_path_list:
    shutil.unpack_archive(zip_path, xml_dir)

# xmlをgeojsonに変換
xml_path_list =glob.glob(os.path.join(xml_dir, "*.xml")) 
for idx, xml_path in enumerate(xml_path_list):
    print(f"[{idx+1}/{len(xml_path_list)}] {os.path.basename(xml_path)}")
    cmd = ["mojxml2geojson", xml_path]
    subprocess.run(cmd)
  • xml -> geojsonの変換は結構遅い。大量にやるなら並列化したいところ。

geopandasでgeojsonをGeoPackageに変換

以下スクリプトでDLしたzipの単位でGeoPackageにまとめて保存する。

import os
import glob
import shutil
import geopandas as gpd
import pandas as pd

input_dir = R"D:\Geo\touki_out\xml\47201-3600" # 先ほどgeojsonに変換したディレクトリを指定
out_dir = R"D:\Geo\touki_out\gpkg"
os.makedirs(out_dir, exist_ok=True)

input_name = os.path.basename(input_dir)
geojson_path_list = glob.glob(os.path.join(input_dir, "*.geojson")) 
gdf_list = []
for idx, geojson_path in enumerate(geojson_path_list):
    print(f"[{idx+1}/{len(geojson_path_list)}] {os.path.basename(geojson_path)}")
    gdf = gpd.read_file(geojson_path)
    gdf = gdf[gdf["座標系"] != "任意座標系"]
    gdf_list.append(gdf.to_crs("epsg:4326"))
gdf = pd.concat(gdf_list)
out_path = os.path.join(out_dir, f"{input_name}.gpkg")
gdf.to_file(out_path, driver='GPKG', layer=input_name)  
  • 大したデータ量でもないのでGeoDataFrameの時点でまとめて一つのGeoDataFrameに してしまっている。大量に処理するなら小分けしてGeoPackageへの保存は追記モードでやった方がよさげ。
  • 任意座標系はあきらめる。
  • .to_crs("epsg:4326")をやっておかないと以下のようなエラーが出る場合がある。
ValueError: Cannot determine common CRS for concatenation inputs, got ['JGD2011', 'WGS 84']. Use `to_crs()` to transform geometries to the same CRS before merging.
  • 本当に少ししかやっていないので、なんかほかにも例外出るようなデータはあるかも・・・。

QGISで表示してみる

大字コードで色分けすると以下のような感じ。

QGISのフィルタでNOT "地番" LIKE '%地区外%'として地区外のデータは除外して表示している。xml -> geojsonの段階でmojxml2geojsonの実行オプションとして--excludeとしておけばそもそも地区外のデータをgeojsonに含まれないようにもできる。

フィルタに関するよさげなリンクメモ。

QGIS(1) pyQGISで画像を出力する

概要

  • pyQGISで画像を出力して保存する

参考

使用するデータなど

QGISで表示されている内容の画像化

QGISで上記のデータとOSMを読み込みます。 避難場所データは「洪水」属性で分類して色分けして表示しています。 ここでのスタイルが画像にも反映されます。

pythonコンソールを開き以下のスクリプトを入力します。

pythonスクリプト

# QGISのプロジェクトファイルがあるディレクトリに画像を保存
image_location = os.path.join(QgsProject.instance().homePath(), "render.png")

# 静岡県の指定緊急避難場所データのレイヤー
vlayer1 = QgsProject.instance().mapLayersByName("22")[0]
# OpenStreetMapのレイヤー
vlayer2 = QgsProject.instance().mapLayersByName("OpenStreetMap")[0]

options = QgsMapSettings()
options.setLayers([vlayer1, vlayer2]) # 後ろのレイヤが下にくる
options.setBackgroundColor(QColor(255, 255, 255)) # 背景色設定(今回はOSM読み込むので意味なしだが・・・)
options.setOutputSize(QSize(800, 600)) # 画像の出力サイズ
options.setExtent(vlayer1.extent()) # これで静岡県のデータがある周辺に設定される
options.setDestinationCrs(vlayer1.crs()) # CRSを設定。

# レンダリング用ジョブのオブジェクトを作成
render = QgsMapRendererParallelJob(options) 

# レンダリングが終わったら画像を保存するようにする
def finished():
    img = render.renderedImage()
    img.save(image_location, "png")
    print("saved")

render.finished.connect(finished)

render.start()

↓こんな感じですね。

終わるとimage_locationで定めた場所に以下のような画像が保存されます。

注意点としては、レンダリングPythonスクリプトとは別で処理が走る点ですね。 処理が別で走っているのでstart()終了後にすぐ保存しようとしてもまだレンダリングが終わっていなくておかしなことになります。 そのため終了後の動作(ここでは画像の保存)をfinish()関数とrender.finished.connect(finished)で定義しておいてレンダリングが終わった後に保存するようにしている、ということみたいですね。

今回はすでにプロジェクトに読み込んでスタイルも設定してあるレイヤーを画像化しました。 pyQGISはレイヤー読み込みもスタイル適用もスクリプトでできるので、そこも含めたスクリプトも書けそうです。もっと言えばQGIS起動しなくてもQGISPython部分だけ起動して画像の生成もできるのでしょうが・・・それはいろいろと設定が面倒くさそう?

Terria (5) GPXをCZMLに変換して読み込んでみる

概要

  • GPXファイルをCZMLに変換してTerriaMapに読み込む。
  • 変換はpython(特にgpxpy)を使用する

参考

GPS記録について

gpxファイルは自分で過去に記録したものを使用しました(さすがにこれだけで個人を特定はできないと思いますが・・・自分のGPSデータを使用するのは気を付けないといけないですね・・・)。探せばネットにもたくさん落ちていると思います。

GPRロガーとしては「ルートヒストリー」を使用しました。シンプルでよいアプリだと思います。

入力に使用したGPXファイル(抜粋)

ルートヒストリーでGPX形式で出力すると以下のようになります。

<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="RouteHistory - https://www.ateow.com" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="35.77923847359902" lon="137.25219315313424">
<time>2022-05-04T06:25:02Z</time>
<ele>353.5262222290039</ele>
</trkpt>
<trkpt lat="35.77924467425494" lon="137.25207535075572">
<time>2022-05-04T06:25:02Z</time>
<ele>353.4094467163086</ele>
</trkpt>
<trkpt lat="35.77933668128351" lon="137.25101184773823">
<time>2022-05-04T06:25:06Z</time>
<ele>350.3656341670015</ele>
</trkpt>
<trkpt lat="35.77934171424567" lon="137.2509021258634">
<time>2022-05-04T06:25:07Z</time>
<ele>349.1141156737026</ele>
</trkpt>
<trkpt lat="35.779352662004264" lon="137.25073872997493">
<time>2022-05-04T06:25:08Z</time>
<ele>348.62688992877827</ele>
</trkpt>
</trkseg>
</trk>
</gpx>

pythonでGPXをCZMLに変換

jupyter notebookで作業してます。GPXの読み込みはQiitaの記事を参考にpygpxで行い、czmlは「GIS実習オープン教材」を参考に作成。GPXは高さが標高で格納されていますが、czmlでは楕円体高での表現とする必要があるので標高⇒楕円体高の変換が必要です(czmlの仕様にまだ詳しくないので断言はできないですが、おそらく楕円体高しかだめっぽい?)。今回はそこまで精度を求めていないのでざっくり40mを足して変換しています。

czmlの中では時間に依存するポイントと軌跡を表現するためのポリラインの二つを作成しています。

変換に使用したnotebook

import json
import os
import glob

import gpxpy
import gpxpy.gpx
from pytz import timezone
import pandas as pd
def make_df_from_gpx(gpx_path):
    # https://qiita.com/toran/items/6b4bcd103292164e8e28
    # タイムゾーン
    dt_tz = timezone('Asia/Tokyo')
    # 日時文字列形式
    dt_fmt = '%Y-%m-%dT%H:%M:%SZ'
    # 配列の初期化
    DateTime = []
    Lat      = []
    Lng      = []
    Alt      = []
    # GPXファイルの読み込み
    with open(gpx_path,'r') as gpx_file_r:
        # GPXファイルのパース
        gpx = gpxpy.parse(gpx_file_r)
        # GPXデータの読み込み
        for track in gpx.tracks:
            for segment in track.segments:
                # ポイントデータリストの読み込み
                points = segment.points
                # ポイントデータの長さ
                N = len(points)
                # ポイントデータの読み込み
                for i in range(N):
                    # ポイントデータ
                    point = points[i]
                    # データ抽出
                    datetime = point.time.astimezone(dt_tz).strftime(dt_fmt)
                    lat      = point.latitude
                    lng      = point.longitude
                    alt      = point.elevation
                    # データ代入
                    DateTime.append(datetime)
                    Lat.append(lat)
                    Lng.append(lng)
                    Alt.append(alt)
    df = pd.DataFrame(columns=["datetime", "lat", "lon", "alt"])
    df["datetime"] = DateTime
    df["lat"] = Lat
    df["lon"] = Lng
    df["alt"] = Alt
    return df
def make_czml(timestamp, lat, lon, alt, diff_geoid=0):
    czml = [{
        "id" : "document",
        "name" : "name",
        "version" : "1.0"
    }]
    elem = {
        "id": "1",
        "name": "gpx_point_with_timestamp",
        "description": "GPXの時刻付きポイントです",
        f"availability":f"{timestamp[0]}/{timestamp[-1]}",
        "billboard" : {
            "image" : "http://maps.google.com/mapfiles/kml/pushpin/ylw-pushpin.png",
            "scale" : 0.5
        },
        "position":{
            "epoch" : f"{time_stamp[0]}",
            "cartographicDegrees":[]
        } 
    }
    coord = elem["position"]["cartographicDegrees"]
    
    for i in range(len(timestamp)):
        coord += [timestamp[i], float(lon[i]), float(lat[i]), float(alt[i])+diff_geoid]
    czml.append(elem)
    
    elem_2 = {
      "id" : "2",
      "name" : "gpx_line",
      "description" : "GPX全体のポリラインです",
      "polyline" : {
          "positions" : {
              "cartographicDegrees" : []
          },
          "material" : {
              "solidColor" : {
                  "color" : {
                      "rgba" : [0, 0, 255, 100]
                  }
              }
          },
          "width" : 2.5
      }
    }
    coord_2 = elem_2["polyline"]["positions"]["cartographicDegrees"]
    
    for i in range(len(timestamp)):
        coord_2 += [float(lon[i]), float(lat[i]), float(alt[i])+diff_geoid]
    czml.append(elem_2)
    return czml

def save_czml(czml, out_dir, out_name):
    out_path = os.path.join(out_dir, f"{out_name}.czml")
    with open(out_path, 'w') as f:
        json.dump(czml, f, indent=4)
gpx_path = "./Log20220504-152502.gpx"
out_dir = "./"

df = make_df_from_gpx(gpx_path)
out_name = os.path.basename(gpx_path).split(".")[0]

lat = df["lat"].values
lon = df["lon"].values
alt = df["alt"].values
time_stamp = df["datetime"].values

# ざっくり40m程度足して標高⇒楕円体高の変換をする
czml = make_czml(time_stamp, lat, lon, alt, diff_geoid=40)
save_czml(czml, out_dir, out_name)

Terriaでの表示

Terriamapにドラッグ&ドロップでczmlを読み込むと以下のような感じ。下に出てくるバーで時間を動かして表示できます。 (背景が寂しかったので地理院の空中写真を読み込んでいます。)

youtu.be