Skip to content

Commit 5e790af

Browse files
committed
Issue #203: support Overpass.
1 parent 8c714a3 commit 5e790af

File tree

5 files changed

+207
-24
lines changed

5 files changed

+207
-24
lines changed

map_machine/geometry/bounding_box.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525
)
2626

2727

28+
def floor(value: float) -> float:
29+
"""Round down to 3 digits after the point."""
30+
return np.floor(value * 1000.0) / 1000.0
31+
32+
33+
def ceil(value: float) -> float:
34+
"""Round up to 3 digits after the point."""
35+
return np.ceil(value * 1000.0) / 1000.0
36+
37+
2838
@dataclass
2939
class BoundingBox:
3040
"""Rectangle that limits the space on the map."""
@@ -156,15 +166,25 @@ def get_format(self) -> str:
156166
"""Get text representation of the bounding box.
157167
158168
Bounding box format is
159-
<longitude 1>,<latitude 1>,<longitude 2>,<latitude 2>. Coordinates are
169+
<longitude 1>,<latitude 1>,<longitude 2>,<latitude 2>. Coordinates are
160170
rounded to three decimal places.
161171
"""
162-
left: float = np.floor(self.left * 1000.0) / 1000.0
163-
bottom: float = np.floor(self.bottom * 1000.0) / 1000.0
164-
right: float = np.ceil(self.right * 1000.0) / 1000.0
165-
top: float = np.ceil(self.top * 1000.0) / 1000.0
172+
return (
173+
f"{floor(self.left):.3f},{floor(self.bottom):.3f},"
174+
f"{ceil(self.right):.3f},{ceil(self.top):.3f}"
175+
)
176+
177+
def get_overpass_format(self) -> str:
178+
"""Get bounding box in Overpass API format.
166179
167-
return f"{left:.3f},{bottom:.3f},{right:.3f},{top:.3f}"
180+
Overpass bounding box format is <south>,<west>,<north>,<east>, which
181+
maps to <bottom>,<left>,<top>,<right>. Minimum coordinates are floored
182+
and maximum coordinates are ceiled to three decimal places.
183+
"""
184+
return (
185+
f"{floor(self.bottom):.3f},{floor(self.left):.3f},"
186+
f"{ceil(self.top):.3f},{ceil(self.right):.3f}"
187+
)
168188

169189
def update(self, coordinates: np.ndarray) -> None:
170190
"""Make the bounding box cover coordinates."""

map_machine/mapper.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
NetworkError,
3333
find_incomplete_relations,
3434
get_osm,
35+
get_osm_overpass,
3536
get_overpass_relations,
3637
)
3738
from map_machine.osm.osm_reader import OSMData, OSMNode
@@ -412,19 +413,41 @@ def render_map(arguments: argparse.Namespace) -> None:
412413

413414
# Determine files.
414415

