mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-04 13:24:13 +08:00
Stabilize RoadScore identity and contextual cue presentation
This commit is contained in:
+3
-1
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user