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?

Streamlitで地図可視化アプリを作ってみた ― PLATEAUの建物・浸水データで試す

0
Posted at

はじめに

2026年9月8日、愛知県西部で線状降水帯が発生し、名古屋市千種区では観測史上最大となる1時間104.5mmの雨を記録しました。名古屋市周辺では道路が冠水し、水につかって動けなくなった車が何台も出ています。その1年前の2025年9月12日にも、三重県四日市市で時間雨量123.5mmの大雨が降り、駅前の地下駐車場で274台の車が浸水しました。

川沿いや山あいに限らず、道路も建物も整った都市の真ん中でも、車が沈むほどの水が出るようになっています。そうなると、自分が住んでいる街や、毎日通る駅のまわりがどれくらい浸かりやすいのかは知っておきたくなります。

そこで、Streamlitで地図を使ったアプリを作ってみました。

こんな感じのものができます。

buildings.png

flood_risk.png

shelter.png

国土交通省のPLATEAUが公開している大阪市の建物データを使って、京橋駅周辺の建物を表示したり、浸水リスクを色分けしたり、避難施設の位置を確認したりできるアプリです。

この記事では、Streamlitでこういった地図アプリを作る方法を、実際のコードを交えて紹介します。

Streamlitの地図表示機能

Streamlitには地図を表示するための仕組みがいくつかあります。

方法 ライブラリ 得意なこと
st.map 組み込み 緯度経度のポイントをサッと表示
st.pydeck_chart pydeck ポリゴンの塗り分け、大量データの描画
st_folium folium + streamlit-folium マーカー、ポップアップ、GeoJSONの重ね合わせ

st.mapは手軽ですが、ポリゴンの描画や色分けには対応していません。今回のように建物の形状を表示したり、浸水深で色を変えたりするにはpydeckを使います。一方、避難施設のようにクリックで情報を表示したいときはfoliumのほうが書きやすいです。

今回のアプリでは、ページによってpydeckとfoliumを使い分けました。

使ったデータ

データはG空間情報センターから取得しました。

データ ファイル 中身
建物CityGML 27100_osaka-shi_city_2024_citygml_1_op.zip 建物の形状、用途、高さ、浸水リスク等
関連データ 27100_osaka-shi_2022_related.zip 避難施設、鉄道、公園などのGeoJSON

CityGMLは大阪市全域で1.5GBあります。今回は京橋駅周辺のメッシュだけ抽出して、GeoJSONに変換してから使いました。変換スクリプトも後述します。

環境構築

mkdir plateau-streamlit-app && cd plateau-streamlit-app
python3 -m venv venv && source venv/bin/activate
pip install streamlit pandas geopandas pydeck folium streamlit-folium lxml

プロジェクト構成

plateau-streamlit-app/
├── app.py                       # メインページ
├── pages/
│   ├── 1_🏢_建物データ.py        # pydeck で建物表示
│   ├── 2_🌊_浸水リスク.py        # pydeck で浸水リスク表示
│   └── 3_🏥_避難施設.py          # folium で避難施設マップ
├── scripts/
│   ├── convert_citygml.py       # CityGML→GeoJSON変換
│   └── extract_flood_risk.py    # 浸水リスク抽出
├── data/
│   ├── buildings.geojson
│   ├── flood/
│   └── related/
└── .streamlit/
    └── config.toml

Streamlitのマルチページ機能は、pages/ディレクトリにファイルを置くだけでサイドバーにページが追加されます。ファイル名の先頭に番号を付けると、その順番で表示されます。

メインページを作る

まずはapp.pyから。アプリの入口になるページです。

# app.py
import streamlit as st

st.set_page_config(
    page_title="PLATEAU 京橋駅周辺 可視化",
    page_icon="🏙️",
    layout="wide",
)

