この記事は Qiita Advent Calendar 2025 - 時系列データ 19日目 の記事です。
はじめに
GPX(GPS Exchange Format)は、GPSデータを交換するためのXMLベースのフォーマットです。ランニングアプリ、サイクリングコンピュータ、カーナビなど、位置情報を記録するあらゆるデバイスで使われています。
この記事では、GPXファイルを「時系列データ」として捉え、どのような情報が格納されているのかを実データを使って解説します。
GPXフォーマットの基本構造
GPXは2002年にバージョン1.0がリリースされ、2004年に現行のバージョン1.1がリリースされました。GPSデータ交換のデファクトスタンダードとして広く使われています。
GPXには3つの主要なデータ型があります。
| データ型 | 要素名 | 用途 |
|---|---|---|
| Waypoint | <wpt> |
単一の地点(POI、目的地など) |
| Route | <rte> |
計画された経路(ナビゲーション用) |
| Track | <trk> |
実際の移動軌跡(GPSログ) |
時系列データとして最も重要なのは Track です。これは実際にGPSが記録した「いつ・どこにいたか」の履歴そのものです。
実データで見るGPXの構造
今回使用するのは、私が神戸から高知へのバス移動で記録したGPXデータです。記録にはiOSアプリ「myTracks - The GPS-Logger」を使用しました。
myTracksはiPhone/Apple Watch向けのGPSロガーアプリで、バックグラウンドでのGPS記録、オフライン地図表示、GPXエクスポートなどの機能を備えています。記録したトラックはGPX形式でエクスポートでき、他のアプリや分析ツールで利用できます。
GPXファイルの全体構造
<?xml version="1.0" encoding="utf-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1"
creator="myTracks"
version="1.1">
<trk>
<name>2025-12-11</name>
<extensions>
<!-- アプリ固有のメタデータ -->
</extensions>
<trkseg>
<trkpt lat="34.69479865599841" lon="135.1938309931624">
<ele>20.78706684271797</ele>
<time>2025-12-11T08:30:34.00000Z</time>
<extensions>
<mytracks:speed>0</mytracks:speed>
<mytracks:length>0</mytracks:length>
</extensions>
</trkpt>
<!-- 続くトラックポイント... -->
</trkseg>
</trk>
</gpx>
階層構造
gpx
└── trk (トラック)
├── name (トラック名)
├── extensions (拡張データ)
└── trkseg (トラックセグメント)
└── trkpt (トラックポイント) × N個
├── @lat (緯度) ※属性
├── @lon (経度) ※属性
├── ele (標高)
├── time (時刻)
└── extensions (拡張データ)
トラックポイント(trkpt)の詳細
トラックポイントは時系列データの1レコードに相当します。
必須属性:位置情報
<trkpt lat="34.69479865599841" lon="135.1938309931624">
| 属性 | 説明 | 範囲 |
|---|---|---|
lat |
緯度(10進度) | -90.0 〜 90.0 |
lon |
経度(10進度) | -180.0 〜 180.0 |
GPXの座標系はWGS84(World Geodetic System 1984)です。これはGPSやGoogle Mapsで使われている標準的な座標系です。
標高(ele)
<ele>20.78706684271797</ele>
メートル単位の標高です。GPSの標高精度は水平精度より低い(誤差が大きい)ことが多いですが、相対的な変化を見るには十分です。
実データでの標高変化を見てみましょう(時刻はJST):
| 時刻 (JST) | 地点 | 標高 (m) |
|---|---|---|
| 19:18 | 神戸出発 | 13.9 |
| 20:36 | 淡路島通過後 | 6.9 |
| 22:28 | 四国山地 | 449.0 ← 最高地点 |
| 23:21 | 高知到着 | 6.2 |
時刻(time)
<time>2025-12-11T08:30:34.00000Z</time>
ISO 8601形式のUTC時刻です。
-
T: 日付と時刻の区切り -
Z: UTC(協定世界時)を示す - ミリ秒まで記録可能
GPXの時刻は常にUTCです。日本時間(JST)に変換するには+9時間する必要があります。
拡張データ(extensions)
GPX 1.1では <extensions> 要素で独自のデータを追加できます。
<extensions>
<mytracks:speed>75.49873136981785</mytracks:speed>
<mytracks:gradient>0.00147669710978283</mytracks:gradient>
<mytracks:length>6.270596107777139</mytracks:length>
</extensions>
このデータは「myTracks」アプリが記録したもので:
| 要素 | 説明 | 単位 |
|---|---|---|
speed |
速度 | km/h |
gradient |
勾配 | 比率 |
length |
累積距離 | km |
拡張データの内容はアプリによって異なります。Garmin、Strava、myTracksなど、各アプリが独自の名前空間で追加データを記録します。
今回はアプリ独自の拡張データとして速度が記録されていましたが、速度を含まないデータもあります。その場合は、2点間の距離と時間差から事後計算する必要があります。
トラックセグメント(trkseg)の意味
<trkseg>
<trkpt>...</trkpt>
<trkpt>...</trkpt>
</trkseg>
トラックセグメントは「連続した記録」を表します。以下の場合に新しいセグメントが開始されます:
- GPS信号の喪失
- 記録の一時停止・再開
- 電源断
今回のデータは1つのセグメントのみですが、トンネル通過時や建物内での信号喪失があると複数セグメントに分けられるケースもあります。
時系列データとしての特徴
1. 不等間隔サンプリング
GPXのタイムスタンプ間隔は一定ではありません(JST表記):
19:18:20 → 19:23:28 (約5分)
19:23:28 → 19:28:27 (約5分)
19:28:27 → 19:33:31 (約5分)
...
20:59:01 → 21:18:49 (約20分) ← 室津SAで休憩中
移動中は約5分間隔で記録されていますが、休憩中は記録間隔が長くなっています。これはmyTracksアプリの仕様によるものでしょう。
時系列データとして扱う場合は、このような不等間隔も考慮する必要があります。
2. 多変量時系列
各時点で複数の値が記録されています(JST変換後):
| タイムスタンプ (JST) | 緯度 | 経度 | 標高 | 速度 |
|---|---|---|---|---|
| 19:28:27 | 34.6486 | 135.1366 | 15.0 | 75.5 |
| 19:33:31 | 34.6497 | 135.0822 | 50.1 | 58.9 |
| 19:42:33 | 34.6064 | 135.0121 | 74.6 | 53.2 |
これは典型的な多変量時系列データです。
3. セグメントによる欠損表現
通常の時系列では欠損値を NULL や NaN で表しますが、GPXでは「セグメントの分割」で表現します。
例えば、長いトンネルを通過してGPS信号が途切れた場合を考えます:
<trk>
<trkseg>
<!-- トンネル入口まで -->
<trkpt lat="34.5" lon="134.8"><time>21:00:00Z</time></trkpt>
<trkpt lat="34.5" lon="134.7"><time>21:05:00Z</time></trkpt>
</trkseg>
<trkseg>
<!-- トンネル出口から再開 -->
<trkpt lat="34.4" lon="134.5"><time>21:15:00Z</time></trkpt>
<trkpt lat="34.4" lon="134.4"><time>21:20:00Z</time></trkpt>
</trkseg>
</trk>
21:05〜21:15の10分間はデータが存在しません。通常のCSVなら:
timestamp,lat,lon
21:00:00,34.5,134.8
21:05:00,34.5,134.7
21:10:00,NaN,NaN ← 欠損を明示
21:15:00,34.4,134.5
しかしNaNだと「この区間を線形補間すべきか?」が曖昧です。GPXのセグメント分割は「この区間はデータがない(補間すべきでない)」ことをデータ構造として明示しています。
<trkseg> 要素はGPX仕様で定義されていますが、「いつセグメントを分割するか」はアプリの実装に依存します。GPS信号喪失を検知して自動分割するアプリもあれば、しないアプリもあります。今回使用したmyTracksでは1つのセグメントとして記録されていました。
Pythonでのパース例
標準ライブラリのみでパースできます:
import xml.etree.ElementTree as ET
from datetime import datetime
NS = {
'gpx': 'http://www.topografix.com/GPX/1/1',
'mytracks': 'http://mytracks.stichling.info/myTracksGPX/1/0'
}
tree = ET.parse('2025-12-11.gpx')
root = tree.getroot()
for trkpt in root.findall('.//gpx:trkpt', NS):
lat = float(trkpt.get('lat'))
lon = float(trkpt.get('lon'))
ele = trkpt.find('gpx:ele', NS)
# タグが存在し、かつ値が入っている場合のみ変換
elevation = float(ele.text) if ele is not None and ele.text else None
time_elem = trkpt.find('gpx:time', NS)
timestamp = datetime.fromisoformat(
time_elem.text.replace('Z', '+00:00')
) if time_elem is not None and time_elem.text else None
# 拡張データ
ext = trkpt.find('gpx:extensions', NS)
if ext is not None:
speed_elem = ext.find('mytracks:speed', NS)
speed = float(speed_elem.text) if speed_elem is not None else None
gpxpy ライブラリを使うと簡潔に書けます:
import gpxpy
with open('2025-12-11.gpx', 'r') as f:
gpx = gpxpy.parse(f)
for track in gpx.tracks:
for segment in track.segments:
for point in segment.points:
print(f"{point.time}: ({point.latitude}, {point.longitude})")
私のバス移動データの分析結果
今回のGPXデータを分析した結果:
基本統計
| 項目 | 値 |
|---|---|
| ポイント数 | 40(移動中のみ) |
| 出発時刻 | 19:18 JST |
| 到着時刻 | 23:21 JST |
| 所要時間 | 約4時間 |
| 直線距離 | 197.5 km |
| 走行距離 | 239.4 km |
距離の計算方法(Haversine公式)
GPXには距離データがないため、緯度・経度から計算します。地球を球体(半径 $R = 6371$ km)と近似し、2点間の大円距離を求めます。
d = 2R \cdot \arcsin\left(\sqrt{\sin^2\left(\frac{\phi_2 - \phi_1}{2}\right) + \cos(\phi_1) \cdot \cos(\phi_2) \cdot \sin^2\left(\frac{\lambda_2 - \lambda_1}{2}\right)}\right)
- $\phi_1, \phi_2$: 2点の緯度(ラジアン)
- $\lambda_1, \lambda_2$: 2点の経度(ラジアン)
- $d$: 大円距離(km)
Rustで書くとこうなります:
fn haversine(point1: (f64, f64), point2: (f64, f64)) -> f64 {
const R: f64 = 6371.0; // 地球の半径 (km)
let (lat1, lon1) = point1;
let (lat2, lon2) = point2;
let phi1 = lat1.to_radians();
let phi2 = lat2.to_radians();
let delta_phi = (lat2 - lat1).to_radians();
let delta_lambda = (lon2 - lon1).to_radians();
let a = (delta_phi / 2.0).sin().powi(2)
+ phi1.cos() * phi2.cos() * (delta_lambda / 2.0).sin().powi(2);
R * 2.0 * a.sqrt().asin()
}
/// 走行距離:各ポイント間の距離を累積
fn total_distance(points: &[(f64, f64)]) -> f64 {
points.windows(2)
.map(|pair| haversine(pair[0], pair[1]))
.sum()
}
-
直線距離:
haversine(起点, 終点) -
走行距離:
total_distance(&全ポイント)— 隣接ポイント間の距離を累積
標高プロファイル
| 項目 | 値 |
|---|---|
| 最低標高 | 1.9 m |
| 最高標高 | 449.0 m |
| 累積上昇 | 896.8 m |
速度分析
| 項目 | 値 |
|---|---|
| 最高速度 | 97.9 km/h |
| 平均速度 | 61.9 km/h |
速度データから休憩地点も検出できました(JST表記):
- 20:18頃: 速度 4.0 km/h(室津SA)
- 22:01頃: 速度 5.3 km/h(吉野川SA)
可視化
速度・標高の時系列グラフ
上段は速度の変化、下段は標高の変化を示しています。赤い縦線は休憩地点(室津SA、吉野川SA)、緑のハイライトは徳島の一般道区間です。
地図上の軌跡
各ポイントの色は速度を表しています。徳島で一般道を使っていることもわかります:
- 緑: 高速道路 (50+ km/h)
- 橙: 一般道 (10-50 km/h)
- 赤: 停止/休憩 (<10 km/h)
まとめ
位置情報フォーマットGPXを、時系列データとして捉えてみました。WGS84系であること、タイムスタンプが常にUTCであることや、セグメントによる表現などを理解しておくといいかもしれません。

