Prototype assisted driving milestones

This commit is contained in:
Isaac Barham
2026-08-28 07:27:01 -04:00
parent 6db807b5a0
commit bb1a17d2a0
11 changed files with 442 additions and 3 deletions
+1
View File
@@ -9,6 +9,7 @@
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
openpilot/selfdrive/assets/sounds/milestone.wav -filter -diff -merge -text
openpilot/selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text
openpilot/common/hardware/comma/updater filter=lfs diff=lfs merge=lfs -text
Binary file not shown.
+5 -1
View File
@@ -1,5 +1,8 @@
import os
import pyray as rl
import openpilot.cereal.messaging as messaging
from openpilot.common.hardware import PC
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
@@ -61,7 +64,8 @@ class MiciMainLayout(Scroller):
# Start onboarding if terms or training not completed, make sure to push after self
self._onboarding_window = OnboardingWindow(lambda: gui_app.pop_widgets_to(self))
if not self._onboarding_window.completed:
skip_onboarding_for_local_prototype = PC and os.getenv("SP_MILESTONE_PROTOTYPE") == "1"
if not self._onboarding_window.completed and not skip_onboarding_for_local_prototype:
gui_app.push_widget(self._onboarding_window)
# initialize correct onroad layout
@@ -19,10 +19,15 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCamera
from openpilot.common.transformations.orientation import rot_from_euler
from enum import IntEnum
MILESTONE_PROTOTYPE_ENABLED = gui_app.sunnypilot_ui()
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.hud_renderer import HudRendererSP as HudRenderer
from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus
if MILESTONE_PROTOTYPE_ENABLED:
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_celebration_prototype import MilestoneCelebrationPrototype
OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD
@@ -156,6 +161,7 @@ class AugmentedRoadView(CameraView):
self._alert_renderer = AlertRenderer()
self._driver_state_renderer = DriverStateRenderer()
self._confidence_ball = ConfidenceBall()
self._milestone_celebration = MilestoneCelebrationPrototype() if MILESTONE_PROTOTYPE_ENABLED else None
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
@@ -221,6 +227,9 @@ class AugmentedRoadView(CameraView):
# Fade out bottom of overlays for looks
rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE)
if self._milestone_celebration is not None:
self._milestone_celebration.render(self._content_rect)
alert_to_render, not_animating_out = self._alert_renderer.will_render()
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
@@ -247,6 +256,8 @@ class AugmentedRoadView(CameraView):
self._confidence_ball.render(self.rect)
self._bookmark_icon.render(self.rect)
if self._milestone_celebration is not None:
self._milestone_celebration.capture_screenshot()
def _switch_stream_if_needed(self, sm):
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
+24 -1
View File
@@ -15,6 +15,7 @@ from openpilot.system import micd
from openpilot.common.hardware import HARDWARE
from openpilot.sunnypilot.selfdrive.ui.quiet_mode import QuietMode
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_tracker_prototype import PerDriveMilestoneTracker, assist_category
SAMPLE_RATE = 48000
SAMPLE_BUFFER = 4096 # (approx 100ms)
@@ -53,6 +54,7 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
AudibleAlert.promptDistracted: ("dm_warning.wav", None, MAX_VOLUME),
AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME),
AudibleAlert.complete: ("milestone.wav", 1, MAX_VOLUME),
AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME),
AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME),
@@ -85,6 +87,8 @@ class Soundd(QuietMode):
self.selfdrive_timeout_alert = False
self.pending_stop = False
self.milestone_tracker = PerDriveMilestoneTracker()
self._started_prev = False
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
@@ -164,6 +168,24 @@ class Soundd(QuietMode):
self.update_alert(AudibleAlert.none)
self.selfdrive_timeout_alert = False
def update_milestone_alert(self, sm):
started = sm['deviceState'].started
if started != self._started_prev:
self.milestone_tracker.reset()
self._started_prev = started
if not started:
return
car_control = sm['carControl']
milestones = self.milestone_tracker.update(
sm.logMonoTime['carState'],
sm['carState'].vEgo,
assist_category(car_control.latActive, car_control.longActive),
)
if milestones and self.current_alert == AudibleAlert.none and not self.enabled:
self.update_alert(AudibleAlert.complete)
def calculate_volume(self, weighted_db):
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
@@ -180,7 +202,7 @@ class Soundd(QuietMode):
import sounddevice as sd
micd.patch_sounddevice(sd)
sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure'])
sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure', 'deviceState', 'carState', 'carControl'])
with self.get_stream(sd) as stream:
rk = Ratekeeper(20)
@@ -198,6 +220,7 @@ class Soundd(QuietMode):
self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x))
self.get_audible_alert(sm)
self.update_milestone_alert(sm)
# Ramp up immediate warning sound over 4s
if self.current_alert == AudibleAlert.warningImmediate:
@@ -0,0 +1,181 @@
"""PROTOTYPE: Tesla-style milestone celebration over the on-road view.
Question: does a brief, full-screen confetti overlay feel at home on comma four?
This deliberately keeps all state in memory and retriggers once on every drive.
"""
import math
import os
import random
import time
from collections import deque
from dataclasses import dataclass
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_tracker_prototype import (
AssistCategory,
DistanceMilestone,
PerDriveMilestoneTracker,
assist_category,
)
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
CELEBRATION_DURATION = 4.5
PARTICLE_COUNT = 150
CONFETTI_COLORS = (
rl.Color(255, 55, 95, 255),
rl.Color(255, 183, 3, 255),
rl.Color(48, 209, 88, 255),
rl.Color(36, 179, 255, 255),
rl.Color(112, 72, 232, 255),
rl.Color(255, 45, 196, 255),
)
@dataclass(frozen=True)
class ConfettiParticle:
x: float
y: float
width: float
height: float
speed: float
drift: float
angle: float
spin: float
phase: float
color: rl.Color
class MilestoneCelebrationPrototype(Widget):
"""Throwaway visual spike enabled on the sunnypilot comma four UI."""
def __init__(self):
super().__init__()
self._drive_started_time = -1.0
self._celebration_started_time: float | None = None
self._current_milestone: DistanceMilestone | None = None
self._pending_milestones: deque[DistanceMilestone] = deque()
self._tracker = PerDriveMilestoneTracker()
self._screenshot_taken = False
self._screenshot_ready = False
self._particles = self._make_particles()
@staticmethod
def _make_particles() -> list[ConfettiParticle]:
rng = random.Random(20260828)
return [
ConfettiParticle(
x=rng.random(),
y=rng.uniform(-0.25, 0.95),
width=rng.uniform(10, 24),
height=rng.uniform(24, 58),
speed=rng.uniform(0.12, 0.34),
drift=rng.uniform(-0.035, 0.035),
angle=rng.uniform(0, 360),
spin=rng.uniform(-150, 150),
phase=rng.uniform(0, math.tau),
color=CONFETTI_COLORS[rng.randrange(len(CONFETTI_COLORS))],
)
for _ in range(PARTICLE_COUNT)
]
def _render(self, rect: rl.Rectangle, /) -> None:
now = time.monotonic()
if ui_state.started_time != self._drive_started_time:
# Reset on every off-road -> on-road transition so the spike can be tested on every drive.
self._drive_started_time = ui_state.started_time
self._celebration_started_time = None
self._current_milestone = None
self._pending_milestones.clear()
self._tracker.reset()
self._screenshot_taken = False
self._screenshot_ready = False
car_control = ui_state.sm["carControl"]
category = assist_category(car_control.latActive, car_control.longActive)
self._pending_milestones.extend(self._tracker.update(
ui_state.sm.logMonoTime["carState"],
ui_state.sm["carState"].vEgo,
category,
))
if self._current_milestone is None and self._pending_milestones:
self._current_milestone = self._pending_milestones.popleft()
self._celebration_started_time = now
if self._celebration_started_time is None or self._current_milestone is None:
return
elapsed = now - self._celebration_started_time
if elapsed >= CELEBRATION_DURATION:
self._celebration_started_time = None
self._current_milestone = None
return
alpha = min(1.0, elapsed / 0.2, (CELEBRATION_DURATION - elapsed) / 0.8)
self._draw_confetti(rect, elapsed, alpha)
self._draw_milestone_card(rect, elapsed, alpha, self._current_milestone)
self._screenshot_ready = elapsed >= 1.0
def capture_screenshot(self) -> None:
screenshot_path = os.getenv("SP_MILESTONE_SCREENSHOT")
if screenshot_path and self._screenshot_ready and not self._screenshot_taken:
rl.rl_draw_render_batch_active()
rl.take_screenshot(screenshot_path)
self._screenshot_taken = True
def _draw_confetti(self, rect: rl.Rectangle, elapsed: float, alpha: float) -> None:
travel_height = rect.height * 1.45
compact = rect.height <= 300
particle_scale = rect.height / 1080.0
particles = self._particles[:100] if compact else self._particles
for particle in particles:
x = rect.x + rect.width * (particle.x + particle.drift * elapsed + 0.012 * math.sin(elapsed * 3 + particle.phase))
y = rect.y - rect.height * 0.2 + (particle.y * travel_height + particle.speed * rect.height * elapsed) % travel_height
flip = 0.2 + 0.8 * abs(math.sin(elapsed * 5 + particle.phase))
particle_rect = rl.Rectangle(x, y, particle.width * particle_scale * flip, particle.height * particle_scale)
origin = rl.Vector2(particle_rect.width / 2, particle_rect.height / 2)
color = rl.Color(particle.color.r, particle.color.g, particle.color.b, int(245 * alpha))
rl.draw_rectangle_pro(particle_rect, origin, particle.angle + particle.spin * elapsed, color)
@staticmethod
def _draw_milestone_card(rect: rl.Rectangle, elapsed: float, alpha: float, milestone: DistanceMilestone) -> None:
compact = rect.height <= 300
scale = rect.height / (240.0 if compact else 1080.0)
card_width = (210 if compact else 590) * scale
card_height = (82 if compact else 230) * scale
pulse = 1.0 + 0.025 * math.sin(min(elapsed, 0.6) / 0.6 * math.pi)
card_width *= pulse
card_height *= pulse
card = rl.Rectangle(
rect.x + (rect.width - card_width) / 2,
rect.y + (rect.height - card_height) / 2,
card_width,
card_height,
)
rl.draw_rectangle_rounded(card, 0.20, 16, rl.Color(25, 31, 42, int(225 * alpha)))
rl.draw_rectangle_rounded_lines_ex(card, 0.20, 16, max(1, int(2 * scale)), rl.Color(255, 255, 255, int(90 * alpha)))
number_font = gui_app.font(FontWeight.BOLD)
label_font = gui_app.font(FontWeight.MEDIUM)
number_size = int((35 if compact else 98) * scale)
label_size = int((14 if compact else 42) * scale)
number = f"{milestone.distance_miles:.1f} mi"
label = "FULL ASSIST MILESTONE" if milestone.category == AssistCategory.FULL_ASSIST else "MADS MILESTONE"
number_bounds = measure_text_cached(number_font, number, number_size)
label_bounds = measure_text_cached(label_font, label, label_size)
number_y = 9 if compact else 35
label_y = 55 if compact else 145
number_pos = rl.Vector2(card.x + (card.width - number_bounds.x) / 2, card.y + number_y * scale)
label_pos = rl.Vector2(card.x + (card.width - label_bounds.x) / 2, card.y + label_y * scale)
rl.draw_text_ex(number_font, number, number_pos, number_size, 0, rl.Color(255, 255, 255, int(255 * alpha)))
rl.draw_text_ex(label_font, label, label_pos, label_size, 2 * scale, rl.Color(220, 226, 235, int(230 * alpha)))
@@ -0,0 +1,70 @@
"""PROTOTYPE: per-drive assisted-distance milestone tracking."""
from dataclasses import dataclass
from enum import StrEnum
METERS_PER_MILE = 1609.344
TEST_MILESTONE_MILES = 0.5
MAX_SAMPLE_INTERVAL_SECONDS = 0.5
class AssistCategory(StrEnum):
MADS = "mads"
FULL_ASSIST = "full_assist"
def assist_category(lat_active: bool, long_active: bool) -> AssistCategory | None:
if not lat_active:
return None
return AssistCategory.FULL_ASSIST if long_active else AssistCategory.MADS
@dataclass(frozen=True)
class DistanceMilestone:
category: AssistCategory
distance_meters: float
@property
def distance_miles(self) -> float:
return self.distance_meters / METERS_PER_MILE
class PerDriveMilestoneTracker:
def __init__(self, milestone_meters: float = TEST_MILESTONE_MILES * METERS_PER_MILE):
if milestone_meters <= 0:
raise ValueError("milestone distance must be positive")
self.milestone_meters = milestone_meters
self.reset()
def reset(self) -> None:
self._distance_meters = dict.fromkeys(AssistCategory, 0.0)
self._next_milestone_meters = dict.fromkeys(AssistCategory, self.milestone_meters)
self._last_timestamp_ns: int | None = None
self._last_speed_mps = 0.0
self._last_category: AssistCategory | None = None
def distance_meters(self, category: AssistCategory) -> float:
return self._distance_meters[category]
def update(self, timestamp_ns: int, speed_mps: float, category: AssistCategory | None) -> list[DistanceMilestone]:
milestones: list[DistanceMilestone] = []
speed_mps = max(0.0, speed_mps)
if self._last_timestamp_ns is not None and timestamp_ns != self._last_timestamp_ns:
dt = (timestamp_ns - self._last_timestamp_ns) / 1e9
if 0 < dt <= MAX_SAMPLE_INTERVAL_SECONDS and self._last_category is not None:
delta_meters = (self._last_speed_mps + speed_mps) / 2.0 * dt
active_category = self._last_category
self._distance_meters[active_category] += delta_meters
next_milestone = self._next_milestone_meters[active_category]
while self._distance_meters[active_category] >= next_milestone:
milestones.append(DistanceMilestone(active_category, next_milestone))
next_milestone += self.milestone_meters
self._next_milestone_meters[active_category] = next_milestone
self._last_timestamp_ns = timestamp_ns
self._last_speed_mps = speed_mps
self._last_category = category
return milestones
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Generate the temporary milestone celebration chime."""
import math
import wave
from array import array
from pathlib import Path
SAMPLE_RATE = 48_000
DURATION_SECONDS = 0.82
NOTES = (
(0.00, 523.25),
(0.11, 659.25),
(0.22, 783.99),
)
def note_sample(age: float, frequency: float) -> float:
if not 0 <= age <= 0.58:
return 0.0
attack = min(age / 0.008, 1.0)
release = min((0.58 - age) / 0.15, 1.0)
envelope = attack * release * math.exp(-3.8 * age)
tone = math.sin(math.tau * frequency * age) + 0.16 * math.sin(math.tau * frequency * 2 * age)
return envelope * tone
def main() -> None:
output = Path(__file__).parents[4] / "openpilot/selfdrive/assets/sounds/milestone.wav"
samples = array('h')
for frame in range(round(SAMPLE_RATE * DURATION_SECONDS)):
t = frame / SAMPLE_RATE
value = 0.38 * sum(note_sample(t - start, frequency) for start, frequency in NOTES)
samples.append(round(max(-1.0, min(1.0, value)) * 32767))
with wave.open(str(output), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(SAMPLE_RATE)
wav.writeframes(samples.tobytes())
if __name__ == "__main__":
main()
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# PROTOTYPE: launch the demo replay and comma four milestone celebration together.
set -e
prototype_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
prototype_replay_pid=""
cleanup_prototype() {
if [[ -n "$prototype_replay_pid" ]]; then
kill "$prototype_replay_pid" 2>/dev/null || true
wait "$prototype_replay_pid" 2>/dev/null || true
fi
}
trap cleanup_prototype EXIT INT TERM
export PATH="$prototype_root/.venv/bin:$PATH"
export SP_MILESTONE_PROTOTYPE=1
"$prototype_root/openpilot/tools/replay/replay" --demo &
prototype_replay_pid=$!
"$prototype_root/.venv/bin/python" "$prototype_root/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py"
@@ -0,0 +1,55 @@
import unittest
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_tracker_prototype import (
AssistCategory,
PerDriveMilestoneTracker,
assist_category,
)
class TestPerDriveMilestoneTracker(unittest.TestCase):
def test_classifies_actual_actuation(self):
self.assertIsNone(assist_category(False, False))
self.assertIsNone(assist_category(False, True))
self.assertEqual(assist_category(True, False), AssistCategory.MADS)
self.assertEqual(assist_category(True, True), AssistCategory.FULL_ASSIST)
def test_tracks_categories_and_emits_repeated_milestones(self):
tracker = PerDriveMilestoneTracker(milestone_meters=10.0)
self.assertEqual(tracker.update(0, 10.0, AssistCategory.MADS), [])
self.assertEqual(tracker.update(500_000_000, 10.0, AssistCategory.MADS), [])
milestones = tracker.update(1_000_000_000, 10.0, AssistCategory.FULL_ASSIST)
self.assertEqual([(m.category, m.distance_meters) for m in milestones], [(AssistCategory.MADS, 10.0)])
self.assertEqual(tracker.update(1_500_000_000, 10.0, AssistCategory.FULL_ASSIST), [])
milestones = tracker.update(2_000_000_000, 10.0, AssistCategory.FULL_ASSIST)
self.assertEqual([(m.category, m.distance_meters) for m in milestones], [(AssistCategory.FULL_ASSIST, 10.0)])
self.assertEqual(tracker.update(2_500_000_000, 10.0, AssistCategory.FULL_ASSIST), [])
milestones = tracker.update(3_000_000_000, 10.0, AssistCategory.FULL_ASSIST)
self.assertEqual([(m.category, m.distance_meters) for m in milestones], [(AssistCategory.FULL_ASSIST, 20.0)])
def test_does_not_count_unassisted_time_or_timestamp_gaps(self):
tracker = PerDriveMilestoneTracker(milestone_meters=10.0)
tracker.update(0, 20.0, None)
tracker.update(500_000_000, 20.0, AssistCategory.MADS)
self.assertEqual(tracker.distance_meters(AssistCategory.MADS), 0.0)
tracker.update(2_000_000_000, 20.0, AssistCategory.MADS)
self.assertEqual(tracker.distance_meters(AssistCategory.MADS), 0.0)
def test_reset_clears_per_drive_distance_and_sampling_state(self):
tracker = PerDriveMilestoneTracker(milestone_meters=10.0)
tracker.update(0, 10.0, AssistCategory.MADS)
tracker.update(500_000_000, 10.0, AssistCategory.MADS)
tracker.reset()
self.assertEqual(tracker.distance_meters(AssistCategory.MADS), 0.0)
self.assertEqual(tracker.update(1_000_000_000, 10.0, AssistCategory.MADS), [])
if __name__ == "__main__":
unittest.main()
+28 -1
View File
@@ -1,15 +1,42 @@
import threading
import time
from types import SimpleNamespace
from openpilot.common.test import OpenpilotTestCase
from openpilot.cereal import log, messaging
from openpilot.cereal.messaging import SubMaster, PubMaster
from openpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, check_selfdrive_timeout_alert
from openpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, Soundd, check_selfdrive_timeout_alert
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_tracker_prototype import PerDriveMilestoneTracker
AudibleAlert = log.SelfdriveState.AudibleAlert
class TestSoundd(OpenpilotTestCase):
def test_milestone_chime_uses_real_assisted_distance(self):
soundd = Soundd()
soundd.milestone_tracker = PerDriveMilestoneTracker(milestone_meters=10.0)
class SubMasterStub:
def __init__(self):
self.logMonoTime = {'carState': 0}
self.data = {
'deviceState': SimpleNamespace(started=True),
'carState': SimpleNamespace(vEgo=10.0),
'carControl': SimpleNamespace(latActive=True, longActive=True),
}
def __getitem__(self, service):
return self.data[service]
sm = SubMasterStub()
soundd.update_milestone_alert(sm)
sm.logMonoTime['carState'] = 500_000_000
soundd.update_milestone_alert(sm)
sm.logMonoTime['carState'] = 1_000_000_000
soundd.update_milestone_alert(sm)
assert soundd.current_alert == AudibleAlert.complete
def test_check_selfdrive_timeout_alert(self, mocker):
sm = SubMaster(['selfdriveState', 'selfdriveStateSP'])
pm = PubMaster(['selfdriveState', 'selfdriveStateSP'])