st.title("🏙️ PLATEAU × Streamlit 京橋駅周辺 可視化")
st.markdown("""
このアプリは、国土交通省のProject PLATEAUが提供する
3D都市モデルデータを使って、大阪・京橋駅周辺の都市情報を可視化するデモアプリです。
""")

layout="wide"にしておくと画面の横幅いっぱいに使えるので、地図を表示するアプリでは基本これにしておくのがおすすめです。

main.png

建物データページ ― pydeckでポリゴン表示

データの読み込みとキャッシュ

Streamlitはウィジェットの値が変わるたびにスクリプト全体が再実行されます。毎回GeoJSONを読み直すと遅くなるので、@st.cache_dataでキャッシュしておきます。

@st.cache_data
def load_buildings():
    with open(DATA_PATH, encoding="utf-8") as f:
        data = json.load(f)
    rows = []
    for feat in data["features"]:
        props = feat["properties"]
        rows.append({
            "gml_id": props.get("gml_id", ""),
            "usage_name": props.get("usage_name", "不明"),
            "year_built": props.get("year_built"),
            "height": props.get("height"),
            "latitude": props.get("latitude"),
            "longitude": props.get("longitude"),
            "coordinates": feat["geometry"]["coordinates"][0],
        })
    return pd.DataFrame(rows), data

サイドバーにフィルターを置く

st.sidebarにウィジェットを追加すると、サイドバーにフィルターが並びます。multiselectで用途の複数選択、sliderで範囲指定ができます。

st.sidebar.header("フィルター")
selected_usage = st.sidebar.multiselect("建物用途", usage_options, default=usage_options)

if df["height"].notna().any():
    height_range = st.sidebar.slider("高さ (m)", min_h, max_h, (min_h, max_h))

if df["year_built"].notna().any():
    year_range = st.sidebar.slider("建築年", min_y, max_y, (min_y, max_y))

フィルターの値を変えると即座に再実行されて地図が更新されます。この「ウィジェットの値 → 描画」の流れをコールバックなしで書けるのがStreamlitのいいところだと思います。

pydeckで地図を描画する

フィルタ済みのデータをpydeckのPolygonLayerに渡します。建物の用途ごとに色を変えて、高さで押し出し表示しています。

COLOR_MAP = {
    "業務施設": [0, 100, 200],
    "商業施設": [200, 50, 50],
    "住宅": [50, 150, 50],
    "共同住宅": [80, 180, 80],
    "官公庁施設": [200, 150, 0],
    # ...
}

polygon_data = []
for feat in filtered_features:
    props = feat["properties"]
    h = props.get("height") or 5
    color = COLOR_MAP.get(props.get("usage_name", "不明"), [128, 128, 128])
    polygon_data.append({
        "polygon": feat["geometry"]["coordinates"][0],
        "height": h,
        "color": color + [180],
        "name": props.get("usage_name", "不明"),
        "h": h,
    })

layer = pdk.Layer(
    "PolygonLayer",
    data=polygon_data,
    get_polygon="polygon",
    get_elevation="height",
    get_fill_color="color",
    extruded=True,
    pickable=True,
)

view = pdk.ViewState(
    latitude=center_lat, longitude=center_lon,
    zoom=15, pitch=45, bearing=-20,
)

st.pydeck_chart(pdk.Deck(
    layers=[layer], initial_view_state=view,
    tooltip={"text": "用途: {name}\n高さ: {h}m"},
))

extruded=Trueにすると建物が立体的に見えます。pickable=Trueでマウスオーバー時にツールチップが出ます。

ここで一つ注意点。tooltipの{name}や{h}は、polygon_dataの辞書のキー名と合わせる必要があります。キー名が違うとツールチップに何も表示されないので、ちょっとハマりやすいポイントです。

buildings_3d.png

ページの下部には、st.bar_chartで用途の内訳と高さの分布も出しています。value_counts()の結果をそのまま渡すだけなので楽です。

浸水リスクページ ― 浸水深で色分け

データの構造