416+
overpass_query: str | None = None
417+
overpass_query_path: str | None = getattr(arguments, "overpass_query", None)
418+
if overpass_query_path:
419+
with Path(overpass_query_path).open(encoding="utf-8") as query_file:
420+
overpass_query = query_file.read()
421+
415422
input_file_names: list[Path]
416423
if arguments.input_file_names:
417424
input_file_names = list(map(Path, arguments.input_file_names))
418425
elif bounding_box:
419-
try:
426+
overpass_cache_path: Path = (
427+
cache_path / f"{bounding_box.get_format()}_overpass.osm"
428+
)
429+
if overpass_cache_path.is_file():
430+
input_file_names = [overpass_cache_path]
431+
else:
420432
cache_file_path: Path = (
421433
cache_path / f"{bounding_box.get_format()}.osm"
422434
)
423-
get_osm(bounding_box, cache_file_path)
424-
input_file_names = [cache_file_path]
425-
except NetworkError as error:
426-
logger.fatal(error.message)
427-
sys.exit(1)
435+
try:
436+
get_osm(bounding_box, cache_file_path)
437+
input_file_names = [cache_file_path]
438+
except NetworkError as error:
439+
logger.warning(
440+
"OSM API failed (%s), falling back to Overpass API...",
441+
error.message,
442+
)
443+
try:
444+
get_osm_overpass(
445+
bounding_box, overpass_cache_path, overpass_query
446+
)
447+
input_file_names = [overpass_cache_path]
448+
except NetworkError as overpass_error:
449+
logger.fatal(overpass_error.message)
450+
sys.exit(1)
428451
else:
429452
fatal(
430453
"Specify `--input`, `--bounding-box`, `--coordinates`, or `--gpx`."

map_machine/osm/osm_getter.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import time
99
from dataclasses import dataclass
10+
from textwrap import dedent
1011
from typing import TYPE_CHECKING
1112

1213
import urllib3
@@ -26,6 +27,23 @@
2627
MAX_OSM_MESSAGE_LENGTH: int = 500
2728
OVERPASS_API_URL: str = "https://overpass-api.de/api/interpreter"
2829

30+
DEFAULT_OVERPASS_FILTER: str = dedent(
31+
"""
32+
(
33+
way["building"];
34+
way["highway"];
35+
way["natural"="water"];
36+
way["waterway"];
37+
way["natural"="wood"];
38+
way["landuse"="forest"];
39+
relation["natural"="water"];
40+
relation["landuse"="forest"];
41+
);
42+
(._;>;);
43+
out body;
44+
"""
45+
)
46+
2947

3048
@dataclass
3149
class NetworkError(Exception):
@@ -158,3 +176,57 @@ def get_overpass_relations(
158176

159177
cache_file.write_bytes(content)
160178
return content
179+
180+
181+
def get_osm_overpass(
182+
bounding_box: BoundingBox,
183+
cache_file_path: Path,
184+
query: str | None = None,
185+
*,
186+
to_update: bool = False,
187+
) -> str:
188+
"""Download OSM data from the Overpass API.
189+
190+
Uses a simplified filter query to fetch only major map features, which
191+
avoids failures when the standard OSM API returns too much data.
192+
193+
:param bounding_box: borders of the map part to download
194+
:param cache_file_path: cache file to store downloaded OSM data
195+
:param query: custom Overpass query with ``{{bbox}}`` placeholder; if None,
196+
the default filter is used
197+
:param to_update: update cache files
198+
"""
199+
if not to_update and cache_file_path.is_file():
200+
with cache_file_path.open(encoding="utf-8") as output_file:
201+
return output_file.read()
202+
203+
box: str = bounding_box.get_overpass_format()
204+
205+
if query is not None:
206+
full_query = query.replace("{{bbox}}", box)
207+
else:
208+
full_query = f"[out:xml][bbox:{box}];\n{DEFAULT_OVERPASS_FILTER}"
209+
210+
logger.info("Querying Overpass API for bounding box %s...", box)
211+
212+
try:
213+
content: bytes = get_data(OVERPASS_API_URL, {"data": full_query})
214+
except NetworkError:
215+
logger.warning("Failed to download data from Overpass API.")
216+
raise
217+
218+
if not content or not content.strip().startswith(b"<"):
219+
if len(content) < MAX_OSM_MESSAGE_LENGTH:
220+
message = (
221+
"Unexpected Overpass API response: `"
222+
+ content.decode("utf-8")
223+
+ "`."
224+
)
225+
else:
226+
message = "Unexpected Overpass API response."
227+
raise NetworkError(message)
228+
229+
with cache_file_path.open("bw+") as output_file:
230+
output_file.write(content)
231+
232+
return content.decode("utf-8")

map_machine/slippy/tile.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
NetworkError,
2626
find_incomplete_relations,
2727
get_osm,
28+
get_osm_overpass,
2829
get_overpass_relations,
2930
)
3031
from map_machine.osm.osm_reader import OSMData
@@ -141,15 +142,33 @@ def get_extended_bounding_box(self) -> BoundingBox:
141142
float(point_1[0]),
142143
).round()
143144

