2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

基盤地図情報のGMLを一括でGeopackageに変換する

Last updated at Posted at 2025-08-09

はじめに

2025年8月現在、基盤地図情報のGML(建物や等高線など)をQGISで読み込むと、X座標Y座標が正しく読み込まれず、90度回転&反転し、おかしな位置に表示されるようになってしまいました。

image.png
図:左が本来の表示。右がGMLをQGISに表示したもの。(QGIS3.40を利用)

これは、基盤地図情報の仕様変更とのことです。
image.png

そこで、GDAL/ORGが対応するまでの処置として、基盤地図情報のGMLを一括でGeopackageに変換するPythonプログラムを作成しました。(生成AIに作成してもらい、若干修正をしています)

この記事は、Windows11で実行する方法を説明しています。

OSGeo4W Shellを使う

このプログラムの実行には、QGISをインストールすると同梱している、「OSGeo4W Shell」を使います。
あらかじめQGISをインストールしておいてください。

GMLをGeojsonに変換してからGeopackageにまとめる

GMLから直接Geopackageに変換しようと思い、生成AIにお願いしましたが、処理速度がめちゃくちゃ遅く、ジオメトリのタイプや属性データをうまく引き継ぐことができなかったので、一度Geojsonに変換してから、ZIPファイルごとにGeopackageへまとめることとしました。

変換プログラム

変換プログラムは次のとおりです。

GML2Geopackage.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
ZIP内の基盤地図情報(FGD GML 等)XML を処理して次を行うスクリプト
 1. data フォルダ内の ZIP を順に開く
 2. ZIP 内の XML ファイルを data フォルダへ展開する
 3. Fiona で読み込める(= ベクタ地図データ) XML のみ処理する
 4. 座標の X/Y を入れ替えて(必要な場合)GeoJSON を data フォルダに作成
 5. ZIP 内の全ての変換済 GeoJSON を ogr2ogr で 1 つの Geopackage (.gpkg) にレイヤとしてまとめる
 6. GPKG 作成後、作成した GeoJSON を削除(data フォルダを散らさない)
 7. 展開した XML は処理後すぐ削除(不要ファイルを残さない)

注意:
 - OSGeo4W Shell で実行してください(ogr2ogr がコマンドとして使える状態)
 - data/<zip_basename>.gpkg が出力されます
 - 同名の XML(ベース名が同じ)だと GeoJSON の上書き・レイヤ名競合の可能性あり
