Skip to content

Commit

Permalink
(Experimental) support for map tiles via contextily (#690)
Browse files Browse the repository at this point in the history
Allows to add a map tile to the plot if the trajectory coordinates are
geo-referenced. Example: `-map_tile epsg:32632`
  • Loading branch information
MichaelGrupp authored Sep 6, 2024
1 parent 63ea6f0 commit 29c20d0
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 0 deletions.
2 changes: 2 additions & 0 deletions evo/common_ape_rpe.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ def plot_result(args: argparse.Namespace, result: Result, traj_ref: PosePath3D,
plot_start_end_markers=SETTINGS.plot_start_end_markers)
plot.draw_coordinate_axes(ax, traj_est, plot_mode,
SETTINGS.plot_axis_marker_scale)
if args.map_tile:
plot.map_tile(ax, crs=args.map_tile)
if args.ros_map_yaml:
plot.ros_map(ax, args.ros_map_yaml, plot_mode)
if SETTINGS.plot_pose_correspondences:
Expand Down
3 changes: 3 additions & 0 deletions evo/main_ape_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ def parser() -> argparse.ArgumentParser:
output_opts.add_argument(
"--ros_map_yaml", help="yaml file of an ROS 2D map image (.pgm/.png)"
" that will be drawn into the plot", default=None)
output_opts.add_argument(
"--map_tile", help="CRS code of a map tile layer to add to the plot. "
"Requires geo-referenced poses and the contextily package installed.")
output_opts.add_argument("--save_plot", default=None,
help="path to save plot")
output_opts.add_argument("--serialize_plot", default=None,
Expand Down
3 changes: 3 additions & 0 deletions evo/main_rpe_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ def parser() -> argparse.ArgumentParser:
output_opts.add_argument(
"--ros_map_yaml", help="yaml file of an ROS 2D map image (.pgm/.png)"
" that will be drawn into the plot", default=None)
output_opts.add_argument(
"--map_tile", help="CRS code of a map tile layer to add to the plot. "
"Requires geo-referenced poses and the contextily package installed.")
output_opts.add_argument("--save_plot", default=None,
help="path to save plot")
output_opts.add_argument("--serialize_plot", default=None,
Expand Down
2 changes: 2 additions & 0 deletions evo/main_traj.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ def run(args):
0., 0.005, "euler_angle_sequence: {}".format(
SETTINGS.euler_angle_sequence), fontsize=6)

if args.map_tile:
plot.map_tile(ax_traj, crs=args.map_tile)
if args.ros_map_yaml:
plot.ros_map(ax_traj, args.ros_map_yaml, plot_mode)

Expand Down
3 changes: 3 additions & 0 deletions evo/main_traj_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ def parser() -> argparse.ArgumentParser:
output_opts.add_argument(
"--ros_map_yaml", help="yaml file of an ROS 2D map image (.pgm/.png)"
" that will be drawn into the plot", default=None)
output_opts.add_argument(
"--map_tile", help="CRS code of a map tile layer to add to the plot. "
"Requires geo-referenced poses and the contextily package installed.")
output_opts.add_argument("--save_plot", help="path to save plot",
default=None)
output_opts.add_argument("--save_table",
Expand Down
104 changes: 104 additions & 0 deletions evo/tools/contextily_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
Helper functions for using the contextily lib.
author: Michael Grupp
This file is part of evo (github.com/MichaelGrupp/evo).
evo is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
evo is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with evo. If not, see <http://www.gnu.org/licenses/>.
"""

import logging

import contextily as cx
import xyzservices.lib

from evo import EvoException
from evo.tools.settings import SETTINGS

logger = logging.getLogger(__name__)


class ContextilyHelperException(EvoException):
pass


# https://xyzservices.readthedocs.io/en/stable/api.html#xyzservices.TileProvider.requires_token
API_TOKEN_PLACEHOLDER = "<insert your "


def add_api_token(
provider: xyzservices.TileProvider) -> xyzservices.TileProvider:
"""
Adds the API token stored in the settings, if the provider requires one.
No-op if the provider requires none.
:param provider: provider to which the API token shall be added
:return: provider, either unchanged or with token set
"""
if not provider.requires_token():
return provider

if SETTINGS.map_tile_api_token == "":
raise ContextilyHelperException(
f"Map tile provider {provider.name} requires an API token. "
"Please set it with: evo_config set map_tile_api_token <token>")

# Attribute name of API token varies, search it using the placeholder
# defined by the xyzservices documentation and corresponding to:
# https://github.com/geopandas/xyzservices/blob/8865842123316699feb7e98215c7644533340f83/xyzservices/lib.py#L506
# TODO: can this be solved smarter in the xyzservices lib?
api_key = None
for key, value in provider.items():
if isinstance(
value, str
) and API_TOKEN_PLACEHOLDER in value and key in provider.url:
api_key = key
break
if not api_key:
raise ContextilyHelperException(
"Failed to find API key attribute "
f"in map tile provider {provider.name}")

provider[api_key] = SETTINGS.map_tile_api_token
return provider


def get_provider(provider_str: str) -> xyzservices.TileProvider:
"""
Retrieve the tile provider from the contextily provider dictionary
using a string representation of the provider, e.g.:
- "OpenStreetMap.Mapnik"
- "MapBox"
:param provider_str: provider as string
:return: tile provider corresponding to the string
"""
# Expects either <bunch>.<provider> or <provider> syntax.
parts = provider_str.split(".")
if len(parts) == 1:
# e.g. "MapBox"
provider = getattr(cx.providers, parts[0])
elif len(parts) == 2:
# e.g. "OpenStreetMap.Mapnik"
provider = getattr(getattr(cx.providers, parts[0]), parts[1])
else:
raise ContextilyHelperException(
"Expected tile provider in a format "
"like: 'OpenStreetMap.Mapnik' or 'MapBox'.")

if type(provider) is xyzservices.Bunch:
raise ContextilyHelperException(
f"{provider_str} points to Bunch, not a TileProvider")

provider = add_api_token(provider)

return provider
24 changes: 24 additions & 0 deletions evo/tools/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,3 +893,27 @@ def ros_map(
ax.invert_xaxis()
if SETTINGS.plot_invert_yaxis:
ax.invert_yaxis()


def map_tile(ax: Axes, crs: str, provider: str = SETTINGS.map_tile_provider):
"""
Downloads and inserts a map tile into the plot axis.
Note: requires the optional contextily package to be installed.
:param ax: matplotlib axes
:param crs: coordinate reference system (e.g. "EPSG:4326")
:param provider: tile provider, either as str (e.g. "OpenStreetMap.Mapnik")
or directly as a TileProvider object
"""
if isinstance(ax, Axes3D):
raise PlotException("map_tile can't be drawn into a 3D axis")

try:
import contextily as cx
from evo.tools import contextily_helper
except ImportError as error:
raise PlotException(
f"contextily package is required for plotting map tiles: {error}")

if isinstance(provider, str):
provider = contextily_helper.get_provider(provider_str=provider)
cx.add_basemap(ax, crs=crs, source=provider)
10 changes: 10 additions & 0 deletions evo/tools/settings_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ def get_default_plot_backend() -> str:
"E.g. 'sxyz' or 'ryxy', where s=static or r=rotating frame.\n"
"See evo/core/transformations.py for more information.")
),
"map_tile_provider": (
"OpenStreetMap.Mapnik",
("Map tile provider used by the --map_tile option.\n"
"Requires the contextily package to be installed.\n"
"See: https://contextily.readthedocs.io/en/latest/providers_deepdive.html")
),
"map_tile_api_token": (
"",
"API token for the map_tile_provider, if required."
),
"plot_axis_marker_scale": (
0.,
"Scaling parameter of pose coordinate frame markers. 0 will draw nothing."
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ dependencies = [
"rosbags>=0.9.20",
]

[project.optional-dependencies]
gui = ["PyQt5"]
geo = ["contextily"]


[project.scripts]
evo_ape = "evo.entry_points:ape"
Expand Down

0 comments on commit 29c20d0

Please sign in to comment.