ui: unify speed-limit signs for clearer confirmation cues

This commit is contained in:
firestarsdog
2026-10-22 02:49:00 -04:00
parent aab66cd015
commit d2dbeddcf1
23 changed files with 636 additions and 185 deletions
+6 -2
View File
@@ -16,8 +16,12 @@ a source gap or same-value provider change; a later different limit starts a new
window, including when an earlier numeric limit returns.
With Automatic adoption on, the widget slowly flashes for three seconds before
adoption. With it off, the widget gently hops and shows an arrow relative to vehicle
speed. Steering-wheel + accepts and − declines. Tapping the sign opens the same
adoption. With it off, the stationary widget pulses purple twice slowly (700 ms
each), rests for one second, pulses twice quickly (300 ms each), and rests for one
second before repeating. Each pair has a 200 ms gap. The arrow remains relative
to vehicle speed; Vienna numerals stay black while their ring pulses.
Large UI uses the same US or Vienna sign at 1.25× scale beside SET speed.
Steering-wheel + accepts and − declines. Tapping the sign opens the same
Accept / Decline choices on either native screen. A pending manual choice expires
after ten monotonic seconds, including time without active longitudinal control.
Decline and timeout leave set speed unchanged. An action at the exact deadline
@@ -11,7 +11,7 @@ from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK
SPEED_ALIGNMENTS, customization_metadata, validate_map_box, validate_widget_order)
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
from openpilot.starpilot.ui.onroad_customization import SPEED_LAYOUT_V7, migrate_speed_layout
from openpilot.starpilot.ui.onroad_customization import SPEED_LAYOUT_V7, migrate_speed_layout, migrate_large_sign_layout
MAX_BYTES = 16384
DOCUMENT_KEY = 'projection'
@@ -178,7 +178,7 @@ def default_layout(screen, display=None):
def default_layout_for_viewport(viewport, display=None):
metadata = layout_metadata_for_viewport(viewport)
display = {**DISPLAY_DEFAULTS, **{key: value for key, value in (display or {}).items() if key in DISPLAY_DEFAULTS}}
return {'version': 4, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')},
return {'version': 5, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')},
'widgets': {key: {**widget['default'], **({'size': 192} if key == 'steering_wheel' else {})}
for key, widget in metadata['widgets'].items()}}
@@ -211,7 +211,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if type(value) is dict:
fields |= {field for field in ('widgetOrder', 'clock24Hour', 'largeUiGammaTrial', *DISPLAY_DEFAULTS) if field in value}
if (type(value) is not dict or set(value) != fields or
type(value['version']) is not int or value['version'] not in (1, 2, 3, 4) or
type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5) or
value['canvas'] != {key: metadata[key] for key in ('width', 'height')} or
type(value['widgets']) is not dict or not set(value['widgets']) <= set(metadata['widgets']) or
not set(metadata['widgets']) - set(value['widgets']) <= {CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, *PROJECTION_WIDGETS}):
@@ -291,6 +291,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
dimensions = placement_size(key, widget, placement)
if value['version'] < 4 and key in SPEED_LAYOUT_V7['large']:
dimensions = SPEED_LAYOUT_V7['large'][key][:2]
elif value['version'] < 5 and key == 'cruise_limits':
dimensions = (widget['width'], 132)
# Version 2 and older saved the CEM gauge's full drawing frame; check that, then move to the inset box.
old_gauge = key == CEM_GAUGE_WIDGET and value['version'] < 3 and key in value['widgets']
if old_gauge:
@@ -314,7 +316,9 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
card, actions = result['widgets']['cruise_limits'], result['widgets']['speed_limit_actions']
gap = actions['y'] - card['y']
migrate_speed_layout(result['widgets'], 'large', bounds, gap if gap in (232, 492) else 232, widgets=metadata['widgets'])
result['version'] = 4
if value['version'] < 5:
migrate_large_sign_layout(result['widgets'], bounds, widgets=metadata['widgets'])
result['version'] = 5
if len(json.dumps(result, separators=(',', ':'), allow_nan=False).encode()) > MAX_BYTES:
raise ValueError('Projection layout too large')
return result
@@ -18,6 +18,29 @@ SCREEN = {'version': 1, 'width': 1280, 'height': 720, 'margin_width': 0,
class TestProjectionLayout(unittest.TestCase):
def test_v4_large_sign_migration_clamps_old_bottom_placement_without_losing_custom_actions(self):
document = default_layout(SCREEN)
document['version'] = 4
bounds = layout_metadata(SCREEN)['bounds']
bottom = bounds['y'] + bounds['height']
document['widgets']['cruise_limits']['y'] = bottom - 132
document['widgets']['speed_limit_actions'].update(x=250, y=400)
before = copy.deepcopy(document)
migrated = validate_layout(document, SCREEN)
self.assertEqual(document, before)
self.assertEqual(migrated['version'], 5)
self.assertEqual(migrated['widgets']['cruise_limits']['y'], bottom - 168)
self.assertEqual(migrated['widgets']['speed_limit_actions'], document['widgets']['speed_limit_actions'])
self.assertEqual(validate_layout(migrated, SCREEN), migrated)
def test_v4_moves_actions_only_when_they_follow_the_old_default_sign_position(self):
document = default_layout(SCREEN)
document['version'] = 4
card = document['widgets']['cruise_limits']
document['widgets']['speed_limit_actions'].update(x=card['x']+12, y=card['y']+173)
migrated = validate_layout(document, SCREEN)
self.assertEqual(migrated['widgets']['speed_limit_actions']['y'], card['y']+221)
def test_jetlink_widget_migrates_and_preserves_placement_and_order(self):
document = default_layout(SCREEN)
expected = document['widgets'].pop('jetlink_status')
+8
View File
@@ -45,3 +45,11 @@ pinned in `sora-brand-font.json` and retains Sora's embedded copyright and SIL
Open Font License notice. Home can now use the pinned source-owned Sora, Inter,
and Unifont fonts without an external directory. An explicit font-directory
override remains available and must pass the same byte validation.
The original SLC circle/ring masks and joined arrows are generated by
`python -m openpilot.starpilot.ui.assets.generate_speed_limit_shapes`.
The 8× coverage sampling happens offline; runtime shares one small atlas and
draws tinted quads without per-frame shape tessellation or new shaders.
Both onroad profiles use these US and Vienna signs. Large UI scales their geometry
by 1.25 and normalizes font sizes to preserve the compact sign's proportions,
including its fitted offsets, joined arrows and confirmation pulses.
@@ -0,0 +1,52 @@
"""Rasterize fixed SLC geometry once; runtime only tints and draws atlas quads."""
import numpy as np
from PIL import Image
from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, ATLAS_SIZE, SPRITES
SAMPLES = 8
def coverage(mask, width, height):
return mask.reshape(height, SAMPLES, width, SAMPLES).mean(axis=(1, 3))
def generate():
atlas = Image.new('RGBA', ATLAS_SIZE)
for name, (x, y, width, height) in SPRITES.items():
px = (np.arange(width * SAMPLES) + .5) / SAMPLES - width / 2
py = (np.arange(height * SAMPLES) + .5) / SAMPLES - height / 2
xx, yy = np.meshgrid(px, py)
rgba = np.full((height, width, 4), 255, dtype=np.uint8)
if name in ('disc', 'ring'):
radius = np.hypot(xx, yy)
# End the white backing inside the opaque rim, avoiding an outer white fringe.
mask = radius <= 55 if name == 'disc' else (radius <= 59) & (radius >= 51)
rgba[..., 3] = np.rint(coverage(mask, width, height) * 255).astype(np.uint8)
else:
half_height, wing, stem, head, outline = (14, 9, 3, 9, 1.4) if name == 'arrow_large' else (7, 5, 2.5, 5, 1)
base = half_height - head
points = ((-stem/2, -half_height), (stem/2, -half_height), (stem/2, base), (wing, base),
(0, half_height), (-wing, base), (-stem/2, base))
inside = ((np.abs(xx) <= stem/2) & (yy >= -half_height) & (yy <= base)) | (
(yy >= base) & (yy <= half_height) & (np.abs(xx) <= wing * (half_height - yy) / head))
distance_sq = np.full_like(xx, np.inf)
for (ax, ay), (bx, by) in zip(points, (*points[1:], points[0]), strict=True):
dx, dy = bx - ax, by - ay
t = np.clip(((xx - ax) * dx + (yy - ay) * dy) / (dx*dx + dy*dy), 0, 1)
distance_sq = np.minimum(distance_sq, (xx - ax - t*dx)**2 + (yy - ay - t*dy)**2)
fill = coverage(inside, width, height)
border = coverage((distance_sq <= outline**2) & ~inside, width, height) * .87
alpha = fill + border
for channel, dark in enumerate((6, 14, 10)):
rgba[..., channel] = np.rint(np.divide(fill * 255 + border * dark, alpha,
out=np.full_like(alpha, dark), where=alpha > 0)).astype(np.uint8)
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
atlas.paste(Image.fromarray(rgba), (x, y))
return atlas
if __name__ == '__main__':
generate().save(ATLAS_PATH, optimize=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -2,22 +2,23 @@
WIDTH = 488
MAX_ROW_HEIGHT = 132
LIMIT_ROW_HEIGHT = MAX_ROW_HEIGHT
CARD_HEIGHT = MAX_ROW_HEIGHT
LARGE_SIGN_SCALE = 1.25
LIMIT_ROW_HEIGHT = 168
CARD_HEIGHT = LIMIT_ROW_HEIGHT
ACTION_WIDTH = 576
ACTION_HEIGHT = 160
ACTION_HEIGHT = 148
ACTION_X = 100
ACTION_Y = 248
ACTION_Y = 296
GAP = 8
SOURCE_ROW_HEIGHT = 76
PADDING = 24
LIMIT_RECTS = {'large': (272, 94, 292, 124), 'compact': (330, 20, 118, 132)}
LIMIT_RECTS = {'large': (272, 75, 148, 165), 'compact': (330, 20, 118, 132)}
ACTION_BOUNDS = {
'large': ((100, 248, 372, 408), (404, 248, 676, 408)),
'large': ((100, ACTION_Y, 372, ACTION_Y + ACTION_HEIGHT), (404, ACTION_Y, 676, ACTION_Y + ACTION_HEIGHT)),
'compact': ((128, 182, 296, 240), (308, 182, 476, 240)),
}
ACTION_TEXT = {'large': (64, 320), 'compact': (48, 213)}
ACTION_TEXT = {'large': (64, ACTION_Y + 72), 'compact': (48, 213)}
def card_height(mode):
+1
View File
@@ -546,6 +546,7 @@ class OnroadView:
self._corner_cache.close()
self.navigation.close()
if self.fonts.profile == Profile.LARGE:
self.unified_speed.close()
self.steering_wheel.close()
else:
self.compact_hud.close()
@@ -170,6 +170,7 @@ class CompactHudRenderer:
rl.Color(*color, int(wheel_alpha)))
def close(self) -> None:
self._speed_limit_widget.close()
if self._wheel is not None and rl.is_window_ready():
rl.unload_texture(self._wheel)
self._wheel = None
+17 -2
View File
@@ -303,7 +303,7 @@ def customization_metadata():
def default_document():
return {"version": 9, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
return {"version": 10, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
"widgetColors": {profile: {} for profile in PROFILES}, "layouts": {
profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})}
for key, widget in data["widgets"].items()}
@@ -364,8 +364,19 @@ def _compact_action_collisions(layout):
yield key, height
def migrate_large_sign_layout(layout, bounds, *, widgets=None, extra_height=0):
widgets = PROFILES['large']['widgets'] if widgets is None else widgets
card, actions = layout['cruise_limits'], layout['speed_limit_actions']
# Only the old attached arrangement follows the sign; independently placed actions stay put.
paired = actions['x'] == card['x'] + 12 and actions['y'] == card['y'] + 173
card['y'] = min(card['y'], bounds['y'] + bounds['height'] - widgets['cruise_limits']['height'] - extra_height)
if paired:
actions['y'] = min(card['y'] + ACTION_Y - 75,
bounds['y'] + bounds['height'] - widgets['speed_limit_actions']['height'])
def validate_document(value):
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 10):
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 11):
raise ValueError("Invalid customization version")
fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set())
if value["version"] >= 3:
@@ -538,6 +549,8 @@ def validate_document(value):
dimensions = SPEED_LAYOUT_V7[profile].get(key)
if value['version'] == 8 and profile == 'compact':
dimensions = COMPACT_SPEED_LAYOUT_V8.get(key)
if dimensions is None and value['version'] < 10 and profile == 'large' and key == 'cruise_limits':
dimensions = (WIDTH, 132)
dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else
size if key in WIDGET_SIZES else placement[extent] if widget.get("box") else widget[extent])
if old_gauge:
@@ -568,6 +581,8 @@ def validate_document(value):
result['clock24Hour'] = value['clock24Hour']
if 'showMaxSpeed' in value:
result['showMaxSpeed'] = value['showMaxSpeed']
if value['version'] < 10:
migrate_large_sign_layout(result['layouts']['large'], PROFILES['large']['bounds'], extra_height=sources_extent(result))
if value['version'] < 8:
if value['version'] < 5 and value['palette'] != PALETTE:
colors = result['widgetColors']['large'].setdefault('cruise_limits', {})
@@ -11,7 +11,7 @@ import json
import pyray as rl
from openpilot.starpilot.ui.onroad_customization import offset, rgba, widget_size
from openpilot.starpilot.ui.onroad_state import OnroadState
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_bounds
from openpilot.starpilot.ui.onroad_widget_style import draw_control_card
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade
@@ -36,6 +36,9 @@ class UnifiedSpeedWidget:
self.fonts = fonts
self.sign = SpeedLimitWidget(fonts, Profile.LARGE)
def close(self):
self.sign.close()
def _draw_sources(self, rect, state):
"""Each enabled source as a small label over its reading; the selected source is highlighted."""
from openpilot.starpilot.ui.large_speed_geometry import PADDING
@@ -82,6 +85,8 @@ class UnifiedSpeedWidget:
size -= 1
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, x, rect.y + 51, text)
self.sign.render(state)
if shown.mode == 'limit_only' and (bounds := speed_limit_bounds(Profile.LARGE, state)) is not None:
draw_ink(self.fonts, shown.unit, FontRole.NORMAL, 24, bounds[2], bounds[3] - 32)
if sources:
row = rl.Rectangle(rect.x, rect.y + height + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT)
if separate:
+33 -14
View File
@@ -4,14 +4,20 @@ import math
import pyray as rl
from openpilot.starpilot.ui.onroad_state import OnroadState
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState
from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps
CONFIRMATION_PURPLE = (170, 115, 235)
CONFIRMATION_PULSES = ((0.0, .7), (.9, .7), (2.6, .3), (3.1, .3))
CONFIRMATION_CYCLE_SECONDS = 4.4
class SpeedLimitPulse:
def __init__(self):
self.last_limit = 0.0
self.start_time = -math.inf
self.manual_pending = False
self._started_frame: int | None = None
def update(self, state: OnroadState, now: float, *, visible: bool = True) -> None:
@@ -20,26 +26,39 @@ class SpeedLimitPulse:
self.start_time = -math.inf
self._started_frame = state.drive_frame
observation = state.speed_limit
if not visible:
self.start_time = -math.inf
return
limit = displayed_limit_mps(observation)
if limit is None:
return
if observation.automatic_adoption_pending and observation.pending_since_ns > 0:
self.start_time = observation.pending_since_ns / 1e9
elif observation.automatic_adoption_pending and limit != self.last_limit:
self.start_time = now
elif not observation.automatic_adoption_pending:
pending = (observation.kind == ObservationKind.VALID and observation.pending_speed_limit_mps is not None and
observation.pending_speed_limit_mps > 0)
if not visible or limit is None or not pending:
self.start_time = -math.inf
self.manual_pending = False
return
manual = not observation.automatic_adoption_pending
if observation.pending_since_ns > 0:
self.start_time = observation.pending_since_ns / 1e9
elif limit != self.last_limit or manual != self.manual_pending or not math.isfinite(self.start_time):
self.start_time = now
self.manual_pending = manual
self.last_limit = limit
def color(self, base: rl.Color, now: float) -> rl.Color:
if isinstance(base, (tuple, list)):
base = rl.Color(*base)
elapsed = now - self.start_time
if not 0.0 <= elapsed < 3.0:
if not math.isfinite(elapsed) or elapsed < 0:
return base
fraction = (1 - math.cos(2 * math.pi * elapsed)) / 2
if self.manual_pending:
phase = elapsed % CONFIRMATION_CYCLE_SECONDS
fraction = 0.0
for start, duration in CONFIRMATION_PULSES:
if start <= phase < start + duration:
fraction = math.sin(math.pi * (phase - start) / duration) ** 2
break
target = CONFIRMATION_PURPLE
else:
if elapsed >= 3.0:
return base
fraction = (1 - math.cos(2 * math.pi * elapsed)) / 2
target = (188, 132, 255)
return rl.Color(*(round(value + (target - value) * fraction)
for value, target in zip((base.r, base.g, base.b), (188, 132, 255), strict=True)), base.a)
for value, target in zip((base.r, base.g, base.b), target, strict=True)), base.a)
@@ -0,0 +1,61 @@
"""One cached atlas for joined arrows and antialiased speed-limit sign edges."""
from pathlib import Path
import pyray as rl
ATLAS_PATH = Path(__file__).with_name('assets') / 'speed_limit_shapes.png'
ATLAS_SIZE = (256, 176)
SPRITES = {
'disc': (0, 0, 128, 128), 'ring': (128, 0, 128, 128),
'arrow_large': (0, 128, 32, 40), 'arrow_compact': (32, 128, 16, 24),
}
class SpeedLimitShapes:
def __init__(self, scale=1):
self.scale = scale
self.texture = None
self.sources = {name: rl.Rectangle(*rect) for name, rect in SPRITES.items()}
self.origins = {name: rl.Vector2(rect[2] * scale / 2, rect[3] * scale / 2) for name, rect in SPRITES.items()}
def _load(self):
if self.texture is not None:
return
if not rl.is_window_ready():
raise RuntimeError('Speed-limit shapes require a graphics context')
image = rl.load_image(str(ATLAS_PATH))
try:
if image.data == rl.ffi.NULL or (image.width, image.height) != ATLAS_SIZE:
raise ValueError('Invalid speed-limit shape atlas')
self.texture = rl.load_texture_from_image(image)
if not self.texture.id:
self.texture = None
raise RuntimeError('Unable to upload speed-limit shapes')
rl.set_texture_filter(self.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_texture_wrap(self.texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP)
except BaseException:
self.close()
raise
finally:
if image.data != rl.ffi.NULL:
rl.unload_image(image)
def _draw(self, name, x, y, color, rotation=0):
self._load()
source = self.sources[name]
rl.draw_texture_pro(self.texture, source, rl.Rectangle(x, y, source.width * self.scale, source.height * self.scale),
self.origins[name], rotation, color)
def circle(self, x, y, color):
self._draw('disc', x, y, rl.WHITE)
self._draw('ring', x, y, color)
def arrow(self, size, x, y, direction, color):
self._draw('arrow_large' if size == 'large' else 'arrow_compact', x, y, color, 180 if direction < 0 else 0)
def close(self):
if self.texture is not None and rl.is_window_ready():
rl.unload_texture(self.texture)
self.texture = None
+54 -73
View File
@@ -1,14 +1,14 @@
"""The latest limit, its attention animation and override mark on both screens."""
import math
import time
import pyray as rl
from openpilot.starpilot.ui.onroad_customization import placement
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_bounds
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
from openpilot.starpilot.ui.speed_limit_pulse import SpeedLimitPulse
from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes
from openpilot.starpilot.ui.unified_speed_presentation import displayed_adjusted_limit_mps, resolve_unified_speed
@@ -59,7 +59,13 @@ class SpeedLimitWidget:
def __init__(self, fonts: BitmapFonts, profile: Profile):
self.fonts = fonts
self.profile = profile
self.scale = LARGE_SIGN_SCALE if profile == Profile.LARGE else 1
self.text_scale = self.scale * Profile.COMPACT.font_scale / profile.font_scale
self.pulse = SpeedLimitPulse()
self.shapes = SpeedLimitShapes(self.scale)
def close(self):
self.shapes.close()
def render(self, state: OnroadState) -> None:
bounds = speed_limit_bounds(self.profile, state)
@@ -67,92 +73,67 @@ class SpeedLimitWidget:
self.pulse.update(state, now, visible=bounds is not None)
if bounds is None:
return
if self.profile == Profile.COMPACT:
self._compact_sign(state, bounds, now)
return
shown, obs = resolve_unified_speed(state), state.speed_limit
pending = shown.pending and not obs.automatic_adoption_pending
elapsed = max(0., now - obs.pending_since_ns / 1e9)
hop = -6 * math.sin(math.pi * (elapsed % 1.5) / 1.5) ** 2 if pending else 0
large = self.profile == Profile.LARGE
x, top, _, _ = bounds
x, top = x + 12, max(0, top + 12)
number_top = top + (40 if large else 32) + hop
color = self.pulse.color(INK, now)
set_visible = shown.mode != 'limit_only' if large else (state.cruise_active and state.cruise_kph is not None and
placement(state.customization, self.profile, 'max_speed')['enabled'])
heading_width, heading_height = draw_caption(self.fonts, 'LIMIT', x, top, unit=None if set_visible else shown.unit)
width, height = draw_ink(self.fonts, shown.posted_text, FontRole.BOLD, 70 if large else 58, x, number_top, color)
end = x + width
if shown.offset_text:
size = 46 if large else 36
a, b = self.fonts.vertical_ink(shown.offset_text, FontRole.MEDIUM, size)
gap = 12 if large else 10
draw_ink(self.fonts, shown.offset_text, FontRole.MEDIUM, size, end + gap, number_top + height - (b - a), color)
if pending:
target = displayed_adjusted_limit_mps(obs)
if target is not None and state.speed_mps is not None and target != state.speed_mps:
ax, ay = x + heading_width + (20 if large else 14), top + heading_height / 2
direction = -1 if target > state.speed_mps else 1
half_height, wing, stroke = (14, 9, 3) if large else (10, 6, 2)
tip = rl.Vector2(ax, ay + direction * half_height)
draw_line(self.profile, rl.Vector2(ax, ay - direction * half_height), tip, stroke, color)
for dx in (-wing, wing):
draw_line(self.profile, tip, rl.Vector2(ax + dx, tip.y - direction * wing), stroke, color)
if obs.driver_override_active:
color = rl.Color(197, 163, 255, 240)
left, top, right, bottom = x - 4, number_top - 3, x + width + 4, number_top + height + 3
draw_line(self.profile, rl.Vector2(left, top), rl.Vector2(right, bottom), 3 if large else 2.5, color)
draw_line(self.profile, rl.Vector2(right, top), rl.Vector2(left, bottom), 3 if large else 2.5, color)
self._sign(state, bounds, now)
def _compact_sign(self, state: OnroadState, bounds, now: float) -> None:
def _sign(self, state: OnroadState, bounds, now: float) -> None:
shown, obs = resolve_unified_speed(state), state.speed_limit
x, top = bounds[0] + 12, bounds[1] + 12
scale = self.scale
pending = shown.pending and not obs.automatic_adoption_pending
elapsed = max(0., now - obs.pending_since_ns / 1e9)
hop = -6 * math.sin(math.pi * (elapsed % 1.5) / 1.5) ** 2 if pending else 0
target = displayed_adjusted_limit_mps(obs) if pending else None
arrow_direction = 0
if target is not None and state.speed_mps is not None and target != state.speed_mps:
arrow_direction = -1 if target > state.speed_mps else 1
white = self.pulse.color(rl.Color(244, 247, 245, 255), now)
secondary = self.pulse.color(rl.Color(223, 232, 227, 255), now)
def centered(text, role, size, ink_top, color):
left, right = self.fonts.horizontal_ink(text, role, size)
a, b = self.fonts.vertical_ink(text, role, size)
origin = x + 59 - (left + right) / 2
self.fonts.draw(text, role, size, origin, ink_top - a, color)
def ink(text, role, size):
return (*self.fonts.horizontal_ink(text, role, size * self.text_scale),
*self.fonts.vertical_ink(text, role, size * self.text_scale))
def centered(text, role, size, ink_top, color, center_x=x + 59 * scale):
left, right, a, b = ink(text, role, size)
origin = center_x - (left + right) / 2
self.fonts.draw(text, role, size * self.text_scale, origin, ink_top - a, color)
return origin + left, ink_top, origin + right, ink_top + b - a
if state.appearance.use_vienna_sign:
cx, cy = x + 59, top + 67 + hop
rl.draw_circle(int(cx), int(cy), 59, rl.WHITE)
rl.draw_ring(rl.Vector2(cx, cy), 51, 59, 0, 360, 64, self.pulse.color(rl.Color(201, 34, 49, 255), now))
color = self.pulse.color(rl.Color(0, 0, 0, 255), now)
size = 58 if len(shown.posted_text) <= 2 else 48
a, b = self.fonts.vertical_ink(shown.posted_text, FontRole.BOLD, size)
number_top = top + 36 + hop if shown.offset_text else cy - (b - a) / 2
cx, cy = x + 59 * scale, top + 67 * scale
self.shapes.circle(cx, cy, self.pulse.color(rl.Color(201, 34, 49, 255), now))
# Keep black-on-white numerals stable; the ring carries manual confirmation pulses.
color = rl.Color(0, 0, 0, 255) if pending else self.pulse.color(rl.Color(0, 0, 0, 255), now)
size = (54 if pending else 58) if len(shown.posted_text) <= 2 else 48
number_center = cy - 7 * scale if shown.offset_text else cy + (7 * scale if pending else 0)
while True:
left, right, a, b = ink(shown.posted_text, FontRole.BOLD, size)
if ((right - left) / 2) ** 2 + (abs(number_center - cy) + (b - a) / 2) ** 2 <= (47 * scale) ** 2 or size <= 32:
break
size -= 2
number_top = number_center - (b - a) / 2
number = centered(shown.posted_text, FontRole.BOLD, size, number_top, color)
if shown.offset_text:
centered(shown.offset_text, FontRole.MEDIUM, 36, top + 81 + hop, color)
offset_top, size = number[3] + 4 * scale, 36
while True:
left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, size)
if ((right - left) / 2) ** 2 + max((offset_top - cy) ** 2, (offset_top + b - a - cy) ** 2) <= (47 * scale) ** 2 or size <= 24:
break
size -= 2
centered(shown.offset_text, FontRole.MEDIUM, size, offset_top, color)
else:
rect = rl.Rectangle(x + 2, top + 2, 114, 128)
rect = rl.Rectangle(x + 2 * scale, top + 2 * scale, 114 * scale, 128 * scale)
rl.draw_rectangle_rounded(rect, .1, 16, rl.Color(10, 17, 19, 230))
rl.draw_rectangle_rounded_lines_ex(rect, .1, 16, 2, white)
for label, y in (('SPEED', top + 11), ('LIMIT', top + 27)):
centered(label, FontRole.SEMI_BOLD, 20, y, white)
number = centered(shown.posted_text, FontRole.BOLD, 58, top + (50 if shown.offset_text else 61) + hop, white)
rl.draw_rectangle_rounded_lines_ex(rect, .1, 16, 2 * scale, white)
for label, y in (('SPEED', top + 11 * scale), ('LIMIT', top + 27 * scale)):
centered(label, FontRole.SEMI_BOLD, 20, y, white, x + (53 if arrow_direction else 59) * scale)
number = centered(shown.posted_text, FontRole.BOLD, 58, top + (50 if shown.offset_text else 61) * scale, white)
if shown.offset_text:
centered(shown.offset_text, FontRole.MEDIUM, 36, top + 98 + hop, secondary)
if pending:
target = displayed_adjusted_limit_mps(obs)
if target is not None and state.speed_mps is not None and target != state.speed_mps:
ax, ay = ((x + 105, top + 49 + hop) if state.appearance.use_vienna_sign else (x + 108, top + 23))
arrow_color = color if state.appearance.use_vienna_sign else white
direction = -1 if target > state.speed_mps else 1
tip = rl.Vector2(ax, ay + direction * 6)
draw_line(self.profile, rl.Vector2(ax, ay - direction * 6), tip, 2, arrow_color)
for dx in (-3, 3):
draw_line(self.profile, tip, rl.Vector2(ax + dx, tip.y - direction * 4), 2, arrow_color)
centered(shown.offset_text, FontRole.MEDIUM, 36, top + 98 * scale, secondary)
if arrow_direction:
ax, ay = ((x + 59 * scale, top + 29 * scale) if state.appearance.use_vienna_sign else (x + 99 * scale, top + 26 * scale))
arrow_color = color if state.appearance.use_vienna_sign else white
self.shapes.arrow('compact' if state.appearance.use_vienna_sign else 'large', ax, ay, arrow_direction, arrow_color)
if obs.driver_override_active:
color = rl.Color(197, 163, 255, 240)
left, top, right, bottom = number
draw_line(self.profile, rl.Vector2(left - 3, top - 2), rl.Vector2(right + 3, bottom + 2), 2.5, color)
draw_line(self.profile, rl.Vector2(right + 3, top - 2), rl.Vector2(left - 3, bottom + 2), 2.5, color)
draw_line(self.profile, rl.Vector2(left - 3 * scale, top - 2 * scale), rl.Vector2(right + 3 * scale, bottom + 2 * scale), 2.5 * scale, color)
draw_line(self.profile, rl.Vector2(right + 3 * scale, top - 2 * scale), rl.Vector2(left - 3 * scale, bottom + 2 * scale), 2.5 * scale, color)
+123 -50
View File
@@ -35,7 +35,7 @@ def test_numeric_subset_is_pinned_and_has_real_tabular_glyphs():
def test_approved_limit_offset_and_action_sizes_fit_actual_fonts():
from openpilot.starpilot.ui.large_speed_geometry import ACTION_TEXT, LIMIT_RECTS
from openpilot.starpilot.ui.large_speed_geometry import ACTION_TEXT, LARGE_SIGN_SCALE, LIMIT_RECTS
owns_context = not rl.is_window_ready()
if owns_context:
rl.init_window(128, 128, "Native speed-group font fixture")
@@ -43,13 +43,15 @@ def test_approved_limit_offset_and_action_sizes_fit_actual_fonts():
for profile in Profile:
with BitmapFonts(profile, default_font_directory()) as fonts:
large = profile == Profile.LARGE
limit_size, offset_size = (70, 46) if large else (58, 36)
scale = LARGE_SIGN_SCALE if large else 1
text_scale = scale * Profile.COMPACT.font_scale / profile.font_scale
limit_size, offset_size = 58 * text_scale, 36 * text_scale
for value, offset in (('65', '+5'), ('120', '+10'), ('130', '-10'), ('145', '-137')):
a, b = fonts.horizontal_ink(value, FontRole.BOLD, limit_size)
value_width = b - a
a, b = fonts.horizontal_ink(offset, FontRole.MEDIUM, offset_size)
offset_width = b - a
width = value_width + offset_width + 40 if large else max(value_width, offset_width) + 20
width = max(value_width, offset_width) + 20 * scale
assert width <= LIMIT_RECTS[profile][2]
action_width = 272 if large else 168
assert all(fonts.measure(label, FontRole.MEDIUM, ACTION_TEXT[profile][0]).width + 4 <= action_width
@@ -65,7 +67,7 @@ def test_approved_limit_offset_and_action_sizes_fit_actual_fonts():
rl.close_window()
def test_set_speed_and_limit_keep_the_approved_origins_and_fixed_limit_size():
def test_large_sign_scales_beside_the_unchanged_set_speed():
from unittest.mock import patch
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
from openpilot.starpilot.ui.layout_preview_renderer import sample_state
@@ -89,8 +91,10 @@ def test_set_speed_and_limit_keep_the_approved_origins_and_fixed_limit_size():
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), current)
calls = [call for call in calls if call[-1].a == 255]
posted = next(call for call in calls if call[0] == '120' and call[1] == FontRole.BOLD)
assert posted[2:4] == (70, 372)
assert posted[4] + fonts.vertical_ink('120', FontRole.BOLD, 70)[0] == 154
assert posted[2] == 58 * 1.25 * Profile.COMPACT.font_scale / Profile.LARGE.font_scale
left, right = fonts.horizontal_ink('120', FontRole.BOLD, posted[2])
assert abs(posted[3] + (left + right) / 2 - 445.75) < 1e-6
assert posted[4] + fonts.vertical_ink('120', FontRole.BOLD, posted[2])[0] == 157.5
set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED]
assert bool(set_calls) == show_set
if set_calls:
@@ -165,7 +169,7 @@ def test_galaxy_card_pixels_align_without_reading_font_textures():
rl.close_window()
def test_native_captions_offsets_and_bounce_fit_their_touch_targets():
def test_native_signs_offsets_and_captions_fit_their_touch_targets():
from unittest.mock import Mock, patch
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
@@ -202,12 +206,10 @@ def test_native_captions_offsets_and_bounce_fit_their_touch_targets():
call[4]+fonts.vertical_ink(call[0], call[1], call[2])[1])
for call in calls if call[-1].a == 255}
if profile == Profile.LARGE:
assert ink['SET'][1] == ink['LIMIT'][1]
assert ink['120'][3] == ink['+10'][3]
assert ink['120'][2] < ink['+10'][0]
assert ink['SET'][2] < ink['SPEED'][0]
else:
assert 'SET' not in ink and 'SPEED' in ink
assert ink['120'][3] < ink['+10'][1]
assert ink['120'][3] < ink['+10'][1]
bounds = speed_limit_bounds(profile, current)
assert bounds is not None
left, top, right, bottom = bounds
@@ -255,47 +257,118 @@ def test_compact_preview_sprite_keeps_the_sign_offset_arrow_and_override_cross()
rl.close_window()
def test_compact_balanced_stack_uses_mock_sizes_and_contains_all_ink():
def test_large_sources_and_confirmation_text_have_clearance_below_both_signs():
from unittest.mock import patch
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, GAP, SOURCE_ROW_HEIGHT
from openpilot.starpilot.ui.onroad import OnroadView
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
from openpilot.starpilot.ui.onroad_state import SourceReading
from openpilot.starpilot.ui.tests.test_unified_speed_native import state
owns_context = not rl.is_window_ready()
if owns_context:
rl.init_window(128, 128, 'C4 contained sign fixture')
rl.init_window(128, 128, 'Large sign sources and confirmation spacing')
try:
with BitmapFonts(Profile.COMPACT, default_font_directory()) as fonts:
widget = SpeedLimitWidget(fonts, Profile.COMPACT)
for vienna, (posted, offset), elapsed, (x, y) in product(
(False, True), ((65, 5), (120, 10), (130, -10), (145, -137), (55, 0)),
(0, 750_000_000, 1_500_000_000), ((330, 20), (100, 0), (358, 108))):
current = state(observed_ns=10_000_000_000+elapsed)
current.customization['layouts']['compact']['speed_limit'].update(x=x, y=y)
with BitmapFonts(Profile.LARGE, default_font_directory()) as fonts:
widget = UnifiedSpeedWidget(fonts)
view = OnroadView.__new__(OnroadView)
view.fonts = fonts
for vienna, separate in product((False, True), repeat=2):
current = state(slc_prompt_open=True)
current.customization.update(speedSources=True, speedSourcesCard=separate)
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna),
speed_limit=replace(current.speed_limit, speed_limit_mps=posted/3.6,
pending_speed_limit_mps=posted/3.6, presentation_adjusted_limit_mps=(posted+offset)/3.6))
speed_limit=replace(current.speed_limit, source_readings=tuple(
SourceReading(source, True, 'valid', 50/3.6) for source in ('dashboard', 'map', 'vision'))))
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
widget.render(current)
ink = []
for text, role, size, left, top, _ in calls:
a, b = fonts.horizontal_ink(text, role, size)
c, d = fonts.vertical_ink(text, role, size)
box = left+a, top+c, left+b, top+d
widget.render(rl.Rectangle(30, 30, 1800, 1020), current)
view._slc_actions(current)
foreground = [call for call in calls if call[-1].a == 255]
action_top = min(y+fonts.vertical_ink(text, role, size)[0]
for text, role, size, _, y, _ in foreground if text in ('Accept', 'Decline'))
source_bottom = 75+CARD_HEIGHT+(GAP if separate else 0)+SOURCE_ROW_HEIGHT
assert source_bottom+8 <= action_top
widget.close()
finally:
if owns_context:
rl.close_window()
def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
from unittest.mock import patch
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
from openpilot.starpilot.ui.tests.test_unified_speed_native import state
from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, SPRITES
glyphs = {}
with Image.open(ATLAS_PATH) as atlas:
for size in ('large', 'compact'):
sx, sy, sw, sh = SPRITES[f'arrow_{size}']
bbox = atlas.crop((sx, sy, sx+sw, sy+sh)).getchannel('A').point(lambda alpha: 255 if alpha > 40 else 0).getbbox()
glyphs[size] = (bbox[0]-sw/2, bbox[1]-sh/2, bbox[2]-sw/2, bbox[3]-sh/2)
owns_context = not rl.is_window_ready()
if owns_context:
rl.init_window(128, 128, 'Contained signs on both device profiles')
try:
for profile in Profile:
with BitmapFonts(profile, default_font_directory()) as fonts:
widget = SpeedLimitWidget(fonts, profile)
scale = widget.scale
positions = ((88, 75), (188, 95), (1642, 882)) if profile == Profile.LARGE else ((330, 20), (100, 0), (358, 108))
for vienna, (posted, offset), elapsed, (saved_x, y), direction in product(
(False, True), ((65, 5), (120, 10), (130, -10), (145, -137), (199, -191), (200, -192), (55, 0)),
(0, 350_000_000, 2_100_000_000), positions, (-1, 1)):
if not vienna and posted > 145:
continue
current = state(observed_ns=10_000_000_000+elapsed)
key = 'cruise_limits' if profile == Profile.LARGE else 'speed_limit'
current.customization['layouts'][profile][key].update(x=saved_x, y=y)
x = saved_x+184 if profile == Profile.LARGE else saved_x
current = replace(current, speed_mps=(posted+offset+direction*2)/3.6,
appearance=replace(current.appearance, use_vienna_sign=vienna),
speed_limit=replace(current.speed_limit, speed_limit_mps=posted/3.6,
pending_speed_limit_mps=posted/3.6, presentation_adjusted_limit_mps=(posted+offset)/3.6))
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), \
patch.object(widget.shapes, 'arrow') as arrow_draw:
widget.render(current)
ink = []
for text, role, size, left, top, color in calls:
a, b = fonts.horizontal_ink(text, role, size)
c, d = fonts.vertical_ink(text, role, size)
box = left+a, top+c, left+b, top+d
if vienna:
cx, cy = x+59*scale, y+67*scale
assert all((px-cx)**2+(py-cy)**2 <= (47*scale)**2
for px, py in product((box[0], box[2]), (box[1], box[3])))
assert (color.r, color.g, color.b, color.a) == (0, 0, 0, 255)
else:
assert x+7*scale <= box[0] < box[2] <= x+111*scale
assert y+7*scale <= box[1] < box[3] <= y+125*scale
ink.append((text, box))
nominal_size = size/widget.text_scale
if role == FontRole.BOLD:
assert 31.99 <= nominal_size <= (48.01 if len(text)>2 else 54.01) if vienna else nominal_size == 58
elif role == FontRole.MEDIUM:
assert 23.99 <= nominal_size <= 36.01 if vienna else nominal_size == 36
arrow_draw.assert_called_once()
size, ax, ay, arrow_direction, _ = arrow_draw.call_args.args
assert size == ('compact' if vienna else 'large')
assert arrow_direction == direction
glyph = glyphs[size]
left, top, right, bottom = glyph if direction>0 else (-glyph[2], -glyph[3], -glyph[0], -glyph[1])
arrow = ax+left*scale, ay+top*scale, ax+right*scale, ay+bottom*scale
if vienna:
hop = -6 if elapsed == 750_000_000 else 0
cx, cy = x+59, y+67+hop
assert all((px-cx)**2+(py-cy)**2 < 59**2 for px, py in product((box[0], box[2]), (box[1], box[3])))
assert all((px-cx)**2+(py-cy)**2 <= (47*scale)**2
for px, py in product((arrow[0], arrow[2]), (arrow[1], arrow[3])))
else:
assert x+7 <= box[0] < box[2] <= x+111
assert y+7 <= box[1] < box[3] <= y+125
ink.append((text, box))
if role == FontRole.BOLD:
assert size == (48 if vienna and len(text) > 2 else 58)
elif role == FontRole.MEDIUM:
assert size == 36
for index, (_, a) in enumerate(ink):
for _, b in ink[index+1:]:
assert not (a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1])
assert x+7*scale <= arrow[0] < arrow[2] <= x+111*scale
assert y+7*scale <= arrow[1] < arrow[3] <= y+125*scale
assert all(box[2]+4*scale <= arrow[0] for text, box in ink if text in ('SPEED', 'LIMIT'))
assert arrow[3]+6*scale <= next(box[1] for text, box in ink if text==str(posted))
ink.append(('arrow', arrow))
for index, (_, a) in enumerate(ink):
for _, b in ink[index+1:]:
assert not (a[0]<b[2] and a[2]>b[0] and a[1]<b[3] and a[3]>b[1])
widget.close()
finally:
if owns_context:
rl.close_window()
@@ -308,23 +381,23 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent():
doc['layouts']['large']['cruise_limits'].update(x=1550, y=638)
doc['layouts']['large']['speed_limit_actions'].update(x=1550, y=954)
migrated = validate_document(doc)
assert migrated['version'] == 9
assert migrated['version'] == 10
assert migrated['layouts']['large']['cruise_limits'] == {'x': 1550, 'y': 638, 'enabled': True}
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 890, 'enabled': True}
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 902, 'enabled': True}
assert migrated['speedSources'] is True
assert doc['layouts']['large']['cruise_limits']['x'] == 1550
assert validate_document(migrated) == migrated
doc['layouts']['large']['cruise_limits'].update(x=88, y=75)
doc['layouts']['large']['speed_limit_actions'].update(x=88, y=447)
assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 248
assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 296
def test_pending_choices_are_plain_accept_and_decline_with_padded_targets():
state = road()
state = replace(state, slc_prompt_open=True, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=25))
accept, decline = slc_controls(Profile.LARGE, state)
assert accept.bounds == (100, 248, 372, 408)
assert decline.bounds == (404, 248, 676, 408)
assert accept.bounds == (100, 296, 372, 444)
assert decline.bounds == (404, 296, 676, 444)
assert (accept.label, decline.label) == ('Accept', 'Decline')
@@ -335,8 +408,8 @@ def test_projection_v1_layout_migrates_without_losing_other_widgets():
doc['widgets']['speed_limit_actions'].update(x=88, y=447)
doc['widgets']['current_speed'].update(x=1400, y=100)
migrated = validate_layout_for_viewport(doc, viewport)
assert migrated['version'] == 4
assert migrated['widgets']['speed_limit_actions']['y'] == 248
assert migrated['version'] == 5
assert migrated['widgets']['speed_limit_actions']['y'] == 296
assert migrated['widgets']['current_speed'] == doc['widgets']['current_speed']
assert validate_layout_for_viewport(migrated, viewport) == migrated
@@ -349,5 +422,5 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped():
doc['widgets']['speed_limit_actions'].update(x=1654, y=500)
migrated = validate_layout_for_viewport(doc, viewport)
assert migrated['widgets']['cruise_limits']['x'] == 1342
assert migrated['widgets']['speed_limit_actions'] == {'x': 1254, 'y': 248, 'enabled': True}
assert migrated['widgets']['speed_limit_actions'] == {'x': 1254, 'y': 296, 'enabled': True}
@@ -337,6 +337,7 @@ class TestOnroadAxes(unittest.TestCase):
fonts.measure.return_value = NS(width=20, height=10)
fonts.vertical_ink.return_value = (0, 10)
compact = CompactHudRenderer(fonts, Path("/unused"))
fonts.horizontal_ink.return_value = (0, 20)
with patch.object(compact, "prepare"), patch("openpilot.starpilot.ui.onroad_compact_widgets.rl.draw_texture_pro"), \
patch("openpilot.starpilot.ui.onroad_compact_widgets.rl.draw_circle_gradient"):
compact.render(active)
@@ -395,5 +396,7 @@ if __name__ == "__main__":
@pytest.fixture(autouse=True)
def mock_sign_graphics(monkeypatch):
for name in ('draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'):
for name in ('draw_rectangle_rounded', 'draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'):
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_widget.rl.' + name, Mock())
for name in ('circle', 'arrow'):
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.' + name, Mock())
@@ -301,11 +301,11 @@ def test_gauge_box_shrinks_to_its_content_and_saved_positions_keep_the_drawing_i
# Android Auto version 2 layouts move the same way.
viewport = (2880, 1080)
aa = default_layout_for_viewport(viewport)
assert aa['version'] == 4
assert aa['version'] == 5
aa['version'] = 2
aa['widgets'][CEM_GAUGE_WIDGET].update(x=900, y=200)
moved = validate_layout_for_viewport(aa, viewport)
assert moved['version'] == 4 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224)
assert moved['version'] == 5 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224)
assert validate_layout_for_viewport(moved, viewport) == moved
@@ -25,6 +25,8 @@ from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_ca
@pytest.fixture(autouse=True)
def drawing_guard(monkeypatch):
monkeypatch.setattr(rl, 'draw_line_ex', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.arrow', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.circle', Mock())
def road(document=None, **changes):
@@ -65,9 +67,9 @@ def test_v7_speed_layout_migration_is_bounded_non_mutating_and_idempotent():
original = copy.deepcopy(document)
migrated = validate_document(document)
assert document == original
assert migrated['version'] == 9 and validate_document(migrated) == migrated
assert migrated['version'] == 10 and validate_document(migrated) == migrated
assert migrated['layouts']['large']['cruise_limits']['x'] == 1510
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 890, 'enabled': True}
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 900, 'enabled': True}
assert migrated['layouts']['large']['current_speed'] == original['layouts']['large']['current_speed']
assert migrated['layouts']['large']['cem_gauge'] == {'x': 56, 'y': 452, 'enabled': True}
assert migrated['layouts']['compact']['speed_limit']['x'] == 330
@@ -95,7 +97,7 @@ def test_v8_restores_only_compact_speed_defaults_and_preserves_customizations(cu
original = copy.deepcopy(document)
migrated = validate_document(document)
assert document == original
assert migrated['version'] == 9 and validate_document(migrated) == migrated
assert migrated['version'] == 10 and validate_document(migrated) == migrated
assert migrated['layouts']['large'] == document['layouts']['large']
assert migrated['widgetColors'] == document['widgetColors']
assert migrated['widgetOrder'] == document['widgetOrder']
@@ -136,7 +138,7 @@ def test_v8_actionless_layout_preserves_customizations_when_adding_compact_actio
original = copy.deepcopy(document)
migrated = validate_document(document)
assert document == original
assert migrated['version'] == 9 and validate_document(migrated) == migrated
assert migrated['version'] == 10 and validate_document(migrated) == migrated
assert migrated['layouts']['large'] == {**original['layouts']['large'], 'speed_limit_actions': large_actions}
assert migrated['layouts']['compact']['speed_limit'] == original['layouts']['compact']['speed_limit']
assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 128, 'y': 186, 'enabled': True}
@@ -255,7 +257,7 @@ def test_v3_layout_migrates_wheel_size_without_changing_saved_placement_or_color
for profile in ("large", "compact"):
del document["layouts"][profile]["steering_wheel"]["size"]
migrated = validate_document(document)
assert migrated["version"] == 9
assert migrated["version"] == 10
assert migrated["palette"] == document["palette"]
assert migrated["layouts"]["large"]["steering_wheel"] == {"x": 1300, "y": 100, "enabled": False, "size": 192}
assert migrated["layouts"]["compact"]["steering_wheel"] == {"x": 90, "y": 130, "enabled": True, "size": 50}
@@ -286,7 +288,7 @@ def test_pre_action_saved_layout_migrates_without_losing_positions_or_palette(re
assert migrated["layouts"][profile][key] == {
**position, **({"size": customization_metadata()["profiles"][profile]["widgets"][key]["width"]}
if key == "steering_wheel" else {})}
assert migrated["layouts"]["large"]["speed_limit_actions"] == {"x": 300, "y": 348, "enabled": False}
assert migrated["layouts"]["large"]["speed_limit_actions"] == {"x": 300, "y": 396, "enabled": False}
assert migrated["layouts"]["compact"]["speed_limit_actions"] == default_document()["layouts"]["compact"]["speed_limit_actions"]
@@ -304,11 +306,40 @@ def test_malformed_pre_action_cruise_placement_is_rejected():
def test_duplicate_key_cannot_apply_otherwise_valid_placement():
document = default_document()
document["layouts"]["large"]["current_speed"]["x"] = 100
raw = json.dumps(document).replace('"version": 9', '"version": 9, "version": 9').encode()
raw = json.dumps(document).replace('"version": 10', '"version": 10, "version": 10').encode()
with patch("openpilot.starpilot.ui.onroad_customization.read_saved", return_value=(raw, True)):
assert read_customization(Mock()) == default_document()
@pytest.mark.parametrize('sources', [False, True])
def test_v9_large_sign_migration_preserves_customizations_and_clamps_bottom_edge(sources):
from copy import deepcopy
from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, SOURCE_ROW_HEIGHT
old = default_document()
old['version'] = 9
old['speedSources'] = sources
old['layouts']['large']['cruise_limits'].update(y=918)
old['layouts']['large']['speed_limit_actions'].update(x=250, y=400)
before = deepcopy(old)
updated = validate_document(old)
assert old == before
assert updated['version'] == 10
assert updated['layouts']['large']['cruise_limits']['y'] == 1050-CARD_HEIGHT-(SOURCE_ROW_HEIGHT if sources else 0)
assert updated['layouts']['large']['speed_limit_actions'] == before['layouts']['large']['speed_limit_actions']
assert updated['layouts']['compact'] == before['layouts']['compact']
assert validate_document(updated) == updated
def test_v9_moves_only_actions_paired_with_the_old_large_speed_group():
old = default_document()
old['version'] = 9
old['layouts']['large']['cruise_limits'].update(x=188, y=95)
old['layouts']['large']['speed_limit_actions'].update(x=200, y=268)
updated = validate_document(old)
assert updated['layouts']['large']['speed_limit_actions'] == {'x': 200, 'y': 316, 'enabled': True}
assert validate_document(updated) == updated
def test_wheel_hit_edges_match_default_draw_bounds():
emit = Mock()
inputs = OnroadInput(emit)
@@ -435,7 +466,7 @@ def test_speed_limit_actions_move_independently_of_cruise_card_and_keep_touch_pr
presentation_id=2, action_enabled=True)
state = road(document, speed_limit=observation, longitudinal_active=True, slc_system_long_available=True)
controls = slc_controls(Profile.LARGE, state)
assert controls[0].bounds == (388, 600, 660, 760)
assert controls[0].bounds == (388, 600, 660, 748)
assert slc_controls(Profile.COMPACT, state)[0].bounds == (128, 182, 296, 240)
emit = Mock()
inputs = OnroadInput(emit)
@@ -605,7 +636,7 @@ def test_v1_palette_positions_and_visibility_migrate_without_mutating_source():
original = copy.deepcopy(old)
result = decode_document(json.dumps(old).encode())
assert old == original
assert result['version'] == 9
assert result['version'] == 10
assert result['palette'] == old['palette']
for profile, layout in old['layouts'].items():
for key, position in layout.items():
@@ -194,7 +194,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt():
outline.assert_not_called()
renderer._speed_limit_sign(replace(road(speed_limit=valid), appearance=replace(shown.appearance, show_speed_limit_sign=False)))
outline.assert_not_called()
with patch.object(rl, 'draw_circle') as circle, patch.object(rl, 'draw_ring'):
with patch.object(renderer._speed_limit_widget.shapes, 'circle') as circle:
renderer._speed_limit_sign(replace(shown, appearance=replace(shown.appearance, use_vienna_sign=True)))
circle.assert_called_once()
@@ -1,7 +1,8 @@
"""The sign flashes only during the controller's three-second automatic window."""
"""Controller-timed automatic pulses and the stationary manual confirmation rhythm."""
from dataclasses import replace
import pyray as rl
import pytest
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation
from openpilot.starpilot.ui.speed_limit_pulse import SpeedLimitPulse
@@ -27,13 +28,64 @@ def test_three_slow_flashes_stop_at_the_controller_deadline():
assert rgba(pulse.color(rl.WHITE, now)) == (188, 132, 255, 255)
def test_a_manual_proposal_never_flashes_and_leaving_automatic_stops_the_flash():
def test_manual_confirmation_repeats_two_slow_pulses_rest_and_two_fast_pulses():
pulse = SpeedLimitPulse()
pulse.update(state(False), 10.)
assert rgba(pulse.color(rl.WHITE, 10.5)) == rgba(rl.WHITE)
pulse.update(state(), 11.)
pulse.update(state(False), 11.1)
assert rgba(pulse.color(rl.WHITE, 11.5)) == rgba(rl.WHITE)
base = rl.Color(244, 247, 245, 96)
for cycle in (0., 4.4):
for elapsed in (.35, 1.25, 2.75, 3.25):
now = 10. + cycle + elapsed
pulse.update(state(False), now)
assert rgba(pulse.color(base, now)) == (170, 115, 235, 96)
for elapsed in (0., .8, 1.6, 2.1, 2.59, 3., 3.4, 3.9, 4.39):
assert rgba(pulse.color(base, 10. + cycle + elapsed)) == rgba(base)
assert rgba(pulse.color(base, 9.9)) == rgba(base)
@pytest.mark.parametrize('kind', list(ObservationKind))
def test_finished_or_unavailable_manual_decision_stops_pulsing(kind):
pulse = SpeedLimitPulse()
current = state(False)
pulse.update(current, 10.)
observation = replace(current.speed_limit, kind=kind, speed_limit_mps=20. if kind == ObservationKind.VALID else None,
pending_speed_limit_mps=None, last_received_speed_limit_mps=20.)
pulse.update(replace(current, speed_limit=observation), 10.1)
assert rgba(pulse.color(rl.WHITE, 10.35)) == rgba(rl.WHITE)
assert pulse.start_time == -float('inf')
def test_manual_widget_reappearance_and_new_decision_use_controller_time():
pulse = SpeedLimitPulse()
pulse.update(state(False), 10., visible=False)
pulse.update(state(False), 12.6)
assert rgba(pulse.color(rl.WHITE, 12.75)) == (170, 115, 235, 255)
current = state(False)
current = replace(current, speed_limit=replace(current.speed_limit, pending_since_ns=13_000_000_000,
speed_limit_mps=25., pending_speed_limit_mps=25.))
pulse.update(current, 13.)
assert rgba(pulse.color(rl.WHITE, 13.)) == rgba(rl.WHITE)
assert rgba(pulse.color(rl.WHITE, 13.35)) == (170, 115, 235, 255)
def test_missing_pending_timestamp_starts_once_and_resets_for_a_new_drive():
pulse = SpeedLimitPulse()
current = state(False)
current = replace(current, speed_limit=replace(current.speed_limit, pending_since_ns=0))
pulse.update(current, 10.)
pulse.update(current, 10.35)
assert rgba(pulse.color(rl.WHITE, 10.35)) == (170, 115, 235, 255)
pulse.update(replace(current, drive_frame=2), 20.)
assert pulse.start_time == 20.
def test_switching_between_manual_and_automatic_uses_the_current_rhythm():
pulse = SpeedLimitPulse()
pulse.update(state(), 10.)
pulse.update(state(False), 10.)
assert rgba(pulse.color(rl.WHITE, 10.35)) == (170, 115, 235, 255)
pulse.update(state(), 10.)
assert rgba(pulse.color(rl.WHITE, 10.5)) == (188, 132, 255, 255)
assert rgba(pulse.color(rl.WHITE, 13.)) == rgba(rl.WHITE)
def test_reappearing_widget_uses_remaining_controller_time():
@@ -0,0 +1,85 @@
"""The atlas has smooth coverage, solid arrow joins, and bounded GPU ownership."""
from types import SimpleNamespace as NS
from unittest.mock import Mock
import numpy as np
from PIL import Image
import pytest
from openpilot.starpilot.ui import speed_limit_shapes as shapes
from openpilot.starpilot.ui.assets.generate_speed_limit_shapes import generate
def test_atlas_matches_generated_geometry_and_has_symmetric_antialiased_edges():
with Image.open(shapes.ATLAS_PATH) as atlas:
assert atlas.size == shapes.ATLAS_SIZE
assert atlas.tobytes() == generate().tobytes()
for name, (x, y, width, height) in shapes.SPRITES.items():
alpha = np.asarray(atlas.crop((x, y, x+width, y+height)))[..., 3]
assert np.array_equal(alpha, alpha[:, ::-1])
assert ((alpha > 0) & (alpha < 255)).sum() > 10
assert not alpha[0].any() and not alpha[-1].any()
assert not alpha[:, 0].any() and not alpha[:, -1].any()
if name.startswith('arrow'):
for row in alpha:
ink = np.flatnonzero(row > 40)
assert not len(ink) or ink[-1] - ink[0] + 1 == len(ink)
center = alpha[:, width//2]
painted = np.flatnonzero(center > 40)
assert painted[-1] - painted[0] + 1 == len(painted)
@pytest.fixture
def gpu(monkeypatch):
monkeypatch.setattr(shapes.rl, 'is_window_ready', lambda: True)
calls = {}
for name, value in (('load_image', NS(data=1, width=256, height=176)),
('load_texture_from_image', NS(id=7)), ('unload_image', None), ('unload_texture', None),
('set_texture_filter', None), ('set_texture_wrap', None), ('draw_texture_pro', None)):
calls[name] = Mock(return_value=value)
monkeypatch.setattr(shapes.rl, name, calls[name])
return calls
def test_shapes_share_one_upload_and_arrow_is_one_draw_in_either_direction(gpu):
owner = shapes.SpeedLimitShapes()
owner.circle(389, 87, shapes.rl.RED)
assert gpu['draw_texture_pro'].call_count == 2
for direction, rotation in ((1, 0), (-1, 180)):
gpu['draw_texture_pro'].reset_mock()
owner.arrow('compact', 389, 49, direction, shapes.rl.BLACK)
gpu['draw_texture_pro'].assert_called_once()
assert gpu['draw_texture_pro'].call_args.args[-2] == rotation
gpu['load_image'].assert_called_once()
gpu['load_texture_from_image'].assert_called_once()
gpu['unload_image'].assert_called_once()
owner.close()
owner.close()
gpu['unload_texture'].assert_called_once()
def test_bad_atlas_dimensions_release_image_without_uploading(gpu):
gpu['load_image'].return_value.width = 4096
with pytest.raises(ValueError, match='Invalid speed-limit'):
shapes.SpeedLimitShapes().circle(0, 0, shapes.rl.RED)
gpu['unload_image'].assert_called_once()
gpu['load_texture_from_image'].assert_not_called()
def test_large_sign_scale_changes_destination_and_rotation_origin_only(gpu):
owner = shapes.SpeedLimitShapes(1.25)
owner.arrow('compact', 345.75, 111.25, -1, shapes.rl.BLACK)
_, source, destination, origin, rotation, _ = gpu['draw_texture_pro'].call_args.args
assert (source.width, source.height) == (16, 24)
assert (destination.x, destination.y, destination.width, destination.height) == (345.75, 111.25, 20, 30)
assert (origin.x, origin.y, rotation) == (10, 15, 180)
owner.close()
def test_texture_setup_failure_releases_both_resources(gpu):
gpu['set_texture_filter'].side_effect = RuntimeError('filter unavailable')
with pytest.raises(RuntimeError, match='filter unavailable'):
shapes.SpeedLimitShapes().circle(0, 0, shapes.rl.RED)
gpu['unload_texture'].assert_called_once()
gpu['unload_image'].assert_called_once()
@@ -52,9 +52,9 @@ def test_v4_migration_preserves_settings_and_clamps_only_enlarged_widgets():
doc['layouts']['large']['speed_limit_actions'].update(x=1654, y=500)
old_speed = dict(doc['layouts']['large']['current_speed'])
migrated = validate_document(doc)
assert migrated['version'] == 9
assert migrated['version'] == 10
assert migrated['layouts']['large']['cruise_limits']['x'] == 1642
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1554, 'y': 248, 'enabled': True}
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1554, 'y': 296, 'enabled': True}
assert migrated['layouts']['large']['current_speed'] == old_speed
assert migrated['speedSources'] is True
assert rgba(migrated, 'cardFill', 'large', 'cruise_limits') == (18, 52, 86, 120)
@@ -13,6 +13,7 @@ from openpilot.starpilot.ui.onroad import OnroadView
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation, slc_controls
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.speed_limit_widget import INK, MUTED, SpeedLimitWidget, draw_text
from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes
from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps, resolve_unified_speed
@@ -22,6 +23,9 @@ def drawing(monkeypatch):
for name in ('draw_rectangle_rounded_lines_ex', 'draw_rectangle_rounded', 'draw_line_ex', 'draw_circle', 'draw_ring', 'draw_circle_gradient'):
calls[name] = Mock()
monkeypatch.setattr(rl, name, calls[name])
for name in ('circle', 'arrow'):
calls[name] = Mock()
monkeypatch.setattr(SpeedLimitShapes, name, calls[name])
monkeypatch.setattr('openpilot.starpilot.ui.onroad_large_widgets.draw_control_card', Mock())
return calls
@@ -46,6 +50,10 @@ def foreground_calls(draw):
return [call for call in draw.call_args_list if (call.args[-1].r, call.args[-1].g, call.args[-1].b) not in ((6, 14, 10), (7, 16, 12))]
def rgba_tuple(color):
return (color.r, color.g, color.b, color.a)
def test_latest_limit_and_offset_replace_accepted_sign_while_waiting():
shown = resolve_unified_speed(state())
assert (shown.max_text, shown.posted_text, shown.offset_text, shown.source) == ('100', '50', '+5', 'map')
@@ -84,21 +92,45 @@ def test_compact_sign_omits_missing_zero_and_rounded_zero_offsets(metric, vienna
@pytest.mark.parametrize('profile', list(Profile))
def test_pending_sign_hops_and_arrow_follows_vehicle_speed(profile, drawing):
@pytest.mark.parametrize('vienna', [False, True])
def test_pending_sign_pulses_in_place_and_arrow_follows_vehicle_speed(profile, vienna, drawing):
font = fonts(profile)
sign = SpeedLimitWidget(font, profile)
current = state()
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna))
sign.render(current)
before = {call.args[0]: call.args for call in foreground_calls(font.draw)}
arrow = foreground_calls(drawing['draw_line_ex'])[0].args
assert arrow[1].y < arrow[0].y
sign.render(replace(current, observed_ns=10_750_000_000, speed_mps=60 / 3.6))
assert drawing['arrow'].call_args.args[3] == -1
sign.render(replace(current, observed_ns=10_350_000_000, speed_mps=60 / 3.6))
after = {call.args[0]: call.args for call in foreground_calls(font.draw)}
assert after['LIMIT'][4] == before['LIMIT'][4]
assert after['50'][4] == before['50'][4] - 6
assert after['+5'][4] == before['+5'][4] - 6
arrow = foreground_calls(drawing['draw_line_ex'])[-3].args
assert arrow[1].y > arrow[0].y
assert all(after[text][3:5] == args[3:5] for text, args in before.items())
if vienna:
assert rgba_tuple(after['50'][-1]) == (0, 0, 0, 255)
assert drawing['circle'].call_args.args[:2] == ((389, 87) if profile == Profile.COMPACT else (345.75, 158.75))
assert rgba_tuple(drawing['circle'].call_args.args[-1]) == (170, 115, 235, 255)
else:
assert rgba_tuple(after['50'][-1]) == (170, 115, 235, 255)
assert rgba_tuple(before['LIMIT'][-1]) == (244, 247, 245, 255)
assert rgba_tuple(after['LIMIT'][-1]) == (170, 115, 235, 255)
assert drawing['arrow'].call_args.args[3] == 1
@pytest.mark.parametrize('reason', ['steady', 'automatic', 'equal_speed', 'unknown_speed'])
def test_compact_us_heading_stays_centered_without_an_arrow(reason, drawing):
current = state()
if reason == 'steady':
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
elif reason == 'automatic':
current = replace(current, speed_limit=replace(current.speed_limit, automatic_adoption_pending=True))
else:
current = replace(current, speed_mps=55 / 3.6 if reason == 'equal_speed' else None)
font = fonts(Profile.COMPACT)
SpeedLimitWidget(font, Profile.COMPACT).render(current)
drawing['arrow'].assert_not_called()
for call in font.draw.call_args_list:
text, role, size, x, _, _ = call.args
left, right = font.horizontal_ink(text, role, size)
assert x + (left + right) / 2 == 389
@pytest.mark.parametrize('profile', list(Profile))
@@ -145,7 +177,7 @@ def test_large_limit_keeps_a_unit_when_set_speed_is_hidden():
current.customization['showMaxSpeed'] = False
current.customization['layouts']['compact']['max_speed']['enabled'] = False
font = fonts(profile)
SpeedLimitWidget(font, profile).render(current)
UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current)
assert [call.args[0] for call in foreground_calls(font.draw)].count('km/h') == 1
@@ -191,7 +223,7 @@ def test_full_coverage_contour_protects_base_muted_and_peak_purple_on_white():
def luminance(channels):
linear = [((x/255+.055)/1.055)**2.4 if x/255 > .04045 else x/255/12.92 for x in channels]
return sum(x*weight for x, weight in zip(linear, (.2126, .7152, .0722), strict=True))
for color in (INK, MUTED, rl.Color(188, 132, 255, 255)):
for color in (INK, MUTED, rl.Color(188, 132, 255, 255), rl.Color(170, 115, 235, 255)):
font = fonts()
draw_text(font, '65', FontRole.BOLD, 70, 100, 50, color)
contour = font.draw.call_args_list[0].args[-1]