0
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?

Step 2のGeoParquetからPMTilesを作る ― 防災Web地図 Step 3

0
Posted at

Step 2のGeoParquetからPMTilesを作る ― 防災Web地図 Step 3

はじめに

Step 1では、e-Statの3次メッシュ境界を全国分まとめ、1kmメッシュのGeoParquetを作りました。Step 2では、洪水浸水想定区域や土砂災害警戒区域を正規化し、元形状と1kmメッシュ集計の2種類をGeoParquetへ保存しました。

Step 3では、その成果物をWeb表示用のPMTilesへ変換します。

ここで気を付けたいのは、GeoParquetとPMTilesの役割が違うことです。

GeoParquet
  集計、再処理、属性確認、DuckDBでの分析に使う

PMTiles
  OpenLayersから地図表示するために使う

PMTilesを作った後も、Step 2のGeoParquetは残します。タイルへ入れる属性を変えたいときや、集計条件を見直したいときは、GeoParquetから作り直します。

今回の実装はPython 3.13とDocker Composeを使います。2026年8月6日時点で、Tippecanoeは2.79.0、PMTiles CLIは1.31.2に固定しました。

この記事は、次の5段階で防災Web地図を作るうちのStep 3です。

防災Web地図を作る

Step 1 全国1kmメッシュをGeoParquetへ変換
Step 2 防災区域を正規化し、1kmメッシュへ集計
Step 3 TippecanoeでPMTilesを作成
Step 4 NginxとOpenLayersで静的表示
Step 5 DuckDBとFastAPIで詳細・統計APIを追加

Step 3で行うこと

処理の流れは次のとおりです。

ChatGPT Image 2026年8月6日 21_48_42.png

Step 3の入力は、Step 2で検査に合格した成果物だけです。

workspace/artifacts/step2/
├─ native/
│  ├─ flood_a31a_2025__maximum.parquet
│  └─ landslide_a33_2025__designated.parquet
├─ summary_1km/
│  ├─ flood_a31a_2025__maximum.parquet
│  └─ landslide_a33_2025__designated.parquet
├─ dataset_catalog_fragment.json
├─ manifest.json
└─ validation_report.json

次の条件を満たさない場合、処理は開始しません。

manifest.step                    step2
manifest.status                  passed
validation_report.status         passed
catalogの成果物版                manifestと一致
GeoParquetのSHA-256              catalogと一致

ZIPを別のPCへ移した場合、カタログに記録された旧絶対パスが使えないことがあります。その場合は、summary_1kmまたはnative配下の同名ファイルを探し直し、SHA-256が一致したものだけを採用します。

なぜFlatGeobufを挟むのか

TippecanoeはGeoJSON、FlatGeobuf、CSVを入力にできますが、GeoParquetを直接読む構成にはしていません。

全国1kmメッシュをいったん巨大なGeoJSONへ書き出す方法もあります。ただし、GeoJSONはテキスト形式なので、ファイルサイズが大きくなり、JSONの生成と読み込みにも時間がかかります。

今回は次の経路にしました。

GeoParquet
    ↓ Python / GeoPandas
FlatGeobuf
    ↓ Tippecanoe
PMTiles

FlatGeobufは単一レイヤーのバイナリ形式で、GDALとPyogrioから扱えます。TippecanoeはFlatGeobufを自動的に並列読み込みします。

中間FlatGeobufは初期設定で残します。

workspace/artifacts/step3/flatgeobuf/

PMTilesに問題があったとき、GeoParquetからの列選択と型変換が正しかったかを確認しやすくするためです。ディスク容量を優先する場合は、設定のkeep_flatgeobuffalseにできます。

作成する成果物

workspace/artifacts/step3/
├─ tiles/
│  ├─ flood_maximum_1km__20260806_r001.pmtiles
│  └─ landslide_designated_1km__20260806_r001.pmtiles
├─ flatgeobuf/
│  ├─ flood_maximum_1km__20260806_r001.fgb
│  └─ landslide_designated_1km__20260806_r001.fgb
├─ pmtiles_catalog_fragment.json
├─ manifest.json
└─ validation_report.json

