OpenStreetMap の世界へようこそ！〜Pythonを使ってデータを活用してみる〜

![山小屋](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962815/179c386c-a8f3-4c2b-9ee0-126fd33ed825.jpeg)

# はじめに

「地図」と聞いて、皆さんは何を思い浮かべますか？
多くの方がGoogleマップのようなサービスを想像するかもしれません。
しかし、世の中には様々な地図サービスが存在し、それぞれに特徴があります。
今回は、その中でも特に「Open Street Map」（OSM）に焦点を当て、その魅力と可能性についてご紹介します。

https://www.openstreetmap.org/

# OSMでできること

OSMは、誰でも自由に利用・編集できる、まさにオープンソースの地図データです。
データが取り放題である点は、開発者や研究者にとって大きなメリットと言えるでしょう。

では、OSMを使って具体的にどのようなことができるのでしょうか？
Pythonを使ってデータを取得してみた例をもとにご紹介します。

## 活用例1：山の情報を抽出する

OSMの豊富なデータを活用することで、特定の地理情報を抽出・分析することができます。
例えば、山岳情報を標高データと共に抽出し、JSON形式で保存するといったことが可能です。
これにより、特定の山岳エリアの情報を簡単にリスト化し、さらなる分析や可視化に繋げることができます。

#### Pythonのサンプルコード

<details><summary>ここをクリックして表示</summary>

```python
import requests
import json
import time
import os


def cleaning_text(text: str) -> str:
    if ";" in text:
        text = text.split(";")[0]
    text = text.strip()
    return text


def fetch_mountain_spots(output_file="mountain_peak_data.json", area="日本"):
    print(f"{area}の山頂（natural=peak）データを取得中...")

    # 出力ディレクトリの設定
    output_dir = "output"
    base_dir = os.path.dirname(os.path.abspath(__file__))
    output_path = os.path.join(base_dir, output_dir, output_file)

    # 出力ディレクトリが存在しない場合は作成
    os.makedirs(os.path.join(base_dir, output_dir), exist_ok=True)

    # Overpass APIのエンドポイント
    overpass_url = "https://overpass-api.de/api/interpreter"

    # スポットデータを保存するリスト
    peak_spots = []
    total_spots = 0
    processed_spot_ids = set()

    print(f"mountain_peaks を取得中...")

    query_body = "node[\"natural\"=\"peak\"](area.searchArea);"
    overpass_query = """
        [out:json][timeout:180];
        area[name="{}"]->.searchArea;
        (
          {}
        );
        out center body;
        """.format(
        area, query_body
    )

    try:
        # APIリクエスト
        response = requests.post(overpass_url, data={"data": overpass_query})
        response.raise_for_status()

        # レスポンスのJSONを取得
        data = response.json()
        element_list = data.get("elements", [])

        print(f"  取得: {len(element_list)}件")

        # 取得したデータを整形
        for element in element_list:
            spot_id = element.get("id")

            # 基本情報
            spot_info = {
                "id": spot_id,
                "type": element.get("type"),
                "spot_type": "mountain_peaks",
                "tags": element.get("tags", {}),
            }

            # スポットIDの重複を防ぐ
            if spot_id in processed_spot_ids:
                continue

            processed_spot_ids.add(spot_id)

            # 座標情報の取得
            if element.get("type") == "node":
                spot_info["lat"] = element.get("lat")
                spot_info["lon"] = element.get("lon")
            elif (
                element.get("type") in ["way", "relation"]
                and "center" in element
            ):
                spot_info["lat"] = element.get("center", {}).get("lat")
                spot_info["lon"] = element.get("center", {}).get("lon")

            # 名前や他の重要な情報を抽出
            tags = element.get("tags", {})
            if "name" in tags:
                spot_info["name"] = cleaning_text(tags["name"])
            if "name:ja" in tags:
                spot_info["name_ja"] = cleaning_text(tags["name:ja"])
            if "name:en" in tags:
                spot_info["name_en"] = cleaning_text(tags["name:en"])

            # 標高情報の取得
            if "ele" in tags:
                spot_info["elevation"] = tags["ele"]
            elif "altitude" in tags:
                spot_info["elevation"] = tags["altitude"]

            # 標高の単位がない場合はメートルと仮定
            if "elevation" in spot_info and isinstance(
                spot_info["elevation"], str
            ):
                elevation_str = spot_info["elevation"]
                try:
                    if elevation_str.replace("ｍ", "m").lower().endswith("m"):
                        elevation_str = elevation_str[:-1]
                    elevation_str = elevation_str.replace(",", "")
                    elevation_str = cleaning_text(elevation_str)
                    if elevation_str and elevation_str.strip().replace('.', '', 1).isdigit(): # 小数点も許容
                        spot_info["elevation"] = float(elevation_str.strip())
                    else:
                        spot_info["elevation"] = None
                except ValueError:
                    spot_info["elevation"] = None
                except Exception:
                    spot_info["elevation"] = None

            if "height" in tags:
                spot_info["height_tag"] = tags["height"]
            if "summit:type" in tags:
                spot_info["summit_type"] = tags["summit:type"]

            peak_spots.append(spot_info)

    except requests.exceptions.RequestException as e:
        print(f"APIリクエストエラー: {e}")
    except json.JSONDecodeError as e:
        print(f"JSONデコードエラー: {e}")
    except Exception as e:
        print(f"エラーが発生しました: {e}")

    print(f"{len(peak_spots)}件を取得しました")
    total_spots = len(peak_spots)

    output_data = {
        "mountain_peaks": peak_spots
    }

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(
            {
                "spots": output_data,
                "total_spots": total_spots,
                "timestamp": time.time(),
                "area": area,
            },
            f,
            ensure_ascii=False,
            indent=2,
        )

    print(
        f"取得完了: 合計{total_spots}件を{output_dir}/{output_file}に保存しました。"
    )
    return {"spots": output_data, "total_spots": total_spots}


if __name__ == "__main__":
    fetch_mountain_spots()
```