PLATEAUの浸水データは、CityGMLの建物属性に埋め込まれています。事前に変換スクリプトで抽出してGeoJSONにしておきました(スクリプトの説明は後述)。各建物に浸水深(depth)とランク(rank)が入っています。

色分けのロジック

浸水深に応じて6段階の色を割り当てました。

DEPTH_COLORS = {
    0.5: [255, 255, 190, 150],   # 薄黄
    1.0: [255, 220, 100, 160],   # 黄
    2.0: [255, 150, 50, 170],    # オレンジ
    3.0: [255, 80, 30, 180],     # 赤オレンジ
    5.0: [200, 0, 0, 190],       # 赤
    10.0: [120, 0, 60, 200],     # 暗赤
}

def get_depth_color(depth):
    for threshold, color in sorted(DEPTH_COLORS.items()):
        if depth <= threshold:
            return color
    return [80, 0, 40, 200]

災害種別の切り替え

サイドバーにselectboxを置いて、洪水・高潮・津波を切り替えられるようにしました。

disaster_type = st.sidebar.selectbox("災害種別", list(flood_files.keys()))

selectboxの値が変わるとスクリプトが再実行されて、対応するGeoJSONが読み込まれます。Streamlitはこの「選択 → 再実行 → 表示」の流れが自然に書けるので、こういうデータ切り替え系のUIは楽に作れます。

描画はPolygonLayerのextruded=Falseで平面表示にしています。建物の輪郭が浸水深に応じた色で塗られるので、どこがどれくらい浸かるのかが直感的にわかります。

避難施設ページ ― foliumでマーカー表示

なぜfoliumを使ったか

避難施設はポリゴンではなくポイントデータで、クリック時に施設名や住所を表示したいのでfoliumを選びました。foliumのほうがポップアップのHTMLを自由に書けるので、こういうケースには向いています。

foliumの地図をStreamlitに埋め込む

streamlit-foliumパッケージのst_foliumを使います。

import folium
from streamlit_folium import st_folium

m = folium.Map(location=[CENTER_LAT, CENTER_LON], zoom_start=14)

for _, row in df.iterrows():
    popup_text = f"<b>{row['name']}</b><br>住所: {row['address']}<br>収容人数: {row['capacity']}"
    folium.Marker(
        location=[row["latitude"], row["longitude"]],
        popup=folium.Popup(popup_text, max_width=300),
        tooltip=row["name"],
        icon=folium.Icon(color="green", icon="home", prefix="fa"),
    ).add_to(m)

st_folium(m, width=None, height=600)

tilesを指定しなければ、背景にはOpenStreetMapの地図が使われます。CARTOの薄いグレーの地図(tiles="cartodbpositron")も指定できますが、執筆時点ではAPIキーが必要になっていて、キーなしでは地図一面に「API KEY REQUIRED」の透かしが入りました。

浸水データとの重ね合わせ

このページのもう一つの機能として、浸水データとの重ね合わせを入れました。サイドバーから選択すると、避難所マーカーの背景に浸水想定区域が表示されます。

flood_type = st.sidebar.selectbox("浸水データ重ね合わせ", ["なし"] + list(flood_files.keys()))

if flood_type != "なし":
    folium.GeoJson(
        flood_data, name="浸水想定区域",
        style_function=lambda x: {
            "fillColor": "#ff6600", "color": "#cc4400",
            "weight": 1, "fillOpacity": 0.3,
        },
    ).add_to(m)

folium.GeoJsonにGeoJSONの辞書をそのまま渡せるのが便利です。style_functionで見た目を調整できます。

shelter_with_flood.png

データの前処理 ― CityGMLからGeoJSONへ

ここまではStreamlitのアプリ側の話でしたが、データの前処理についても触れておきます。

PLATEAUのCityGMLはXMLベースで、1ファイル数十MBあります。lxmlでパースして、必要な属性だけ抜き出してGeoJSONに変換しました。

# scripts/convert_citygml.py

