import os
import warnings
from math import isclose
from typing import Optional, Tuple
import matplotlib.animation as animation
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.basemap import Basemap
from .plotter import Plotter
[docs]
class MatplotlibAnimPlotter(Plotter):
"""
GPX animated plotter based on Matplotlib.
"""
[docs]
def plot(
self,
figsize: Tuple[int, int] = (16, 9),
size: float = 5,
color: str = "#FFA800",
background: Optional[str] = None,
offset_percentage: float = 0.04,
dpi: int = 96,
interval: float = 20,
fps: int = 24,
bitrate: int = 1800,
repeat: bool = True,
title: Optional[str] = None,
title_fontsize: int = 20,
watermark: bool = False,
file_path: str = None,
):
"""
Plot (animation) GPX using Matplotlib.
Crashes may be due to parametres exceeding system capabilities.
Try reducing fps and/or bitrate.
Args:
figsize (Tuple[int, int], optional): Width and height of
the plot. Defaults to (16, 9).
size (float, optional): Size of the track. Defaults to 5.
color (str, optional): Color of the track. Defaults to
"#FFA800".
background (Optional[str], optional): Map tiles to use.
Possible choice are: None, "bluemarble",
"shadedrelief", "etopo", "World_Imagery", "wms" or any
server supported by
`mpl_toolkits.basemap.Basemap.arcgisimage`. Defaults to
None.
offset_percentage (float, optional): Offset percentage to
apply to the track bounding box. Defaults to 0.04.
dpi (int, optional): Resolution of the animation. Defaults
to 96.
interval (float, optional): Interval between frames of the
animation. Defaults to 20.
fps (int, optional): FPS of the animation. Defaults to 24.
bitrate (int, optional): Bit-rate of the animation.
Defaults to 1800.
repeat (bool, optional): Repeat the animation when viewed.
Defaults to True.
title (Optional[str], optional): Title of the plot.
Defaults to None.
title_fontsize (int, optional): Font size of the title of
the plot. Defaults to 20.
watermark (bool, optional): Watermark. Defaults to False.
file_path (str, optional): Path to save the plot. Defaults
to None.
Raises:
FileNotFoundError: Provided path does not exist.
Returns:
matplotlib.Figure: Animated plot of the GPX.
"""
# Create dataframe containing data from the GPX file
self._df = self._gpx.to_pandas()
# Retrieve useful data
lat = self._df["lat"].values
lon = self._df["lon"].values
# Create figure
fig = plt.figure(figsize=figsize)
ax = fig.gca()
# Compute track boundaries
min_lat, min_lon, max_lat, max_lon = [b.value for b in self._gpx.trkpt_bounds()]
# Compute default offset
delta_lat = abs(max_lat - min_lat)
delta_lon = abs(max_lon - min_lon)
delta_max = max(delta_lat, delta_lon)
offset = delta_max * offset_percentage
# Add default offset
min_lat = max(-90, min_lat - offset)
max_lat = min(max_lat + offset, 90)
min_lon = max(-180, min_lon - offset)
max_lon = min(max_lon + offset, 180)
# Update min/max lat/lon to achieve correct aspect ratio
lat_offset = 1e-5
lon_offset = 1e-5
delta_lat = abs(max_lat - min_lat)
delta_lon = abs(max_lon - min_lon)
r = delta_lon / delta_lat # Current map aspect ratio
r_ref = figsize[0] / figsize[1] # Target map aspect ratio
tolerance = 1e-3
while not isclose(r, r_ref, abs_tol=tolerance):
if r > r_ref:
min_lat = max(-90, min_lat - lat_offset)
max_lat = min(max_lat + lat_offset, 90)
delta_lat = max_lat - min_lat
if r < r_ref:
min_lon = max(-180, min_lon - lon_offset)
max_lon = min(max_lon + lon_offset, 180)
delta_lon = max_lon - min_lon
r = delta_lon / delta_lat
# Create map
map_ = Basemap(
projection="cyl",
llcrnrlon=min_lon,
llcrnrlat=min_lat,
urcrnrlon=max_lon,
urcrnrlat=max_lat,
ax=ax,
)
# Add background
if background is None:
pass
elif background == "bluemarble":
map_.bluemarble()
elif background == "shadedrelief":
map_.shadedrelief()
elif background == "etopo":
map_.etopo()
elif background == "World_Imagery":
map_.arcgisimage(service=background, dpi=dpi)
elif background == "wms":
wms_server = "http://www.ga.gov.au/gis/services/topography/Australian_Topography/MapServer/WMSServer"
wms_server = "http://wms.geosignal.fr/metropole?"
map_.wmsimage(
wms_server, layers=["Communes", "Nationales", "Regions"], verbose=True
)
else:
map_.arcgisimage(service=background, dpi=dpi, verbose=True)
# Create empty line
(line,) = fig.gca().plot([], [], color=color, linewidth=size)
# Animation function
def animate(i):
# Clear line at the beginning of the animation
if i == 0:
line.set_xdata([])
line.set_ydata([])
try:
line.set_xdata(np.concatenate((line.get_xdata(), np.array([lon[i]]))))
line.set_ydata(np.concatenate((line.get_ydata(), np.array([lat[i]]))))
except IndexError:
warnings.warn("No more data point, you need to stop!!")
return line
# Create animation
ani = animation.FuncAnimation(
fig=fig,
func=animate,
frames=len(lat),
interval=interval, # Delay between frames in ms
blit=False,
repeat=repeat if file_path is None else False,
)
# Add title
if title is not None:
if watermark:
fig.suptitle(title + "\n[made with ezGPX]", fontsize=title_fontsize)
else:
fig.suptitle(title, fontsize=title_fontsize)
# Set figure layout
fig.tight_layout()
# Save plot
if file_path is not None:
# Check if provided path exists
directory_path = os.path.dirname(os.path.realpath(file_path))
if not os.path.exists(directory_path):
raise FileNotFoundError("Provided path does not exist")
writer = None
if file_path.endswith(".mp4"):
writer = animation.FFMpegWriter(
fps=fps, metadata={"artist": "ezGPX"}, bitrate=bitrate
)
elif file_path.endswith(".gif"):
writer = animation.PillowWriter(
fps=fps, metadata={"artist": "ezGPX"}, bitrate=bitrate
)
ani.save(file_path, writer=writer)
return fig