Files
IQ.Pilot/iqpilot/ui/onroad/hud_overlays.py
2026-09-03 18:23:24 -05:00

919 lines
33 KiB
Python

"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import math
import time
import numpy as np
import pyray as rl
from iqpilot.cereal import car, custom
from iqpilot.common.constants import CV
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.onroad.hud_renderer import COLORS, FONT_SIZES, UI_CONFIG
from iqpilot.selfdrive.ui.mici.onroad.alert_renderer import IconSide, TURN_SIGNAL_BLINK_PERIOD
from iqpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.iqwidgets.lib import canvas
from iqdbc.car.volkswagen.values import VolkswagenFlags
def _feed():
return ui_state.sm
def _speed_scale() -> float:
return CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
_STRIP_WIDTH = 28
_STRIP_INSET = 14
_STRIP_CEILING = 0.85
_STRIP_EMA = 5.0
_STRIP_ARC_SEGMENTS = 24
_STRIP_ACCEL = (33, 112, 115)
_STRIP_ACCEL_NEON = (94, 232, 236)
_STRIP_DECEL = (255, 0, 247)
_STRIP_DECEL_NEON = (255, 145, 251)
_STRIP_TIP_ALPHA = 235
_STRIP_ROOT_ALPHA = 55
_STRIP_CORE_DEPTH = 7.0
_STRIP_CORE_LAYERS = 8
_STRIP_CORE_ALPHA = 0.15
_STRIP_CORE_FLOOR = 0.3
_STRIP_CORE_TAIL = 22.0
_STRIP_HALO_SPREAD = 11.0
_STRIP_HALO_LAYERS = 11
_STRIP_HALO_ALPHA = 0.095
_STRIP_HALO_TAIL = 34.0
_STRIP_NEON_FULL = 2.0
class IQAccelBar:
def __init__(self):
self._eased = 0.0
def _reach(self) -> float:
mag = abs(self._eased)
return 0.0 if mag == 0.0 else max(0.0, _STRIP_CEILING - 0.1 / mag)
@staticmethod
def _tint(fill, frac: float):
return canvas.shade(*fill, int(_STRIP_TIP_ALPHA + (_STRIP_ROOT_ALPHA - _STRIP_TIP_ALPHA) * frac))
@staticmethod
def _halo(center, cap: float, up: bool, neon, heat: float, tail: float) -> None:
step = int(255 * _STRIP_HALO_ALPHA * heat)
if step < 1:
return
start, end = (180.0, 360.0) if up else (0.0, 180.0)
tint = canvas.shade(*neon, step)
faded = canvas.shade(*neon, 0)
top, bottom = (tint, faded) if up else (faded, tint)
for i in range(_STRIP_HALO_LAYERS):
spread = _STRIP_HALO_SPREAD * (1.0 - i / _STRIP_HALO_LAYERS)
if spread <= 0.0:
continue
canvas.annulus(center, cap, cap + spread, start, end, _STRIP_ARC_SEGMENTS, tint)
if tail <= 0.0:
continue
run = min(tail, _STRIP_HALO_TAIL)
y = center.y if up else center.y - run
canvas.v_sweep(center.x - cap - spread, y, spread, run, top, bottom)
canvas.v_sweep(center.x + cap, y, spread, run, top, bottom)
@staticmethod
def _cap(center, cap: float, up: bool, fill, neon, heat: float, tail: float) -> None:
start, end = (180.0, 360.0) if up else (0.0, 180.0)
canvas.annulus(center, 0.0, cap, start, end, _STRIP_ARC_SEGMENTS, fill)
IQAccelBar._halo(center, cap, up, neon, heat, tail)
lit = _STRIP_CORE_FLOOR + (1.0 - _STRIP_CORE_FLOOR) * heat
core = canvas.shade(*neon, max(1, int(255 * _STRIP_CORE_ALPHA * lit)))
faded = canvas.shade(*neon, 0)
top, bottom = (core, faded) if up else (faded, core)
run = min(tail, _STRIP_CORE_TAIL)
for i in range(_STRIP_CORE_LAYERS):
depth = _STRIP_CORE_DEPTH * (1.0 - i / _STRIP_CORE_LAYERS)
if depth <= 0.0:
continue
canvas.annulus(center, max(0.0, cap - depth), cap, start, end, _STRIP_ARC_SEGMENTS, core)
if run <= 0.0:
continue
y = center.y if up else center.y - run
canvas.v_sweep(center.x - cap, y, depth, run, top, bottom)
canvas.v_sweep(center.x + cap - depth, y, depth, run, top, bottom)
def render(self, rect, sm) -> None:
if not ui_state.rocket_fuel:
return
self._eased += (sm['carState'].aEgo - self._eased) / _STRIP_EMA
reach = self._reach() * rect.height / 2.0
if reach <= 0.0:
return
accelerating = self._eased > 0.0
mid = rect.y + rect.height / 2.0
top = mid - reach if accelerating else mid
x = rect.x + _STRIP_INSET
cap = min(_STRIP_WIDTH / 2.0, reach / 2.0)
fill, neon = (_STRIP_ACCEL, _STRIP_ACCEL_NEON) if accelerating else (_STRIP_DECEL, _STRIP_DECEL_NEON)
near = cap / reach
frac_top, frac_bottom = (near, 1.0 - near) if accelerating else (1.0 - near, near)
heat = min(abs(self._eased) / _STRIP_NEON_FULL, 1.0)
canvas.v_sweep(x, top + cap, cap * 2.0, reach - cap * 2.0,
self._tint(fill, frac_top), self._tint(fill, frac_bottom))
tail = max(0.0, reach / 2.0 - cap)
self._cap(canvas.Pt(x + cap, top + cap), cap, True, self._tint(fill, frac_top), neon, heat, tail)
self._cap(canvas.Pt(x + cap, top + reach - cap), cap, False, self._tint(fill, frac_bottom), neon, heat, tail)
_BS_INSET = 20
_BS_DROP = 100
_BS_MIN = 0.01
class _BlindSide:
def __init__(self, name: str):
self.texture = gui_app.texture(f'icons_mici/onroad/blind_spot_{name}.png', 108, 128)
self.glow = FirstOrderFilter(0, 0.15, 1 / gui_app.target_fps)
self.on_left = name == "left"
def feed(self, present: bool):
self.glow.update(1.0 if present else 0.0)
def lit(self) -> bool:
return self.glow.x > _BS_MIN
def place(self, rect):
tex = self.texture
x = rect.x + _BS_INSET if self.on_left else rect.x + rect.width - _BS_INSET - tex.width
canvas.stamp(tex, x, rect.y + _BS_DROP, canvas.shade(255, 255, 255, int(255 * self.glow.x)))
class IQBlindSpotOverlay:
def __init__(self):
self._left = _BlindSide("left")
self._right = _BlindSide("right")
def update(self) -> None:
cs = _feed()['carState']
self._left.feed(cs.leftBlindspot)
self._right.feed(cs.rightBlindspot)
@property
def detected(self) -> bool:
return self._left.lit() or self._right.lit()
def render(self, rect) -> None:
if not ui_state.blindspot:
return
for side in (self._left, self._right):
if side.lit():
side.place(rect)
_MQB_CLUSTER_EXEMPT = (VolkswagenFlags.PQ | VolkswagenFlags.MLB | VolkswagenFlags.MEB | VolkswagenFlags.MEB_GEN2 | VolkswagenFlags.MQB_EVO)
class IQSpeedOverlay:
def __init__(self):
self.speed: float = 0.0
self._cluster_ever_live: bool = False
self._heavy = gui_app.font(FontWeight.BOLD)
self._mid = gui_app.font(FontWeight.MEDIUM)
def _source_speed(self, cs) -> float:
cp = ui_state.CP
if cp is not None and cp.brand == "volkswagen" and not (cp.flags & _MQB_CLUSTER_EXEMPT):
return cs.vEgoCluster
self._cluster_ever_live = self._cluster_ever_live or cs.vEgoCluster != 0.0
return cs.vEgoCluster if self._cluster_ever_live else cs.vEgo
def update(self) -> None:
self.speed = max(0.0, self._source_speed(_feed()['carState']) * _speed_scale())
def _stack(self, face, text: str, size: int, rect, top: float, color) -> float:
extent = canvas.span(face, text, size)
canvas.glyphs(face, text, canvas.Pt(rect.x + (rect.width - extent.x) / 2, top), size, color)
return extent.y
def render(self, rect) -> None:
top = rect.y + 52
number_h = self._stack(self._heavy, str(round(self.speed)), FONT_SIZES.current_speed, rect, top, COLORS.WHITE)
unit = tr("km/h") if ui_state.is_metric else tr("mph")
self._stack(self._mid, unit, FONT_SIZES.speed_unit, rect, top + number_h - 10, COLORS.WHITE_TRANSLUCENT)
def clip_to_width(font, words: str, size: int, limit: float) -> str:
if canvas.span(font, words, size).x <= limit:
return words
trimmed = words
while len(trimmed) > 3 and canvas.span(font, trimmed + "...", size).x > limit:
trimmed = trimmed[:-1]
return trimmed + "..."
class RoadNameBanner(Widget):
TYPE_SIZE = 46
MARGIN = 40
GAP = 10
TORQUE_SCALE = 3.0
def __init__(self):
super().__init__()
self.road_name = ""
self._face = gui_app.font(FontWeight.BOLD)
def update(self):
sm = _feed()
if sm.recv_frame["carState"] < ui_state.started_frame:
return
if sm.updated["iqLiveData"]:
self.road_name = sm["iqLiveData"].roadName
def _render(self, rect):
if not self.road_name or not ui_state.road_name_toggle:
return
label = clip_to_width(self._face, self.road_name, self.TYPE_SIZE, rect.width - self.MARGIN)
extent = canvas.span(self._face, label, self.TYPE_SIZE)
top = min(TorqueBar.resting_bottom(rect, self.TORQUE_SCALE) + self.GAP,
rect.y + rect.height - extent.y)
canvas.glyphs(self._face, label,
canvas.Pt(rect.x + (rect.width - extent.x) / 2, top),
self.TYPE_SIZE, COLORS.WHITE)
RoadNameRenderer = RoadNameBanner
ellipsize = clip_to_width
from dataclasses import dataclass, field
_ARROW = 'signal'
_WARN = 'blind_spot'
@dataclass(frozen=True)
class TurnSignalConfig:
left_x: int = 80
left_y: int = 190
right_x: int = 80
right_y: int = 190
size: int = 150
class _IndicatorLamp(Widget):
def __init__(self, direction: IconSide):
super().__init__()
self.mode: str | None = None
self._epoch = 0.0
self._glow = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
self._art = {
_ARROW: gui_app.texture(f'icons_mici/onroad/turn_signal_{direction}.png', 120, 109),
_WARN: gui_app.texture(f'icons_mici/onroad/blind_spot_{direction}.png', 120, 109),
}
def set_mode(self, mode: str | None):
if mode != self.mode or mode is None:
self._epoch = 0.0
self.mode = mode
def _pulse(self) -> int:
self._glow.dt = 1 / gui_app.target_fps
self._glow.update_alpha(0.3)
if time.monotonic() - self._epoch > TURN_SIGNAL_BLINK_PERIOD:
self._epoch = time.monotonic()
self._glow.x = 255 * 2
else:
self._glow.update(255 * 0.2)
return int(min(self._glow.x, 255))
def _render(self, _):
if self.mode is None:
return
alpha = self._pulse() if self.mode == _ARROW else 255
tex = self._art[self.mode]
canvas.stamp(tex, self._rect.x + (self._rect.width - tex.width) / 2,
self._rect.y + (self._rect.height - tex.height) / 2, canvas.shade(255, 255, 255, alpha))
def _lamp_modes(event_name: str, cs, remembered):
if event_name == 'preLaneChangeLeft':
return _ARROW, None, IconSide.left
if event_name == 'preLaneChangeRight':
return None, _ARROW, IconSide.right
if event_name == 'laneChange':
if remembered == IconSide.left:
return _ARROW, None, remembered
if remembered == IconSide.right:
return None, _ARROW, remembered
return None, None, remembered
if event_name == 'laneChangeBlocked':
side = IconSide.left if cs.leftBlinker else IconSide.right if cs.rightBlinker else remembered
if side == IconSide.left:
return _WARN, None, remembered
if side == IconSide.right:
return None, _WARN, remembered
return None, None, remembered
left = _WARN if cs.leftBlindspot else _ARROW if cs.leftBlinker else None
right = _WARN if cs.rightBlindspot else _ARROW if cs.rightBlinker else None
return left, right, None
class IQTurnSignalOverlay:
def __init__(self, config: TurnSignalConfig | None = None):
self._config = config or TurnSignalConfig()
self._lamps = {IconSide.left: _IndicatorLamp(IconSide.left),
IconSide.right: _IndicatorLamp(IconSide.right)}
self._remembered: IconSide | None = None
def update(self):
sm = _feed()
alert = sm['selfdriveState'].alertType
event_name = alert.split('/')[0] if alert else ''
left, right, self._remembered = _lamp_modes(event_name, sm['carState'], self._remembered)
self._lamps[IconSide.left].set_mode(left)
self._lamps[IconSide.right].set_mode(right)
def render(self, rect):
if not ui_state.turn_signals:
return
c = self._config
mid_x = rect.x + rect.width / 2
spots = {
IconSide.left: canvas.Box(mid_x - c.left_x - c.size, rect.y + c.left_y, c.size, c.size),
IconSide.right: canvas.Box(mid_x + c.right_x, rect.y + c.right_y, c.size, c.size),
}
for side, lamp in self._lamps.items():
if lamp.mode is not None:
lamp.render(spots[side])
@property
def config(self) -> TurnSignalConfig:
return self._config
@config.setter
def config(self, new_config: TurnSignalConfig):
self._config = new_config
_PROVIDER_TAGS = {0: "", 1: "NAV", 2: "MBX", 3: "VIS", 4: "OSM"}
_NAV_TEX_W, _NAV_TEX_H = 256, 128
_NAV_BADGE_W = 160
_NAV_FONT = 36
_NAV_SHIFT = -260
class NavInfluenceRenderer(Widget):
def __init__(self):
super().__init__()
self.engaged = False
self.valid = False
self.provider = 0
self.long_override = False
self._streak = 0
self.font = gui_app.font(FontWeight.BOLD)
self._offscreen = rl.load_render_texture(_NAV_TEX_W, _NAV_TEX_H)
def update(self):
sm = _feed()
if sm.updated["iqPlan"]:
nav = sm["iqPlan"].iqNavState.nav
self.engaged = nav.engaged
self.valid = nav.valid
self.provider = getattr(nav.provider, "raw", nav.provider)
if sm.updated["carControl"]:
self.long_override = sm["carControl"].cruiseControl.override
self._streak = self._streak + 1 if (self.engaged and self.valid) else 0
def _blinked_off(self) -> bool:
fps = gui_app.target_fps
return self.engaged and (self._streak % fps) < (fps / 2.5)
def _bake(self, label: str):
extent = canvas.span(self.font, label, _NAV_FONT)
badge = canvas.Box((_NAV_TEX_W - _NAV_BADGE_W) // 2, (_NAV_TEX_H - extent.y - 10) // 2,
_NAV_BADGE_W, int(extent.y + 10))
rl.begin_texture_mode(self._offscreen)
rl.clear_background(canvas.CLEAR)
canvas.panel(badge, 0.2, 10, COLORS.OVERRIDE if self.long_override else canvas.shade(0, 255, 0, 255))
rl.rl_set_blend_factors(rl.RL_ZERO, rl.RL_ONE_MINUS_SRC_ALPHA, 0x8006)
rl.rl_set_blend_mode(rl.BLEND_CUSTOM)
canvas.glyphs(self.font, label,
canvas.Pt(badge.x + (badge.width - extent.x) / 2, badge.y + (badge.height - extent.y) / 2),
_NAV_FONT, canvas.WHITE)
rl.rl_set_blend_mode(rl.BLEND_ALPHA)
rl.end_texture_mode()
def _render(self, rect):
if not self.valid or self._blinked_off():
return
label = _PROVIDER_TAGS.get(int(self.provider), "NAV")
if not label:
return
self._bake(label)
ax = rect.x + rect.width / 2 + _NAV_SHIFT - _NAV_TEX_W / 2
ay = (rect.height / 4 - 40) - _NAV_TEX_H / 2
canvas.stamp_scaled(self._offscreen.texture, canvas.Box(0, 0, _NAV_TEX_W, -_NAV_TEX_H),
canvas.Box(ax, ay, _NAV_TEX_W, _NAV_TEX_H), canvas.Pt(0, 0), 0, canvas.WHITE)
class ChevronOptions:
OFF = 0
DISTANCE_ONLY = 1
SPEED_ONLY = 2
TTC_ONLY = 3
ALL = 4
_CH_FONT = 40
_CH_LINE = 50
_CH_MARGIN = 20
_CH_FADE_DOWN = 0.05
_CH_FADE_UP = 0.1
_CH_DEDUP = 3.0
def _gap_label(d_rel: float, _v_abs: float) -> str:
val = max(0.0, d_rel)
return f"{val:.0f} m" if ui_state.is_metric else f"{val * 3.28084:.0f} ft"
def _pace_label(_d_rel: float, v_abs: float) -> str:
unit = "km/h" if ui_state.is_metric else "mph"
return f"{max(0.0, v_abs * _speed_scale()):.0f} {unit}"
def _ttc_label(d_rel: float, _v_abs: float, v_ego: float) -> str:
ttc = (d_rel / v_ego) if (d_rel > 0 and v_ego > 0) else 0.0
return f"{ttc:.1f} s" if 0 < ttc < 200 else "---"
_CH_METRICS = (
((ChevronOptions.DISTANCE_ONLY, ChevronOptions.ALL), lambda d, va, ve: _gap_label(d, va)),
((ChevronOptions.SPEED_ONLY, ChevronOptions.ALL), lambda d, va, ve: _pace_label(d, va)),
((ChevronOptions.TTC_ONLY, ChevronOptions.ALL), _ttc_label),
)
class ChevronMetrics:
def __init__(self):
self._alpha: float = 0.0
self._font = gui_app.font(FontWeight.SEMI_BOLD)
def update_alpha(self, has_lead: bool):
self._alpha = float(np.clip(self._alpha + (_CH_FADE_UP if has_lead else -_CH_FADE_DOWN), 0.0, 1.0))
def should_render(self) -> bool:
return ui_state.chevron_metrics != ChevronOptions.OFF and self._alpha > 0.0
@staticmethod
def _marker_size(d_rel: float) -> float:
return float(np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0)) * 2.35
def _labels(self, d_rel: float, v_rel: float, v_ego: float) -> list[str]:
mode = ui_state.chevron_metrics
return [fn(d_rel, v_rel + v_ego, v_ego) for modes, fn in _CH_METRICS if mode in modes]
def _top_y(self, anchor_y: float, size: float, n: int, rect) -> float:
y = anchor_y + size + 15
block = n * _CH_LINE
floor = rect.y + rect.height - _CH_MARGIN
if y + block > floor:
y = max(rect.y + _CH_MARGIN, min(anchor_y, floor) - 15 - block)
return y
def _stack(self, lines, cx: float, top: float, rect):
a = self._alpha
fg = canvas.shade(255, 255, 255, int(255 * a))
shadow = canvas.shade(0, 0, 0, int(200 * a))
floor = rect.y + rect.height - _CH_MARGIN
for i, line in enumerate(lines):
y = int(top + i * _CH_LINE)
if y + _CH_LINE > floor:
break
w = canvas.span(self._font, line, _CH_FONT).x
x = int(np.clip(cx - w / 2, rect.x + _CH_MARGIN, rect.x + rect.width - w - _CH_MARGIN))
canvas.glyphs(self._font, line, canvas.Pt(x + 2, y + 2), _CH_FONT, shadow)
canvas.glyphs(self._font, line, canvas.Pt(x, y), _CH_FONT, fg)
def _one_lead(self, lead, marker, v_ego: float, rect):
if not self.should_render() or marker.center is None:
return
lines = self._labels(lead.dRel, lead.vRel, v_ego)
if not lines:
return
self._stack(lines, marker.center[0], self._top_y(marker.center[1], self._marker_size(lead.dRel), len(lines), rect), rect)
@staticmethod
def _active_leads(radar_state, markers):
tracked = []
for lead, marker in zip((radar_state.leadOne, radar_state.leadTwo), markers, strict=False):
if lead and lead.status and marker.center is not None:
tracked.append((lead, marker))
if len(tracked) == 2 and abs(tracked[0][0].dRel - tracked[1][0].dRel) <= _CH_DEDUP:
tracked.pop()
return tracked
def draw_lead_status(self, sm, radar_state, rect, lead_vehicles):
present = [radar_state.leadOne, radar_state.leadTwo]
self.update_alpha(any(bool(x) and x.status for x in present))
if not self.should_render():
return
v_ego = sm['carState'].vEgo
for lead, marker in self._active_leads(radar_state, lead_vehicles):
self._one_lead(lead, marker, v_ego, rect)
_TEAL = canvas.shade(0x0C, 0x94, 0x96, 0xFF)
_AMBER = canvas.shade(255, 188, 0, 255)
_GREEN = canvas.shade(0, 255, 0, 255)
_GREY = canvas.shade(145, 155, 149, 255)
_G = 9.81
_BAR_FONT = 38
_ANGLE_TYPES = (car.CarParams.SteerControlType.angle, car.CarParams.SteerControlType.curvatureDEPRECATED)
@dataclass
class Readout:
tag: str
value: str
unit: str = ""
color: object = field(default_factory=lambda: canvas.WHITE)
tag_text: str = ""
value_text: str = ""
unit_text: str = ""
tag_w: float = 0.0
value_w: float = 0.0
unit_w: float = 0.0
span: float = 0.0
def size_up(self, font, px: int):
self.tag_text = f"{self.tag} "
self.value_text = self.value
self.unit_text = f" {self.unit}" if self.unit else ""
self.tag_w = canvas.span(font, self.tag_text, px, 0).x
self.value_w = canvas.span(font, self.value_text, px, 0).x
self.unit_w = canvas.span(font, self.unit_text, px, 0).x if self.unit else 0
self.span = self.tag_w + self.value_w + self.unit_w
@property
def total_width(self):
return self.span
def measure(self, font, px):
self.size_up(font, px)
UiElement = Readout
def _banded(magnitude, warn, crit, ok):
if magnitude > crit:
return canvas.RED
return _AMBER if magnitude > warn else ok
def _closing(v_rel):
return _banded(-v_rel if v_rel < 0 else 0.0, 0.0, 4.4704, canvas.WHITE)
def _following(d_rel):
if d_rel < 5:
return canvas.RED
return _AMBER if d_rel < 15 else canvas.WHITE
def _steer_tint(sm):
if not sm['carControl'].latActive:
return canvas.WHITE
return _GREY if sm['carState'].steeringPressed else _TEAL
def _angle_tint(sm, deg):
floor = _steer_tint(sm) if sm['carControl'].latActive else canvas.WHITE
return _banded(abs(deg), 90.0, 180.0, floor)
def _yaw_offset(sm):
return sm['vehicleParameters'].angleOffsetAverageDeg if sm.valid['vehicleParameters'] else 0.0
def _bank(sm):
return sm['vehicleParameters'].roll if sm.valid['vehicleParameters'] else 0.0
def _units(is_metric):
return (CV.MS_TO_KPH, "km/h") if is_metric else (CV.MS_TO_MPH, "mph")
def _fix(sm):
for svc in ('gpsLocationExternal', 'gpsLocation'):
if sm.valid[svc]:
return sm[svc], svc
return None, None
def steering_angle(sm, is_metric):
deg = sm['carState'].steeringAngleDeg - _yaw_offset(sm)
return Readout("R.S.", f"{deg:.1f}°", color=_angle_tint(sm, deg))
def desired_steering_angle(sm, is_metric):
live = sm['carControl'].latActive
off = _yaw_offset(sm)
lat = sm['controlsState'].lateralControlState
if lat.which() == 'angleState':
want = lat.angleState.steeringAngleDesiredDeg - off
else:
want = sm['carControl'].actuators.steeringAngleDeg - off
seen = sm['carState'].steeringAngleDeg - off
tint = _banded(abs(seen), 90.0, 180.0, _TEAL) if live else canvas.WHITE
return Readout("D.S.", f"{want:.1f}°" if live else "-", color=tint)
def desired_steering_pid(sm, is_metric):
live = sm['carControl'].latActive
off = _yaw_offset(sm)
want = sm['controlsState'].lateralControlState.pidState.steeringAngleDesiredDeg - off
seen = sm['carState'].steeringAngleDeg - off
tint = _banded(abs(seen), 90.0, 180.0, _TEAL) if live else canvas.WHITE
return Readout("D.S.", f"{want:.1f}°" if live else "-", color=tint)
def actual_lat_accel(sm, is_metric):
a = sm['controlsState'].curvature * sm['carState'].vEgo ** 2 - _bank(sm) * _G
return Readout("A.L.A.", f"{a:.2f}", "m/s^2", _steer_tint(sm))
def desired_lat_accel(sm, is_metric):
live = sm['carControl'].latActive
a = sm['controlsState'].desiredCurvature * sm['carState'].vEgo ** 2 - _bank(sm) * _G
return Readout("D.L.A.", f"{a:.2f}" if live else "-", "m/s^2", _steer_tint(sm))
def a_ego(sm, is_metric):
return Readout("L.ACC.", f"{sm['carState'].aEgo:.1f}", "m/s^2")
def lead_distance(sm, is_metric):
lead = sm['radarState'].leadOne
return Readout("REL DIST", "-", "m") if not lead.status else Readout("REL DIST", f"{lead.dRel:.0f}", "m", _following(lead.dRel))
def lead_rel_speed(sm, is_metric):
lead = sm['radarState'].leadOne
k, unit = _units(is_metric)
return Readout("REL SPEED", "-", unit) if not lead.status else Readout("REL SPEED", f"{lead.vRel * k:.0f}", unit, _closing(lead.vRel))
def lead_speed(sm, is_metric):
lead = sm['radarState'].leadOne
k, unit = _units(is_metric)
if not lead.status:
return Readout("L.S.", "-", unit)
return Readout("L.S.", f"{(lead.vRel + sm['carState'].vEgo) * k:.0f}", unit, _closing(lead.vRel))
def friction_coefficient(sm, is_metric):
ltp = sm['lateralTorqueParameters']
return Readout("FRIC.", f"{ltp.frictionCoefficientFiltered:.3f}", color=_GREEN if ltp.valid else canvas.WHITE)
def lat_accel_factor(sm, is_metric):
ltp = sm['lateralTorqueParameters']
return Readout("L.A.F.", f"{ltp.latAccelFactorFiltered:.3f}", color=_GREEN if ltp.valid else canvas.WHITE)
def eps_torque(sm, is_metric):
return Readout("E.T.", f"{abs(sm['carState'].steeringTorqueEps):.1f}", "N·dm")
_COMPASS = ("N", "NE", "E", "SE", "S", "SW", "W", "NW")
def bearing(sm, is_metric):
fix, _ = _fix(sm)
if fix is None or fix.bearingAccuracyDeg == 180.0:
return Readout("B.D.", "OFF | -")
heading = _COMPASS[int(((fix.bearingDeg + 22.5) % 360) // 45)]
return Readout("B.D.", f"{heading} | {fix.bearingDeg:.0f}°")
def altitude(sm, is_metric):
fix, svc = _fix(sm)
if fix is None:
return Readout("ALT.", "-", "m")
acc = fix.horizontalAccuracy if svc == 'gpsLocationExternal' else 1.0
return Readout("ALT.", f"{fix.altitude:.1f}" if acc != 0.0 else "-", "m")
def _desired_probe(sm):
if sm['controlsState'].lateralControlState.which() == 'angleState':
return desired_steering_angle
if ui_state.CP is not None and ui_state.CP.steerControlType in _ANGLE_TYPES:
return desired_steering_angle
if sm['controlsState'].lateralControlState.which() == 'pidState':
return desired_steering_pid
return desired_lat_accel
class IQDevMetricsOverlay(Widget):
DEV_UI_OFF = 0
DEV_UI_RIGHT = 1
DEV_UI_BOTTOM = 2
DEV_UI_BOTH = 3
BOTTOM_BAR_HEIGHT = 61
def __init__(self):
super().__init__()
self._face = gui_app.font(FontWeight.BOLD)
self.dev_ui_mode = self.DEV_UI_OFF
@staticmethod
def get_bottom_dev_ui_offset():
return IQDevMetricsOverlay.BOTTOM_BAR_HEIGHT if ui_state.developer_ui != IQDevMetricsOverlay.DEV_UI_OFF else 0
def _update_state(self) -> None:
self.dev_ui_mode = ui_state.developer_ui
def _render(self, rect) -> None:
if self.dev_ui_mode == self.DEV_UI_OFF:
return
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame:
return
self._paint_bar(rect)
def _gather(self, sm):
probes = (_desired_probe(sm), actual_lat_accel, steering_angle, a_ego, lead_speed)
cells = [probe(sm, ui_state.is_metric) for probe in probes]
for cell in cells:
cell.size_up(self._face, _BAR_FONT)
return cells
def _paint_bar(self, rect) -> None:
height = self.BOTTOM_BAR_HEIGHT
top = int(rect.y + rect.height - height)
canvas.slab(rect.x, top, rect.width, height, canvas.shade(0, 0, 0, 100))
cells = self._gather(ui_state.sm)
slack = (rect.width - sum(c.span for c in cells)) / (len(cells) + 1)
baseline = top + height // 2 - _BAR_FONT // 2
cursor = rect.x + slack
for cell in cells:
self._paint_cell(cursor, baseline, cell)
cursor += cell.span + slack
def _paint_cell(self, x, y, cell) -> None:
canvas.glyphs(self._face, cell.tag_text, canvas.Pt(x, y), _BAR_FONT, canvas.WHITE)
canvas.glyphs(self._face, cell.value_text, canvas.Pt(x + cell.tag_w, y), _BAR_FONT, cell.color)
if cell.unit:
canvas.glyphs(self._face, cell.unit_text, canvas.Pt(x + cell.tag_w + cell.value_w, y), _BAR_FONT, canvas.WHITE)
_SL_M_TO_FT = 3.28084
_SL_M_TO_MI = 0.000621371
_SL_AHEAD_STEPS = 5
_SL_ASSIST = custom.IQPlan.SpeedLimit.AssistState
_SL_SOURCE = custom.IQPlan.SpeedLimit.Source
_SL_GREY = canvas.shade(145, 155, 149, 255)
_SL_DARK = canvas.shade(77, 77, 77, 255)
_SL_PANEL_BG = canvas.shade(0, 0, 0, 180)
_SL_PANEL_EDGE = canvas.shade(255, 255, 255, 100)
def _dim(color, alpha: float):
return canvas.with_opacity(color, 255 * alpha)
class IQSpeedLimitOverlay(Widget):
def __init__(self):
super().__init__()
self.speed_limit = 0.0
self.speed_limit_last = 0.0
self.speed_limit_offset = 0.0
self.speed_limit_valid = False
self.speed_limit_last_valid = False
self.speed_limit_final_last = 0.0
self.speed_limit_source = _SL_SOURCE.none
self.assist_state = _SL_ASSIST.disabled
self.ahead_limit = 0.0
self.ahead_dist = 0.0
self._ahead_prev = 0.0
self.ahead_valid = False
self._ahead_streak = 0
self.assist_frame = 0
self.speed = 0.0
self.set_speed = 0.0
self._bold = gui_app.font(FontWeight.BOLD)
self._demi = gui_app.font(FontWeight.SEMI_BOLD)
self._norm = gui_app.font(FontWeight.NORMAL)
self._pulse_ema = FirstOrderFilter(1.0, 0.5, 1 / gui_app.target_fps)
px = 90
self._up = gui_app.texture("img_plus_arrow_up.png", px, px)
self._down = gui_app.texture("img_minus_arrow_down.png", px, px)
@property
def speed_limit_assist_state(self):
return self.assist_state
@property
def _scale(self):
return CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
def _take_plan(self, lp_iq):
k = self._scale
r = lp_iq.speedLimit.resolver
self.speed_limit = r.speedLimit * k
self.speed_limit_last = r.speedLimitLast * k
self.speed_limit_offset = r.speedLimitOffset * k
self.speed_limit_valid = r.speedLimitValid
self.speed_limit_last_valid = r.speedLimitLastValid
self.speed_limit_final_last = r.speedLimitFinalLast * k
self.speed_limit_source = r.source
self.assist_state = lp_iq.speedLimit.assist.state
def _take_ahead(self, lmd):
self.ahead_valid = lmd.speedLimitAheadValid
self.ahead_limit = lmd.speedLimitAhead * self._scale
self.ahead_dist = lmd.speedLimitAheadDistance
if self.ahead_dist < self._ahead_prev:
self._ahead_streak = min(_SL_AHEAD_STEPS, self._ahead_streak + 1)
elif self.ahead_dist > self._ahead_prev:
self._ahead_streak = max(0, self._ahead_streak - 1)
self._ahead_prev = self.ahead_dist
def update(self):
sm = _feed()
if sm.recv_frame["carState"] < ui_state.started_frame:
return
if sm.updated["iqPlan"]:
self._take_plan(sm["iqPlan"])
if sm.updated["iqLiveData"]:
self._take_ahead(sm["iqLiveData"])
cs = sm["carState"]
self.set_speed = cs.cruiseState.speed * self._scale
v_ego = cs.vEgoCluster if cs.vEgoCluster != 0.0 else cs.vEgo
self.speed = max(0.0, v_ego * self._scale)
def _spec(self):
has_limit = self.speed_limit_valid or self.speed_limit_last_valid
value = str(round(self.speed_limit_last)) if has_limit else "---"
badge = ""
if self.speed_limit_offset != 0:
badge = f"{'' if self.speed_limit_offset > 0 else '-'}{round(abs(self.speed_limit_offset))}"
warn = ui_state.speed_limit_mode >= 2
over = has_limit and round(self.speed_limit_final_last) < round(self.speed)
tint = canvas.RED if (warn and over) else (_SL_GREY if not self.speed_limit_valid else canvas.BLACK)
return value, badge, tint, has_limit
def _render(self, rect):
if ui_state.speed_limit_mode == 0:
return
w = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
sign = canvas.Box(rect.x + 60 - 6, rect.y + 45 + UI_CONFIG.set_speed_height + 12, w + 12, 160)
if self.assist_state == _SL_ASSIST.preActive:
self.assist_frame += 1
pulse = 0.65 + 0.35 * math.sin(self.assist_frame * math.pi / gui_app.target_fps)
self._sign(sign, self._pulse_ema.update(pulse))
self._nudge_arrow(sign)
else:
self.assist_frame = 0
self._pulse_ema.update(1.0)
self._sign(sign)
self._ahead(sign)
def _sign(self, rect, alpha=1.0):
value, badge, tint, has_limit = self._spec()
(self._vienna if ui_state.is_metric else self._mutcd)(rect, value, badge, tint, has_limit, alpha)
def _nudge_arrow(self, sign):
delta = round(self.speed_limit_final_last) - round(self.set_speed)
if delta == 0:
return
arrow = self._up if delta > 0 else self._down
bounce = int(20 * math.sin(self.assist_frame * 2.0 * math.pi / (gui_app.target_fps * 2.5)))
x = sign.x + (sign.width - arrow.width) / 2
y = sign.y + (sign.height - arrow.height) / 2 + (bounce if delta > 0 else -bounce)
canvas.stamp(arrow, x, y, canvas.WHITE)
def _vienna(self, rect, value, badge, tint, has_limit, alpha=1.0):
hub = canvas.Pt(rect.x + rect.width / 2, rect.y + rect.height / 2)
radius = (rect.width + 18) / 2
canvas.disc_at(hub, radius, _dim(canvas.WHITE, alpha))
canvas.annulus(hub, radius * 0.80, radius, 0, 360, 36, _dim(canvas.RED, alpha))
canvas.glyphs_centered(self._bold, value, 70 if len(value) >= 3 else 85, hub, _dim(tint, alpha))
if badge and has_limit:
br = radius * 0.4
bc = canvas.Pt(rect.x + rect.width - br / 2, rect.y + br / 2)
canvas.disc_at(bc, br, _dim(canvas.BLACK, alpha))
canvas.annulus(bc, br - 3, br, 0, 360, 36, _dim(_SL_DARK, alpha))
canvas.glyphs_centered(self._bold, badge, int(br * 2 * (0.5 if len(badge) < 3 else 0.45)), bc, _dim(canvas.WHITE, alpha))
def _mutcd(self, rect, value, badge, tint, has_limit, alpha=1.0):
canvas.panel(rect, 0.35, 10, _dim(canvas.WHITE, alpha))
inner = canvas.Box(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20)
canvas.panel_outline(inner, 0.35, 10, 4, _dim(canvas.BLACK, alpha))
mid = rect.x + rect.width / 2
canvas.glyphs_centered(self._demi, "SPEED", 40, canvas.Pt(mid, rect.y + 40), _dim(canvas.BLACK, alpha))
canvas.glyphs_centered(self._demi, "LIMIT", 40, canvas.Pt(mid, rect.y + 80), _dim(canvas.BLACK, alpha))
canvas.glyphs_centered(self._bold, value, 90, canvas.Pt(mid, rect.y + 150), _dim(tint, alpha))
if badge and has_limit:
side = rect.width * 0.3
overlap = side * 0.2
chip = canvas.Box(rect.x + rect.width - side / 1.5 + overlap, rect.y - side / 1.25 + overlap, side, side)
canvas.panel(chip, 0.35, 10, _dim(canvas.BLACK, alpha))
canvas.panel_outline(chip, 0.35, 10, 6, _dim(_SL_DARK, alpha))
canvas.glyphs_centered(self._bold, badge, int(side * (0.6 if len(badge) < 3 else 0.475)),
canvas.Pt(chip.x + side / 2, chip.y + side / 2), _dim(canvas.WHITE, alpha))
def _ahead(self, sign):
if not (self.ahead_valid and self.ahead_limit > 0 and self.ahead_limit != self.speed_limit_last and self._ahead_streak > 0):
return
panel = canvas.Box(sign.x + (sign.width - 170) / 2, sign.y + sign.height + 10, 170, 160)
canvas.panel(panel, 0.35, 10, _SL_PANEL_BG)
canvas.panel_outline(panel, 0.35, 10, 3, _SL_PANEL_EDGE)
mid = panel.x + panel.width / 2
canvas.glyphs_centered(self._demi, "AHEAD", 40, canvas.Pt(mid, panel.y + 28), _SL_GREY)
canvas.glyphs_centered(self._bold, str(round(self.ahead_limit)), 70, canvas.Pt(mid, panel.y + 82), canvas.WHITE)
canvas.glyphs_centered(self._norm, self._dist(self.ahead_dist), 36, canvas.Pt(mid, panel.y + 134), _SL_GREY)
@staticmethod
def _dist(d):
if ui_state.is_metric:
if d < 50:
return tr("Near")
if d >= 1000:
return f"{d / 1000:.1f} km"
return f"{int(round(d, -1) if d < 200 else round(d, -2))} m"
ft = d * _SL_M_TO_FT
if ft < 100:
return tr("Near")
if ft >= 900:
return f"{d * _SL_M_TO_MI:.1f} mi"
step = 50 if ft < 500 else 100
return f"{int(round(ft / step) * step)} ft"