Pythonを使ってPMTilesから特定のタイルを抽出する

## はじめに

[PMTiles](https://github.com/protomaps/PMTiles)はラスタータイルを単一のファイルにパッケージ化できるフォーマットです。ディレクトリにタイルを配置する従来の方法と比べてタイルの取り回しが簡単になるという利点があります。

しかし、単一ファイルに複数のタイルが格納されているため、タイルの一覧を取得したり、特定のタイルを抽出したい場合は自前で実装が必要です。

今回はPythonを使ってPMTilesファイルに含まれるタイル一覧の取得、および特定タイルの抽出方法を紹介します。

## 環境準備

Python環境に[pmtiles](https://github.com/protomaps/PMTiles/tree/main/python), [aiopmtiles](https://github.com/developmentseed/aiopmtiles)をインストールします。なお、これらのパッケージは2025年2月時点では実験的なモジュールであることに注意してください。

```bash
pip install pmtiles git+http://github.com/developmentseed/aiopmtiles
```

## タイル一覧の取得

### 実装

PMTilesファイルに含まれるタイルの情報を取得する実装です。

```python
import asyncio
from aiopmtiles import Reader
from pmtiles.tile import Entry, deserialize_directory, tileid_to_zxy


async def extract_tile_entries(directory: list[Entry], header: dict, src: Reader) -> list[tuple[int, int, int, int]]:
    """PMTilesのディレクトリからタイル情報のリストを抽出します

    Args:
        directory (list): PMTilesのディレクトリエントリのリスト
        header (dict): PMTilesのヘッダー情報
        src (Reader): PMTilesリーダーオブジェクト

    Returns:
        list[tuple[int, int, int, int]]: タイル情報のリスト (z, x, y, length)
    """
    
    tile_entries = []
    for entry in directory:
        if entry.run_length > 0:
            for i in range(entry.run_length):
                z, x, y = tileid_to_zxy(entry.tile_id + i)
                tile_entries.append((z, x, y, entry.length))
        else:
            leaf_offset = header["leaf_directory_offset"] + entry.offset
            leaf_values = await src._get(leaf_offset, entry.length - 1)
            leaf_directory = deserialize_directory(leaf_values)
            leaf_entries = await extract_tile_entries(leaf_directory, header, src)
            tile_entries.extend(leaf_entries)

    return tile_entries


async def list_tiles(pmtiles_url: str) -> list[tuple[int, int, int, int]]:
    """PMTilesファイルからタイル情報を取得します

    Args:
        pmtiles_url (str): PMTilesファイルのURL

    Returns:
        list[tuple[int, int, int, int]]: タイル情報のリスト (z, x, y, length)
    """
    
    async with Reader(pmtiles_url) as src:
        directory_values = await src._get(src._header["root_offset"], src._header["root_length"] - 1)
        directory = deserialize_directory(directory_values)
        return await extract_tile_entries(directory, src._header, src)
```

### 使用例

PMTilesファイルに含まれるすべてのタイル情報をCSVファイルに保存する例です。

```python
import asyncio
import csv

async def main():
    pmtiles_url = "path/to/your/file.pmtiles"

    # タイルリストの取得
    tile_entries = await list_tiles(pmtiles_url)

    # CSVファイルに保存
    output_path = "tiles.csv"
    with open(output_path, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["z", "x", "y", "length"])
        writer.writerows(tile_entries)


if __name__ == "__main__":
    asyncio.run(main())
```

## 特定のタイルの抽出

### 実装

ズームレベル（z）とx/y座標を指定して、個別のタイルを取得する実装です。

```python
async def get_tile(pmtiles_url: str, z: int, x: int, y: int) -> bytes | None:
    """PMTilesファイルから特定のタイルを取得します

    Args:
        pmtiles_url (str): PMTilesファイルのURL
        z (int): ズームレベル
        x (int): タイルのX座標
        y (int): タイルのY座標

    Returns:
        bytes | None: タイルデータ。タイルが存在しない場合はNone
    """

    async with Reader(pmtiles_url) as src:
        return await src.get_tile(z, x, y)
```

### 使用例

指定した座標のタイルを取得してファイルに保存する例です。

```python
import asyncio
from pathlib import Path


async def main():
    pmtiles_url = "path/to/your/file.pmtiles"

    # タイル座標の指定
    zoom = 15
    tile_x = 28376
    tile_y = 13013

    # 特定のタイルの取得
    tile_data = await get_tile(pmtiles_url, z=zoom, x=tile_x, y=tile_y)
    if tile_data is None:
        print("タイルが存在しません")
        return

    # タイルの保存（PNG形式）
    output_dir = "tiles"
    output_path = Path(output_dir) / str(zoom) / str(tile_x)
    output_path.mkdir(parents=True, exist_ok=True)
    tile_path = output_path / f"{tile_y}.png"
    with open(tile_path, "wb") as f:
        f.write(tile_data)


if __name__ == "__main__":
    asyncio.run(main())
```

## 参考文献

タイル一覧の取得の実装は、以下を参考にさせていただきました。

https://qiita.com/mg_kudo/items/9f87fca0c579fd2b645d