Add --coordinates and --size arguments.

Construct boundary box from center coordinates and size.
This commit is contained in:
Sergey Vartanov 2021-09-16 07:44:41 +03:00
parent cb440e8a8b
commit b715e12924
8 changed files with 156 additions and 114 deletions

View file

@ -76,6 +76,45 @@ class BoundaryBox:
return cls(left, bottom, right, top)
@classmethod
def from_coordinates(
cls,
coordinates: np.ndarray,
zoom_level: float,
width: float,
height: float,
) -> "BoundaryBox":
"""
Compute boundary box from central coordinates, zoom level and resulting
image size.
:param coordinates: boundary box central coordinates
:param zoom_level: resulting image zoom level
:param width: resulting image width
:param height: resulting image height
"""
lat_rad: np.ndarray = np.radians(coordinates[0])
n: float = 2.0 ** (zoom_level + 8.0)
x: int = int((coordinates[1] + 180.0) / 360.0 * n)
left: float = (x - width / 2) / n * 360.0 - 180.0
right: float = (x + width / 2) / n * 360.0 - 180.0
y: int = (1.0 - np.arcsinh(np.tan(lat_rad)) / np.pi) / 2.0 * n
bottom_radians = np.arctan(
np.sinh((1.0 - (y + height / 2) * 2.0 / n) * np.pi)
)
top_radians = np.arctan(
np.sinh((1.0 - (y - height / 2) * 2.0 / n) * np.pi)
)
return cls(
left,
float(np.degrees(bottom_radians)),
right,
float(np.degrees(top_radians)),
)
def min_(self) -> np.ndarray:
"""Get minimum coordinates."""
return np.array((self.bottom, self.left))
@ -113,6 +152,16 @@ class BoundaryBox:
<longitude 1>,<latitude 1>,<longitude 2>,<latitude 2>. Coordinates are
rounded to three digits after comma.
"""
return (
f"{self.left:.3f},{self.bottom:.3f},{self.right:.3f},{self.top:.3f}"
)
left: float = np.floor(self.left * 1000) / 1000
bottom: float = np.floor(self.bottom * 1000) / 1000
right: float = np.ceil(self.right * 1000) / 1000
top: float = np.ceil(self.top * 1000) / 1000
return f"{left:.3f},{bottom:.3f},{right:.3f},{top:.3f}"
def combine(self, other: "BoundaryBox") -> None:
"""Combine with another boundary box."""
self.left = min(self.left, other.left)
self.right = min(self.right, other.right)
self.bottom = min(self.bottom, other.bottom)
self.top = min(self.top, other.top)

View file

@ -406,7 +406,7 @@ class DirectionSector(Tagged):
angle = float(self.get_tag("camera:angle"))
if "angle" in self.tags:
angle = float(self.get_tag("angle"))
direction_radius = 25
direction_radius = 50
direction_color = scheme.get_color("direction_camera_color")
elif self.get_tag("traffic_sign") == "stop":
direction = self.get_tag("direction")

View file

@ -6,7 +6,7 @@ import logging
import sys
from pathlib import Path
from map_machine.ui import parse_options
from map_machine.ui import parse_arguments
from map_machine.workspace import Workspace
__author__ = "Sergey Vartanov"
@ -18,7 +18,7 @@ def main() -> None:
logging.basicConfig(format="%(levelname)s %(message)s", level=logging.INFO)
workspace: Workspace = Workspace(Path("out"))
arguments: argparse.Namespace = parse_options(sys.argv)
arguments: argparse.Namespace = parse_arguments(sys.argv)
if not arguments.command:
logging.fatal("No command provided. See --help.")

View file

@ -54,6 +54,7 @@ class MapConfiguration:
seed: str = ""
show_tooltips: bool = False
country: str = "world"
ignore_level_matching: bool = False
@classmethod
def from_options(

View file

@ -20,7 +20,7 @@ from map_machine.flinger import Flinger
from map_machine.icon import ShapeExtractor
from map_machine.map_configuration import LabelMode, MapConfiguration
from map_machine.osm_getter import NetworkError, get_osm
from map_machine.osm_reader import OSMData, OSMNode, OSMReader, OverpassReader
from map_machine.osm_reader import OSMData, OSMNode
from map_machine.point import Occupied, Point
from map_machine.road import Intersection, RoadPart
from map_machine.scheme import Scheme
@ -94,11 +94,11 @@ class Map:
for tree in constructor.craters:
tree.draw(self.svg, self.flinger)
self.draw_buildings(constructor)
for direction_sector in constructor.direction_sectors:
direction_sector.draw(self.svg, self.scheme)
self.draw_buildings(constructor)
# All other points
occupied: Optional[Occupied]
@ -201,74 +201,73 @@ class Map:
intersection.draw(self.svg, True)
def ui(options: argparse.Namespace) -> None:
def ui(arguments: argparse.Namespace) -> None:
"""
Map Machine entry point.
:param options: command-line arguments
:param arguments: command-line arguments
"""
configuration: MapConfiguration = MapConfiguration.from_options(
options, int(options.zoom)
arguments, int(arguments.zoom)
)
if not options.boundary_box and not options.input_file_name:
logging.fatal("Specify either --boundary-box, or --input.")
exit(1)
if options.boundary_box:
boundary_box: BoundaryBox = BoundaryBox.from_text(options.boundary_box)
cache_path: Path = Path(options.cache)
cache_path: Path = Path(arguments.cache)
cache_path.mkdir(parents=True, exist_ok=True)
input_file_names: list[Path]
boundary_box: Optional[BoundaryBox] = None
input_file_names: list[Path] = []
if options.input_file_name:
input_file_names = list(map(Path, options.input_file_name))
if arguments.input_file_names:
input_file_names = list(map(Path, arguments.input_file_names))
else:
if arguments.boundary_box:
boundary_box = BoundaryBox.from_text(arguments.boundary_box)
else:
coordinates: np.ndarray = np.array(
list(map(float, arguments.coordinates.split(",")))
)
width, height = np.array(
list(map(float, arguments.size.split(",")))
)
boundary_box = BoundaryBox.from_coordinates(
coordinates, configuration.zoom_level, width, height
)
try:
cache_file_path: Path = (
cache_path / f"{boundary_box.get_format()}.osm"
)
get_osm(boundary_box, cache_file_path)
input_file_names = [cache_file_path]
except NetworkError as e:
logging.fatal(e.message)
exit(1)
input_file_names = [cache_file_path]
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
min_: np.ndarray
max_: np.ndarray
osm_data: OSMData
view_box: BoundaryBox
if input_file_names[0].name.endswith(".json"):
reader: OverpassReader = OverpassReader()
reader.parse_json_file(input_file_names[0])
osm_data: OSMData = OSMData()
osm_data = reader.osm_data
view_box = boundary_box
else:
osm_reader = OSMReader()
for file_name in input_file_names:
if not file_name.is_file():
logging.fatal(f"No such file: {file_name}.")
for input_file_name in input_file_names:
if not input_file_name.is_file():
logging.fatal(f"No such file: {input_file_name}.")
exit(1)
osm_reader.parse_osm_file(file_name)
osm_data = osm_reader.osm_data
if options.boundary_box:
view_box = boundary_box
if input_file_name.name.endswith(".json"):
osm_data.parse_overpass(input_file_name)
else:
view_box = osm_data.view_box
osm_data.parse_osm_file(input_file_name)
flinger: Flinger = Flinger(view_box, options.zoom, osm_data.equator_length)
view_box: BoundaryBox = boundary_box if boundary_box else osm_data.view_box
flinger: Flinger = Flinger(
view_box, arguments.zoom, osm_data.equator_length
)
size: np.ndarray = flinger.size
svg: svgwrite.Drawing = svgwrite.Drawing(
options.output_file_name, size=size
arguments.output_file_name, size=size
)
icon_extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
@ -288,6 +287,6 @@ def ui(options: argparse.Namespace) -> None:
)
painter.draw(constructor)
logging.info(f"Writing output SVG to {options.output_file_name}...")
with open(options.output_file_name, "w") as output_file:
logging.info(f"Writing output SVG to {arguments.output_file_name}...")
with open(arguments.output_file_name, "w") as output_file:
svg.write(output_file)

View file

@ -355,119 +355,97 @@ class OSMData:
)
self.relations[relation.id_] = relation
class OverpassReader:
def parse_overpass(self, file_name: Path) -> None:
"""
Reader for JSON structure extracted from Overpass API.
Parse JSON structure extracted from Overpass API.
See https://wiki.openstreetmap.org/wiki/Overpass_API
"""
def __init__(self) -> None:
self.osm_data = OSMData()
def parse_json_file(self, file_name: Path) -> OSMData:
"""Parse JSON structure from the file and construct map."""
with file_name.open() as input_file:
structure = json.load(input_file)
node_map = {}
way_map = {}
node_map: dict[int, OSMNode] = {}
way_map: dict[int, OSMWay] = {}
for element in structure["elements"]:
if element["type"] == "node":
node = OSMNode.parse_from_structure(element)
node_map[node.id_] = node
self.osm_data.add_node(node)
self.add_node(node)
for element in structure["elements"]:
if element["type"] == "way":
way = OSMWay.parse_from_structure(element, node_map)
way_map[way.id_] = way
self.osm_data.add_way(way)
self.add_way(way)
for element in structure["elements"]:
if element["type"] == "relation":
relation = OSMRelation.parse_from_structure(element)
self.osm_data.add_relation(relation)
self.add_relation(relation)
return self.osm_data
class OSMReader:
"""
OpenStreetMap XML file parser.
See https://wiki.openstreetmap.org/wiki/OSM_XML
"""
def __init__(
self,
parse_nodes: bool = True,
parse_ways: bool = True,
parse_relations: bool = True,
) -> None:
"""
:param parse_nodes: whether nodes should be parsed
:param parse_ways: whether ways should be parsed
:param parse_relations: whether relations should be parsed
"""
self.osm_data = OSMData()
self.parse_nodes: bool = parse_nodes
self.parse_ways: bool = parse_ways
self.parse_relations: bool = parse_relations
def parse_osm_file(self, file_name: Path) -> OSMData:
def parse_osm_file(self, file_name: Path) -> None:
"""
Parse OSM XML file.
See https://wiki.openstreetmap.org/wiki/OSM_XML
:param file_name: input XML file
:return: parsed map
"""
return self.parse_osm(ElementTree.parse(file_name).getroot())
self.parse_osm(ElementTree.parse(file_name).getroot())
def parse_osm_text(self, text: str) -> OSMData:
def parse_osm_text(self, text: str) -> None:
"""
Parse OSM XML data from text representation.
:param text: XML text representation
:return: parsed map
"""
return self.parse_osm(ElementTree.fromstring(text))
self.parse_osm(ElementTree.fromstring(text))
def parse_osm(self, root: Element) -> OSMData:
def parse_osm(
self,
root: Element,
parse_nodes: bool = True,
parse_ways: bool = True,
parse_relations: bool = True,
) -> None:
"""
Parse OSM XML data.
:param root: top element of XML data
:return: parsed map
:param parse_nodes: whether nodes should be parsed
:param parse_ways: whether ways should be parsed
:param parse_relations: whether relations should be parsed
"""
for element in root:
if element.tag == "bounds":
self.parse_bounds(element)
elif element.tag == "object":
self.parse_object(element)
elif element.tag == "node" and self.parse_nodes:
elif element.tag == "node" and parse_nodes:
node = OSMNode.from_xml_structure(element)
self.osm_data.add_node(node)
elif element.tag == "way" and self.parse_ways:
self.osm_data.add_way(
OSMWay.from_xml_structure(element, self.osm_data.nodes)
)
elif element.tag == "relation" and self.parse_relations:
self.osm_data.add_relation(
OSMRelation.from_xml_structure(element)
)
return self.osm_data
self.add_node(node)
elif element.tag == "way" and parse_ways:
self.add_way(OSMWay.from_xml_structure(element, self.nodes))
elif element.tag == "relation" and parse_relations:
self.add_relation(OSMRelation.from_xml_structure(element))
def parse_bounds(self, element: Element) -> None:
"""Parse view box from XML element."""
attributes = element.attrib
self.osm_data.view_box = BoundaryBox(
boundary_box: BoundaryBox = BoundaryBox(
float(attributes["minlon"]),
float(attributes["minlat"]),
float(attributes["maxlon"]),
float(attributes["maxlat"]),
)
if self.view_box:
self.view_box.combine(boundary_box)
else:
self.view_box = boundary_box
def parse_object(self, element: Element) -> None:
"""Parse astronomical object properties from XML element."""
self.osm_data.equator_length = float(element.get("equator"))
self.equator_length = float(element.get("equator"))

View file

@ -414,7 +414,10 @@ class Scheme:
continue
if not matcher.is_matched(tags, configuration):
continue
if not matcher.check_zoom_level(configuration.zoom_level):
if (
not configuration.ignore_level_matching
and not matcher.check_zoom_level(configuration.zoom_level)
):
return None, 0
matcher_tags: set[str] = set(matcher.tags.keys())
priority = len(self.node_matchers) - index

View file

@ -29,8 +29,8 @@ COMMANDS: dict[str, list[str]] = {
}
def parse_options(args: list[str]) -> argparse.Namespace:
"""Parse Map Machine command-line options."""
def parse_arguments(args: list[str]) -> argparse.Namespace:
"""Parse Map Machine command-line arguments."""
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Map Machine. OpenStreetMap renderer with custom icon set"
)
@ -203,7 +203,7 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-i",
"--input",
dest="input_file_name",
dest="input_file_names",
metavar="<path>",
nargs="*",
help="input XML file name or names (if not specified, file will be "
@ -238,6 +238,18 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
help="OSM zoom level",
default=18,
)
parser.add_argument(
"-c",
"--coordinates",
metavar="<latitude>,<longitude>",
help="coordinates of any location inside the tile",
)
parser.add_argument(
"-s",
"--size",
metavar="<width>,<height>",
help="resulted image size",
)
def add_mapcss_arguments(parser: argparse.ArgumentParser) -> None: