Stabilize RoadScore identity and contextual cue presentation

This commit is contained in:
firestar5683
2026-09-19 12:16:17 -07:00
parent a6095e614a
commit 7b6863589b
4 changed files with 95 additions and 35 deletions
+3 -1
View File
@@ -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.
+23 -15
View File
@@ -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<alert_clear_after or native_nav_visible)})+'\n')
if now<alert_clear_after or native_nav_visible:return
view=draw_panel(rl,gui_app.font(FontWeight.NORMAL),state,gui_app.width,gui_app.height,gui_app.font(FontWeight.SEMI_BOLD))
if now<alert_clear_after:
if alert_seen_at is None:alert_seen_at=now
if os.environ.get('ROADSCORE_CAPTURE_EVENTS')=='1' and not alert_captured and now-alert_seen_at>.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):
+48 -18
View File
@@ -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
+21 -1
View File
@@ -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'])