mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
ui: unify speed-limit signs for clearer confirmation cues
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user