"""

import os
import zipfile
import subprocess
import json
import traceback

import fiona
from shapely.geometry import shape, mapping

# ---------------------------------------------------------------------
# 設定セクション
# ---------------------------------------------------------------------
# スクリプト配置ディレクトリを基準に data フォルダを参照します
script_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.join(script_dir, "data")

# data フォルダの存在チェック(無ければエラーで止めるより作成する方が親切)
if not os.path.isdir(data_dir):
    raise FileNotFoundError(f"data フォルダが見つかりません: {data_dir}")
# ---------------------------------------------------------------------

# ---------------------------
# ユーティリティ関数群
# ---------------------------
def swap_coords(coords):
    """
    座標配列を再帰的に走査して X/Y を入れ替える関数。
    GeoJSON では [X, Y] (経度, 緯度) の順が期待されるが、
    入力データが [Y, X] の順になっている場合に入れ替えるために使用する。
    - coords が最内層の座標 [num, num] の場合は (num2, num1) を返す
    - coords が配列(例: LineString の点列、Polygon の外周/内周、Multi* のネスト)なら再帰処理する
    """
    # 最も内側が数値のリスト/タプル(例: [34.67, 134.51] など)
    if isinstance(coords, (list, tuple)) and len(coords) > 0 and isinstance(coords[0], (float, int)):
        # 入れ替えて返す(タプルでも問題ない:json.dump で配列として扱われる)
        return (coords[1], coords[0])
    # 再帰ケース:リストの各要素へ適用
    return [swap_coords(c) for c in coords]

def remove_empty_parent_dirs(path, stop_dir):
    """
    指定したファイルパスの親ディレクトリが空なら削除し、さらに上の親も空なら削除... を stop_dir まで繰り返す。
    - path: 削除済みのファイルのパス(またはディレクトリ)
    - stop_dir: 削除を止めるディレクトリ(通常 data_dir)
    例: data/subdir/file.xml を削除したあと subdir が空なら削除する。
    """
    parent = os.path.dirname(path)
    # os.path.commonpath を使って stop_dir と親が同一のルートか確認(安全対策)
    try:
        stop_dir_norm = os.path.normpath(stop_dir)
        while True:
            parent_norm = os.path.normpath(parent)
            # stop_dir より上は削除しない
            if not parent_norm.startswith(stop_dir_norm) or parent_norm == stop_dir_norm:
                break
            try:
                # ディレクトリが空なら削除、空でなければ OSError(WindowsではPermissionErrorやOSError)で止まる
                os.rmdir(parent)
            except OSError:
                break
            parent = os.path.dirname(parent)
    except Exception:
        # 念のため例外は握りつぶす(ログだけ出す)
        print("  (注意)空ディレクトリ削除で予期せぬエラー:", traceback.format_exc())

# ---------------------------
# メイン処理(ZIP を1つずつ処理)
# ---------------------------
# data フォルダ内の全ファイルを確認し、.zip ファイルのみ処理する
for entry in os.listdir(data_dir):
    if not entry.lower().endswith(".zip"):
        # ZIP 以外はスキップ
        continue

    zip_path = os.path.join(data_dir, entry)
    print(f"\n=== 処理開始: {entry} ===")

    # ZIP を開く(読み取り専用)
    try:
        z = zipfile.ZipFile(zip_path, "r")
    except Exception as e:
        print(f"ZIP を開けません: {zip_path} -> {e}")
        continue

    with z:
        # ZIP 内の全エントリ名を取得し、そのうち .xml のものを候補とする
        # 注意: zip 内にフォルダパスが含まれる場合もある(例: folder/file.xml)
        xml_entries = [n for n in z.namelist() if n.lower().endswith(".xml")]

        # この ZIP 内で作成した GeoJSON ファイルのパスを蓄えるリスト
        geojson_files = []
        # スキップした XML ファイル名を蓄える(ログ用)
        skipped_xml = []

        # 各 XML を順に処理する
        for xml_name in xml_entries:
            print(f"  - XML 処理候補: {xml_name}")

            # 1) ZIP 内の XML を data フォルダへ展開する
            #    z.extract は zip 内のパス通りに展開するため、サブディレクトリも作られる
            try:
                extracted_path = z.extract(xml_name, path=data_dir)
                # extracted_path は data_dir/.../file.xml のような実際のパス
            except Exception as e:
                print(f"    展開失敗: {xml_name} -> {e}")
                skipped_xml.append(xml_name)
                continue

            # 2) 出力する GeoJSON のパスを data フォルダ直下に作る(XML ベース名を使用)
            base_name = os.path.splitext(os.path.basename(xml_name))[0]
            geojson_path = os.path.join(data_dir, base_name + ".geojson")

            # 3) Fiona で開けるか試し、読み込めるならジオメトリを取り出して座標入れ替えを行う
            try:
                features = []  # この XML から作る GeoJSON の Feature リスト

                # fiona.open に失敗する(非地図XML の場合)と例外が出る -> except に飛ぶ
                with fiona.open(extracted_path) as src:
                    # src はレコードの反復可能オブジェクト(フィーチャーを返す)
                    for feat in src:
                        # geometry が無いレコードは無視する(属性だけのレコード等)
                        if feat.get("geometry") is None:
                            continue

                        # shapely.shape で Fiona の geometry dict を Shapely ジオメトリに変換
                        geom = shape(feat["geometry"])

                        # mapping(geom) で GeoJSON 互換の辞書に変換して座標を取り出す
                        mapping_geom = mapping(geom)

                        # mapping が通常の 'coordinates' を含むならそのまま swap、GeometryCollection の場合は 'geometries' を処理
                        if "coordinates" in mapping_geom:
                            raw_coords = mapping_geom["coordinates"]
                            swapped_coords = swap_coords(raw_coords)
                            new_geom = {"type": mapping_geom["type"], "coordinates": swapped_coords}

                        elif "geometries" in mapping_geom:
                            # GeometryCollection の場合、各ジオメトリごとに座標入れ替えを行う
                            new_geoms = []
                            for sub in mapping_geom["geometries"]:
                                if "coordinates" in sub:
                                    new_geoms.append({
                                        "type": sub["type"],
                                        "coordinates": swap_coords(sub["coordinates"])
                                    })
                                else:
                                    # 想定外のサブジオメトリはそのまま追加(極端に稀)
                                    new_geoms.append(sub)
                            new_geom = {"type": "GeometryCollection", "geometries": new_geoms}

                        else:
                            # 想定外の mapping 構造(極めて稀)。そのまま入れる(後続の処理で問題になるかもしれない)
                            new_geom = mapping_geom

                        # properties は Fiona の型なので dict() で通常の辞書にする
                        properties = dict(feat.get("properties", {}))

                        # Feature を組み立ててリストに追加
                        features.append({
                            "type": "Feature",
                            "geometry": new_geom,
                            "properties": properties
                        })

                # 4) もし features が空(ジオメトリが一件も取れなかった)ならスキップ
                if not features:
                    print(f"    スキップ: ジオメトリが見つかりませんでした -> {xml_name}")
                    skipped_xml.append(xml_name)
                    # 展開した XML ファイルは不要なので削除し、空ディレクトリも掃除
                    try:
                        if os.path.exists(extracted_path):
                            os.remove(extracted_path)
                            remove_empty_parent_dirs(extracted_path, data_dir)
                    except Exception:
                        print("    (注意)展開ファイル削除時にエラー")
                    continue

                # 5) GeoJSON として data フォルダに書き出す(日本語を保持するため ensure_ascii=False)
                geojson_obj = {"type": "FeatureCollection", "features": features}
                with open(geojson_path, "w", encoding="utf-8") as gf:
                    json.dump(geojson_obj, gf, ensure_ascii=False)

                # GeoJSON 作成成功として記録
                geojson_files.append(geojson_path)
                print(f"    変換成功: {xml_name} -> {os.path.basename(geojson_path)}")

            except Exception as e:
                # fiona.open に失敗、あるいは処理中に別の問題が発生した場合はスキップ
                print(f"    スキップ(非地図XML または 読込エラー): {xml_name} -> {e}")
                skipped_xml.append(xml_name)

            finally:
                # 6) 展開した XML ファイルは不要なので削除(成功/失敗問わず)
                #    ただし削除に失敗しても処理は継続する(ログを出す)
                try:
                    if os.path.exists(extracted_path):
                        os.remove(extracted_path)
                        # 展開時に作られた空の親ディレクトリも削除(存在すれば)
                        remove_empty_parent_dirs(extracted_path, data_dir)
                except Exception:
                    print("    (注意)展開ファイルの削除時に例外が発生しました:")
                    print(traceback.format_exc())

        # --- ZIP 内すべての XML を処理し終えた ---
        # 7) GeoJSON ファイル群を 1 つの GPKG にまとめる(ZIP のベース名を使う)
        if not geojson_files:
            print("  -> この ZIP からは GeoJSON が 1 件も作成されませんでした。GPKG は作りません。")
            if skipped_xml:
                print("  スキップしたファイル(例):")
                for s in skipped_xml[:20]:
                    print("   -", s)
            continue  # 次の ZIP へ

        # GPKG の出力先 (data/<zip_basename>.gpkg)
        gpkg_basename = os.path.splitext(entry)[0] + ".gpkg"
        gpkg_path = os.path.join(data_dir, gpkg_basename)

        # ogr2ogr で最初の GeoJSON を使って新規 GPKG を作成し、以降は -append で追加していく
        # 注意: 既に同名の GPKG が存在する場合は上書きされる(挙動を変えたいなら事前に削除等を入れてください)
        first = True
        for gj in geojson_files:
            layer_name = os.path.splitext(os.path.basename(gj))[0]  # レイヤ名は GeoJSON のベース名
            try:
                if first:
                    # 新規作成:ogr2ogr -f GPKG out.gpkg in.geojson -nln layer_name
                    subprocess.run(["ogr2ogr", "-f", "GPKG", gpkg_path, gj, "-nln", layer_name], check=True)
                    first = False
                else:
                    # 追加:既存 GPKG にレイヤ(同名があるとエラー/上書きの可能性あり)
                    # ここでは -append と -nln を使って別レイヤーとして追加する方法を採用
                    subprocess.run(["ogr2ogr", "-f", "GPKG", "-append", gpkg_path, gj, "-nln", layer_name], check=True)
                print(f"  ogr2ogr 成功: {os.path.basename(gj)} -> {os.path.basename(gpkg_path)} (layer: {layer_name})")
            except subprocess.CalledProcessError as e:
                # ogr2ogr が非ゼロ終了した時の例外(ログを出して続行)
                print(f"  (注意)ogr2ogr 実行中にエラー: {gj} -> {e}")
                # ここで続行するか中止するかは要件次第。現状は続行して可能な限りレイヤを追加する。
            except FileNotFoundError:
                # ogr2ogr が見つからない(PATH にない)場合
                print("  致命的エラー: ogr2ogr が見つかりません。OSGeo4W Shell で実行しているか確認してください。")
                raise

        print(f"  GPKG 作成完了: {gpkg_path}")

        # 8) GPKG を作ったら、作成した GeoJSON はもう不要なので削除してクリーンにする
        for gj in geojson_files:
            try:
                if os.path.exists(gj):
                    os.remove(gj)
                    print(f"    GeoJSON 削除: {os.path.basename(gj)}")
            except Exception:
                print(f"    (注意)GeoJSON 削除失敗: {gj}")
                print(traceback.format_exc())

        # 9) スキップしたファイル一覧の簡易ログ
        if skipped_xml:
            print("  スキップしたファイル一覧:")
            for s in skipped_xml[:50]:
                print("   -", s)

print("\nすべての ZIP の処理が終了しました。")

このコードをコピーして、「GML2Geopackage.py」で保存してください。

Fionaのインストール

このプログラムを実行するためには、「Fiona」というライブラリのインストールが必要です。
プログラムを実行した際に「ModuleNotFoundError: No module named 'fiona'」と表示されたら、OSGeo4w Shellを起動して、インストールを行います。

pip install fiona

image.png
図:Fionaのインストールコマンドを実行した様子

事前準備(はじめの一度のみ)

※以下は作業フォルダを「C:\kiban」とした場合の説明です。作業フォルダはどこでも構いません。

  • Cドライブに「kiban」フォルダを作成します。
  • 「kiban」フォルダに「data」フォルダを作成します。
  • 「kiban」フォルダに「GML2Geopackage.py」を保存します。

プログラム実行方法

変換プログラムは、dataフォルダに保存されたZIPファイルを展開することなくGML(xmlファイル)を読み込んで、Geojsonに変換します。すべて変換後にZIPファイル単位でGeopackageにまとめます。

  • 基盤地図情報からダウンロードしたZIPファイルを「data」フォルダに保存します。
    image.png

  • OSGeo4w Shellを起動します。

  • 「python c:\kiban\GML2Geopackage.py」を実行します。
    image.png

  • dataフォルダに一時的にGeojsonを作成して、すべて変換後にGeopackageにまとめます。Geopackageを作成したら、Geojsonは削除されます。

QGISでGeopackageを表示する

作成されたGeopackageファイルをQGISにドラッグ&ドロップすると、レイヤの選択画面が表示されます。
追加するレイヤを選択して、「レイヤを追加」ボタンをクリックすると、QGISでレイヤが表示されます。
「グループにレイヤを追加する」にチェックを付けておくと、自動的にレイヤグループが作成されます。

image.png

image.png

最後に

今回は基盤地図情報の仕様の変更により、QGISで正常に表示できなくなりましたが、Pythonプログラムで一括変換することにより正しく表示することができました。
これは一時的な対応かもしれませんが、はやくGDAL/OGRが対応していただけることを願っています。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?