gpx-player 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gpx_player-0.1.0.dist-info/METADATA +171 -0
- gpx_player-0.1.0.dist-info/RECORD +11 -0
- gpx_player-0.1.0.dist-info/WHEEL +5 -0
- gpx_player-0.1.0.dist-info/entry_points.txt +3 -0
- gpx_player-0.1.0.dist-info/licenses/LICENSE +21 -0
- gpx_player-0.1.0.dist-info/top_level.txt +5 -0
- gpx_utils.py +38 -0
- main.py +192 -0
- openseamap.py +185 -0
- utils.py +94 -0
- validator.py +200 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gpx-player
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Visualise & animate GPX race tracks.
|
|
5
|
+
Author: Yury Kirienko
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2023 Yury Kirienko
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Requires-Python: >=3.8
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: folium
|
|
32
|
+
Requires-Dist: jinja2
|
|
33
|
+
Requires-Dist: matplotlib
|
|
34
|
+
Requires-Dist: pytz
|
|
35
|
+
Requires-Dist: lxml
|
|
36
|
+
Requires-Dist: numpy
|
|
37
|
+
Requires-Dist: gpxpy
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# gpx-player
|
|
41
|
+
## GPX Race Visualizer
|
|
42
|
+
|
|
43
|
+
GPX Race Visualizer is a Python script to visualize the progression of multiple GPS tracks (e.g., from a race) on a 2D map.
|
|
44
|
+
It takes as input one or more GPX files and creates an animation showing the movement of each track over time.
|
|
45
|
+
This is a simple, open-source alternative to features like Strava's Flyby, which require an account and can have privacy issues.
|
|
46
|
+
|
|
47
|
+
### Modes
|
|
48
|
+
The player supports two modes:
|
|
49
|
+
#### 1. "Video" mode
|
|
50
|
+
Produce an `MP4` or a `GIF` file showing how the situation developed.
|
|
51
|
+
For sailing races, it also calculates the distance covered after the 'start' signal and the current speed.
|
|
52
|
+
##### Example:
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
#### 2. Map mode
|
|
56
|
+
Displays the track on OpenSeaMap.
|
|
57
|
+
You can see the full tracks with colour-coded speeds,
|
|
58
|
+
and you can 'play' the tracks and see the markers move around the map.
|
|
59
|
+
|
|
60
|
+
#### Example:
|
|
61
|
+
Since GitHub Markdown doesn't allow embedding HTML,
|
|
62
|
+
you can see an [interactive example](https://kirienko.github.io/static/GinSul-2024.html) here.
|
|
63
|
+
|
|
64
|
+
Screenshot:
|
|
65
|
+
[](https://kirienko.github.io/static/GinSul-2024.html)
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
Clone the repository and install the required dependencies with pip:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/kirienko/gpx-player.git
|
|
72
|
+
cd gpx-player
|
|
73
|
+
pip install -r requirements.txt
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
To run the script producing `mp4`, pass one or more GPX file paths as command-line arguments:
|
|
78
|
+
```bash
|
|
79
|
+
python main.py example-data/track1.gpx example-data/track2.gpx
|
|
80
|
+
```
|
|
81
|
+
To get a sea map, run the `openseamap.py`:
|
|
82
|
+
```bash
|
|
83
|
+
python openseamap.py --title 'Gin Sul Regatta 2024' --names Alex Yury Richard \
|
|
84
|
+
--files example-data/osm-demo-Alex.gpx example-data/osm-demo-Richard.gpx \
|
|
85
|
+
example-data/osm-demo-Yury.gpx
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A more sophisticated example, that produced a video above:
|
|
89
|
+
```bash
|
|
90
|
+
python main.py example-data/track1.gpx example-data/track2.gpx example-data/track3.gpx \
|
|
91
|
+
--start 2023-07-01T10:53:00+0000 \
|
|
92
|
+
--names "Mr. Pommeroy" "Miss Sophie" "Sir Toby²" \
|
|
93
|
+
--title "Elbe-Damm Regatta (01.07.2023), Race 1" \
|
|
94
|
+
--race_start 2023-07-01T10:58:00+0000 --marks example-data/marks.txt -g
|
|
95
|
+
```
|
|
96
|
+
### Additional parameters:
|
|
97
|
+
* `--title` or `-t`: The title of the video
|
|
98
|
+
* `--start` or `-s`: start time in the format `2023-06-30T12:53:00+0200`, all points _before_ that will not be plotted
|
|
99
|
+
* `--end` or `-e`: end time in the format `2023-06-30T13:53:00+0200`, all points _after_ that will not be plotted
|
|
100
|
+
* `--names` or `-n`: names of the participants (otherwise the file names will be used in the legend)
|
|
101
|
+
* `--race_start`, `-r`: Race start time in the format `YYYY-MM-DDTHH:MM:SS%z`, e.g. `2023-07-01T12:29:00+0200`
|
|
102
|
+
* `--names` or `-n`: Names of the participants
|
|
103
|
+
* `--marks`or`-m`: The file with the static marks to put onto the map. One pair of coordinates per line, see below.
|
|
104
|
+
* `--gif` or `-g`: Save as GIF moving picture instead of MP4
|
|
105
|
+
* `--timezone` or `-tz`: Local timezone to use for processing timestamps, e.g. `America/Los_Angeles`, see [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (default: `Europe/Berlin`).
|
|
106
|
+
|
|
107
|
+
## Marks
|
|
108
|
+
The script also supports visualizing predefined marks on the map, which can be useful for events like sailing regattas.
|
|
109
|
+
The marks are defined as a list of (latitude, longitude) tuples in a separate text file and can be added to the script as follows:
|
|
110
|
+
```
|
|
111
|
+
53.542484632728, 9.801163896918299
|
|
112
|
+
53.542997846049374, 9.80611324310303
|
|
113
|
+
53.54823800356785, 9.812614917755129
|
|
114
|
+
53.54921647691311, 9.807373881340029
|
|
115
|
+
53.54508251196638, 9.80433225631714
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Getting GPX Files
|
|
119
|
+
|
|
120
|
+
GPX files can be obtained from several GPS-tracking services:
|
|
121
|
+
* Strava: Go to the activity page and click on the wrench icon. Then select "Export GPX".
|
|
122
|
+
* Garmin Connect: Open the activity, go to the gear icon and select "Export to GPX".
|
|
123
|
+
* Endomondo: From the workout page, click the three-dot menu icon and select "Export". Then choose "GPX".
|
|
124
|
+
|
|
125
|
+
## GPX Validation
|
|
126
|
+
|
|
127
|
+
For the `gpx-player` to work properly, it needs the correct GPX files.
|
|
128
|
+
You can verify that you are inputting the correct file by using the special validator
|
|
129
|
+
included in this package.
|
|
130
|
+
|
|
131
|
+
The `validator.py` script is a command-line utility and module for validating GPX files.
|
|
132
|
+
It checks for XML schema conformance and timestamp consistency,
|
|
133
|
+
supporting both strict and lenient modes.
|
|
134
|
+
Errors are raised as `GPXValidationError` which can be caught in Python code.
|
|
135
|
+
To run as a CLI tool, use:
|
|
136
|
+
```bash
|
|
137
|
+
python validator.py path/to/yourfile.gpx --strict
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The `--strict` parameter is optional. In most cases you do not need it,
|
|
141
|
+
because files that strictly correspond to the GPX schema are rare.
|
|
142
|
+
For example, almost all modern files contain coordinates, elevations and time stamps
|
|
143
|
+
with more decimal places than originally planned.
|
|
144
|
+
|
|
145
|
+
Also, to better understand your GPX file, you can use the `gpxinfo` console command
|
|
146
|
+
that comes with `gpxpy`. If you are already using the player, you have it:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
$ gpxinfo example-data/osm_track1.gpx
|
|
150
|
+
File: example-data/osm_track1.gpx
|
|
151
|
+
Waypoints: 0
|
|
152
|
+
Routes: 0
|
|
153
|
+
Length 2D: 9.621km
|
|
154
|
+
Length 3D: 9.648km
|
|
155
|
+
Moving time: 01:05:22
|
|
156
|
+
Stopped time: n/a
|
|
157
|
+
Max speed: 3.12m/s = 11.22km/h
|
|
158
|
+
Avg speed: 2.46m/s = 8.85km/h
|
|
159
|
+
Total uphill: 97.20m
|
|
160
|
+
Total downhill: 98.40m
|
|
161
|
+
Started: 2024-07-24 15:59:05+00:00
|
|
162
|
+
Ended: 2024-07-24 17:04:27+00:00
|
|
163
|
+
Points: 776
|
|
164
|
+
Avg distance between points: 12.40m
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Support
|
|
169
|
+
Now you can buy me a coffee to encourage further development!
|
|
170
|
+
|
|
171
|
+
[](https://www.buymeacoffee.com/kirienko)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
gpx_utils.py,sha256=8Q5GeZuNl4W0RDkrobIMMrUip51pBLOhuDAD3bivnuc,1341
|
|
2
|
+
main.py,sha256=5hkqeTbwYuWqiEyUeMO-j4cbGjYPe2LDzCYUja1rMms,8645
|
|
3
|
+
openseamap.py,sha256=X4ecjuZ8juHYaumhH9xBbH-0Hphpurw7FpHOp_Zs2Lc,7706
|
|
4
|
+
utils.py,sha256=mpSI0usPsjlLjRqj7S8UrQv-eNjQ4tGlWzxAu946iU8,3039
|
|
5
|
+
validator.py,sha256=0sIDDoD4bxRD3TbzNg96rV8JJcbKzXWthG7Xd4tEgpc,7878
|
|
6
|
+
gpx_player-0.1.0.dist-info/licenses/LICENSE,sha256=Z2JusFyF7CSjDcNSGCsLA5zEK4Q2FcsS_0Owy7wEZME,1070
|
|
7
|
+
gpx_player-0.1.0.dist-info/METADATA,sha256=XRj0aEBTEp32xpAyaRD5lAGTvt16vKHq5_vnVRa1_Xc,7269
|
|
8
|
+
gpx_player-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
gpx_player-0.1.0.dist-info/entry_points.txt,sha256=JO1WVud0YaaRmPNvpNMU3M7K8geuvC-LSskt3Df1AU4,71
|
|
10
|
+
gpx_player-0.1.0.dist-info/top_level.txt,sha256=Z74aRgq8oA3AMS6MWoJ7kjLvXPc0T4B_oZ_Vlsov0hw,42
|
|
11
|
+
gpx_player-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Yury Kirienko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gpx_utils.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import gpxpy
|
|
2
|
+
import gpxpy.gpx
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
def cut_gpx_file(file_path, timestamp, cut_type):
|
|
6
|
+
"""
|
|
7
|
+
Cuts a GPX file at the point closest to the given timestamp.
|
|
8
|
+
|
|
9
|
+
:param file_path: Path to the original GPX file.
|
|
10
|
+
:param timestamp: Timestamp as a datetime instance or string in the format 'YYYY-MM-DDTHH:MM:SS%z'.
|
|
11
|
+
:param cut_type: 'start' to keep everything after the timestamp, 'end' to keep everything before.
|
|
12
|
+
:return: Path to the new GPX file.
|
|
13
|
+
"""
|
|
14
|
+
if isinstance(timestamp, str):
|
|
15
|
+
timestamp = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z')
|
|
16
|
+
|
|
17
|
+
with open(file_path, 'r') as gpx_file:
|
|
18
|
+
gpx = gpxpy.parse(gpx_file)
|
|
19
|
+
|
|
20
|
+
new_gpx = gpxpy.gpx.GPX()
|
|
21
|
+
|
|
22
|
+
for track in gpx.tracks:
|
|
23
|
+
new_track = gpxpy.gpx.GPXTrack()
|
|
24
|
+
new_gpx.tracks.append(new_track)
|
|
25
|
+
|
|
26
|
+
for segment in track.segments:
|
|
27
|
+
new_segment = gpxpy.gpx.GPXTrackSegment()
|
|
28
|
+
new_track.segments.append(new_segment)
|
|
29
|
+
|
|
30
|
+
for point in segment.points:
|
|
31
|
+
if (cut_type == 'start' and point.time >= timestamp) or (cut_type == 'end' and point.time <= timestamp):
|
|
32
|
+
new_segment.points.append(point)
|
|
33
|
+
|
|
34
|
+
new_file_path = file_path.replace('.gpx', '_cut.gpx')
|
|
35
|
+
with open(new_file_path, 'w') as f:
|
|
36
|
+
f.write(new_gpx.to_xml())
|
|
37
|
+
|
|
38
|
+
return new_file_path
|
main.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import os.path as op
|
|
4
|
+
from math import atan2, degrees
|
|
5
|
+
|
|
6
|
+
import gpxpy
|
|
7
|
+
import matplotlib.animation as animation
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import pytz
|
|
10
|
+
from matplotlib.ticker import FuncFormatter, MultipleLocator
|
|
11
|
+
|
|
12
|
+
from utils import (format_func, gen_arrow_head_marker,
|
|
13
|
+
km_to_nm, slug, timedelta_to_hms)
|
|
14
|
+
|
|
15
|
+
base_path = '.'
|
|
16
|
+
|
|
17
|
+
# Define argument parser
|
|
18
|
+
parser = argparse.ArgumentParser()
|
|
19
|
+
parser.add_argument('files', nargs='+', help='GPX files to process')
|
|
20
|
+
parser.add_argument('--title', '-t', help='The title of the video')
|
|
21
|
+
parser.add_argument('--start', '-s', type=lambda s: dt.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S%z'), help='Start time (YYYY-MM-DDTHH:MM:SS%z)')
|
|
22
|
+
parser.add_argument('--end', '-e', type=lambda s: dt.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S%z'), help='End time (YYYY-MM-DDTHH:MM:SS%z)')
|
|
23
|
+
parser.add_argument('--race_start', '-r', type=lambda s: dt.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S%z'),
|
|
24
|
+
help='Race start time (YYYY-MM-DDTHH:MM:SS%z)')
|
|
25
|
+
parser.add_argument('--names', '-n', nargs='+', help='Names of the participants')
|
|
26
|
+
parser.add_argument('--marks', '-m', help='The file with the static marks to put onto the map. One pair of coordinates per line')
|
|
27
|
+
parser.add_argument('--gif', '-g', action='store_true', help='Save as GIF moving picture instead of MP4')
|
|
28
|
+
parser.add_argument('--timezone', '-tz', default='Europe/Berlin', help='Timezone to use for processing timestamps')
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
local_tz = pytz.timezone(args.timezone)
|
|
31
|
+
|
|
32
|
+
start_time = args.start.astimezone(local_tz) if args.start else None
|
|
33
|
+
end_time = args.end.astimezone(local_tz) if args.end else None
|
|
34
|
+
race_start = args.race_start.astimezone(local_tz) if args.race_start else None
|
|
35
|
+
|
|
36
|
+
tracks = []
|
|
37
|
+
points_list = []
|
|
38
|
+
|
|
39
|
+
# Parse the GPX files
|
|
40
|
+
for filename in args.files:
|
|
41
|
+
with open(op.join(base_path, filename), 'r') as gpx_file:
|
|
42
|
+
gpx = gpxpy.parse(gpx_file)
|
|
43
|
+
# all timestamps show the local time from this point on:
|
|
44
|
+
points = [(point.latitude, point.longitude, point.time.astimezone(local_tz)) for track in gpx.tracks for segment in track.segments for
|
|
45
|
+
point in segment.points]
|
|
46
|
+
if start_time:
|
|
47
|
+
points = [(lat, lon, time) for (lat, lon, time) in points if time >= start_time]
|
|
48
|
+
if end_time:
|
|
49
|
+
points = [(lat, lon, time) for (lat, lon, time) in points if time <= end_time]
|
|
50
|
+
points_list.append(points)
|
|
51
|
+
|
|
52
|
+
title = args.title if args.title else ''
|
|
53
|
+
|
|
54
|
+
# Initialize the figure and axis
|
|
55
|
+
fig, ax = plt.subplots()
|
|
56
|
+
ax.set_title(title)
|
|
57
|
+
# Apply the custom formatter to the x and y axes
|
|
58
|
+
ax.xaxis.set_major_formatter(FuncFormatter(format_func))
|
|
59
|
+
ax.yaxis.set_major_formatter(FuncFormatter(format_func))
|
|
60
|
+
ax.xaxis.set_major_locator(MultipleLocator(1/120)) # locator at every 1/60/2 degrees = 30"
|
|
61
|
+
ax.yaxis.set_major_locator(MultipleLocator(1/360)) # locator at every 1/60/0 degrees = 10"
|
|
62
|
+
ax.tick_params(axis='both', labelsize=5)
|
|
63
|
+
|
|
64
|
+
margin = 0.001 # increase to zoom out
|
|
65
|
+
lat_min = min(point[0] for point in [point for points in points_list for point in points]) - margin
|
|
66
|
+
lat_max = max(point[0] for point in [point for points in points_list for point in points]) + margin
|
|
67
|
+
lon_min = min(point[1] for point in [point for points in points_list for point in points]) - margin
|
|
68
|
+
lon_max = max(point[1] for point in [point for points in points_list for point in points]) + margin
|
|
69
|
+
|
|
70
|
+
ax.set_xlim(lon_min, lon_max)
|
|
71
|
+
ax.set_ylim(lat_min, lat_max)
|
|
72
|
+
|
|
73
|
+
# Initialize the plot with the first data
|
|
74
|
+
lines = [ax.plot(points[0][1], points[0][0], '-', linewidth='0.8', label=filename)[0]
|
|
75
|
+
for points, filename in zip(points_list, args.files)]
|
|
76
|
+
|
|
77
|
+
marker, scale = gen_arrow_head_marker(0)
|
|
78
|
+
markersize = 10
|
|
79
|
+
heads = [ax.plot(l[0][0], l[0][1], marker=marker, markersize=markersize, color=lines[i].get_color())[0]
|
|
80
|
+
for i, l in enumerate(points_list)]
|
|
81
|
+
|
|
82
|
+
if args.names:
|
|
83
|
+
for i, name in enumerate(args.names):
|
|
84
|
+
lines[i].set_label(name)
|
|
85
|
+
ax.text(0.7, 0.95 - 0.03*i,
|
|
86
|
+
name[:13]+'...' if len(name) > 13 else f'{name:>13}', transform=ax.transAxes,
|
|
87
|
+
fontsize=6)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Add time labels
|
|
91
|
+
time_text = ax.text(0.30, 0.95, '', transform=ax.transAxes)
|
|
92
|
+
|
|
93
|
+
# Static points
|
|
94
|
+
if args.marks:
|
|
95
|
+
with open(args.marks) as fd:
|
|
96
|
+
marks = [line.strip().split(',') for line in fd.readlines()]
|
|
97
|
+
for i, (lat, lon) in enumerate(marks, 1):
|
|
98
|
+
ax.plot(float(lon), float(lat), marker='o', markersize=5, color='orange')
|
|
99
|
+
|
|
100
|
+
# Initialize counters in number of input files
|
|
101
|
+
counters = [0] * len(points_list)
|
|
102
|
+
dist_counter = [0.0] * len(points_list)
|
|
103
|
+
speeds = [0.0] * len(points_list)
|
|
104
|
+
|
|
105
|
+
ax_dist = [ax.text(0.83, 0.95 - 0.03*i, '', fontsize=7, transform=ax.transAxes) for i in range(len(points_list))]
|
|
106
|
+
ax_speed = [ax.text(0.93, 0.95 - 0.03*i, '', fontsize=7, transform=ax.transAxes) for i in range(len(points_list))]
|
|
107
|
+
|
|
108
|
+
# Update function for animation
|
|
109
|
+
def update(current_time, points_list, lines, heads, time_text):
|
|
110
|
+
# Only advance in points_list if their time is less than or equal to the current time
|
|
111
|
+
# iterate over points in each file
|
|
112
|
+
for idx, (points, counter, line) in enumerate(zip(points_list, counters, lines)):
|
|
113
|
+
pre_start_counter = 0
|
|
114
|
+
while counter < len(points) and points[counter][2] <= current_time:
|
|
115
|
+
if race_start or start_time:
|
|
116
|
+
if counter > 0 and points[counter][2] >= (race_start or start_time):
|
|
117
|
+
# Calculate the distance between two consecutive points and add it to dist_counter
|
|
118
|
+
lat1, lon1, t1 = points[counter-1]
|
|
119
|
+
lat2, lon2, t2 = points[counter]
|
|
120
|
+
# gpxpy.geo.haversine_distance returns meters
|
|
121
|
+
dst = gpxpy.geo.haversine_distance(lat1, lon1, lat2, lon2) / 1000 # in km
|
|
122
|
+
dist_counter[idx] += dst
|
|
123
|
+
speeds[idx] = km_to_nm(dst)/(t2-t1).total_seconds()*3600
|
|
124
|
+
elif counter > 0 and points[counter][2] < (race_start or start_time):
|
|
125
|
+
pre_start_counter += 1
|
|
126
|
+
counter += 1
|
|
127
|
+
# Update lines
|
|
128
|
+
if race_start:
|
|
129
|
+
try:
|
|
130
|
+
# `start_counter` = 0 before start
|
|
131
|
+
# = counter - 60 after start
|
|
132
|
+
start_counter = 0 if points[counter][2] < race_start \
|
|
133
|
+
else max(pre_start_counter, counter-60)
|
|
134
|
+
except IndexError:
|
|
135
|
+
start_counter = counter-60
|
|
136
|
+
else:
|
|
137
|
+
start_counter = 0
|
|
138
|
+
|
|
139
|
+
line.set_data([point[1] for point in points[start_counter:counter]], [point[0] for point in points[start_counter:counter]])
|
|
140
|
+
# plot the marker
|
|
141
|
+
heads[idx].set_data([points[counter-1][1]], [points[counter-1][0]])
|
|
142
|
+
# Calculate the marker rotation angle
|
|
143
|
+
try:
|
|
144
|
+
y1, x1 = points[counter-2][1], points[counter-2][0]
|
|
145
|
+
y2, x2 = points[counter-1][1], points[counter-1][0]
|
|
146
|
+
theta = degrees(atan2(y2 - y1, x2 - x1))
|
|
147
|
+
marker, scale = gen_arrow_head_marker(90-theta)
|
|
148
|
+
heads[idx].set_marker(marker)
|
|
149
|
+
except IndexError:
|
|
150
|
+
heads[idx].set_marker('o')
|
|
151
|
+
# Update distance/speed table
|
|
152
|
+
ax_dist[idx].set_text(f'{km_to_nm(dist_counter[idx]):.2f} nm') # Update the displayed distance
|
|
153
|
+
ax_speed[idx].set_text(f'{speeds[idx]:.1f} kt') # Update the displayed speed
|
|
154
|
+
dist_counter[idx] = 0.
|
|
155
|
+
|
|
156
|
+
# Update time text
|
|
157
|
+
if race_start:
|
|
158
|
+
diff_time = current_time - race_start
|
|
159
|
+
minutes = diff_time.total_seconds() / 60
|
|
160
|
+
if minutes < 0:
|
|
161
|
+
time_text.set_text(f"Time to start: {timedelta_to_hms(-diff_time)}")
|
|
162
|
+
time_text.set_color('red')
|
|
163
|
+
else:
|
|
164
|
+
time_text.set_text(f"Time of the race: {timedelta_to_hms(diff_time)}")
|
|
165
|
+
time_text.set_color('black')
|
|
166
|
+
|
|
167
|
+
else:
|
|
168
|
+
time_text.set_text(f'Time: {points[counter-1][2]:%Y-%m-%d %H:%M:%S}' if counter > 0 else '')
|
|
169
|
+
return [*lines, *heads, time_text, *ax_dist, *ax_speed]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Get common timeline
|
|
173
|
+
# Flatten the list of points from all tracks
|
|
174
|
+
flat_points = [point for points in points_list
|
|
175
|
+
for point in points]
|
|
176
|
+
# Extract timestamps
|
|
177
|
+
timestamps = [point[2] for point in flat_points]
|
|
178
|
+
# Create a sorted set of unique timestamps
|
|
179
|
+
timeline = sorted(set(timestamps))
|
|
180
|
+
|
|
181
|
+
ax.legend(loc='lower right', fontsize=8)
|
|
182
|
+
|
|
183
|
+
ani = animation.FuncAnimation(fig, update, frames=timeline, fargs=[points_list, lines, heads, time_text],
|
|
184
|
+
interval=25, blit=True)
|
|
185
|
+
|
|
186
|
+
# # Save the animation as a movie
|
|
187
|
+
if args.gif:
|
|
188
|
+
ani.save(f"{slug(title or 'untitled')}.gif")
|
|
189
|
+
else:
|
|
190
|
+
ani.save(f"{slug(title or 'untitled')}.mp4", fps=10)
|
|
191
|
+
|
|
192
|
+
plt.show()
|
openseamap.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
|
|
5
|
+
import folium
|
|
6
|
+
import gpxpy
|
|
7
|
+
import gpxpy.gpx
|
|
8
|
+
import jinja2
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
from jinja2 import Environment, FileSystemLoader
|
|
11
|
+
|
|
12
|
+
from utils import track_serializer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_arguments():
|
|
16
|
+
parser = argparse.ArgumentParser(description="Animate GPX tracks on an OpenSeaMap.")
|
|
17
|
+
parser.add_argument('--files', nargs='+', required=True, help='GPX files to process')
|
|
18
|
+
parser.add_argument('--names', '-n', nargs='+', help='Names of the participants')
|
|
19
|
+
parser.add_argument('--max-speed', '-ms', type=float, default=12, help='Maximum speed in knots (default: 12)')
|
|
20
|
+
parser.add_argument('--title', '-t', help='The title of the page')
|
|
21
|
+
return parser.parse_args()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_gpx(file_path: str) -> List[dict]:
|
|
25
|
+
with open(file_path, 'r') as gpx_file:
|
|
26
|
+
gpx = gpxpy.parse(gpx_file)
|
|
27
|
+
all_tracks = []
|
|
28
|
+
for track in gpx.tracks:
|
|
29
|
+
points = [
|
|
30
|
+
{'lat': point.latitude, 'lon': point.longitude, 'time': point.time}
|
|
31
|
+
for segment in track.segments
|
|
32
|
+
for point in segment.points
|
|
33
|
+
]
|
|
34
|
+
all_tracks.append({
|
|
35
|
+
'name': track.name,
|
|
36
|
+
'description': track.description,
|
|
37
|
+
'points': points,
|
|
38
|
+
})
|
|
39
|
+
return all_tracks
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def calculate_speeds(points: List[dict], max_speed: float) -> List[float]:
|
|
43
|
+
"""
|
|
44
|
+
Calculates the speed of each point in the list of points.
|
|
45
|
+
|
|
46
|
+
The `max_speed` is used to control the dirty data: if the speed is larger
|
|
47
|
+
than some reasonable value (max_speed), then usually this means zero division,
|
|
48
|
+
that's we simply nullify the speed.
|
|
49
|
+
"""
|
|
50
|
+
speeds = []
|
|
51
|
+
for i in range(1, len(points)):
|
|
52
|
+
lat1, lon1, time1 = points[i - 1].values()
|
|
53
|
+
lat2, lon2, time2 = points[i].values()
|
|
54
|
+
distance = gpxpy.geo.haversine_distance(lat1, lon1, lat2, lon2)
|
|
55
|
+
time_diff = (time2 - time1).total_seconds()
|
|
56
|
+
if time_diff > 0:
|
|
57
|
+
speed = (distance / time_diff) * 1.94384 # Convert m/s to knots
|
|
58
|
+
if speed > max_speed:
|
|
59
|
+
print(f"Warning: speed {speed:.2f} exceeds {max_speed} kn at time {time1} (dt = {time_diff:.2f}s)")
|
|
60
|
+
speed = 0
|
|
61
|
+
speeds.append(speed)
|
|
62
|
+
else:
|
|
63
|
+
speeds.append(0)
|
|
64
|
+
return speeds
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def speed_to_color(speed: float, max_speed: float) -> str:
|
|
68
|
+
norm_speed = min(speed / max_speed, 1.0)
|
|
69
|
+
# norm_speed = min(speed / max(1,max_speed), 1.0)
|
|
70
|
+
color = plt.cm.RdYlGn(norm_speed)
|
|
71
|
+
return f"#{int(color[0] * 255):x}{int(color[1] * 255):x}{int(color[2] * 255):x}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_map(gpx_files: List[str], names: List[str], max_speed: float) -> Tuple[folium.Map, List[List], float, str]:
|
|
75
|
+
folium_map = folium.Map(location=[0, 0], zoom_start=12, control_scale=True, attributionControl=False, tiles=None)
|
|
76
|
+
map_id = folium_map.get_name()
|
|
77
|
+
|
|
78
|
+
folium.TileLayer('openstreetmap', control=False).add_to(folium_map)
|
|
79
|
+
# Add OpenSeaMap layer directly to the map, not to the layer control
|
|
80
|
+
folium.TileLayer(
|
|
81
|
+
tiles='https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
|
82
|
+
attr='OpenSeaMap',
|
|
83
|
+
overlay=False,
|
|
84
|
+
control=False, # Set control to `False` to exclude from layer control
|
|
85
|
+
).add_to(folium_map)
|
|
86
|
+
|
|
87
|
+
all_tracks = []
|
|
88
|
+
original_names = []
|
|
89
|
+
for gpx_file in gpx_files:
|
|
90
|
+
for track in parse_gpx(gpx_file):
|
|
91
|
+
points = track['points']
|
|
92
|
+
speeds = calculate_speeds(points, max_speed)
|
|
93
|
+
all_tracks.append(list(zip(points, speeds)))
|
|
94
|
+
original_names.append(track['name'])
|
|
95
|
+
|
|
96
|
+
max_speed = max(s for track in all_tracks for _, s in track)
|
|
97
|
+
|
|
98
|
+
# Calculate map bounds
|
|
99
|
+
latitudes = [p['lat'] for track in all_tracks for p, _ in track]
|
|
100
|
+
longitudes = [p['lon'] for track in all_tracks for p, _ in track]
|
|
101
|
+
folium_map.fit_bounds([[min(latitudes), min(longitudes)], [max(latitudes), max(longitudes)]])
|
|
102
|
+
|
|
103
|
+
track_layers = []
|
|
104
|
+
colors = ['red', 'green', 'blue', 'orange', 'purple', 'brown', 'pink', 'yellow', 'cyan', 'magenta']
|
|
105
|
+
|
|
106
|
+
for i, track in enumerate(all_tracks):
|
|
107
|
+
color = colors[i % len(colors)]
|
|
108
|
+
lat_lon = [(p['lat'], p['lon']) for p, _ in track]
|
|
109
|
+
speeds = [s for _, s in track]
|
|
110
|
+
times = [p['time'].strftime('%Y-%m-%d %H:%M:%S') for p, _ in track]
|
|
111
|
+
name = names[i] if names and i < len(names) else original_names[i]
|
|
112
|
+
|
|
113
|
+
track_layer = folium.FeatureGroup(name=f"<span style='color:{color};'>●</span> {name}", show=True)
|
|
114
|
+
for j in range(len(lat_lon) - 1):
|
|
115
|
+
color = speed_to_color(speeds[j], max_speed)
|
|
116
|
+
tooltip_content = f"Name: {name}<br>Time: {times[j]} UTC<br>Speed: {speeds[j]:.2f} knots"
|
|
117
|
+
folium.PolyLine(
|
|
118
|
+
lat_lon[j:j + 2],
|
|
119
|
+
color=color,
|
|
120
|
+
weight=2.5,
|
|
121
|
+
opacity=1,
|
|
122
|
+
tooltip=folium.Tooltip(tooltip_content)
|
|
123
|
+
).add_to(track_layer)
|
|
124
|
+
track_layers.append(track_layer)
|
|
125
|
+
folium_map.add_child(track_layer)
|
|
126
|
+
|
|
127
|
+
folium.LayerControl(collapsed=False).add_to(folium_map)
|
|
128
|
+
|
|
129
|
+
return folium_map, all_tracks, max_speed, map_id
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def add_animation(folium_map: folium.Map,
|
|
133
|
+
all_tracks: List[List],
|
|
134
|
+
jinja_env: jinja2.Environment,
|
|
135
|
+
title: str, map_id:str) -> None:
|
|
136
|
+
gpx_points_data = [[point[0] for point in track] for track in all_tracks]
|
|
137
|
+
gpx_timestamps = sorted(set(point[0]['time'] for track in all_tracks for point in track))
|
|
138
|
+
min_time, max_time = min(gpx_timestamps), max(gpx_timestamps)
|
|
139
|
+
time_range = (max_time - min_time).total_seconds()
|
|
140
|
+
|
|
141
|
+
animation_script = f"""
|
|
142
|
+
<script>
|
|
143
|
+
var gpx_points_data = {json.dumps(gpx_points_data, default=track_serializer)};
|
|
144
|
+
var gpx_timestamps = {json.dumps([t for t in gpx_timestamps], default=track_serializer)};
|
|
145
|
+
var min_time = new Date('{min_time.strftime('%Y-%m-%dT%H:%M:%S%z')}').getTime();
|
|
146
|
+
var max_time = new Date('{max_time.strftime('%Y-%m-%dT%H:%M:%S%z')}').getTime();
|
|
147
|
+
var time_range = {time_range};
|
|
148
|
+
var map_id = "{map_id}";
|
|
149
|
+
document.title = "{title if title else 'GPX Player'}";
|
|
150
|
+
</script>
|
|
151
|
+
<script>
|
|
152
|
+
{open('animate_tracks.js', encoding='UTF-8').read()}
|
|
153
|
+
</script>
|
|
154
|
+
"""
|
|
155
|
+
folium_map.get_root().html.add_child(folium.Element(animation_script))
|
|
156
|
+
|
|
157
|
+
if title:
|
|
158
|
+
template = jinja_env.get_template('header_template.html')
|
|
159
|
+
header_html = template.render(title=title)
|
|
160
|
+
folium_map.get_root().html.add_child(folium.Element(header_html))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def add_legend(folium_map: folium.Map, max_speed: float, jinja_env: jinja2.Environment) -> None:
|
|
164
|
+
template = jinja_env.get_template('speed_legend_template.html')
|
|
165
|
+
legend_html = template.render(max_speed=max_speed)
|
|
166
|
+
folium_map.get_root().html.add_child(folium.Element(legend_html))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
args = parse_arguments()
|
|
171
|
+
gpx_files = args.files
|
|
172
|
+
names = args.names
|
|
173
|
+
|
|
174
|
+
env = Environment(loader=FileSystemLoader('.'))
|
|
175
|
+
|
|
176
|
+
folium_map, all_tracks, max_speed, map_id = create_map(gpx_files, names, args.max_speed)
|
|
177
|
+
add_animation(folium_map, all_tracks, env, args.title, map_id)
|
|
178
|
+
|
|
179
|
+
add_legend(folium_map, max_speed, env)
|
|
180
|
+
|
|
181
|
+
folium_map.save('boat_tracks.html')
|
|
182
|
+
print('Map has been saved to boat_tracks.html')
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
utils.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import matplotlib as mpl
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def slug(input_string: str) -> str:
|
|
9
|
+
# Remove leading and trailing spaces
|
|
10
|
+
input_string = input_string.strip()
|
|
11
|
+
# Remove non-word characters (everything except numbers, underscores and letters)
|
|
12
|
+
s = re.sub(r"[^\w\s]", '', input_string)
|
|
13
|
+
# Replace all runs of whitespace with a single dash
|
|
14
|
+
s = re.sub(r"\s+", '-', s)
|
|
15
|
+
return s.lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def timedelta_to_hms(td: dt.timedelta) -> str:
|
|
19
|
+
""" converts timedelta to [hours:]minutes:seconds"""
|
|
20
|
+
total_seconds = int(td.total_seconds())
|
|
21
|
+
# Break down the seconds into hours, minutes, and seconds
|
|
22
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
23
|
+
minutes, seconds = divmod(remainder, 60)
|
|
24
|
+
|
|
25
|
+
# Format it, including hours only if they are greater than 0
|
|
26
|
+
if hours:
|
|
27
|
+
return f"{hours}:{minutes:02}:{seconds:02}"
|
|
28
|
+
else:
|
|
29
|
+
return f"{minutes:02}:{seconds:02}"
|
|
30
|
+
|
|
31
|
+
def track_serializer(obj):
|
|
32
|
+
if isinstance(obj, dt.datetime):
|
|
33
|
+
return obj.isoformat() # Convert datetime to an ISO 8601 string
|
|
34
|
+
elif isinstance(obj, dt.SimpleTZ):
|
|
35
|
+
return str(obj) # Convert timezone info to a string
|
|
36
|
+
else:
|
|
37
|
+
raise TypeError(f"Type {type(obj)} not serializable")
|
|
38
|
+
|
|
39
|
+
def decimal_to_dms(value: float) -> str:
|
|
40
|
+
degrees = int(value)
|
|
41
|
+
submin = abs(value - degrees) * 60
|
|
42
|
+
minutes = int(submin)
|
|
43
|
+
seconds = int((submin - minutes) * 60)
|
|
44
|
+
return f"{degrees}°{minutes}'{seconds}\""
|
|
45
|
+
|
|
46
|
+
def format_func(value, tick_number):
|
|
47
|
+
# a custom ticker formatter function
|
|
48
|
+
return decimal_to_dms(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def km_to_nm(dist: float) -> float:
|
|
52
|
+
return dist/1.852
|
|
53
|
+
|
|
54
|
+
def gen_arrow_head_marker(rot: float) -> (mpl.path.Path, float):
|
|
55
|
+
"""generate a marker to plot with matplotlib scatter, plot, ...
|
|
56
|
+
|
|
57
|
+
https://matplotlib.org/stable/api/markers_api.html#module-matplotlib.markers
|
|
58
|
+
|
|
59
|
+
Source: https://stackoverflow.com/a/66973317/4222580
|
|
60
|
+
|
|
61
|
+
rot=0: positive x direction
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
rot : float
|
|
65
|
+
rotation in degree
|
|
66
|
+
0 is positive x direction
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
arrow_head_marker : Path
|
|
71
|
+
use this path for marker argument of plt.scatter
|
|
72
|
+
scale : float
|
|
73
|
+
multiply a argument of plt.scatter with this factor got get markers
|
|
74
|
+
with the same size independent of their rotation.
|
|
75
|
+
Paths are autoscaled to a box of size -1 <= x, y <= 1 by plt.scatter
|
|
76
|
+
"""
|
|
77
|
+
arr = np.array([[.1, .3], [.1, -.3], [1, 0], [.1, .3]]) # arrow shape
|
|
78
|
+
angle = rot / 180 * np.pi
|
|
79
|
+
rot_mat = np.array([
|
|
80
|
+
[np.cos(angle), np.sin(angle)],
|
|
81
|
+
[-np.sin(angle), np.cos(angle)]
|
|
82
|
+
])
|
|
83
|
+
arr = np.matmul(arr, rot_mat) # rotates the arrow
|
|
84
|
+
|
|
85
|
+
# scale
|
|
86
|
+
x0 = np.amin(arr[:, 0])
|
|
87
|
+
x1 = np.amax(arr[:, 0])
|
|
88
|
+
y0 = np.amin(arr[:, 1])
|
|
89
|
+
y1 = np.amax(arr[:, 1])
|
|
90
|
+
scale = np.amax(np.abs([x0, x1, y0, y1]))
|
|
91
|
+
codes = [mpl.path.Path.MOVETO, mpl.path.Path.LINETO,mpl.path.Path.LINETO, mpl.path.Path.CLOSEPOLY]
|
|
92
|
+
arrow_head_marker = mpl.path.Path(arr, codes)
|
|
93
|
+
|
|
94
|
+
return arrow_head_marker, scale
|
validator.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime as dt
|
|
4
|
+
|
|
5
|
+
from lxml import etree
|
|
6
|
+
|
|
7
|
+
class GPXValidationError(Exception):
|
|
8
|
+
"""Exception raised for GPX validation errors."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_gpx(file_path):
|
|
13
|
+
try:
|
|
14
|
+
tree = etree.parse(file_path)
|
|
15
|
+
except etree.XMLSyntaxError as e:
|
|
16
|
+
raise GPXValidationError(f"XML Syntax Error: {e}")
|
|
17
|
+
return tree
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_timestamp(timestamp_str):
|
|
21
|
+
"""
|
|
22
|
+
A helper function that tolerates both `2024-06-15T14:46:21.000Z` and `2024-06-15T14:46:21Z` time formats,
|
|
23
|
+
i.e. both integer and decimal seconds.
|
|
24
|
+
"""
|
|
25
|
+
formats = ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]
|
|
26
|
+
for fmt in formats:
|
|
27
|
+
try:
|
|
28
|
+
return dt.strptime(timestamp_str, fmt)
|
|
29
|
+
except ValueError:
|
|
30
|
+
continue
|
|
31
|
+
raise ValueError(f"Timestamp '{timestamp_str}' does not match any expected format.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_schema(version):
|
|
35
|
+
xsd_filename = ""
|
|
36
|
+
if version in ("1.0", "1.1"):
|
|
37
|
+
xsd_filename = f"schema/gpx_{version}.xsd"
|
|
38
|
+
else:
|
|
39
|
+
raise GPXValidationError(f"Unsupported GPX version: {version}")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with open(xsd_filename, 'rb') as f:
|
|
43
|
+
schema_doc = etree.parse(f)
|
|
44
|
+
schema = etree.XMLSchema(schema_doc)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
raise GPXValidationError(f"Error loading schema file '{xsd_filename}': {e}")
|
|
47
|
+
return schema
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_coordinates(root):
|
|
51
|
+
"""Custom check for coordinate values in lenient mode."""
|
|
52
|
+
for trkpt in root.findall(".//{*}trkpt"):
|
|
53
|
+
lat = trkpt.get("lat")
|
|
54
|
+
lon = trkpt.get("lon")
|
|
55
|
+
try:
|
|
56
|
+
lat_val = float(lat)
|
|
57
|
+
lon_val = float(lon)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
raise GPXValidationError(f"Invalid coordinate values: lat={lat}, lon={lon}")
|
|
60
|
+
if not (-90 <= lat_val <= 90):
|
|
61
|
+
raise GPXValidationError(f"Latitude {lat_val} out of range (-90, 90)")
|
|
62
|
+
if not (-180 <= lon_val <= 180):
|
|
63
|
+
raise GPXValidationError(f"Longitude {lon_val} out of range (-180, 180)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_elevations(root):
|
|
67
|
+
"""Custom check for elevation values in lenient mode."""
|
|
68
|
+
for trkpt in root.findall(".//{*}trkpt"):
|
|
69
|
+
ele_elem = trkpt.find("{*}ele")
|
|
70
|
+
if ele_elem is not None and ele_elem.text:
|
|
71
|
+
try:
|
|
72
|
+
ele_val = float(ele_elem.text)
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise GPXValidationError(f"Invalid elevation value: {ele_elem.text}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_schema(tree, schema, strict, root=None):
|
|
78
|
+
"""
|
|
79
|
+
Validate the XML tree against the provided schema.
|
|
80
|
+
In default (lenient) mode, if errors are solely due to extra precision on coordinates,
|
|
81
|
+
perform a manual coordinate check.
|
|
82
|
+
In strict mode, any schema violation will result in an error.
|
|
83
|
+
"""
|
|
84
|
+
if schema.validate(tree):
|
|
85
|
+
return # Validation passed
|
|
86
|
+
|
|
87
|
+
if strict:
|
|
88
|
+
error_messages = "\n".join(f" {error.message}" for error in schema.error_log[:10])
|
|
89
|
+
raise GPXValidationError(f"XML does not conform to the GPX schema (strict mode enabled):\n"
|
|
90
|
+
f"The first {min(10, len(schema.error_log))} errors (out of {len(schema.error_log)}) are:\n"
|
|
91
|
+
f"{error_messages}")
|
|
92
|
+
|
|
93
|
+
# Lenient mode: check if errors are exclusively about lat/lon or elevation precision
|
|
94
|
+
|
|
95
|
+
# Define allowed keywords for lenient errors: latitude, longitude, and elevation.
|
|
96
|
+
allowed_keywords = ["latitudeType", "longitudeType", "ele"]
|
|
97
|
+
|
|
98
|
+
# Filter out errors that are solely about these issues.
|
|
99
|
+
error_log = [e.message for e in schema.error_log]
|
|
100
|
+
filtered_errors = [msg for msg in error_log if not any(keyword in msg for keyword in allowed_keywords)]
|
|
101
|
+
|
|
102
|
+
if len(filtered_errors) == 0:
|
|
103
|
+
# Run manual coordinate validation
|
|
104
|
+
validate_coordinates(root)
|
|
105
|
+
validate_elevations(root)
|
|
106
|
+
print("Warning: GPX file does not strictly conform to the schema (coordinate precision issues), "
|
|
107
|
+
"but manual checks passed in lenient mode.")
|
|
108
|
+
else:
|
|
109
|
+
error_messages = "\n".join(f" {error}" for error in filtered_errors[:10])
|
|
110
|
+
raise GPXValidationError(f"XML does not conform to the GPX schema ({len(filtered_errors)} errors):\n"
|
|
111
|
+
f"The first {min(10, len(filtered_errors))} errors (out of {len(filtered_errors)}) are:\n"
|
|
112
|
+
f"{error_messages}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_timestamp_consistency(root):
|
|
116
|
+
"""
|
|
117
|
+
For each track that has at least one timestamped point, ensure that:
|
|
118
|
+
- Timestamps appear in strictly increasing order.
|
|
119
|
+
- No two distinct points have the same timestamp.
|
|
120
|
+
"""
|
|
121
|
+
# Using a namespace-agnostic search with {*}
|
|
122
|
+
tracks = root.findall(".//{*}trk")
|
|
123
|
+
if not tracks:
|
|
124
|
+
# No tracks in the file.
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Iterate over each track.
|
|
128
|
+
for trk in tracks:
|
|
129
|
+
# List to store timestamps (in order) for the current track.
|
|
130
|
+
timestamps = []
|
|
131
|
+
# Process each track segment (trkseg) in the order they appear.
|
|
132
|
+
for trkseg in trk.findall(".//{*}trkseg"):
|
|
133
|
+
for trkpt in trkseg.findall("{*}trkpt"):
|
|
134
|
+
time_elem = trkpt.find("{*}time")
|
|
135
|
+
if time_elem is not None and time_elem.text:
|
|
136
|
+
try:
|
|
137
|
+
# GPX timestamps are typically in ISO8601 format ending with 'Z' (UTC).
|
|
138
|
+
t = parse_timestamp(time_elem.text)
|
|
139
|
+
except ValueError:
|
|
140
|
+
print(f"Invalid timestamp format: {time_elem.text}")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
# Check for duplicate timestamp (i.e. same time appears more than once in this track)
|
|
143
|
+
if t in timestamps:
|
|
144
|
+
raise GPXValidationError(f"Duplicate timestamp found in track: {t}")
|
|
145
|
+
# If there is a previous timestamp, ensure the current one is later.
|
|
146
|
+
if timestamps and t <= timestamps[-1]:
|
|
147
|
+
raise GPXValidationError(f"Timestamps not strictly increasing: {t} does not come after {timestamps[-1]}")
|
|
148
|
+
timestamps.append(t)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_gpx(file_path, strict=False):
|
|
152
|
+
"""Run the full validation procedure on the provided GPX file."""
|
|
153
|
+
# Step 1. Parse the file as XML.
|
|
154
|
+
tree = parse_gpx(file_path)
|
|
155
|
+
root = tree.getroot()
|
|
156
|
+
|
|
157
|
+
# Step 2. Determine GPX version from the root element.
|
|
158
|
+
version = root.get("version")
|
|
159
|
+
if version not in ("1.0", "1.1"):
|
|
160
|
+
print(f"Unsupported or missing GPX version: {version}")
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
# Step 3. Validate against the corresponding GPX XSD.
|
|
164
|
+
schema = load_schema(version)
|
|
165
|
+
# Note: lenient mode is the default behavior; strict mode is enabled via command-line flag.
|
|
166
|
+
validate_schema(tree, schema, strict=strict, root=root)
|
|
167
|
+
print("XML schema validation passed.")
|
|
168
|
+
|
|
169
|
+
# Step 4. GPX-specific content check.
|
|
170
|
+
tracks = root.findall(".//{*}trk")
|
|
171
|
+
if not tracks:
|
|
172
|
+
print("Warning: No tracks found in the GPX file.")
|
|
173
|
+
|
|
174
|
+
# Step 5. Timestamp consistency check.
|
|
175
|
+
check_timestamp_consistency(root)
|
|
176
|
+
print("Timestamp consistency check passed.")
|
|
177
|
+
|
|
178
|
+
print("GPX file is valid.")
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main():
|
|
183
|
+
parser = argparse.ArgumentParser(
|
|
184
|
+
description="Validate a GPX file for XML schema and timestamp consistency.\n"
|
|
185
|
+
"By default, the validator runs in lenient mode (extra precision is allowed).\n"
|
|
186
|
+
"Use --strict to enforce strict schema compliance."
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument("gpx_file", help="Path to the GPX file to validate")
|
|
189
|
+
parser.add_argument("--strict", action="store_true", help="Enable strict mode (default is lenient)")
|
|
190
|
+
|
|
191
|
+
args = parser.parse_args()
|
|
192
|
+
try:
|
|
193
|
+
validate_gpx(args.gpx_file, strict=args.strict)
|
|
194
|
+
except GPXValidationError as e:
|
|
195
|
+
print(e, file=sys.stderr)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == '__main__':
|
|
200
|
+
main()
|