gpx-player
GPX Race Visualizer
gpx-player is a Python package (with two command-line tools and a small public API)
that visualises the progression of one or more GPS tracks, for example from a race, on a 2D map.
It takes one or more GPX files and produces either a rendered animation or an
interactive HTML map that you can play back in a browser.
This is a simple, open-source alternative to features like Strava's Flyby, which require an account and can have privacy issues.
See CHANGELOG.md for the release history.
Modes
The player supports two modes.
1. "Video" mode (gpx-player)
Produces an MP4 or a GIF file showing how the situation developed.
For sailing races, it also calculates the distance covered after the 'start' signal and the current speed.
By default, the file is written to the current directory and named after
--title (slugified), so --title "Race 1" produces race-1.mp4. Without a
title you get untitled.mp4. Use --output / -o to choose an explicit path
(use a .gif path with --gif, or .mp4 otherwise). Existing files at that
path are overwritten. MP4 output requires ffmpeg on your PATH. Without it
matplotlib falls back to its Pillow writer, which cannot encode MP4, and the run
dies with ValueError: unknown file extension: .mp4 after rendering every frame,
leaving no output file. --gif works without ffmpeg.
Example:
2. Map mode (python -m gpx_player.openseamap)
Writes a self-contained, playable HTML page on top of OpenStreetMap with an OpenSeaMap seamark overlay. The page shows:
- the full track of every participant, colour-coded by speed;
- a play/pause button and a time slider to replay the tracks;
- a directional arrow marker per participant that rotates to the current heading;
- a moving tail behind each marker, drawn as a dark outline with a speed-coloured core, with a configurable length;
- a speed legend, and a live legend with each boat's distance travelled (nautical miles), current speed (knots) and average speed (knots);
- a per-participant visibility control (top-right) to switch each track between the full speed-coloured track, the moving tail only, or off.
Displayed speeds are smoothed over a short trailing time window (10 s), which removes the "zero, then jump" artefacts typical of noisy GPS samples while still showing genuine stationary periods.
Example:
▶ Open the live interactive demo
GitHub sanitises Markdown and strips <script> and <iframe>, so a live
playable map cannot be embedded in this page. The screenshot below links to the
hosted, fully interactive version:
The demo is built from the GPX files in example-data/ by
scripts/build_demo.py and redeployed by the
Pages workflow on every push to main, so it
always reflects the current code. Build it yourself with:
python scripts/build_demo.py --output-dir site
python -m http.server -d site 8000 # then open http://localhost:8000
Installation
Requires Python 3.9 or newer.
Install from PyPI:
pip install gpx-player
To work on the code, clone the repository and install it in editable mode.
This installs the runtime dependencies declared in pyproject.toml;
requirements.txt additionally pins the test tooling used by CI:
git clone https://github.com/kirienko/gpx-player.git
cd gpx-player
pip install -e .
pip install -r requirements.txt # optional: adds pytest / pytest-cov
pytest
Usage
Video mode
Pass one or more GPX file paths as positional arguments:
python -m gpx_player.main example-data/track1.gpx example-data/track2.gpx
The gpx-player command and python -m gpx_player.main are equivalent.
Both exit 0 after a successful render and non-zero on failure. If any input
track has no points in the selected window, the command reports the filename
and fails before rendering.
A more sophisticated example, which produced the video above:
python -m gpx_player.main example-data/track1.gpx example-data/track2.gpx example-data/track3.gpx \
--start 2023-07-01T10:53:00+0000 \
--names "Mr. Pommeroy" "Miss Sophie" "Sir Toby²" \
--title "Elbe-Damm Regatta (01.07.2023), Race 1" \
--race_start 2023-07-01T10:58:00+0000 --marks example-data/marks.txt -g
Video mode options
| Option | Description |
|---|---|
files (positional) |
One or more GPX files to process. |
--title, -t |
Title of the video. Determines the default output filename. |
--output, -o |
Explicit output path, overriding the title-based filename. Use .gif with --gif, or .mp4 otherwise. |
--start, -s |
Start time, all points before it are dropped. |
--end, -e |
End time, all points after it are dropped. |
--race_start, -r |
Race start time, used for the "distance since the start signal" readout. |
--names, -n |
Names of the participants (file names are used in the legend otherwise). |
--marks, -m |
File with static marks to put onto the map, one coordinate pair per line, see Marks. |
--gif, -g |
Save as an animated GIF instead of MP4. |
--timezone, -tz |
Local timezone for displayed timestamps, e.g. America/Los_Angeles, see the tz database list (default: Europe/Berlin). |
In video mode --start, --end and --race_start are parsed strictly as
%Y-%m-%dT%H:%M:%S%z, e.g. 2023-06-30T12:53:00+0200. The UTC offset is
mandatory and Z is not accepted here.
Map mode
Note that in map mode the GPX files are passed via the required --files
option, not as positional arguments:
python -m gpx_player.openseamap --title 'Gin Sul Regatta 2024' --names Alex Yury Richard \
--files example-data/osm-demo-Alex.gpx example-data/osm-demo-Richard.gpx \
example-data/osm-demo-Yury.gpx
This always writes the map to boat_tracks.html in the current directory,
overwriting any existing file of that name.
Restrict the map to a specific time window with --start / --end. Speed,
distance, map bounds and the animation slider all reflect only the filtered
segment:
python -m gpx_player.openseamap --files example-data/osm-demo-Alex.gpx \
--start 2024-06-15T17:00:00+0200 --end 2024-06-15T17:30:00+0200
Map mode options
| Option | Description |
|---|---|
--files (required) |
One or more GPX files to process. |
--names, -n |
Names of the participants (track names, or Track N, are used otherwise). |
--title, -t |
Title of the page. Also becomes the HTML document title. |
--start, -s |
Only render points at or after this time. |
--end, -e |
Only render points at or before this time. |
--max-speed, -ms |
Plausibility cut-off in knots (default: 12), see the note below. |
--tail-length |
Length of the moving tail: short (30 points), normal (60, default) or long (120). |
Map mode accepts any ISO 8601 timestamp with a timezone, so
2024-06-15T17:00:00+0200, 2024-06-15T17:00:00+02:00 and
2024-06-15T15:00:00Z are all valid. This is more permissive than video mode.
Video-mode options that do not exist in map mode: --race_start,
--marks, --gif and --timezone. Map-mode timestamps are shown in UTC.
About
--max-speed. It is a dirty-data filter, not a display cap: any segment whose computed speed exceeds it is treated as a GPS glitch and recorded as0knots. The colour scale is then rescaled to the highest speed that actually survived the filter. Set it above the fastest speed you expect, otherwise your quickest segments will silently be flattened to zero.
Python API
The same playable OpenSeaMap can be created from Python:
from gpx_player.openseamap import create_playback_map
folium_map = create_playback_map(
[
"example-data/osm-demo-Alex.gpx",
"example-data/osm-demo-Richard.gpx",
"example-data/osm-demo-Yury.gpx",
],
names=["Alex", "Richard", "Yury"],
max_speed=12,
title="Gin Sul Regatta 2024",
slider_active_color="#6e6e6e",
slider_inactive_color="#d0d0d0",
tail_length="normal",
)
folium_map.save("boat_tracks.html")
create_playback_map(gpx_files, names=None, *, max_speed=12, title=None, start_time=None, end_time=None, slider_active_color="#6e6e6e", slider_inactive_color="#d0d0d0", tail_length="normal") returns a folium.Map, so
you can add your own layers before saving, or render it into an existing page.
start_time / end_time are timezone-aware datetime objects and are the
programmatic equivalent of --start / --end.
slider_active_color and slider_inactive_color are Python-only arguments for
theming the played and unplayed sections of the playback slider; they default to
the built-in greys shown above, and passing None selects those same defaults.
Lower-level building blocks are public too:
| Function | Purpose |
|---|---|
openseamap.create_map(files, names, max_speed, ...) |
Build the base map and parsed track data without any playback UI. |
openseamap.add_playback_controls(folium_map, all_tracks, ...) |
Attach the playback UI, legends and data to a map you already have. |
gpx_utils.trim_track(track, start, end) / trim_tracks(...) |
Trim already-parsed tracks to a time window without mutating the input. |
gpx_utils.remove_extensions_tags(path, overwrite=False) |
Strip <extensions> blocks from a GPX file. |
validator.validate_gpx(path, strict=False) |
Validate a GPX file, see GPX Validation. |
Playback templates and JavaScript are bundled as package assets, so downstream
applications can call this API from any current working directory after
pip install gpx-player; no source checkout or local asset copies are needed.
OpenStreetMap tile access
The generated HTML uses live raster tiles from tile.openstreetmap.org for
the base map and an OpenSeaMap seamark overlay. OpenStreetMap data is open, but
the public OSM tile servers are a shared service with a
tile usage policy.
In particular, browser requests from web pages must send a valid HTTP
Referer header.
If you open a generated map directly as a local file://.../boat_tracks.html
file, many browsers will not send a valid HTTP Referer for tile requests.
OSM may then return "blocked" placeholder tiles linking to
https://osm.wiki/blocked. This can happen even for an old HTML file that
works normally when hosted on a website.
For local viewing, serve the directory over HTTP instead:
python3 -m http.server 8000
Then open http://localhost:8000/boat_tracks.html in the browser.
For public or production applications, do not rely on the community
tile.openstreetmap.org service as an application tile backend. Host the HTML
on a normal web origin and use a tile provider, self-hosted tiles, or vector
tiles whose terms fit your traffic and offline/static distribution needs.
Marks
Video mode also supports visualising predefined marks on the map, which is useful
for events like sailing regattas. The marks are given as one latitude, longitude
pair per line in a plain text file, passed via --marks:
53.542484632728, 9.801163896918299
53.542997846049374, 9.80611324310303
53.54823800356785, 9.812614917755129
53.54921647691311, 9.807373881340029
53.54508251196638, 9.80433225631714
Getting GPX Files
Most GPS-tracking services can export GPX. The exact menu wording changes over time, so treat the following as a hint rather than a click-by-click recipe:
- Strava: open the activity, use the "..." (more options) menu and choose "Export GPX".
- Garmin Connect: open the activity, use the gear / "..." menu and choose "Export to GPX".
- Komoot: open the tour and choose "Export GPX" (a free account is enough for your own tours).
- Wikiloc, Suunto App, Polar Flow, COROS: all offer a per-activity GPX export from the activity page.
- Apple Health / Fitness: no direct GPX export; use a third-party app or the full Health data export.
(Endomondo, previously listed here, was shut down at the end of 2020.)
GPX Validation
For gpx-player to work properly, it needs correct GPX files.
You can check a file with the validator included in this package.
gpx_player.validator is a command-line utility and a module. It checks XML
schema conformance (against the bundled GPX 1.0 / 1.1 XSDs), coordinate and
elevation ranges, and timestamp consistency, in either strict or lenient mode.
As a CLI tool:
gpx-validate path/to/yourfile.gpx --strict
It exits with 0 if the file is valid and 1 otherwise, printing the reason to
stderr.
--strict is optional. In most cases you do not need it, because files that
strictly correspond to the GPX schema are rare. For example, almost all modern
files contain coordinates, elevations and timestamps with more decimal places
than originally planned.
Use as a Python module
from gpx_player.validator import validate_gpx, GPXValidationError
try:
validate_gpx("path/to/yourfile.gpx", strict=True)
print("GPX file is valid")
except GPXValidationError as e:
print("GPX validation failed:", e)
Caveat. Most failures raise
GPXValidationError, but a missing or unsupportedversionattribute on the root<gpx>element currently callssys.exit(1)instead of raising. If you embed the validator in a long-running process, guard againstSystemExitas well.
Also, to better understand your GPX file, you can use the gpxinfo console command
that comes with gpxpy. If you are already using the player, you have it:
$ gpxinfo example-data/osm_track1.gpx
File: example-data/osm_track1.gpx
Waypoints: 0
Routes: 0
Length 2D: 9.621km
Length 3D: 9.648km
Moving time: 01:05:22
Stopped time: n/a
Max speed: 3.12m/s = 11.22km/h
Avg speed: 2.46m/s = 8.85km/h
Total uphill: 97.20m
Total downhill: 98.40m
Started: 2024-07-24 15:59:05+00:00
Ended: 2024-07-24 17:04:27+00:00
Points: 776
Avg distance between points: 12.40m
GPX Cleanup
For convenience, the package provides gpx_player.clean_gpx. This utility first
validates a GPX file using the validator and then removes all <extensions>
blocks using remove_extensions_tags from gpx_player.gpx_utils. By default the
cleaned file is saved alongside the original with _noext appended to its name.
With the optional --overwrite flag the original file is modified in place.
python -m gpx_player.clean_gpx path/to/yourfile.gpx [--overwrite]
If validation fails, the command exits with an error message. The output reports how many extension blocks were removed.
For AI agents
This section is a compact contract for LLM agents and automated pipelines that
drive gpx-player. Humans can skip it.
What this package does: turns GPX track files into either a rendered animation (MP4/GIF) or a single-file interactive HTML map. Generation itself is offline and deterministic: the track data is inlined into the page and nothing is uploaded.
The generated page is not self-contained at view time. Folium references
Leaflet, jQuery and Bootstrap from cdn.jsdelivr.net, cdnjs.cloudflare.com,
code.jquery.com and netdna.bootstrapcdn.com, on top of the map tiles. In a
browser that cannot reach those hosts the map does not initialise at all, so do
not treat the output as an offline artifact or promise a viewer it will work
air-gapped.
Recommended workflow
- Validate first. Call
validate_gpx(path)(orgpx-validate path) on every input before rendering. Most rendering failures are bad input, and the validator gives a specific reason where the renderer gives a traceback. - Clean if needed.
clean_gpx_file(path)strips vendor<extensions>, but it runsvalidate_gpx(path, strict=True)first and raisesGPXValidationErrorif that fails, so it can only clean files that already validate. To strip extensions from a file that does not validate, callgpx_utils.remove_extensions_tags(path)directly, which does no validation. - Render via the Python API, not the CLI.
create_playback_map()returns afolium.Map, so you choose the output path. The map-mode CLI always writesboat_tracks.htmlinto the current working directory and overwrites it, which makes concurrent or repeated runs collide. - Report the artifact path you saved to, not "the file was created".
Minimal end-to-end recipe
import datetime as dt
from gpx_player.validator import validate_gpx, GPXValidationError
from gpx_player.openseamap import create_playback_map
files = ["example-data/osm-demo-Alex.gpx", "example-data/osm-demo-Yury.gpx"]
for f in files:
try:
validate_gpx(f) # lenient mode; strict=True is usually too strict
except (GPXValidationError, SystemExit) as e:
raise SystemExit(f"unusable input {f}: {e}")
folium_map = create_playback_map(
files,
names=["Alex", "Yury"],
max_speed=20, # see the --max-speed caveat below
title="Race 1",
start_time=dt.datetime.fromisoformat("2024-06-15T17:00:00+02:00"),
end_time=dt.datetime.fromisoformat("2024-06-15T17:30:00+02:00"),
tail_length="normal",
)
out = "/tmp/race1.html"
folium_map.save(out)
print(out)
Interface summary
| Entry point | Kind | Inputs | Produces |
|---|---|---|---|
python -m gpx_player.main FILES... |
CLI | positional GPX paths | The --output path, otherwise <slug(title)>.mp4 or .gif in the CWD (untitled when no title is given) |
python -m gpx_player.openseamap --files FILES... |
CLI | --files is required |
boat_tracks.html in the CWD (always this name) |
gpx-validate FILE |
CLI | one GPX path | exit 0 valid / 1 invalid |
python -m gpx_player.clean_gpx FILE |
CLI | one GPX path | FILE_noext.gpx, or in place with --overwrite |
openseamap.create_playback_map(...) |
API | list of paths | folium.Map, caller chooses the path |
openseamap.create_map(...) |
API | list of paths | (folium.Map, tracks, max_speed, map_id) |
validator.validate_gpx(...) |
API | one path | True, or raises GPXValidationError (also SystemExit on a bad version, see below) |
gpx_utils.trim_track(...) |
API | parsed track dict | trimmed copy, input untouched |
Failure modes to expect
- Naive timestamps. Every
--start/--end/start_time/end_timevalue must carry a UTC offset. Video mode is strict (%Y-%m-%dT%H:%M:%S%z, noZ); map mode accepts general ISO 8601, includingZ. max_speedsilently zeroes fast segments. It is a plausibility filter, not a display cap. The default of12knots is tuned for sailing; for cycling, driving or running, raise it, or your fastest segments will be recorded as0.- Empty time window. Video mode fails with a non-zero exit status if any input track has no selected points. In map mode, empty tracks are skipped with a warning; if all tracks are empty the map CLI prints a message and writes nothing. Check that the output file exists rather than assuming it does.
- Headless rendering. Video mode uses matplotlib; set
MPLBACKEND=Aggin the environment. MP4 output additionally requiresffmpegonPATH: without it the run renders every frame and then dies withValueError: unknown file extension: .mp4, writing nothing. Check forffmpegbefore choosing MP4, or use--gif, which does not need it. - Blocked map tiles. A generated HTML opened over
file://may show "blocked" tiles. Serve it over HTTP, see OpenStreetMap tile access. - Blocked CDNs. If the viewer cannot reach the JS/CSS hosts listed above, the page loads but the map never initialises, and it fails quietly: the static legend still renders, so "the HTML looks fine" is not evidence the map works.
- Track-to-name mapping. Names are matched to tracks in file order, and a
single GPX file may contain several
<trk>elements. If a file has more than one track, supply one name per track, not per file.
Untrusted input
GPX files are XML from arbitrary sources. Track names and descriptions are HTML-escaped and playback data is JSON-escaped before being written into the generated page, so a hostile track name cannot break out of the HTML or the inline script. Still, treat file paths and titles you pass in as data, and do not interpolate agent-controlled text into the shell; call the Python API instead of building command strings.
Support
Now you can buy me a coffee to encourage further development!
Release files for gpx-player 0.5.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| gpx_player-0.5.3.tar.gz | 60.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| gpx_player-0.5.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 103.6 kB
Release files / gpx_player-0.5.3.tar.gz
| Download URL | gpx_player-0.5.3.tar.gz |
|---|---|
| Size | 60.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
87719d020aef12ffde5153f627f3b8767abed9b2a3a7c79da8ce5eee4bb8c89b
|
|
BLAKE2b-256 checksum How to use checksums |
daf5962e353bb94557d302b4cb2a0b7c518629954828d46b8b7392009807eb38
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / gpx_player-0.5.3-py3-none-any.whl
| Download URL | gpx_player-0.5.3-py3-none-any.whl |
|---|---|
| Size | 43.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7bf5edd8ed74758c9ad718e3d8b377cb412180da114663cda6cac560d760e3ec
|
|
BLAKE2b-256 checksum How to use checksums |
6d9107d90ff79b00d9b1e5f0a6cc45e455db94b7411121ce8ce374c6b2097ac8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log