01 — Overview
概要
TCX(Training Center XML)は、もともと Garmin が策定した GPS アクティビティデータの XML フォーマットです。Google Health(Health Connect)からアクティビティ履歴をエクスポートすると、この TCX 形式でトラックポイント(時刻・緯度・経度・標高など)の系列データが出力されます。
このスクリプトは、その TCX ファイルから緯度・経度の系列を抽出し、実際の地図タイル(OpenStreetMap)の上にルートを描画して1枚の画像として保存します。処理の流れは以下の5段階です。
& XMLパース
の抽出
変換
ルート描画
として保存
座標変換がポイント: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等の背景地図タイルを取得・合成 | 実行時にネットワーク経由でタイルをダウンロード |
インストールコマンド
pip install matplotlib contextily pandas pyproj
contextily はネットワークアクセスが必要です。実行時に OpenStreetMap のタイルサーバーから画像をダウンロードするため、オフライン環境やネットワーク制限のあるVPS・コンテナでは事前のタイルキャッシュやプロキシ設定が必要になる場合があります。
インストール確認
# 各ライブラリのバージョンを確認 python3 -c "import matplotlib, contextily, pandas, pyproj; print(matplotlib.__version__, contextily.__version__, pandas.__version__, pyproj.__version__)"
Linux環境での補足(システム依存ライブラリ)
pyproj は内部で PROJ、contextily は GDAL 系の機能に依存することがあり、環境によっては事前にシステムパッケージが必要です。pip でビルドエラーが出た場合は以下を試してください。
# PROJ・GEOS 関連のシステムライブラリを導入
sudo apt update
sudo apt install -y proj-bin libproj-dev libgeos-dev
03 — Code Walkthrough
コード解説
スクリプト内のコメント番号 1.〜5. に沿って、各ブロックの処理内容を解説します。
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: プレフィックスを付けて名前空間を明示する必要があります。これを省略すると要素が一切ヒットせず空のリストになってしまいます。
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 を防いでいます。
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(経度が先)にしているのもこのためです。
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 はタイルプロバイダの指定で、ここを差し替えることで見た目の異なる地図に変更できます(拡張オプションで詳しく扱います)。
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 を使うとセグメント単位で色を変えられます。
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)')
静止画ではなく、ズーム・パンができるWebマップとして出力したい場合は folium(Leaflet.jsのPythonラッパー)が手軽です。出力はHTMLファイル1枚になり、ブラウザでそのまま開けます。
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ファイルを同じ地図上に重ねて表示することで「よく歩くエリア」を可視化できます。ファイル名のリストをループ処理し、それぞれ異なる色でプロットします。
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> 要素(各トラックポイントの時刻)も含まれています。これと緯度経度から総距離・経過時間・平均ペースを算出し、地図上にテキストボックスとして表示できます。
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))
geopy の geodesic は2点間の正確な距離(測地線距離)を計算します。隣接トラックポイント間の距離を全区間で合計すると総移動距離が得られます。
ctx.providers には OpenStreetMap 以外にも多数のタイルプロバイダが用意されています。ダークテーマの地図にしたい場合は CartoDB の Dark Matter が手軽です。
# ダークテーマの地図に変更 ctx.add_basemap(ax, source=ctx.providers.CartoDB.DarkMatter) # 地形(等高線)を強調した地図に変更 ctx.add_basemap(ax, source=ctx.providers.OpenTopoMap) # 利用可能な全プロバイダを確認 print(ctx.providers.keys())
エクスポート元によっては .gpx 形式の場合もあります。gpxpy ライブラリで GPX もパースできるようにし、さらに argparse でファイルパスをコマンドライン引数化すると汎用性が上がります。
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) ...
# 実行例
python3 make-map.py walk_2026-05-19.gpx