スマートフォン・タブレットからインターネットサーバーオペレーション

APPW.jp
 

TCX · GPS Route Cartography

TCXファイルからアクティビティマップを生成する

Google Health がエクスポートする .tcx 形式の位置情報履歴から、matplotlib + contextily で歩行・走行ルートを地図上に描画する Python スクリプトの解説です。

START END

01 — Overview

概要

TCX(Training Center XML)は、もともと Garmin が策定した GPS アクティビティデータの XML フォーマットです。Google Health(Health Connect)からアクティビティ履歴をエクスポートすると、この TCX 形式でトラックポイント(時刻・緯度・経度・標高など)の系列データが出力されます。

このスクリプトは、その TCX ファイルから緯度・経度の系列を抽出し、実際の地図タイル(OpenStreetMap)の上にルートを描画して1枚の画像として保存します。処理の流れは以下の5段階です。

📄TCX 読込
& XMLパース
📍緯度・経度
の抽出
🧭座標系の
変換
🗺️地図タイル上に
ルート描画
💾PNG画像
として保存
🧭

座標変換がポイント:GPSの緯度経度(WGS84 / EPSG:4326)と、地図タイルが使う Web メルカトル(EPSG:3857)は異なる座標系です。この変換を挟まないとルート線と地図タイルの位置がずれてしまいます。詳細はコード解説セクションで扱います。

02 — Setup

ライブラリのインストール

このスクリプトが依存する外部ライブラリは4つです。xml.etree.ElementTree は Python 標準ライブラリのためインストール不要です。

ライブラリ役割備考
xml.etree.ElementTree TCX(XML)ファイルのパース 標準ライブラリ(インストール不要)
pandas 座標データをDataFrameとして扱う
pyproj 座標系の変換(緯度経度 ⇄ Webメルカトル) 内部で PROJ ライブラリを使用
matplotlib ルート線・マーカーの描画、画像保存
contextily OpenStreetMap等の背景地図タイルを取得・合成 実行時にネットワーク経由でタイルをダウンロード

インストールコマンド

shell
pip install matplotlib contextily pandas pyproj
⚠️

contextily はネットワークアクセスが必要です。実行時に OpenStreetMap のタイルサーバーから画像をダウンロードするため、オフライン環境やネットワーク制限のあるVPS・コンテナでは事前のタイルキャッシュやプロキシ設定が必要になる場合があります。

インストール確認

shell
# 各ライブラリのバージョンを確認
python3 -c "import matplotlib, contextily, pandas, pyproj; print(matplotlib.__version__, contextily.__version__, pandas.__version__, pyproj.__version__)"

Linux環境での補足(システム依存ライブラリ)

pyproj は内部で PROJ、contextily は GDAL 系の機能に依存することがあり、環境によっては事前にシステムパッケージが必要です。pip でビルドエラーが出た場合は以下を試してください。

Ubuntu / Debian
# PROJ・GEOS 関連のシステムライブラリを導入
sudo apt update
sudo apt install -y proj-bin libproj-dev libgeos-dev

03 — Code Walkthrough

コード解説

スクリプト内のコメント番号 1.〜5. に沿って、各ブロックの処理内容を解説します。

1
TCXファイルの読み込みとパース
import xml.etree.ElementTree as ET

tcx_file = "exercise_tcx_file.tcx"
tree = ET.parse(tcx_file)
root = tree.getroot()