144-
def load_osm_data(self, cache_path: Path) -> OSMData:
145+
def load_osm_data(
146+
self, cache_path: Path, overpass_query: str | None = None
147+
) -> OSMData:
145148
"""Construct map data from extended bounding box.
146149
147150
:param cache_path: directory to store OSM data files
151+
:param overpass_query: custom Overpass query with `{{bbox}}`
152+
placeholder
148153
"""
149-
cache_file_path: Path = (
150-
cache_path / f"{self.get_extended_bounding_box().get_format()}.osm"
154+
bounding_box: BoundingBox = self.get_extended_bounding_box()
155+
overpass_cache_path: Path = (
156+
cache_path / f"{bounding_box.get_format()}_overpass.osm"
151157
)
152-
get_osm(self.get_extended_bounding_box(), cache_file_path)
158+
159+
if overpass_cache_path.is_file():
160+
cache_file_path = overpass_cache_path
161+
else:
162+
cache_file_path = cache_path / f"{bounding_box.get_format()}.osm"
163+
try:
164+
get_osm(bounding_box, cache_file_path)
165+
except NetworkError as error:
166+
logger.warning(
167+
"OSM API failed (%s), falling back to Overpass API...",
168+
error.message,
169+
)
170+
cache_file_path = overpass_cache_path
171+
get_osm_overpass(bounding_box, cache_file_path, overpass_query)
153172

154173
osm_data: OSMData = OSMData()
155174
osm_data.parse_osm_file(cache_file_path)
@@ -178,16 +197,19 @@ def draw(
178197
configuration: MapConfiguration,
179198
*,
180199
use_overpass: bool = True,
200+
overpass_query: str | None = None,
181201
) -> None:
182202
"""Draw tile to SVG and PNG files.
183203
184204
:param directory_name: output directory for storing tiles
185205
:param cache_path: directory to store SVG and PNG tiles
186206
:param configuration: drawing configuration
187207
:param use_overpass: fetch missing relation data via Overpass API
208+
:param overpass_query: custom Overpass query with `{{bbox}}`
209+
placeholder
188210
"""
189211
try:
190-
osm_data: OSMData = self.load_osm_data(cache_path)
212+
osm_data: OSMData = self.load_osm_data(cache_path, overpass_query)
191213
except NetworkError as error:
192214
msg = f"Map is not loaded. {error.message}"
193215
raise NetworkError(msg) from error
@@ -300,12 +322,35 @@ def from_bounding_box(
300322

301323
return cls(tiles, tile_1, tile_2, zoom_level, extended_bounding_box)
302324

303-
def load_osm_data(self, cache_path: Path) -> OSMData:
304-
"""Load OpenStreetMap data."""
305-
cache_file_path: Path = (
306-
cache_path / f"{self.bounding_box.get_format()}.osm"
325+
def load_osm_data(
326+
self, cache_path: Path, overpass_query: str | None = None
327+
) -> OSMData:
328+
"""Load OpenStreetMap data.
329+
330+
:param cache_path: directory for caching OSM data files
331+
:param overpass_query: custom Overpass query with `{{bbox}}` placeholder
332+
"""
333+
overpass_cache_path: Path = (
334+
cache_path / f"{self.bounding_box.get_format()}_overpass.osm"
307335
)
308-
get_osm(self.bounding_box, cache_file_path)
336+
337+
if overpass_cache_path.is_file():
338+
cache_file_path = overpass_cache_path
339+
else:
340+
cache_file_path = (
341+
cache_path / f"{self.bounding_box.get_format()}.osm"
342+
)
343+
try:
344+
get_osm(self.bounding_box, cache_file_path)
345+
except NetworkError as error:
346+
logger.warning(
347+
"OSM API failed (%s), falling back to Overpass API...",
348+
error.message,
349+
)
350+
cache_file_path = overpass_cache_path
351+
get_osm_overpass(
352+
self.bounding_box, cache_file_path, overpass_query
353+
)
309354

310355
osm_data: OSMData = OSMData()
311356
osm_data.parse_osm_file(cache_file_path)
@@ -517,6 +562,12 @@ def generate_tiles(options: argparse.Namespace) -> None:
517562

518563
use_overpass: bool = not getattr(options, "no_overpass", False)
519564

565+
overpass_query: str | None = None
566+
overpass_query_path: str | None = getattr(options, "overpass_query", None)
567+
if overpass_query_path:
568+
with Path(overpass_query_path).open(encoding="utf-8") as query_file:
569+
overpass_query = query_file.read()
570+
520571
if options.input_file_name:
521572
osm_data = OSMData()
522573
osm_data.parse_osm_file(Path(options.input_file_name))
@@ -548,7 +599,7 @@ def generate_tiles(options: argparse.Namespace) -> None:
548599
np.array(coordinates), min_zoom_level
549600
)
550601
try:
551-
osm_data = min_tile.load_osm_data(cache_path)
602+
osm_data = min_tile.load_osm_data(cache_path, overpass_query)
552603
except NetworkError as error:
553604
message = f"Map is not loaded. {error.message}"
554605
raise NetworkError(message) from error
@@ -577,6 +628,7 @@ def generate_tiles(options: argparse.Namespace) -> None:
577628
cache_path,
578629
configuration,
579630
use_overpass=use_overpass,
631+
overpass_query=overpass_query,
580632
)
581633

582634
elif options.bounding_box:
@@ -589,7 +641,7 @@ def generate_tiles(options: argparse.Namespace) -> None:
589641

590642
min_tiles: Tiles = Tiles.from_bounding_box(bounding_box, min_zoom_level)
591643
try:
592-
osm_data = min_tiles.load_osm_data(cache_path)
644+
osm_data = min_tiles.load_osm_data(cache_path, overpass_query)
593645
except NetworkError as error:
594646
message = f"Map is not loaded. {error.message}"
595647
raise NetworkError(message) from error

map_machine/ui/cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,14 @@ def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
278278
action="store_true",
279279
default=False,
280280
)
281+
parser.add_argument(
282+
"--overpass-query",
283+
metavar="<path>",
284+
help=(
285+
"path to a custom Overpass query file; use {{bbox}} as a "
286+
"placeholder for the bounding box"
287+
),
288+
)
281289
parser.add_argument(
282290
"-c",
283291
"--coordinates",
@@ -370,6 +378,14 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
370378
action="store_true",
371379
default=False,
372380
)
381+
parser.add_argument(
382+
"--overpass-query",
383+
metavar="<path>",
384+
help=(
385+
"path to a custom Overpass query file; use {{bbox}} as a "
386+
"placeholder for the bounding box"
387+
),
388+
)
373389
parser.add_argument(
374390
"-i",
375391
"--input",

0 commit comments

Comments
 (0)