mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
209 lines
12 KiB
Python
209 lines
12 KiB
Python
"""Deterministic CPU rasterization of Protomaps roads and a supplied route.
|
|
|
|
Styled after the training map renderer: 1 m per pixel, the ego three quarters
|
|
down the frame, pure white roads whose casings are part of the stroke, bridges cutting over the roads beneath
|
|
them, a grey route and a white ego arrow.
|
|
"""
|
|
import math
|
|
from functools import lru_cache
|
|
|
|
import numpy as np
|
|
|
|
from openpilot.selfdrive.navigation.pmtiles import PMTiles
|
|
from openpilot.selfdrive.navigation.route import cumulative_distance, nearest_segment
|
|
from openpilot.selfdrive.navigation.vector_tile import Road, decode_roads
|
|
|
|
SIZE = 256
|
|
ZOOM = 15 # tile zoom; the style zoom is derived from the 1 m per pixel scale instead
|
|
FORWARD_OFFSET = 64.0 # the ego sits three quarters down the frame
|
|
EGO = np.array([SIZE / 2, SIZE / 2 + FORWARD_OFFSET]) # pixel position of the ego
|
|
EARTH_CIRCUMFERENCE = 40075016.68557849 # equatorial, for Web Mercator
|
|
BACKGROUND = 0
|
|
ROAD_INTENSITY = 255
|
|
ROUTE_INTENSITY = 128
|
|
ROUTE_WIDTH = 5.0 # pixels
|
|
CUT_WIDTH = 3.0 # black separator under a bridge, added to the road width
|
|
MAX_ROAD_DISTANCE = 100.0 # meters from the ego to the nearest road centerline for a valid map
|
|
ARROW = np.array([(0, -9), (7, 7), (0, 3), (-7, 7)], dtype=np.float64) # ego marker, pixels around the ego
|
|
ARROW_OUTLINE = 2.0 # pixels
|
|
|
|
|
|
# The training style's road classes as (zoom, width, zoom, width) stops for the interior and for one side of the
|
|
# casing, in pixels. Casings are white there too, so they only widen the stroke. Only each class's top interval is
|
|
# kept: the style zoom at 1 m per pixel stays above 14 until 78 degrees of latitude, where the lower stops begin.
|
|
MAJOR, MEDIUM, STREET, MINOR, TRACK = range(5)
|
|
STOPS = np.array([[[5, 0.75, 18, 32], [10, 1, 18, 2]], # MAJOR: motorway, trunk, primary
|
|
[[5, 0.1, 18, 26], [10, 0.75, 18, 2]], # MEDIUM: secondary, tertiary
|
|
[[14, 2, 18, 18], [12, 0.75, 20, 2]], # STREET: residential, unclassified, living streets, major links
|
|
[[14, 0.5, 18, 12], [12, 0.75, 20, 2]], # MINOR: service roads and minor links
|
|
[[14, 1, 18, 12], [12, 0.75, 20, 2]]], dtype=np.float64) # TRACK
|
|
|
|
|
|
def stroke_widths(zoom: float, styles: np.ndarray, levels: np.ndarray) -> np.ndarray:
|
|
"""Full stroke width of each segment: the style's exponential (base 1.5) interpolation between its stops, flat
|
|
beyond them, with the casing on both sides except for tunnels, which are drawn at their interior width."""
|
|
z0, w0, z1, w1 = np.moveaxis(STOPS, -1, 0)
|
|
z = np.clip(zoom, z0, z1)
|
|
table = w0 + (w1 - w0) * (1.5 ** (z - z0) - 1) / (1.5 ** (z1 - z0) - 1) # (style, interior / casing)
|
|
return table[styles, 0] + np.where(levels < 0, 0, 2 * table[styles, 1])
|
|
|
|
|
|
def road_style(road: Road) -> int | None:
|
|
"""The style for a road, or None when hidden. Protomaps kind_detail is the underlying OSM highway type."""
|
|
detail = road.detail
|
|
if detail in ('platform', 'pier', 'raceway'):
|
|
return None
|
|
if detail in ('motorway', 'trunk', 'primary') or (not detail and road.kind == 'highway'):
|
|
return MAJOR
|
|
if detail in ('secondary', 'tertiary') or (not detail and road.kind == 'major_road'):
|
|
return MEDIUM
|
|
if detail == 'track':
|
|
return TRACK
|
|
if detail in ('secondary_link', 'tertiary_link', 'service', 'driveway', 'parking_aisle', 'alley') or (
|
|
road.kind == 'other' and detail != 'living_street'):
|
|
return MINOR
|
|
return STREET
|
|
|
|
|
|
def road_level(road: Road) -> int:
|
|
"""Draw order: tunnels, then ground roads, then bridges, each cutting over the levels beneath."""
|
|
return 1 if road.bridge else -1 if road.tunnel else 0
|
|
|
|
|
|
def mercator(coordinates: np.ndarray) -> np.ndarray:
|
|
"""Longitude/latitude -> normalized Web Mercator, x east / y south."""
|
|
longitude, latitude = coordinates.T
|
|
y = (1.0 - np.arcsinh(np.tan(np.deg2rad(latitude))) / np.pi) / 2.0
|
|
return np.column_stack(((longitude + 180.0) / 360.0, y))
|
|
|
|
|
|
def project(points: np.ndarray, longitude: float, latitude: float, bearing: float) -> np.ndarray:
|
|
"""Mercator points to pixels of a frame at a position, headed along a bearing in degrees clockwise from north."""
|
|
origin = mercator(np.array([[longitude, latitude]]))[0]
|
|
delta = points - origin
|
|
delta[:, 0] = (delta[:, 0] + 0.5) % 1.0 - 0.5 # wrap across the antimeridian
|
|
delta *= EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude)) # meters, which are pixels
|
|
c, s = math.cos(math.radians(bearing)), math.sin(math.radians(bearing))
|
|
return EGO + delta @ np.array([[c, -s], [s, c]]) # x right, y down
|
|
|
|
|
|
def draw_segments(image: np.ndarray, starts: np.ndarray, ends: np.ndarray, radii: np.ndarray | float, intensity: int,
|
|
*, overlay: bool = False, caps: np.ndarray | None = None):
|
|
"""Strokes with round joins and one pixel of analytic edge antialiasing.
|
|
|
|
caps is (segments, 2): whether each segment's start/end gets a round cap; without it every end is round.
|
|
Max blending (the default) makes draw order irrelevant; overlay replaces the pixels beneath.
|
|
"""
|
|
radii = np.broadcast_to(np.asarray(radii, dtype=np.float64), (len(starts),))
|
|
reach = radii[:, None] + 1
|
|
low = np.maximum(np.floor(np.minimum(starts, ends) - reach), 0).astype(int)
|
|
high = np.minimum(np.ceil(np.maximum(starts, ends) + reach), [image.shape[1] - 1, image.shape[0] - 1]).astype(int)
|
|
# Cull offscreen segments in one pass, then loop over plain Python floats.
|
|
visible = np.flatnonzero((high >= low).all(axis=1))
|
|
round_caps = np.ones((len(starts), 2), dtype=bool) if caps is None else caps
|
|
for i, (sx, sy), (ex, ey), (x0, y0), (x1, y1), radius in zip(visible.tolist(), starts[visible].tolist(), ends[visible].tolist(),
|
|
low[visible].tolist(), high[visible].tolist(), radii[visible].tolist(),
|
|
strict=True):
|
|
dx, dy = ex - sx, ey - sy
|
|
px = np.arange(x0, x1 + 1) + (0.5 - sx)
|
|
py = (np.arange(y0, y1 + 1) + (0.5 - sy))[:, None]
|
|
along = (px * dx + py * dy) / max(dx * dx + dy * dy, 1e-12)
|
|
t = np.clip(along, 0, 1)
|
|
alpha = np.clip(radius + 0.5 - np.hypot(px - t * dx, py - t * dy), 0, 1)
|
|
if not round_caps[i, 0]:
|
|
alpha = alpha * (along >= 0)
|
|
if not round_caps[i, 1]:
|
|
alpha = alpha * (along <= 1)
|
|
view = image[y0:y1 + 1, x0:x1 + 1]
|
|
if overlay:
|
|
view[:] = np.rint(view * (1 - alpha) + intensity * alpha).astype(np.uint8)
|
|
else:
|
|
np.maximum(view, np.rint(alpha * intensity).astype(np.uint8), out=view)
|
|
|
|
|
|
def draw_vehicle(image: np.ndarray):
|
|
"""The training ego marker: a white arrow with a black outline, drawn over everything else."""
|
|
arrow = ARROW + EGO
|
|
(x0, y0), (x1, y1) = (np.floor(arrow.min(axis=0)) - 2).astype(int), (np.ceil(arrow.max(axis=0)) + 2).astype(int)
|
|
px, py = np.meshgrid(np.arange(x0, x1) + 0.5, np.arange(y0, y1) + 0.5)
|
|
inside = np.zeros(px.shape, dtype=bool)
|
|
for a, b, c in ((arrow[0], arrow[1], arrow[2]), (arrow[0], arrow[2], arrow[3])): # the concave arrow as two triangles
|
|
sides = [(q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) for p, q in ((a, b), (b, c), (c, a))]
|
|
inside |= ((sides[0] >= 0) & (sides[1] >= 0) & (sides[2] >= 0)) | ((sides[0] <= 0) & (sides[1] <= 0) & (sides[2] <= 0))
|
|
image[y0:y1, x0:x1][inside] = 255
|
|
draw_segments(image, arrow, np.roll(arrow, -1, axis=0), ARROW_OUTLINE / 2, 0, overlay=True)
|
|
|
|
|
|
class MapRenderer:
|
|
def __init__(self, archive: PMTiles):
|
|
self.archive = archive
|
|
self.zoom = min(max(ZOOM, archive.min_zoom), archive.max_zoom)
|
|
self.roads = lru_cache(maxsize=128)(self._load_roads)
|
|
|
|
def _load_roads(self, x: int, y: int):
|
|
"""A tile's visible road segments as flat arrays: mercator starts/ends, style index, level and polyline end flags."""
|
|
tile = self.archive.get(self.zoom, x, y)
|
|
if tile is None:
|
|
return None
|
|
starts, ends, styles, levels, caps = [np.empty((0, 2))], [np.empty((0, 2))], [np.empty(0, int)], [np.empty(0, int)], [np.empty((0, 2), bool)]
|
|
for road in decode_roads(tile):
|
|
style = road_style(road)
|
|
if style is None:
|
|
continue
|
|
points = (road.points + np.array([x, y])) / (1 << self.zoom)
|
|
n = len(points) - 1
|
|
starts.append(points[:-1])
|
|
ends.append(points[1:])
|
|
styles.append(np.full(n, style))
|
|
levels.append(np.full(n, road_level(road)))
|
|
ends_of_line = np.zeros((n, 2), dtype=bool)
|
|
ends_of_line[0, 0] = ends_of_line[-1, 1] = True
|
|
caps.append(ends_of_line)
|
|
return np.concatenate(starts), np.concatenate(ends), np.concatenate(styles), np.concatenate(levels), np.concatenate(caps)
|
|
|
|
def view_tiles(self, longitude: float, latitude: float) -> list[tuple[int, int]]:
|
|
"""Tiles covering the view at a point for every heading."""
|
|
origin = mercator(np.array([[longitude, latitude]]))[0]
|
|
# A bounding circle includes all headings, forward offset and road stroke widths.
|
|
radius = (SIZE / math.sqrt(2) + FORWARD_OFFSET + 16) / (EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude)))
|
|
count = 1 << self.zoom
|
|
low = np.floor((origin - radius) * count).astype(int)
|
|
high = np.floor((origin + radius) * count).astype(int)
|
|
return [(x % count, y) for y in range(low[1], high[1] + 1) for x in range(low[0], high[0] + 1) if 0 <= y < count]
|
|
|
|
def route_tiles(self, route: np.ndarray) -> set[tuple[int, int]]:
|
|
"""Every tile the view can show while the pose is snapped onto the route, sampled every 100 m."""
|
|
distance = cumulative_distance(route)
|
|
samples = np.column_stack([np.interp(np.arange(0, distance[-1], 100), distance, route[:, axis]) for axis in (0, 1)])
|
|
return {tile for lon, lat in np.vstack((samples, route)).tolist() for tile in self.view_tiles(lon, lat)}
|
|
|
|
def prefetch(self, route: np.ndarray):
|
|
self.archive.prefetch((self.zoom, x, y) for x, y in self.route_tiles(route))
|
|
|
|
def render(self, longitude: float, latitude: float, bearing: float, route: np.ndarray) -> tuple[np.ndarray, bool]:
|
|
"""The map image at a position and bearing, and whether every tile was present with a road near the ego."""
|
|
image = np.full((SIZE, SIZE), BACKGROUND, dtype=np.uint8)
|
|
loaded = [self.roads(x, y) for x, y in self.view_tiles(longitude, latitude)]
|
|
tiles = [tile for tile in loaded if tile is not None]
|
|
# The training renderer's latitude-dependent style zoom: the web mercator zoom with one meter per pixel
|
|
# at this latitude, under the 512-pixel tile convention. Tile zoom and style zoom are independent.
|
|
style_zoom = math.log2(math.cos(math.radians(latitude)) * EARTH_CIRCUMFERENCE / 256) - 1
|
|
road_distance = math.inf
|
|
if tiles:
|
|
starts, ends, styles, levels, caps = (np.concatenate(column) for column in zip(*tiles, strict=True))
|
|
starts, ends = project(starts, longitude, latitude, bearing), project(ends, longitude, latitude, bearing)
|
|
widths = stroke_widths(style_zoom, styles, levels)
|
|
if len(starts):
|
|
road_distance = nearest_segment(starts, ends, EGO)[2]
|
|
for level in np.unique(levels):
|
|
chosen = levels == level
|
|
if level > levels.min():
|
|
# Cut the roads beneath, open at way endpoints so a bridge does not end in a black oval.
|
|
draw_segments(image, starts[chosen], ends[chosen], (widths[chosen] + CUT_WIDTH) / 2, BACKGROUND,
|
|
overlay=True, caps=~caps[chosen])
|
|
draw_segments(image, starts[chosen], ends[chosen], widths[chosen] / 2, ROAD_INTENSITY)
|
|
pixels = project(mercator(route), longitude, latitude, bearing)
|
|
draw_segments(image, pixels[:-1], pixels[1:], ROUTE_WIDTH / 2, ROUTE_INTENSITY, overlay=True)
|
|
draw_vehicle(image)
|
|
return image, len(tiles) == len(loaded) and road_distance <= MAX_ROAD_DISTANCE
|