namespaces = {'ns': 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2'}

TCXファイルはXML形式なので、標準ライブラリの ElementTree でパースします。ポイントは namespaces 辞書です。TCXファイルの要素はすべて Garmin 独自の名前空間(xmlschemas/TrainingCenterDatabase/v2)に属しているため、findall() でタグを検索する際は ns: プレフィックスを付けて名前空間を明示する必要があります。これを省略すると要素が一切ヒットせず空のリストになってしまいます。

2
緯度・経度データの抽出
latitudes = []
longitudes = []

for trackpoint in root.findall('.//ns:Trackpoint', namespaces):
    position = trackpoint.find('ns:Position', namespaces)
    if position is not None:
        lat = position.find('ns:LatitudeDegrees', namespaces)
        lon = position.find('ns:LongitudeDegrees', namespaces)
        if lat is not None and lon is not None:
            latitudes.append(float(lat.text))
            longitudes.append(float(lon.text))

.//ns:Trackpoint は「ルート内のどの階層にあっても良いので全ての Trackpoint 要素を探す」という XPath 表現です。各トラックポイントは <Position> 子要素を持つとは限らない(GPS信号ロスト時など)ため、position is not None でガードしてから緯度経度を取り出しています。同様に lat/lon 個別の存在チェックも行うことで、欠損データによる AttributeError を防いでいます。

3
座標の変換(EPSG:4326 → EPSG:3857)
from pyproj import Transformer

transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
x_coords, y_coords = transformer.transform(longitudes, latitudes)

df = pd.DataFrame({'X': x_coords, 'Y': y_coords})

GPSの緯度経度はそのままでは地図タイルに正しく重ねられません。EPSG:4326(WGS84、緯度・経度による座標系)とEPSG:3857(Web メルカトル、Google Maps や OpenStreetMap タイルが採用する平面座標系)は別物だからです。

always_xy=True は地味に重要なオプションです。PROJ のバージョンによって軸の順序(緯度が先か経度が先か)の扱いが歴史的に揺れていたため、これを指定することで常に「経度→緯度(X→Y)」の順で統一されます。引数の渡し方を longitudes, latitudes(経度が先)にしているのもこのためです。

4
地図の描画設定
fig, ax = plt.subplots(figsize=(10, 10))

ax.plot(df['X'], df['Y'], color='red', linewidth=3, label='Route')

ax.scatter(df['X'].iloc[0], df['Y'].iloc[0], color='green', s=100, label='Start', zorder=5)
ax.scatter(df['X'].iloc[-1], df['Y'].iloc[-1], color='black', s=100, label='End', zorder=5)

ctx.add_basemap(ax, source=ctx.providers.OpenStreetMap.Mapnik)

ax.plot() がルート線、ax.scatter() がスタート(緑)・ゴール(黒)のマーカーです。zorder=5 はマーカーを地図タイルやルート線より手前に描画するための重なり順指定で、これがないとマーカーが地図タイルの下に隠れてしまうことがあります。

ctx.add_basemap() が背景に実際の地図タイルを合成する処理です。source に渡している ctx.providers.OpenStreetMap.Mapnik はタイルプロバイダの指定で、ここを差し替えることで見た目の異なる地図に変更できます(拡張オプションで詳しく扱います)。

5
画像として保存
ax.set_title("Activity Route Map", fontsize=16)
ax.axis('off')
plt.legend(loc='upper left', bbox_to_anchor=(1.05, 1))

output_filename = "route_map.png"
plt.savefig(output_filename, bbox_inches='tight', dpi=150)
plt.close()

ax.axis('off') で緯度経度の目盛りを非表示にし、地図らしい見た目にしています。bbox_to_anchor=(1.05, 1) は凡例(凡例ボックス)をグラフ領域の右外側に配置する指定です。1.0を超える値は「軸の外側」を意味します。

savefig()bbox_inches='tight' は、凡例を外に出したことで生じる余白を自動的にトリミングするオプションです。これを忘れると凡例が画像から見切れてしまいます。dpi=150 は解像度の指定で、印刷用途であれば 300 まで上げると鮮明になります。

04 — Extensions

コードの拡張オプション

基本のスクリプトを土台に、用途に応じて拡張できるアイデアをまとめました。難易度の目安も併記しています。

🎨 標高・速度によるカラーグラデーション 難易度: 中

単色のルート線ではなく、<AltitudeMeters> や時刻差から算出した速度に応じて線の色を変化させると、坂道やペースの変化が一目でわかります。LineCollection を使うとセグメント単位で色を変えられます。

python
from matplotlib.collections import LineCollection
import numpy as np

points = np.array([df['X'], df['Y']]).T.reshape(-1, 1, 2)
segments = np.concatenate([points[:-1], points[1:]], axis=1)

# altitudes は Trackpoint から抽出した標高リスト
lc = LineCollection(segments, cmap='plasma', linewidth=3)
lc.set_array(np.array(altitudes[:-1]))
ax.add_collection(lc)
fig.colorbar(lc, ax=ax, label='Altitude (m)')
🖱️ インタラクティブ地図化(folium) 難易度: 易

静止画ではなく、ズーム・パンができるWebマップとして出力したい場合は folium(Leaflet.jsのPythonラッパー)が手軽です。出力はHTMLファイル1枚になり、ブラウザでそのまま開けます。

python
import folium

m = folium.Map(location=[latitudes[0], longitudes[0]], zoom_start=15)
coords = list(zip(latitudes, longitudes))
folium.PolyLine(coords, color='red', weight=4).add_to(m)
folium.Marker(coords[0], tooltip='Start', icon=folium.Icon(color='green')).add_to(m)
folium.Marker(coords[-1], tooltip='End', icon=folium.Icon(color='black')).add_to(m)
m.save('route_map.html')
💡

folium はそのまま緯度経度(EPSG:4326)を渡せるので、座標変換のステップ(ブロック3)が不要になります。

🗂️ 複数アクティビティの重ね描画 難易度: 中

1週間分・1ヶ月分など、複数のTCXファイルを同じ地図上に重ねて表示することで「よく歩くエリア」を可視化できます。ファイル名のリストをループ処理し、それぞれ異なる色でプロットします。

python
tcx_files = ["walk_0501.tcx", "walk_0508.tcx", "walk_0515.tcx"]
colors = ['red', 'cyan', 'yellow']

fig, ax = plt.subplots(figsize=(10, 10))
for path, color in zip(tcx_files, colors):
    lats, lons = extract_coords(path)  # ブロック1・2を関数化したもの
    x, y = transformer.transform(lons, lats)
    ax.plot(x, y, color=color, linewidth=2, alpha=0.8, label=path)
📊 距離・時間・ペースの計算と表示 難易度: 中

TCXには <Time> 要素(各トラックポイントの時刻)も含まれています。これと緯度経度から総距離・経過時間・平均ペースを算出し、地図上にテキストボックスとして表示できます。

python
from geopy.distance import geodesic

total_km = sum(
    geodesic((latitudes[i], longitudes[i]), (latitudes[i+1], longitudes[i+1])).km
    for i in range(len(latitudes) - 1)
)
ax.text(0.02, 0.02, f"距離: {total_km:.2f} km",
        transform=ax.transAxes, fontsize=12, color='white',
        bbox=dict(facecolor='black', alpha=0.6))

geopygeodesic は2点間の正確な距離(測地線距離)を計算します。隣接トラックポイント間の距離を全区間で合計すると総移動距離が得られます。

🌍 地図プロバイダ・テイストの変更 難易度: 易

ctx.providers には OpenStreetMap 以外にも多数のタイルプロバイダが用意されています。ダークテーマの地図にしたい場合は CartoDB の Dark Matter が手軽です。

python
# ダークテーマの地図に変更
ctx.add_basemap(ax, source=ctx.providers.CartoDB.DarkMatter)

# 地形(等高線)を強調した地図に変更
ctx.add_basemap(ax, source=ctx.providers.OpenTopoMap)

# 利用可能な全プロバイダを確認
print(ctx.providers.keys())
📁 GPX形式への対応・ファイル指定の引数化 難易度: 高

エクスポート元によっては .gpx 形式の場合もあります。gpxpy ライブラリで GPX もパースできるようにし、さらに argparse でファイルパスをコマンドライン引数化すると汎用性が上がります。

python
import argparse
import gpxpy

parser = argparse.ArgumentParser()
parser.add_argument("input_file", help="TCX または GPX ファイルのパス")
args = parser.parse_args()

if args.input_file.endswith(".gpx"):
    with open(args.input_file) as f:
        gpx = gpxpy.parse(f)
    latitudes = [p.latitude for t in gpx.tracks for s in t.segments for p in s.points]
    longitudes = [p.longitude for t in gpx.tracks for s in t.segments for p in s.points]
else:
    # 既存の TCX パース処理(ブロック1・2)
    ...
shell
# 実行例
python3 make-map.py walk_2026-05-19.gpx

『TCXルートマップ生成 解説』を公開しました。