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.
@@ -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
+ ![Example output](example.gif "Example of the script output")
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
+ [![OPS Example](./example_osm.png)](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
+ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gpx-player = main:main
3
+ gpx-validate = validator:main
@@ -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.
@@ -0,0 +1,5 @@
1
+ gpx_utils
2
+ main
3
+ openseamap
4
+ utils
5
+ validator
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};'>&#9679;</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()