</details>

#### JSONの中身

![JSONの中身](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962815/235faa3c-aab2-4f8b-bf59-929b02b29c92.png)

## 活用例2：東京周辺のカフェをマップに起こす

同様に特定の種類の施設情報を抽出し、地図上にプロットすることも可能です。
例えば、東京周辺のカフェ情報をOSMから取得して、[Googleマイマップ](https://www.google.co.jp/intl/ja/maps/about/mymaps/)などのツールで可視化することができます。

#### Pythonのサンプルコード

<details><summary>ここをクリックして表示</summary>

```python
import requests
import csv
import time
import os


def fetch_cafe_spots(
    output_file="tokyo_cafe_spots.csv",
    center_lat=35.6812,
    center_lon=139.7671,
    radius=10000,
):
    print(f"東京周辺（半径{radius/1000}km圏内）のカフェスポットデータを取得中...")

    # Overpass APIのエンドポイント
    overpass_url = "https://overpass-api.de/api/interpreter"

    # カフェを検索するOverpass QLクエリ
    # amenity=cafe または cuisine=coffee_shop のノードを検索
    query = f"""
    [out:json][timeout:60];
    (
      node["amenity"="cafe"](around:{radius},{center_lat},{center_lon});
      way["amenity"="cafe"](around:{radius},{center_lat},{center_lon});
      relation["amenity"="cafe"](around:{radius},{center_lat},{center_lon});
      node["cuisine"="coffee_shop"](around:{radius},{center_lat},{center_lon});
      way["cuisine"="coffee_shop"](around:{radius},{center_lat},{center_lon});
      relation["cuisine"="coffee_shop"](around:{radius},{center_lat},{center_lon});
    );
    out body;
    >;
    out skel qt;
    """

    try:
        print("Overpass APIにリクエスト送信中...")
        response = requests.post(overpass_url, data={"data": query})
        response.raise_for_status()

        data = response.json()

        cafe_spots = []

        for element in data.get("elements", []):
            # ノードタイプのみ処理（ウェイとリレーションは中心点を計算する必要があるため簡略化）
            if element["type"] == "node":
                spot_name = element.get("tags", {}).get("name", "")

                if len(spot_name) == 0:
                    continue

                # タグから追加情報を取得
                tags = element.get("tags", {})

                # フル名を構築（ブランド名や店名などを含む）
                full_name = spot_name

                # brand名があれば追加
                if "brand" in tags:
                    brand = tags["brand"]
                    if brand and brand not in full_name:
                        full_name = f"{brand} {full_name}"

                # operator名があれば追加
                if "operator" in tags:
                    operator = tags["operator"]
                    if operator and operator not in full_name:
                        full_name = f"{operator} {full_name}"

                # branch(支店名)があれば追加
                if "branch" in tags:
                    branch = tags["branch"]
                    if branch and branch not in full_name:
                        full_name = f"{full_name} {branch}店"

                spot_info = {
                    "id": element["id"],
                    "name": full_name,
                    "lat": element["lat"],
                    "lon": element["lon"],
                    "google_maps_url": f"https://www.google.com/maps/search/?api=1&query={element['lat']},{element['lon']}",
                }

                # 名前が日本語で設定されていない場合は日本語名を探す
                if "name:ja" in element.get("tags", {}):
                    spot_info["name"] = element["tags"]["name:ja"]

                # 名前が設定されている場所のみ追加
                if spot_info["name"] and spot_info["name"] != "名称不明":
                    cafe_spots.append(spot_info)

        print(f"{len(cafe_spots)}件の名前が設定されているカフェスポットを取得しました")

        # CSVファイルに保存
        with open(output_file, "w", encoding="utf-8", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["name", "id", "lat", "lon", "google_maps_url"])

            for spot in cafe_spots:
                writer.writerow(
                    [
                        spot["name"],
                        spot["id"],
                        spot["lat"],
                        spot["lon"],
                        spot["google_maps_url"],
                    ]
                )

        print(
            f"取得完了: 合計{len(cafe_spots)}件のカフェスポットデータを{output_file}に保存しました。"
        )
        return cafe_spots

    except requests.exceptions.RequestException as e:
        print(f"APIリクエストエラー: {e}")
    except Exception as e:
        print(f"エラーが発生しました: {e}")

    return []


if __name__ == "__main__":
    fetch_cafe_spots()
```

</details>

#### プロットした様子

![マップ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962815/ecedcba5-f077-4297-b7cd-4c76e7184959.png)

## OSMのデータ構造

OSMのデータは、「タグ」によって施設の種別などが定義されています。
これは Google Maps API における「プレイスタイプ」に似た概念です。
例えば `amenity=cafe` はカフェを、`natural=peak` は山頂を示します。

OSMを構成する基本的な要素は以下の3つです。

- **ノード (Node):** 特定の場所（点情報）
- **ウェイ (Way):** 道路やエリア（線情報・面情報）
- **リレーション (Relation):** ノードやウェイの集合で、複雑な関係性を表現

詳細については [OSM Wiki](https://www.google.com/url?sa=E&source=gmail&q=https://wiki.openstreetmap.org/wiki/JA:%E8%A6%81%E7%B4%A0) をご覧ください。

# 地図の表示について

主に「ラスタータイル」と「ベクトルタイル」の2種類があります。

- ラスタータイル
  - 地図を画像タイルに分割して表示
  - タイルのURLは、ズームレベル、X座標、Y座標の組み合わせで構成される
  - メリット：描画が速い
  - デメリット：カスタマイズ性に乏しく、回転させると文字やアイコンも一緒に回転してしまう
- ベクトルタイル
  - 地図データを数値データとして読み込み、クライアント側で描画する
  - メリット：表示する情報を選択できる、鮮明な表示が可能、通信量が少ない
  - デメリット：描画にスペックを要する場合がある

OSMで現在採用されているのはラスタータイルですが、より応用力の高いベクトルタイルへの対応を現在進めているようです。[^vector]


## コントリビュートしてみよう

OSMは誰でも編集に参加できるプロジェクトです。
情報が不足している箇所に施設名やタグを追加するなど、簡単な編集から始めることができます。

編集をしてボタンを押すと、数秒後にはOSMを利用しているユーザーに情報を提供できるワクワク感をぜひ味わってみてください。

![送信後の表示](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962815/9006c28a-823c-4000-bb7f-db2952e84c15.png)

実際にこの記事を書く際、私もコントリビュートしてみました。
（以前山に登った際に立ち寄った小屋です）

https://www.openstreetmap.org/way/697899811

# まとめ

OpenStreetMap は既にたくさんの方々の協力のもと、地理データが集まるプラットフォームになっています。
この記事を読んで興味を持った方は、是非この際にデータの利活用を考えてみると世界が拡がるかもしれません🗺️

[^vector]: https://blog.openstreetmap.org/2024/06/06/an-progress-update-on-vector-tiles-from-the-engineering-working-group/