diff --git a/cereal/libcereal.a b/cereal/libcereal.a index 9e78a24b8..754a1bcb3 100644 Binary files a/cereal/libcereal.a and b/cereal/libcereal.a differ diff --git a/cereal/log.capnp b/cereal/log.capnp index 1212e1982..7f3876b33 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2264,7 +2264,8 @@ struct DriverMonitoringStateDEPRECATED @0xb83cda094a1da284 { struct DriverMonitoringState { lockout @0 :Bool; - lockoutRecoveryPercent @11 :Int8; + lockoutCount @15 :Int8; + lockoutMinutesRemaining @11 :Int8; alert3Count @12 :Int8; noResponseCount @13 :Int8; noResponseForceDecel @14 :Bool; diff --git a/common/file_chunker.py b/common/file_chunker.py index eb80744cc..0ad002603 100644 --- a/common/file_chunker.py +++ b/common/file_chunker.py @@ -1,5 +1,8 @@ +#!/usr/bin/env python3 +import io import math import os +import sys from pathlib import Path CHUNK_SIZE = 45 * 1024 * 1024 # 45MB, under GitHub's 50MB limit @@ -13,9 +16,16 @@ def get_manifest_path(name): return f"{name}.chunkmanifest" +def _chunk_paths(path, num_chunks): + return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] + + def get_chunk_paths(path, file_size): num_chunks = math.ceil(file_size / CHUNK_SIZE) - return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] + return _chunk_paths(path, num_chunks) + + +get_chunk_targets = get_chunk_paths def chunk_file(path, targets): @@ -31,6 +41,51 @@ def chunk_file(path, targets): os.remove(path) +def get_existing_chunks(path): + if os.path.isfile(path): + return [path] + manifest_path = get_manifest_path(path) + if os.path.isfile(manifest_path): + num_chunks = int(Path(manifest_path).read_text().strip()) + return _chunk_paths(path, num_chunks) + raise FileNotFoundError(path) + + +def file_chunked_exists(path) -> bool: + return os.path.isfile(path) or os.path.isfile(get_manifest_path(path)) + + +class ChunkStream(io.RawIOBase): + def __init__(self, paths): + self._paths = iter(paths) + self._buffer = memoryview(b"") + + def readable(self): + return True + + def readinto(self, buffer): + count = 0 + while count < len(buffer): + if not self._buffer: + path = next(self._paths, None) + if path is None: + break + self._buffer = memoryview(Path(path).read_bytes()) + continue + take = min(len(buffer) - count, len(self._buffer)) + buffer[count:count + take] = self._buffer[:take] + self._buffer = self._buffer[take:] + count += take + return count + + +def open_file_chunked(path): + chunks = get_existing_chunks(path) + if chunks and chunks[0] == get_manifest_path(path): + chunks = chunks[1:] + return io.BufferedReader(ChunkStream(chunks)) + + def read_file_chunked(path): manifest_path = get_manifest_path(path) if os.path.isfile(manifest_path): @@ -39,3 +94,8 @@ def read_file_chunked(path): if os.path.isfile(path): return Path(path).read_bytes() raise FileNotFoundError(path) + + +if __name__ == "__main__": + file_path = sys.argv[1] + chunk_file(file_path, get_chunk_targets(file_path, os.path.getsize(file_path))) diff --git a/common/libcommon.a b/common/libcommon.a index 01704bd1a..411443397 100644 Binary files a/common/libcommon.a and b/common/libcommon.a differ diff --git a/common/params_keys.h b/common/params_keys.h index fee0be79e..51389d0f9 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -37,6 +37,7 @@ inline static std::unordered_map keys = { {"DoShutdown", {CLEAR_ON_MANAGER_START, BOOL}}, {"DoUninstall", {CLEAR_ON_MANAGER_START, BOOL}}, {"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, + {"DriverLockoutCount", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, INT, "0"}}, {"EcuDisableFailed", {CLEAR_ON_MANAGER_START, BOOL}}, {"AlphaLongitudinalEnabled", {PERSISTENT, BOOL}}, {"ExperimentalLongitudinalEnabled", {PERSISTENT, BOOL}}, @@ -148,6 +149,9 @@ inline static std::unordered_map keys = { {"UpdaterLastFetchTime", {PERSISTENT, TIME}}, {"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}}, {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, + {"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, + {"UsbGpuCompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, + {"UsbGpuPresent", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"Version", {PERSISTENT, STRING}}, // StarPilot variables @@ -457,6 +461,8 @@ inline static std::unordered_map keys = { {"OneLaneChange", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"OnroadDistanceButton", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"OnroadDistanceButtonPressed", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}}, + {"FavoriteVirtualAccelCruiseCounter", {CLEAR_ON_MANAGER_START, INT, "0", "0"}}, + {"FavoriteVirtualDecelCruiseCounter", {CLEAR_ON_MANAGER_START, INT, "0", "0"}}, {"WheelButtonBookmarkCounter", {CLEAR_ON_MANAGER_START, INT, "0", "0"}}, {"openpilotMinutes", {PERSISTENT, INT, "0", "0", 0}}, {"OverpassRequests", {PERSISTENT, JSON, "{}", "{}"}}, diff --git a/common/params_pyx.so b/common/params_pyx.so index 71bd89a4b..d4877fbca 100755 Binary files a/common/params_pyx.so and b/common/params_pyx.so differ diff --git a/common/tests/test_file_chunker.py b/common/tests/test_file_chunker.py new file mode 100644 index 000000000..3d0e54a26 --- /dev/null +++ b/common/tests/test_file_chunker.py @@ -0,0 +1,16 @@ +from openpilot.common import file_chunker + + +def test_chunked_stream_round_trip(tmp_path, monkeypatch): + monkeypatch.setattr(file_chunker, "CHUNK_SIZE", 7) + path = tmp_path / "artifact.pkl" + payload = b"a model artifact spanning several chunks" + path.write_bytes(payload) + + targets = file_chunker.get_chunk_targets(path, len(payload)) + file_chunker.chunk_file(path, targets) + + assert file_chunker.file_chunked_exists(path) + assert file_chunker.read_file_chunked(path) == payload + with file_chunker.open_file_chunked(path) as stream: + assert stream.read(9) + stream.read() == payload diff --git a/docs/MODEL_REBUILD.md b/docs/MODEL_REBUILD.md index 18257a18d..76a1c409c 100644 --- a/docs/MODEL_REBUILD.md +++ b/docs/MODEL_REBUILD.md @@ -6,7 +6,7 @@ This workflow rebuilds StarPilot driving and driver-monitoring artifacts for the - The supported build device is `comma@192.168.3.110`. - Never run these commands against `192.168.3.109`. -- Do not compile normal and big-GPU artifacts together. This workflow builds normal QCOM artifacts only. +- Normal artifacts target QCOM. External-GPU artifacts must be compiled explicitly and tagged in the manifest. - Keep source ONNX files and compiled PKLs on the T5 workspace, not the comma. ## Workspace @@ -82,6 +82,23 @@ The lower-level device compiler also supports direct use: ./models --model deeprl3v2 --input-format supercombo --version v15 ``` +For a model that cannot run on the device GPU, compile with the USB AMD GPU attached: + +```bash +./models --model heavyweight --input-format supercombo --version v15 --external-gpu +``` + +This emits a streaming out-of-band pickle and keeps QCOM available for camera warps. Its manifest entry must include: + +```json +{ + "id": "heavyweight", + "uses_external_gpu": true +} +``` + +Only tagged models activate the external GPU. If the GPU or artifact is unavailable, runtime falls back to the built-in model; all untagged models retain the existing QCOM path. + `--version` records behavioral semantics only. It does not change artifact layout. If the compiled PKL exceeds 100 MiB, `./models` automatically keeps the full @@ -139,6 +156,8 @@ The generator preserves existing IDs and behavioral metadata and adds Repository-hosted multipart files are discovered by naming convention, so no size, hash, format, or part-count metadata is required. +`uses_external_gpu` is optional and defaults to `false`. + ## Runtime Verification Compilation validates JIT capture/replay, pickle round-trip, finite outputs, metadata slices, and both camera warps. Before release: diff --git a/scripts/model_compiler.py b/scripts/model_compiler.py index a5a54abe6..1b9cc28cd 100644 --- a/scripts/model_compiler.py +++ b/scripts/model_compiler.py @@ -80,6 +80,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--list", action="store_true", help="List staged models and exit.") parser.add_argument("--force", action="store_true", help="Accepted for compatibility; selected outputs are always replaced.") + parser.add_argument("--external-gpu", action="store_true", help="Compile the driving artifact for the USB AMD GPU.") parser.add_argument("--split-artifact", type=Path, help="Split an existing oversized PKL without compiling.") parser.add_argument("--chunk-size-mib", type=int, default=95, help="Multipart size in MiB; must be below 100.") parser.add_argument( @@ -355,6 +356,7 @@ def compile_driving( version: str, output_dir: Path, image_history_pipeline: str, + external_gpu: bool = False, ) -> Path: model_type, source_args = driving_compile_args(files, input_format) output_path = output_dir / f"{model_key}_driving_tinygrad.pkl" @@ -390,7 +392,20 @@ def compile_driving( ] if version: command += ["--behavior-version", version] - subprocess.run(command, cwd=REPO_ROOT, env=build_compile_env(), check=True) + compile_env = build_compile_env() + if external_gpu: + for qcom_only_flag in ("IMAGE", "NOLOCALS", "OPENPILOT_HACKS"): + compile_env.pop(qcom_only_flag, None) + compile_env.update({ + "DEBUG": "2", + "DEV": "USB+AMD:LLVM", + "WARP_DEV": "QCOM", + "FLOAT16": "1", + "JIT_BATCH_SIZE": "0", + "GMMU": "0", + }) + command.append("--out-of-band") + subprocess.run(command, cwd=REPO_ROOT, env=compile_env, check=True) return output_path @@ -487,7 +502,8 @@ def main() -> int: version = "v15" version_label = version or "unspecified behavior" print(f"Compiling {model_key} ({input_format}, {version_label}) from {args.input_dir} -> {args.output_dir}") - output = compile_driving(model_key, files, input_format, version, args.output_dir, args.image_history_pipeline) + output = compile_driving(model_key, files, input_format, version, args.output_dir, + args.image_history_pipeline, args.external_gpu) print(f" saved {output.name}") multipart_outputs = split_oversized_artifact(output) if multipart_outputs: diff --git a/scripts/model_rebuild_pipeline.py b/scripts/model_rebuild_pipeline.py index 3f33a1331..41ce620a9 100644 --- a/scripts/model_rebuild_pipeline.py +++ b/scripts/model_rebuild_pipeline.py @@ -236,11 +236,14 @@ def compile_model(model_id: str, source: dict, version: str, workspace: Path, fo run(["rsync", "-az", "--exclude=._*", f"{source_dir}/", f"{REMOTE}:{remote_input}/"]) log_path = workspace / "logs" / f"{model_id}.log" - command = ( - f"cd {REMOTE_ROOT} && ./models --model {model_id} " - f"--input-dir {remote_input} --output-dir {REMOTE_ROOT}/compiledmodels " - f"--input-format {source['input_format']} --version {version}" - ) + command_parts = [ + f"cd {REMOTE_ROOT} && ./models --model {model_id}", + f"--input-dir {remote_input} --output-dir {REMOTE_ROOT}/compiledmodels", + f"--input-format {source['input_format']} --version {version}", + ] + if source.get("uses_external_gpu"): + command_parts.append("--external-gpu") + command = " ".join(command_parts) with open(log_path, "wb") as log_file: process = subprocess.run(["ssh", REMOTE, command], stdout=log_file, stderr=subprocess.STDOUT) if process.returncode != 0: @@ -288,7 +291,7 @@ def validate_model(model_id: str, version: str, workspace: Path) -> dict: return payload -def update_manifest(base_manifest: Path, workspace: Path) -> dict: +def update_manifest(base_manifest: Path, workspace: Path, source_map: dict) -> dict: payload = load_json(base_manifest) models = payload["models"] if isinstance(payload, dict) else payload if not any(model.get("id") == "deeprl3v2" for model in models): @@ -302,6 +305,9 @@ def update_manifest(base_manifest: Path, workspace: Path) -> dict: }) multipart_handoff = [] for model in models: + source = source_map.get(model["id"], {}) + if "uses_external_gpu" in source: + model["uses_external_gpu"] = bool(source["uses_external_gpu"]) artifact = workspace / "compiled" / f"{model['id']}_driving_tinygrad.pkl" model.pop("artifact_format", None) model.pop("artifact_size", None) @@ -350,7 +356,7 @@ def main() -> int: if args.command == "manifest": if args.base_manifest is None: parser.error("--base-manifest is required") - update_manifest(args.base_manifest, args.workspace) + update_manifest(args.base_manifest, args.workspace, source_map) return 0 model_ids = [args.model] if args.model else list(source_map) diff --git a/scripts/speed_limit_vision/benchmark_onnx.py b/scripts/speed_limit_vision/benchmark_onnx.py new file mode 100644 index 000000000..ba62a56b8 --- /dev/null +++ b/scripts/speed_limit_vision/benchmark_onnx.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import statistics +import time + +from pathlib import Path + +import cv2 +import numpy as np + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Benchmark fixed-shape ONNX forwards through OpenCV DNN.") + parser.add_argument("models", nargs="+", type=Path, help="ONNX model paths to benchmark.") + parser.add_argument("--input-size", type=int, required=True, help="Square model input size.") + parser.add_argument("--iterations", type=int, default=50) + parser.add_argument("--warmup", type=int, default=5) + return parser.parse_args() + + +def percentile(values: list[float], quantile: float) -> float: + ordered = sorted(values) + index = min(round((len(ordered) - 1) * quantile), len(ordered) - 1) + return ordered[index] + + +def benchmark(path: Path, input_size: int, iterations: int, warmup: int) -> dict[str, object]: + net = cv2.dnn.readNetFromONNX(str(path)) + blob = np.zeros((1, 3, input_size, input_size), dtype=np.float32) + net.setInput(blob) + output = net.forward() + for _ in range(max(warmup - 1, 0)): + net.setInput(blob) + output = net.forward() + + durations_ms = [] + for _ in range(max(iterations, 1)): + net.setInput(blob) + started_at = time.perf_counter() + output = net.forward() + durations_ms.append((time.perf_counter() - started_at) * 1000.0) + + return { + "model": str(path.resolve()), + "input_size": input_size, + "output_shape": list(output.shape), + "iterations": len(durations_ms), + "median_ms": round(statistics.median(durations_ms), 3), + "p95_ms": round(percentile(durations_ms, 0.95), 3), + "mean_ms": round(statistics.mean(durations_ms), 3), + } + + +def main() -> int: + args = parse_args() + results = [ + benchmark(path.expanduser().resolve(), args.input_size, args.iterations, args.warmup) + for path in args.models + ] + print(json.dumps(results, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/speed_limit_vision/build_localized_bookmark_review_queue.py b/scripts/speed_limit_vision/build_localized_bookmark_review_queue.py index 9d9149baf..630e4694d 100644 --- a/scripts/speed_limit_vision/build_localized_bookmark_review_queue.py +++ b/scripts/speed_limit_vision/build_localized_bookmark_review_queue.py @@ -86,6 +86,13 @@ def parse_read(text: str) -> tuple[str, str]: return (speed, confidence) if separator else ("", "") +def nonzero_value(text: str) -> str: + try: + return text if float(text) > 0.0 else "" + except (TypeError, ValueError): + return "" + + def queue_row(row: dict[str, str]) -> dict[str, str]: key = record_key(row) route, dongle_id, log_id = route_identity(row) @@ -99,8 +106,17 @@ def queue_row(row: dict[str, str]) -> dict[str, str]: read_sources += ";logged_vision_publish" review_reasons = ["corrected_source_timing"] + review_priority = float(row.get("score") or 0.0) if row.get("event_type", "") == "visionPublish": - review_reasons.extend(("route_vision_publish", f"published_{row.get('published_speed', '')}")) + published_speed = row.get("published_speed", "") + review_reasons.extend(("route_vision_publish", f"published_{published_speed}")) + if published_speed and candidate_speed and published_speed != candidate_speed: + review_reasons.append("logged_current_value_disagreement") + review_priority += 5.0 + map_speed = nonzero_value(row.get("map_speed", "")) + if map_speed and published_speed and map_speed != published_speed: + review_reasons.append("logged_map_disagreement") + review_priority += 3.0 else: review_reasons.append("route_bookmark") @@ -129,9 +145,9 @@ def queue_row(row: dict[str, str]) -> dict[str, str]: "read_sources": read_sources, "read_support_count": "1", "is_regulatory": row.get("is_regulatory", ""), - "map_current_speed_limit_mph": row.get("map_speed", ""), - "map_next_speed_limit_mph": row.get("next_speed", ""), - "review_priority": row.get("score", ""), + "map_current_speed_limit_mph": nonzero_value(row.get("map_speed", "")), + "map_next_speed_limit_mph": nonzero_value(row.get("next_speed", "")), + "review_priority": f"{review_priority:.4f}", "review_reasons": ";".join(review_reasons), }) return item diff --git a/scripts/speed_limit_vision/expand_classifier_checkpoint.py b/scripts/speed_limit_vision/expand_classifier_checkpoint.py new file mode 100644 index 000000000..f1528b9d9 --- /dev/null +++ b/scripts/speed_limit_vision/expand_classifier_checkpoint.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse + +from pathlib import Path + +import torch + +if __package__ in (None, ""): + import sys + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from common import EXTENDED_CLASSIFIER_SPEED_VALUES # type: ignore # noqa: TID251 +else: + from .common import EXTENDED_CLASSIFIER_SPEED_VALUES + + +DEFAULT_CLASSES = tuple(str(value) for value in EXTENDED_CLASSIFIER_SPEED_VALUES) + ("reject",) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Expand a YOLO classification head while preserving existing class rows.") + parser.add_argument("--model", type=Path, required=True, help="Existing Ultralytics classification checkpoint.") + parser.add_argument("--output", type=Path, required=True, help="Expanded checkpoint path.") + parser.add_argument("--classes", nargs="+", default=DEFAULT_CLASSES, help="New output classes in dataset index order.") + return parser.parse_args() + + +def main() -> int: + from ultralytics import YOLO + + args = parse_args() + model = YOLO(str(args.model.expanduser().resolve())) + old_names = {int(index): str(name) for index, name in model.names.items()} + new_names = dict(enumerate(args.classes)) + if len(set(new_names.values())) != len(new_names): + raise ValueError("--classes contains duplicate names") + if not set(old_names.values()).issubset(new_names.values()): + missing = sorted(set(old_names.values()) - set(new_names.values())) + raise ValueError(f"Expanded classes omit existing outputs: {missing}") + + head = model.model.model[-1] + old_linear = head.linear + new_linear = torch.nn.Linear( + old_linear.in_features, + len(new_names), + bias=old_linear.bias is not None, + device=old_linear.weight.device, + dtype=old_linear.weight.dtype, + ) + new_index_by_name = {name: index for index, name in new_names.items()} + with torch.no_grad(): + for old_index, name in old_names.items(): + new_index = new_index_by_name[name] + new_linear.weight[new_index].copy_(old_linear.weight[old_index]) + if old_linear.bias is not None: + new_linear.bias[new_index].copy_(old_linear.bias[old_index]) + + head.linear = new_linear + head.np = sum(parameter.numel() for parameter in head.parameters()) + model.model.names = new_names + model.model.yaml["nc"] = len(new_names) + model.model.args["classes"] = None + + output = args.output.expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + model.save(str(output)) + print(f"Expanded {len(old_names)} outputs to {len(new_names)}: {output}") + print(new_names) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/speed_limit_vision/generate_synthetic_us_speed_limits.py b/scripts/speed_limit_vision/generate_synthetic_us_speed_limits.py index 3c3733ca2..ad65b0430 100644 --- a/scripts/speed_limit_vision/generate_synthetic_us_speed_limits.py +++ b/scripts/speed_limit_vision/generate_synthetic_us_speed_limits.py @@ -222,7 +222,11 @@ def save_classifier_crop(base_dir: Path, split: str, speed_value: int, image_bgr def collect_backgrounds(background_dir: Path) -> list[Path]: if not background_dir.is_dir(): return [] - return sorted(path for path in background_dir.iterdir() if path.suffix.lower() in {".jpg", ".jpeg", ".png"}) + return sorted( + path + for path in background_dir.iterdir() + if not path.name.startswith("._") and path.suffix.lower() in {".jpg", ".jpeg", ".png"} + ) def main(): @@ -233,6 +237,7 @@ def main(): parser.add_argument("--val-count", type=int, default=1200, help="Number of synthetic validation detector images.") parser.add_argument("--negative-ratio", type=float, default=0.18, help="Share of detector images with no sign.") parser.add_argument("--speed-values", nargs="+", type=int, default=list(DEFAULT_SPEED_VALUES), help="Posted values to synthesize.") + parser.add_argument("--regulatory-only", action="store_true", help="Generate only regulatory signs for the requested values.") parser.add_argument("--seed", type=int, default=20260330, help="Random seed.") args = parser.parse_args() @@ -265,7 +270,11 @@ def main(): detector_lines: list[str] = [] if rng.random() >= args.negative_ratio: - sign_spec = choose_sign_spec(rng, speed_values) + sign_spec = ( + SignSpec(detector_class=0, style="regulatory", speed_value=rng.choice(speed_values)) + if args.regulatory_only + else choose_sign_spec(rng, speed_values) + ) if sign_spec.style == "advisory": sign_image = render_advisory_sign(sign_spec.speed_value or 25, seed=rng.randint(0, 1_000_000)) else: diff --git a/scripts/speed_limit_vision/merge_classifier_datasets.py b/scripts/speed_limit_vision/merge_classifier_datasets.py new file mode 100644 index 000000000..e6ab0d685 --- /dev/null +++ b/scripts/speed_limit_vision/merge_classifier_datasets.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import shutil + +from collections import Counter +from pathlib import Path + +if __package__ in (None, ""): + import sys + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from common import EXTENDED_CLASSIFIER_SPEED_VALUES # type: ignore # noqa: TID251 +else: + from .common import EXTENDED_CLASSIFIER_SPEED_VALUES + + +IMAGE_SUFFIXES = frozenset((".jpg", ".jpeg", ".png", ".bmp", ".webp")) +SUPPORTED_CLASSES = frozenset(str(value) for value in EXTENDED_CLASSIFIER_SPEED_VALUES) | {"reject"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Merge image-folder classifier datasets using hard links when possible.") + parser.add_argument("sources", nargs="+", type=Path, help="Classifier roots containing train/ and val/.") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--overwrite", action="store_true") + return parser.parse_args() + + +def link_or_copy(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(source, destination) + except OSError: + shutil.copy2(source, destination) + + +def main() -> int: + args = parse_args() + sources = [path.expanduser().resolve() for path in args.sources] + output = args.output.expanduser().resolve() + if output.exists(): + if not args.overwrite: + raise FileExistsError(f"Output already exists: {output}") + shutil.rmtree(output) + + counts: Counter[str] = Counter() + for source_index, source_root in enumerate(sources): + for split in ("train", "val"): + split_root = source_root / split + if not split_root.is_dir(): + raise FileNotFoundError(split_root) + for class_dir in sorted(split_root.iterdir()): + if not class_dir.is_dir() or class_dir.name.startswith("._"): + continue + if class_dir.name not in SUPPORTED_CLASSES: + raise ValueError(f"Unsupported classifier class {class_dir.name!r} in {source_root}") + for image_path in sorted(class_dir.iterdir()): + if image_path.name.startswith("._") or image_path.suffix.lower() not in IMAGE_SUFFIXES or not image_path.is_file(): + continue + destination = output / split / class_dir.name / f"s{source_index:02d}_{image_path.name}" + link_or_copy(image_path, destination) + counts[f"{split}/{class_dir.name}"] += 1 + + summary = { + "sources": [str(path) for path in sources], + "output": str(output), + "counts": dict(sorted(counts.items())), + } + (output / "merge_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="ascii") + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/speed_limit_vision/sample_route_backgrounds.py b/scripts/speed_limit_vision/sample_route_backgrounds.py index 9267f7a40..fca177775 100644 --- a/scripts/speed_limit_vision/sample_route_backgrounds.py +++ b/scripts/speed_limit_vision/sample_route_backgrounds.py @@ -21,7 +21,7 @@ VIDEO_SUFFIXES = {".hevc", ".mp4", ".mov", ".mkv", ".avi"} def iter_source_files(paths: list[Path]): for input_path in paths: - if input_path.is_file(): + if input_path.is_file() and not input_path.name.startswith("._"): yield input_path continue @@ -29,7 +29,7 @@ def iter_source_files(paths: list[Path]): continue for path in sorted(input_path.rglob("*")): - if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES: + if path.is_file() and not path.name.startswith("._") and path.suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES: yield path diff --git a/scripts/speed_limit_vision/serve_manual_review_queue.py b/scripts/speed_limit_vision/serve_manual_review_queue.py index d1efeb2c5..20e19221a 100644 --- a/scripts/speed_limit_vision/serve_manual_review_queue.py +++ b/scripts/speed_limit_vision/serve_manual_review_queue.py @@ -321,19 +321,32 @@ function setSpeed(speed, shouldSave) { function handleDigitShortcut(digit) { speedBuffer += digit; - if (speedBuffer.length > 2) speedBuffer = speedBuffer.slice(-2); + if (speedBuffer.length > 3) speedBuffer = digit; if (speedBufferTimer) clearTimeout(speedBufferTimer); speedBufferTimer = setTimeout(clearSpeedBuffer, 1000); if (speedBuffer.length < 2) return; + if (speedBuffer === "10") { + clearTimeout(speedBufferTimer); + speedBufferTimer = setTimeout(() => { + if (speedBuffer === "10") { + clearSpeedBuffer(); + setSpeed(10, true); + } + }, 400); + return; + } + const speed = Number(speedBuffer); - clearSpeedBuffer(); if (speeds.includes(speed)) { + clearSpeedBuffer(); setSpeed(speed, true); return; } + if (speedBuffer.length < 3) return; + speedBuffer = digit; speedBufferTimer = setTimeout(clearSpeedBuffer, 1000); } diff --git a/selfdrive/car/card.py b/selfdrive/car/card.py index 6570f5674..76d30ced2 100644 --- a/selfdrive/car/card.py +++ b/selfdrive/car/card.py @@ -29,6 +29,10 @@ from openpilot.selfdrive.car.cruise import ( from openpilot.selfdrive.car.redneck_cruise import RedneckCruise, select_redneck_target_speed from openpilot.selfdrive.car.car_specific import MockCarState +from openpilot.starpilot.common.favorite_slots import ( + FAVORITE_ACTION_ACCEL_COUNTER, + FAVORITE_ACTION_DECEL_COUNTER, +) from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles, update_starpilot_toggles from openpilot.starpilot.controls.starpilot_card import StarPilotCard @@ -93,6 +97,9 @@ class Car: self.params = Params() self.params_memory = Params(memory=True) + self._favorite_virtual_accel_counter = self.params_memory.get_int(FAVORITE_ACTION_ACCEL_COUNTER) + self._favorite_virtual_decel_counter = self.params_memory.get_int(FAVORITE_ACTION_DECEL_COUNTER) + self._favorite_virtual_releases = [] self.can_callbacks = can_comm_callbacks(self.can_sock, self.pm.sock['sendcan']) @@ -205,6 +212,27 @@ class Car: self.sm = self.sm.extend(['starpilotOnroadEvents', 'starpilotPlan', 'starpilotSelfdriveState', 'liveCalibration', 'selfdriveState']) self.pm = self.pm.extend(['starpilotCarState']) + def _inject_favorite_virtual_cruise_events(self, CS: car.CarState) -> None: + virtual_events = [ + structs.CarState.ButtonEvent(pressed=False, type=button_type) + for button_type in self._favorite_virtual_releases + ] + self._favorite_virtual_releases = [] + + for counter_key, counter_attr, button_type in ( + (FAVORITE_ACTION_ACCEL_COUNTER, "_favorite_virtual_accel_counter", ButtonType.accelCruise), + (FAVORITE_ACTION_DECEL_COUNTER, "_favorite_virtual_decel_counter", ButtonType.decelCruise), + ): + counter = self.params_memory.get_int(counter_key) + if counter == getattr(self, counter_attr): + continue + setattr(self, counter_attr, counter) + virtual_events.append(structs.CarState.ButtonEvent(pressed=True, type=button_type)) + self._favorite_virtual_releases.append(button_type) + + if virtual_events: + CS.buttonEvents = list(CS.buttonEvents) + virtual_events + def state_update(self) -> tuple[car.CarState, structs.RadarDataT | None]: """carState update loop, driven by can""" @@ -215,6 +243,7 @@ class Car: CS, FPCS = self.CI.update(can_list, self.starpilot_toggles) if self.CP.brand == 'mock': CS, FPCS = self.mock_carstate.update(CS, FPCS) + self._inject_favorite_virtual_cruise_events(CS) # Update radar tracks from CAN RD: structs.RadarDataT | None = self.RI.update(can_list) diff --git a/selfdrive/modeld/compile_modeld.py b/selfdrive/modeld/compile_modeld.py index 533387922..a2c9a5ce0 100644 --- a/selfdrive/modeld/compile_modeld.py +++ b/selfdrive/modeld/compile_modeld.py @@ -43,6 +43,8 @@ from tinygrad.device import Device from tinygrad.engine.jit import TinyJit from tinygrad.helpers import Context from tinygrad.tensor import Tensor +from openpilot.selfdrive.modeld.helpers import dump_oob +from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link ARTIFACT_FORMAT_VERSION = 1 @@ -59,6 +61,7 @@ WARP_INPUTS = LEGACY_WARP_INPUTS SPLIT_POLICY_INPUTS = BASE_POLICY_INPUTS SUPERCOMBO_POLICY_INPUTS = BASE_POLICY_INPUTS WARP_DEV = os.getenv("WARP_DEV") +OOB_PICKLE = False def _detect_desire_key(input_shapes): @@ -451,7 +454,14 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues): print("capture + replay") test_values, test_buffers = random_inputs_run(jit, seed) print("pickle round trip") - jit = pickle.loads(pickle.dumps(jit)) + if OOB_PICKLE: + with tempfile.TemporaryFile(dir=".") as artifact_file: + dump_oob(jit, artifact_file) + artifact_file.seek(0) + from openpilot.selfdrive.modeld.helpers import load_oob + jit = load_oob(artifact_file) + else: + jit = pickle.loads(pickle.dumps(jit)) random_inputs_run(jit, seed, test_values, test_buffers, expect_match=True) random_inputs_run(jit, seed + 1, test_values, test_buffers, expect_match=False) return jit @@ -486,6 +496,7 @@ def validate_metadata(metadata): def main(): + global OOB_PICKLE from tinygrad.nn.onnx import OnnxRunner from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict @@ -498,6 +509,7 @@ def main(): parser.add_argument("--frame-skip", type=int) parser.add_argument("--behavior-version") parser.add_argument("--output", required=True) + parser.add_argument("--out-of-band", action="store_true", help="Stream model weights outside pickle opcodes for large artifacts.") parser.add_argument("--vision-onnx") parser.add_argument("--policy-onnx") parser.add_argument("--off-policy-onnx") @@ -510,6 +522,9 @@ def main(): help="Where img/big_img history queues are updated. 'policy' is the newer faster ABI; 'warp' reproduces legacy v22 artifacts.", ) args = parser.parse_args() + OOB_PICKLE = args.out_of_band + if "USB+AMD" in os.environ.get("DEV", ""): + wait_usbgpu_link() output = { "format_version": ARTIFACT_FORMAT_VERSION, @@ -619,7 +634,10 @@ def main(): ) with open(args.output, "wb") as artifact_file: - pickle.dump(output, artifact_file) + if args.out_of_band: + dump_oob(output, artifact_file) + else: + pickle.dump(output, artifact_file) print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") return 0 diff --git a/selfdrive/modeld/helpers.py b/selfdrive/modeld/helpers.py index 18beb60ba..def75c88c 100644 --- a/selfdrive/modeld/helpers.py +++ b/selfdrive/modeld/helpers.py @@ -1,5 +1,10 @@ +import io import json import os +import pickle +import shutil +import struct +import tempfile from pathlib import Path from tinygrad.device import Device @@ -13,7 +18,8 @@ USBGPU_PID = 0x0001 def _default_tinygrad_backend() -> str: env_dev = os.getenv("DEV", "").strip() if env_dev: - return env_dev.split(":", 1)[0].split("+", 1)[-1] or env_dev + default_target = env_dev.split(";", 1)[0] + return default_target.split(":", 1)[0].split("+", 1)[-1] or default_target try: default = Device.DEFAULT @@ -57,7 +63,7 @@ def get_tg_input_devices(process_name: str, usbgpu: bool) -> dict[str, str]: return _fallback_tg_devices(process_name, usbgpu) -def modeld_pkl_path(usbgpu: bool): +def modeld_pkl_path(usbgpu: bool) -> Path: prefix = "big_" if usbgpu else "" return MODELS_DIR / f"{prefix}driving_tinygrad.pkl" @@ -71,3 +77,48 @@ def usbgpu_present() -> bool: except Exception: pass return False + + +def tinygrad_dev_config(usbgpu: bool, tici: bool) -> str: + default = "QCOM" if tici else ("CPU:LLVM" if usbgpu else "LLVM") + return f"{default};USB+AMD:LLVM" if usbgpu else default + + +def dump_oob(obj, output) -> None: + """Pickle large buffers out-of-band so model weights are never duplicated in RAM.""" + with tempfile.TemporaryFile(dir=".") as buffers: + def buffer_callback(pickle_buffer: pickle.PickleBuffer): + view = pickle_buffer.raw() + buffers.write(struct.pack(" str: if not hasattr(self, "_queue_dev"): - devices = get_tg_input_devices(PROCESS_NAME, usbgpu=False) + devices = get_tg_input_devices(PROCESS_NAME, usbgpu=self.uses_external_gpu) self._warp_dev = devices["WARP_DEV"] self._queue_dev = devices["QUEUE_DEV"] return self._queue_dev @@ -415,6 +438,22 @@ def main(demo=False): setproctitle(PROCESS_NAME) config_realtime_process(7, 54) + params = Params() + selected_model = _canonical_model_id(_resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY) + usbgpu_present_now = usbgpu_present() + external_model_selected = model_uses_external_gpu(selected_model) + external_artifact = MODELS_PATH / f"{selected_model}_driving_tinygrad.pkl" + external_artifact_ready = external_model_selected and file_chunked_exists(external_artifact) + external_gpu_requested = usbgpu_present_now and external_model_selected + params.put_bool("UsbGpuPresent", usbgpu_present_now) + params.put_bool("UsbGpuCompiled", external_artifact_ready) + params.put_bool("UsbGpuActive", False) + if external_gpu_requested: + from tinygrad.helpers import DEV + device_config = tinygrad_dev_config(True, TICI) + DEV.value = device_config + os.environ["DEV"] = device_config + # visionipc clients while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) @@ -440,7 +479,21 @@ def main(demo=False): start_time = time.monotonic() cloudlog.warning("loading model") - model = ModelState(vipc_client_main.width, vipc_client_main.height) + if external_gpu_requested: + wait_usbgpu_link() + try: + model = ModelState(vipc_client_main.width, vipc_client_main.height, external_gpu_requested) + except Exception: + if not external_gpu_requested: + raise + cloudlog.exception(f"Failed to load external-GPU model {selected_model}; falling back to {BUILTIN_MODEL_KEY}") + device_config = tinygrad_dev_config(False, TICI) + DEV.value = device_config + os.environ["DEV"] = device_config + model = ModelState(vipc_client_main.width, vipc_client_main.height, False) + external_gpu_active = model.uses_external_gpu + params.put_bool("UsbGpuCompiled", external_model_selected and file_chunked_exists(external_artifact)) + params.put_bool("UsbGpuActive", external_gpu_active) cloudlog.warning(f"model loaded in {time.monotonic() - start_time:.1f}s, modeld starting") # messaging @@ -448,7 +501,6 @@ def main(demo=False): sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay", "starpilotPlan"]) publish_state = PublishState() - params = Params() # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_FREQ) frame_id = 0 diff --git a/selfdrive/modeld/tests/test_usbgpu_helpers.py b/selfdrive/modeld/tests/test_usbgpu_helpers.py new file mode 100644 index 000000000..2a2805e95 --- /dev/null +++ b/selfdrive/modeld/tests/test_usbgpu_helpers.py @@ -0,0 +1,22 @@ +import io + +import numpy as np + +from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob, tinygrad_dev_config + + +def test_external_gpu_keeps_the_native_device_available(): + assert tinygrad_dev_config(True, tici=True) == "QCOM;USB+AMD:LLVM" + assert tinygrad_dev_config(False, tici=True) == "QCOM" + assert tinygrad_dev_config(True, tici=False) == "CPU:LLVM;USB+AMD:LLVM" + + +def test_out_of_band_artifact_round_trip(): + artifact = {"weights": np.arange(32, dtype=np.float32), "metadata": {"version": 1}} + stream = io.BytesIO() + dump_oob(artifact, stream) + stream.seek(0) + + restored = load_oob(stream) + assert restored["metadata"] == artifact["metadata"] + np.testing.assert_array_equal(restored["weights"], artifact["weights"]) diff --git a/selfdrive/modeld/usbgpu_link.py b/selfdrive/modeld/usbgpu_link.py new file mode 100644 index 000000000..c7ab4996b --- /dev/null +++ b/selfdrive/modeld/usbgpu_link.py @@ -0,0 +1,35 @@ +import time +from pathlib import Path + +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware.usb import CHESTNUT_PRODUCT_ID, CHESTNUT_VENDOR_ID, controller, read_int, usb_devices + +STABLE_SECONDS = 2.0 +STABLE_THRESHOLD = 5.0 + + +def _chestnut_portli() -> Path | None: + for device in usb_devices(): + if read_int(device / "idVendor", 16) != CHESTNUT_VENDOR_ID or \ + read_int(device / "idProduct", 16) != CHESTNUT_PRODUCT_ID: + continue + usb_controller = controller(device) + if usb_controller is not None and (usb_controller / "portli").exists(): + return usb_controller / "portli" + return None + + +def wait_usbgpu_link(timeout: float = 30.0) -> None: + portli = _chestnut_portli() + if portli is None: + return + + start_time = time.monotonic() + while time.monotonic() - start_time < timeout: + start_errors = read_int(portli, 0) + time.sleep(STABLE_SECONDS) + error_rate = (read_int(portli, 0) - start_errors) / STABLE_SECONDS + if error_rate <= STABLE_THRESHOLD: + return + cloudlog.warning(f"usbgpu link not stable: {error_rate:.0f} errors/s") + cloudlog.error("usbgpu link never stabilized") diff --git a/selfdrive/monitoring/policy.py b/selfdrive/monitoring/policy.py index ca8ffdc3d..7f763bd62 100644 --- a/selfdrive/monitoring/policy.py +++ b/selfdrive/monitoring/policy.py @@ -61,7 +61,7 @@ class DRIVER_MONITOR_SETTINGS: # lockout specs self._MAX_ALERT_3 = 2 self._MAX_NO_RESPONSE = 1 - self._LOCKOUT_TIME = int(1800 / DT_DMON) + self._LOCKOUT_TIMES = [int(60 * n_min / DT_DMON) for n_min in [1, 5, 15, 30]] self._TIMEOUT_RECOVERY_FACTOR_MAX = 5. self._TIMEOUT_RECOVERY_FACTOR_MIN = 1.25 @@ -173,7 +173,10 @@ class DriverMonitoring: self.cnt_since_alert_3 = 0 self.no_response_timeout = int(self.settings._NO_RESPONSE_TIMEOUT / DT_DMON) self.no_response_cnt = 0 - self.lockout_time = 0 + self.lockout_active = Params().get_bool("DriverTooDistracted") + self.lockout_count = Params().get("DriverLockoutCount") or 0 + self.lockout_duration = self.settings._LOCKOUT_TIMES[min(max(self.lockout_count - 1, 0), len(self.settings._LOCKOUT_TIMES) - 1)] + self.lockout_time_elapsed = 0 self.step_change = 0. self.active_policy = MonitoringPolicy.vision self.driver_interacting = False @@ -184,8 +187,6 @@ class DriverMonitoring: self.threshold_alert_2 = 0. self.dcam_uncertain_cnt = 0 self.dcam_reset_cnt = 0 - self.too_distracted = Params().get_bool("DriverTooDistracted") - self._reset_awareness() self._set_policy(MonitoringPolicy.vision) @@ -334,16 +335,20 @@ class DriverMonitoring: self.driver_interacting = driver_engaged if self.alert_3_cnt >= self.settings._MAX_ALERT_3 or self.no_response_cnt >= self.settings._MAX_NO_RESPONSE: - self.too_distracted = True + if not self.lockout_active: + self.lockout_count += 1 + self.lockout_duration = self.settings._LOCKOUT_TIMES[min(self.lockout_count - 1, len(self.settings._LOCKOUT_TIMES) - 1)] + Params().put("DriverLockoutCount", self.lockout_count) + self.lockout_active = True - if self.too_distracted: - self.lockout_time += 1 - if self.lockout_time > self.settings._LOCKOUT_TIME: - self.too_distracted = False + if self.lockout_active: + self.lockout_time_elapsed += 1 + if self.lockout_time_elapsed > self.lockout_duration: + self.lockout_active = False self.alert_3_cnt = 0 self.cnt_since_alert_3 = 0 self.no_response_cnt = 0 - self.lockout_time = 0 + self.lockout_time_elapsed = 0 always_on_valid = self.always_on and not wrong_gear if (self.driver_interacting and self.awareness > 0 and self.active_policy == MonitoringPolicy.wheeltouch) or \ @@ -403,8 +408,10 @@ class DriverMonitoring: dat = messaging.new_message('driverMonitoringState', valid=valid) dm = dat.driverMonitoringState - dm.lockout = self.too_distracted - dm.lockoutRecoveryPercent = to_percent(self.lockout_time / self.settings._LOCKOUT_TIME) + dm.lockout = self.lockout_active + dm.lockoutCount = self.lockout_count + if self.lockout_active: + dm.lockoutMinutesRemaining = max(1, round((self.lockout_duration - self.lockout_time_elapsed) * DT_DMON / 60.)) dm.alert3Count = self.alert_3_cnt dm.noResponseCount = self.no_response_cnt dm.noResponseForceDecel = self.alert_level == AlertLevel.three and self.cnt_since_alert_3 >= self.no_response_timeout diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index fc557effc..786a004de 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -113,21 +113,17 @@ class TestMonitoring: # engaged, distracted past red and beyond the no-response window -> unavailability response + lockout def test_distracted_lockout(self): alert_lvls, d_status = self._run_seq(always_distracted, always_false, always_true, always_false) - s = d_status.settings assert alert_lvls[int(DISTRACTED_SECONDS_TO_RED / DT_DMON)] == 3 - assert d_status.alert_3_cnt == 1 - assert d_status.no_response_cnt == s._MAX_NO_RESPONSE - assert d_status.too_distracted - assert d_status.lockout_time > 0 + assert d_status.lockout_active + assert d_status.lockout_time_elapsed > 0 + assert d_status.lockout_count >= 1 # no face -> wheeltouch red, sustained past the no-response timeout -> unavailability response + lockout def test_invisible_lockout(self): _, d_status = self._run_seq(always_no_face, always_false, always_true, always_false) - s = d_status.settings assert d_status.active_policy == log.DriverMonitoringState.MonitoringPolicy.wheeltouch - assert d_status.alert_3_cnt == 1 - assert d_status.no_response_cnt == s._MAX_NO_RESPONSE - assert d_status.too_distracted + assert d_status.lockout_active + assert d_status.lockout_count >= 1 # engaged, no face detected the whole time, no action def test_fully_invisible_driver(self): diff --git a/selfdrive/selfdrived/events.py b/selfdrive/selfdrived/events.py index ce2c20daa..f4c4d4fdd 100644 --- a/selfdrive/selfdrived/events.py +++ b/selfdrive/selfdrived/events.py @@ -11,10 +11,9 @@ import cereal.messaging as messaging from openpilot.common.constants import CV from openpilot.common.git import get_short_branch from openpilot.common.params import Params -from openpilot.common.realtime import DT_CTRL, DT_DMON +from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.controls.lib.desire_helper import LaneChangeDirection from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER -from openpilot.selfdrive.monitoring.policy import DRIVER_MONITOR_SETTINGS from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION from openpilot.system.hardware import HARDWARE @@ -29,9 +28,6 @@ StarPilotAlertStatus = custom.StarPilotSelfdriveState.AlertStatus StarPilotAudibleAlert = custom.StarPilotCarControl.HUDControl.AudibleAlert StarPilotEventName = custom.StarPilotOnroadEvent.EventName -DMON_LOCKOUT_TIME = DRIVER_MONITOR_SETTINGS()._LOCKOUT_TIME - - # Alert priorities class Priority(IntEnum): LOWEST = 0 @@ -288,7 +284,7 @@ def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messag def too_distracted_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality, starpilot_toggles: SimpleNamespace) -> Alert: if sm['driverMonitoringState'].lockout: - mins_left = max(1, round((100 - sm['driverMonitoringState'].lockoutRecoveryPercent) / 100 * DMON_LOCKOUT_TIME * DT_DMON / 60.)) + mins_left = sm['driverMonitoringState'].lockoutMinutesRemaining return NoEntryAlert("Too Distracted", f"{mins_left} minute{'s' if mins_left != 1 else ''} Left", priority=Priority.HIGH) return NoEntryAlert("Pay Attention to Engage", priority=Priority.HIGH) diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index d5106cb67..699564660 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -96,12 +96,18 @@ class MiciHomeLayout(Widget): self._current_model_name = "default" self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) + self._egpu_label = UnifiedLabel("eGPU", font_size=30, text_color=rl.Color(0, 255, 96, 255), + font_weight=FontWeight.SEMI_BOLD, max_width=76, wrap_text=False) + self._egpu_label_gray = UnifiedLabel("eGPU", font_size=30, text_color=rl.GRAY, + font_weight=FontWeight.SEMI_BOLD, max_width=76, wrap_text=False) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._status_bar_layout = HBoxLayout([ IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, + self._egpu_label, + self._egpu_label_gray, self._mic_icon, ], spacing=18) @@ -211,6 +217,8 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(self._experimental_mode) + self._egpu_label.set_visible(ui_state.usbgpu_active) + self._egpu_label_gray.set_visible(ui_state.usbgpu and not ui_state.usbgpu_active) self._mic_icon.set_visible(ui_state.recording_audio) footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 01409be45..4bf5d4f3c 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -21,7 +21,7 @@ from openpilot.selfdrive.ui.mici.onroad.starpilot_status import ( ) from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_width -from openpilot.starpilot.common.favorite_slots import load_favorite_slots, toggle_favorite_slot +from openpilot.starpilot.common.favorite_slots import is_favorite_action_key, load_favorite_slots, toggle_favorite_slot from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.wrap_text import wrap_text @@ -262,7 +262,7 @@ class FavoriteSlotsOverlay(Widget): rl.draw_rectangle_rounded_lines_ex(panel_rect, 0.16, 12, 3, accent) label = slot.get("label") or slot.get("key") or "Favorite" - state_text = "ON" if self._feedback_value else "OFF" + state_text = "PRESS" if is_favorite_action_key(slot.get("key")) else ("ON" if self._feedback_value else "OFF") lines, font_size = self._fit_label(label, panel_rect.width - 20, panel_rect.height - 46) line_height = font_size * 1.08 label_height = len(lines) * line_height @@ -310,7 +310,7 @@ class FavoriteSlotsOverlay(Widget): self._feedback_started_at = rl.get_time() slot = dict(self._visible_slots()).get(self._pressed_slot, {}) key = slot.get("key") - self._feedback_value = not ui_state.params.get_bool(key) if key else None + self._feedback_value = None if is_favorite_action_key(key) else (not ui_state.params.get_bool(key) if key else None) self._interacting = True def _handle_mouse_event(self, mouse_event: MouseEvent): diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 221eb8ba0..8d6241f0e 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -81,6 +81,9 @@ class UIState: self.is_metric: bool = self.params.get_bool("IsMetric") self.is_release = self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") + self.usbgpu: bool = self.params.get_bool("UsbGpuPresent") + self.usbgpu_compiled: bool = self.params.get_bool("UsbGpuCompiled") + self.usbgpu_active: bool = self.params.get_bool("UsbGpuActive") self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False @@ -182,6 +185,9 @@ class UIState: self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") + self.usbgpu = self.params.get_bool("UsbGpuPresent") + self.usbgpu_compiled = self.params.get_bool("UsbGpuCompiled") + self.usbgpu_active = self.params.get_bool("UsbGpuActive") self.switchback_mode_enabled = self.params_memory.get_bool("SwitchbackModeEnabled") if self.started else False if self.sm.valid.get("starpilotCarState", False): starpilot_car_state = self.sm["starpilotCarState"] diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py index 2266b474c..f79f6012e 100644 --- a/starpilot/assets/model_manager.py +++ b/starpilot/assets/model_manager.py @@ -68,6 +68,19 @@ def model_key_aliases(model_key: str) -> list[str]: return [alias for alias in dict.fromkeys(alias for alias in aliases if alias)] +def load_model_artifact_metadata(model_key: str) -> dict: + try: + payload = json.loads((MODELS_PATH / ARTIFACT_METADATA_CACHE).read_text()) + metadata = payload.get(canonical_model_key(model_key), {}) if isinstance(payload, dict) else {} + return metadata if isinstance(metadata, dict) else {} + except (OSError, ValueError, TypeError): + return {} + + +def model_uses_external_gpu(model_key: str) -> bool: + return bool(load_model_artifact_metadata(model_key).get("uses_external_gpu", False)) + + class ModelManager: def __init__(self, params, params_memory, boot_run=False): self.params = params @@ -291,6 +304,7 @@ class ModelManager: "artifact_size": int(model.get("artifact_size") or 0), "artifact_sha256": str(model.get("artifact_sha256") or "").strip().lower(), "artifact_url": str(model.get("artifact_url") or model.get("download_url") or "").strip(), + "uses_external_gpu": bool(model.get("uses_external_gpu", False)), } return metadata diff --git a/starpilot/assets/tests/test_model_pipeline.py b/starpilot/assets/tests/test_model_pipeline.py index 859ff0337..20ad21504 100644 --- a/starpilot/assets/tests/test_model_pipeline.py +++ b/starpilot/assets/tests/test_model_pipeline.py @@ -1,7 +1,10 @@ import hashlib +import json +from scripts import model_compiler from scripts.model_compiler import split_oversized_artifact from openpilot.starpilot.assets import download_functions +from openpilot.starpilot.assets import model_manager from openpilot.starpilot.assets.model_manager import MANIFEST_CANDIDATES, ModelManager from openpilot.starpilot.common.model_versions import UNIFIED_ARTIFACT_FORMAT @@ -21,6 +24,42 @@ def test_behavior_version_does_not_control_artifact_layout(): assert manager._required_files("example", "split") == [] +def test_external_gpu_requirement_is_cached_from_manifest(tmp_path, monkeypatch): + monkeypatch.setattr(model_manager, "MODELS_PATH", tmp_path) + manager = object.__new__(ModelManager) + metadata = manager._build_artifact_metadata_map([ + {"id": "large", "artifact_format": UNIFIED_ARTIFACT_FORMAT, "uses_external_gpu": True}, + {"id": "normal", "artifact_format": UNIFIED_ARTIFACT_FORMAT}, + ]) + (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata)) + + assert model_manager.model_uses_external_gpu("large") + assert not model_manager.model_uses_external_gpu("normal") + assert not model_manager.model_uses_external_gpu("missing") + + +def test_external_gpu_compilation_is_opt_in(tmp_path, monkeypatch): + invocations = [] + monkeypatch.setattr(model_compiler, "build_compile_env", lambda: { + "DEV": "QCOM", "IMAGE": "2", "NOLOCALS": "1", "OPENPILOT_HACKS": "1", + }) + monkeypatch.setattr(model_compiler.subprocess, "run", lambda command, **kwargs: invocations.append((command, kwargs))) + files = {"driving_supercombo": tmp_path / "model.onnx"} + + model_compiler.compile_driving("normal", files, "supercombo", "v15", tmp_path, "policy") + model_compiler.compile_driving("large", files, "supercombo", "v15", tmp_path, "policy", external_gpu=True) + + normal_command, normal_kwargs = invocations[0] + external_command, external_kwargs = invocations[1] + assert "--out-of-band" not in normal_command + assert normal_kwargs["env"]["DEV"] == "QCOM" + assert normal_kwargs["env"]["IMAGE"] == "2" + assert "--out-of-band" in external_command + assert external_kwargs["env"]["DEV"] == "USB+AMD:LLVM" + assert external_kwargs["env"]["WARP_DEV"] == "QCOM" + assert all(flag not in external_kwargs["env"] for flag in ("IMAGE", "NOLOCALS", "OPENPILOT_HACKS")) + + def test_dropbox_urls_are_direct_downloads(): url = "https://www.dropbox.com/scl/fi/id/model.pkl?rlkey=key&st=value&dl=0" normalized = download_functions.normalize_download_url(url) diff --git a/starpilot/common/favorite_slots.py b/starpilot/common/favorite_slots.py index f09349588..04d0f62a8 100644 --- a/starpilot/common/favorite_slots.py +++ b/starpilot/common/favorite_slots.py @@ -10,6 +10,29 @@ from openpilot.common.params import ParamKeyType, Params FAVORITE_SLOTS_PARAM = "StarPilotFavoriteSlots" FAVORITE_SLOT_COUNT = 3 +FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:" +FAVORITE_ACTION_DISTANCE_DECREASE = f"{FAVORITE_ACTION_PREFIX}distance_decrease" +FAVORITE_ACTION_DISTANCE_INCREASE = f"{FAVORITE_ACTION_PREFIX}distance_increase" +FAVORITE_ACTION_DECEL_COUNTER = "FavoriteVirtualDecelCruiseCounter" +FAVORITE_ACTION_ACCEL_COUNTER = "FavoriteVirtualAccelCruiseCounter" +FAVORITE_ACTION_OPTIONS = ( + { + "key": FAVORITE_ACTION_DISTANCE_DECREASE, + "label": "Distance - / SET", + "description": "Acts like a short press of the car's SET/- cruise button.", + "section": "Actions", + "action": "decelCruise", + }, + { + "key": FAVORITE_ACTION_DISTANCE_INCREASE, + "label": "Distance + / RES", + "description": "Acts like a short press of the car's RES/+ cruise button.", + "section": "Actions", + "action": "accelCruise", + }, +) +FAVORITE_ACTION_KEYS = {option["key"] for option in FAVORITE_ACTION_OPTIONS} +FAVORITE_ACTION_LABELS = {option["key"]: option["label"] for option in FAVORITE_ACTION_OPTIONS} def default_favorite_slots() -> list[dict[str, Any]]: @@ -50,6 +73,23 @@ def _get_key_type(params: Params, key: str): return None +def is_favorite_action_key(key: str | None) -> bool: + return bool(key) and key in FAVORITE_ACTION_KEYS + + +def favorite_key_is_valid(params: Params, key: str | None, eligible_keys: Iterable[str] | None = None) -> bool: + if not key: + return False + + if is_favorite_action_key(key): + return True + + if eligible_keys is not None and key not in set(eligible_keys): + return False + + return _get_key_type(params, key) == ParamKeyType.BOOL + + def is_bool_param(params: Params, key: str | None, eligible_keys: Iterable[str] | None = None) -> bool: if not key: return False @@ -73,10 +113,15 @@ def normalize_favorite_slots(raw_slots: Any, params: Params | None = None, if key is not None: key = str(key).strip() or None - if key and ((eligible is not None and key not in eligible) or (params is not None and not is_bool_param(params, key))): + if key and is_favorite_action_key(key): + pass + elif key and ( + (eligible is not None and key not in eligible) or + (params is not None and not is_bool_param(params, key)) + ): key = None - label = str(raw_slot.get("label") or "").strip() + label = str(raw_slot.get("label") or FAVORITE_ACTION_LABELS.get(key, "")).strip() if len(label) > 32: label = label[:32].rstrip() @@ -111,6 +156,20 @@ def request_starpilot_toggle_refresh(params_memory: Params | None = None) -> Non params_memory.put_bool("StarPilotTogglesUpdated", True) +def trigger_favorite_action(key: str | None, params_memory: Params | None = None) -> bool: + if not is_favorite_action_key(key): + return False + + params_memory = params_memory or Params(memory=True) + counter_key = ( + FAVORITE_ACTION_ACCEL_COUNTER + if key == FAVORITE_ACTION_DISTANCE_INCREASE + else FAVORITE_ACTION_DECEL_COUNTER + ) + params_memory.put_int(counter_key, params_memory.get_int(counter_key) + 1) + return True + + def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None) -> bool: if slot_index < 0 or slot_index >= FAVORITE_SLOT_COUNT: return False @@ -119,7 +178,13 @@ def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_m slots = load_favorite_slots(params) slot = slots[slot_index] key = slot.get("key") - if not slot.get("enabled") or not is_bool_param(params, key): + if not slot.get("enabled"): + return False + + if is_favorite_action_key(key): + return trigger_favorite_action(key, params_memory) + + if not is_bool_param(params, key): return False next_value = not params.get_bool(key) diff --git a/starpilot/common/tests/test_favorite_slots.py b/starpilot/common/tests/test_favorite_slots.py index 398bc9d9c..517455e7a 100644 --- a/starpilot/common/tests/test_favorite_slots.py +++ b/starpilot/common/tests/test_favorite_slots.py @@ -1,5 +1,7 @@ from openpilot.common.params import ParamKeyType from openpilot.starpilot.common.favorite_slots import ( + FAVORITE_ACTION_ACCEL_COUNTER, + FAVORITE_ACTION_DISTANCE_INCREASE, FAVORITE_SLOTS_PARAM, default_favorite_slots, load_favorite_slots, @@ -31,6 +33,12 @@ class FakeParams: def put_bool_nonblocking(self, key, value): self.put_bool(key, value) + def get_int(self, key, default=0): + return int(self.store.get(key, default)) + + def put_int(self, key, value): + self.store[key] = int(value) + def get_type(self, key): return self.types.get(key, ParamKeyType.STRING) @@ -78,3 +86,14 @@ def test_toggle_favorite_slot_flips_bool_and_requests_refresh(): assert toggle_favorite_slot(0, params, memory) is True assert params.get_bool("RedneckCruise") is True assert memory.get_bool("StarPilotTogglesUpdated") is True + + +def test_toggle_favorite_slot_action_increments_virtual_button_counter(): + params = FakeParams() + memory = FakeParams() + params.put(FAVORITE_SLOTS_PARAM, [ + {"enabled": True, "show_onroad": True, "key": FAVORITE_ACTION_DISTANCE_INCREASE, "label": "Distance + / RES"}, + ]) + + assert toggle_favorite_slot(0, params, memory) is True + assert memory.get_int(FAVORITE_ACTION_ACCEL_COUNTER) == 1 diff --git a/starpilot/controls/tests/test_starpilot_card.py b/starpilot/controls/tests/test_starpilot_card.py index 671236b2e..8b5ce9b49 100644 --- a/starpilot/controls/tests/test_starpilot_card.py +++ b/starpilot/controls/tests/test_starpilot_card.py @@ -3,7 +3,11 @@ from types import SimpleNamespace from opendbc.car.chrysler.values import CAR as CHRYSLER_CAR from openpilot.common.params import ParamKeyType -from openpilot.starpilot.common.favorite_slots import FAVORITE_SLOTS_PARAM +from openpilot.starpilot.common.favorite_slots import ( + FAVORITE_ACTION_ACCEL_COUNTER, + FAVORITE_ACTION_DISTANCE_INCREASE, + FAVORITE_SLOTS_PARAM, +) from openpilot.starpilot.controls import starpilot_card as spc @@ -659,3 +663,18 @@ def test_favorite_wheel_action_toggles_hidden_onroad_slot(monkeypatch, tmp_path) assert card.params.get_bool("RedneckCruise") is True assert card.params_memory.get_bool("StarPilotTogglesUpdated") is True + + +def test_favorite_wheel_action_can_press_virtual_resume(monkeypatch, tmp_path): + monkeypatch.setattr(spc, "Params", FakeParams) + monkeypatch.setattr(spc, "is_FrogsGoMoo", lambda: False) + monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path) + + card = spc.StarPilotCard(SimpleNamespace(brand="gm"), SimpleNamespace(alternativeExperience=0)) + card.params.put(FAVORITE_SLOTS_PARAM, [ + {"enabled": True, "show_onroad": False, "key": FAVORITE_ACTION_DISTANCE_INCREASE, "label": "Distance + / RES"}, + ]) + + card.handle_button_event("lkas", make_sm(), make_toggles(favorite_1_via_lkas=True)) + + assert card.params_memory.get_int(FAVORITE_ACTION_ACCEL_COUNTER) == 1 diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index a699d65a0..9ab749503 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -1,7 +1,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js" import { hideSidebar } from "/assets/js/utils.js" -import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-live-values-1" +import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-actions-1" import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js" diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.css b/starpilot/system/the_galaxy/assets/components/tools/device_settings.css index cf089d20e..b65d368ac 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.css +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.css @@ -533,14 +533,19 @@ background: var(--color-gray-950); border: 2px solid var(--sidebar-border-color); border-radius: var(--border-radius-base); + box-sizing: border-box; + color: inherit; cursor: pointer; display: flex; + font-family: var(--font-body); gap: var(--gap-base); justify-content: space-between; min-height: 8.5rem; padding: 1.1rem; + text-align: left; transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast); user-select: none; + width: 100%; } .ds-favorite-quick-card:hover { @@ -607,6 +612,20 @@ transform: translateX(1.75rem); } +.ds-favorite-action-chip { + align-items: center; + background: var(--main-fg); + border-radius: var(--border-radius-base); + color: var(--color-black); + display: inline-flex; + flex-shrink: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + justify-content: center; + min-width: 4.25rem; + padding: 0.55rem 0.75rem; +} + .ds-favorite-card { background: var(--input-bg); border: var(--border-style-main); diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index 1e7dceaf0..392d9a3d2 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -8,6 +8,7 @@ const COLOR_UI_DEFAULTS = { PathColor: "#30ff9c", } const FAVORITE_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }) +const FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:" const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode" const HIDDEN_SECTION_NAMES = new Set(["Model & Customization"]) const HIDDEN_SETTING_KEYS = new Set(["DisableWideRoad", "HumanAcceleration", "ReverseCruise"]) @@ -566,6 +567,14 @@ function favoriteOptionMatchesFilter(option, filter) { .some(value => String(value || "").toLowerCase().includes(q)) } +function isFavoriteActionKey(key) { + return String(key || "").startsWith(FAVORITE_ACTION_PREFIX) +} + +function isFavoriteActionOption(option) { + return isFavoriteActionKey(option?.key) || !!option?.action +} + function filteredFavoriteOptions(index) { const filter = state.favoriteFilters[index] || "" return normalizeFavoriteOptions(state.favoriteOptions).filter(opt => favoriteOptionMatchesFilter(opt, filter)) @@ -738,6 +747,25 @@ async function updateFavoriteValue(key, checked, sourceEl = null) { } } +async function activateFavoriteAction(key) { + try { + const res = await fetch("/api/favorites/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }) + const data = await res.json() + + if (res.ok) { + showParamSnackbar(data.message || "Favorite action sent.") + } else { + showParamSnackbar(data.error || "Failed to send favorite action", "error") + } + } catch (e) { + showParamSnackbar("Network error — is the device reachable?", "error") + } +} + function stepPrecision(step, explicitPrecision) { if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") { const parsed = Number.parseInt(explicitPrecision, 10) @@ -1248,15 +1276,31 @@ function renderFavoriteSlotsPanel() { const selectedOption = favorite.selectedOption const selectedKey = favorite.selectedKey const selectedValue = favorite.selectedValue + const isAction = isFavoriteActionOption(selectedOption) + const quickCopy = html` +
+ Favorite #${favorite.index + 1} + ${selectedOption.label || favorite.slot.label || selectedKey} + ${selectedOption.section ? html`${selectedOption.section}` : ""} + ${selectedOption.description ? html`${selectedOption.description}` : ""} +
+ ` + + if (isAction) { + return html` + + ` + } return html`