防災レイヤーは、更新時期やシナリオが異なります。すべてを1つの巨大なPMTilesへまとめず、基本的にはレイヤーと版ごとに分けます。

flood_maximum_1km__20260806_r001.pmtiles
landslide_designated_1km__20260806_r001.pmtiles
tsunami_maximum_1km__20260806_r001.pmtiles

洪水だけ更新したいときに、土砂災害や津波のタイルまで作り直さずに済みます。

フォルダー構成

参考のために、ZIPファイルを置いておきますので、ダウンロードして活用してください。

japan_disaster_pmtiles_step3_r001/
├─ README.md
├─ VALIDATION.md
├─ japan_disaster_pmtiles_step3_qiita_r001.md
├─ docker-compose.yml
├─ Makefile
├─ .env.example
├─ pyproject.toml
│
├─ builder/
│  ├─ Dockerfile
│  └─ requirements.txt
│
├─ config/
│  ├─ tilesets.demo.json
│  └─ tilesets.example.json
│
├─ contracts/
│  ├─ artifact_manifest.schema.json
│  ├─ pmtiles_catalog_fragment.schema.json
│  ├─ tilesets.schema.json
│  └─ validation_report.schema.json
│
├─ src/pmtiles_step3/
│  ├─ catalog.py
│  ├─ cli.py
│  ├─ config.py
│  ├─ demo.py
│  ├─ geoparquet.py
│  ├─ metadata.py
│  ├─ pipeline.py
│  ├─ pmtiles_archive.py
│  ├─ tippecanoe.py
│  ├─ upstream.py
│  └─ ...
│
├─ tests/
├─ docs/
├─ scripts/
└─ workspace/

各Stepを分離しているため、Step 3のPythonソースからStep 2のモジュールはimportしません。Step 2のGeoParquetと管理JSONだけを受け取ります。

Dockerイメージ

TippecanoeとPMTiles CLIは、公式リポジトリのタグを固定してビルドします。

FROM debian:bookworm-slim AS tippecanoe_builder

