mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
971 lines
44 KiB
Python
971 lines
44 KiB
Python
"""Heading-up road-line map, drawn as a translucent overlay on the Android Auto screen.
|
|
|
|
Roads come from the compact tiles ``navtilesd`` writes; this module never
|
|
touches the network. The work is split so no frame waits on anything slow:
|
|
|
|
worker thread reads and decodes tiles, and turns roads and the route into
|
|
triangle strips for a north-up canvas around the car
|
|
prepare() outside any parent render target: draws a finished canvas
|
|
into a texture (only when one is ready), then composes the
|
|
widget: canvas rotated heading-up, the car, a glass tint
|
|
draw() inside the frame: one textured quad through a shader that
|
|
rounds and feathers the edges; opacity fades the map, not navigation guidance
|
|
|
|
Colors are premultiplied end to end, so translucent glows and the feathered
|
|
edge composite without dark fringes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pyray as rl
|
|
|
|
from openpilot.starpilot.gps.acquisition import GpsAcquisition, acquisition_progress
|
|
from openpilot.starpilot.gps.source import GPS_MAX_AGE_NS, PHONE_MAX_AGE_NS
|
|
from openpilot.starpilot.navigation.road_tiles import (
|
|
DATA_ZOOM, EXTENT, LINK, MAJOR, MINOR, MOTORWAY, STREET, RoadTile, TileFormatError, TileKey, decode_road_tile,
|
|
encode_road_tile, meters_per_tile, world_xy,
|
|
)
|
|
from openpilot.system.ui.lib.application import GL_VERSION
|
|
|
|
SUPERSAMPLE = 1.5 # canvas pixels per screen pixel: edges are smoothed when it is scaled down
|
|
MAX_CANVAS = 2560
|
|
TILE_CACHE = 48
|
|
MISSING_RETRY_S = 3.0
|
|
DEAD_RECKON_S = 1.0
|
|
PHONE_DEAD_RECKON_S = GPS_MAX_AGE_NS / 1e9 # cover phone measurement/transport gaps, independently of the 10 s hold
|
|
ROUTE_STALE_OVERLAY_SECONDS = 10.0 # a route lost mid-guidance only brings back "Starting navigation" after this long
|
|
STARTING_OVERLAY_MIN_SECONDS = 5.0 # "Starting navigation" stays up at least this long, even when the route loads sooner
|
|
DESTINATION_MAX_CHARS = 32
|
|
CAR_ANCHOR = 0.68 # the car sits below center so more road ahead is visible
|
|
ZOOM_SLOW_M_PER_PX = 0.9 # parking-lot and city detail
|
|
ZOOM_FAST_M_PER_PX = 3.4 # highway overview
|
|
MIN_HEADING_SPEED = 1.5 # m/s; GPS heading is noise below this
|
|
GUIDANCE_SLACK_PX = 4.0 # screen pixels of driven route left under the puck before guidance is trimmed again
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoadStyle:
|
|
fill: tuple[int, int, int]
|
|
width: float # screen pixels at the reference zoom
|
|
hide_above_m_per_px: float = math.inf
|
|
|
|
|
|
# Bottom to top. Casings go under every fill so crossings read cleanly.
|
|
ROAD_STYLES: dict[int, RoadStyle] = {
|
|
MINOR: RoadStyle((112, 124, 140), 2.4, hide_above_m_per_px=2.2),
|
|
STREET: RoadStyle((176, 188, 204), 3.4, hide_above_m_per_px=4.5),
|
|
LINK: RoadStyle((214, 220, 232), 3.6),
|
|
MAJOR: RoadStyle((236, 240, 246), 5.2),
|
|
MOTORWAY: RoadStyle((255, 196, 92), 6.8),
|
|
}
|
|
CASING = (0, 0, 0, 200)
|
|
CASING_EXTRA = 2.6
|
|
ROUTE_GLOW = (150, 110, 255, 90)
|
|
ROUTE_CORE = (178, 150, 255, 255)
|
|
ROUTE_DONE = (118, 104, 150, 200)
|
|
GLASS = (8, 12, 20, 132)
|
|
SOURCE_LABELS = {"comma": "Comma GPS", "car": "Car GPS", "phone": "Phone GPS"}
|
|
REFERENCE_M_PER_PX = 1.4
|
|
|
|
|
|
def premultiplied(color: Sequence[int], alpha_scale: float = 1.0) -> rl.Color:
|
|
r, g, b, a = (*color, 255) if len(color) == 3 else color
|
|
alpha = a / 255.0 * alpha_scale
|
|
return rl.Color(round(r * alpha), round(g * alpha), round(b * alpha), round(255 * alpha))
|
|
|
|
|
|
def zoom_for_speed(speed_mps: float) -> float:
|
|
"""Meters per screen pixel: street detail when slow, a wider view at highway speed."""
|
|
t = min(1.0, max(0.0, (speed_mps - 8.0) / 24.0))
|
|
t = t * t * (3 - 2 * t)
|
|
return ZOOM_SLOW_M_PER_PX + (ZOOM_FAST_M_PER_PX - ZOOM_SLOW_M_PER_PX) * t
|
|
|
|
|
|
# ---------------------------------------------------------------- geometry
|
|
|
|
def line_strip(points: np.ndarray, offsets: np.ndarray, half_width: float, bounds: tuple[float, float, float, float],
|
|
max_miter: float = 2.5) -> np.ndarray:
|
|
"""One triangle strip for many polylines: mitered quads, joined by degenerate triangles.
|
|
|
|
``points`` (N, 2) and ``offsets`` (L + 1) describe L polylines of at least two
|
|
points. Lines entirely outside ``bounds`` (x0, y0, x1, y1) are skipped.
|
|
"""
|
|
if len(offsets) < 2 or len(points) < 2:
|
|
return np.empty((0, 2), np.float32)
|
|
starts, ends = offsets[:-1], offsets[1:]
|
|
x0, y0, x1, y1 = bounds
|
|
lo, hi = np.minimum.reduceat(points, starts, axis=0), np.maximum.reduceat(points, starts, axis=0)
|
|
keep = (hi[:, 0] >= x0) & (lo[:, 0] <= x1) & (hi[:, 1] >= y0) & (lo[:, 1] <= y1) & (ends - starts >= 2)
|
|
if not keep.any():
|
|
return np.empty((0, 2), np.float32)
|
|
lengths = (ends - starts)[keep]
|
|
line_offsets = np.concatenate(([0], np.cumsum(lengths)[:-1]))
|
|
index = np.arange(int(lengths.sum())) - np.repeat(line_offsets, lengths) + np.repeat(starts[keep], lengths)
|
|
p = points[index].astype(np.float32)
|
|
first = np.zeros(len(p), bool)
|
|
first[np.concatenate(([0], np.cumsum(lengths)[:-1]))] = True
|
|
last = np.roll(first, -1)
|
|
last[-1] = True
|
|
|
|
seg = np.diff(p, axis=0)
|
|
norm = np.hypot(seg[:, 0], seg[:, 1])
|
|
seg /= np.maximum(norm, 1e-6)[:, None]
|
|
normal = np.stack((-seg[:, 1], seg[:, 0]), axis=1) # left of travel
|
|
n_prev = np.vstack((normal[:1], normal)) # segment ending at each vertex
|
|
n_next = np.vstack((normal, normal[-1:])) # segment starting at each vertex
|
|
n_prev[first] = n_next[first]
|
|
n_next[last] = n_prev[last]
|
|
miter = n_prev + n_next
|
|
miter_len = np.hypot(miter[:, 0], miter[:, 1])
|
|
flat = miter_len < 1e-3 # a full reversal: fall back to the segment normal
|
|
miter[flat] = n_next[flat]
|
|
miter_len[flat] = 1.0
|
|
miter /= miter_len[:, None]
|
|
scale = 1.0 / np.clip(np.einsum("ij,ij->i", miter, n_next), 1.0 / max_miter, 1.0)
|
|
offset = miter * (scale * half_width)[:, None]
|
|
|
|
# Right of travel, then left: front-facing for raylib in y-down screen space,
|
|
# so backface culling never drops a road.
|
|
strip = np.empty((len(p) * 2, 2), np.float32)
|
|
strip[0::2] = p - offset
|
|
strip[1::2] = p + offset
|
|
# Repeat the last vertex of each line and the first of the next: two zero-area
|
|
# triangles bridge the gap and keep every real triangle's winding.
|
|
repeat = np.ones(len(strip), np.int64)
|
|
line_starts = np.flatnonzero(first) * 2
|
|
repeat[line_starts[1:]] += 1
|
|
repeat[line_starts[1:] - 1] += 1
|
|
return np.repeat(strip, repeat, axis=0)
|
|
|
|
|
|
@dataclass
|
|
class CanvasRequest:
|
|
center: tuple[float, float] # world (data-zoom tile units) at the canvas center
|
|
m_per_px: float # screen meters per pixel this canvas was built for
|
|
side: int # canvas texture side in pixels (supersampled)
|
|
route: np.ndarray | None # (N, 2) world points ahead of the car
|
|
route_done: np.ndarray | None # (N, 2) world points already driven
|
|
tiles: tuple[TileKey, ...]
|
|
|
|
|
|
@dataclass
|
|
class CanvasGeometry:
|
|
request: CanvasRequest
|
|
layers: list[tuple[rl.Color, np.ndarray]] = field(default_factory=list)
|
|
tiles_missing: int = 0
|
|
guidance: list[tuple[rl.Color, np.ndarray]] = field(default_factory=list)
|
|
|
|
|
|
def build_canvas(request: CanvasRequest, tiles: dict[TileKey, RoadTile], latitude: float) -> CanvasGeometry:
|
|
"""Screen-space strips for a north-up canvas. Pure numpy; runs off the render thread."""
|
|
side = request.side
|
|
px_per_m = SUPERSAMPLE / request.m_per_px
|
|
px_per_unit = meters_per_tile(latitude) * px_per_m # canvas pixels per world tile unit
|
|
cx, cy = request.center
|
|
bounds = (-8.0, -8.0, side + 8.0, side + 8.0)
|
|
ratio = math.sqrt(REFERENCE_M_PER_PX / request.m_per_px)
|
|
width_scale = SUPERSAMPLE * min(1.35, max(0.7, ratio))
|
|
geometry = CanvasGeometry(request)
|
|
per_class: dict[int, list[tuple[np.ndarray, np.ndarray]]] = {}
|
|
for key in request.tiles:
|
|
tile = tiles.get(key)
|
|
if tile is None:
|
|
geometry.tiles_missing += 1
|
|
continue
|
|
ox = (key.x - cx) * px_per_unit + side / 2
|
|
oy = (key.y - cy) * px_per_unit + side / 2
|
|
scale = px_per_unit / EXTENT
|
|
for road_class, (points, offsets) in tile.lines.items():
|
|
if request.m_per_px > ROAD_STYLES[road_class].hide_above_m_per_px:
|
|
continue
|
|
per_class.setdefault(road_class, []).append((points * scale + (ox, oy), offsets))
|
|
merged = {}
|
|
for road_class, parts in per_class.items():
|
|
points = np.concatenate([part[0] for part in parts])
|
|
offsets, base = [np.zeros(1, np.int32)], 0
|
|
for part_points, part_offsets in parts:
|
|
offsets.append(part_offsets[1:] + base)
|
|
base += len(part_points)
|
|
merged[road_class] = (points, np.concatenate(offsets))
|
|
order = [c for c in ROAD_STYLES if c in merged]
|
|
for road_class in order:
|
|
style = ROAD_STYLES[road_class]
|
|
strip = line_strip(*merged[road_class], (style.width + CASING_EXTRA) * width_scale / 2, bounds)
|
|
if len(strip):
|
|
geometry.layers.append((premultiplied(CASING), strip))
|
|
for road_class in order:
|
|
style = ROAD_STYLES[road_class]
|
|
strip = line_strip(*merged[road_class], style.width * width_scale / 2, bounds)
|
|
if len(strip):
|
|
geometry.layers.append((premultiplied(style.fill), strip))
|
|
|
|
geometry.guidance = route_guidance(request.route, request, latitude)
|
|
return geometry
|
|
|
|
|
|
def route_guidance(route, request: CanvasRequest, latitude: float):
|
|
"""Build only the remaining route; the road canvas never bakes in a driven tail."""
|
|
if route is None or len(route) < 2:
|
|
return []
|
|
scale = meters_per_tile(latitude) * SUPERSAMPLE / request.m_per_px
|
|
points = ((route - request.center) * scale + request.side / 2).astype(np.float32)
|
|
width_scale = SUPERSAMPLE * min(1.35, max(0.7, math.sqrt(REFERENCE_M_PER_PX / request.m_per_px)))
|
|
bounds = (-8.0, -8.0, request.side + 8.0, request.side + 8.0)
|
|
# Every frame draws these strips: keep only the stretches that can reach the canvas, widest line included.
|
|
reach = 8.0 + 17.0 * width_scale / 2
|
|
points, offsets = visible_runs(points, (-reach, -reach, request.side + reach, request.side + reach))
|
|
layers = []
|
|
for color, width in ((ROUTE_GLOW, 17.0), (ROUTE_CORE, 7.0)):
|
|
strip = line_strip(points, offsets, width * width_scale / 2, bounds)
|
|
if len(strip):
|
|
layers.append((premultiplied(color), strip))
|
|
return layers
|
|
|
|
|
|
def visible_runs(points: np.ndarray, bounds: tuple[float, float, float, float]) -> tuple[np.ndarray, np.ndarray]:
|
|
"""The polyline's stretches whose segments can touch ``bounds``, as (points, offsets) for line_strip.
|
|
|
|
A stretch is broken only where a whole segment lies outside, so seams never show inside the bounds.
|
|
"""
|
|
x0, y0, x1, y1 = bounds
|
|
a, b = points[:-1], points[1:]
|
|
lo, hi = np.minimum(a, b), np.maximum(a, b)
|
|
keep = (hi[:, 0] >= x0) & (lo[:, 0] <= x1) & (hi[:, 1] >= y0) & (lo[:, 1] <= y1)
|
|
if keep.all():
|
|
return points, np.array([0, len(points)], np.int32)
|
|
edges = np.diff(np.concatenate(([0], keep.astype(np.int8), [0])))
|
|
runs = [points[start:end + 1] for start, end in zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1), strict=True)]
|
|
if not runs:
|
|
return points[:0], np.zeros(1, np.int32)
|
|
return np.concatenate(runs), np.concatenate(([0], np.cumsum([len(run) for run in runs]))).astype(np.int32)
|
|
|
|
|
|
def split_route(route: np.ndarray | None, position: tuple[float, float]) -> tuple[np.ndarray | None, np.ndarray | None]:
|
|
"""(driven, ahead) world polylines, split at the point on the route nearest the car."""
|
|
if route is None or len(route) < 2:
|
|
return None, None
|
|
a, b = route[:-1], route[1:]
|
|
ab = b - a
|
|
t = np.clip(np.einsum("ij,ij->i", np.asarray(position) - a, ab) / np.maximum(np.einsum("ij,ij->i", ab, ab), 1e-12), 0, 1)
|
|
nearest = a + ab * t[:, None]
|
|
index = int(np.argmin(np.hypot(*(nearest - position).T)))
|
|
split = nearest[index]
|
|
return np.vstack((route[:index + 1], split)), np.vstack((split, route[index + 1:]))
|
|
|
|
|
|
# ---------------------------------------------------------------- data
|
|
|
|
@dataclass(frozen=True)
|
|
class MapFix:
|
|
latitude: float
|
|
longitude: float
|
|
bearing: float | None
|
|
speed: float
|
|
monotonic: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MapInput:
|
|
fix: MapFix | None
|
|
route: tuple[tuple[float, float], ...] = () # (latitude, longitude)
|
|
navigation_active: bool = False
|
|
satellites: int | None = None
|
|
gps_wait_seconds: float = 0.0
|
|
destination: str = '' # where navigation is going: "Home", "Work" or the place name
|
|
source: str = '' # where the fix comes from: comma, car or phone
|
|
|
|
|
|
class TileReader:
|
|
"""Road tiles from disk, decoded on a worker thread; the render thread only reads the cache."""
|
|
|
|
def __init__(self, root: Path | None = None, clock: Callable[[], float] = time.monotonic):
|
|
from openpilot.starpilot.navigation.offline_roads import TileStore
|
|
self.store = TileStore(root)
|
|
self.clock = clock
|
|
self._lock = threading.Condition()
|
|
self._tiles: OrderedDict[TileKey, RoadTile] = OrderedDict()
|
|
self._missing: dict[TileKey, float] = {}
|
|
self._wanted: tuple[TileKey, ...] = ()
|
|
self._jobs: list[Callable[[], None]] = []
|
|
self.generation = 0
|
|
self._stopped = False
|
|
self._thread = threading.Thread(target=self._run, name="map-tiles", daemon=True)
|
|
self._thread.start()
|
|
|
|
def want(self, keys: Sequence[TileKey]) -> None:
|
|
with self._lock:
|
|
self._wanted = tuple(keys)
|
|
self._lock.notify()
|
|
|
|
def submit(self, job: Callable[[], None]) -> None:
|
|
with self._lock:
|
|
self._jobs = [job] # only the newest canvas matters
|
|
self._lock.notify()
|
|
|
|
def snapshot(self, keys: Sequence[TileKey]) -> dict[TileKey, RoadTile]:
|
|
with self._lock:
|
|
return {key: self._tiles[key] for key in keys if key in self._tiles}
|
|
|
|
def _next_load(self) -> TileKey | None:
|
|
now = self.clock()
|
|
for key in self._wanted:
|
|
if key not in self._tiles and self._missing.get(key, -math.inf) <= now:
|
|
return key
|
|
return None
|
|
|
|
def _run(self) -> None:
|
|
while True:
|
|
with self._lock:
|
|
while not self._stopped and not self._jobs and self._next_load() is None:
|
|
self._lock.wait(timeout=MISSING_RETRY_S / 2)
|
|
if self._stopped:
|
|
return
|
|
key = self._next_load()
|
|
job = self._jobs.pop() if key is None and self._jobs else None
|
|
if key is not None:
|
|
raw = self.store.read(key)
|
|
try:
|
|
tile = decode_road_tile(key, raw) if raw is not None else None
|
|
except TileFormatError:
|
|
tile = None
|
|
with self._lock:
|
|
if tile is None:
|
|
self._missing[key] = self.clock() + MISSING_RETRY_S
|
|
else:
|
|
self._missing.pop(key, None)
|
|
self._tiles[key] = tile
|
|
self._tiles.move_to_end(key)
|
|
while len(self._tiles) > max(TILE_CACHE, len(self._wanted)):
|
|
self._tiles.popitem(last=False)
|
|
self.generation += 1
|
|
elif job is not None:
|
|
try:
|
|
job()
|
|
except Exception:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
self._stopped = True
|
|
self._lock.notify()
|
|
self._thread.join(timeout=1.0)
|
|
|
|
|
|
# ---------------------------------------------------------------- shader
|
|
|
|
MASK_VERTEX = GL_VERSION + """
|
|
in vec3 vertexPosition;
|
|
in vec2 vertexTexCoord;
|
|
in vec4 vertexColor;
|
|
out vec2 fragTexCoord;
|
|
uniform mat4 mvp;
|
|
void main() {
|
|
fragTexCoord = vertexTexCoord;
|
|
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
|
}
|
|
"""
|
|
|
|
MASK_FRAGMENT = GL_VERSION + """
|
|
in vec2 fragTexCoord;
|
|
out vec4 finalColor;
|
|
uniform sampler2D texture0;
|
|
uniform sampler2D guidance;
|
|
uniform vec2 size; // widget pixels
|
|
uniform float radius; // corner radius in pixels
|
|
uniform float feather; // soft edge width in pixels
|
|
uniform float opacity;
|
|
void main() {
|
|
vec2 p = fragTexCoord * size;
|
|
vec2 q = abs(p - size * 0.5) - (size * 0.5 - vec2(radius));
|
|
float outside = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; // rounded-rect distance, < 0 inside
|
|
float edge = clamp(-outside / feather, 0.0, 1.0);
|
|
edge = edge * edge * (3.0 - 2.0 * edge);
|
|
// The glass fades a little toward the top so the road ahead blends into the camera.
|
|
float top = smoothstep(0.0, 0.22, fragTexCoord.y) * 0.35 + 0.65;
|
|
// A faint glass rim where the feather meets the solid part.
|
|
float rim = (1.0 - smoothstep(0.0, 1.6, abs(outside + feather))) * 0.14;
|
|
vec2 uv = vec2(fragTexCoord.x, 1.0 - fragTexCoord.y);
|
|
vec4 base = texture(texture0, uv) * (opacity * top);
|
|
vec4 route = texture(guidance, uv);
|
|
finalColor = (route + base * (1.0 - route.a)) * edge + vec4(rim * opacity * top) * (1.0 - route.a);
|
|
}
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------- overlay
|
|
|
|
class MapOverlay:
|
|
"""Owns two canvases (front/back), a widget texture and the mask shader."""
|
|
|
|
def __init__(self, root: Path | None = None, *, reader: TileReader | SampleTileReader | None = None, fonts=None,
|
|
clock: Callable[[], float] = time.monotonic):
|
|
self.reader = reader or TileReader(root, clock)
|
|
self.fonts = fonts
|
|
self.clock = clock
|
|
self._lock = threading.Lock()
|
|
self._ready: CanvasGeometry | None = None
|
|
self._pending: CanvasRequest | None = None
|
|
self._canvas: rl.RenderTexture | None = None
|
|
self._canvas_request: CanvasRequest | None = None
|
|
self._widget: rl.RenderTexture | None = None
|
|
self._guidance: rl.RenderTexture | None = None
|
|
self._route_layers: list[tuple[rl.Color, np.ndarray]] = []
|
|
self._guidance_key = None
|
|
self._guidance_at: tuple[float, float] | None = None
|
|
self._widget_size: tuple[int, int] | None = None
|
|
self._shader: rl.Shader | None = None
|
|
self._uniforms: dict[str, int] = {}
|
|
self._uniform_values = (rl.ffi.new("Vector2 *"), rl.ffi.new("float *"), rl.ffi.new("float *"), rl.ffi.new("float *"))
|
|
self._pending_at = -math.inf
|
|
self.has_roads = False
|
|
self._route_key: tuple | None = None
|
|
self._route_world: np.ndarray | None = None
|
|
self._route_last_shown: float | None = None # clock time the route was last available this navigation session
|
|
self._starting_since: float | None = None # clock time "Starting navigation" went up
|
|
self._generation_built = -1
|
|
# Smoothed display state.
|
|
self._world: tuple[float, float] | None = None
|
|
self._latitude = 0.0
|
|
self._bearing = 0.0
|
|
self._has_heading = False
|
|
self._m_per_px = ZOOM_SLOW_M_PER_PX
|
|
self._last_prepare: float | None = None
|
|
self.status = "waiting"
|
|
self._input = MapInput(None)
|
|
|
|
# ---- state
|
|
|
|
def _advance(self, data: MapInput, now: float) -> None:
|
|
self._input = data
|
|
self.status = "live" if data.fix is not None and 0 <= now - data.fix.monotonic <= fix_hold_seconds(data.source) else "waiting"
|
|
fix = data.fix
|
|
dt = 0.0 if self._last_prepare is None else min(0.2, max(0.0, now - self._last_prepare))
|
|
self._last_prepare = now
|
|
if fix is None:
|
|
return
|
|
target_world = world_xy(fix.latitude, fix.longitude)
|
|
age = min(PHONE_DEAD_RECKON_S if data.source == "phone" else DEAD_RECKON_S, max(0.0, now - fix.monotonic))
|
|
if fix.bearing is not None and fix.speed > MIN_HEADING_SPEED:
|
|
units = fix.speed * age / meters_per_tile(fix.latitude)
|
|
heading = math.radians(fix.bearing)
|
|
target_world = (target_world[0] + math.sin(heading) * units, target_world[1] - math.cos(heading) * units)
|
|
delta = (fix.bearing - self._bearing + 540.0) % 360.0 - 180.0
|
|
self._bearing = (self._bearing + delta * (min(1.0, dt * 6.0) if self._has_heading else 1.0)) % 360.0
|
|
self._has_heading = True
|
|
if self._world is None or math.hypot(target_world[0] - self._world[0], target_world[1] - self._world[1]) > 0.5:
|
|
self._world = target_world # first fix or a jump: no slow glide across town
|
|
else:
|
|
k = min(1.0, dt * 12.0)
|
|
self._world = (self._world[0] + (target_world[0] - self._world[0]) * k,
|
|
self._world[1] + (target_world[1] - self._world[1]) * k)
|
|
self._latitude = fix.latitude
|
|
target_zoom = zoom_for_speed(fix.speed) if math.isfinite(fix.speed) else self._m_per_px
|
|
self._m_per_px += (target_zoom - self._m_per_px) * min(1.0, dt * 0.8)
|
|
|
|
def _route(self, route: tuple[tuple[float, float], ...]) -> None:
|
|
if route is self._route_key:
|
|
return # MapFeed hands back the same tuple until the route changes
|
|
if route != self._route_key:
|
|
self._guidance_key = None
|
|
self._route_world = np.array([world_xy(lat, lon) for lat, lon in route], np.float64) if len(route) >= 2 else None
|
|
self._route_key = route
|
|
|
|
def _track_route(self, data: MapInput, now: float) -> None:
|
|
if self._route_world is not None:
|
|
self._route_last_shown = now
|
|
elif not data.navigation_active:
|
|
self._route_last_shown = None
|
|
if not data.navigation_active:
|
|
self._starting_since = None
|
|
elif self._waiting_for_route(data, now):
|
|
if self._starting_since is None:
|
|
self._starting_since = now
|
|
elif self._starting_since is not None and now - self._starting_since >= STARTING_OVERLAY_MIN_SECONDS:
|
|
self._starting_since = None
|
|
|
|
def _waiting_for_route(self, data: MapInput, now: float) -> bool:
|
|
"""Navigation is active without a route: at once on a fresh start, after a grace period once a route was lost."""
|
|
return (data.navigation_active and self._route_world is None and
|
|
(self._route_last_shown is None or now - self._route_last_shown >= ROUTE_STALE_OVERLAY_SECONDS))
|
|
|
|
def _starting_navigation(self) -> bool:
|
|
"""While waiting for the route, and for a short minimum so the card can be read when the route is quick."""
|
|
now = self.clock()
|
|
return self._waiting_for_route(self._input, now) or (
|
|
self._input.navigation_active and self._starting_since is not None and now - self._starting_since < STARTING_OVERLAY_MIN_SECONDS)
|
|
|
|
# ---- canvas planning
|
|
|
|
@staticmethod
|
|
def _reach(width: int, height: int) -> float:
|
|
"""Screen pixels from the car to the widget's farthest corner, at any rotation."""
|
|
return math.hypot(width / 2, height * max(CAR_ANCHOR, 1 - CAR_ANCHOR))
|
|
|
|
def _canvas_side(self, width: int, height: int) -> int:
|
|
return int(min(MAX_CANVAS, math.ceil(self._reach(width, height) * 2 * 1.6 * SUPERSAMPLE / 64) * 64))
|
|
|
|
def _needs_canvas(self, side: int) -> bool:
|
|
request = self._canvas_request
|
|
if request is None or request.side != side:
|
|
return True
|
|
assert self._world is not None
|
|
assert self._widget_size is not None
|
|
px_per_unit = meters_per_tile(self._latitude) * SUPERSAMPLE / request.m_per_px
|
|
drift = math.hypot(self._world[0] - request.center[0], self._world[1] - request.center[1]) * px_per_unit
|
|
reach = self._reach(*self._widget_size) * SUPERSAMPLE * self._m_per_px / request.m_per_px
|
|
ratio = request.m_per_px / self._m_per_px
|
|
return drift > (side / 2 - reach) * 0.5 or not 0.82 < ratio < 1.22 or self.reader.generation != self._generation_built
|
|
|
|
def _request(self, side: int) -> CanvasRequest:
|
|
assert self._world is not None
|
|
reach_m = side / SUPERSAMPLE * self._m_per_px * 0.75
|
|
reach = reach_m / meters_per_tile(self._latitude)
|
|
x, y = self._world
|
|
tiles = tuple(TileKey(DATA_ZOOM, tx % (1 << DATA_ZOOM), ty)
|
|
for ty in range(int(math.floor(y - reach)), int(math.floor(y + reach)) + 1)
|
|
for tx in range(int(math.floor(x - reach)), int(math.floor(x + reach)) + 1)
|
|
if 0 <= ty < 1 << DATA_ZOOM)
|
|
done, ahead = split_route(self._route_world, self._world) if self._route_world is not None else (None, None)
|
|
return CanvasRequest(self._world, self._m_per_px, side, ahead, done, tiles)
|
|
|
|
def _schedule(self, request: CanvasRequest) -> None:
|
|
self._pending = request
|
|
self._pending_at = self.clock()
|
|
self.reader.want(sorted(request.tiles, key=lambda k: (k.x + 0.5 - request.center[0]) ** 2 + (k.y + 0.5 - request.center[1]) ** 2))
|
|
generation = self.reader.generation
|
|
latitude = self._latitude
|
|
|
|
def job():
|
|
geometry = build_canvas(request, self.reader.snapshot(request.tiles), latitude)
|
|
with self._lock:
|
|
if self._pending is request:
|
|
self._ready = geometry
|
|
self._generation_built = generation
|
|
self.reader.submit(job)
|
|
|
|
# ---- GPU
|
|
|
|
def _ensure_targets(self, width: int, height: int, side: int) -> bool:
|
|
if self._widget_size != (width, height):
|
|
if self._widget is not None:
|
|
rl.unload_render_texture(self._widget)
|
|
if self._guidance is not None:
|
|
rl.unload_render_texture(self._guidance)
|
|
self._widget = rl.load_render_texture(width, height)
|
|
self._guidance = rl.load_render_texture(width, height)
|
|
self._widget_size = None
|
|
if not self._widget.id or not self._guidance.id:
|
|
for target in (self._widget, self._guidance):
|
|
if target.id:
|
|
rl.unload_render_texture(target)
|
|
self._widget = self._guidance = None
|
|
return False
|
|
self._widget_size = (width, height)
|
|
for target in (self._widget, self._guidance):
|
|
rl.set_texture_filter(target.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
|
if self._shader is None:
|
|
self._shader = rl.load_shader_from_memory(MASK_VERTEX, MASK_FRAGMENT)
|
|
if not self._shader.id:
|
|
raise RuntimeError("Map overlay shader unavailable")
|
|
self._uniforms = {name: rl.get_shader_location(self._shader, name) for name in ("size", "radius", "feather", "opacity", "guidance")}
|
|
return True
|
|
|
|
def _draw_canvas(self, geometry: CanvasGeometry) -> None:
|
|
side = geometry.request.side
|
|
if self._canvas is None or self._canvas.texture.width != side:
|
|
if self._canvas is not None:
|
|
rl.unload_render_texture(self._canvas)
|
|
self._canvas = rl.load_render_texture(side, side)
|
|
if not self._canvas.id:
|
|
self._canvas = None
|
|
return
|
|
rl.set_texture_filter(self._canvas.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
|
rl.begin_texture_mode(self._canvas)
|
|
try:
|
|
rl.clear_background(rl.BLANK)
|
|
rl.begin_blend_mode(rl.BlendMode.BLEND_ALPHA_PREMULTIPLY)
|
|
for color, strip in geometry.layers:
|
|
strip = np.ascontiguousarray(strip, np.float32)
|
|
rl.draw_triangle_strip(rl.ffi.from_buffer("Vector2 *", strip), len(strip), color)
|
|
rl.end_blend_mode()
|
|
finally:
|
|
rl.end_texture_mode()
|
|
self._guidance_key = None
|
|
self._canvas_request = geometry.request
|
|
self.has_roads = geometry.tiles_missing < len(geometry.request.tiles)
|
|
|
|
def _puck(self, x: float, y: float) -> None:
|
|
rl.draw_circle_v(rl.Vector2(x, y), 26, premultiplied((150, 110, 255, 70)))
|
|
points = [(0, -19), (-14, 15), (0, 7), (14, 15)]
|
|
outline = [rl.Vector2(x + px * 1.25, y + py * 1.25) for px, py in points]
|
|
body = [rl.Vector2(x + px, y + py) for px, py in points]
|
|
for shape, color in ((outline, premultiplied((96, 70, 210))), (body, premultiplied((255, 255, 255)))):
|
|
rl.draw_triangle(shape[0], shape[1], shape[2], color)
|
|
rl.draw_triangle(shape[0], shape[2], shape[3], color)
|
|
|
|
def _compass(self, width: int, height: int) -> None:
|
|
"""A small needle in the top right that always points north."""
|
|
inset = max(22.0, min(width, height) * 0.09)
|
|
cx, cy = width - inset, inset
|
|
rl.draw_circle_v(rl.Vector2(cx, cy), 13, premultiplied((8, 12, 20, 170)))
|
|
angle = math.radians(-self._bearing)
|
|
def point(r, a):
|
|
return rl.Vector2(cx + r * math.sin(angle + a), cy - r * math.cos(angle + a))
|
|
rl.draw_triangle(point(10, 0), point(5, -2.3), point(5, 2.3), premultiplied((255, 110, 96)))
|
|
rl.draw_triangle(point(10, math.pi), point(5, math.pi + 2.3), point(5, math.pi - 2.3), premultiplied((210, 214, 224, 200)))
|
|
|
|
def _compose_layer(self, width: int, height: int, guidance: bool) -> None:
|
|
rl.begin_texture_mode(self._guidance if guidance else self._widget)
|
|
try:
|
|
rl.clear_background(rl.BLANK)
|
|
rl.begin_blend_mode(rl.BlendMode.BLEND_ALPHA_PREMULTIPLY)
|
|
if not guidance:
|
|
rl.draw_rectangle(0, 0, width, height, premultiplied(GLASS))
|
|
request = self._canvas_request
|
|
if self._canvas is not None and request is not None and self._world is not None:
|
|
px_per_unit = meters_per_tile(self._latitude) / request.m_per_px
|
|
zoom = request.m_per_px / self._m_per_px / SUPERSAMPLE
|
|
side = request.side
|
|
# The car's spot on the canvas, in canvas pixels.
|
|
car_x = (self._world[0] - request.center[0]) * px_per_unit * SUPERSAMPLE + side / 2
|
|
car_y = (self._world[1] - request.center[1]) * px_per_unit * SUPERSAMPLE + side / 2
|
|
anchor = rl.Vector2(width / 2, height * CAR_ANCHOR)
|
|
if guidance:
|
|
# Road geometry is cached across many frames. Guidance is trimmed at the
|
|
# interpolated car, but only again once the car has moved a few screen
|
|
# pixels: the driven bit in between stays under the puck. Re-trimming
|
|
# every frame halved the car view's frame rate on long routes.
|
|
moved = math.inf if self._guidance_at is None else math.hypot(self._world[0] - self._guidance_at[0],
|
|
self._world[1] - self._guidance_at[1])
|
|
slack = GUIDANCE_SLACK_PX * self._m_per_px / meters_per_tile(self._latitude)
|
|
if self._guidance_key != id(request) or moved > slack:
|
|
_, ahead = split_route(self._route_world, self._world)
|
|
self._route_layers = route_guidance(ahead, request, self._latitude)
|
|
self._guidance_key = id(request)
|
|
self._guidance_at = self._world
|
|
rl.rl_push_matrix()
|
|
try:
|
|
rl.rl_translatef(anchor.x, anchor.y, 0)
|
|
rl.rl_rotatef(-self._bearing, 0, 0, 1)
|
|
rl.rl_scalef(zoom, zoom, 1)
|
|
rl.rl_translatef(-car_x, -car_y, 0)
|
|
for color, strip in self._route_layers:
|
|
strip = np.ascontiguousarray(strip, np.float32)
|
|
rl.draw_triangle_strip(rl.ffi.from_buffer("Vector2 *", strip), len(strip), color)
|
|
finally:
|
|
rl.rl_pop_matrix()
|
|
if self.status == "live":
|
|
self._puck(anchor.x, anchor.y)
|
|
self._compass(width, height)
|
|
else:
|
|
rl.draw_texture_pro(self._canvas.texture, rl.Rectangle(0, 0, side, -side),
|
|
rl.Rectangle(anchor.x, anchor.y, side * zoom, side * zoom),
|
|
rl.Vector2(car_x * zoom, car_y * zoom), -self._bearing, rl.WHITE)
|
|
rl.end_blend_mode()
|
|
finally:
|
|
rl.end_texture_mode()
|
|
|
|
def _compose(self, width: int, height: int) -> None:
|
|
self._compose_layer(width, height, False)
|
|
self._compose_layer(width, height, True)
|
|
|
|
# ---- public
|
|
|
|
def prepare(self, data: MapInput, width: int, height: int) -> None:
|
|
"""Outside any render target: advance, rebuild the canvas if needed and compose the widget."""
|
|
width, height = max(16, int(width)), max(16, int(height))
|
|
if not self._ensure_targets(width, height, 0):
|
|
return
|
|
now = self.clock()
|
|
self._advance(data, now)
|
|
self._route(data.route)
|
|
self._track_route(data, now)
|
|
if self._world is not None:
|
|
side = self._canvas_side(width, height)
|
|
with self._lock:
|
|
ready, self._ready = self._ready, None
|
|
if ready is not None:
|
|
self._draw_canvas(ready)
|
|
in_flight = self._pending is not None and self._canvas_request is not self._pending and now - self._pending_at < 2.0
|
|
if not in_flight and self._needs_canvas(side):
|
|
self._schedule(self._request(side))
|
|
self._compose(width, height)
|
|
|
|
def draw(self, rect: rl.Rectangle, opacity: float) -> None:
|
|
"""Inside the frame: the composed widget, rounded, feathered and faded."""
|
|
if self._widget is None or self._shader is None:
|
|
return
|
|
assert self._widget_size is not None
|
|
width, height = self._widget_size
|
|
size, radius, feather, alpha = self._uniform_values
|
|
size.x, size.y = width, height
|
|
radius[0] = min(width, height) * 0.09
|
|
feather[0] = max(10.0, min(width, height) * 0.07)
|
|
alpha[0] = max(0.0, min(1.0, opacity))
|
|
rl.begin_blend_mode(rl.BlendMode.BLEND_ALPHA_PREMULTIPLY)
|
|
rl.begin_shader_mode(self._shader)
|
|
try:
|
|
# Typed pointers: pyray drops float arrays and by-value structs passed as uniform data.
|
|
vec2, scalar = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2, rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
|
|
for name, value, kind in (("size", size, vec2), ("radius", radius, scalar), ("feather", feather, scalar), ("opacity", alpha, scalar)):
|
|
rl.set_shader_value(self._shader, self._uniforms[name], value, kind)
|
|
rl.set_shader_value_texture(self._shader, self._uniforms["guidance"], self._guidance.texture)
|
|
rl.draw_texture_pro(self._widget.texture, rl.Rectangle(0, 0, width, height), rect, rl.Vector2(0, 0), 0, rl.WHITE)
|
|
finally:
|
|
rl.end_shader_mode()
|
|
rl.end_blend_mode()
|
|
if self.status == "waiting":
|
|
self._draw_gps_status(rect)
|
|
return
|
|
self._draw_source_chip(rect)
|
|
if self._starting_navigation():
|
|
self._draw_starting_navigation(rect)
|
|
return
|
|
text = "" if self.has_roads or self._canvas_request is None else "No map data here yet"
|
|
if text and self.fonts is not None:
|
|
from openpilot.starpilot.ui.presentation import FontRole
|
|
measured = self.fonts.measure(text, FontRole.MEDIUM, 26)
|
|
self.fonts.draw(text, FontRole.MEDIUM, 26, rect.x + (rect.width - measured.width) / 2,
|
|
rect.y + rect.height * CAR_ANCHOR + 40, rl.Color(225, 228, 238, round(200 * opacity)))
|
|
|
|
def _draw_source_chip(self, rect: rl.Rectangle) -> None:
|
|
"""Where the fix comes from, left of the compass; solid whatever the map's opacity so it always reads."""
|
|
label = SOURCE_LABELS.get(self._input.source)
|
|
if label is None or self.fonts is None:
|
|
return
|
|
from openpilot.starpilot.ui.presentation import FontRole
|
|
scale = max(.7, min(1.0, rect.width / 560, rect.height / 420))
|
|
size, pad_x, height = 17 * scale, 10 * scale, 26 * scale
|
|
inset = max(22.0, min(rect.width, rect.height) * 0.09)
|
|
measured = self.fonts.measure(label, FontRole.SEMI_BOLD, size)
|
|
width = measured.width + 2 * pad_x
|
|
chip = rl.Rectangle(rect.x + rect.width - inset - 13 - 8 * scale - width, rect.y + inset - height / 2, width, height)
|
|
if chip.x < rect.x + 4:
|
|
return
|
|
rl.draw_rectangle_rounded(chip, 1.0, 10, rl.Color(15, 13, 23, 255))
|
|
rl.draw_rectangle_rounded_lines_ex(chip, 1.0, 10, 1.5, rl.Color(150, 140, 200, 255))
|
|
self.fonts.draw(label, FontRole.SEMI_BOLD, size, chip.x + pad_x, chip.y + (height - measured.height) / 2, rl.Color(240, 242, 250, 255))
|
|
|
|
def _draw_starting_navigation(self, rect: rl.Rectangle) -> None:
|
|
"""Shown while navigation is starting; solid whatever the map's opacity so it always reads."""
|
|
if self.fonts is None:
|
|
return
|
|
from openpilot.starpilot.ui.presentation import FontRole
|
|
scale = max(.7, min(1.0, rect.width / 560, rect.height / 420))
|
|
inset, padding = 20 * scale, 14 * scale
|
|
width = rect.width - 2 * inset
|
|
destination = self._input.destination.strip()
|
|
if len(destination) > DESTINATION_MAX_CHARS:
|
|
destination = destination[:DESTINATION_MAX_CHARS - 1].rstrip() + '…'
|
|
rows = [('Starting navigation', FontRole.SEMI_BOLD, 26)]
|
|
if destination:
|
|
rows.append((f'to {destination}', FontRole.MEDIUM, 22))
|
|
if self._route_world is None:
|
|
rows.append(('Finding your route…', FontRole.NORMAL, 18))
|
|
scale = min(scale, *(max(1, width - 2 * padding) / self.fonts.measure(text, role, size).width
|
|
for text, role, size in rows))
|
|
height = (len(rows) * 32 + 22) * scale
|
|
card = rl.Rectangle(rect.x + inset, rect.y + rect.height - inset - height, width, height)
|
|
rl.draw_rectangle_rounded(card, .2, 8, rl.Color(15, 13, 23, 255))
|
|
y = card.y + 10 * scale
|
|
for text, role, size in rows:
|
|
measured = self.fonts.measure(text, role, size * scale)
|
|
self.fonts.draw(text, role, size * scale, card.x + (card.width - measured.width) / 2, y,
|
|
rl.Color(225, 228, 238, 255))
|
|
y += 32 * scale
|
|
|
|
def _draw_gps_status(self, rect: rl.Rectangle) -> None:
|
|
"""AAComma's acquisition overlay, fitted to the bottom of the projected map."""
|
|
if self.fonts is None:
|
|
return
|
|
from openpilot.starpilot.ui.presentation import FontRole
|
|
data = self._input
|
|
elapsed = max(0, int(data.gps_wait_seconds))
|
|
satellites = ('Searching for satellites' if data.satellites is None else
|
|
f"{data.satellites} satellite{'s' if data.satellites != 1 else ''} locked")
|
|
rows = [('Navigation active', FontRole.SEMI_BOLD, 22)] if data.navigation_active else []
|
|
rows += [('Waiting for GPS', FontRole.MEDIUM, 26), (f'{satellites} • {elapsed // 60}:{elapsed % 60:02d}', FontRole.NORMAL, 18)]
|
|
scale = max(.7, min(1.0, rect.width / 560, rect.height / 420))
|
|
inset, padding = 20 * scale, 14 * scale
|
|
width = rect.width - 2 * inset
|
|
# Keep every line inside even the smallest allowed map widget.
|
|
scale = min(scale, *(max(1, width - 2 * padding) / self.fonts.measure(text, role, size).width
|
|
for text, role, size in rows))
|
|
height = (len(rows) * 30 + 30) * scale
|
|
card = rl.Rectangle(rect.x + inset, rect.y + rect.height - inset - height, width, height)
|
|
rl.draw_rectangle_rounded(card, .2, 8, rl.Color(15, 13, 23, 235))
|
|
y = card.y + 10 * scale
|
|
for text, role, size in rows:
|
|
measured = self.fonts.measure(text, role, size * scale)
|
|
self.fonts.draw(text, role, size * scale, card.x + (card.width - measured.width) / 2, y,
|
|
rl.Color(225, 228, 238, 255))
|
|
y += 30 * scale
|
|
track = rl.Rectangle(card.x + padding, y + 2 * scale, card.width - 2 * padding, 6 * scale)
|
|
rl.draw_rectangle_rounded(track, 1, 8, rl.Color(66, 58, 82, 255))
|
|
fill = rl.Rectangle(track.x, track.y, max(track.height, track.width * acquisition_progress(data.satellites)), track.height)
|
|
rl.draw_rectangle_rounded(fill, 1, 8, rl.Color(199, 174, 247, 255))
|
|
|
|
def close(self) -> None:
|
|
self.reader.close()
|
|
if rl.is_window_ready():
|
|
for target in (self._canvas, self._widget, self._guidance):
|
|
if target is not None:
|
|
rl.unload_render_texture(target)
|
|
if self._shader is not None and self._shader.id:
|
|
rl.unload_shader(self._shader)
|
|
self._canvas = self._widget = self._guidance = None
|
|
self._route_layers = []
|
|
self._shader = None
|
|
|
|
|
|
# ---------------------------------------------------------------- live input
|
|
|
|
def fix_hold_seconds(source: str) -> float:
|
|
return (PHONE_MAX_AGE_NS if source == "phone" else GPS_MAX_AGE_NS) / 1e9
|
|
|
|
|
|
class MapFeed:
|
|
"""The same GPS choice as navigation (either receiver, then the car's own), and the active route."""
|
|
|
|
def __init__(self, sm=None, *, acquisition=None):
|
|
from openpilot.starpilot.gps.source import GPS_SERVICES
|
|
if sm is None:
|
|
from openpilot.cereal import messaging
|
|
sm = messaging.SubMaster(list(GPS_SERVICES))
|
|
self.sm = sm
|
|
self._fix: MapFix | None = None
|
|
self._source = ''
|
|
self._route_stamp = None
|
|
self._route_valid_until = 0
|
|
self._route: tuple[tuple[float, float], ...] = ()
|
|
self._navigation_active = False
|
|
self._destination = ''
|
|
self._acquisition = acquisition if acquisition is not None else GpsAcquisition()
|
|
self._acquisition_polled = -math.inf
|
|
|
|
def read(self, navigation_sm=None, *, navigation_requested=None, destination=None) -> MapInput:
|
|
from openpilot.starpilot.gps.source import bearing, select_source
|
|
self.sm.update(0)
|
|
now = time.monotonic()
|
|
now_ns = time.monotonic_ns()
|
|
chosen = select_source(self.sm, now_ns)
|
|
selected = chosen[1] if chosen is not None else None
|
|
if selected is not None:
|
|
stamp, gps = selected
|
|
speed = float(getattr(gps, "speed", float("nan")))
|
|
if -85 < gps.latitude < 85:
|
|
# The fix's own timestamp, so dead reckoning starts from when it was measured.
|
|
self._fix = MapFix(gps.latitude, gps.longitude, bearing(gps), max(0.0, speed) if math.isfinite(speed) else math.nan, stamp / 1e9)
|
|
self._source = chosen[0]
|
|
if self._fix is not None and now - self._fix.monotonic > max(5.0, fix_hold_seconds(self._source)):
|
|
self._fix = None
|
|
fresh = self._fix is not None and 0 <= now - self._fix.monotonic <= fix_hold_seconds(self._source)
|
|
if fresh:
|
|
self._acquisition.reset()
|
|
self._acquisition_polled = -math.inf
|
|
elif now - self._acquisition_polled >= .5:
|
|
self._acquisition_polled = now
|
|
self._acquisition.update(now)
|
|
route: tuple[tuple[float, float], ...] = ()
|
|
active = False
|
|
if navigation_sm is not None:
|
|
# Decode only new navigation messages and retain the same route tuple
|
|
# while its points are unchanged. GPS acquisition still advances each read.
|
|
try:
|
|
stamp = (navigation_sm.logMonoTime["starpilotNavigation"], navigation_sm.valid["starpilotNavigation"])
|
|
except Exception:
|
|
stamp = None
|
|
if stamp is None or stamp != self._route_stamp:
|
|
self._route_stamp = stamp
|
|
try:
|
|
from openpilot.starpilot.navigation.wire import navigation_state
|
|
nav = navigation_state(navigation_sm["starpilotNavigation"]) if navigation_sm.valid["starpilotNavigation"] else None
|
|
active = bool(nav is not None and nav.enabled and nav.destinationName)
|
|
self._destination = nav.destinationName if active else ''
|
|
self._route_valid_until = 0
|
|
if (nav is not None and nav.enabled and nav.status in ("guiding", "arrived") and
|
|
0 < nav.frameMonoTime <= now_ns <= nav.frameMonoTime + 3_000_000_000):
|
|
self._route_valid_until = nav.frameMonoTime + 3_000_000_000
|
|
route = tuple((point.latitude, point.longitude) for point in nav.route)
|
|
except Exception:
|
|
route = ()
|
|
if route != self._route:
|
|
self._route = route
|
|
self._navigation_active = active
|
|
if now_ns > self._route_valid_until:
|
|
self._route = ()
|
|
route, active = self._route, self._navigation_active
|
|
acquisition_elapsed = 0.0
|
|
if not fresh:
|
|
acquisition_since = self._acquisition.since
|
|
assert acquisition_since is not None # update() starts the acquisition clock.
|
|
acquisition_elapsed = now - acquisition_since
|
|
return MapInput(self._fix, route, active if navigation_requested is None else navigation_requested,
|
|
None if fresh else self._acquisition.satellites(now),
|
|
acquisition_elapsed, self._destination if destination is None else destination,
|
|
self._source if self._fix is not None else '')
|
|
|
|
|
|
# ---------------------------------------------------------------- parked preview
|
|
|
|
SAMPLE_TILE = TileKey(DATA_ZOOM, 2950, 6427)
|
|
SAMPLE_CAR = (1450.0, 2930.0) # tile units inside SAMPLE_TILE, south of a left turn
|
|
SAMPLE_BEARING = 0.0
|
|
|
|
|
|
def _sample_roads() -> dict[int, list[np.ndarray]]:
|
|
"""A believable neighborhood for the parked layout preview; drawn from no real map."""
|
|
t = np.linspace(-4500, 8600, 90)
|
|
roads: dict[int, list[np.ndarray]] = {
|
|
MOTORWAY: [np.stack((2700 + 520 * np.sin(t / 1900), t), axis=1)],
|
|
MAJOR: [np.array([[-4500, 2300], [1200, 2620], [8600, 2050]]), np.array([[1450, 8600], [1450, 2620], [1700, -4500]])],
|
|
STREET: [], MINOR: [], LINK: [np.array([[2400, 1500], [2650, 1900], [3050, 2200]])],
|
|
}
|
|
angle = math.radians(8)
|
|
rotation = np.array([[math.cos(angle), -math.sin(angle)], [math.sin(angle), math.cos(angle)]])
|
|
for i in range(-12, 22):
|
|
offset = i * 420.0
|
|
roads[STREET].append(np.array([[offset, -4500], [offset + 60, 8600]]) @ rotation.T)
|
|
roads[STREET].append(np.array([[-4500, offset], [8600, offset - 90]]) @ rotation.T)
|
|
roads[MINOR].append(np.array([[offset + 210, offset - 300], [offset + 330, offset - 120], [offset + 210, offset + 60]]))
|
|
return roads
|
|
|
|
|
|
class SampleTileReader:
|
|
"""TileReader's interface over one synthetic tile; jobs run inline."""
|
|
|
|
def __init__(self):
|
|
raw = encode_road_tile(_sample_roads())
|
|
self._tile = decode_road_tile(SAMPLE_TILE, raw)
|
|
self.generation = 1
|
|
|
|
def want(self, keys) -> None:
|
|
pass
|
|
|
|
def submit(self, job) -> None:
|
|
job()
|
|
|
|
def snapshot(self, keys) -> dict[TileKey, RoadTile]:
|
|
return {key: self._tile for key in keys if key == SAMPLE_TILE}
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
def sample_input() -> MapInput:
|
|
from openpilot.starpilot.navigation.road_tiles import lat_lon
|
|
def world(x, y):
|
|
return lat_lon(SAMPLE_TILE.x + x / EXTENT, SAMPLE_TILE.y + y / EXTENT)
|
|
latitude, longitude = world(*SAMPLE_CAR)
|
|
route = tuple(world(x, y) for x, y in ((1450, 4200), (1450, 2620), (1200, 2620), (-1800, 2460)))
|
|
return MapInput(MapFix(latitude, longitude, SAMPLE_BEARING, 8.0, time.monotonic()), route)
|