IQ.Pilot Release Commit @ 0798119

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:42 -05:00
commit b42569dbca
4529 changed files with 1132125 additions and 0 deletions

View File

View File

@@ -0,0 +1,18 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.selfdrive.ui.ui_state import UIStatus
BORDER_COLORS_IQ = {
UIStatus.LAT_ONLY: rl.Color(0x0C, 0x94, 0x96, 0xFF),
UIStatus.LONG_ONLY: rl.Color(0x96, 0x1C, 0xA8, 0xFF), # Purple for longitudinal-only state
}
class AugmentedRoadViewIQ:
def __init__(self):
pass
def update_fade_out_bottom_overlay(self, _content_rect):
pass

View File

@@ -0,0 +1,162 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import time
import numpy as np
import pyray as rl
from openpilot.common.params import Params
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer, BTN_SIZE, ARC_LENGTH
from openpilot.iqpilot.ui.onroad.hud_overlays import IQDevMetricsOverlay
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
# LongitudinalPersonality ordinals (matches cereal enum: relaxed=0, standard=1, aggressive=2)
_PERSONALITY_RELAXED = 0
_PERSONALITY_STANDARD = 1
_PERSONALITY_AGGRESSIVE = 2
PERSONALITY_COLORS = {
_PERSONALITY_RELAXED: rl.Color(0x17, 0xC9, 0x64, 0xFF), # green
_PERSONALITY_STANDARD: rl.Color(0x0C, 0x94, 0x96, 0xFF), # teal
_PERSONALITY_AGGRESSIVE: rl.Color(0xE8, 0x2C, 0x2C, 0xFF), # red
}
PERSONALITY_NAMES = {
_PERSONALITY_RELAXED: "Relaxed",
_PERSONALITY_STANDARD: "Standard",
_PERSONALITY_AGGRESSIVE: "Aggressive",
}
_TOAST_DURATION = 2.0 # seconds
_TOAST_FONT_SIZE = 52
_TOAST_PAD_X = 52
_TOAST_PAD_Y = 22
_TOAST_BOTTOM_MARGIN = UI_BORDER_SIZE + 36
_TOAST_RADIUS = 0.45
_TOAST_FADE = 0.25 # fade-in / fade-out window
class DriverStateRendererIQ(DriverStateRenderer):
def __init__(self):
super().__init__()
self._params = Params()
self._personality: int = _PERSONALITY_STANDARD
self._personality_color: rl.Color = PERSONALITY_COLORS[_PERSONALITY_STANDARD]
self._toast_end_time: float = 0.0
self._toast_text: str = ""
self._toast_color: rl.Color = rl.WHITE
self._font = gui_app.font(FontWeight.SEMI_BOLD)
self.dev_ui_offset = IQDevMetricsOverlay.get_bottom_dev_ui_offset()
self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", BTN_SIZE, BTN_SIZE)
self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", 118, 118)
self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", 118, 118)
def _update_state(self):
super()._update_state()
personality = self._params.get("LongitudinalPersonality", return_default=True)
if personality is not None:
self._personality = int(personality)
self._personality_color = PERSONALITY_COLORS.get(self._personality, PERSONALITY_COLORS[_PERSONALITY_STANDARD])
def cycle_personality(self):
next_p = (self._personality + 1) % 3
self._params.put_nonblocking("LongitudinalPersonality", next_p)
self._personality = next_p
self._personality_color = PERSONALITY_COLORS[next_p]
self._toast_text = PERSONALITY_NAMES[next_p]
self._toast_color = PERSONALITY_COLORS[next_p]
self._toast_end_time = time.monotonic() + _TOAST_DURATION
def _render(self, _):
fade = max(0.35, 1.0 - self.dm_fade_state)
alpha = int(255 * fade)
pc = self._personality_color
rl.draw_texture(
self._dm_background,
int(self.position_x - self._dm_background.width / 2),
int(self.position_y - self._dm_background.height / 2),
rl.Color(pc.r, pc.g, pc.b, alpha),
)
rl.draw_texture(
self._dm_person,
int(self.position_x - self._dm_person.width / 2),
int(self.position_y - self._dm_person.height / 2),
rl.Color(255, 255, 255, int(alpha * 0.9)),
)
if self.is_active:
dest_rect = rl.Rectangle(self.position_x, self.position_y, self._dm_cone.width, self._dm_cone.height)
rl.draw_texture_pro(
self._dm_cone,
rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height),
dest_rect,
rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
180.0,
rl.Color(pc.r, pc.g, pc.b, alpha),
)
else:
rl.draw_circle(int(self.position_x), int(self.position_y), 14, rl.Color(255, 255, 255, alpha))
self._draw_personality_toast()
def _draw_personality_toast(self):
now = time.monotonic()
remaining = self._toast_end_time - now
if remaining <= 0 or not self._toast_text:
return
elapsed = _TOAST_DURATION - remaining
fade_in = min(1.0, elapsed / _TOAST_FADE)
fade_out = min(1.0, remaining / _TOAST_FADE)
a = int(255 * fade_in * fade_out)
text_size = measure_text_cached(self._font, self._toast_text, _TOAST_FONT_SIZE)
toast_w = text_size.x + _TOAST_PAD_X * 2
toast_h = text_size.y + _TOAST_PAD_Y * 2
cx = self._rect.x + self._rect.width / 2
toast_x = cx - toast_w / 2
toast_y = self._rect.y + self._rect.height - _TOAST_BOTTOM_MARGIN - toast_h
tc = self._toast_color
toast_rect = rl.Rectangle(toast_x, toast_y, toast_w, toast_h)
rl.draw_rectangle_rounded(toast_rect, _TOAST_RADIUS, 10, rl.Color(tc.r, tc.g, tc.b, a))
rl.draw_text_ex(
self._font, self._toast_text,
rl.Vector2(toast_x + _TOAST_PAD_X, toast_y + _TOAST_PAD_Y),
_TOAST_FONT_SIZE, 0,
rl.Color(255, 255, 255, a),
)
def _pre_calculate_drawing_elements(self):
"""Pre-calculate all drawing elements based on the current rectangle"""
width, height = self._rect.width, self._rect.height
offset = UI_BORDER_SIZE + BTN_SIZE // 2
self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
self.position_y = self._rect.y + height - offset - self.dev_ui_offset
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
for i in range(len(positioned_keypoints)):
self.face_lines[i].x = positioned_keypoints[i][0]
self.face_lines[i].y = positioned_keypoints[i][1]
delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0
delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0
h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data(
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
)
v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data(
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
IQ.Pilot road-view HUD: extends the stock renderer and layers on the IQ overlays
(developer bar, nav map, road name, speed + speed-limit, turn signals, rocket-fuel
accel bar, soft warnings, steering arc).
"""
import pyray as rl
from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer
from openpilot.iqpilot.ui.onroad.hud_overlays import (
IQDevMetricsOverlay,
RoadNameRenderer,
IQAccelBar,
IQSpeedLimitOverlay,
IQTurnSignalOverlay,
IQSpeedOverlay,
)
from openpilot.iqpilot.ui.onroad.nav_map_panel import NavMapPanel
from openpilot.iqpilot.ui.onroad.soft_warning import SoftWarningRenderer
ENABLE_FLOATING_NAV_MAP_PANEL = False
ENABLE_SPLIT_NAV_MAP_PANEL = True
class IQHudRenderer(HudRenderer):
def __init__(self):
super().__init__()
self.developer_ui = IQDevMetricsOverlay()
self.nav_map_panel = NavMapPanel()
self.road_name_renderer = RoadNameRenderer()
self.rocket_fuel = IQAccelBar()
self.speed_limit_renderer = IQSpeedLimitOverlay()
self.turn_signal_controller = IQTurnSignalOverlay()
self.speed_renderer = IQSpeedOverlay()
self.soft_warning_renderer = SoftWarningRenderer()
self._torque_bar = TorqueBar(scale=3.0, always=True)
def _update_state(self) -> None:
super()._update_state()
if ENABLE_FLOATING_NAV_MAP_PANEL or ENABLE_SPLIT_NAV_MAP_PANEL:
self.nav_map_panel.update()
self.road_name_renderer.update()
self.speed_limit_renderer.update()
has_limit = self.speed_limit_renderer.speed_limit_valid or self.speed_limit_renderer.speed_limit_last_valid
self.limit_available = has_limit
self.limit_speed_text = str(round(self.speed_limit_renderer.speed_limit_last)) if has_limit else "---"
offset = round(self.speed_limit_renderer.speed_limit_offset)
self.limit_offset_text = f"{offset:+d}" if has_limit and offset != 0 else ""
self.turn_signal_controller.update()
self.speed_renderer.update()
self.soft_warning_renderer.update()
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
self.speed_renderer.render(rect)
def _render(self, rect: rl.Rectangle) -> None:
super()._render(rect)
if ui_state.torque_bar:
torque_rect = rect
if ui_state.developer_ui in (IQDevMetricsOverlay.DEV_UI_BOTTOM, IQDevMetricsOverlay.DEV_UI_BOTH):
torque_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height - IQDevMetricsOverlay.BOTTOM_BAR_HEIGHT)
self._torque_bar.render(torque_rect)
if not self.split_nav_enabled():
self.developer_ui.render(rect)
if ENABLE_FLOATING_NAV_MAP_PANEL:
self.nav_map_panel.render(rect)
self.road_name_renderer.render(rect)
self.turn_signal_controller.render(rect)
self.soft_warning_renderer.render(rect)
self.rocket_fuel.render(rect, ui_state.sm)
def split_nav_enabled(self) -> bool:
if not ENABLE_SPLIT_NAV_MAP_PANEL:
return False
if hasattr(self.nav_map_panel, "maps_enabled"):
return bool(self.nav_map_panel.maps_enabled())
return bool(getattr(self.nav_map_panel, "_maps_enabled", False))
def render_split_nav(self, rect: rl.Rectangle) -> None:
if self.split_nav_enabled():
self.nav_map_panel.render_split(rect)
def render_full_width_overlays(self, rect: rl.Rectangle) -> None:
if self.split_nav_enabled():
self.developer_ui.render(rect)

View File

@@ -0,0 +1,70 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.system.ui.lib.application import gui_app
ACTIVE_TOP = rl.Color(0x22, 0xB8, 0xB9, 255)
ACTIVE_BOTTOM = rl.Color(0x0C, 0x94, 0x96, 255)
MEDIUM_TOP = rl.Color(255, 200, 0, 255)
MEDIUM_BOTTOM = rl.Color(255, 115, 0, 255)
LOW_TOP = rl.Color(255, 0, 21, 255)
LOW_BOTTOM = rl.Color(255, 0, 89, 255)
OVERRIDE_TOP = rl.Color(255, 255, 255, 255)
OVERRIDE_BOTTOM = rl.Color(82, 82, 82, 255)
IDLE_TOP = rl.Color(120, 120, 120, 255)
IDLE_BOTTOM = rl.Color(60, 60, 60, 255)
def _zone_colors(confidence: float) -> tuple[rl.Color, rl.Color]:
if confidence > 0.5:
return ACTIVE_TOP, ACTIVE_BOTTOM
if confidence > 0.2:
return MEDIUM_TOP, MEDIUM_BOTTOM
return LOW_TOP, LOW_BOTTOM
class DrivingConfidence:
def __init__(self):
self._filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
self._last_frame = -1
def update(self) -> None:
frame = ui_state.sm.frame
if frame == self._last_frame:
return
self._last_frame = frame
try:
predictions = ui_state.sm['modelV2'].meta.disengagePredictions
except Exception:
return
if ui_state.status == UIStatus.DISENGAGED:
value = -0.5
elif ui_state.status == UIStatus.LAT_ONLY:
value = 1 - max(predictions.steerOverrideProbs or [1])
elif ui_state.status == UIStatus.LONG_ONLY:
value = 1 - max(predictions.brakeDisengageProbs or [1])
else:
value = (1 - max(predictions.brakeDisengageProbs or [1])) * (1 - max(predictions.steerOverrideProbs or [1]))
self._filter.update(value)
@property
def value(self) -> float:
return self._filter.x
def colors(self, demo: bool = False) -> tuple[rl.Color, rl.Color]:
confidence = self._filter.x
if ui_state.status == UIStatus.ENGAGED or demo:
return _zone_colors(confidence)
if ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
return _zone_colors(confidence)
if ui_state.status == UIStatus.OVERRIDE:
return OVERRIDE_TOP, OVERRIDE_BOTTOM
return IDLE_TOP, IDLE_BOTTOM
driving_confidence = DrivingConfidence()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
import math
from urllib.parse import quote
EARTH_RADIUS_M = 6378137.0
TILE_SIZE = 256.0
# Shift the whole driving zoom window closer in. The route-ahead fit (fit_zoom_for_points)
# still zooms out for distant turns and in for straight roads; this just biases the baseline
# so the route line + ego marker are easier to read while driving.
NAV_DRIVE_ZOOM_BOOST = 1.0
def _mercator_normalized(latitude: float, longitude: float) -> tuple[float, float]:
x = (longitude + 180.0) / 360.0
siny = min(max(math.sin(math.radians(latitude)), -0.9999), 0.9999)
y = 0.5 - math.log((1.0 + siny) / (1.0 - siny)) / (4.0 * math.pi)
return x, y
def mercator_world_px(latitude: float, longitude: float, zoom: float) -> tuple[float, float]:
world_size = TILE_SIZE * (2.0 ** zoom)
nx, ny = _mercator_normalized(latitude, longitude)
x = nx * world_size
y = ny * world_size
return x, y
def destination_point(latitude: float, longitude: float, bearing_deg: float, distance_m: float) -> tuple[float, float]:
if abs(distance_m) < 1e-3:
return latitude, longitude
angular_distance = distance_m / EARTH_RADIUS_M
bearing = math.radians(bearing_deg)
lat1 = math.radians(latitude)
lon1 = math.radians(longitude)
sin_lat1 = math.sin(lat1)
cos_lat1 = math.cos(lat1)
sin_ad = math.sin(angular_distance)
cos_ad = math.cos(angular_distance)
lat2 = math.asin(sin_lat1 * cos_ad + cos_lat1 * sin_ad * math.cos(bearing))
lon2 = lon1 + math.atan2(
math.sin(bearing) * sin_ad * cos_lat1,
cos_ad - sin_lat1 * math.sin(lat2),
)
return math.degrees(lat2), math.degrees(lon2)
def fit_zoom_for_points(points, width: float, height: float, max_zoom: float = 17.6,
min_zoom: float = 12.8, padding: float = 56.0) -> float:
coords = [(float(point.latitude), float(point.longitude)) for point in points if point is not None]
if len(coords) < 2:
return max_zoom
xs, ys = zip(*[_mercator_normalized(lat, lon) for lat, lon in coords])
span_x = max(max(xs) - min(xs), 1e-6)
span_y = max(max(ys) - min(ys), 1e-6)
usable_width = max(width - 2.0 * padding, 32.0)
usable_height = max(height - 2.0 * padding, 32.0)
zoom_x = math.log2(usable_width / (TILE_SIZE * span_x))
zoom_y = math.log2(usable_height / (TILE_SIZE * span_y))
return max(min(min(zoom_x, zoom_y), max_zoom), min_zoom)
def choose_nav_camera(current_latitude: float, current_longitude: float, bearing_deg: float, points,
width: float, height: float, preferred_zoom: float) -> tuple[float, float, float]:
preferred_zoom += NAV_DRIVE_ZOOM_BOOST
zoom = preferred_zoom
if points:
zoom = fit_zoom_for_points(points, width, height * 0.78, max_zoom=preferred_zoom + 0.6)
zoom = min(max(zoom, preferred_zoom - 1.2), preferred_zoom + 0.6)
meters_per_pixel = 156543.03392 * math.cos(math.radians(current_latitude)) / (2.0 ** zoom)
lookahead_pixels = height * 0.16
lookahead_m = max(lookahead_pixels * meters_per_pixel, 12.0)
center_latitude, center_longitude = destination_point(current_latitude, current_longitude, bearing_deg, lookahead_m)
return center_latitude, center_longitude, zoom
def build_mapbox_static_url(latitude: float, longitude: float, zoom: float, bearing: float,
width: int, height: int, points=None) -> str:
overlay = ""
if points:
overlay = f"path-7+34d17a-0.85({encode_polyline(points)})/"
return (
f"https://api.mapbox.com/styles/v1/mapbox/navigation-night-v1/static/"
f"{overlay}{longitude:.6f},{latitude:.6f},{zoom:.2f},{bearing:.1f},0/{width}x{height}@2x"
)
def build_mapbox_tile_url(z: int, x: int, y: int, tile_size: int = 256, scale: int = 2,
style: str = "navigation-night-v1") -> str:
suffix = f"@{scale}x" if scale > 1 else ""
return (
f"https://api.mapbox.com/styles/v1/mapbox/{style}/tiles/"
f"{tile_size}/{z}/{x}/{y}{suffix}"
)
def tile_world_size(z: int, tile_size: int = 256) -> int:
return tile_size * (2 ** z)
def mercator_world_px_at_zoom(latitude: float, longitude: float, z: int, tile_size: int = 256) -> tuple[float, float]:
world_size = tile_world_size(z, tile_size)
nx, ny = _mercator_normalized(latitude, longitude)
return nx * world_size, ny * world_size
def encode_polyline(points) -> str:
result = []
last_lat = 0
last_lon = 0
for point in points:
lat = int(round(float(point.latitude if hasattr(point, "latitude") else point[0]) * 1e5))
lon = int(round(float(point.longitude if hasattr(point, "longitude") else point[1]) * 1e5))
for value in (lat - last_lat, lon - last_lon):
shifted = ~(value << 1) if value < 0 else (value << 1)
while shifted >= 0x20:
result.append(chr((0x20 | (shifted & 0x1f)) + 63))
shifted >>= 5
result.append(chr(shifted + 63))
last_lat = lat
last_lon = lon
return quote("".join(result), safe="")
def project_nav_point(latitude: float, longitude: float, center_latitude: float, center_longitude: float,
zoom: float, bearing_deg: float, width: float, height: float,
anchor_x: float = 0.5, anchor_y: float = 0.5) -> tuple[float, float]:
px, py = mercator_world_px(latitude, longitude, zoom)
cx, cy = mercator_world_px(center_latitude, center_longitude, zoom)
dx = px - cx
dy = py - cy
theta = math.radians(bearing_deg)
cos_theta = math.cos(theta)
sin_theta = math.sin(theta)
rx = dx * cos_theta + dy * sin_theta
ry = -dx * sin_theta + dy * cos_theta
return width * anchor_x + rx, height * anchor_y + ry
def project_nav_polyline(points, center_latitude: float, center_longitude: float, zoom: float, bearing_deg: float,
width: float, height: float, anchor_x: float = 0.5, anchor_y: float = 0.5) -> list[tuple[float, float]]:
projected = []
for point in points:
projected.append(
project_nav_point(
float(point.latitude),
float(point.longitude),
center_latitude,
center_longitude,
zoom,
bearing_deg,
width,
height,
anchor_x=anchor_x,
anchor_y=anchor_y,
)
)
return projected
def solar_elevation_deg(latitude: float, longitude: float, unix_time: float) -> float:
"""Approximate solar elevation (NOAA-style, good to ~0.5 deg) for day/night map styling."""
days = unix_time / 86400.0 - 10957.5 # days since J2000 epoch
mean_longitude = math.radians((280.460 + 0.9856474 * days) % 360.0)
mean_anomaly = math.radians((357.528 + 0.9856003 * days) % 360.0)
ecliptic_longitude = mean_longitude + math.radians(1.915) * math.sin(mean_anomaly) \
+ math.radians(0.020) * math.sin(2.0 * mean_anomaly)
obliquity = math.radians(23.439 - 0.0000004 * days)
declination = math.asin(math.sin(obliquity) * math.sin(ecliptic_longitude))
right_ascension = math.atan2(math.cos(obliquity) * math.sin(ecliptic_longitude), math.cos(ecliptic_longitude))
gmst_deg = (280.46061837 + 360.98564736629 * days) % 360.0
hour_angle = math.radians(gmst_deg) + math.radians(longitude) - right_ascension
lat_rad = math.radians(latitude)
elevation = math.asin(
math.sin(lat_rad) * math.sin(declination)
+ math.cos(lat_rad) * math.cos(declination) * math.cos(hour_angle)
)
return math.degrees(elevation)

View File

@@ -0,0 +1,288 @@
import os
import json
import time
from typing import Any
from pathlib import Path
try:
import sqlite3
except Exception:
sqlite3 = None # type: ignore[assignment]
OFFLINE_MBTILES_ENV = "IQPILOT_OFFLINE_MBTILES"
OFFLINE_TILE_ROOT_ENV = "IQPILOT_OFFLINE_TILE_ROOT"
DEFAULT_OFFLINE_TILE_ROOT = Path("/data/offline_maps/tiles" if Path("/data").exists() else "/tmp/offline_maps/tiles")
DEFAULT_OFFLINE_MAP_ROOT = Path("/data/offline_maps" if Path("/data").exists() else "/tmp/offline_maps")
SQLITE_ERRORS = (sqlite3.Error,) if sqlite3 is not None else (Exception,)
SQLiteConnection = Any
def offline_tile_root() -> Path:
override = os.getenv(OFFLINE_TILE_ROOT_ENV)
return Path(override) if override else DEFAULT_OFFLINE_TILE_ROOT
def offline_map_root() -> Path:
root = offline_tile_root()
if root.name == "tiles":
return root.parent
if root.name == "xyz":
return root.parent.parent if root.parent.name == "tiles" else root.parent
return DEFAULT_OFFLINE_MAP_ROOT
def _parse_bounds(bounds: str) -> tuple[float, float, float, float] | None:
try:
min_lon, min_lat, max_lon, max_lat = [float(part) for part in bounds.split(",")]
except (ValueError, AttributeError):
return None
return min_lat, min_lon, max_lat, max_lon
def _bounds_contains(bounds: tuple[float, float, float, float], latitude: float, longitude: float) -> bool:
min_lat, min_lon, max_lat, max_lon = bounds
return min_lat <= latitude <= max_lat and min_lon <= longitude <= max_lon
def _bounds_area(bounds: tuple[float, float, float, float]) -> float:
min_lat, min_lon, max_lat, max_lon = bounds
return max(max_lat - min_lat, 0.0) * max(max_lon - min_lon, 0.0)
# Manual cache that only stores hits: caching a None (manifest not written yet — e.g. a
# bundle download in flight) would otherwise pin the miss for the life of the process.
_region_bounds_cache: dict[Path, tuple[float, float, float, float]] = {}
def _load_region_bounds(region_root: Path) -> tuple[float, float, float, float] | None:
cached = _region_bounds_cache.get(region_root)
if cached is not None:
return cached
bounds = _load_region_bounds_uncached(region_root)
if bounds is not None:
if len(_region_bounds_cache) > 64:
_region_bounds_cache.clear()
_region_bounds_cache[region_root] = bounds
return bounds
def _load_region_bounds_uncached(region_root: Path) -> tuple[float, float, float, float] | None:
manifest_path = region_root / "manifest.json"
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text())
bounds = manifest.get("mbtiles", {}).get("bounds")
parsed = _parse_bounds(bounds) if bounds else None
if parsed is not None:
return parsed
except (OSError, json.JSONDecodeError):
pass
mbtiles_path = region_root / "tiles" / "offline.mbtiles"
if not mbtiles_path.exists():
return None
try:
conn = open_mbtiles(mbtiles_path)
row = conn.execute("SELECT value FROM metadata WHERE name = 'bounds'").fetchone()
conn.close()
except SQLITE_ERRORS:
return None
return _parse_bounds(row["value"]) if row is not None else None
# Short-TTL cache instead of lru_cache: region bundles can be downloaded while the UI is
# running, and a forever-cached candidate list would hide them until the process restarts.
_REGION_ROOTS_TTL_S = 15.0
_region_roots_cache: tuple[float, Path, tuple[Path, ...]] | None = None
def _candidate_region_roots() -> tuple[Path, ...]:
global _region_roots_cache
root = offline_map_root()
now = time.monotonic()
if _region_roots_cache is not None:
cached_at, cached_root, cached = _region_roots_cache
if cached_root == root and now - cached_at < _REGION_ROOTS_TTL_S:
return cached
candidates: list[Path] = []
if (root / "tiles").exists():
candidates.append(root)
regions_root = root / "regions"
if regions_root.exists():
for child in sorted(regions_root.iterdir()):
if child.is_dir() and (child / "tiles").exists():
candidates.append(child)
result = tuple(candidates)
_region_roots_cache = (now, root, result)
return result
def _region_covers_point(region_root: Path, latitude: float, longitude: float) -> bool:
mb = region_root / "tiles" / "offline.mbtiles"
if not mb.exists():
return True # xyz-only / unknown layout: don't second-guess the bbox match
try:
conn = open_mbtiles(mb)
try:
_, max_zoom = mbtiles_zoom_bounds(conn)
z = max_zoom if max_zoom is not None else 14
import math
n = 2 ** z
lat_r = math.radians(max(min(latitude, 85.05112878), -85.05112878))
x = int((longitude + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
# 3x3 cluster: tolerate an empty sub-tile at the exact point (a z15 child with no road)
# while still rejecting a neighbor whose coverage doesn't reach this area at all.
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if load_raster_tile_blob(conn, z, x + dx, y + dy) is not None:
return True
return False
finally:
conn.close()
except SQLITE_ERRORS:
return True
def find_offline_region_root(latitude: float | None = None, longitude: float | None = None) -> Path | None:
candidates = _candidate_region_roots()
if not candidates:
return None
if latitude is None or longitude is None:
return candidates[0]
bounded: list[tuple[float, Path]] = []
for candidate in candidates:
bounds = _load_region_bounds(candidate)
if bounds is None:
continue
if _bounds_contains(bounds, latitude, longitude):
bounded.append((_bounds_area(bounds), candidate))
if bounded:
if len(bounded) == 1:
return bounded[0][1]
# multiple bboxes overlap this point (border zone): prefer the smallest-area region that
# ACTUALLY has tiles here, so we don't pick a neighbor whose bundle is empty at the border.
bounded.sort(key=lambda item: item[0])
for _, candidate in bounded:
if _region_covers_point(candidate, latitude, longitude):
return candidate
return bounded[0][1]
return candidates[0]
def find_offline_mbtiles_path(latitude: float | None = None, longitude: float | None = None,
day: bool = False) -> Path | None:
explicit = os.getenv(OFFLINE_MBTILES_ENV)
if explicit:
path = Path(explicit)
if day:
day_path = path.with_name("offline_day.mbtiles")
if day_path.exists():
return day_path
return path if path.exists() else None
region_root = find_offline_region_root(latitude, longitude)
if region_root is None:
return None
# day variant is optional: regions built before the day palette fall back to the night set
if day:
day_preferred = region_root / "tiles" / "offline_day.mbtiles"
if day_preferred.exists():
return day_preferred
preferred = region_root / "tiles" / "offline.mbtiles"
if preferred.exists():
return preferred
matches = sorted(p for p in (region_root / "tiles").glob("*.mbtiles") if "_day" not in p.name or day)
return matches[0] if matches else None
def find_offline_xyz_root(latitude: float | None = None, longitude: float | None = None) -> Path | None:
root = offline_tile_root()
if not root.exists():
region_root = find_offline_region_root(latitude, longitude)
if region_root is None:
return None
root = region_root / "tiles"
if any(child.is_dir() and child.name.isdigit() for child in root.iterdir()):
return root
xyz_dir = root / "xyz"
if xyz_dir.exists() and any(child.is_dir() and child.name.isdigit() for child in xyz_dir.iterdir()):
return xyz_dir
return None
def xyz_to_tms_y(z: int, y: int) -> int:
return (2 ** z - 1) - y
def open_mbtiles(path: Path) -> SQLiteConnection:
if sqlite3 is None:
raise RuntimeError("sqlite3 unavailable")
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def mbtiles_is_raster(conn: SQLiteConnection) -> bool:
row = conn.execute("SELECT value FROM metadata WHERE name = 'format'").fetchone()
if row is None:
return False
return row["value"] in {"png", "jpg", "jpeg", "webp"}
def mbtiles_zoom_bounds(conn: SQLiteConnection) -> tuple[int | None, int | None]:
rows = {
row["name"]: row["value"]
for row in conn.execute("SELECT name, value FROM metadata WHERE name IN ('minzoom', 'maxzoom')")
}
min_zoom = int(rows["minzoom"]) if "minzoom" in rows else None
max_zoom = int(rows["maxzoom"]) if "maxzoom" in rows else None
return min_zoom, max_zoom
def xyz_zoom_bounds(root: Path) -> tuple[int | None, int | None]:
zoom_dirs = sorted(
int(child.name)
for child in root.iterdir()
if child.is_dir() and child.name.isdigit()
)
if not zoom_dirs:
return None, None
return zoom_dirs[0], zoom_dirs[-1]
def load_raster_tile_blob(conn: SQLiteConnection, z: int, x: int, y: int) -> bytes | None:
row = conn.execute(
"""
SELECT tile_data
FROM tiles
WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?
""",
(z, x, xyz_to_tms_y(z, y)),
).fetchone()
return bytes(row["tile_data"]) if row is not None else None
def load_raster_xyz_tile_blob(root: Path, z: int, x: int, y: int) -> bytes | None:
for suffix in ("png", "webp", "jpg", "jpeg"):
for filename in (f"{y}.{suffix}", f"{y}@2x.{suffix}"):
tile_path = root / str(z) / str(x) / filename
if tile_path.exists():
return tile_path.read_bytes()
return None

View File

@@ -0,0 +1,60 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from cereal import log
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.onroad.driver_state import BTN_SIZE
from openpilot.system.ui.lib.application import gui_app
EventName = log.OnroadEvent.EventName
# Events that trigger the soft warning triangle instead of a disruptive alert
SOFT_WARNING_EVENTS = {
EventName.commIssue,
EventName.commIssueAvgFreq,
EventName.selfdrivedLagging,
}
ICON_SIZE = 96
# Speed box geometry (mirrors hud_renderer._draw_set_speed)
_SPEED_BOX_X_OFFSET = 60
_SPEED_BOX_Y_OFFSET = 45
_SPEED_BOX_WIDTH = 180 # midpoint between metric (186) and imperial (174)
_SPEED_BOX_HEIGHT = 228
_DM_OFFSET = UI_BORDER_SIZE + BTN_SIZE // 2 # = 126
class SoftWarningRenderer:
def __init__(self):
self._icon = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", ICON_SIZE, ICON_SIZE)
self._active = False
def update(self) -> None:
sm = ui_state.sm
self._active = any(e.name in SOFT_WARNING_EVENTS for e in sm['onroadEvents'])
def render(self, rect: rl.Rectangle) -> None:
if not self._active:
return
# Centre of speed box (top-left of screen)
speed_cx = rect.x + _SPEED_BOX_X_OFFSET + _SPEED_BOX_WIDTH / 2
speed_cy = rect.y + _SPEED_BOX_Y_OFFSET + _SPEED_BOX_HEIGHT / 2
# Centre of driver monitoring icon (bottom-left of screen, LHD)
dm_cx = rect.x + _DM_OFFSET
dm_cy = rect.y + rect.height - _DM_OFFSET
# Midpoint between the two
mid_x = (speed_cx + dm_cx) / 2
mid_y = (speed_cy + dm_cy) / 2
draw_x = int(mid_x - ICON_SIZE / 2)
draw_y = int(mid_y - ICON_SIZE / 2)
rl.draw_texture(self._icon, draw_x, draw_y, rl.WHITE)