Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 125 additions & 22 deletions map_machine/feature/road.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@

import numpy as np
from colour import Color
from shapely.geometry import LineString
from svgwrite.path import Path
from svgwrite.text import TextPath

from map_machine.geometry.vector import (
Line,
Expand All @@ -28,6 +30,7 @@

from map_machine.drawing import PathCommands
from map_machine.geometry.flinger import Flinger
from map_machine.pictogram.point import Occupied
from map_machine.scheme import RoadMatcher, Scheme

__author__ = "Sergey Vartanov"
Expand Down Expand Up @@ -640,31 +643,110 @@ def draw_lanes(self, svg: Drawing, color: Color | None = None) -> None:
path.update(style)
svg.add(path)

def draw_caption(self, svg: Drawing) -> None:
"""Draw road name along its path."""
def draw_caption(
self,
svg: Drawing,
occupied: Occupied | None,
path_id: str,
) -> bool:
"""Draw road name along its path with collision detection.

:param svg: SVG drawing
:param occupied: occupied pixel matrix for collision detection
:param path_id: unique identifier for the SVG path element
:return: True if the label was drawn, False if skipped
"""
if self.is_area:
return False

name: str | None = self.tags.get("name")
if not name:
return
return False

if (
path_commands := self.line.get_path(self.placement_offset + 3.0)
) is None:
return
font_size: float = self.matcher.font_size
char_width: float = font_size * 0.6
text_width: float = len(name) * char_width

path: Path = svg.path(d=path_commands, fill="none")
svg.add(path)
road_length: float = self.line.length()
if road_length < text_width * 1.5:
return False

# Skip very curvy roads where text would be illegible.
straight_distance: float = float(
np.linalg.norm(self.line.points[-1] - self.line.points[0])
)
if straight_distance < road_length * 0.3:
return False

# Ensure text reads left-to-right.
line: Polyline = self.line
if not line.is_left_to_right():
line = line.reversed()

# Compute the offset path used for text rendering.
text_offset: float = self.placement_offset - font_size * 0.35
path_commands: str | None = line.get_path(text_offset)
if path_commands is None:
return False

# Collision detection along the actual offset path where text renders.
if occupied is not None:
half_band: float = font_size * 0.7
sample_interval: float = 1.0

# Build the offset geometry to sample from.
offset_line = LineString(line.points)
if not np.allclose(text_offset, 0.0):
offset_line = offset_line.parallel_offset(text_offset)

total_length: float = offset_line.length
text_start: float = (total_length - text_width) / 2.0
text_end: float = (total_length + text_width) / 2.0

# Check phase.
distance = text_start
while distance <= text_end:
center = offset_line.interpolate(distance)
cx, cy = int(center.x), int(center.y)
for dy in range(int(-half_band), int(half_band) + 1):
if occupied.check(np.array((cx, cy + dy))):
return False
distance += sample_interval

# Register phase.
distance = text_start
while distance <= text_end:
center = offset_line.interpolate(distance)
cx, cy = int(center.x), int(center.y)
for d in range(int(-half_band), int(half_band) + 1):
occupied.register(np.array((cx, cy + d)))
occupied.register(np.array((cx + d, cy)))
distance += sample_interval

# Place the invisible reference path in <defs>.
ref_path = svg.path(
d=path_commands, id=path_id, fill="none", stroke="none"
)
svg.defs.add(ref_path)

text = svg.add(svg.text.Text(""))
text_path = svg.text.TextPath(
path=path,
text_element = svg.text(
"",
font_size=font_size,
font_family="Helvetica",
fill="#333333",
)
text_path_element = TextPath(
ref_path,
text=name,
startOffset=None,
startOffset="50%",
method="align",
spacing="exact",
font_family="Roboto",
font_size=10.0,
)
text.add(text_path)
text_path_element["text-anchor"] = "middle"
text_element.add(text_path_element)
svg.add(text_element)

return True


def get_curve_points(
Expand Down Expand Up @@ -916,9 +998,7 @@ def draw_simple(self, svg: Drawing) -> None:
):
road.draw(svg, is_border=False)

def draw_lanes(
self, svg: Drawing, flinger: Flinger, *, draw_captions: bool = False
) -> None:
def draw_lanes(self, svg: Drawing, flinger: Flinger) -> None:
"""Draw whole road system with lanes and width."""
if not self.roads:
return
Expand Down Expand Up @@ -1027,6 +1107,29 @@ def draw_lanes(
for road in roads:
road.draw_lanes(svg, road.matcher.border_color)

if draw_captions:
for road in self.roads:
road.draw_caption(svg)
def draw_labels(self, svg: Drawing, occupied: Occupied | None) -> None:
"""Draw road name labels with collision detection.

Labels higher-priority roads first. Only one label per unique road
name to avoid repetition.
"""
sorted_roads: list[Road] = sorted(
self.roads,
key=lambda r: (-r.matcher.priority, -r.line.length()),
)

labeled_names: set[str] = set()
path_counter: int = 0

for road in sorted_roads:
name: str | None = road.tags.get("name")
if not name:
continue

if name in labeled_names:
continue

path_id: str = f"road-label-path-{path_counter}"
if road.draw_caption(svg, occupied, path_id):
labeled_names.add(name)
path_counter += 1
14 changes: 14 additions & 0 deletions map_machine/geometry/vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ def get_path(self, parallel_offset: float = 0.0) -> str | None:
+ (" Z" if np.allclose(points[0], points[-1]) else "")
)

def length(self) -> float:
"""Get the total length of the polyline in pixels."""
return LineString(self.points).length

def is_left_to_right(self) -> bool:
"""Check whether the polyline runs generally left-to-right."""
if len(self.points) < 2:
return True
return self.points[-1][0] >= self.points[0][0]

def reversed(self) -> Polyline:
"""Return a new Polyline with points in reversed order."""
return Polyline(list(reversed(self.points)))

def shorten(self, index: int, length: float) -> None:
"""Shorten the part at the specified index."""
index_2: int = 1 if index == 0 else -2
Expand Down
26 changes: 16 additions & 10 deletions map_machine/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ def draw(self, constructor: Constructor) -> None:
# Experimental debug drawing:
# `self.draw_complex_roads(constructor.roads.roads)`.

occupied: Occupied | None = None
if self.configuration.overlap != 0:
occupied = Occupied(
int(self.flinger.size[0]),
int(self.flinger.size[1]),
self.configuration.overlap,
)

# Draw road labels after roads, before other features.
if (
self.configuration.road_mode != RoadMode.NO
and self.configuration.label_mode != LabelMode.NO
):
logger.info("Drawing road labels...")
constructor.roads.draw_labels(self.svg, occupied)

for figure in top_figures:
path_commands = figure.get_path(self.flinger)

Expand Down Expand Up @@ -161,16 +177,6 @@ def draw(self, constructor: Constructor) -> None:
# All other points

if self.scheme.nodes:
occupied: Occupied | None
if self.configuration.overlap == 0:
occupied = None
else:
occupied = Occupied(
int(self.flinger.size[0]),
int(self.flinger.size[1]),
self.configuration.overlap,
)

nodes: list[Point] = sorted(
constructor.points, key=lambda x: -x.priority
)
Expand Down
16 changes: 16 additions & 0 deletions map_machine/pictogram/point.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ def register(self, point: np.ndarray) -> None:
self.matrix[point[0], point[1]] = True
assert self.matrix[point[0], point[1]]

def dump_debug_image(self, path: str) -> None:
"""Write occupied matrix as a PNG image for debugging.

White pixels are free, black pixels are occupied.
"""
from PIL import Image # noqa: PLC0415

# Matrix is (width, height) with [x, y] indexing; transpose to get
# (height, width) row-major image.
pixel_data: np.ndarray = np.where(self.matrix.T, 0, 255).astype(
np.uint8
)
image: Image.Image = Image.fromarray(pixel_data, mode="L")
image.save(path)
logger.info("Occupied debug image saved to `%s`.", path)


def draw_icon_safe(
icon_specification: IconSpecification,
Expand Down
2 changes: 2 additions & 0 deletions map_machine/scheme.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ class RoadMatcher(Matcher):
color: Color | None = None
default_width: float | None = None
priority: float = 0.0
font_size: float = 10.0

@classmethod
def from_structure(
Expand All @@ -340,6 +341,7 @@ def from_structure(
road_matcher.color = Color(scheme.get_color(structure["color"]))
road_matcher.default_width = structure["default_width"]
road_matcher.priority = structure.get("priority", 0.0)
road_matcher.font_size = structure.get("font_size", 10.0)
return road_matcher

def get_priority(self, tags: Tags) -> float:
Expand Down
17 changes: 17 additions & 0 deletions map_machine/scheme/roads.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,98 @@ roads:
border_color: $motorway_border_color
color: $motorway_color
priority: 41.8
font_size: 12.0
- tags: {highway: trunk}
default_width: 7.0
border_color: $motorway_border_color
color: $motorway_color
priority: 41.0
font_size: 12.0
- tags: {highway: trunk_link}
default_width: 7.0
border_color: $motorway_border_color
color: $motorway_color
priority: 41.0
font_size: 12.0
- tags: {highway: primary}
default_width: 7.0
border_color: $primary_border_color
color: $primary_color
priority: 41.7
font_size: 11.0
- tags: {highway: primary_link}
default_width: 7.0
border_color: $primary_border_color
color: $primary_color
priority: 41.7
font_size: 11.0
- tags: {highway: motorway_link}
default_width: 7.0
border_color: $motorway_border_color
color: $motorway_color
priority: 41.8
font_size: 12.0
- tags: {highway: secondary}
default_width: 7.0
border_color: $secondary_border_color
priority: 41.6
color: $secondary_color
font_size: 10.0
- tags: {highway: secondary_link}
default_width: 7.0
border_color: $secondary_border_color
priority: 41.6
color: $secondary_color
font_size: 10.0
- tags: {highway: tertiary}
default_width: 7.0
border_color: $tertiary_border_color
priority: 41.5
color: $tertiary_color
font_size: 10.0
- tags: {highway: tertiary_link}
default_width: 7.0
border_color: $tertiary_border_color
priority: 41.5
color: $tertiary_color
font_size: 10.0
- tags: {highway: unclassified}
default_width: 5.0
border_color: $road_border_color
priority: 41.0
font_size: 9.0
- tags: {highway: residential}
default_width: 5.0
border_color: $road_border_color
priority: 41.0
font_size: 9.0
- tags: {highway: living_street}
default_width: 4.0
border_color: $road_border_color
priority: 41.0
font_size: 9.0
- tags: {highway: service}
exception: {service: parking_aisle}
default_width: 3.0
border_color: $road_border_color
priority: 41.0
font_size: 8.0
- tags: {highway: service, service: parking_aisle}
default_width: 2.0
border_color: $road_border_color
priority: 41.0
font_size: 8.0
- tags: {leisure: track}
exception: {area: "yes"}
color: $pitch_color
border_color: $pitch_border_color
default_width: 5.0
priority: 21.0
font_size: 9.0

- tags: {highway: raceway}
color: $pitch_color
border_color: $pitch_border_color
default_width: 7.0
priority: 21.0
font_size: 9.0
1 change: 1 addition & 0 deletions tests/test_command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
b"INFO Constructing ways...\n"
b"INFO Constructing nodes...\n"
b"INFO Drawing ways...\n"
b"INFO Drawing road labels...\n"
b"INFO Drawing main icons...\n"
b"INFO Drawing extra icons...\n"
b"INFO Drawing texts...\n"
Expand Down
Loading