BBOX = {
    "min_lat": 34.693, "max_lat": 34.707,
    "min_lon": 135.528, "max_lon": 135.542,
}

def extract_coordinates(pos_list_text):
    """gml:posList から座標を取り出す"""
    coords = pos_list_text.strip().split()
    points = []
    for i in range(0, len(coords) - 2, 3):
        lat, lon = float(coords[i]), float(coords[i + 1])
        points.append([lon, lat])
    return points

def parse_building(bldg_elem, ns):
    """建物要素から用途、高さ、座標を取得"""
    # (中略)
    usage_map = {
        "401": "業務施設", "402": "商業施設", "411": "住宅",
        "412": "共同住宅", "431": "官公庁施設",
        # ...
    }
    return {
        "type": "Feature",
        "geometry": {"type": "Polygon", "coordinates": [ground_coords]},
        "properties": {
            "gml_id": gml_id, "usage_name": usage_name,
            "height": height, "latitude": centroid_lat, "longitude": centroid_lon,
        },
    }

浸水リスクもCityGMLの建物属性に埋め込まれています。RiverFloodingRiskAttribute(洪水)、HighTideRiskAttribute(高潮)、TsunamiRiskAttribute(津波)という属性から浸水深を取り出して、災害種別ごとにGeoJSONを出力しました。

# scripts/extract_flood_risk.py

RISK_TYPES = {
    "RiverFloodingRiskAttribute": "洪水",
    "HighTideRiskAttribute": "高潮",
    "TsunamiRiskAttribute": "津波",
}

for attr_name, risk_name in RISK_TYPES.items():
    risk_elems = b.findall(f'.//{{{URO}}}{attr_name}')
    for risk in risk_elems:
        depth = float(risk.find(f'{{{URO}}}depth').text)
        # → GeoJSONに変換して出力

9ファイル(約450MB)を処理して、建物7,582棟、浸水リスク(洪水21,557件・高潮7,529件・津波5,964件)を抽出しました。

Streamlitで地図を扱うときのポイント

実際に作ってみて気づいたことをいくつか。

@st.cache_dataは必須

地図データは容量が大きくなりがちです。Streamlitは操作のたびにスクリプトが再実行されるので、@st.cache_dataでデータ読み込みをキャッシュしないと毎回固まります。

pydeckとfoliumは別ページに分ける

pydeckはWebGL、foliumはLeafletベースで、レンダリングの仕組みが違います。同じページに両方を配置すると挙動が不安定になることがあったので、ページを分けるのが無難です。

データは軽くしてから渡す

GeoJSONの座標を小数6桁に丸めたり、インデントなしで出力したり、データの前処理段階でサイズを削っておくのが大事です。今回は450MBのCityGMLから19MBのGeoJSONにまで落としました。

pydeckとfoliumの比較

両方使ってみた感想をまとめます。

pydeck folium
得意なこと ポリゴンの塗り分け、大量データ マーカー、ポップアップ
Streamlit連携 st.pydeck_chart(組み込み) streamlit-folium(別パッケージ)
描画エンジン WebGL Leaflet
使いどころ 「面」の表示 「点」の情報表示

「面」で見せるものはpydeck、「点」で情報を出すものはfolium。今回のアプリではこの使い分けがしっくりきました。

まとめ

Streamlitで地図可視化アプリを作る流れを、PLATEAUのデータを使って一通り試してみました。

やってみた感想として、Streamlitは地図系のアプリにも十分使えます。st.sidebarのウィジェットでフィルタリングを入れたり、selectboxでデータを切り替えたりする操作が、コールバックを書かずに実現できるのは楽でした。

pydeckとfoliumのどちらを使うかはデータの性質次第ですが、両方知っておくと表現の幅が広がります。

PLATEAUのデータは建物以外にも道路や土地利用など色々あるので、興味がある方は試してみてください。データはG空間情報センターからダウンロードできます。

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?