Add vision adjacent spot monitoring

Integrate V-ASM from PR #75 with Galaxy-only configuration, stale-state safety, conditional SLV coexistence, and OpenCV inference.

Originally contributed by @prabhaavp in #75.

Co-authored-by: Prabhaav Pillai <143428353+prabhaavp@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-07-23 00:05:43 -05:00
parent 9f1066ce83
commit b945d4c021
28 changed files with 1982 additions and 13 deletions
+10
View File
@@ -650,6 +650,16 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"UseSI", {PERSISTENT, BOOL, "1", "1", 3}},
{"UserFavorites", {PERSISTENT, STRING, "", "", 1}},
{"UseVienna", {PERSISTENT, BOOL, "0", "0", 1, SETTINGS_SIMPLE}},
{"VASMAnnotationConfig", {PERSISTENT, JSON, "{}", "{}", 2}},
{"VASMConfidenceThreshold", {PERSISTENT, FLOAT, "0.85", "0.85", 2}},
{"VASMEnabled", {PERSISTENT, BOOL, "0", "0", 1}},
{"VASMLeftActive", {CLEAR_ON_MANAGER_START, STRING, "0", "0", 2}},
{"VASMLeftConfidence", {CLEAR_ON_MANAGER_START, STRING, "0.0", "0.0", 2}},
{"VASMLastUpdateMonoTime", {CLEAR_ON_MANAGER_START, STRING, "0", "0", 2}},
{"VASMRightActive", {CLEAR_ON_MANAGER_START, STRING, "0", "0", 2}},
{"VASMRightConfidence", {CLEAR_ON_MANAGER_START, STRING, "0.0", "0.0", 2}},
{"VASMSmoothSeconds", {PERSISTENT, FLOAT, "0.2", "0.2", 2}},
{"VASMTimestampEof", {CLEAR_ON_MANAGER_START, STRING, "0", "0", 2}},
{"VEgoStarting", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"VEgoStartingStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"VEgoStopping", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
+15 -7
View File
@@ -32,6 +32,7 @@ from openpilot.system.hardware import HARDWARE
from openpilot.starpilot.common.starpilot_utilities import contains_event_type
from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles
from openpilot.starpilot.common.vision_bsm import get_fresh_vasm_state
REPLAY = "REPLAY" in os.environ
SIMULATION = "SIMULATION" in os.environ
@@ -61,13 +62,15 @@ def commanded_torque_at_max_for_saturation(CP, output: float) -> bool:
return torque_controller and abs(output) > 0.99
def should_loud_blindspot_alert_without_lateral(CS, sm, starpilot_toggles) -> bool:
def should_loud_blindspot_alert_without_lateral(CS, sm, starpilot_toggles, combined_left_bsm=None, combined_right_bsm=None) -> bool:
if not (getattr(starpilot_toggles, "loud_blindspot_alert", False) and
getattr(starpilot_toggles, "loud_blindspot_alert_when_disengaged", False)):
return False
left_signal_blocked = bool(CS.leftBlinker and CS.leftBlindspot)
right_signal_blocked = bool(CS.rightBlinker and CS.rightBlindspot)
combined_left_bsm = CS.leftBlindspot if combined_left_bsm is None else combined_left_bsm
combined_right_bsm = CS.rightBlindspot if combined_right_bsm is None else combined_right_bsm
left_signal_blocked = bool(CS.leftBlinker and combined_left_bsm)
right_signal_blocked = bool(CS.rightBlinker and combined_right_bsm)
one_blinker = bool(CS.leftBlinker) != bool(CS.rightBlinker)
if not (one_blinker and (left_signal_blocked or right_signal_blocked)):
return False
@@ -502,12 +505,17 @@ class SelfdriveD:
self.events.add(EventName.excessiveActuation)
# ******************************************************************************************
# Handle lane change
# Handle lane change - combine OEM BSM with fresh V-ASM state.
blindspot_alert_added = False
vasm_left, vasm_right = (False, False)
if getattr(self.starpilot_toggles, "v_asm_enabled", False):
vasm_left, vasm_right = get_fresh_vasm_state(self.params_memory)
combined_left_bsm = CS.leftBlindspot or vasm_left
combined_right_bsm = CS.rightBlindspot or vasm_right
if self.sm['modelV2'].meta.laneChangeState == LaneChangeState.preLaneChange:
direction = self.sm['modelV2'].meta.laneChangeDirection
if (CS.leftBlindspot and direction == LaneChangeDirection.left) or \
(CS.rightBlindspot and direction == LaneChangeDirection.right):
if (combined_left_bsm and direction == LaneChangeDirection.left) or \
(combined_right_bsm and direction == LaneChangeDirection.right):
blindspot_alert_added = True
if self.starpilot_toggles.loud_blindspot_alert:
self.starpilot_events.add(StarPilotEventName.laneChangeBlockedLoud)
@@ -529,7 +537,7 @@ class SelfdriveD:
LaneChangeState.laneChangeFinishing):
self.events.add(EventName.laneChange)
if not blindspot_alert_added and should_loud_blindspot_alert_without_lateral(CS, self.sm, self.starpilot_toggles):
if not blindspot_alert_added and should_loud_blindspot_alert_without_lateral(CS, self.sm, self.starpilot_toggles, combined_left_bsm, combined_right_bsm):
self.starpilot_events.add(StarPilotEventName.laneChangeBlockedLoud)
for i, pandaState in enumerate(self.sm['pandaStates']):
@@ -45,6 +45,14 @@ def test_loud_blindspot_alert_without_lateral_for_matching_signal():
assert should_loud_blindspot_alert_without_lateral(CS, _sm(lat_active=True, lateral_check=True, pause_lateral=True), _toggles())
def test_loud_blindspot_alert_accepts_combined_vision_state():
CS = _car_state(left_blinker=True)
assert should_loud_blindspot_alert_without_lateral(
CS, _sm(lat_active=False), _toggles(), combined_left_bsm=True,
)
def test_loud_blindspot_alert_without_lateral_ignores_active_lateral():
CS = _car_state(right_blinker=True, right_blindspot=True)
+5
View File
@@ -8,6 +8,7 @@ import pyray as rl
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.selfdrive.ui.lib.starpilot_theme import get_param_color, get_theme_color, is_stock_color_scheme, with_alpha
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.starpilot.common.vision_bsm import get_fresh_vasm_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -68,6 +69,10 @@ def render_adjacent_lanes(renderer) -> None:
car_state = sm["carState"]
blindspot_left = bool(car_state.leftBlindspot)
blindspot_right = bool(car_state.rightBlindspot)
if ui_state.starpilot_toggles.get("v_asm_enabled", False):
vasm_left, vasm_right = get_fresh_vasm_state(ui_state.params_memory)
blindspot_left = blindspot_left or vasm_left
blindspot_right = blindspot_right or vasm_right
# Fetch adjacent lane widths if adjacent path is enabled
lane_width_left = 0.0
@@ -5,6 +5,7 @@ from collections.abc import Callable
from enum import Enum
import pyray as rl
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.starpilot.common.vision_bsm import get_fresh_vasm_state
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.starpilot_status import (
@@ -180,6 +181,10 @@ def render_background_effects(rect: rl.Rectangle, border_width: float):
if show_signal or show_blindspot:
left_blindspot = car_state.leftBlindspot
right_blindspot = car_state.rightBlindspot
if ui_state.starpilot_toggles.get("v_asm_enabled", False):
vasm_left, vasm_right = get_fresh_vasm_state(ui_state.params_memory)
left_blindspot = left_blindspot or vasm_left
right_blindspot = right_blindspot or vasm_right
left_blinker = car_state.leftBlinker
right_blinker = car_state.rightBlinker
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import math
import time
DEVICE_BUSY_MAX_CPU_USAGE_PERCENT = 89.0
DEVICE_BUSY_AVG_CPU_USAGE_PERCENT = 74.0
DEVICE_BUSY_HOT_CORE_COUNT = 4
_throttle_state: dict[str, dict[str, float]] = {}
def device_cpu_throttle_factor(cpu_usage, name="vision"):
"""Return a process-local, low-pass-filtered CPU throttle factor."""
usage = list(cpu_usage)
if not usage:
return 1.0
now = time.monotonic()
state = _throttle_state.setdefault(name, {
"factor": 1.0,
"last_time": now,
"last_logged_factor": 1.0,
})
dt = max(0.0, now - state["last_time"])
state["last_time"] = now
average = sum(usage) / len(usage)
hot_cores = sum(core >= DEVICE_BUSY_MAX_CPU_USAGE_PERCENT for core in usage)
target_factor = _compute_throttle_factor(average, hot_cores)
alpha = min(1.0 - math.exp(-0.8 * dt), 1.0)
state["factor"] = min(max(target_factor * alpha + state["factor"] * (1.0 - alpha), 1.0), 4.0)
if state["factor"] >= state["last_logged_factor"] + 0.6:
print(f"[{name}] CPU throttle factor={state['factor']:.1f}x (avg={average:.0f}%, hot={hot_cores})")
state["last_logged_factor"] = state["factor"]
elif state["factor"] <= 1.05 and state["last_logged_factor"] > 1.05:
print(f"[{name}] CPU recovered")
state["last_logged_factor"] = 1.0
return state["factor"]
def _compute_throttle_factor(average, hot_cores):
average_factor = 1.0 if average < DEVICE_BUSY_AVG_CPU_USAGE_PERCENT else 1.0 + (average - DEVICE_BUSY_AVG_CPU_USAGE_PERCENT) / 8.0
hot_factor = 1.0 + max(0, hot_cores - DEVICE_BUSY_HOT_CORE_COUNT + 1) * 0.5
return min(max(average_factor, hot_factor), 4.0)
+1
View File
@@ -149,6 +149,7 @@ SAFE_MODE_MANAGED_KEYS = (
"Offset7",
"SpeedLimitFiller",
"VisionSpeedLimitDetection",
"VASMEnabled",
"CustomPersonalities",
"TrafficPersonalityProfile",
"AggressivePersonalityProfile",
+1
View File
@@ -1314,6 +1314,7 @@ class StarPilotVariables:
toggle.speed_limit_filler = self.get_value("SpeedLimitFiller")
toggle.vision_speed_limit_detection = self.get_value("VisionSpeedLimitDetection")
toggle.v_asm_enabled = self.get_value("VASMEnabled")
toggle.startup_alert_top = self.get_value("StartupMessageTop", cast=str, default="")
toggle.startup_alert_bottom = self.get_value("StartupMessageBottom", cast=str, default="")
+27
View File
@@ -0,0 +1,27 @@
from openpilot.starpilot.common.vision_bsm import VASM_STATE_TIMEOUT_SECONDS, get_fresh_vasm_state
class FakeParams:
def __init__(self, values):
self.values = values
def get(self, key):
return self.values.get(key)
def test_fresh_vasm_state_is_returned():
params = FakeParams({
"VASMLastUpdateMonoTime": "100.0",
"VASMLeftActive": "1",
"VASMRightActive": "0",
})
assert get_fresh_vasm_state(params, now=101.0) == (True, False)
def test_stale_or_invalid_vasm_state_fails_closed():
stale = FakeParams({"VASMLastUpdateMonoTime": "100.0", "VASMLeftActive": "1"})
invalid = FakeParams({"VASMLastUpdateMonoTime": "invalid", "VASMLeftActive": "1"})
assert get_fresh_vasm_state(stale, now=100.0 + VASM_STATE_TIMEOUT_SECONDS + 0.01) == (False, False)
assert get_fresh_vasm_state(invalid, now=100.0) == (False, False)
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
import time
VASM_STATE_TIMEOUT_SECONDS = 3.0
def get_fresh_vasm_state(params_memory, now: float | None = None) -> tuple[bool, bool]:
"""Return V-ASM state only while the vision daemon is updating it."""
try:
updated_at = float(params_memory.get("VASMLastUpdateMonoTime") or 0)
except (TypeError, ValueError):
return False, False
current_time = time.monotonic() if now is None else now
age = current_time - updated_at
if updated_at <= 0 or age < 0 or age > VASM_STATE_TIMEOUT_SECONDS:
return False, False
active_values = ("1", b"1", True)
return params_memory.get("VASMLeftActive") in active_values, params_memory.get("VASMRightActive") in active_values
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
os.environ["NUMEXPR_NUM_THREADS"] = "1"
import time
import cv2
import numpy as np
from openpilot.common.params import Params
from openpilot.common.realtime import set_core_affinity, Ratekeeper
from openpilot.system.hardware import PC
from openpilot.starpilot.common.cpu_throttle import device_cpu_throttle_factor
from openpilot.starpilot.system.adj_spot_monitor_vision_inference import VASMInference, V_ASM_MODEL_PATH
V_ASM_AFFINITY_CORES = [2]
V_ASM_SOLO_AFFINITY_CORES = [0, 1, 2]
BASE_INTERVAL = 0.500
FOLLOWUP_INTERVAL = 0.200
FOLLOWUP_WINDOW = 1.5
PARAM_REFRESH_INTERVAL = 2.0
STATUS_LOG_INTERVAL = 10.0
class VASMDaemon:
def __init__(self):
from cereal import messaging
from msgq.visionipc import VisionIpcClient, VisionStreamType
self.params = Params()
self.params_memory = Params(memory=True)
self.sm = messaging.SubMaster(["deviceState", "starpilotCarState"])
self.VisionIpcClient = VisionIpcClient
self.stream_type = VisionStreamType.VISION_STREAM_DRIVER
self.client = None
self.inference = VASMInference(V_ASM_MODEL_PATH)
self.inference.load()
self._cache_params()
self.last_inference_at = 0.0
self.last_inference_at_side = {"left": 0.0, "right": 0.0}
self.current_side = "left"
self.followup_until = 0.0
self.onroad_prev = False
self._last_pub_left = False
self._last_pub_right = False
self._last_pub_left_conf = -1.0
self._last_pub_right_conf = -1.0
self._last_update_at = 0.0
self._last_param_refresh = 0.0
self._throttle_reason = "none"
self._last_status_log = 0.0
self._inference_count = 0
self._throttle_factor = 1.0
self._affinity_set = False
self._prev_other_running = None
self._annotation_loaded = False
self._annotation_config = object()
self._load_annotation_config()
self._publish(False, False, 0.0, 0.0, 0, force=True, updated_at=0.0)
print(f"[VASM] Started (model_valid={self.inference.valid})")
def _cache_params(self):
self._enabled = self.params.get_bool("VASMEnabled")
self._slv_enabled = self.params.get_bool("VisionSpeedLimitDetection")
confidence_threshold = self.params.get_float("VASMConfidenceThreshold") or 0.85
smooth_seconds = self.params.get_float("VASMSmoothSeconds") or 0.2
self._conf_thresh = min(max(confidence_threshold, 0.25), 1.0)
self._smooth_sec = min(max(smooth_seconds, 0.1), 0.5)
def _maybe_refresh_params(self, now):
if now - self._last_param_refresh >= PARAM_REFRESH_INTERVAL:
self._last_param_refresh = now
self._cache_params()
if self._load_annotation_config():
self._update_inactive(reset_inference=True)
def _load_annotation_config(self):
config = {}
try:
config = self.params.get("VASMAnnotationConfig") or {}
if isinstance(config, (bytes, str)):
config = json.loads(config)
if not isinstance(config, dict):
config = {}
if config == self._annotation_config:
return False
self.inference.reset_state()
if config:
has_left = bool(config.get("poly_left"))
has_right = bool(config.get("poly_right"))
if has_left or has_right:
self.inference.load_config(config)
self._annotation_config = config
self._annotation_loaded = True
sides = []
if has_left:
sides.append("left")
if has_right:
sides.append("right")
print(f"[VASM] Annotation config loaded (sides: {', '.join(sides)})")
return True
except (TypeError, ValueError, json.JSONDecodeError) as exc:
print(f"[VASM] Invalid annotation config: {exc}")
self._annotation_config = config
self._annotation_loaded = False
print("[VASM] No valid annotation config found; inference disabled until annotated via Galaxy")
return True
def _connect_camera(self):
if self.client is not None and self.client.is_connected():
return True
try:
available = self.VisionIpcClient.available_streams("camerad", block=False)
except Exception:
available = []
if self.stream_type not in available:
return False
if self.client is None:
self.client = self.VisionIpcClient("camerad", self.stream_type, True)
if not self.client.is_connected():
self.client.connect(True)
return self.client.is_connected()
def _update_inactive(self, reset_inference=False):
if reset_inference:
self.inference.reset_state()
if self._last_update_at != 0.0 or self._last_pub_left or self._last_pub_right or self._last_pub_left_conf != 0.0 or self._last_pub_right_conf != 0.0:
self._publish(False, False, 0.0, 0.0, 0, force=True, updated_at=0.0)
def _inference_interval(self, now):
in_followup = now < self.followup_until
base = FOLLOWUP_INTERVAL if in_followup else BASE_INTERVAL
cpu_usage = list(self.sm["deviceState"].cpuUsagePercent) if self.sm.valid.get("deviceState", False) else []
factor = device_cpu_throttle_factor(cpu_usage, name="VASM")
self._throttle_factor = factor
interval = base * factor
self._throttle_reason = f"cpu_{factor:.1f}x" if factor > 1.05 else ("followup" if in_followup else "steady")
return interval
def _update_core_affinity(self, now):
other_running = self._slv_enabled
if other_running != self._prev_other_running:
self._affinity_set = False
self._prev_other_running = other_running
if not self._affinity_set:
set_core_affinity(V_ASM_AFFINITY_CORES if other_running else V_ASM_SOLO_AFFINITY_CORES)
self._affinity_set = True
def run(self):
rk = Ratekeeper(10, None)
while True:
try:
now = time.monotonic()
self._maybe_refresh_params(now)
self.sm.update(0)
if not PC:
self._update_core_affinity(now)
onroad = self.sm["deviceState"].started if self.sm.valid.get("deviceState", False) else False
parked = self.sm["starpilotCarState"].isParked if self.sm.valid.get("starpilotCarState", False) else False
if not onroad or not self._enabled or not self.inference.valid or not self._annotation_loaded:
self._update_inactive(reset_inference=(onroad != self.onroad_prev))
if onroad != self.onroad_prev:
self.last_inference_at = 0.0
self.onroad_prev = onroad
if now - self._last_status_log >= STATUS_LOG_INTERVAL and not onroad:
cpu = list(self.sm["deviceState"].cpuUsagePercent) if self.sm.valid.get("deviceState", False) else []
cpu_str = f"avg={sum(cpu)/len(cpu):.0f}% cores={','.join(f'{c:.0f}' for c in cpu)}" if cpu else "?"
status = f"[VASM] idle | {cpu_str} | onroad={onroad} parked={parked}"
status += f" enabled={self._enabled} model={self.inference.valid} ann={self._annotation_loaded}"
print(status)
self._last_status_log = now
rk.keep_time()
continue
self.onroad_prev = onroad
if not self._connect_camera():
self._update_inactive(reset_inference=True)
rk.keep_time()
continue
inference_interval = self._inference_interval(now)
if self.last_inference_at != 0.0 and (now - self.last_inference_at < inference_interval - 0.015):
self._publish(self.inference.left_active, self.inference.right_active,
self.inference.left_confidence, self.inference.right_confidence,
int(self.client.timestamp_sof))
rk.keep_time()
continue
buffer = None
while True:
b = self.client.recv(timeout_ms=0)
if b is None:
break
buffer = b
if buffer is None:
rk.keep_time()
continue
configured_sides = self.inference.configured_sides
if not configured_sides:
self._update_inactive(reset_inference=True)
rk.keep_time()
continue
if self.current_side not in configured_sides:
self.current_side = configured_sides[0]
last_side_time = self.last_inference_at_side[self.current_side]
dt = (now - last_side_time) if last_side_time != 0.0 else inference_interval
self.last_inference_at = now
self.last_inference_at_side[self.current_side] = now
image = np.frombuffer(buffer.data, dtype=np.uint8).reshape(
(len(buffer.data) // self.client.stride, self.client.stride)
)
if self.client.stride != self.client.width:
image = image[:, :self.client.width]
l_active, r_active = self.inference.update(
image,
self.client.width,
self.client.height,
dt=dt,
conf_thresh=self._conf_thresh,
smooth_sec=self._smooth_sec,
side_to_infer=self.current_side,
)
self._inference_count += 1
if now - self._last_status_log >= STATUS_LOG_INTERVAL:
self._last_status_log = now
cpu = list(self.sm["deviceState"].cpuUsagePercent) if self.sm.valid.get("deviceState", False) else []
cpu_str = f"avg={sum(cpu)/len(cpu):.0f}% cores={','.join(f'{c:.0f}' for c in cpu)}" if cpu else "?"
status = f"[VASM] {self._inference_count} inf {self._throttle_reason} | {cpu_str}"
status += f" | factor={self._throttle_factor:.1f}x | L={self.inference.left_confidence:.3f}"
status += f" R={self.inference.right_confidence:.3f}"
print(status)
self._inference_count = 0
self._publish(l_active, r_active, self.inference.left_confidence,
self.inference.right_confidence, int(self.client.timestamp_sof), updated_at=now)
if l_active or r_active:
self.followup_until = now + FOLLOWUP_WINDOW
side_index = configured_sides.index(self.current_side)
self.current_side = configured_sides[(side_index + 1) % len(configured_sides)]
rk.keep_time()
except Exception as e:
print(f"VASM Daemon Error: {e}")
self._update_inactive(reset_inference=True)
time.sleep(1.0)
def _publish(self, left_active, right_active, left_conf, right_conf, ts_sof, force=False, updated_at=None):
if updated_at is not None:
self._last_update_at = updated_at
self.params_memory.put("VASMLastUpdateMonoTime", str(updated_at))
r_left_conf = round(left_conf, 3)
r_right_conf = round(right_conf, 3)
if not force and \
left_active == self._last_pub_left and \
right_active == self._last_pub_right and \
abs(r_left_conf - self._last_pub_left_conf) < 0.005 and \
abs(r_right_conf - self._last_pub_right_conf) < 0.005:
return
self._last_pub_left = left_active
self._last_pub_right = right_active
self._last_pub_left_conf = r_left_conf
self._last_pub_right_conf = r_right_conf
ui_left_active, ui_right_active = right_active, left_active
ui_left_conf, ui_right_conf = right_conf, left_conf
self.params_memory.put("VASMLeftActive", "1" if ui_left_active else "0")
self.params_memory.put("VASMRightActive", "1" if ui_right_active else "0")
self.params_memory.put("VASMLeftConfidence", str(ui_left_conf))
self.params_memory.put("VASMRightConfidence", str(ui_right_conf))
self.params_memory.put("VASMTimestampEof", str(ts_sof))
def main():
cv2.setNumThreads(1)
VASMDaemon().run()
if __name__ == "__main__":
main()
@@ -0,0 +1,190 @@
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
_ASSETS = Path(__file__).resolve().parents[1] / "assets" / "vision_models"
# The bundled export keeps 299 final candidates so OpenCV's TopK importer can load it.
V_ASM_MODEL_PATH = _ASSETS / "v_asm_model.onnx"
MODEL_INPUT_H = 256
MODEL_INPUT_W = 352
HYSTERESIS_ON = 0.65
HYSTERESIS_OFF = 0.25
class VASMInference:
def __init__(self, model_path: Path):
self.model_path = model_path
self.net = None
self._valid = False
self.last_error = ""
self.reset_state()
self.frame_res = (0, 0)
self.config_width = 0
self.config_height = 0
self.masks = {"left": None, "right": None}
self.bboxes = {"left": None, "right": None, "left_raw": None, "right_raw": None}
def load(self) -> bool:
if not self.model_path.is_file():
self.last_error = f"Missing model: {self.model_path}"
self._valid = False
return False
try:
self.net = cv2.dnn.readNetFromONNX(str(self.model_path))
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
self._valid = True
self.last_error = ""
except Exception as e:
self.last_error = f"Failed to load model: {e}"
self._valid = False
return self._valid
def reset_state(self):
self._l_score = 0.0
self._r_score = 0.0
self.left_active = False
self.right_active = False
self.left_confidence = 0.0
self.right_confidence = 0.0
@property
def valid(self):
return self._valid
@property
def configured_sides(self):
return tuple(side for side in ("left", "right") if self.bboxes.get(f"{side}_raw") is not None)
def _prepare_geometry(self, h, w):
if (h, w) == self.frame_res:
return
self.frame_res = (h, w)
scale_x = w / float(self.config_width) if self.config_width > 0 else 1.0
scale_y = h / float(self.config_height) if self.config_height > 0 else 1.0
for side in ("left", "right"):
raw_pts = self.bboxes.get(f"{side}_raw")
if raw_pts is None:
self.bboxes[side] = None
self.masks[side] = None
continue
pts = raw_pts.copy()
pts[:, 0] *= scale_x
pts[:, 1] *= scale_y
bx, by, bw, bh = cv2.boundingRect(pts.astype(np.int32))
bx = (bx // 2) * 2
by = (by // 2) * 2
bw = ((bw + 1) // 2) * 2
bh = ((bh + 1) // 2) * 2
bx = max(0, min(bx, w - 2))
by = max(0, min(by, h - 2))
bw = max(2, min(bw, w - bx))
bh = max(2, min(bh, h - by))
bw = (bw // 2) * 2
bh = (bh // 2) * 2
self.bboxes[side] = (bx, by, bw, bh)
mask = np.zeros((bh, bw), dtype=np.uint8)
cv2.fillPoly(mask, [pts.astype(np.int32) - [bx, by]], 255)
self.masks[side] = mask
def load_config(self, config: dict):
self.frame_res = (0, 0)
self.config_width = config.get("width", 0)
self.config_height = config.get("height", 0)
for side in ("left", "right"):
poly = config.get(f"poly_{side}", [])
if len(poly) >= 3:
self.bboxes[f"{side}_raw"] = np.array(poly, dtype=np.float32)
else:
self.bboxes[f"{side}_raw"] = None
self.bboxes[side] = None
def _run_inference(self, raw_image, height, side):
bbox = self.bboxes[side]
if bbox is None or self.net is None:
return 0.0
x, y, w, h = bbox
# Slice NV12 directly
y_crop = raw_image[y: y + h, x: x + w]
uv_crop = raw_image[height + y // 2: height + (y + h) // 2, x: x + w]
nv12_crop = np.vstack([y_crop, uv_crop])
# Convert cropped area directly from YUV NV12 to RGB (1-step, avoids double conversion)
crop_rgb = cv2.cvtColor(nv12_crop, cv2.COLOR_YUV2RGB_NV12)
if self.masks[side] is not None:
crop_rgb = cv2.bitwise_and(crop_rgb, crop_rgb, mask=self.masks[side])
# Preprocess -> NCHW Float32 [0.0 - 1.0]
resized = cv2.resize(crop_rgb, (MODEL_INPUT_W, MODEL_INPUT_H), interpolation=cv2.INTER_LINEAR)
blob = resized.astype(np.float32) / 255.0
blob = np.transpose(blob, (2, 0, 1))
blob = np.expand_dims(blob, axis=0)
self.net.setInput(blob)
out = self.net.forward()
preds = np.squeeze(out)
if preds.ndim == 2:
if preds.shape[0] < preds.shape[1]:
preds = preds.T
if preds.shape[1] >= 6:
is_class_0 = (np.round(preds[:, 5]).astype(int) == 0)
relevant = preds[is_class_0]
if len(relevant) == 0:
return 0.0
return float(np.max(relevant[:, 4]))
elif preds.shape[1] >= 5:
return float(np.max(preds[:, 4]))
else:
return float(np.max(preds[:, 0]))
elif preds.ndim == 1 and preds.size > 0:
return float(np.max(preds))
return 0.0
def update(self, raw_image, width, height, dt, conf_thresh, smooth_sec, side_to_infer):
if not self._valid:
return False, False
self._prepare_geometry(height, width)
alpha = min(1.0, dt / max(smooth_sec, 0.001))
raw_conf = self._run_inference(raw_image, height, side_to_infer)
if side_to_infer == "left":
if raw_conf >= conf_thresh:
self._l_score = min(1.0, self._l_score + alpha)
else:
self._l_score = max(0.0, self._l_score - alpha)
self.left_confidence = raw_conf
if self._l_score >= HYSTERESIS_ON:
self.left_active = True
elif self._l_score <= HYSTERESIS_OFF:
self.left_active = False
else:
if raw_conf >= conf_thresh:
self._r_score = min(1.0, self._r_score + alpha)
else:
self._r_score = max(0.0, self._r_score - alpha)
self.right_confidence = raw_conf
if self._r_score >= HYSTERESIS_ON:
self.right_active = True
elif self._r_score <= HYSTERESIS_OFF:
self.right_active = False
return self.left_active, self.right_active
+65 -6
View File
@@ -15,6 +15,7 @@ import numpy as np
from openpilot.common.constants import CV
from openpilot.common.realtime import set_core_affinity
from openpilot.starpilot.common.cpu_throttle import device_cpu_throttle_factor
from openpilot.system.hardware import PC
RUNTIME_LOOP_HZ = 20
@@ -237,6 +238,13 @@ DEBUG_RUNTIME_STATUS_PATH = DEBUG_BASE_DIR / "runtime_status.json"
DEBUG_CAPTURE_DIRNAME = "captures"
SNAPSHOT_JPEG_QUALITY = 85
SPEED_LIMIT_VISION_AFFINITY_CORES = [0, 1, 2]
SPEED_LIMIT_VISION_COEXISTENCE_AFFINITY_CORES = [0, 1]
COEXISTENCE_PARAM_REFRESH_SECONDS = 2.0
COEXISTENCE_TRACK_DETECTOR_INTERVAL = 0.80
COEXISTENCE_DETECTOR_CLASSIFIER_EXPANSIONS = (
(0.00, 0.00, 0.00, 0.00, 1.10),
(0.10, 0.06, 0.10, 0.12, 1.00),
)
def device_cpu_usage_busy(cpu_usage):
@@ -380,6 +388,11 @@ class SpeedLimitVisionDaemon:
self.last_inference_interval = INFERENCE_INTERVAL
self.last_inference_interval_reason = "steady"
self.last_cpu_busy = False
self.coexistence_mode = False
self.last_coexistence_param_refresh_at = -float("inf")
self.temporal_tracking_enabled = TEMPORAL_TRACKING_ENABLED
self.track_detector_interval = TRACK_DETECTOR_INTERVAL
self.detector_classifier_expansions = DETECTOR_CLASSIFIER_EXPANSIONS
self.last_frame_process_duration_s = 0.0
self.last_detector_forward_count = 0
self.last_detector_forward_duration_s = 0.0
@@ -838,6 +851,32 @@ class SpeedLimitVisionDaemon:
return False
return device_cpu_usage_busy(self.sm["deviceState"].cpuUsagePercent)
def _update_coexistence_mode(self, now):
if self.params is None or now - self.last_coexistence_param_refresh_at < COEXISTENCE_PARAM_REFRESH_SECONDS:
return
self.last_coexistence_param_refresh_at = now
coexistence_mode = self.params.get_bool("VASMEnabled")
if coexistence_mode == self.coexistence_mode:
return
self.coexistence_mode = coexistence_mode
self.latest_detector_proposal = None
self._clear_proposal_track()
if coexistence_mode:
self.temporal_tracking_enabled = True
self.track_detector_interval = COEXISTENCE_TRACK_DETECTOR_INTERVAL
self.detector_classifier_expansions = COEXISTENCE_DETECTOR_CLASSIFIER_EXPANSIONS
affinity_cores = SPEED_LIMIT_VISION_COEXISTENCE_AFFINITY_CORES
else:
self.temporal_tracking_enabled = TEMPORAL_TRACKING_ENABLED
self.track_detector_interval = TRACK_DETECTOR_INTERVAL
self.detector_classifier_expansions = DETECTOR_CLASSIFIER_EXPANSIONS
affinity_cores = SPEED_LIMIT_VISION_AFFINITY_CORES
if not PC:
set_core_affinity(affinity_cores)
def _inference_interval(self, now):
in_followup = now < self.followup_until
interval = FOLLOWUP_INFERENCE_INTERVAL if in_followup else INFERENCE_INTERVAL
@@ -846,6 +885,13 @@ class SpeedLimitVisionDaemon:
if now - self.last_live_pose_inputs_not_ok_at < LIVE_POSE_RECOVERY_THROTTLE_SECONDS:
interval = max(interval, LIVE_POSE_RECOVERY_INFERENCE_INTERVAL)
reason = "live_pose_recovery"
elif self.coexistence_mode:
cpu_usage = list(self.sm["deviceState"].cpuUsagePercent) if self.sm is not None and self.sm.valid.get("deviceState", False) else []
factor = device_cpu_throttle_factor(cpu_usage, name="SpeedLimit")
if factor > 1.05:
self.last_cpu_busy = True
interval *= factor
reason = f"cpu_{factor:.1f}x"
elif self._device_cpu_busy():
self.last_cpu_busy = True
interval = max(interval, BUSY_INFERENCE_INTERVAL)
@@ -1145,7 +1191,7 @@ class SpeedLimitVisionDaemon:
def _remember_detector_proposal(self, confidence, class_id, bbox, speed_limit_mph=0, preferred=False):
min_confidence = TRACK_MIN_PROPOSAL_CONFIDENCE if speed_limit_mph else TRACK_UNREADABLE_MIN_PROPOSAL_CONFIDENCE
if not TEMPORAL_TRACKING_ENABLED or class_id == 1 or confidence < min_confidence:
if not getattr(self, "temporal_tracking_enabled", TEMPORAL_TRACKING_ENABLED) or class_id == 1 or confidence < min_confidence:
return
proposal = DetectorProposal(float(confidence), int(class_id), bbox, int(speed_limit_mph))
latest_proposal = getattr(self, "latest_detector_proposal", None)
@@ -1156,7 +1202,7 @@ class SpeedLimitVisionDaemon:
proposal = self.latest_detector_proposal
self.latest_detector_proposal = None
if (
not TEMPORAL_TRACKING_ENABLED or
not getattr(self, "temporal_tracking_enabled", TEMPORAL_TRACKING_ENABLED) or
proposal is None or
(proposal.speed_limit_mph and not TRACK_CONFIRMED_PROPOSALS_ENABLED)
):
@@ -1187,7 +1233,11 @@ class SpeedLimitVisionDaemon:
interval = TRACK_CLASSIFICATION_INTERVAL
if now - self.last_live_pose_inputs_not_ok_at < LIVE_POSE_RECOVERY_THROTTLE_SECONDS:
return max(interval, LIVE_POSE_RECOVERY_INFERENCE_INTERVAL)
if self._device_cpu_busy():
if self.coexistence_mode:
cpu_usage = list(self.sm["deviceState"].cpuUsagePercent) if self.sm is not None and self.sm.valid.get("deviceState", False) else []
if device_cpu_throttle_factor(cpu_usage, name="SpeedLimit") > 1.05:
return max(interval, TRACK_BUSY_CLASSIFICATION_INTERVAL)
elif self._device_cpu_busy():
return max(interval, TRACK_BUSY_CLASSIFICATION_INTERVAL)
return interval
@@ -1200,6 +1250,13 @@ class SpeedLimitVisionDaemon:
return False
return now - track.last_classified_at >= self._track_classification_interval(now)
def _detector_interval(self, inference_interval):
if self.coexistence_mode:
return max(inference_interval, self.track_detector_interval)
if self.proposal_track is not None:
return max(inference_interval, TRACK_DETECTOR_INTERVAL)
return inference_interval
def _classify_proposal_track(self, frame_bgr, now):
track = self.proposal_track
if track is None:
@@ -1748,7 +1805,8 @@ class SpeedLimitVisionDaemon:
speed_direct_model_support: dict[int, int] = {}
speed_strong_model_support: dict[int, int] = {}
for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in DETECTOR_CLASSIFIER_EXPANSIONS:
expansions = getattr(self, "detector_classifier_expansions", DETECTOR_CLASSIFIER_EXPANSIONS)
for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in expansions:
expanded_x1 = max(int(x1 - box_width * expand_left), 0)
expanded_y1 = max(int(y1 - box_height * expand_top), 0)
expanded_x2 = min(int(x2 + box_width * expand_right), frame_width)
@@ -2541,13 +2599,14 @@ class SpeedLimitVisionDaemon:
ratekeeper.keep_time()
continue
self._update_coexistence_mode(now)
inference_interval = self._inference_interval(now)
track_due = self._track_classification_due(now)
detector_interval = max(inference_interval, TRACK_DETECTOR_INTERVAL) if self.proposal_track is not None else inference_interval
detector_interval = self._detector_interval(inference_interval)
detector_due = now - self.last_inference_at >= detector_interval
if not track_due and not detector_due:
self.interval_skip_count += 1
if self.last_inference_interval_reason == "cpu_busy":
if self.last_cpu_busy:
self.busy_skip_count += 1
stale_cleared = self._clear_published_detection_if_stale(now, "inference_interval")
if self.published_speed_limit_mph > 0 and not stale_cleared:
@@ -0,0 +1,107 @@
from pathlib import Path
import numpy as np
from starpilot.system.adj_spot_monitor_vision import VASMDaemon
from starpilot.system.adj_spot_monitor_vision_inference import MODEL_INPUT_H, MODEL_INPUT_W, V_ASM_MODEL_PATH, VASMInference
class FakeParams:
def __init__(self, config=None):
self.config = config or {}
def get(self, key):
assert key == "VASMAnnotationConfig"
return self.config
class FakeMemoryParams:
def __init__(self):
self.values = {}
def put(self, key, value):
self.values[key] = value
class FakeInference:
def __init__(self):
self.loaded = []
self.reset_count = 0
def load_config(self, config):
self.loaded.append(config)
def reset_state(self):
self.reset_count += 1
def test_inference_geometry_supports_single_annotated_side():
inference = VASMInference(Path("unused.onnx"))
inference.load_config({
"width": 200,
"height": 100,
"poly_left": [[10, 10], [80, 10], [80, 80], [10, 80]],
"poly_right": [],
})
inference._prepare_geometry(100, 200)
assert inference.configured_sides == ("left",)
assert inference.bboxes["left"] is not None
assert inference.bboxes["right"] is None
def test_model_loads_with_repo_inference_backend():
inference = VASMInference(V_ASM_MODEL_PATH)
assert inference.load(), inference.last_error
inference.net.setInput(np.zeros((1, 3, MODEL_INPUT_H, MODEL_INPUT_W), dtype=np.float32))
assert inference.net.forward().shape == (1, 299, 6)
def test_model_runs_from_nv12_camera_frame():
inference = VASMInference(V_ASM_MODEL_PATH)
assert inference.load(), inference.last_error
inference.load_config({
"width": MODEL_INPUT_W,
"height": MODEL_INPUT_H,
"poly_left": [[0, 0], [MODEL_INPUT_W, 0], [MODEL_INPUT_W, MODEL_INPUT_H], [0, MODEL_INPUT_H]],
"poly_right": [],
})
nv12 = np.zeros((MODEL_INPUT_H * 3 // 2, MODEL_INPUT_W), dtype=np.uint8)
assert inference.update(nv12, MODEL_INPUT_W, MODEL_INPUT_H, 0.5, 1.0, 0.2, "left") == (False, False)
def test_annotation_changes_reload_without_process_restart():
first = {"width": 200, "height": 100, "poly_left": [[1, 1], [10, 1], [10, 10]], "poly_right": []}
second = {"width": 200, "height": 100, "poly_left": [], "poly_right": [[20, 1], [30, 1], [30, 10]]}
daemon = VASMDaemon.__new__(VASMDaemon)
daemon.params = FakeParams(first)
daemon.inference = FakeInference()
daemon._annotation_config = object()
daemon._annotation_loaded = False
assert daemon._load_annotation_config()
assert not daemon._load_annotation_config()
daemon.params.config = second
assert daemon._load_annotation_config()
assert daemon.inference.loaded == [first, second]
def test_publish_writes_freshness_and_maps_camera_sides_to_ui_sides():
daemon = VASMDaemon.__new__(VASMDaemon)
daemon.params_memory = FakeMemoryParams()
daemon._last_pub_left = False
daemon._last_pub_right = False
daemon._last_pub_left_conf = -1.0
daemon._last_pub_right_conf = -1.0
daemon._last_update_at = 0.0
daemon._publish(True, False, 0.9, 0.1, 123, updated_at=50.0)
assert daemon.params_memory.values["VASMLastUpdateMonoTime"] == "50.0"
assert daemon.params_memory.values["VASMLeftActive"] == "0"
assert daemon.params_memory.values["VASMRightActive"] == "1"
@@ -34,6 +34,15 @@ class StaticClassifierNet:
return self.probabilities
class ToggleParams:
def __init__(self, enabled):
self.enabled = enabled
def get_bool(self, key):
assert key == "VASMEnabled"
return self.enabled
def daemon_with_history(current_speed, entries):
daemon = SpeedLimitVisionDaemon.__new__(SpeedLimitVisionDaemon)
daemon.published_speed_limit_mph = current_speed
@@ -71,6 +80,37 @@ def test_disconnect_camera_releases_client_state():
assert daemon.stream_name == ""
def test_vasm_coexistence_mode_is_conditional(monkeypatch):
daemon = SpeedLimitVisionDaemon.__new__(SpeedLimitVisionDaemon)
daemon.params = ToggleParams(False)
daemon.coexistence_mode = False
daemon.last_coexistence_param_refresh_at = -float("inf")
daemon.temporal_tracking_enabled = slv.TEMPORAL_TRACKING_ENABLED
daemon.track_detector_interval = slv.TRACK_DETECTOR_INTERVAL
daemon.detector_classifier_expansions = slv.DETECTOR_CLASSIFIER_EXPANSIONS
daemon.latest_detector_proposal = None
daemon.proposal_track = None
monkeypatch.setattr(slv, "PC", True)
daemon._update_coexistence_mode(0.0)
assert not daemon.coexistence_mode
assert daemon.detector_classifier_expansions == slv.DETECTOR_CLASSIFIER_EXPANSIONS
assert daemon._detector_interval(slv.INFERENCE_INTERVAL) == slv.INFERENCE_INTERVAL
daemon.params.enabled = True
daemon._update_coexistence_mode(slv.COEXISTENCE_PARAM_REFRESH_SECONDS + 0.1)
assert daemon.coexistence_mode
assert daemon.temporal_tracking_enabled
assert daemon.detector_classifier_expansions == slv.COEXISTENCE_DETECTOR_CLASSIFIER_EXPANSIONS
assert daemon._detector_interval(slv.INFERENCE_INTERVAL) == slv.COEXISTENCE_TRACK_DETECTOR_INTERVAL
daemon.params.enabled = False
daemon._update_coexistence_mode(2 * slv.COEXISTENCE_PARAM_REFRESH_SECONDS + 0.2)
assert not daemon.coexistence_mode
assert daemon.temporal_tracking_enabled == slv.TEMPORAL_TRACKING_ENABLED
assert daemon.detector_classifier_expansions == slv.DETECTOR_CLASSIFIER_EXPANSIONS
def test_receive_frame_does_not_retain_vision_buffer(monkeypatch):
buffer_refs = []
@@ -23,6 +23,7 @@ import { Tuning } from "/assets/components/tools/tuning.js?v=flm-workspace-9"
import { Troubleshoot } from "/assets/components/tools/troubleshoot.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
import { VASMAnnotations } from "/assets/components/tools/v_asm.js"
import { UpdateManager } from "/assets/components/tools/update_manager.js"
let router, routerState
@@ -84,6 +85,7 @@ function Root() {
createRoute("toggles", "/manage_toggles", ToggleControl),
createRoute("updates", "/manage_updates", UpdateManager),
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
createRoute("v_asm", "/manage_v_asm", VASMAnnotations),
]
router = createRouter({
@@ -23,6 +23,7 @@ const MENU_ITEMS = {
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Troubleshoot", link: "/troubleshoot", icon: "bi-tools" },
{ name: "V-ASM Annotations", link: "/manage_v_asm", icon: "bi-eye" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
{ name: "Backup and Restore", link: "/manage_toggles", icon: "bi-arrow-repeat" },
@@ -1180,6 +1180,12 @@ function getSettingLockReason(param) {
if (param?.disabled_when_key_true && state.values[param.disabled_when_key_true]) {
return param.disabled_reason || "Disabled by another setting."
}
if (param?.requires_nonempty_key) {
const val = state.values[param.requires_nonempty_key]
if (!val || val === "{}" || val === "") {
return param.disabled_reason || "Required configuration missing."
}
}
return ""
}
@@ -1530,6 +1536,7 @@ function renderSettingRow(p) {
type="checkbox"
class="ds-toggle"
id="ds-${p.key}"
disabled="${() => isLocked()}"
@change="${() => updateParam(p.key, "checkbox")}" />
`
}
@@ -314,6 +314,43 @@
"step": 0.1,
"parent_key": "QOLLateral",
"settings_tier": "simple"
},
{
"key": "VASMEnabled",
"label": "Enable V-ASM",
"description": "Camera-based adjacent spot monitoring using the driver camera. Supplements factory blind spot monitoring.",
"data_type": "bool",
"ui_type": "toggle",
"is_parent_toggle": true,
"requires_nonempty_key": "VASMAnnotationConfig",
"disabled_reason": "Configure window annotations first in Galaxy > V-ASM Annotations",
"settings_tier": "advanced"
},
{
"key": "VASMConfidenceThreshold",
"label": "Confidence Threshold",
"description": "Minimum vehicle-detection confidence (0.25-1.00, default 0.85). Higher values reduce false positives but may miss detections.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.25,
"max": 1.00,
"step": 0.05,
"precision": 2,
"parent_key": "VASMEnabled",
"settings_tier": "advanced"
},
{
"key": "VASMSmoothSeconds",
"label": "Smoothing Duration",
"description": "Temporal smoothing duration (0.1-0.5 seconds, default 0.2). Higher values reduce flicker but add latency.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.1,
"max": 0.5,
"step": 0.1,
"precision": 1,
"parent_key": "VASMEnabled",
"settings_tier": "advanced"
}
]
},
@@ -0,0 +1,337 @@
.v-asm-wrapper {
display: flex;
flex-direction: column;
gap: var(--gap-base);
}
.v-asm-note {
background: rgba(13, 110, 253, 0.08);
border-left: 4px solid #0d6efd;
padding: 12px 16px;
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
margin: 12px 0;
color: var(--text-muted);
}
.v-asm-header {
margin-bottom: var(--margin-base);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.v-asm-header h2 {
margin: 0 0 var(--margin-xs);
font-size: var(--font-size-xl);
}
.v-asm-card {
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
padding: var(--padding-base);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.v-asm-card-info {
border-left: 4px solid var(--main-fg);
}
.v-asm-card-warning {
border-left: 4px solid #fd7e14;
}
.v-asm-card-danger {
border-left: 4px solid var(--danger-fg);
}
.v-asm-card-title {
color: var(--text-color);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
}
.v-asm-card-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
}
.v-asm-card-list li {
color: var(--text-muted);
font-size: var(--font-size-sm);
padding-left: 1.2rem;
position: relative;
line-height: 1.4;
}
.v-asm-card-list li::before {
content: "-";
position: absolute;
left: 0;
color: var(--text-muted);
}
.v-asm-card-list li.v-asm-card-action {
border-top: 1px solid var(--sidebar-border-color);
margin-top: var(--margin-xs);
padding-top: var(--padding-xs);
padding-left: 0;
list-style-type: none;
}
.v-asm-card-list li.v-asm-card-action::before {
content: "";
}
.v-asm-card-list a {
color: var(--main-fg);
text-decoration: none;
font-weight: var(--font-weight-demi-bold);
}
.v-asm-card-list a:hover {
text-decoration: underline;
}
.v-asm-section {
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: var(--padding-lg);
margin-bottom: var(--margin-lg);
}
.v-asm-section h3 {
margin: 0 0 var(--margin-base);
font-size: var(--font-size-lg);
}
.v-asm-desc {
color: var(--text-muted);
font-size: var(--font-size-sm);
margin: 4px 0 var(--margin-base);
}
.v-asm-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--gap-sm);
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
padding: var(--padding-sm) var(--padding-base);
margin-bottom: var(--margin-base);
}
.v-asm-btn-group {
display: flex;
flex-wrap: wrap;
gap: var(--gap-xs);
}
.v-asm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--padding-sm) var(--padding-base);
background: linear-gradient(135deg, #7a62b8, #8b6cc5);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-demi-bold);
cursor: pointer;
transition: opacity var(--transition-fast);
height: 32px;
}
.v-asm-btn:hover {
opacity: var(--hover-opacity);
}
.v-asm-btn:disabled {
opacity: var(--disabled-opacity);
cursor: not-allowed;
}
.v-asm-btn-primary {
background: linear-gradient(135deg, #3a9e5c, #28a745);
}
.v-asm-btn-secondary {
background: linear-gradient(135deg, #495057, #6c757d);
}
.v-asm-btn-danger {
background: linear-gradient(135deg, #b14a6b, #dc3545);
}
.v-asm-btn-outline-left {
background: transparent;
border: 1px solid #0d6efd;
color: #0d6efd;
}
.v-asm-btn-outline-left:hover {
background: rgba(13, 110, 253, 0.1);
}
.v-asm-btn-left-active {
background: #0d6efd;
border: 1px solid #0d6efd;
color: #fff;
outline: 2px solid #fff;
outline-offset: 2px;
}
.v-asm-btn-outline-right {
background: transparent;
border: 1px solid #fd7e14;
color: #fd7e14;
}
.v-asm-btn-outline-right:hover {
background: rgba(253, 126, 20, 0.1);
}
.v-asm-btn-right-active {
background: #fd7e14;
border: 1px solid #fd7e14;
color: #fff;
outline: 2px solid #fff;
outline-offset: 2px;
}
.v-asm-points-summary {
display: flex;
gap: var(--gap-xs);
align-items: center;
}
.v-asm-summary-badge {
display: flex;
align-items: center;
gap: var(--gap-xs);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
padding: 4px 10px;
border-radius: 12px;
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
}
.v-asm-summary-badge.badge-left {
border-color: rgba(13, 110, 253, 0.4);
color: #0d6efd;
}
.v-asm-summary-badge.badge-right {
border-color: rgba(253, 126, 20, 0.4);
color: #fd7e14;
}
.v-asm-summary-badge .badge-done {
color: var(--success-fg);
}
.v-asm-mini-clear {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 14px;
padding: 0 0 0 4px;
line-height: 1;
}
.v-asm-mini-clear:hover {
opacity: 0.7;
}
.v-asm-error-banner, .v-asm-success-banner {
padding: var(--padding-sm) var(--padding-base);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
margin-bottom: var(--margin-sm);
border: 1px solid transparent;
}
.v-asm-error-banner {
background: rgba(217, 90, 123, 0.1);
border-color: var(--danger-fg);
color: var(--danger-fg);
}
.v-asm-success-banner {
background: rgba(40, 167, 69, 0.1);
border-color: var(--success-fg);
color: var(--success-fg);
}
.v-asm-success-banner a {
color: var(--main-fg);
text-decoration: underline;
}
.v-asm-canvas-wrapper {
position: relative;
margin: var(--margin-base) 0;
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
background: #000;
}
.v-asm-canvas-wrapper canvas {
display: block;
width: 100%;
height: auto;
cursor: crosshair;
}
.v-asm-mode-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
margin-bottom: var(--margin-xs);
gap: var(--gap-sm);
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
color: var(--text-muted);
}
.v-asm-mode-banner.v-asm-mode-left {
background: rgba(13, 110, 253, 0.15);
border-color: #0d6efd;
color: #0d6efd;
}
.v-asm-mode-banner.v-asm-mode-right {
background: rgba(253, 126, 20, 0.15);
border-color: #fd7e14;
color: #fd7e14;
}
.v-asm-mode-banner.v-asm-mode-idle {
background: rgba(255, 255, 255, 0.03);
border-color: var(--sidebar-border-color);
color: var(--text-muted);
}
.v-asm-mode-banner.v-asm-mode-banner-error {
background: rgba(217, 90, 123, 0.12);
border-color: var(--danger-fg);
flex-wrap: wrap;
}
.v-asm-mode-banner span:last-child:not(button) {
font-weight: var(--font-weight-normal);
font-size: var(--font-size-xs);
opacity: 0.8;
}
.v-asm-btn-retry {
background: var(--danger-fg);
padding: 4px 14px;
font-size: var(--font-size-xs);
flex-shrink: 0;
}
.v-asm-instructions {
text-align: center;
padding: var(--padding-xs);
font-size: var(--font-size-sm);
color: var(--text-muted);
background: var(--sidebar-bg);
border-radius: 0 0 var(--border-radius-md) var(--border-radius-md);
}
.v-asm-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--gap-sm);
margin: var(--margin-base) 0;
}
.v-asm-status-card {
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
padding: var(--padding-base);
text-align: center;
}
.v-asm-status-label {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: 4px;
}
.v-asm-status-value {
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
}
.v-asm-status-value.active {
color: var(--success-fg);
}
.v-asm-status-value.inactive {
color: var(--text-muted);
}
@@ -0,0 +1,572 @@
import { html, reactive } from "/assets/vendor/arrow-core.js";
const CANVAS_W = 640;
const CANVAS_H = 480;
let pollInterval = null;
let pollHasBeenActive = false;
let initialLoadTriggered = false;
const state = reactive({
loading: false,
error: "",
success: "",
annotating: null,
leftPoints: [],
rightPoints: [],
image: false,
configSaved: false,
configExists: false,
});
let _loadedImage = null;
let _lastCanvas = null;
let loadedConfig = null;
function polyCenter(pts) {
if (!pts || pts.length === 0) return null;
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length;
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length;
return [cx, cy];
}
function getCanvas() {
return document.getElementById("v-asm-canvas");
}
function redraw() {
const canvas = getCanvas();
if (!canvas) return;
if (canvas !== _lastCanvas) {
_lastCanvas = canvas;
if (_loadedImage) {
canvas._img = _loadedImage;
canvas.width = Math.min(_loadedImage.naturalWidth, 1280);
canvas.height = Math.round(canvas.width * (_loadedImage.naturalHeight / _loadedImage.naturalWidth));
} else {
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
}
}
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const img = _loadedImage;
if (img) {
canvas._img = img;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = "#222";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#888";
ctx.font = "16px monospace";
ctx.textAlign = "center";
ctx.fillText("Loading camera snapshot...", canvas.width / 2, canvas.height / 2);
return;
}
const sides = [
{ key: "left", points: state.leftPoints, fillColor: "rgba(13, 110, 253, 0.25)", borderColor: "#0d6efd", label: "LEFT" },
{ key: "right", points: state.rightPoints, fillColor: "rgba(253, 126, 20, 0.25)", borderColor: "#fd7e14", label: "RIGHT" },
];
for (const side of sides) {
if (side.points.length < 2) {
for (const pt of side.points) {
ctx.beginPath();
ctx.arc(pt[0], pt[1], 5, 0, Math.PI * 2);
ctx.fillStyle = side.borderColor;
ctx.fill();
}
continue;
}
ctx.beginPath();
ctx.moveTo(side.points[0][0], side.points[0][1]);
for (let i = 1; i < side.points.length; i++) {
ctx.lineTo(side.points[i][0], side.points[i][1]);
}
if (side.points.length >= 3) {
ctx.closePath();
ctx.fillStyle = side.fillColor;
ctx.fill();
}
ctx.strokeStyle = side.borderColor;
ctx.lineWidth = 2;
ctx.stroke();
for (const pt of side.points) {
ctx.beginPath();
ctx.arc(pt[0], pt[1], 4, 0, Math.PI * 2);
ctx.fillStyle = "#fff";
ctx.fill();
ctx.strokeStyle = side.borderColor;
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (side.points.length >= 3) {
const center = polyCenter(side.points);
if (center) {
ctx.fillStyle = "#fff";
ctx.font = "bold 14px monospace";
ctx.textAlign = "center";
ctx.fillText(side.label, center[0], center[1] + 5);
}
}
}
}
async function loadSnapshot() {
state.error = "";
state.success = "";
state.image = false;
let blobUrl = null;
try {
const resp = await fetch("/api/v_asm/snapshot");
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
throw new Error(payload.error || resp.statusText || "Failed to load snapshot");
}
let src;
const contentType = resp.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const data = await resp.json();
if (!data.jpeg) throw new Error("Snapshot missing image data");
src = `data:image/jpeg;base64,${data.jpeg}`;
} else {
const blob = await resp.blob();
blobUrl = URL.createObjectURL(blob);
src = blobUrl;
}
const img = new Image();
img.onload = () => {
_loadedImage = img;
const canvas = getCanvas();
if (canvas) {
canvas._img = img;
canvas.width = Math.min(img.naturalWidth, 1280);
canvas.height = Math.round(canvas.width * (img.naturalHeight / img.naturalWidth));
}
state.image = true;
state.success = "Camera snapshot loaded. Click a window button above to start annotating.";
applyConfigToCanvas();
requestAnimationFrame(redraw);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
};
img.onerror = () => {
state.image = false;
state.error = "Failed to decode image";
requestAnimationFrame(redraw);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
};
img.src = src;
} catch (e) {
state.image = false;
state.error = e.message;
requestAnimationFrame(redraw);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
}
}
function canvasClick(e) {
if (!state.annotating || !e || !state.image) return;
const canvas = getCanvas();
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = Math.round((e.clientX - rect.left) * (canvas.width / rect.width));
const y = Math.round((e.clientY - rect.top) * (canvas.height / rect.height));
if (x < 0 || y < 0) return;
const points = state.annotating === "left" ? [...state.leftPoints] : [...state.rightPoints];
if (points.length >= 3) {
const first = points[0];
const dist = Math.sqrt((x - first[0]) ** 2 + (y - first[1]) ** 2);
if (dist < 12) {
finishSide();
return;
}
}
points.push([x, y]);
if (state.annotating === "left") {
state.leftPoints = points;
} else {
state.rightPoints = points;
}
requestAnimationFrame(redraw);
}
function canvasRightClick(e) {
if (!state.annotating || !e) return;
e.preventDefault();
const pts = state.annotating === "left" ? [...state.leftPoints] : [...state.rightPoints];
if (!pts.length) return;
pts.pop();
if (state.annotating === "left") {
state.leftPoints = pts;
} else {
state.rightPoints = pts;
}
requestAnimationFrame(redraw);
}
function startAnnotate(e) {
const side = e?.currentTarget?.value || e?.target?.value;
if (!side) return;
state.annotating = side;
state.error = "";
state.success = "";
requestAnimationFrame(redraw);
}
function finishSide() {
if (!state.annotating) return;
const side = state.annotating;
const points = side === "left" ? state.leftPoints : state.rightPoints;
if (points.length < 3) {
state.error = "Need at least 3 points to define a region.";
return;
}
state.annotating = null;
state.success = `${side === "left" ? "Left" : "Right"} window annotated (${points.length} points)!`;
requestAnimationFrame(redraw);
}
function clearSide(side) {
if (side === "left") {
state.leftPoints = [];
} else {
state.rightPoints = [];
}
state.annotating = null;
requestAnimationFrame(redraw);
}
function clearAll() {
state.leftPoints = [];
state.rightPoints = [];
state.annotating = null;
state.configSaved = false;
requestAnimationFrame(redraw);
}
async function saveConfig() {
if (state.leftPoints.length < 3 && state.rightPoints.length < 3) {
state.error = "Annotate at least one window with 3+ points.";
return;
}
const canvas = getCanvas();
const img = _loadedImage;
const nativeW = img ? img.naturalWidth : 1920;
const nativeH = img ? img.naturalHeight : 1080;
const cw = canvas ? canvas.width : nativeW;
const ch = canvas ? canvas.height : nativeH;
function scalePoints(pts) {
return pts.map(([x, y]) => [Math.round(x * nativeW / cw), Math.round(y * nativeH / ch)]);
}
const config = {
width: nativeW,
height: nativeH,
};
if (state.leftPoints.length >= 3) {
config.poly_left = scalePoints(state.leftPoints);
} else {
config.poly_left = [];
}
if (state.rightPoints.length >= 3) {
config.poly_right = scalePoints(state.rightPoints);
} else {
config.poly_right = [];
}
state.loading = true;
state.error = "";
state.success = "";
try {
const resp = await fetch("/api/v_asm/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Failed to save");
state.configSaved = true;
state.configExists = true;
state.success = "Annotation config saved! V-ASM is now enabled.";
await loadExistingConfig();
} catch (e) {
state.error = e.message;
}
state.loading = false;
}
async function loadExistingConfig() {
try {
const resp = await fetch("/api/v_asm/config");
if (!resp.ok) return;
const config = await resp.json();
loadedConfig = config;
applyConfigToCanvas();
} catch (e) {
console.error("V-ASM config load failed", e);
}
}
function applyConfigToCanvas() {
const config = loadedConfig;
if (!config) {
state.leftPoints = [];
state.rightPoints = [];
state.configExists = false;
redraw();
return;
}
const canvas = getCanvas();
const cw = canvas ? canvas.width || CANVAS_W : CANVAS_W;
const ch = canvas ? canvas.height || CANVAS_H : CANVAS_H;
const nativeW = _loadedImage ? _loadedImage.naturalWidth : (config.width || 1920);
const nativeH = _loadedImage ? _loadedImage.naturalHeight : (config.height || 1080);
const scaleX = cw / nativeW;
const scaleY = ch / nativeH;
const left = Array.isArray(config.poly_left) && config.poly_left.length >= 3
? config.poly_left.map(([x, y]) => [Math.round(x * scaleX), Math.round(y * scaleY)])
: [];
const right = Array.isArray(config.poly_right) && config.poly_right.length >= 3
? config.poly_right.map(([x, y]) => [Math.round(x * scaleX), Math.round(y * scaleY)])
: [];
state.leftPoints = left;
state.rightPoints = right;
state.configExists = Boolean(left.length || right.length);
redraw();
}
async function deleteConfig() {
state.loading = true;
state.error = "";
state.success = "";
try {
const resp = await fetch("/api/v_asm/config", { method: "DELETE" });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Failed to delete");
state.leftPoints = [];
state.rightPoints = [];
state.annotating = null;
state.configSaved = false;
state.configExists = false;
loadedConfig = null;
state.success = "Annotation config cleared.";
requestAnimationFrame(redraw);
} catch (e) {
state.error = e.message;
}
state.loading = false;
}
function scheduleInitialLoad() {
if (!initialLoadTriggered) {
initialLoadTriggered = true;
loadExistingConfig();
loadSnapshot();
}
const attempt = () => {
if (getCanvas()) {
redraw();
return;
}
requestAnimationFrame(attempt);
};
requestAnimationFrame(attempt);
}
function retrySnapshot() {
state.error = "";
state.success = "";
loadSnapshot();
}
export function VASMAnnotations() {
const el = html`
<div class="v-asm-wrapper">
<div class="v-asm-section">
<div class="v-asm-header">
<h2>Vision Adjacent Spot Monitoring (V-ASM)</h2>
<div class="v-asm-card v-asm-card-info">
<div class="v-asm-card-title">About V-ASM</div>
<ul class="v-asm-card-list">
<li>Camera-based adjacent spot monitoring using the driver camera, works alongside factory blind spot monitoring or standalone</li>
<li>Annotate window areas so V-ASM knows where to look</li>
<li>Massive thanks to those who contributed to our total of 65GB of training data (over 36GB from your submissions). If performance is lacking, submit edge case routes via the repo form or give feedback in the StarPilot Discord.</li>
<li class="v-asm-card-action">Submit edge cases, learn about training pipeline, and data handling within the form at <a href="https://github.com/prabhaavp/vasm-op" target="_blank" rel="noopener noreferrer">github.com/prabhaavp/vasm-op</a></li>
</ul>
</div>
<div class="v-asm-card v-asm-card-danger">
<div class="v-asm-card-title">Tracing Guidelines</div>
<ul class="v-asm-card-list">
<li>Trace the visible glass (front and rear side windows on each side, as seen by the driver camera). Mask as much of the window area as possible.</li>
<li>Exclude A-pillars, door frames, and interior. Include the side mirror if visible through the glass.</li>
<li>The B-pillar is fine to include if needed for a continuous mask. Your head or body being in frame is fine (that is part of the training data). Be consistent left vs right.</li>
</ul>
</div>
</div>
<div class="v-asm-note">
V-ASM is like a friend saying, "Hol' up, I don't think you're clear." Always check before merging and be aware false positives/negatives are possible.
</div>
${state.error ? html`<div class="v-asm-error-banner">${state.error}</div>` : ""}
${state.success ? html`<div class="v-asm-success-banner">${state.success}</div>` : ""}
${state.configSaved ? html`
<div class="v-asm-success-banner">
Annotations saved! V-ASM is now enabled. Configure sensitivity in <a href="/device_settings/lateral-steering">Toggles -> Lateral</a>.
</div>
` : ""}
<div class="v-asm-toolbar">
<div class="v-asm-btn-group">
<button class="${state.annotating === "left" ? "v-asm-btn v-asm-btn-left-active" : "v-asm-btn v-asm-btn-outline-left"}"
@click="${startAnnotate}" value="left">
${state.annotating === "left" ? "Annotating Left..." : "Annotate Left"}
</button>
<button class="${state.annotating === "right" ? "v-asm-btn v-asm-btn-right-active" : "v-asm-btn v-asm-btn-outline-right"}"
@click="${startAnnotate}" value="right">
${state.annotating === "right" ? "Annotating Right..." : "Annotate Right"}
</button>
${state.annotating ? html`<button class="v-asm-btn v-asm-btn-primary" @click="${finishSide}">Finish ${state.annotating === "left" ? "Left" : "Right"}</button>` : ""}
<button class="v-asm-btn v-asm-btn-primary" @click="${saveConfig}" .disabled="${state.loading || (state.leftPoints.length < 3 && state.rightPoints.length < 3)}">
${state.loading ? "Saving..." : "Save Config"}
</button>
${state.configExists ? html`<button class="v-asm-btn v-asm-btn-danger" @click="${deleteConfig}" .disabled="${state.loading}">Delete Config</button>` : ""}
<button class="v-asm-btn v-asm-btn-secondary" @click="${clearAll}">Clear All</button>
</div>
<div class="v-asm-points-summary">
<div class="v-asm-summary-badge badge-left">
<span>Left: ${state.leftPoints.length} pt${state.leftPoints.length !== 1 ? "s" : ""}</span>
${state.leftPoints.length >= 3 ? html`<span class="badge-done">✔</span>` : ""}
${state.leftPoints.length > 0 ? html`<button class="v-asm-mini-clear" @click="${() => clearSide("left")}">×</button>` : ""}
</div>
<div class="v-asm-summary-badge badge-right">
<span>Right: ${state.rightPoints.length} pt${state.rightPoints.length !== 1 ? "s" : ""}</span>
${state.rightPoints.length >= 3 ? html`<span class="badge-done">✔</span>` : ""}
${state.rightPoints.length > 0 ? html`<button class="v-asm-mini-clear" @click="${() => clearSide("right")}">×</button>` : ""}
</div>
</div>
</div>
${state.annotating === "left" ? html`<div class="v-asm-mode-banner v-asm-mode-left"><span>⬅ Annotating Left Window</span><span>Click the canvas to place points around the visible glass</span></div>`
: state.annotating === "right" ? html`<div class="v-asm-mode-banner v-asm-mode-right"><span>➡ Annotating Right Window</span><span>Click the canvas to place points around the visible glass</span></div>`
: state.error ? html`<div class="v-asm-mode-banner v-asm-mode-banner-error"><span>Snapshot unavailable</span><span>${state.error}</span><button class="v-asm-btn v-asm-btn-retry" @click="${retrySnapshot}">Retry</button></div>`
: !state.image ? html`<div class="v-asm-mode-banner"><span>Loading camera snapshot...</span><span>The snapshot will load automatically</span></div>`
: html`<div class="v-asm-mode-banner v-asm-mode-idle"><span>Idle Mode</span><span>Click "Annotate" above to configure your active canvas boundaries</span></div>`}
<div class="v-asm-canvas-wrapper">
<canvas id="v-asm-canvas" @click="${canvasClick}" @contextmenu="${canvasRightClick}"></canvas>
<div class="v-asm-instructions">
${state.annotating ? "Click near the first point to close the polygon. Right-click to undo last point." : "Use the controls above to annotate each side's window area."}
</div>
</div>
</div>
<div class="v-asm-card v-asm-card-warning">
<div class="v-asm-card-title">Sensitivity Settings</div>
<ul class="v-asm-card-list">
<li>Confidence Threshold: minimum confidence for a detection (higher means fewer false positives)</li>
<li>Smoothing Duration: time constant for signal smoothing (higher means less flickering)</li>
<li class="v-asm-card-action">Adjust these in <a href="/device_settings/lateral-steering">Toggles -> Lateral</a></li>
</ul>
</div>
<div class="v-asm-section">
<h3>Current Status</h3>
<p class="v-asm-desc">Real-time V-ASM detection state from the daemon.</p>
<div class="v-asm-status-grid">
<div class="v-asm-status-card">
<div class="v-asm-status-label">Left Active</div>
<div class="v-asm-status-value" id="vasm-left-status">-</div>
</div>
<div class="v-asm-status-card">
<div class="v-asm-status-label">Right Active</div>
<div class="v-asm-status-value" id="vasm-right-status">-</div>
</div>
<div class="v-asm-status-card">
<div class="v-asm-status-label">Left Confidence</div>
<div class="v-asm-status-value" id="vasm-left-conf">-</div>
</div>
<div class="v-asm-status-card">
<div class="v-asm-status-label">Right Confidence</div>
<div class="v-asm-status-value" id="vasm-right-conf">-</div>
</div>
</div>
</div>
</div>
`;
scheduleInitialLoad();
if (!window.__vasmPollStarted) {
window.__vasmPollStarted = true;
pollInterval = setInterval(async () => {
try {
const elLeft = document.getElementById("vasm-left-status");
if (!elLeft) {
if (pollHasBeenActive) {
clearInterval(pollInterval);
window.__vasmPollStarted = false;
pollHasBeenActive = false;
}
return;
}
pollHasBeenActive = true;
const keys = ["VASMLeftActive", "VASMRightActive", "VASMLeftConfidence", "VASMRightConfidence"];
const results = {};
for (const key of keys) {
const resp = await fetch(`/api/params_memory?key=${encodeURIComponent(key)}`);
if (resp.ok) results[key] = await resp.text();
}
const elRight = document.getElementById("vasm-right-status");
const elLeftConf = document.getElementById("vasm-left-conf");
const elRightConf = document.getElementById("vasm-right-conf");
if (elLeft) {
const active = results.VASMLeftActive === "1";
elLeft.textContent = active ? "YES" : "no";
elLeft.className = "v-asm-status-value " + (active ? "active" : "inactive");
}
if (elRight) {
const active = results.VASMRightActive === "1";
elRight.textContent = active ? "YES" : "no";
elRight.className = "v-asm-status-value " + (active ? "active" : "inactive");
}
if (elLeftConf) elLeftConf.textContent = parseFloat(results.VASMLeftConfidence || "0").toFixed(3);
if (elRightConf) elRightConf.textContent = parseFloat(results.VASMRightConfidence || "0").toFixed(3);
} catch (e) {
}
}, 3000);
}
return el;
}
@@ -42,6 +42,7 @@
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-actions-1">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
<link rel="stylesheet" href="/assets/components/tools/v_asm.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module">
@@ -129,3 +129,16 @@ def test_human_acceleration_param_is_removed():
params_source = PARAM_KEYS_PATH.read_text(encoding="utf-8")
assert '{"HumanAcceleration",' not in params_source
def test_vasm_is_default_off_and_configured_only_in_galaxy():
sections = _params_by_section(_layout())
lateral = sections["Lateral (Steering)"]
assert {"VASMEnabled", "VASMConfidenceThreshold", "VASMSmoothSeconds"} <= lateral.keys()
assert _declared_default("VASMEnabled") == "0"
physical_settings = (
REPO_ROOT / "selfdrive/ui/layouts/settings/starpilot/aethergrid.py",
REPO_ROOT / "selfdrive/ui/layouts/settings/starpilot/lateral.py",
)
assert all("VASM" not in path.read_text(encoding="utf-8") for path in physical_settings)
@@ -0,0 +1,30 @@
import pytest
from openpilot.starpilot.system.the_galaxy.the_galaxy import _normalize_vasm_config
def test_normalize_vasm_config_accepts_bounded_polygons():
config = _normalize_vasm_config({
"width": 1920,
"height": 1080,
"poly_left": [[1.2, 2.7], [100, 3], [90, 200]],
"poly_right": [],
})
assert config == {
"width": 1920,
"height": 1080,
"poly_left": [[1, 3], [100, 3], [90, 200]],
"poly_right": [],
}
@pytest.mark.parametrize("config", (
{},
{"width": 1920, "height": 1080, "poly_left": [], "poly_right": []},
{"width": 1920, "height": 1080, "poly_left": [[-1, 1], [2, 2], [3, 3]], "poly_right": []},
{"width": 1920, "height": 1080, "poly_left": [[1, 1], [2, 2]], "poly_right": []},
))
def test_normalize_vasm_config_rejects_unsafe_config(config):
with pytest.raises(ValueError):
_normalize_vasm_config(config)
+104
View File
@@ -92,6 +92,7 @@ GITLAB_API = "https://gitlab.com/api/v4"
GITLAB_SUBMISSIONS_PROJECT_ID = "71992109"
GITLAB_TOKEN = os.environ.get("GITLAB_TOKEN", "")
LEGACY_LATERAL_METHOD_API_PREFIX = "/api/" + "".join(("f", "t", "m"))
VASM_CONFIGURATION_KEYS = {"VASMEnabled", "VASMConfidenceThreshold", "VASMSmoothSeconds", "VASMAnnotationConfig"}
GALAXY_DEPS_PATH = "/data/galaxy_deps"
LEGACY_GALAXY_DEPS_PATH = "/data/" + "".join(chr(code) for code in (112, 111, 110, 100)) + "_deps"
@@ -2553,6 +2554,48 @@ def _safe_params_get_bool(key, default=False):
except Exception:
return bool(default)
def _normalize_vasm_config(data):
if not isinstance(data, dict):
raise ValueError("Configuration must be a JSON object.")
try:
width = int(data.get("width", 0))
height = int(data.get("height", 0))
except (TypeError, ValueError) as exc:
raise ValueError("Invalid camera dimensions.") from exc
if not (1 <= width <= 8192 and 1 <= height <= 8192):
raise ValueError("Camera dimensions are out of range.")
def normalize_polygon(key):
polygon = data.get(key, [])
if not isinstance(polygon, list) or len(polygon) > 64:
raise ValueError(f"{key} must contain at most 64 points.")
if polygon and len(polygon) < 3:
raise ValueError(f"{key} requires at least 3 points.")
normalized = []
for point in polygon:
if not isinstance(point, (list, tuple)) or len(point) != 2:
raise ValueError(f"{key} contains an invalid point.")
try:
x, y = float(point[0]), float(point[1])
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} contains a non-numeric point.") from exc
if not (math.isfinite(x) and math.isfinite(y) and 0 <= x <= width and 0 <= y <= height):
raise ValueError(f"{key} contains a point outside the camera frame.")
normalized.append([round(x), round(y)])
return normalized
config = {
"width": width,
"height": height,
"poly_left": normalize_polygon("poly_left"),
"poly_right": normalize_polygon("poly_right"),
}
if not config["poly_left"] and not config["poly_right"]:
raise ValueError("At least one window polygon is required.")
return config
def _is_blank_param_raw(raw_value):
if raw_value is None:
return True
@@ -3912,6 +3955,8 @@ def setup(app):
"/assets/components/tools/device_settings.js",
"/assets/components/tools/device_settings.css",
"/assets/components/tools/device_settings_layout.json",
"/assets/components/tools/v_asm.js",
"/assets/components/tools/v_asm.css",
"/assets/components/tools/toggles.js",
}:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
@@ -4371,6 +4416,9 @@ def setup(app):
if key == "AutomaticUpdates" and params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot change Automatic Updates while driving."}), 403
if key in VASM_CONFIGURATION_KEYS and params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot change V-ASM configuration while driving."}), 403
if key in PANDA_FIRMWARE_TOGGLE_KEYS and params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot flash Panda firmware while driving."}), 403
if key in PANDA_FIRMWARE_TOGGLE_KEYS and data.get(PANDA_FIRMWARE_CONFIRMATION_FIELD) is not True:
@@ -7364,6 +7412,62 @@ def setup(app):
HARDWARE.reboot()
return jsonify({"success": True, "message": "Toggles reset to default StarPilot values. Rebooting..."})
@app.route("/api/v_asm/snapshot", methods=["GET"])
def v_asm_snapshot():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Snapshot only available while offroad."}), 409
for footage_path in FOOTAGE_PATHS:
if not os.path.isdir(footage_path):
continue
try:
entries = sorted((e for e in os.listdir(footage_path) if utilities.SEGMENT_RE.fullmatch(e)), reverse=True)
except OSError:
continue
for entry in entries[:3]:
camera_file = os.path.join(footage_path, entry, "dcamera.hevc")
if not os.path.isfile(camera_file):
continue
for seek_time in ("5", "2", None):
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-i", camera_file]
if seek_time is not None:
command.extend(["-ss", seek_time])
command.extend(["-frames:v", "1", "-q:v", "2", "-f", "image2pipe", "-vcodec", "mjpeg", "-"])
try:
result = subprocess.run(command, capture_output=True, check=True, timeout=5, stdin=subprocess.DEVNULL)
return Response(result.stdout, mimetype="image/jpeg")
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
continue
return jsonify({"error": "No driver camera footage available."}), 404
@app.route("/api/v_asm/config", methods=["GET"])
def v_asm_get_config():
config = params.get("VASMAnnotationConfig")
return jsonify(config if isinstance(config, dict) else {})
@app.route("/api/v_asm/config", methods=["POST"])
def v_asm_save_config():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot change V-ASM configuration while driving."}), 409
try:
config = _normalize_vasm_config(request.get_json(silent=True))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
params.put("VASMAnnotationConfig", config)
params.put_bool("VASMEnabled", True)
update_starpilot_toggles()
return jsonify({"success": True, "message": "Annotation config saved. V-ASM enabled."})
@app.route("/api/v_asm/config", methods=["DELETE"])
def v_asm_delete_config():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot change V-ASM configuration while driving."}), 409
params.put_bool("VASMEnabled", False)
params.put("VASMAnnotationConfig", {})
update_starpilot_toggles()
return jsonify({"success": True, "message": "Annotation config cleared. V-ASM disabled."})
@app.route("/mapbox-help/<path:filename>", methods=["GET"])
def serve_mapbox_help(filename):
return send_from_directory("/data/openpilot/starpilot/navigation/navigation_training", filename)
+5
View File
@@ -85,6 +85,10 @@ def run_navigationd(started: bool, params: Params, CP: car.CarParams, starpilot_
return started and params.get("NavDestination") is not None
def run_v_asm(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return started and getattr(starpilot_toggles, "v_asm_enabled", False)
class BigDeviceUIProcess:
name = "ui"
enabled = True
@@ -238,6 +242,7 @@ procs += [
PythonProcess("navigationd", "starpilot.navigation.navigationd", run_navigationd, nice=19),
PythonProcess("speed_limit_filler", "starpilot.system.speed_limit_filler", run_speed_limit_filler, nice=19),
PythonProcess("speed_limit_vision", "starpilot.system.speed_limit_vision", run_speed_limit_vision, nice=19),
PythonProcess("adj_spot_monitor_vision", "starpilot.system.adj_spot_monitor_vision", run_v_asm, nice=19),
]
managed_processes = {p.name: p for p in procs}
+10
View File
@@ -0,0 +1,10 @@
from types import SimpleNamespace
from openpilot.system.manager.process_config import run_v_asm
def test_vasm_process_defaults_off_and_runs_only_onroad():
assert not run_v_asm(False, None, None, SimpleNamespace(v_asm_enabled=False))
assert not run_v_asm(False, None, None, SimpleNamespace(v_asm_enabled=True))
assert not run_v_asm(True, None, None, SimpleNamespace(v_asm_enabled=False))
assert run_v_asm(True, None, None, SimpleNamespace(v_asm_enabled=True))