ARG TIPPECANOE_VERSION=2.79.0

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        build-essential \
        ca-certificates \
        git \
        libsqlite3-dev \
        zlib1g-dev \
    && rm -rf /var/lib/apt/lists/*

RUN git clone \
        --branch "${TIPPECANOE_VERSION}" \
        --depth 1 \
        https://github.com/felt/tippecanoe.git \
        /src/tippecanoe \
    && make -C /src/tippecanoe -j"$(nproc)"

PMTiles CLIもマルチステージビルドで作り、最終イメージにはGoのビルド環境を残しません。タグ、コミット、ソースの日時をldflagsへ入れているため、pmtiles versionでも採用版を確認できます。

FROM golang:1.25-bookworm AS pmtiles_builder

ARG PMTILES_VERSION=1.31.2

RUN git clone \
        --branch "v${PMTILES_VERSION}" \
        --depth 1 \
        https://github.com/protomaps/go-pmtiles.git \
        /src/go-pmtiles \
    && cd /src/go-pmtiles \
    && COMMIT="$(git rev-parse --short=12 HEAD)" \
    && SOURCE_DATE="$(git show -s --format=%cI HEAD)" \
    && CGO_ENABLED=0 go build \
        -trimpath \
        -ldflags "-X main.version=${PMTILES_VERSION} -X main.commit=${COMMIT} -X main.date=${SOURCE_DATE}" \
        -o /pmtiles \
        .

最終段にはPython 3.13、GeoPandas、Pyogrio、PyArrow、GDAL、Tippecanoe、PMTiles CLIを入れています。

タイル設定をJSONへ分ける

実データ用設定はconfig/tilesets.example.jsonです。

洪水1km集計の例を抜き出すと、次のようになります。

{
  "layer_id": "flood_maximum_1km",
  "dataset_id": "flood_a31a_2025",
  "scenario_id": "maximum",
  "source_artifact": "summary_1km",
  "layer_name": "flood_1km",
  "title": "洪水浸水想定区域 1km集計(想定最大規模)",
  "output_stem": "flood_maximum_1km",
  "id_field": "feature_id",
  "include_fields": [
    "feature_id",
    "mesh_code",
    "has_hazard",
    "max_class",
    "max_class_label",
    "max_value",
    "affected_area_ratio",
    "source_feature_count"
  ],
  "attribute_types": {
    "feature_id": "int",
    "mesh_code": "string",
    "has_hazard": "bool",
    "max_class": "int",
    "max_class_label": "string",
    "max_value": "float",
    "affected_area_ratio": "float",
    "source_feature_count": "int"
  },
  "value_filters": {
    "has_hazard": [true]
  },
  "minimum_zoom": 10,
  "maximum_zoom": 14,
  "enabled": true
}

source_artifact

summary_1km  1kmメッシュ集計をタイル化する
native       元の防災区域ポリゴンをタイル化する

今回の初期設定では、洪水と土砂災害のsummary_1kmだけを有効にしています。元形状レイヤーの例も入れていますが、enabled: falseです。

include_fields

MVTに残す属性です。

全国データでは、属性を1列追加しただけでもPMTiles全体の容量に影響します。表示に使わない列は入れません。

PMTilesへ入れる
  feature_id
  mesh_code
  max_class
  max_class_label
  affected_area_ratio

PMTilesへ入れない
  長い説明文
  元データURL
  ライセンス全文
  処理ログ
  詳細な集計途中値

出典、版、基準日、ライセンスはpmtiles_catalog_fragment.jsonへ記録します。クリック時の詳細情報はStep 5のFastAPIから取得します。

id_field

feature_idをMVTのfeature IDとして使います。

次の条件を検査します。

整数
欠損なし
重複なし
1以上
64bit符号付き整数の範囲内

OpenLayersで地物をクリックしたとき、このIDを使って詳細APIを呼べます。

value_filters

次の設定では、防災区域と交差したメッシュだけをタイル化します。

{
  "value_filters": {
    "has_hazard": [true]
  }
}

これはファイルサイズを抑えるには有効ですが、地物がない場所を「安全」と判断してはいけません。

タイルに地物がない
  ≠ 安全

考えられる状態
  区域外
  未整備
  対象シナリオ外
  データ更新前

区域外メッシュも灰色で表示したい場合は、このフィルターを外します。

入力GeoParquetを検査する

最初にStep 2の管理情報を読みます。

def load_step2_context(run_config: RunConfig) -> Step2Context:
    """Step 2のmanifest・検査報告・カタログを相互確認する。

    Parameters
    ----------
    run_config:
        Step 2成果物ディレクトリを含む実行設定。

    Returns
    -------
    Step2Context
        相互に整合するStep 2管理情報。
    """

対象GeoParquetを見つけた後、カタログのSHA-256と実ファイルを比較します。

actual_hash = sha256_file(path)

if actual_hash.lower() != expected_hash.lower():
    raise UpstreamArtifactError(
        "Step 2 GeoParquetのSHA-256がカタログと一致しません。"
    )

入力の取り違えや、コピー途中の破損をこの段階で止めます。

表示用の列と型を整える

GeoParquetから読む列は、JSON設定へ書いたものだけです。

frame = gpd.read_parquet(
    resolved.path,
    columns=[*layer.include_fields, "geometry"],
)

その後、属性型を明示的にそろえます。

string
int
float
bool

例えば整数列に3.5が混ざっていた場合、四捨五入して通すのではなくエラーにします。

fractional = numeric.dropna().map(
    lambda value: abs(value - round(value)) > 1e-9
)

if fractional.any():
    raise ValidationFailedError(
        f"{field_name}に整数ではない値があります。"
    )

Tippecanoe側にも属性型を渡しますが、Python側で先に検査しておけば、文字列が数値0へ置き換わるような意図しない変換を避けられます。

CRSはTippecanoeが受け付ける次のどちらかにします。

EPSG:4326
EPSG:3857

初期設定はEPSG:4326です。

FlatGeobufへ保存する

書き込みは一時ファイルを経由します。

def write_flatgeobuf_atomic(
    prepared: PreparedLayer,
    output_path: Path,
    *,
    layer_name: str,
    overwrite: bool,
) -> FlatGeobufInfo:
    """GeoDataFrameを空間索引付きFlatGeobufへ安全に保存する。"""
prepared.frame.to_file(
    temporary_path,
    layer=layer_name,
    driver="FlatGeobuf",
    engine="pyogrio",
    index=False,
    SPATIAL_INDEX="YES",
)

書き出した後、Pyogrioで読み戻します。

地物件数
CRS
geometry型
属性列
SHA-256

を確認し、問題がなければos.replace()で完成ファイルへ置き換えます。

TippecanoeでPMTilesを作る

実際に組み立てるコマンドは、概ね次の形です。

tippecanoe \
  --output flood_maximum_1km__20260806_r001.pmtiles \
  --force \
  --layer flood_1km \
  --name "洪水浸水想定区域 1km集計(想定最大規模)" \
  --projection EPSG:4326 \
  --minimum-zoom 10 \
  --maximum-zoom 14 \
  --buffer 5 \
  --maximum-tile-bytes 1000000 \
  --maximum-tile-features 200000 \
  --use-attribute-for-id feature_id \
  --include feature_id \
  --include mesh_code \
  --include max_class \
  --include affected_area_ratio \
  --attribute-type feature_id:int \
  --attribute-type mesh_code:string \
  --attribute-type max_class:int \
  --attribute-type affected_area_ratio:float \
  --simplify-only-low-zooms \
  --no-simplification-of-shared-nodes \
  --no-tiny-polygon-reduction \
  input.fgb

Pythonではsubprocess.run()へ文字列配列を渡し、shell=Trueは使いません。

result = subprocess.run(
    normalized,
    text=True,
    encoding="utf-8",
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    check=False,
)

設定値に空白や記号があっても、シェル展開や別コマンドとして解釈されない形です。

地物の自動間引きを使わない

Tippecanoeには、大きすぎるタイルから地物を減らす便利なオプションがあります。

--drop-densest-as-needed
--drop-smallest-as-needed
--coalesce-smallest-as-needed

一般的なポイント表示では役立ちますが、防災メッシュでは初期設定にしません。

1kmメッシュが消えた場合、利用者からは次の区別がつかないためです。

危険度が低いので表示していない
データがない
タイル容量の都合で削除された

小さなポリゴンの扱いも明示します。Tippecanoeは通常、非常に小さなポリゴンの面積を小さな四角へまとめることがあります。--no-tiny-polygon-reduction-at-maximum-zoomでは最大ズームだけが対象になり、下位ズームではまとめられます。今回は全ズームで個々のポリゴンを維持するため、--no-tiny-polygon-reductionを指定しました。

--no-tiny-polygon-reduction

extra_tippecanoe_argsへ間引き・統合・属性除外などのオプションを書いた場合も、設定読込時に拒否します。短縮形は意味を追いにくく制御済み設定を回避しやすいため、追加引数では長形式だけを受け付けます。--prefilter--postfilterは外部コマンドを起動できるため、JSON設定からは指定できません。

for arg in args:
    if arg.startswith(controlled_prefixes):
        raise ConfigurationError(
            "Step 3が制御する引数を上書きできません。"
        )
    if arg.startswith(feature_changing_prefixes):
        raise ConfigurationError(
            "地物を間引く引数や形を変える引数を指定できません。"
        )
    if arg.startswith(command_filter_prefixes):
        raise ConfigurationError(
            "外部コマンドを実行するフィルターを指定できません。"
        )

Tippecanoeの標準出力・標準エラーに、地物削除やタイル上限超過を示す文言が出た場合も成果物を破棄します。

タイルが大きすぎるときは、次の順で調整します。

1. MVTへ入れる属性を減らす
2. 1kmメッシュのminimum zoomを上げる
3. 10km・20km集計を低ズーム用に別途作る
4. 元形状レイヤーを地域や主題で分割する

ズームレベル

1kmメッシュの初期値は次です。

minimum zoom  10
maximum zoom  14

ズーム4や5で全国の1kmメッシュを描く必要はありません。画面上で形を判別できず、タイルだけが大きくなります。

最終形では、次のように分けます。

ズーム 表示候補
4~6 都道府県集計
7~9 10km・20km集計
10~14 1kmメッシュ
12以上 元の防災区域

低ズーム用データはTippecanoeの自動間引きで作るのではなく、Step 2で集計値を作ってから別PMTilesにします。

PMTilesを読み戻す

Tippecanoeが終了コード0を返しただけでは、公開可能とは判断しません。

1. pmtiles verify

pmtiles verify \
  workspace/artifacts/step3/tiles/flood_maximum_1km__20260806_r001.pmtiles

アーカイブの順序とヘッダーを確認します。

2. ヘッダー

pmtiles show input.pmtiles --header-json

次を設定値と照合します。

minzoom
maxzoom
bounds
center
tile type
tile compression

3. メタデータ

pmtiles show input.pmtiles --metadata

vector_layersから、次を確認します。

MVTレイヤー名
属性列
属性型

4. 代表タイルをdecode

データ範囲の中心は、海上や地物のない場所になることがあります。そこで、入力GeoParquetの先頭地物からrepresentative_point()を求め、その地点を含む最大ズームタイルを選びます。

sample_frame = frame.iloc[[0]].to_crs("EPSG:4326")
representative = sample_frame.geometry.iloc[0].representative_point()

選んだZ/X/Yは、まずPMTiles CLIのtileコマンドで取り出します。--quietを付け、バイナリ標準出力へ進捗ログが混ざらないようにします。実際の値は、入力地物の位置からプログラム内で計算します。

pmtiles --quiet tile \
  input.pmtiles \
  {z} {x} {y} \
  > sample.tile

MVTはgzip圧縮されている場合があります。実装では先頭2バイトを確認し、gzipのマジックナンバーで始まるときだけ展開してsample.pbfへ保存します。

def _normalize_vector_tile_payload(payload: bytes) -> bytes:
    """PMTilesから取得したタイルを未圧縮MVTへそろえる。"""

    if not payload:
        raise ValidationFailedError(
            "代表タイルのバイト列が空です。"
        )

    if payload.startswith(b"\x1f\x8b"):
        return gzip.decompress(payload)

    return payload

その後、個別PBFをtippecanoe-decodeへ渡します。

tippecanoe-decode \
  --layer flood_1km \
  sample.pbf \
  {z} {x} {y}

地物数が1件以上あることを確認します。PMTilesをZ/X/Y付きでtippecanoe-decodeへ直接渡す形にはせず、PMTiles CLIとPBF入力を組み合わせています。

pmtiles verifyはアーカイブ構造の検査です。実際のMVTレイヤー名や属性、代表タイルの地物まで別に見ることで、空のタイルや設定違いを見つけやすくしています。

manifestは最後に書く

PMTilesは作業ディレクトリへ出力し、すべての検査に合格してから成果物フォルダーへ移します。

workspace/work/step3
    ↓ 作成・検査
workspace/artifacts/step3/tiles

manifest.jsonは、PMTiles、カタログ、検査報告がそろった後で最後に書きます。

Step 4では、次の条件を満たす版だけを公開します。

manifest.status            passed
validation_report.status   passed

同じ版を作り直すときは、--overwriteを明示します。上書き処理の開始時に旧manifestを消すため、新しい処理が途中で失敗しても、旧版のpassedだけが残ることはありません。

PMTilesカタログ

pmtiles_catalog_fragment.jsonには、OpenLayers側で必要になる情報をまとめます。

{
  "tileset_id": "flood_maximum_1km",
  "filename": "flood_maximum_1km__20260806_r001.pmtiles",
  "url": "/tiles/flood_maximum_1km__20260806_r001.pmtiles",
  "layer_name": "flood_1km",
  "minimum_zoom": 10,
  "maximum_zoom": 14,
  "bounds": [122.0, 20.0, 154.0, 46.0],
  "feature_id_field": "feature_id",
  "fields": {
    "mesh_code": {
      "type": "string",
      "description": "8桁の3次メッシュコード"
    },
    "max_class": {
      "type": "int",
      "description": "メッシュ内の最大危険度クラス"
    }
  }
}

Step 4ではこのカタログを読み、ファイル名、MVTレイヤー名、ズーム範囲、出典を画面へ反映します。

デモを実行する

まず.envを作ります。

cp .env.example .env

Dockerイメージを作成します。

make build

ツールの版を確認します。

make tools

デモを実行します。

make demo

内部では次を行います。

Step 2互換の架空GeoParquetを2つ作る
    ↓
洪水1km集計のPMTilesを作る
    ↓
土砂災害1km集計のPMTilesを作る
    ↓
両方を読み戻して検査する
    ↓
manifest・catalog・validation reportを作る

成果物は実データと分けて保存します。

workspace/artifacts/step3_demo/
├─ tiles/
├─ flatgeobuf/
├─ pmtiles_catalog_fragment.json
├─ manifest.json
└─ validation_report.json

デモデータは架空です。実在する浸水区域や土砂災害警戒区域とは関係ありません。

実データで実行する

Step 2成果物を次へ配置します。

workspace/artifacts/step2/

実行順は次です。

make build
make tools
make step3
make validate
make inspect
make test

既存の同じ成果物版を作り直す場合だけ、次を使います。

./scripts/run_step3.sh --overwrite

成果物版は.envで管理します。

STEP3_ARTIFACT_VERSION=20260806_r001

公開後のPMTilesは同じファイル名で上書きせず、新しい版を作ります。

flood_maximum_1km__20260806_r001.pmtiles
flood_maximum_1km__20260901_r001.pmtiles

Step 4で新しいカタログへ切り替えた後、旧版を一定期間残しておくと戻しやすくなります。

テスト

make test

単体テストでは次を確認します。

設定JSONの読み込み
間引き系オプションの拒否
MVT feature ID型の検査
Tippecanoe引数
地物削除メッセージの検出
Step 2成果物の移動後パス解決
SHA-256不一致の拒否
経度緯度からZ/X/Yへの変換

Docker内の統合テストでは、架空GeoParquetから実際にPMTilesを作ります。

この配布物を組み立てた環境にはDocker、PyArrow、Tippecanoe、PMTiles CLIがなかったため、作成時のローカルテスト結果は次でした。

13 passed, 2 skipped

スキップした2件は、Docker環境でmake testを実行すると確認できます。

よくあるエラー

Step 2のSHA-256が一致しない

Step 2 GeoParquetのSHA-256がカタログと一致しません

GeoParquetだけを差し替えず、Step 2の成果物一式を同じ版で配置してください。

フィルター後に0件になる

フィルター後に0件になりました

value_filtersの型を確認します。

{
  "has_hazard": [true]
}

"true"という文字列ではなく、JSONの真偽値trueを使います。

Tippecanoeがタイル上限で止まる

workspace/logs/step3/{layer_id}/tippecanoe.logを確認します。

最初に、MVTへ入れている属性を減らします。それでも大きい場合は、minimum zoomを上げるか、低ズーム用集計を別に作ります。

代表タイルのdecodeが0件

代表タイルをdecodeしましたが、指定レイヤーの地物がありません

次を確認します。

layer_name
maximum_zoom
feature_id
geometry
value_filters

PMTilesのズームが設定と違う

過去に作った別版のPMTilesを読んでいる可能性があります。成果物版とファイル名を確認してください。

Step 4へ渡すもの

Step 4が受け取るのは次の成果物です。

workspace/artifacts/step3/
├─ tiles/*.pmtiles
├─ pmtiles_catalog_fragment.json
├─ manifest.json
└─ validation_report.json

Step 4では、NginxがPMTilesをHTTP Range Requestで配信できるように設定し、ol-pmtilesとOpenLayersから読み込みます。

PMTiles
    ↓ 206 Partial Content
Nginx
    ↓
ol-pmtiles
    ↓
OpenLayers

まとめ

Step 3で行ったのは、GeoParquetを単にPMTilesへ変換するだけではありません。

Step 3の実装

  • Step 2成果物の版とハッシュを確認する
  • 表示に必要な属性だけを選ぶ
  • 型、CRS、feature IDをそろえる
  • FlatGeobufへ変換する
  • TippecanoeでPMTilesを作る
  • 地物を自動間引きしない
  • PMTilesの構造とMVT内容を読み戻す
  • 版、出典、検査結果を管理JSONへ残す

全国データでは、作成できたことよりも、どの入力と設定から作り、地物が意図せず消えていないことを確認できる方が大切です。

次のStep 4では、今回作成したPMTilesをNginxから静的配信し、OpenLayersで表示します。

参考資料


0
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
0
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?