mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
c532223fa0
Bundle the verified VASM model, enable it at startup, and split VASM and vision speed-sign inference across little cores when both are active. Restore VASM warnings in PiP activation, red outlines, and onroad blind-spot borders, including expanded Android Auto views. Improve cabin snapshots with longer warm-up, adjusted capture timeouts, a fresh preview frame, and clearer capture buttons and save prompts. Polish Galaxy settings with dimmed gated controls and icons, clearer vehicle fingerprinting and steering availability descriptions, rounded speed-offset ranges, and immediate refresh after saving. Improve updater startup messages and preserve update/reconnect screens across service interruptions and restarts. Add regression tests for the updated behavior.
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""Cabin-camera PiP selection and VisionIPC lifetime, independent of rendering.
|
|
|
|
The onroad view owns the renderer and supplies fresh vehicle signals. This
|
|
module never opens a camera until that view enables the PiP session.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
import math
|
|
import time
|
|
from typing import Protocol
|
|
|
|
from openpilot.common.hardware import COMMA_HARDWARE
|
|
|
|
|
|
RETRY_SECONDS = 0.2
|
|
FRAME_STALE_SECONDS = 0.5
|
|
IMAGE_TO_VEHICLE_SIDE = {"left": "right", "right": "left"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Crop:
|
|
x: float
|
|
y: float
|
|
size: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rect:
|
|
x: float
|
|
y: float
|
|
width: float
|
|
height: float
|
|
|
|
|
|
def bubble_rect(content: Rect, side: str) -> Rect:
|
|
"""The frozen C3 bubble placement, in native onroad content coordinates."""
|
|
if side not in ("left", "right") or content.width <= 0 or content.height <= 0:
|
|
raise ValueError("invalid PiP content or side")
|
|
radius = max(180, min(int(min(content.width, content.height) * 0.3), 420))
|
|
x = content.x + 24 if side == "left" else content.x + content.width - 24 - 2 * radius
|
|
y = content.y + content.height - 24 - 2 * radius
|
|
return Rect(x, y, 2 * radius, 2 * radius)
|
|
|
|
|
|
def curved_crop(crop: Crop, panel: Rect) -> Rect:
|
|
"""C4 samples an aspect-matched band of the saved square without stretching."""
|
|
if panel.width <= 0 or panel.height <= 0 or crop.size <= 0:
|
|
raise ValueError("invalid PiP panel")
|
|
aspect = panel.width / panel.height
|
|
if aspect >= 1:
|
|
height = crop.size / aspect
|
|
return Rect(crop.x, crop.y + (crop.size - height) / 2, crop.size, height)
|
|
width = crop.size * aspect
|
|
return Rect(crop.x + (crop.size - width) / 2, crop.y, width, crop.size)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Mask:
|
|
width: int
|
|
height: int
|
|
crop_size: float
|
|
center_left: tuple[float, float] | None
|
|
center_right: tuple[float, float] | None
|
|
|
|
@classmethod
|
|
def parse(cls, value: object) -> Mask | None:
|
|
if not isinstance(value, dict) or set(value) != {"width", "height", "crop_size", "center_left", "center_right"}:
|
|
return None
|
|
width, height = value["width"], value["height"]
|
|
if type(width) is not int or type(height) is not int or not (1 <= width <= 8192 and 1 <= height <= 8192):
|
|
return None
|
|
size = _finite(value["crop_size"])
|
|
if size is None or not (1 <= size <= min(width, height)):
|
|
return None
|
|
|
|
def center(raw: object) -> tuple[float, float] | None:
|
|
if raw is None:
|
|
return None
|
|
if not isinstance(raw, (list, tuple)) or len(raw) != 2:
|
|
raise ValueError("invalid center")
|
|
x, y = _finite(raw[0]), _finite(raw[1])
|
|
if x is None or y is None or x - size / 2 < 0 or x + size / 2 > width or y - size / 2 < 0 or y + size / 2 > height:
|
|
raise ValueError("center outside image")
|
|
return x, y
|
|
|
|
try:
|
|
left, right = center(value["center_left"]), center(value["center_right"])
|
|
except ValueError:
|
|
return None
|
|
return cls(width, height, size, left, right)
|
|
|
|
def for_frame(self, width: int, height: int) -> Mask | None:
|
|
"""Keep saved crop alignment when the cabin camera resolution changes."""
|
|
if (width, height) == (self.width, self.height):
|
|
return self
|
|
formats = {(1928, 1208), (1344, 760)}
|
|
if (self.width, self.height) not in formats or (width, height) not in formats:
|
|
return None
|
|
sx, sy = width / self.width, height / self.height
|
|
def scaled(center):
|
|
return None if center is None else [center[0] * sx, center[1] * sy]
|
|
return Mask.parse({"width": width, "height": height, "crop_size": max(1.0, self.crop_size * min(sx, sy)),
|
|
"center_left": scaled(self.center_left), "center_right": scaled(self.center_right)})
|
|
|
|
def crop(self, vehicle_side: str) -> Crop | None:
|
|
image_side = IMAGE_TO_VEHICLE_SIDE.get(vehicle_side)
|
|
if image_side is None:
|
|
return None
|
|
center = self.center_left if image_side == "left" else self.center_right
|
|
return None if center is None else Crop(center[0] - self.crop_size / 2, center[1] - self.crop_size / 2, self.crop_size)
|
|
|
|
|
|
def _finite(value: object) -> float | None:
|
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
return None
|
|
try:
|
|
result = float(value)
|
|
except OverflowError:
|
|
return None
|
|
return result if math.isfinite(result) else None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Signals:
|
|
"""The caller has already checked carState and optional VASM freshness."""
|
|
|
|
car_state_fresh: bool
|
|
left_blinker: bool
|
|
right_blinker: bool
|
|
left_blindspot: bool
|
|
right_blindspot: bool
|
|
vasm_left: bool = False
|
|
vasm_right: bool = False
|
|
|
|
|
|
def selected_sides(mask: Mask | None, signals: Signals, *, started: bool, enabled: bool,
|
|
on_blinker: bool, on_bsm: bool) -> tuple[str, ...]:
|
|
"""Select saved camera crops using PiP blinker and OEM blind-spot triggers."""
|
|
if not (started and enabled and signals.car_state_fresh and mask is not None):
|
|
return ()
|
|
sides: list[str] = []
|
|
for vehicle_side, blinker, blindspot, visual_warning in (
|
|
("right", signals.right_blinker, signals.right_blindspot, signals.vasm_right),
|
|
("left", signals.left_blinker, signals.left_blindspot, signals.vasm_left),
|
|
):
|
|
if mask.crop(vehicle_side) is not None and ((on_blinker and blinker) or (on_bsm and (blindspot or visual_warning))):
|
|
sides.append(vehicle_side)
|
|
return tuple(sides)
|
|
|
|
|
|
class _VisionClient(Protocol):
|
|
num_buffers: int
|
|
width: int
|
|
height: int
|
|
stride: int
|
|
|
|
def is_connected(self) -> bool: ...
|
|
def connect(self, block: bool) -> bool: ...
|
|
def recv(self, timeout_ms: int) -> object | None: ...
|
|
|
|
|
|
def cabin_client() -> _VisionClient:
|
|
"""Use the actual camerad cabin stream; import only on onroad activation."""
|
|
from msgq.visionipc import VisionIpcClient
|
|
from openpilot.cereal.visionipc import VisionStreamType
|
|
return VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_CABIN, conflate=True)
|
|
|
|
|
|
class PiPStream:
|
|
"""One nonblocking cabin VisionIPC subscription with explicit expiry.
|
|
|
|
A frame is borrowed only while this session retains its client. Rendering
|
|
must happen synchronously after poll(); it must not keep VisionBuf pointers.
|
|
"""
|
|
|
|
def __init__(self, client_factory: Callable[[], _VisionClient] = cabin_client, *,
|
|
require_boot_eof: bool = COMMA_HARDWARE):
|
|
self._factory = client_factory
|
|
self._client: _VisionClient | None = None
|
|
self._frame: object | None = None
|
|
self._frame_id: int | None = None
|
|
self._received_at: float | None = None
|
|
self._capture_eof_ns: int | None = None
|
|
self._last_attempt: float | None = None
|
|
self._connected_at: float | None = None
|
|
self._active = False
|
|
self._require_boot_eof = require_boot_eof
|
|
self._generation = 0
|
|
self._retired_clients: list[_VisionClient] = []
|
|
|
|
@property
|
|
def generation(self) -> int:
|
|
"""Changes on every client loss or replacement, even within one poll."""
|
|
return self._generation
|
|
|
|
def release_retired(self) -> None:
|
|
"""The renderer calls this only after destroying the old GPU images."""
|
|
self._retired_clients.clear()
|
|
|
|
@property
|
|
def connected(self) -> bool:
|
|
return self._client is not None and self._client.is_connected()
|
|
|
|
@property
|
|
def frame_size(self) -> tuple[int, int] | None:
|
|
if not self.connected or self._client is None:
|
|
return None
|
|
return self._client.width, self._client.height
|
|
|
|
def set_active(self, active: bool) -> None:
|
|
if not active:
|
|
self.close()
|
|
elif not self._active:
|
|
self._active = True
|
|
self._last_attempt = None
|
|
|
|
def close(self) -> None:
|
|
self._active = False
|
|
self._release()
|
|
self.release_retired()
|
|
self._last_attempt = None
|
|
|
|
def _release(self) -> None:
|
|
if self._client is not None:
|
|
self._retired_clients.append(self._client)
|
|
self._generation += 1
|
|
self._frame = None
|
|
self._frame_id = None
|
|
self._received_at = None
|
|
self._capture_eof_ns = None
|
|
self._connected_at = None
|
|
self._client = None
|
|
|
|
def poll(self, now: float, *, now_boot_ns: int | None = None) -> object | None:
|
|
if not self._active or not math.isfinite(now):
|
|
return None
|
|
if self._require_boot_eof:
|
|
if now_boot_ns is None and not hasattr(time, "CLOCK_BOOTTIME"):
|
|
return None
|
|
if now_boot_ns is not None and now_boot_ns <= 0:
|
|
return None
|
|
if self._client is not None and not self._client.is_connected():
|
|
self._release()
|
|
if self._client is None:
|
|
if self._last_attempt is not None and 0 <= now - self._last_attempt < RETRY_SECONDS:
|
|
return None
|
|
self._last_attempt = now
|
|
try:
|
|
client = self._factory()
|
|
if not client.connect(False) or client.num_buffers <= 0:
|
|
return None
|
|
except (OSError, RuntimeError):
|
|
return None
|
|
self._client = client
|
|
self._connected_at = now
|
|
self._generation += 1
|
|
assert self._client is not None
|
|
try:
|
|
frame = self._client.recv(timeout_ms=0)
|
|
except (OSError, RuntimeError):
|
|
self._release()
|
|
return None
|
|
if frame is not None:
|
|
frame_id = getattr(frame, "frame_id", None)
|
|
if type(frame_id) is not int or frame_id < 0:
|
|
self._release()
|
|
return None
|
|
if self._frame_id is not None and frame_id <= self._frame_id:
|
|
# A restarted producer or duplicated delivery must reconnect, so the
|
|
# old buffer and queued IPC data cannot be displayed as fresh.
|
|
self._release()
|
|
return None
|
|
self._frame = frame
|
|
self._frame_id = frame_id
|
|
self._received_at = now
|
|
if self._require_boot_eof:
|
|
eof = getattr(self._client, "timestamp_eof", None)
|
|
if type(eof) is not int or eof <= 0:
|
|
self._release()
|
|
return None
|
|
self._capture_eof_ns = eof
|
|
if self._received_at is None:
|
|
# A new conflated subscriber has nothing queued until the next capture, so
|
|
# an empty first recv is normal. Allow one staleness window for it to arrive.
|
|
if self._connected_at is None or not 0 <= now - self._connected_at <= FRAME_STALE_SECONDS:
|
|
self._release()
|
|
return None
|
|
# Connect and recv can cross a camera capture. Measure automatic BOOTTIME
|
|
# only after receiving, so a newly captured frame is not misread as future.
|
|
if self._require_boot_eof and now_boot_ns is None:
|
|
now_boot_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
|
|
if now_boot_ns <= 0:
|
|
self._release()
|
|
return None
|
|
if self._received_at is None or now < self._received_at or now - self._received_at > FRAME_STALE_SECONDS:
|
|
self._release()
|
|
return None
|
|
if self._require_boot_eof and (self._capture_eof_ns is None or now_boot_ns is None or
|
|
self._capture_eof_ns > now_boot_ns or
|
|
now_boot_ns - self._capture_eof_ns > int(FRAME_STALE_SECONDS * 1e9)):
|
|
self._release()
|
|
return None
|
|
return self._frame
|