From 7b6863589be1bde75588cab52dd05dac357f3243 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:16:17 -0700 Subject: [PATCH] Stabilize RoadScore identity and contextual cue presentation --- roadscore/DEMO.md | 4 +- roadscore/prototype/overlay.py | 38 +++++++++------- roadscore/prototype/overlay_view.py | 66 ++++++++++++++++++++-------- roadscore/tests/test_overlay_view.py | 22 +++++++++- 4 files changed, 95 insertions(+), 35 deletions(-) diff --git a/roadscore/DEMO.md b/roadscore/DEMO.md index 8aba8a372b..fbaff073b2 100644 --- a/roadscore/DEMO.md +++ b/roadscore/DEMO.md @@ -6,7 +6,7 @@ The conductor receives only road messages already delivered by replay. It reques ## Reading the overlay -RoadScore uses a native-style icon and label group in the free area below driver monitoring and current speed, left of the speed-limit sign. A 40-pixel musical mark accompanies a 20-pixel action title and 14-pixel supporting identity. Local text shadows preserve contrast without a panel covering the camera. Normal buffer/backend detail is omitted; degraded state remains explicit. The group yields to actual native navigation cards and alerts. +RoadScore uses a native-style icon and label group in the free area below driver monitoring and current speed, left of the speed-limit sign. A 50-pixel musical mark matches the native steering wheel and accompanies a fixed 20-pixel RoadScore title and 14-pixel supporting identity. Local text shadows preserve contrast without a panel covering the camera. Normal buffer/backend detail is omitted; degraded state remains explicit. The group yields to actual native navigation cards and alerts. | Display | Meaning | | --- | --- | @@ -47,3 +47,5 @@ The active native overlay is `prototype/overlay.py`; the pure presentation helpe The contextual line names reported musical cues: Turn signal / percussion, Curve ahead / build, Curve apex / impact, Navigation turn / accent, and arrival/stop/resume cues. Active cues take priority over queued cues; queued cues are explicitly prefixed Next. A brief curve apex takes priority over ongoing signal percussion. Unknown kinds are labeled Music cue without guessing a road cause. The archived player supplies these fields from the original scheduler timing. The score ribbon yields completely to native selfdrive/StarPilot alerts and their fade-out. Optional `ROADSCORE_CAPTURE_EVENTS=1` captures the first displayed active cue of each kind into the ignored replay output for UI review; it does not alter status or music. + +The supporting action line stays at a fixed anchor. Brief completed cues may remain for up to 2.5 seconds labeled Recent; queued cues must remain present for 0.4 seconds before display. New active cues and degraded state update immediately. This changes presentation only; raw scheduler status and capture timing remain unchanged. diff --git a/roadscore/prototype/overlay.py b/roadscore/prototype/overlay.py index fa3e5fa941..eb03136f48 100644 --- a/roadscore/prototype/overlay.py +++ b/roadscore/prototype/overlay.py @@ -1,13 +1,13 @@ """Optional external overlay on the existing normal UI; no public source modifications.""" import json,time,os -from overlay_view import draw_panel +from overlay_view import draw_panel, overlay_view, EventPresentation from pathlib import Path def install(): if os.environ.get('ROADSCORE_OVERLAY')!='1':return import pyray as rl from openpilot.system.ui.lib.application import gui_app,FontWeight - original=gui_app.render;last=0.;state={};frames=0;captured=False;capture_ready_since=None;captured_events=set();alert_clear_after=0.;native_nav_visible=False + original=gui_app.render;last=0.;state={};frames=0;captured=False;capture_ready_since=None;captured_events=set();alert_clear_after=0.;native_nav_visible=False;event_presentation=EventPresentation();alert_seen_at=None;alert_captured=False # Observe the actual native nav card; it keeps priority over this accessory. from openpilot.selfdrive.ui.onroad.starpilot.navigation_card import NavigationCardRenderer original_nav_render=NavigationCardRenderer._render @@ -18,8 +18,18 @@ def install(): return result NavigationCardRenderer._render=nav_render path=Path(os.environ['ROADSCORE_STATUS_FILE']) + def capture_image(target): + from PIL import Image + rl.rl_draw_render_batch_active() + image=rl.load_image_from_texture(gui_app._render_texture.texture) if gui_app._render_texture else rl.load_image_from_screen() + try: + data=bytes(rl.ffi.buffer(image.data,image.width*image.height*4)) + picture=Image.frombytes('RGBA',(image.width,image.height),data) + if gui_app._render_texture:picture=picture.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + picture.save(target) + finally:rl.unload_image(image) def draw(): - nonlocal last,state,frames,captured,capture_ready_since,alert_clear_after + nonlocal last,state,frames,captured,capture_ready_since,alert_clear_after,alert_seen_at,alert_captured now=time.monotonic() if now-last>.2: try:state=json.loads(path.read_text()) @@ -34,8 +44,15 @@ def install(): if frames==1:path.with_name('ui_capture_origin.json').write_text(json.dumps({'wall':time.monotonic(),'frame':0})) if os.environ.get('ROADSCORE_CAPTURE_TIMING')=='1': with path.with_name('ui_frames.jsonl').open('a') as audit:audit.write(json.dumps({'frame':frames-1,'wall':time.monotonic(),'state':state,'overlay_visible':not(now.7 and os.environ.get('ROADSCORE_OVERLAY_CAPTURE'): + capture_image(Path(os.environ['ROADSCORE_OVERLAY_CAPTURE']).with_name('overlay-native-alert.png'));alert_captured=True + return + alert_seen_at=None + if native_nav_visible:return + presentation=event_presentation.update(overlay_view(state),now) + view=draw_panel(rl,gui_app.font(FontWeight.NORMAL),state,gui_app.width,gui_app.height,gui_app.font(FontWeight.SEMI_BOLD),presentation) ready=view['ready'] if frames%60==1:path.with_name('overlay_status.json').write_text(json.dumps({'frames':frames,'native_gpu_icon':False,'presentation':view,'state':state})) from openpilot.selfdrive.ui.ui_state import ui_state @@ -48,16 +65,7 @@ def install(): kind=view.get('event_kind') if os.environ.get('ROADSCORE_CAPTURE_EVENTS')=='1' and view.get('event_state')=='active' and kind and kind not in captured_events: captured_events.add(kind);capture_target=Path(capture_path).with_name('overlay-event-'+kind+'.png') - if capture_target is not None: - from PIL import Image - rl.rl_draw_render_batch_active() - image=rl.load_image_from_texture(gui_app._render_texture.texture) if gui_app._render_texture else rl.load_image_from_screen() - try: - data=bytes(rl.ffi.buffer(image.data,image.width*image.height*4)) - picture=Image.frombytes('RGBA',(image.width,image.height),data) - if gui_app._render_texture:picture=picture.transpose(Image.Transpose.FLIP_TOP_BOTTOM) - picture.save(capture_target) - finally:rl.unload_image(image) + if capture_target is not None:capture_image(capture_target) def render(*args,**kwargs): nonlocal native_nav_visible for should_render in original(*args,**kwargs): diff --git a/roadscore/prototype/overlay_view.py b/roadscore/prototype/overlay_view.py index 21be46a769..ed295210d1 100644 --- a/roadscore/prototype/overlay_view.py +++ b/roadscore/prototype/overlay_view.py @@ -97,6 +97,38 @@ def fit_text(text, max_width, measure): return text.rstrip() + '...' if text else '' +class EventPresentation: + """Stable visual history; ended cues are explicitly marked Recent, never active.""" + def __init__(self): + self.last_active = None + self.last_active_at = -math.inf + self.pending = None + self.pending_since = 0. + + def update(self, view, now): + result = dict(view) + if view['activity'] == 'DEGRADED': + self.last_active = None + self.pending = None + return result + if view['event_state'] == 'active': + self.last_active = view['event'].split(' / ')[0] + self.last_active_at = now + self.pending = None + return result + if self.last_active and now - self.last_active_at < 2.5: + result.update(event='Recent: ' + self.last_active, event_state='recent', event_kind=None) + return result + if view['event_state'] == 'queued': + if view['event'] != self.pending: + self.pending, self.pending_since = view['event'], now + if now - self.pending_since < .4: + result.update(event='', event_state='', event_kind=None) + else: + self.pending = None + return result + + def hud_bounds(screen_width, screen_height): """Accessory slot below DM/speed, left of the native speed-limit sign.""" width = min(286, screen_width - 64 - 144 - 16) @@ -105,8 +137,8 @@ def hud_bounds(screen_width, screen_height): return (16, 88, width, 60) -def draw_panel(rl, font, state, screen_width, screen_height, emphasis_font=None): - view = overlay_view(state) +def draw_panel(rl, font, state, screen_width, screen_height, emphasis_font=None, presentation=None): + view = presentation or overlay_view(state) bounds = hud_bounds(screen_width, screen_height) if bounds is None: return view @@ -118,32 +150,30 @@ def draw_panel(rl, font, state, screen_width, screen_height, emphasis_font=None) section = view['section'].removeprefix('INTENT: ').split(' > ')[0].title() if section in ('Archived Score', 'Waiting For Score'): section = '' - identity = 'Stored score' if view['stored'] else view['profile'] + identity = 'Stored' if view['stored'] else view['profile'] subtitle = identity + (' / ' + section if section else '') title = 'RoadScore' if view['activity'] == 'DEGRADED': - title = 'Music on hold' if state.get('holding_accepted_music') else ('Composer offline' if state.get('worker_failed') else 'Reserve in use') - subtitle = 'DEGRADED / ' + identity + subtitle = 'DEGRADED / ' + ('Music on hold' if state.get('holding_accepted_music') else 'Composer unavailable') elif view['event']: - title, _, effect = view['event'].partition(' / ') - subtitle = identity + (' / ' + effect.capitalize() if effect else '') + subtitle = identity + ' / ' + view['event'].split(' / ')[0] elif view['activity'] == 'GENERATING': - title = 'Composing' + subtitle = identity + ' / Composing' elif view['activity'] == 'PREPARING': - title = 'Preparing music' - # Match native icon/label groups: no enclosing dashboard panel. - rl.draw_circle(int(x + 20), int(y + 28), 21, rl.Color(0, 0, 0, 150)) + subtitle = identity + ' / Preparing' + # Match the native steering wheel's 50px identity, with fixed text anchors. + rl.draw_circle(int(x + 25), int(y + 29), 25, rl.Color(0, 0, 0, 150)) icon_color = accent if view['activity'] == 'DEGRADED' else rl.WHITE - rl.draw_circle(int(x + 12), int(y + 36), 4, icon_color) - rl.draw_circle(int(x + 27), int(y + 32), 4, icon_color) - for left, top, w, h in ((14, 16, 2.5, 20), (29, 12, 2.5, 20), (14, 12, 17.5, 3.5)): + rl.draw_circle(int(x + 14), int(y + 38), 5, icon_color) + rl.draw_circle(int(x + 33), int(y + 34), 5, icon_color) + for left, top, w, h in ((17, 14, 3, 24), (36, 10, 3, 24), (17, 10, 22, 4)): rl.draw_rectangle_rounded(rl.Rectangle(x + left, y + top, w, h), .2, 4, icon_color) def text(label, top, size, face, tint, available): label = fit_text(label, available, lambda value: rl.measure_text_ex(face, value, size, 0).x) # Native HUD text uses local shadows; avoid an opaque rectangle over the road. for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1), (1, 2)): - rl.draw_text_ex(face, label, rl.Vector2(x + 52 + dx, y + top + dy), size, 0, rl.Color(0, 0, 0, 210)) - rl.draw_text_ex(face, label, rl.Vector2(x + 52, y + top), size, 0, tint) - text(title, 5, 20, title_font, accent if view['activity'] == 'DEGRADED' else rl.WHITE, width - 52) - text(subtitle, 33, 14, font, muted, width - 52) + rl.draw_text_ex(face, label, rl.Vector2(x + 64 + dx, y + top + dy), size, 0, rl.Color(0, 0, 0, 210)) + rl.draw_text_ex(face, label, rl.Vector2(x + 64, y + top), size, 0, tint) + text(title, 5, 20, title_font, accent if view['activity'] == 'DEGRADED' else rl.WHITE, width - 64) + text(subtitle, 33, 14, font, muted, width - 64) return view diff --git a/roadscore/tests/test_overlay_view.py b/roadscore/tests/test_overlay_view.py index 309e79709a..66820a9bff 100644 --- a/roadscore/tests/test_overlay_view.py +++ b/roadscore/tests/test_overlay_view.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'prototype')) -from overlay_view import display_text, fit_text, overlay_view, hud_bounds +from overlay_view import display_text, fit_text, overlay_view, hud_bounds, EventPresentation class OverlayTests(unittest.TestCase): @@ -57,6 +57,26 @@ class OverlayTests(unittest.TestCase): self.assertLessEqual(y + height, 240) self.assertIsNone(hud_bounds(320, 240)) + def test_recent_linger_is_explicit_and_new_active_cue_is_immediate(self): + presenter = EventPresentation() + active = overlay_view(dict(gesture_active=['turn_signal'], readiness='READY')) + self.assertEqual(presenter.update(active, 0)['event_state'], 'active') + recent = presenter.update(overlay_view({'readiness': 'READY'}), .2) + self.assertEqual(recent['event'], 'Recent: Turn signal') + self.assertEqual(recent['event_state'], 'recent') + apex = presenter.update(overlay_view(dict(gesture_active=['curve_apex'], readiness='READY')), .3) + self.assertEqual(apex['event'], 'Curve apex / impact') + self.assertEqual(presenter.update(overlay_view({'readiness': 'READY'}), 3)['event'], '') + + def test_queue_debounce_and_degraded_never_wait(self): + presenter = EventPresentation() + queued = overlay_view(dict(gesture_queued=[{'kind': 'curve_apex'}], readiness='READY')) + self.assertEqual(presenter.update(queued, 0)['event'], '') + self.assertEqual(presenter.update(queued, .5)['event'], 'Next: Curve apex') + degraded = presenter.update(overlay_view(dict(readiness='DEGRADED', holding_accepted_music=True)), .6) + self.assertEqual(degraded['activity'], 'DEGRADED') + self.assertEqual(degraded['note'], 'Holding accepted music') + def test_missing_buffer_is_not_zero(self): for value in (None, float('nan'), float('inf'), '12', True): self.assertIsNone(overlay_view({'buffered': value})['buffered'])