friar carl

This commit is contained in:
firestar5683
2026-03-30 02:56:11 -05:00
parent 675227a8eb
commit fe4f42a616
99 changed files with 8362 additions and 36 deletions
Binary file not shown.
+10
View File
@@ -328,6 +328,13 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"MapsSelected", {PERSISTENT, STRING, "", "", 0}},
{"MapSpeedLimit", {CLEAR_ON_MANAGER_START, FLOAT, "0.0", "0.0"}},
{"NextMapSpeedLimit", {CLEAR_ON_MANAGER_START, JSON, "{}", "{}"}},
{"VisionSpeedLimit", {CLEAR_ON_MANAGER_START, FLOAT, "0.0", "0.0"}},
{"VisionSpeedLimitConfidence", {CLEAR_ON_MANAGER_START, FLOAT, "0.0", "0.0"}},
{"VisionSpeedLimitBookmarkCount", {CLEAR_ON_MANAGER_START, INT, "0", "0"}},
{"VisionSpeedLimitDebugSession", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"VisionSpeedLimitLastEvent", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"VisionSpeedLimitStatus", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"VisionSpeedLimitStream", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"MaxDesiredAcceleration", {PERSISTENT, FLOAT, "4.0", "2.0", 2}},
{"MinimumBackupSize", {PERSISTENT, INT, "0", "0"}},
{"MinimumLaneChangeSpeed", {PERSISTENT, FLOAT, "20.0", "20.0", 2}},
@@ -449,6 +456,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"SpeedLimits", {PERSISTENT | DONT_LOG, JSON, "[]", "[]"}},
{"SpeedLimitsFiltered", {PERSISTENT | DONT_LOG, JSON, "[]", "[]"}},
{"SpeedLimitSources", {PERSISTENT, BOOL, "0", "0", 3}},
{"VisionSpeedLimitAutoBookmark", {PERSISTENT, BOOL, "1", "0", 0}},
{"VisionSpeedLimitAutoPreserveSegment", {PERSISTENT, BOOL, "0", "0", 0}},
{"VisionSpeedLimitDetection", {PERSISTENT, BOOL, "0", "0", 0}},
{"StandardFollow", {PERSISTENT, FLOAT, "1.45", "1.45", 2}},
{"StandardFollowHigh", {PERSISTENT, FLOAT, "1.2", "1.2", 2}},
{"StandardJerkAcceleration", {PERSISTENT, FLOAT, "50.0", "50.0", 3}},
Binary file not shown.
+304
View File
@@ -0,0 +1,304 @@
# Train Speed-Limit Vision
This flow is for replacing the current imported `ayoubsa_best` checkpoint with a U.S.-focused detector plus a separate posted-speed classifier.
The intended recipe is:
1. bootstrap from public traffic-sign data
2. fine-tune on comma-specific bookmarked drives
3. export ONNX models
4. copy them onto the device for runtime testing
## Why Two Models
The detector and the value reader solve different problems.
- detector: find the sign and decide whether it is a regulatory speed-limit sign
- classifier: read the posted value from the cropped sign
This is a better fit for U.S. roads than a single detector with baked-in classes like `Speed Limit 10`, `Speed Limit 20`, `Speed Limit 30`, and so on.
## Suggested Public Data
- `LISA` for U.S. roadside sign geometry and sign style
- `ARTS` for U.S. MUTCD-style sign annotations and values
- `GLARE` for U.S. glare/lighting failures
Use public data to get the model into the right regime, then fine-tune on comma bookmarks and replay clips.
## Install Training Deps
From the repo root:
```bash
uv sync --extra speedvision
```
The runtime device does not need these packages. Only the training machine does.
## Initialize a Workspace
```bash
.venv/bin/python scripts/speed_limit_vision/init_workspace.py
```
This creates `.tmp/speed_limit_training` with:
- detector image/label folders
- classifier crop folders
- review/bookmark manifests
- raw-source manifests
- export and run directories
To keep the raw datasets off the internal disk, point `--workspace` at the SSD-backed workspace, for example:
```bash
.venv/bin/python scripts/speed_limit_vision/init_workspace.py \
--workspace /Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean
```
The workspace now also tracks:
- `manifests/raw_sources.csv`
- `manifests/public_detector_samples.csv`
- `manifests/public_classifier_samples.csv`
Those manifests are the provenance record for every imported public sample.
## Import Public Datasets
ARTS challenging subset:
```bash
.venv/bin/python scripts/speed_limit_vision/import_arts_challenging.py \
--workspace /Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean
```
This imports mapped speed-limit signs from the raw `challenging-dev.tar.gz` archive and appends detector/classifier provenance rows into the manifest CSVs.
GLARE and LISA should be downloaded into the SSD raw tree first:
- `/Volumes/T5/starpilot_speed_limit/raw/glare_official`
- `/Volumes/T5/starpilot_speed_limit/raw/lisa_official`
Then import them into the same workspace so the detector/classifier datasets stay source-traceable.
For GLARE, do not pull the whole Drive tree blindly. Use the filtered raw downloader so only the `Images/` and optional `Tracks/` files land on disk:
```bash
.venv/bin/python scripts/speed_limit_vision/download_glare_raw.py \
--workspace /Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean \
--output-root /Volumes/T5/starpilot_speed_limit/raw/glare_raw \
--prefix Images/ \
--resume
```
Then import the completed image tree:
```bash
.venv/bin/python scripts/speed_limit_vision/import_glare_images.py \
--workspace /Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean \
--overwrite
```
## Import Bookmarked Debug Sessions
After a drive, copy or mount the debug session directory locally, then import it:
```bash
.venv/bin/python scripts/speed_limit_vision/import_debug_sessions.py
```
Or point at specific sessions:
```bash
.venv/bin/python scripts/speed_limit_vision/import_debug_sessions.py 20260330_220102 20260330_223355
```
This writes:
- `review/bookmarks.csv`
- snapshot images under `review/images`
That manifest is the shortlist for labeling.
If the route only lives on comma connect, fetch it directly into the same clip layout the bookmark tools expect:
```bash
.venv/bin/python scripts/speed_limit_vision/download_connect_routes.py \
<dongle_id>/<route_log_id> \
--streams fcamera,qlog
```
This reads the JWT from `~/.comma/auth.json`, downloads the requested route files from comma connect, and updates:
- `/Volumes/T5/starpilot_speed_limit/live_route_clips/bookmark_windows/data/media/0/realdata/...` when the SSD is mounted, otherwise the same `.tmp/...` paths under the repo
- `/Volumes/T5/starpilot_speed_limit/live_routes_meta/qlog_mtimes.txt` when the SSD is mounted, otherwise `.tmp/live_routes_meta/qlog_mtimes.txt`
- `/Volumes/T5/starpilot_speed_limit/live_routes_meta/files.txt` when the SSD is mounted, otherwise `.tmp/live_routes_meta/files.txt`
For multiple routes, either pass several ids on the command line or use `--routes-file`.
## Evaluate Real Sign Lead-Ins
Bookmark stills are often too late. To score what matters, replay the real `fcamera.hevc` footage from the `5` seconds before each bookmark:
```bash
.venv/bin/python scripts/speed_limit_vision/evaluate_bookmark_leadins.py \
--json-out .tmp/live_route_clips/bookmark_windows_report.json
```
This produces a per-bookmark report of whether the current runtime saw anything in the usable sign approach window.
The evaluator now reads a local session-to-route map from `session_route_map.json` under the same `live_routes_meta` root. Keep that file local or on the SSD so personal route ids never need to live in the repo.
## Import Missed Lead-Ins for Labeling
Turn those lead-in misses into review frames and contact sheets:
```bash
.venv/bin/python scripts/speed_limit_vision/import_bookmark_leadins.py \
--mode misses
```
This writes:
- `review/bookmark_leadins.csv`
- sampled frames under `review/leadins/frames`
- contact sheets under `review/leadins/contact_sheets`
That review set is the right source for labeling missed `55 mph`, night, and town-sequence failures.
The bookmark/lead-in importers also accept source metadata fields such as region, device, and driver. Use those when importing debug sessions from multiple users so the comma-specific fine-tune can be sliced by contributor or geography instead of becoming one opaque pool.
To shrink that review set to the most promising frames per missed sign window:
```bash
.venv/bin/python scripts/speed_limit_vision/rank_bookmark_leadin_frames.py
```
This writes `review/bookmark_leadin_shortlist.csv` with the top-ranked sampled frames per bookmark.
## Build the Detector Dataset
Take the imported review images and move or copy the ones you want into:
- `detector/images/train`
- `detector/images/val`
Label them in YOLO detect format into:
- `detector/labels/train`
- `detector/labels/val`
Recommended classes:
- `regulatory_speed_limit`
- `advisory_speed_limit`
- `school_zone_speed_limit`
The dataset YAML is already generated at:
- `detector/dataset.yaml`
## Build the Value Classifier Dataset
Fill out:
- `classifier/value_labels.csv`
Columns:
- `image_path`: source image file
- `split`: `train` or `val`
- `speed_limit_mph`: posted value such as `25`, `35`, or `55`
- `bbox_index`: which YOLO box to crop if an image has multiple labeled signs
- `padding`: optional crop padding ratio
- `label_path`: optional explicit path to the YOLO label file
Then generate the classifier crop folders:
```bash
.venv/bin/python scripts/speed_limit_vision/build_value_dataset.py
```
This writes cropped sign images into:
- `classifier/train/<value>/...`
- `classifier/val/<value>/...`
## Train
Detector:
```bash
.venv/bin/python scripts/speed_limit_vision/train_detector.py --device mps
```
Classifier:
```bash
.venv/bin/python scripts/speed_limit_vision/train_value_classifier.py --device mps
```
Use `--device cpu`, `--device mps`, or a CUDA device string depending on the training host.
## Rebalance Toward Real Comma Data
If the detector starts overfitting to synthetic/public data, build a lighter rebalanced dataset that keeps all `real_*` detector images and samples the rest:
```bash
.venv/bin/python scripts/speed_limit_vision/rebalance_detector_dataset.py \
--workspace .tmp/speed_limit_training \
--max-other-train 3200
```
Then point the detector trainer at the generated YAML:
```bash
.venv/bin/python scripts/speed_limit_vision/train_detector.py \
--workspace .tmp/speed_limit_training \
--data .tmp/speed_limit_training/detector_rebalanced/dataset.yaml \
--device mps
```
This keeps validation unchanged while making retrains faster and more comma-biased.
## Export ONNX
```bash
.venv/bin/python scripts/speed_limit_vision/export_models.py \
--detector-weights .tmp/speed_limit_training/runs/detector/yolo11n-speed-limit-us/weights/best.pt \
--classifier-weights .tmp/speed_limit_training/runs/classifier/yolo11n-cls-speed-limit-us/weights/best.pt \
--install-repo-assets
```
That writes:
- `.tmp/speed_limit_training/exports/speed_limit_us_detector.onnx`
- `.tmp/speed_limit_training/exports/speed_limit_us_value_classifier.onnx`
And optionally copies them into:
- `starpilot/assets/vision_models`
## Copy to the Device
```bash
.venv/bin/python scripts/speed_limit_vision/install_models.py --host comma@192.168.3.110
```
The runtime already prefers `speed_limit_us_detector.onnx` plus `speed_limit_us_value_classifier.onnx` when both files exist in `starpilot/assets/vision_models`.
## Evaluate the Runtime Path
Run the real StarPilot runtime path, using the installed ONNX pair, against the known saved-frame cases:
```bash
.venv/bin/python scripts/speed_limit_vision/evaluate_runtime_cases.py --strict
```
For temporal behavior on a saved frame directory or route extract, replay the runtime directly:
```bash
.venv/bin/python scripts/replay_speed_limit_vision.py .tmp/vision_iter/seg10_5fps --frames-fps 5
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,2 +1,2 @@
extern const uint8_t gitversion[19];
const uint8_t gitversion[19] = "DEV-523a26be-DEBUG";
const uint8_t gitversion[19] = "DEV-f7ee2e65-DEBUG";
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
DEV-523a26be-DEBUG
DEV-f7ee2e65-DEBUG
+7 -1
View File
@@ -45,6 +45,7 @@ dependencies = [
# modeld
"onnx >= 1.14.0",
"opencv-python-headless",
# logging
"pyzmq",
@@ -111,7 +112,6 @@ dev = [
"dbus-next", # TODO: remove once we moved everything to jeepney
"dictdiffer",
"matplotlib",
"opencv-python-headless",
"parameterized >=0.8, <0.9",
"pyautogui",
"pygame",
@@ -129,6 +129,12 @@ tools = [
"dearpygui>=2.1.0; (sys_platform != 'linux' or platform_machine != 'aarch64')", # not vended for linux aarch64
]
speedvision = [
"torch>=2.4",
"torchvision>=0.19",
"ultralytics>=8.3,<9",
]
[project.urls]
Homepage = "https://github.com/commaai/openpilot"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
DEBUG_BASE_DIR = Path("/data/media/0/vision_speed_limit_debug")
def load_events(session_path: Path):
events_path = session_path / "events.jsonl"
if not events_path.is_file():
raise FileNotFoundError(f"Missing events file: {events_path}")
events = []
with events_path.open("r", encoding="utf-8") as log_file:
for line in log_file:
line = line.strip()
if not line:
continue
events.append(json.loads(line))
return events
def resolve_session(session_arg: str | None) -> Path | None:
if session_arg:
session_path = Path(session_arg)
if session_path.is_dir():
return session_path
candidate = DEBUG_BASE_DIR / session_arg
if candidate.is_dir():
return candidate
raise FileNotFoundError(f"Session not found: {session_arg}")
if not DEBUG_BASE_DIR.is_dir():
return None
sessions = sorted((path for path in DEBUG_BASE_DIR.iterdir() if path.is_dir()), reverse=True)
return sessions[0] if sessions else None
def list_sessions():
if not DEBUG_BASE_DIR.is_dir():
print(f"No debug sessions found in {DEBUG_BASE_DIR}")
return
sessions = sorted((path for path in DEBUG_BASE_DIR.iterdir() if path.is_dir()), reverse=True)
if not sessions:
print(f"No debug sessions found in {DEBUG_BASE_DIR}")
return
for session_path in sessions:
events_path = session_path / "events.jsonl"
event_count = 0
bookmark_count = 0
if events_path.is_file():
with events_path.open("r", encoding="utf-8") as log_file:
for line in log_file:
if not line.strip():
continue
event_count += 1
if '"event":"bookmark"' in line:
bookmark_count += 1
print(f"{session_path.name}: {event_count} events, {bookmark_count} bookmarks")
def print_event(event: dict):
fields = [
event.get("wallTime", ""),
event.get("event", ""),
]
if event.get("sessionSeconds") is not None:
fields.append(f"t+{event['sessionSeconds']}s")
if event.get("roadName"):
fields.append(f"road={event['roadName']}")
if event.get("speedLimitMph"):
fields.append(f"speed={event['speedLimitMph']} mph")
if event.get("candidateSpeedLimitMph"):
fields.append(f"candidate={event['candidateSpeedLimitMph']} mph")
if event.get("confidence"):
fields.append(f"conf={event['confidence']}")
if event.get("candidateConfidence"):
fields.append(f"candidateConf={event['candidateConfidence']}")
if event.get("statusText"):
fields.append(f"status={event['statusText']}")
elif event.get("status"):
fields.append(f"status={event['status']}")
if event.get("snapshot"):
fields.append(f"snapshot={event['snapshot']}")
print(" | ".join(str(field) for field in fields if field != ""))
def summarize_session(session_path: Path, window: int):
events = load_events(session_path)
print(f"Session: {session_path}")
print(f"Events: {len(events)}")
bookmarks = [idx for idx, event in enumerate(events) if event.get("event") == "bookmark"]
if not bookmarks:
print("Bookmarks: none")
return
print(f"Bookmarks: {len(bookmarks)}")
for bookmark_number, event_idx in enumerate(bookmarks, start=1):
print(f"\nBookmark {bookmark_number}")
start = max(event_idx - window, 0)
end = min(event_idx + window + 1, len(events))
for idx in range(start, end):
prefix = "->" if idx == event_idx else " "
print(prefix, end="")
print_event(events[idx])
def main():
parser = argparse.ArgumentParser(description="Summarize StarPilot speed-limit vision debug sessions.")
parser.add_argument("session", nargs="?", help="Session id or full path. Defaults to the latest session.")
parser.add_argument("--list", action="store_true", help="List available sessions and exit.")
parser.add_argument("--window", type=int, default=5, help="How many events before/after each bookmark to print.")
args = parser.parse_args()
if args.list:
list_sessions()
return
session_path = resolve_session(args.session)
if session_path is None:
print(f"No debug sessions found in {DEBUG_BASE_DIR}")
return
summarize_session(session_path, max(args.window, 0))
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import cv2
import starpilot.system.speed_limit_vision as slv
class ReplayDaemon(slv.SpeedLimitVisionDaemon):
def __init__(self):
super().__init__(use_runtime=False)
self.now = 0.0
def _write_debug_event(self, event_type, frame_bgr=None, snapshot_prefix=None, **fields):
if event_type in ("candidate", "publish", "stale_clear"):
print(f"t={self.now:6.2f}s {event_type:12} {fields}")
def _publish_status(self, status, clear_speed=False):
if clear_speed:
self._clear_detection()
def _publish_detection(self, speed_limit_mph, confidence, status_prefix):
super()._publish_detection(speed_limit_mph, confidence, status_prefix)
def process_frame(self, now, frame_bgr):
self.now = now
slv.time.monotonic = lambda now=now: now
self.current_frame_bgr = frame_bgr
detection = self._detect_sign(frame_bgr)
if detection is not None:
self._update_detection(detection)
elif self.published_speed_limit_mph > 0 and self._published_detection_stale(now):
print(f"t={self.now:6.2f}s stale_clear {{'reason': 'no_detection'}}")
self._clear_detection()
def iter_directory_frames(path: Path, fps: float):
for index, frame_path in enumerate(sorted(path.glob("frame_*.png")), start=1):
frame = cv2.imread(str(frame_path))
if frame is None:
continue
yield (index - 1) / fps, frame
def iter_video_frames(path: Path):
cap = cv2.VideoCapture(str(path))
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
frame_index = 0
while True:
ok, frame = cap.read()
if not ok:
break
yield frame_index / fps, frame
frame_index += 1
cap.release()
def main():
parser = argparse.ArgumentParser(description="Replay StarPilot speed-limit vision on saved video or extracted frames.")
parser.add_argument("path", help="Path to an fcamera.hevc file or a directory of frame_XXX.png images.")
parser.add_argument("--frames-fps", type=float, default=5.0, help="FPS to assume when replaying an extracted frame directory.")
parser.add_argument("--start", type=float, default=0.0, help="Skip frames before this timestamp in seconds.")
parser.add_argument("--end", type=float, default=None, help="Stop once this timestamp in seconds is exceeded.")
args = parser.parse_args()
path = Path(args.path)
if not path.exists():
raise FileNotFoundError(path)
daemon = ReplayDaemon()
frame_iter = iter_directory_frames(path, max(args.frames_fps, 0.1)) if path.is_dir() else iter_video_frames(path)
for now, frame_bgr in frame_iter:
if now < args.start:
continue
if args.end is not None and now > args.end:
break
daemon.process_frame(now, frame_bgr)
if __name__ == "__main__":
main()
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import random
from dataclasses import dataclass
from pathlib import Path
import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
from generate_value_roi_classifier_dataset import augment_mask, extract_value_mask # type: ignore
else:
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
from .generate_value_roi_classifier_dataset import augment_mask, extract_value_mask
@dataclass(frozen=True)
class ExampleSpec:
name: str
speed_limit_mph: int
image_path: str | None = None
frame_path: str | None = None
bbox: tuple[int, int, int, int] | None = None
DEFAULT_EXAMPLES = (
ExampleSpec(
name="live15_runtime",
speed_limit_mph=15,
frame_path=".tmp/live_c4_capture/stopped_sign_road.jpg",
bbox=(725, 253, 768, 314),
),
ExampleSpec(
name="school20_crop",
speed_limit_mph=20,
image_path=".tmp/route_vision/frame_041_sign_tight.jpg",
),
ExampleSpec(
name="town30_crop",
speed_limit_mph=30,
image_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
),
ExampleSpec(
name="town30_late_runtime",
speed_limit_mph=30,
frame_path=".tmp/vision_iter/seg10_5fps/frame_054.png",
bbox=(887, 275, 931, 378),
),
ExampleSpec(
name="town40_crop",
speed_limit_mph=40,
image_path=".tmp/route_12c_seg9_10/seg10_real40_crop.png",
),
ExampleSpec(
name="highway40_crop",
speed_limit_mph=40,
image_path=".tmp/speed_route_frames_seg2_10_20/t12_sign_crop.png",
),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Inject curated real runtime-style masks into the classifier dataset.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--variants-per-example", type=int, default=80, help="Augmented mask variants to generate per example.")
parser.add_argument("--seed", type=int, default=20260330, help="Random seed.")
return parser.parse_args()
def load_crop(spec: ExampleSpec):
if spec.image_path:
image = cv2.imread(spec.image_path)
if image is None:
raise FileNotFoundError(spec.image_path)
return image
if spec.frame_path and spec.bbox:
frame = cv2.imread(spec.frame_path)
if frame is None:
raise FileNotFoundError(spec.frame_path)
x1, y1, x2, y2 = spec.bbox
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
raise ValueError(f"{spec.name}: empty crop for bbox {spec.bbox}")
return crop
raise ValueError(f"{spec.name}: provide image_path or frame_path+bbox")
def save_mask(workspace: Path, split: str, speed_limit_mph: int, stem: str, mask_bgr) -> None:
output_dir = ensure_dir(workspace / "classifier" / split / str(speed_limit_mph))
cv2.imwrite(str(output_dir / f"{stem}.png"), mask_bgr)
def remove_appledouble_files(root: Path) -> None:
for path in root.rglob("._*"):
if path.is_file() or path.is_symlink():
path.unlink()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
rng = random.Random(args.seed)
written = 0
for spec in DEFAULT_EXAMPLES:
crop = load_crop(spec)
mask = extract_value_mask(crop)
if mask is None:
print(f"{spec.name}: skipped, no mask extracted")
continue
base_mask = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
save_mask(workspace, "train", spec.speed_limit_mph, f"real_runtime_{spec.name}_base", base_mask)
written += 1
for variant_index in range(max(args.variants_per_example, 0)):
split = "val" if variant_index % 7 == 0 else "train"
augmented = augment_mask(mask, rng)
save_mask(workspace, split, spec.speed_limit_mph, f"real_runtime_{spec.name}_{variant_index:03d}", augmented)
written += 1
print(f"{spec.name}: added {1 + max(args.variants_per_example, 0)} mask(s) for {spec.speed_limit_mph} mph")
remove_appledouble_files(workspace / "classifier" / "train")
remove_appledouble_files(workspace / "classifier" / "val")
print(f"Wrote {written} classifier mask image(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import random
from pathlib import Path
import cv2
import numpy as np
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
def parse_label(label_path: Path, image_shape: tuple[int, int, int]):
lines = [line.strip() for line in label_path.read_text(encoding="utf-8").splitlines() if line.strip()]
if len(lines) != 1:
raise ValueError(f"Expected exactly one box in {label_path}")
class_id, x_center, y_center, width, height = lines[0].split()
image_height, image_width = image_shape[:2]
x_center = float(x_center) * image_width
y_center = float(y_center) * image_height
width = float(width) * image_width
height = float(height) * image_height
x1 = x_center - width / 2
y1 = y_center - height / 2
x2 = x_center + width / 2
y2 = y_center + height / 2
return int(class_id), np.array([x1, y1, x2, y2], dtype=np.float32)
def write_label(label_path: Path, class_id: int, box: np.ndarray, image_shape: tuple[int, int, int]):
image_height, image_width = image_shape[:2]
x1, y1, x2, y2 = box.tolist()
x_center = ((x1 + x2) / 2) / image_width
y_center = ((y1 + y2) / 2) / image_height
width = (x2 - x1) / image_width
height = (y2 - y1) / image_height
label_path.write_text(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n", encoding="utf-8")
def random_motion_blur(image: np.ndarray, rng: random.Random):
size = rng.choice((3, 5, 7))
if rng.random() < 0.5:
kernel = np.zeros((size, size), dtype=np.float32)
kernel[size // 2, :] = 1.0 / size
else:
kernel = np.zeros((size, size), dtype=np.float32)
kernel[:, size // 2] = 1.0 / size
return cv2.filter2D(image, -1, kernel)
def augment_image(image: np.ndarray, box: np.ndarray, rng: random.Random):
image_height, image_width = image.shape[:2]
scale = rng.uniform(0.92, 1.08)
translate_x = rng.uniform(-0.05, 0.05) * image_width
translate_y = rng.uniform(-0.04, 0.04) * image_height
center = (image_width / 2, image_height / 2)
matrix = cv2.getRotationMatrix2D(center, rng.uniform(-1.5, 1.5), scale)
matrix[0, 2] += translate_x
matrix[1, 2] += translate_y
warped = cv2.warpAffine(
image,
matrix,
(image_width, image_height),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
corners = np.array([
[box[0], box[1], 1.0],
[box[2], box[1], 1.0],
[box[2], box[3], 1.0],
[box[0], box[3], 1.0],
], dtype=np.float32)
transformed = corners @ matrix.T
x_coords = transformed[:, 0]
y_coords = transformed[:, 1]
warped_box = np.array([
np.clip(np.min(x_coords), 0, image_width - 1),
np.clip(np.min(y_coords), 0, image_height - 1),
np.clip(np.max(x_coords), 0, image_width - 1),
np.clip(np.max(y_coords), 0, image_height - 1),
], dtype=np.float32)
alpha = rng.uniform(0.85, 1.18)
beta = rng.uniform(-18.0, 16.0)
augmented = cv2.convertScaleAbs(warped, alpha=alpha, beta=beta)
if rng.random() < 0.55:
augmented = cv2.GaussianBlur(augmented, (3, 3), rng.uniform(0.1, 1.0))
if rng.random() < 0.35:
augmented = random_motion_blur(augmented, rng)
if rng.random() < 0.45:
noise = rng.uniform(4.0, 12.0)
augmented = np.clip(augmented.astype(np.float32) + np.random.normal(0.0, noise, augmented.shape), 0, 255).astype(np.uint8)
return augmented, warped_box
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Oversample bootstrapped real detector frames with light augmentation.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--split", default="train", choices=("train", "val"), help="Detector split to augment.")
parser.add_argument("--variants-per-image", type=int, default=80, help="How many augmented variants to generate for each real_*.jpg frame.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
image_dir = workspace / "detector" / "images" / args.split
label_dir = workspace / "detector" / "labels" / args.split
ensure_dir(image_dir)
ensure_dir(label_dir)
rng = random.Random(42)
base_images = sorted(image_dir.glob("real_*.jpg"))
created = 0
for image_path in base_images:
label_path = label_dir / f"{image_path.stem}.txt"
image = cv2.imread(str(image_path))
if image is None or not label_path.is_file():
continue
class_id, box = parse_label(label_path, image.shape)
for variant_index in range(args.variants_per_image):
augmented, warped_box = augment_image(image, box, rng)
if warped_box[2] - warped_box[0] < 10 or warped_box[3] - warped_box[1] < 12:
continue
output_stem = f"{image_path.stem}_aug_{variant_index:03d}"
output_image = image_dir / f"{output_stem}.jpg"
output_label = label_dir / f"{output_stem}.txt"
cv2.imwrite(str(output_image), augmented, [cv2.IMWRITE_JPEG_QUALITY, 92])
write_label(output_label, class_id, warped_box, augmented.shape)
created += 1
print(f"Augmented {len(base_images)} real detector images into {created} variants")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass
from pathlib import Path
import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
@dataclass(frozen=True)
class ExampleSpec:
name: str
detector_class: int
frame_path: str | None = None
frame_dir: str | None = None
template_path: str | None = None
bbox_override: tuple[int, int, int, int] | None = None
DEFAULT_EXAMPLES = (
ExampleSpec(
name="route_seg2_t12_speed40",
detector_class=0,
frame_path=".tmp/speed_route_frames_seg2_10_20/t12.png",
template_path=".tmp/speed_route_frames_seg2_10_20/t12_sign_crop.png",
),
ExampleSpec(
name="route_seg38_school20",
detector_class=2,
frame_path=".tmp/route_vision/seg38_frames/frame_041.jpg",
template_path=".tmp/route_vision/frame_041_sign_tight.jpg",
),
ExampleSpec(
name="route_seg10_early_speed40",
detector_class=0,
frame_path=".tmp/route_12c_seg9_10/seg10_early/frame_005.png",
template_path=".tmp/route_12c_seg9_10/seg10_real40_crop.png",
),
ExampleSpec(
name="route_seg10_early_speed30",
detector_class=0,
frame_path=".tmp/route_12c_seg9_10/seg10_early/frame_012.png",
template_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
),
ExampleSpec(
name="route_seg10_late_speed30",
detector_class=0,
frame_path=".tmp/vision_iter/seg10_5fps/frame_054.png",
template_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
bbox_override=(885, 250, 941, 386),
),
ExampleSpec(
name="route_seg10_late_speed30_clear",
detector_class=0,
frame_path=".tmp/vision_iter/seg10_5fps/frame_052.png",
template_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
bbox_override=(829, 284, 870, 382),
),
ExampleSpec(
name="live_capture_speed15",
detector_class=0,
frame_path=".tmp/live_c4_capture/stopped_sign_road.jpg",
template_path=".tmp/live_c4_capture/stopped_sign_crop_manual.png",
bbox_override=(724, 258, 763, 307),
),
)
def detector_label_line(detector_class: int, x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> str:
image_h, image_w = image_shape[:2]
x_center = ((x1 + x2) / 2) / image_w
y_center = ((y1 + y2) / 2) / image_h
width = (x2 - x1) / image_w
height = (y2 - y1) / image_h
return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
def match_template(frame: cv2.typing.MatLike, template: cv2.typing.MatLike):
result = cv2.matchTemplate(frame, template, cv2.TM_CCOEFF_NORMED)
_, max_value, _, max_location = cv2.minMaxLoc(result)
template_h, template_w = template.shape[:2]
x1, y1 = max_location
x2 = x1 + template_w
y2 = y1 + template_h
return float(max_value), (x1, y1, x2, y2)
def resolve_match(spec: ExampleSpec):
if spec.template_path is None:
raise FileNotFoundError(f"{spec.name}: missing template_path")
template = cv2.imread(spec.template_path)
if template is None:
raise FileNotFoundError(f"{spec.name}: failed to read template {spec.template_path}")
if spec.frame_path:
frame = cv2.imread(spec.frame_path)
if frame is None:
raise FileNotFoundError(f"{spec.name}: failed to read frame {spec.frame_path}")
if spec.bbox_override is not None:
return Path(spec.frame_path), frame, 1.0, spec.bbox_override
confidence, bbox = match_template(frame, template)
return Path(spec.frame_path), frame, confidence, bbox
if spec.frame_dir:
best = None
for frame_path in sorted(Path(spec.frame_dir).glob("*")):
frame = cv2.imread(str(frame_path))
if frame is None:
continue
if frame.shape[0] < template.shape[0] or frame.shape[1] < template.shape[1]:
continue
confidence, bbox = match_template(frame, template)
if best is None or confidence > best[2]:
best = (frame_path, frame, confidence, bbox)
if best is None:
raise FileNotFoundError(f"{spec.name}: no readable frames found in {spec.frame_dir}")
return best
raise ValueError(f"{spec.name}: one of frame_path or frame_dir must be provided")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import a few known real comma sign examples into the detector dataset.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--split", default="train", choices=("train", "val"), help="Detector dataset split to populate.")
parser.add_argument("--manifest", type=Path, help="Optional CSV manifest path. Defaults to <workspace>/review/real_detector_examples.csv.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
split = args.split
image_dir = ensure_dir(workspace / "detector" / "images" / split)
label_dir = ensure_dir(workspace / "detector" / "labels" / split)
manifest_path = args.manifest.resolve() if args.manifest else (ensure_dir(workspace / "review") / "real_detector_examples.csv")
records: list[dict[str, object]] = []
for spec in DEFAULT_EXAMPLES:
frame_path, frame_bgr, confidence, (x1, y1, x2, y2) = resolve_match(spec)
stem = f"real_{spec.name}"
image_path = image_dir / f"{stem}.jpg"
label_path = label_dir / f"{stem}.txt"
cv2.imwrite(str(image_path), frame_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
label_path.write_text(detector_label_line(spec.detector_class, x1, y1, x2, y2, frame_bgr.shape), encoding="utf-8")
records.append({
"name": spec.name,
"split": split,
"source_frame": str(frame_path),
"template_path": spec.template_path or "",
"template_match_confidence": round(confidence, 6),
"detector_class": spec.detector_class,
"bbox_x1": x1,
"bbox_y1": y1,
"bbox_x2": x2,
"bbox_y2": y2,
"dataset_image": str(image_path),
"dataset_label": str(label_path),
})
with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file:
fieldnames = [
"name",
"split",
"source_frame",
"template_path",
"template_match_confidence",
"detector_class",
"bbox_x1",
"bbox_y1",
"bbox_x2",
"bbox_y2",
"dataset_image",
"dataset_label",
]
writer = csv.DictWriter(manifest_file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(records)
print(f"Imported {len(records)} real detector examples into {split} split")
print(f"Manifest: {manifest_path}")
for record in records:
print(
f"{record['name']}: conf={record['template_match_confidence']} "
f"bbox=({record['bbox_x1']},{record['bbox_y1']},{record['bbox_x2']},{record['bbox_y2']})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from pathlib import Path
import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, VALUE_LABEL_FIELDS, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, VALUE_LABEL_FIELDS, ensure_dir, resolve_workspace
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build a classifier crop dataset from detector labels and a value label manifest.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--labels-csv", type=Path, help="CSV manifest describing which labeled detector images map to which posted speed values.")
parser.add_argument("--default-padding", type=float, default=0.10, help="Default crop padding ratio when a row does not provide one.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing classifier crops.")
return parser.parse_args()
def load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as csv_file:
reader = csv.DictReader(csv_file)
missing = [field for field in VALUE_LABEL_FIELDS if field not in (reader.fieldnames or [])]
if missing:
raise ValueError(f"Missing required CSV columns: {', '.join(missing)}")
return [row for row in reader if (row.get("image_path") or "").strip()]
def resolve_image_path(workspace: Path, image_path_text: str) -> Path:
image_path = Path(image_path_text).expanduser()
if image_path.is_file():
return image_path.resolve()
candidate = (workspace / image_path_text).resolve()
if candidate.is_file():
return candidate
basename = Path(image_path_text).name
for search_root in (workspace / "detector" / "images", workspace / "review" / "images"):
if not search_root.is_dir():
continue
for found in search_root.rglob(basename):
if found.is_file():
return found.resolve()
raise FileNotFoundError(f"Image not found: {image_path_text}")
def resolve_label_path(workspace: Path, image_path: Path, label_path_text: str, split: str) -> Path:
if label_path_text:
label_path = Path(label_path_text).expanduser()
if label_path.is_file():
return label_path.resolve()
candidate = (workspace / label_path_text).resolve()
if candidate.is_file():
return candidate
raise FileNotFoundError(f"Label path not found: {label_path_text}")
train_label = workspace / "detector" / "labels" / split / f"{image_path.stem}.txt"
if train_label.is_file():
return train_label.resolve()
for split_name in ("train", "val"):
candidate = workspace / "detector" / "labels" / split_name / f"{image_path.stem}.txt"
if candidate.is_file():
return candidate.resolve()
raise FileNotFoundError(f"Detector label not found for {image_path.name}")
def parse_yolo_labels(label_path: Path) -> list[tuple[int, float, float, float, float]]:
boxes = []
with label_path.open("r", encoding="utf-8") as label_file:
for raw_line in label_file:
line = raw_line.strip()
if not line:
continue
class_id, x_center, y_center, width, height = line.split(maxsplit=4)
boxes.append((int(class_id), float(x_center), float(y_center), float(width), float(height)))
return boxes
def crop_box(image, yolo_box: tuple[int, float, float, float, float], padding: float):
_, x_center, y_center, width, height = yolo_box
image_height, image_width = image.shape[:2]
box_width = width * image_width
box_height = height * image_height
pad_width = box_width * padding
pad_height = box_height * padding
x1 = max(int(round((x_center * image_width) - box_width / 2 - pad_width)), 0)
y1 = max(int(round((y_center * image_height) - box_height / 2 - pad_height)), 0)
x2 = min(int(round((x_center * image_width) + box_width / 2 + pad_width)), image_width)
y2 = min(int(round((y_center * image_height) + box_height / 2 + pad_height)), image_height)
if x2 <= x1 or y2 <= y1:
raise ValueError("Resolved crop has no area")
return image[y1:y2, x1:x2]
def remove_appledouble_files(root: Path) -> None:
for path in root.rglob("._*"):
if path.is_file() or path.is_symlink():
path.unlink()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
labels_csv = args.labels_csv.resolve() if args.labels_csv else (workspace / "classifier" / "value_labels.csv")
rows = load_rows(labels_csv)
built = 0
for row in rows:
split = (row.get("split") or "train").strip().lower()
if split not in ("train", "val"):
raise ValueError(f"Unsupported split '{split}' in {labels_csv}")
speed_limit = (row.get("speed_limit_mph") or "").strip()
if not speed_limit:
raise ValueError(f"Missing speed_limit_mph for image {row['image_path']}")
bbox_index = int((row.get("bbox_index") or "0").strip())
padding_text = (row.get("padding") or "").strip()
padding = float(padding_text) if padding_text else args.default_padding
image_path = resolve_image_path(workspace, row["image_path"])
label_path = resolve_label_path(workspace, image_path, (row.get("label_path") or "").strip(), split)
boxes = parse_yolo_labels(label_path)
if bbox_index >= len(boxes):
raise IndexError(f"bbox_index {bbox_index} out of range for {label_path}")
image = cv2.imread(str(image_path))
if image is None:
raise RuntimeError(f"Failed to read {image_path}")
crop = crop_box(image, boxes[bbox_index], padding)
output_dir = ensure_dir(workspace / "classifier" / split / speed_limit)
output_path = output_dir / f"{image_path.stem}_bbox{bbox_index}.jpg"
if output_path.exists() and not args.overwrite:
continue
cv2.imwrite(str(output_path), crop)
built += 1
remove_appledouble_files(workspace / "classifier")
print(f"Built {built} classifier crop(s) into {workspace / 'classifier'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
from __future__ import annotations
import csv
import json
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_WORKSPACE = REPO_ROOT / ".tmp" / "speed_limit_training"
DEFAULT_DEBUG_BASE = Path("/data/media/0/vision_speed_limit_debug")
REPO_ASSET_DIR = REPO_ROOT / "starpilot" / "assets" / "vision_models"
DEFAULT_LOCAL_CLIP_ROOT = REPO_ROOT / ".tmp" / "live_route_clips" / "bookmark_windows" / "data" / "media" / "0" / "realdata"
DEFAULT_LOCAL_QLOG_MTIMES = REPO_ROOT / ".tmp" / "live_routes_meta" / "qlog_mtimes.txt"
DEFAULT_LOCAL_FILES_MANIFEST = REPO_ROOT / ".tmp" / "live_routes_meta" / "files.txt"
DEFAULT_LOCAL_SESSION_ROUTE_MAP = REPO_ROOT / ".tmp" / "live_routes_meta" / "session_route_map.json"
DEFAULT_EXTERNAL_ROOT = Path("/Volumes/T5/starpilot_speed_limit")
DETECTOR_CLASS_NAMES = (
"regulatory_speed_limit",
"advisory_speed_limit",
"school_zone_speed_limit",
)
DEFAULT_SPEED_VALUES = (15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75)
DETECTOR_EXPORT_NAME = "speed_limit_us_detector.onnx"
CLASSIFIER_EXPORT_NAME = "speed_limit_us_value_classifier.onnx"
def resolve_workspace(path: str | Path | None) -> Path:
return Path(path).expanduser().resolve() if path else DEFAULT_WORKSPACE
def preferred_external_root() -> Path | None:
return DEFAULT_EXTERNAL_ROOT if DEFAULT_EXTERNAL_ROOT.is_dir() else None
def preferred_analysis_root() -> Path | None:
external_root = preferred_external_root()
if external_root is None:
return None
analysis_root = external_root / "analysis"
return analysis_root if analysis_root.is_dir() else external_root
def preferred_clip_root() -> Path:
external_root = preferred_analysis_root()
if external_root is not None:
return external_root / "live_route_clips" / "bookmark_windows" / "data" / "media" / "0" / "realdata"
return DEFAULT_LOCAL_CLIP_ROOT
def preferred_qlog_mtimes_path() -> Path:
external_root = preferred_analysis_root()
if external_root is not None:
return external_root / "live_routes_meta" / "qlog_mtimes.txt"
return DEFAULT_LOCAL_QLOG_MTIMES
def preferred_files_manifest_path() -> Path:
external_root = preferred_analysis_root()
if external_root is not None:
return external_root / "live_routes_meta" / "files.txt"
return DEFAULT_LOCAL_FILES_MANIFEST
def preferred_session_route_map_path() -> Path:
external_root = preferred_analysis_root()
if external_root is not None:
return external_root / "live_routes_meta" / "session_route_map.json"
return DEFAULT_LOCAL_SESSION_ROUTE_MAP
def load_session_route_map(path: str | Path | None = None) -> dict[str, str]:
route_map_path = Path(path).expanduser().resolve() if path else preferred_session_route_map_path()
if not route_map_path.is_file():
return {}
data = json.loads(route_map_path.read_text(encoding="utf-8"))
return {str(key): str(value) for key, value in data.items() if key and value}
def default_raw_root(workspace: str | Path | None = None) -> Path:
resolved = resolve_workspace(workspace)
if resolved.parent.name == "workspace":
return resolved.parent.parent / "raw"
return resolved / "raw"
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def write_text(path: Path, text: str, force: bool = False) -> None:
if path.exists() and not force:
return
ensure_dir(path.parent)
path.write_text(text, encoding="utf-8")
def write_csv_header(path: Path, fieldnames: list[str], force: bool = False) -> None:
if path.exists() and not force:
return
ensure_dir(path.parent)
with path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
def read_jsonl(path: Path) -> list[dict]:
records: list[dict] = []
with path.open("r", encoding="utf-8") as jsonl_file:
for line in jsonl_file:
line = line.strip()
if line:
records.append(json.loads(line))
return records
def latest_debug_sessions(debug_base: Path, count: int = 1) -> list[Path]:
if not debug_base.is_dir():
return []
sessions = sorted((path for path in debug_base.iterdir() if path.is_dir()), reverse=True)
return sessions[:max(count, 0)]
def detector_dataset_yaml(workspace: Path) -> str:
detector_root = workspace / "detector"
lines = [
f"path: {detector_root}",
"train: images/train",
"val: images/val",
"names:",
]
for index, class_name in enumerate(DETECTOR_CLASS_NAMES):
lines.append(f" {index}: {class_name}")
return "\n".join(lines) + "\n"
def workspace_readme(speed_values: tuple[int, ...]) -> str:
supported_values = ", ".join(str(value) for value in speed_values)
return f"""# Speed Limit Vision Training Workspace
This workspace is generated by `scripts/speed_limit_vision/init_workspace.py`.
Directory layout:
- `detector/images/train` and `detector/images/val`: detector training images
- `detector/labels/train` and `detector/labels/val`: YOLO detector labels
- `classifier/value_labels.csv`: value labels used to crop detector boxes into classifier folders
- `classifier/train` and `classifier/val`: classifier-ready crop folders
- `review/images`: imported snapshots from live debug sessions
- `review/bookmarks.csv`: bookmark/publish/candidate manifest built from debug sessions
- `review/leadins/frames` and `review/leadins/contact_sheets`: sampled frames from 5-second pre-bookmark route windows
- `review/bookmark_leadins.csv`: manifest describing sampled pre-bookmark review frames
- `exports`: exported ONNX models
- `runs`: training outputs
Suggested detector classes:
- `regulatory_speed_limit`
- `advisory_speed_limit`
- `school_zone_speed_limit`
Suggested classifier values:
- `{supported_values}`
Generated manifests:
- `manifests/raw_sources.csv`: raw public/comma source provenance
- `manifests/public_detector_samples.csv`: imported detector samples and sign metadata
- `manifests/public_classifier_samples.csv`: imported classifier-ready value samples
"""
BOOKMARK_MANIFEST_FIELDS = [
"record_key",
"source_name",
"source_region",
"source_device",
"source_driver",
"session_id",
"event_index",
"event",
"session_seconds",
"wall_time",
"road_name",
"stream",
"status",
"candidate_speed_limit_mph",
"candidate_confidence",
"speed_limit_mph",
"confidence",
"published_speed_limit_mph",
"published_confidence",
"bookmark_count",
"snapshot_path",
"source_session_path",
]
BOOKMARK_LEADIN_MANIFEST_FIELDS = [
"record_key",
"source_name",
"source_region",
"source_device",
"source_driver",
"session_id",
"bookmark_number",
"route",
"segment",
"segment_offset_s",
"leadin_start_s",
"sample_offset_s",
"window_result",
"published_values",
"candidate_values",
"frame_path",
"contact_sheet_path",
"source_video_path",
]
VALUE_LABEL_FIELDS = [
"image_path",
"split",
"speed_limit_mph",
"bbox_index",
"padding",
"label_path",
]
RAW_SOURCE_FIELDS = [
"source_name",
"source_version",
"source_license",
"source_type",
"raw_path",
"notes",
]
PUBLIC_DETECTOR_SAMPLE_FIELDS = [
"record_key",
"source_name",
"split",
"image_path",
"label_path",
"annotation_path",
"source_image_id",
"class_name",
"speed_limit_mph",
"sign_code",
"bbox_left",
"bbox_top",
"bbox_right",
"bbox_bottom",
]
PUBLIC_CLASSIFIER_SAMPLE_FIELDS = [
"record_key",
"source_name",
"split",
"image_path",
"speed_limit_mph",
"bbox_index",
"label_path",
"source_image_id",
"sign_code",
]
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import urlparse
import requests
from scripts.speed_limit_vision import common
API_HOST = os.getenv("COMMA_API_HOST", "https://api.commadotai.com")
DEFAULT_FILES_MANIFEST = common.preferred_files_manifest_path()
STREAM_FILE_NAMES = {
"fcamera": {"fcamera.hevc"},
"qlog": {"qlog.zst", "qlog.bz2", "qlog"},
"rlog": {"rlog.zst", "rlog.bz2", "rlog"},
"qcamera": {"qcamera.ts"},
"dcamera": {"dcamera.hevc"},
"ecamera": {"ecamera.hevc"},
}
@dataclass(frozen=True)
class RouteRequest:
dongle_id: str
log_id: str
segment_filter: set[int] | None
@property
def canonical_name(self) -> str:
return f"{self.dongle_id}|{self.log_id}"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Download route files directly from comma connect into the speed-limit review layout.")
parser.add_argument("routes", nargs="*", help="Route ids like 'dongle/logid' or segment ids like 'dongle/logid--9'.")
parser.add_argument("--routes-file", type=Path, help="Optional file containing one route id per line.")
parser.add_argument("--clip-root", type=Path, default=common.preferred_clip_root(), help="Destination root for downloaded segment directories.")
parser.add_argument("--qlog-mtimes", type=Path, default=common.preferred_qlog_mtimes_path(), help="Path to qlog mtime manifest used by bookmark replay.")
parser.add_argument("--files-manifest", type=Path, default=DEFAULT_FILES_MANIFEST, help="Path to downloaded-files manifest.")
parser.add_argument("--streams", default="fcamera,qlog", help="Comma-separated stream set: fcamera,qlog,rlog,qcamera,dcamera,ecamera.")
parser.add_argument("--segments", help="Optional segment filter, e.g. '0,2,5-8'.")
parser.add_argument("--overwrite", action="store_true", help="Redownload files even if they already exist locally.")
parser.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds.")
return parser.parse_args()
def read_token() -> str:
token = os.getenv("COMMA_JWT", "").strip()
if token:
return token
auth_path = Path.home() / ".comma" / "auth.json"
if not auth_path.is_file():
raise FileNotFoundError(f"Missing auth token at {auth_path}. Run python3 tools/lib/auth.py first or set COMMA_JWT.")
auth = json.loads(auth_path.read_text(encoding="utf-8"))
token = auth.get("access_token", "").strip()
if not token:
raise ValueError(f"No access_token in {auth_path}")
return token
def parse_segment_spec(spec: str | None) -> set[int] | None:
if not spec:
return None
selected: set[int] = set()
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
start_text, end_text = part.split("-", 1)
start = int(start_text)
end = int(end_text)
selected.update(range(min(start, end), max(start, end) + 1))
else:
selected.add(int(part))
return selected
def load_route_inputs(args: argparse.Namespace) -> list[str]:
raw_routes = list(args.routes)
if args.routes_file:
raw_routes.extend(line.strip() for line in args.routes_file.expanduser().resolve().read_text(encoding="utf-8").splitlines())
if not raw_routes:
raise ValueError("No routes provided.")
return [route for route in raw_routes if route and not route.lstrip().startswith("#")]
def parse_route_request(raw: str, default_segments: set[int] | None) -> RouteRequest:
text = raw.strip().strip("'\"")
text = text.replace("|", "/")
match = re.fullmatch(r"([0-9a-f]{16})/([^/]+)", text)
if not match:
raise ValueError(f"Unrecognized route id: {raw}")
dongle_id = match.group(1)
tail = match.group(2)
segment_filter = set(default_segments) if default_segments else None
parts = tail.split("--")
if len(parts) == 3 and parts[-1].isdigit():
log_id = "--".join(parts[:2])
segment = int(parts[-1])
if segment_filter is None:
segment_filter = {segment}
else:
segment_filter.add(segment)
else:
log_id = tail
if len(log_id) != 20:
raise ValueError(f"Invalid log id in route: {raw}")
return RouteRequest(dongle_id=dongle_id, log_id=log_id, segment_filter=segment_filter)
def merge_route_requests(raw_routes: list[str], default_segments: set[int] | None) -> list[RouteRequest]:
merged: dict[tuple[str, str], set[int] | None] = {}
for raw in raw_routes:
request = parse_route_request(raw, default_segments)
key = (request.dongle_id, request.log_id)
if key not in merged:
merged[key] = None if request.segment_filter is None else set(request.segment_filter)
continue
if merged[key] is None or request.segment_filter is None:
merged[key] = None
else:
merged[key].update(request.segment_filter)
return [RouteRequest(dongle_id=dongle_id, log_id=log_id, segment_filter=segments) for (dongle_id, log_id), segments in sorted(merged.items())]
def selected_file_names(streams_csv: str) -> set[str]:
selected: set[str] = set()
for stream in streams_csv.split(","):
stream = stream.strip()
if not stream:
continue
if stream not in STREAM_FILE_NAMES:
raise ValueError(f"Unknown stream '{stream}'. Valid streams: {', '.join(sorted(STREAM_FILE_NAMES))}")
selected.update(STREAM_FILE_NAMES[stream])
return selected
def api_get_json(session: requests.Session, endpoint: str, timeout: float):
response = session.get(f"{API_HOST}/{endpoint.lstrip('/')}", timeout=timeout)
response.raise_for_status()
return response.json()
def iter_route_file_urls(files_payload: dict, file_names: set[str]):
for value in files_payload.values():
if not isinstance(value, list):
continue
for url in value:
parsed = urlparse(url)
parts = parsed.path.strip("/").split("/")
if len(parts) < 4:
continue
segment_text = parts[-2]
file_name = parts[-1]
if not segment_text.isdigit() or file_name not in file_names:
continue
yield int(segment_text), file_name, url
def download_to_path(session: requests.Session, url: str, dest_path: Path, overwrite: bool, timeout: float) -> int | None:
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and not overwrite:
return int(dest_path.stat().st_mtime)
response = session.get(url, stream=True, timeout=timeout)
response.raise_for_status()
temp_path = dest_path.with_suffix(dest_path.suffix + ".part")
with temp_path.open("wb") as handle:
for chunk in response.iter_content(chunk_size=1 << 20):
if chunk:
handle.write(chunk)
temp_path.replace(dest_path)
last_modified = response.headers.get("Last-Modified")
if last_modified:
epoch = int(parsedate_to_datetime(last_modified).timestamp())
os.utime(dest_path, (epoch, epoch))
return epoch
return int(dest_path.stat().st_mtime)
def load_manifest_lines(path: Path) -> dict[str, str]:
if not path.is_file():
return {}
lines: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line:
continue
key = line.split(" ", 1)[0]
lines[key] = line
return lines
def write_manifest(path: Path, lines: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for key in sorted(lines):
handle.write(lines[key] + "\n")
def main() -> int:
args = parse_args()
token = read_token()
route_requests = merge_route_requests(load_route_inputs(args), parse_segment_spec(args.segments))
file_names = selected_file_names(args.streams)
clip_root = args.clip_root.expanduser().resolve()
qlog_mtimes_path = args.qlog_mtimes.expanduser().resolve()
files_manifest_path = args.files_manifest.expanduser().resolve()
api_session = requests.Session()
api_session.headers.update({
"Authorization": f"JWT {token}",
"User-Agent": "OpenpilotTools",
})
download_session = requests.Session()
download_session.headers.update({
"User-Agent": "OpenpilotTools",
})
qlog_lines = load_manifest_lines(qlog_mtimes_path)
file_lines = load_manifest_lines(files_manifest_path)
for request in route_requests:
route_meta = api_get_json(api_session, f"v1/route/{request.canonical_name}", timeout=args.timeout)
files_payload = api_get_json(api_session, f"v1/route/{request.canonical_name}/files", timeout=args.timeout)
log_id = request.log_id
start_time = route_meta.get("start_time", "")
print(f"{request.canonical_name}: downloading streams={sorted(file_names)} segments={sorted(request.segment_filter) if request.segment_filter else 'all'}")
for segment, file_name, url in iter_route_file_urls(files_payload, file_names):
if request.segment_filter is not None and segment not in request.segment_filter:
continue
segment_name = f"{log_id}--{segment}"
segment_dir = clip_root / segment_name
dest_path = segment_dir / file_name
epoch = download_to_path(download_session, url, dest_path, args.overwrite, args.timeout)
print(f" {segment_name}/{file_name} <- {urlparse(url).netloc}")
file_lines[f"{segment_name} {dest_path}"] = f"{segment_name} {dest_path}"
if file_name.startswith("qlog") and epoch is not None:
qlog_lines[str(dest_path)] = f"{dest_path} {epoch}"
if start_time:
print(f" route start_time={start_time}")
write_manifest(qlog_mtimes_path, qlog_lines)
write_manifest(files_manifest_path, file_lines)
print(f"Updated {qlog_mtimes_path}")
print(f"Updated {files_manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import importlib
from pathlib import Path
import gdown
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, default_raw_root, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, default_raw_root, ensure_dir, resolve_workspace
GLARE_ROOT_URL = "https://drive.google.com/drive/folders/1gmoOSgvjR4DP7jGfGS_xAmxcMShyeThx?usp=sharing"
DEFAULT_PREFIXES = ("Images/", "Tracks/")
MANIFEST_FIELDS = ["file_id", "relative_path", "local_path"]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Download the raw GLARE image/track files without checkpoint artifacts.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--output-root", type=Path, help="Download destination. Defaults to <raw>/glare_raw.")
parser.add_argument("--prefix", dest="prefixes", nargs="+", default=list(DEFAULT_PREFIXES), help="Path prefixes to keep from the GLARE Drive tree.")
parser.add_argument("--manifest", type=Path, help="Optional CSV path for the filtered file manifest.")
parser.add_argument("--list-only", action="store_true", help="List matching files without downloading them.")
parser.add_argument("--resume", action="store_true", help="Resume partial downloads and skip completed files.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
output_root = args.output_root.resolve() if args.output_root else (default_raw_root(workspace) / "glare_raw")
manifest_path = args.manifest.resolve() if args.manifest else (output_root / "manifest.csv")
ensure_dir(output_root)
download_folder_mod = importlib.import_module("gdown.download_folder")
entries = download_folder_mod.download_folder(
url=GLARE_ROOT_URL,
skip_download=True,
quiet=True,
remaining_ok=True,
)
if entries is None:
raise RuntimeError("Failed to enumerate the GLARE Drive tree")
selected = [entry for entry in entries if any(entry.path.startswith(prefix) for prefix in args.prefixes)]
print(f"Matched {len(selected)} GLARE file(s) under prefixes: {', '.join(args.prefixes)}")
with manifest_path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=MANIFEST_FIELDS)
writer.writeheader()
for entry in selected:
local_path = output_root / entry.path
writer.writerow({
"file_id": entry.id,
"relative_path": entry.path,
"local_path": str(local_path),
})
if args.list_only:
continue
ensure_dir(local_path.parent)
gdown.download(
id=entry.id,
output=str(local_path),
quiet=False,
resume=args.resume,
)
print(f"GLARE manifest: {manifest_path}")
print(f"GLARE output: {output_root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import cv2
import starpilot.system.speed_limit_vision as slv
from scripts.speed_limit_vision import common
DEFAULT_SESSION_ROOT = Path(".tmp/live_drive_debug")
DEFAULT_CLIP_ROOT = common.preferred_clip_root()
DEFAULT_QLOG_MTIMES = common.preferred_qlog_mtimes_path()
EVENT_TYPES = ("candidate", "publish", "stale_clear")
@dataclass(frozen=True)
class BookmarkWindow:
bookmark_number: int
route: str
segment: int
segment_offset_s: float
leadin_start_s: float
spans_previous_segment: bool
class LiveReplayDaemon(slv.SpeedLimitVisionDaemon):
def __init__(self):
super().__init__(use_runtime=False)
self.now = 0.0
self.captured_events: list[dict] = []
def _write_debug_event(self, event_type, frame_bgr=None, snapshot_prefix=None, **fields):
if event_type not in EVENT_TYPES:
return
record = {"event": event_type, "t": round(self.now, 3)}
record.update(fields)
self.captured_events.append(record)
def _publish_status(self, status, clear_speed=False):
if clear_speed:
self._clear_detection()
def process_frame(self, now, frame_bgr):
self.now = now
slv.time.monotonic = lambda now=now: now
self.current_frame_bgr = frame_bgr
inference_interval = slv.FOLLOWUP_INFERENCE_INTERVAL if now < self.followup_until else slv.INFERENCE_INTERVAL
if now - self.last_inference_at < inference_interval:
if self.published_speed_limit_mph > 0 and self._published_detection_stale(now):
self._write_debug_event("stale_clear", reason="hold_timeout")
self._publish_status("Scanning replay", clear_speed=True)
return
self.last_inference_at = now
detection = self._detect_sign(frame_bgr)
if detection is not None:
self._update_detection(detection)
elif self.published_speed_limit_mph > 0 and self._published_detection_stale(now):
self._write_debug_event("stale_clear", reason="no_detection")
self._publish_status("Scanning replay", clear_speed=True)
def parse_args():
parser = argparse.ArgumentParser(description="Replay 5-second pre-bookmark sign windows through the live speed-limit vision path.")
parser.add_argument("--session-root", type=Path, default=DEFAULT_SESSION_ROOT, help="Directory containing debug session folders.")
parser.add_argument("--clip-root", type=Path, default=DEFAULT_CLIP_ROOT, help="Directory containing copied route clips under <route>--<seg>/fcamera.hevc.")
parser.add_argument("--qlog-mtimes", type=Path, default=DEFAULT_QLOG_MTIMES, help="Text file with '<qlog path> <mtime epoch>' lines.")
parser.add_argument("--session-route-map", type=Path, default=common.preferred_session_route_map_path(), help="JSON file mapping debug session ids to route log ids.")
parser.add_argument("--models-dir", type=Path, help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.")
parser.add_argument("--lead-in", type=float, default=5.0, help="Seconds before each bookmark to replay.")
parser.add_argument("--sample-fps", type=float, help="Optional decode sample rate. Use 5 for faster bookmark sweeps that still match the live inference cadence.")
parser.add_argument("--session", action="append", help="Optional session id filter. Repeat to run more than one.")
parser.add_argument("--bookmark", action="append", type=int, help="Optional bookmark number filter within the selected sessions.")
parser.add_argument("--json-out", type=Path, help="Optional path to write the summary JSON.")
return parser.parse_args()
def load_qlog_mtimes(path: Path):
route_mtimes: dict[str, dict[int, int]] = {}
pattern = re.compile(r"/([^/]+)--(\d+)/(qlog(?:\.(?:zst|bz2))?)$")
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
qlog_path, timestamp = line.rsplit(" ", 1)
match = pattern.search(qlog_path)
if match is None:
continue
route, segment = match.group(1), int(match.group(2))
route_mtimes.setdefault(route, {})[segment] = int(timestamp)
return route_mtimes
def load_bookmarks(session_path: Path):
events = []
with (session_path / "events.jsonl").open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
event = json.loads(line)
if event.get("event") in ("bookmark", "auto_bookmark"):
events.append(event)
return events
def locate_window(route: str, event: dict, route_mtimes: dict[str, dict[int, int]], lead_in: float):
event_wall_time = datetime.fromisoformat(event["wallTime"]).timestamp()
segment_mtimes = route_mtimes.get(route, {})
for segment, start_epoch in sorted(segment_mtimes.items()):
if start_epoch <= event_wall_time < start_epoch + 60:
offset_s = event_wall_time - start_epoch
return BookmarkWindow(
bookmark_number=0,
route=route,
segment=segment,
segment_offset_s=offset_s,
leadin_start_s=offset_s - lead_in,
spans_previous_segment=offset_s - lead_in < 0.0,
)
return None
def iter_video_window(path: Path, start_s: float, end_s: float, sample_fps: float | None = None):
capture = cv2.VideoCapture(str(path))
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
start_frame = max(int(start_s * fps), 0)
end_frame = max(int(end_s * fps), start_frame)
frame_step = 1
if sample_fps is not None and sample_fps > 0.0 and sample_fps < fps:
frame_step = max(int(round(fps / sample_fps)), 1)
capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
frame_index = start_frame
while frame_index <= end_frame:
ok, frame_bgr = capture.read()
if not ok:
break
frame_time_s = frame_index / fps
if start_s <= frame_time_s <= end_s:
yield frame_time_s, frame_bgr
frame_index += 1
skipped = 1
while skipped < frame_step and frame_index <= end_frame:
ok = capture.grab()
if not ok:
capture.release()
return
frame_index += 1
skipped += 1
capture.release()
def replay_window(window: BookmarkWindow, clip_root: Path, sample_fps: float | None = None):
daemon = LiveReplayDaemon()
elapsed_base_s = 0.0
replayed_frames = 0
segments: list[tuple[Path, float, float]] = []
if window.spans_previous_segment:
previous_segment = window.segment - 1
if previous_segment >= 0:
previous_clip = clip_root / f"{window.route}--{previous_segment}" / "fcamera.hevc"
previous_start_s = 60.0 + window.leadin_start_s
segments.append((previous_clip, max(previous_start_s, 0.0), 60.0))
elapsed_base_s += max(60.0 - max(previous_start_s, 0.0), 0.0)
current_start_s = 0.0
else:
current_start_s = window.leadin_start_s
current_clip = clip_root / f"{window.route}--{window.segment}" / "fcamera.hevc"
segments.append((current_clip, max(current_start_s, 0.0), window.segment_offset_s))
cumulative_offset_s = 0.0
for clip_path, start_s, end_s in segments:
if not clip_path.is_file():
return {
"missingClip": str(clip_path),
"events": [],
"replayedFrames": replayed_frames,
}
for frame_time_s, frame_bgr in iter_video_window(clip_path, start_s, end_s, sample_fps=sample_fps):
replay_time_s = cumulative_offset_s + (frame_time_s - start_s)
daemon.process_frame(replay_time_s, frame_bgr)
replayed_frames += 1
cumulative_offset_s += max(end_s - start_s, 0.0)
candidate_values = [event["candidateSpeedLimitMph"] for event in daemon.captured_events if event["event"] == "candidate"]
published_values = [event["speedLimitMph"] for event in daemon.captured_events if event["event"] == "publish"]
return {
"events": daemon.captured_events,
"candidateValues": candidate_values,
"publishedValues": published_values,
"replayedFrames": replayed_frames,
"hit": bool(candidate_values or published_values),
}
def main():
args = parse_args()
selected_sessions = set(args.session or [])
selected_bookmarks = set(args.bookmark or [])
route_mtimes = load_qlog_mtimes(args.qlog_mtimes.expanduser().resolve())
session_route_map = common.load_session_route_map(args.session_route_map)
if not session_route_map:
raise FileNotFoundError(f"No session route map found at {args.session_route_map}")
if args.models_dir:
models_dir = args.models_dir.expanduser().resolve()
detector_path = models_dir / "speed_limit_us_detector.onnx"
classifier_path = models_dir / "speed_limit_us_value_classifier.onnx"
if not detector_path.is_file():
raise FileNotFoundError(detector_path)
if not classifier_path.is_file():
raise FileNotFoundError(classifier_path)
slv.US_DETECTOR_MODEL_PATH = detector_path
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
summaries = []
for session_id, route in session_route_map.items():
if selected_sessions and session_id not in selected_sessions:
continue
session_path = args.session_root.expanduser().resolve() / session_id
if not session_path.is_dir():
continue
bookmarks = load_bookmarks(session_path)
for bookmark_number, event in enumerate(bookmarks, start=1):
if selected_bookmarks and bookmark_number not in selected_bookmarks:
continue
window = locate_window(route, event, route_mtimes, args.lead_in)
if window is None:
summary = {
"sessionId": session_id,
"bookmarkNumber": bookmark_number,
"route": route,
"status": "unmapped",
}
else:
window = BookmarkWindow(
bookmark_number=bookmark_number,
route=window.route,
segment=window.segment,
segment_offset_s=window.segment_offset_s,
leadin_start_s=window.leadin_start_s,
spans_previous_segment=window.spans_previous_segment,
)
replay_summary = replay_window(window, args.clip_root.expanduser().resolve(), sample_fps=args.sample_fps)
summary = {
"sessionId": session_id,
"bookmarkNumber": bookmark_number,
"route": route,
"segment": window.segment,
"segmentOffsetS": round(window.segment_offset_s, 3),
"leadinStartS": round(window.leadin_start_s, 3),
"spansPreviousSegment": window.spans_previous_segment,
}
summary.update(replay_summary)
summaries.append(summary)
hit_count = sum(1 for summary in summaries if summary.get("hit"))
print(f"Bookmarks with detections in lead-in: {hit_count}/{len(summaries)}")
for summary in summaries:
if summary.get("status") == "unmapped":
print(f"{summary['sessionId']} bookmark {summary['bookmarkNumber']:02d}: unmapped")
continue
result = "hit" if summary.get("hit") else "miss"
publish_values = ",".join(str(value) for value in summary.get("publishedValues", [])) or "-"
candidate_values = ",".join(str(value) for value in summary.get("candidateValues", [])) or "-"
note = ""
if summary.get("missingClip"):
note = f" missing={summary['missingClip']}"
print(
f"{summary['sessionId']} bookmark {summary['bookmarkNumber']:02d}: "
f"seg {summary['segment']} @ {summary['segmentOffsetS']:.2f}s "
f"lead-in [{summary['leadinStartS']:.2f}s, {summary['segmentOffsetS']:.2f}s] "
f"{result} publish={publish_values} candidate={candidate_values}{note}"
)
if args.json_out:
args.json_out.expanduser().resolve().write_text(json.dumps(summaries, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import cv2
import numpy as np
from ultralytics import YOLO
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_SPEED_VALUES # type: ignore
from generate_value_roi_classifier_dataset import extract_value_mask # type: ignore
else:
from .common import DEFAULT_SPEED_VALUES
from .generate_value_roi_classifier_dataset import extract_value_mask
DETECTOR_SIGN_CLASSES = {
"regulatory_speed_limit",
"school_zone_speed_limit",
"speedLimit15",
"speedLimit20",
"speedLimit25",
"speedLimit30",
"speedLimit35",
"speedLimit40",
"speedLimit45",
"speedLimit50",
"speedLimit55",
"speedLimit60",
"speedLimit65",
"speedLimit70",
"speedLimit75",
"schoolSpeedLimit25",
"speedLimit55Ahead",
}
def iter_frames(path: Path):
if path.is_dir():
for frame_path in sorted(path.glob("frame_*")):
frame = cv2.imread(str(frame_path))
if frame is not None:
yield frame_path.name, frame
return
cap = cv2.VideoCapture(str(path))
fps = cap.get(cv2.CAP_PROP_FPS) or 20.0
frame_index = 0
while True:
ok, frame = cap.read()
if not ok:
break
yield f"t={frame_index / fps:06.2f}s", frame
frame_index += 1
cap.release()
def crop_with_margin(frame: np.ndarray, xyxy: np.ndarray, margin_ratio: float = 0.16):
frame_h, frame_w = frame.shape[:2]
x1, y1, x2, y2 = xyxy.astype(int)
box_w = x2 - x1
box_h = y2 - y1
margin_x = int(box_w * margin_ratio)
margin_y = int(box_h * margin_ratio)
left = max(x1 - margin_x, 0)
top = max(y1 - margin_y, 0)
right = min(x2 + margin_x, frame_w)
bottom = min(y2 + margin_y, frame_h)
return frame[top:bottom, left:right]
def main():
parser = argparse.ArgumentParser(description="Evaluate a detector + value classifier pair on saved frames or route video.")
parser.add_argument("path", help="Frame directory or video path.")
parser.add_argument("--detector", required=True, help="Ultralytics detector checkpoint (.pt).")
parser.add_argument("--classifier", required=True, help="Ultralytics classifier checkpoint (.pt).")
parser.add_argument("--conf", type=float, default=0.10, help="Detector confidence threshold.")
parser.add_argument("--imgsz", type=int, default=960, help="Detector image size.")
parser.add_argument("--device", default="mps", help="Inference device, such as mps or cpu.")
parser.add_argument("--max-frames", type=int, default=0, help="Optional frame cap.")
args = parser.parse_args()
detector = YOLO(args.detector)
classifier = YOLO(args.classifier)
path = Path(args.path).expanduser().resolve()
speed_values = tuple(DEFAULT_SPEED_VALUES)
for frame_index, (label, frame_bgr) in enumerate(iter_frames(path), start=1):
if args.max_frames > 0 and frame_index > args.max_frames:
break
detector_result = detector.predict(source=frame_bgr, conf=args.conf, imgsz=args.imgsz, device=args.device, verbose=False)[0]
if detector_result.boxes is None or len(detector_result.boxes) == 0:
continue
printed = False
for box, cls, conf in zip(detector_result.boxes.xyxy.cpu().numpy(), detector_result.boxes.cls.cpu().numpy(), detector_result.boxes.conf.cpu().numpy()):
class_name = detector.names[int(cls)]
if class_name not in DETECTOR_SIGN_CLASSES:
continue
crop = crop_with_margin(frame_bgr, box)
if crop.size == 0:
continue
mask = extract_value_mask(crop)
if mask is None:
continue
classifier_input = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
classifier_result = classifier.predict(source=classifier_input, imgsz=128, device=args.device, verbose=False)[0]
probabilities = classifier_result.probs
if probabilities is None:
continue
top_index = int(probabilities.top1)
if top_index >= len(speed_values):
continue
predicted_value = speed_values[top_index]
predicted_confidence = float(probabilities.top1conf)
if not printed:
print(label)
printed = True
print(
f" detector={class_name} det_conf={float(conf):.3f} "
f"classifier={predicted_value} cls_conf={predicted_confidence:.3f} "
f"box={[round(float(v), 1) for v in box.tolist()]}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import cv2
import starpilot.system.speed_limit_vision as slv
@dataclass(frozen=True)
class RuntimeCase:
name: str
frame_path: str
expected_speed_limit_mph: int
DEFAULT_CASES = (
RuntimeCase("live15", ".tmp/live_c4_capture/stopped_sign_road.jpg", 15),
RuntimeCase("school20", ".tmp/route_vision/seg38_frames/frame_041.jpg", 20),
RuntimeCase("highway40", ".tmp/speed_route_frames_seg2_10_20/t12.png", 40),
RuntimeCase("town40", ".tmp/route_12c_seg9_10/seg10_early/frame_005.png", 40),
RuntimeCase("town30", ".tmp/route_12c_seg9_10/seg10_early/frame_012.png", 30),
RuntimeCase("town30_late", ".tmp/vision_iter/seg10_5fps/frame_054.png", 30),
RuntimeCase("town30_late_earlier", ".tmp/vision_iter/seg10_5fps/frame_052.png", 30),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Evaluate the StarPilot speed-limit runtime path on known saved-frame cases.")
parser.add_argument(
"--models-dir",
type=Path,
default=Path("starpilot/assets/vision_models"),
help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.",
)
parser.add_argument(
"--case",
action="append",
dest="selected_cases",
help="Optional case name filter. Repeat to run more than one case.",
)
parser.add_argument("--strict", action="store_true", help="Exit non-zero if any evaluated case misses the expected value.")
return parser.parse_args()
def resolve_cases(selected_cases: list[str] | None):
if not selected_cases:
return DEFAULT_CASES
selected = set(selected_cases)
cases = tuple(case for case in DEFAULT_CASES if case.name in selected)
missing = sorted(selected - {case.name for case in cases})
if missing:
raise ValueError(f"Unknown case names: {', '.join(missing)}")
return cases
def main() -> int:
args = parse_args()
models_dir = args.models_dir.expanduser().resolve()
detector_path = models_dir / "speed_limit_us_detector.onnx"
classifier_path = models_dir / "speed_limit_us_value_classifier.onnx"
if not detector_path.is_file():
raise FileNotFoundError(detector_path)
if not classifier_path.is_file():
raise FileNotFoundError(classifier_path)
slv.US_DETECTOR_MODEL_PATH = detector_path
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
failures = 0
for case in resolve_cases(args.selected_cases):
image_path = Path(case.frame_path).expanduser().resolve()
frame_bgr = cv2.imread(str(image_path))
if frame_bgr is None:
raise FileNotFoundError(image_path)
detection = daemon._detect_sign(frame_bgr)
predicted_speed = detection.speed_limit_mph if detection is not None else None
confidence = round(detection.confidence, 4) if detection is not None else None
passed = predicted_speed == case.expected_speed_limit_mph
if not passed:
failures += 1
print(
f"{case.name}: expected={case.expected_speed_limit_mph} "
f"predicted={predicted_speed} confidence={confidence} "
f"{'PASS' if passed else 'FAIL'}"
)
return 1 if args.strict and failures else 0
if __name__ == "__main__":
raise SystemExit(main())
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
import cv2
import numpy as np
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
CLASSIFIER_EXPORT_NAME,
DEFAULT_WORKSPACE,
DETECTOR_EXPORT_NAME,
REPO_ASSET_DIR,
ensure_dir,
resolve_workspace,
)
else:
from .common import (
CLASSIFIER_EXPORT_NAME,
DEFAULT_WORKSPACE,
DETECTOR_EXPORT_NAME,
REPO_ASSET_DIR,
ensure_dir,
resolve_workspace,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Export trained speed-limit detector/classifier checkpoints to ONNX.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--detector-weights", type=Path, help="Path to the trained detector .pt weights.")
parser.add_argument("--classifier-weights", type=Path, help="Path to the trained classifier .pt weights.")
parser.add_argument("--output-dir", type=Path, help="Where exported ONNX models should be written. Defaults to <workspace>/exports.")
parser.add_argument("--detector-imgsz", type=int, default=640, help="Detector export image size.")
parser.add_argument("--classifier-imgsz", type=int, default=128, help="Classifier export image size.")
parser.add_argument("--opset", type=int, default=12, help="ONNX opset.")
parser.add_argument("--install-repo-assets", action="store_true", help="Copy exported ONNX files into starpilot/assets/vision_models.")
parser.add_argument("--skip-verify", action="store_true", help="Skip the OpenCV DNN load/forward smoke test after export.")
return parser.parse_args()
def export_yolo(weights_path: Path, output_path: Path, imgsz: int, opset: int, nms: bool) -> None:
from ultralytics import YOLO
model = YOLO(str(weights_path))
exported_path = Path(model.export(format="onnx", imgsz=imgsz, opset=opset, simplify=False, dynamic=False, nms=nms))
ensure_dir(output_path.parent)
shutil.copy2(exported_path, output_path)
def verify_onnx_with_opencv(model_path: Path, input_size: int) -> None:
net = cv2.dnn.readNetFromONNX(str(model_path))
blob = np.zeros((1, 3, input_size, input_size), dtype=np.float32)
net.setInput(blob)
_ = net.forward()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
output_dir = args.output_dir.resolve() if args.output_dir else (workspace / "exports")
ensure_dir(output_dir)
try:
from ultralytics import YOLO # noqa: F401
except Exception as exc:
raise SystemExit(
"Ultralytics is not installed. Run `uv sync --extra speedvision` in the repo root before exporting."
) from exc
exported_paths: list[Path] = []
if args.detector_weights:
detector_weights = args.detector_weights.resolve()
detector_output = output_dir / DETECTOR_EXPORT_NAME
export_yolo(detector_weights, detector_output, args.detector_imgsz, args.opset, nms=False)
if not args.skip_verify:
verify_onnx_with_opencv(detector_output, args.detector_imgsz)
exported_paths.append(detector_output)
if args.classifier_weights:
classifier_weights = args.classifier_weights.resolve()
classifier_output = output_dir / CLASSIFIER_EXPORT_NAME
export_yolo(classifier_weights, classifier_output, args.classifier_imgsz, args.opset, nms=False)
if not args.skip_verify:
verify_onnx_with_opencv(classifier_output, args.classifier_imgsz)
exported_paths.append(classifier_output)
if not exported_paths:
raise SystemExit("Pass at least one of --detector-weights or --classifier-weights")
if args.install_repo_assets:
ensure_dir(REPO_ASSET_DIR)
for exported_path in exported_paths:
shutil.copy2(exported_path, REPO_ASSET_DIR / exported_path.name)
for exported_path in exported_paths:
print(f"Exported {exported_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import math
import random
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
DEFAULT_BACKGROUND_DIR = DEFAULT_WORKSPACE / "backgrounds"
HEADER_FONT_CANDIDATES = (
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/ArialHB.ttc",
"/System/Library/Fonts/Supplemental/Arial.ttf",
)
NUMBER_FONT_CANDIDATES = (
"/System/Library/Fonts/Supplemental/DIN Condensed Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial Black.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
)
KNOWN_REAL_CROPS = (
(".tmp/live_c4_capture/stopped_sign_crop_manual.png", 15),
(".tmp/route_vision/frame_041_sign_tight.jpg", 20),
(".tmp/route_vision/frame_041_sign_manual.jpg", 20),
(".tmp/route_12c_seg9_10/seg10_real30_crop.png", 30),
(".tmp/speed_route_frames_seg2_10_20/t12_sign_crop.png", 40),
)
@dataclass(frozen=True)
class SignSpec:
detector_class: int
style: str
speed_value: int | None
def load_font(candidates: tuple[str, ...], size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for candidate in candidates:
if Path(candidate).exists():
return ImageFont.truetype(candidate, size=size)
return ImageFont.load_default()
def draw_centered(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], text: str, font, fill: str):
left, top, right, bottom = box
bbox = draw.multiline_textbbox((0, 0), text, font=font, align="center", spacing=2)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
x = left + (right - left - width) / 2
y = top + (bottom - top - height) / 2
draw.multiline_text((x, y), text, font=font, fill=fill, align="center", spacing=2)
def render_regulatory_sign(speed_value: int, school_zone: bool, seed: int) -> Image.Image:
rng = random.Random(seed)
sign_w = rng.randint(260, 320)
sign_h = rng.randint(390, 470)
image = Image.new("RGBA", (sign_w, sign_h), (255, 255, 255, 0))
draw = ImageDraw.Draw(image)
border_radius = max(int(sign_w * 0.08), 16)
draw.rounded_rectangle((0, 0, sign_w - 1, sign_h - 1), border_radius, fill="white", outline="black", width=max(sign_w // 38, 5))
header_font = load_font(HEADER_FONT_CANDIDATES, max(sign_w // 9, 26))
number_font = load_font(NUMBER_FONT_CANDIDATES, max(sign_w // 3, 84))
footer_font = load_font(HEADER_FONT_CANDIDATES, max(sign_w // 12, 18))
if school_zone:
draw_centered(draw, (int(sign_w * 0.10), int(sign_h * 0.06), int(sign_w * 0.90), int(sign_h * 0.24)), "SCHOOL", header_font, "black")
draw_centered(draw, (int(sign_w * 0.10), int(sign_h * 0.20), int(sign_w * 0.90), int(sign_h * 0.42)), "SPEED\nLIMIT", header_font, "black")
draw_centered(draw, (int(sign_w * 0.12), int(sign_h * 0.42), int(sign_w * 0.88), int(sign_h * 0.78)), str(speed_value), number_font, "black")
draw_centered(draw, (int(sign_w * 0.08), int(sign_h * 0.76), int(sign_w * 0.92), int(sign_h * 0.94)), "WHEN FLASHING", footer_font, "black")
else:
draw_centered(draw, (int(sign_w * 0.10), int(sign_h * 0.08), int(sign_w * 0.90), int(sign_h * 0.34)), "SPEED\nLIMIT", header_font, "black")
draw_centered(draw, (int(sign_w * 0.12), int(sign_h * 0.40), int(sign_w * 0.88), int(sign_h * 0.84)), str(speed_value), number_font, "black")
if school_zone and rng.random() < 0.65:
lamp_y = int(sign_h * 0.12)
lamp_r = max(sign_w // 18, 12)
for lamp_x in (int(sign_w * 0.16), int(sign_w * 0.84)):
draw.ellipse((lamp_x - lamp_r, lamp_y - lamp_r, lamp_x + lamp_r, lamp_y + lamp_r), fill=(255, 192, 0), outline="black", width=3)
return image
def render_advisory_sign(speed_value: int, seed: int) -> Image.Image:
rng = random.Random(seed)
size = rng.randint(240, 320)
image = Image.new("RGBA", (size, size), (255, 255, 255, 0))
base = Image.new("RGBA", (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(base)
draw.polygon(((size / 2, 0), (size, size / 2), (size / 2, size), (0, size / 2)), fill=(255, 214, 10), outline="black")
base = base.rotate(45, expand=True, resample=Image.Resampling.BICUBIC)
bbox = base.getbbox()
if bbox is not None:
base = base.crop(bbox)
draw = ImageDraw.Draw(base)
number_font = load_font(NUMBER_FONT_CANDIDATES, max(base.size[0] // 3, 72))
footer_font = load_font(HEADER_FONT_CANDIDATES, max(base.size[0] // 12, 18))
draw_centered(draw, (0, int(base.size[1] * 0.18), base.size[0], int(base.size[1] * 0.68)), str(speed_value), number_font, "black")
if rng.random() < 0.7:
draw_centered(draw, (0, int(base.size[1] * 0.68), base.size[0], int(base.size[1] * 0.92)), "MPH", footer_font, "black")
return base
def add_motion_blur(image: Image.Image, radius: int) -> Image.Image:
if radius <= 1:
return image
kernel_size = radius * 2 + 1
kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
kernel[kernel_size // 2, :] = 1.0 / kernel_size
array = cv2.filter2D(np.array(image), -1, kernel)
return Image.fromarray(array)
def augment_sign(sign: Image.Image, rng: random.Random) -> Image.Image:
image = sign.copy()
if rng.random() < 0.7:
image = image.filter(ImageFilter.GaussianBlur(radius=rng.uniform(0.2, 1.8)))
if rng.random() < 0.35:
image = add_motion_blur(image, radius=rng.randint(2, 5))
brightness = ImageEnhance.Brightness(image)
image = brightness.enhance(rng.uniform(0.75, 1.18))
contrast = ImageEnhance.Contrast(image)
image = contrast.enhance(rng.uniform(0.85, 1.25))
return image
def choose_sign_spec(rng: random.Random, speed_values: tuple[int, ...]) -> SignSpec:
roll = rng.random()
if roll < 0.16:
return SignSpec(detector_class=1, style="advisory", speed_value=rng.choice((20, 25, 30, 35, 40, 45)))
if roll < 0.34:
school_choices = tuple(value for value in speed_values if value in (15, 20, 25))
return SignSpec(detector_class=2, style="school_zone", speed_value=rng.choice(school_choices or speed_values))
return SignSpec(detector_class=0, style="regulatory", speed_value=rng.choice(speed_values))
def paste_transformed(background_bgr: np.ndarray, sign_rgba: Image.Image, rng: random.Random):
background = background_bgr.copy()
bg_h, bg_w = background.shape[:2]
sign = np.array(sign_rgba)
sign_h, sign_w = sign.shape[:2]
target_h = int(rng.uniform(bg_h * 0.045, bg_h * 0.17))
scale = target_h / max(sign_h, 1)
target_w = max(int(sign_w * scale), 12)
resized = cv2.resize(sign, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
sign_h, sign_w = resized.shape[:2]
center_x = int(rng.uniform(bg_w * 0.58, bg_w * 0.92))
center_y = int(rng.uniform(bg_h * 0.10, bg_h * 0.58))
src = np.float32([[0, 0], [sign_w - 1, 0], [sign_w - 1, sign_h - 1], [0, sign_h - 1]])
skew_x = sign_w * rng.uniform(0.04, 0.18)
skew_y = sign_h * rng.uniform(0.02, 0.12)
dst = np.float32([
[center_x - sign_w * rng.uniform(0.35, 0.55), center_y - sign_h * rng.uniform(0.55, 0.70)],
[center_x + sign_w * rng.uniform(0.35, 0.55), center_y - sign_h * rng.uniform(0.45, 0.70)],
[center_x + sign_w * rng.uniform(0.28, 0.52), center_y + sign_h * rng.uniform(0.30, 0.58)],
[center_x - sign_w * rng.uniform(0.26, 0.48), center_y + sign_h * rng.uniform(0.34, 0.62)],
])
dst += np.float32([
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
])
matrix = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(resized, matrix, (bg_w, bg_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
alpha = warped[:, :, 3:4].astype(np.float32) / 255.0
if alpha.max() <= 0.01:
return background, None, None
warped_rgb = warped[:, :, :3].astype(np.float32)
composite = background.astype(np.float32) * (1.0 - alpha) + warped_rgb * alpha
composite = composite.astype(np.uint8)
ys, xs = np.where(alpha[:, :, 0] > 0.05)
if len(xs) == 0 or len(ys) == 0:
return background, None, None
x1, x2 = int(xs.min()), int(xs.max())
y1, y2 = int(ys.min()), int(ys.max())
bbox = (x1, y1, x2, y2)
crop = composite[y1:y2 + 1, x1:x2 + 1]
return composite, bbox, crop
def detector_label_line(detector_class: int, bbox: tuple[int, int, int, int], image_shape: tuple[int, int, int]) -> str:
image_h, image_w = image_shape[:2]
x1, y1, x2, y2 = bbox
x_center = ((x1 + x2) / 2) / image_w
y_center = ((y1 + y2) / 2) / image_h
width = (x2 - x1) / image_w
height = (y2 - y1) / image_h
return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
def save_classifier_crop(base_dir: Path, split: str, speed_value: int, image_bgr: np.ndarray, stem: str):
output_dir = ensure_dir(base_dir / split / str(speed_value))
output_path = output_dir / f"{stem}.jpg"
cv2.imwrite(str(output_path), image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 92])
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"})
def main():
parser = argparse.ArgumentParser(description="Generate a synthetic U.S. speed-limit detector/classifier dataset.")
parser.add_argument("--workspace", default=str(DEFAULT_WORKSPACE), help="Training workspace root.")
parser.add_argument("--background-dir", default=str(DEFAULT_BACKGROUND_DIR), help="Background image directory.")
parser.add_argument("--train-count", type=int, default=9000, help="Number of synthetic training detector images.")
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("--seed", type=int, default=20260330, help="Random seed.")
args = parser.parse_args()
workspace = resolve_workspace(args.workspace)
background_dir = Path(args.background_dir).expanduser().resolve()
backgrounds = collect_backgrounds(background_dir)
if not backgrounds:
raise FileNotFoundError(f"No backgrounds found in {background_dir}")
detector_image_dir = workspace / "detector" / "images"
detector_label_dir = workspace / "detector" / "labels"
classifier_dir = workspace / "classifier"
speed_values = tuple(DEFAULT_SPEED_VALUES)
rng = random.Random(args.seed)
for split, count in (("train", max(args.train_count, 0)), ("val", max(args.val_count, 0))):
ensure_dir(detector_image_dir / split)
ensure_dir(detector_label_dir / split)
ensure_dir(classifier_dir / split)
for index in range(count):
background_path = rng.choice(backgrounds)
background = cv2.imread(str(background_path))
if background is None:
continue
stem = f"{split}_{index:06d}"
image_path = detector_image_dir / split / f"{stem}.jpg"
label_path = detector_label_dir / split / f"{stem}.txt"
detector_lines: list[str] = []
if rng.random() >= args.negative_ratio:
sign_spec = 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:
sign_image = render_regulatory_sign(sign_spec.speed_value or 25, school_zone=sign_spec.style == "school_zone", seed=rng.randint(0, 1_000_000))
sign_image = augment_sign(sign_image, rng)
composite, bbox, crop = paste_transformed(background, sign_image, rng)
if bbox is not None:
detector_lines.append(detector_label_line(sign_spec.detector_class, bbox, composite.shape))
background = composite
if crop is not None and sign_spec.speed_value is not None and sign_spec.detector_class != 1:
save_classifier_crop(classifier_dir, split, sign_spec.speed_value, crop, stem)
if rng.random() < 0.45:
alpha = rng.uniform(0.05, 0.20)
overlay = np.full_like(background, int(rng.uniform(180, 240)))
background = cv2.addWeighted(background, 1.0 - alpha, overlay, alpha, 0)
if rng.random() < 0.3:
background = cv2.GaussianBlur(background, (3, 3), rng.uniform(0.1, 0.9))
if rng.random() < 0.25:
noise = rng.normalvariate(0, 6)
background = np.clip(background.astype(np.int16) + noise, 0, 255).astype(np.uint8)
cv2.imwrite(str(image_path), background, [cv2.IMWRITE_JPEG_QUALITY, 92])
label_path.write_text("".join(detector_lines), encoding="utf-8")
repo_root = Path(__file__).resolve().parents[2]
imported_real = 0
for relative_path, speed_value in KNOWN_REAL_CROPS:
crop_path = repo_root / relative_path
if not crop_path.is_file():
continue
image = cv2.imread(str(crop_path))
if image is None:
continue
split = "val" if imported_real % 4 == 0 else "train"
save_classifier_crop(classifier_dir, split, speed_value, image, f"real_{speed_value}_{imported_real:03d}")
imported_real += 1
print(f"Generated synthetic detector data in {workspace / 'detector'}")
print(f"Generated synthetic classifier data in {workspace / 'classifier'}")
print(f"Backgrounds used: {len(backgrounds)}")
print(f"Imported real crops: {imported_real}")
if __name__ == "__main__":
main()
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import random
import shutil
from pathlib import Path
import cv2
import numpy as np
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
from generate_synthetic_us_speed_limits import KNOWN_REAL_CROPS, augment_sign, render_regulatory_sign # type: ignore
else:
from .common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
from .generate_synthetic_us_speed_limits import KNOWN_REAL_CROPS, augment_sign, render_regulatory_sign
VALUE_TEMPLATE_ROIS = (
(0.35, 0.82, 0.15, 0.78),
(0.45, 0.85, 0.18, 0.78),
(0.40, 0.84, 0.18, 0.75),
)
def normalize_binary_mask(binary_mask: np.ndarray, size=(72, 96), padding=6):
points = cv2.findNonZero(binary_mask)
if points is None:
return None
x, y, width, height = cv2.boundingRect(points)
digit = binary_mask[y:y + height, x:x + width]
target_w, target_h = size
scale = min((target_w - padding * 2) / max(width, 1), (target_h - padding * 2) / max(height, 1))
resized_w = max(int(round(width * scale)), 1)
resized_h = max(int(round(height * scale)), 1)
resized = cv2.resize(digit, (resized_w, resized_h), interpolation=cv2.INTER_NEAREST)
canvas = np.zeros((target_h, target_w), dtype=np.uint8)
offset_x = (target_w - resized_w) // 2
offset_y = (target_h - resized_h) // 2
canvas[offset_y:offset_y + resized_h, offset_x:offset_x + resized_w] = resized
return canvas
def extract_value_mask(sign_bgr: np.ndarray):
gray = cv2.cvtColor(sign_bgr, cv2.COLOR_BGR2GRAY)
height, width = gray.shape
best_mask = None
best_fill = 0.0
for top_ratio, bottom_ratio, left_ratio, right_ratio in VALUE_TEMPLATE_ROIS:
roi = gray[int(height * top_ratio):int(height * bottom_ratio), int(width * left_ratio):int(width * right_ratio)]
if roi.size == 0:
continue
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(roi)
_, binary = cv2.threshold(clahe, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((2, 2), dtype=np.uint8))
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
mask = np.zeros_like(binary)
for label_idx in range(1, num_labels):
x, y, comp_w, comp_h, area = stats[label_idx]
if area < roi.shape[0] * roi.shape[1] * 0.01:
continue
if y < binary.shape[0] * 0.08:
continue
if comp_h < binary.shape[0] * 0.18:
continue
if comp_w > binary.shape[1] * 0.75:
continue
mask[labels == label_idx] = 255
normalized = normalize_binary_mask(mask, size=(72, 96))
if normalized is None:
continue
fill_ratio = float(np.count_nonzero(normalized)) / normalized.size
if fill_ratio > best_fill:
best_fill = fill_ratio
best_mask = normalized
return best_mask
def perspective_jitter(sign_rgba, rng: random.Random):
sign = np.array(sign_rgba)
sign_h, sign_w = sign.shape[:2]
pad = max(sign_w, sign_h) // 5
canvas = np.zeros((sign_h + pad * 2, sign_w + pad * 2, 4), dtype=np.uint8)
canvas[pad:pad + sign_h, pad:pad + sign_w] = sign
sign_h, sign_w = canvas.shape[:2]
src = np.float32([[0, 0], [sign_w - 1, 0], [sign_w - 1, sign_h - 1], [0, sign_h - 1]])
jitter_x = sign_w * 0.08
jitter_y = sign_h * 0.08
dst = src + np.float32([
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
])
matrix = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(canvas, matrix, (sign_w, sign_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
ys, xs = np.where(warped[:, :, 3] > 0)
if len(xs) == 0 or len(ys) == 0:
return canvas
return warped[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
def augment_mask(mask: np.ndarray, rng: random.Random):
canvas = np.zeros((128, 128), dtype=np.uint8)
resized = cv2.resize(mask, None, fx=rng.uniform(0.85, 1.15), fy=rng.uniform(0.85, 1.15), interpolation=cv2.INTER_NEAREST)
offset_x = max((canvas.shape[1] - resized.shape[1]) // 2 + rng.randint(-8, 8), 0)
offset_y = max((canvas.shape[0] - resized.shape[0]) // 2 + rng.randint(-8, 8), 0)
end_x = min(offset_x + resized.shape[1], canvas.shape[1])
end_y = min(offset_y + resized.shape[0], canvas.shape[0])
canvas[offset_y:end_y, offset_x:end_x] = resized[:end_y - offset_y, :end_x - offset_x]
if rng.random() < 0.45:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (rng.choice((1, 2, 3)), rng.choice((1, 2, 3))))
operation = cv2.MORPH_DILATE if rng.random() < 0.5 else cv2.MORPH_ERODE
canvas = cv2.morphologyEx(canvas, operation, kernel)
if rng.random() < 0.55:
canvas = cv2.GaussianBlur(canvas, (3, 3), rng.uniform(0.1, 1.0))
if rng.random() < 0.35:
noise = np.random.normal(0.0, rng.uniform(2.0, 9.0), canvas.shape).astype(np.float32)
canvas = np.clip(canvas.astype(np.float32) + noise, 0, 255).astype(np.uint8)
return cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR)
def save_mask(base_dir: Path, split: str, speed_value: int, image_bgr: np.ndarray, stem: str):
output_dir = ensure_dir(base_dir / split / str(speed_value))
cv2.imwrite(str(output_dir / f"{stem}.png"), image_bgr)
def main():
parser = argparse.ArgumentParser(description="Generate a value-ROI classifier dataset from synthetic U.S. speed-limit signs.")
parser.add_argument("--workspace", default=str(DEFAULT_WORKSPACE), help="Training workspace root.")
parser.add_argument("--train-per-class", type=int, default=1800, help="Synthetic training samples per value.")
parser.add_argument("--val-per-class", type=int, default=260, help="Synthetic validation samples per value.")
parser.add_argument("--real-augmentations", type=int, default=28, help="Augmented mask samples to create per known real crop.")
parser.add_argument("--seed", type=int, default=20260330, help="Random seed.")
args = parser.parse_args()
workspace = resolve_workspace(args.workspace)
classifier_dir = workspace / "classifier"
if classifier_dir.exists():
shutil.rmtree(classifier_dir)
ensure_dir(classifier_dir / "train")
ensure_dir(classifier_dir / "val")
rng = random.Random(args.seed)
speed_values = tuple(DEFAULT_SPEED_VALUES)
for split, per_class in (("train", max(args.train_per_class, 0)), ("val", max(args.val_per_class, 0))):
for speed_value in speed_values:
for index in range(per_class):
school_zone = speed_value in (15, 20, 25) and rng.random() < 0.45
sign_rgba = render_regulatory_sign(speed_value, school_zone=school_zone, seed=rng.randint(0, 1_000_000))
sign_rgba = augment_sign(sign_rgba, rng)
sign_rgba = perspective_jitter(sign_rgba, rng)
sign_bgr = cv2.cvtColor(sign_rgba[:, :, :3], cv2.COLOR_RGB2BGR)
mask = extract_value_mask(sign_bgr)
if mask is None:
continue
output = augment_mask(mask, rng)
save_mask(classifier_dir, split, speed_value, output, f"{split}_{speed_value}_{index:05d}")
repo_root = Path(__file__).resolve().parents[2]
imported_real = 0
for relative_path, speed_value in KNOWN_REAL_CROPS:
crop_path = repo_root / relative_path
if not crop_path.is_file():
continue
crop_bgr = cv2.imread(str(crop_path))
if crop_bgr is None:
continue
mask = extract_value_mask(crop_bgr)
if mask is None:
continue
for augmentation_index in range(max(args.real_augmentations, 1)):
split = "val" if augmentation_index % 5 == 0 else "train"
output = augment_mask(mask, rng)
save_mask(classifier_dir, split, speed_value, output, f"real_{speed_value}_{imported_real:03d}_{augmentation_index:03d}")
imported_real += 1
print(f"Generated ROI classifier dataset in {classifier_dir}")
print(f"Imported real crops: {imported_real}")
if __name__ == "__main__":
main()
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import tarfile
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
DEFAULT_WORKSPACE,
DETECTOR_CLASS_NAMES,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
VALUE_LABEL_FIELDS,
default_raw_root,
ensure_dir,
resolve_workspace,
)
else:
from .common import (
DEFAULT_WORKSPACE,
DETECTOR_CLASS_NAMES,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
VALUE_LABEL_FIELDS,
default_raw_root,
ensure_dir,
resolve_workspace,
)
DEFAULT_ARCHIVE_RELATIVE = Path("external/arts_probe/Public/ARTS-V1/Challenging/challenging-dev.tar.gz")
SOURCE_NAME = "arts_challenging"
SOURCE_VERSION = "ARTS-V1 Challenging"
SOURCE_LICENSE = "Research dataset, see source distribution"
ARTS_CODE_MAP: dict[str, tuple[str, int | None]] = {
"R2-1": ("regulatory_speed_limit", None),
"R2-125": ("regulatory_speed_limit", 25),
"R2-130": ("regulatory_speed_limit", 30),
"R2-135": ("regulatory_speed_limit", 35),
"R2-140": ("regulatory_speed_limit", 40),
"R2-145": ("regulatory_speed_limit", 45),
"R2-150": ("regulatory_speed_limit", 50),
"R2-155": ("regulatory_speed_limit", 55),
"R2-165": ("regulatory_speed_limit", 65),
"W13-1": ("advisory_speed_limit", None),
"W13-2": ("advisory_speed_limit", None),
"W13-3": ("advisory_speed_limit", None),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import ARTS Challenging speed-limit samples into the training workspace.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--archive", type=Path, help="Path to challenging-dev.tar.gz. Defaults to the SSD raw-data layout.")
parser.add_argument("--train-split", type=float, default=0.85, help="Fallback train split ratio when ARTS split files are unavailable.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite previously imported ARTS images/labels.")
return parser.parse_args()
def default_archive_path(workspace: Path) -> Path:
return default_raw_root(workspace) / DEFAULT_ARCHIVE_RELATIVE
def read_existing_rows(path: Path) -> list[dict[str, str]]:
if not path.is_file():
return []
with path.open("r", encoding="utf-8", newline="") as csv_file:
return list(csv.DictReader(csv_file))
def write_rows(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
ensure_dir(path.parent)
with path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def build_fallback_split_lookup(stem_names: list[str], train_ratio: float) -> dict[str, str]:
cutoff = int(len(stem_names) * max(0.0, min(train_ratio, 1.0)))
fallback: dict[str, str] = {}
for index, stem in enumerate(sorted(stem_names)):
fallback[stem] = "train" if index < cutoff else "val"
return fallback
def yolo_box(size: tuple[int, int], bbox: tuple[int, int, int, int]) -> tuple[float, float, float, float]:
width, height = size
xmin, ymin, xmax, ymax = bbox
box_width = max(xmax - xmin, 1)
box_height = max(ymax - ymin, 1)
x_center = xmin + box_width / 2.0
y_center = ymin + box_height / 2.0
return (
x_center / width,
y_center / height,
box_width / width,
box_height / height,
)
def parse_annotation(xml_bytes: bytes) -> tuple[tuple[int, int], list[dict[str, object]]]:
root = ET.fromstring(xml_bytes)
size_node = root.find("size")
width = int(size_node.findtext("width", default="0"))
height = int(size_node.findtext("height", default="0"))
parsed: list[dict[str, object]] = []
for obj in root.findall("object"):
sign_code = (obj.findtext("name") or "").strip()
mapped = ARTS_CODE_MAP.get(sign_code)
if mapped is None:
continue
bbox_node = obj.find("bndbox")
xmin = int(float(bbox_node.findtext("xmin", default="0")))
ymin = int(float(bbox_node.findtext("ymin", default="0")))
xmax = int(float(bbox_node.findtext("xmax", default="0")))
ymax = int(float(bbox_node.findtext("ymax", default="0")))
class_name, speed_value = mapped
parsed.append({
"sign_code": sign_code,
"class_name": class_name,
"speed_limit_mph": speed_value,
"bbox": (xmin, ymin, xmax, ymax),
})
return (width, height), parsed
def update_split_lookup(split_lookup: dict[str, str], split_name: str, text: str) -> None:
normalized_split = "val" if split_name == "test" else split_name
for line in text.splitlines():
stem = line.strip()
if stem:
split_lookup[stem] = normalized_split
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
archive_path = args.archive.resolve() if args.archive else default_archive_path(workspace)
if not archive_path.is_file():
raise FileNotFoundError(f"ARTS archive not found: {archive_path}")
detector_manifest_path = workspace / "manifests" / "public_detector_samples.csv"
classifier_manifest_path = workspace / "manifests" / "public_classifier_samples.csv"
value_labels_path = workspace / "classifier" / "value_labels.csv"
raw_sources_path = workspace / "manifests" / "raw_sources.csv"
existing_detector_rows = [row for row in read_existing_rows(detector_manifest_path) if row.get("source_name") != SOURCE_NAME]
existing_classifier_rows = [row for row in read_existing_rows(classifier_manifest_path) if row.get("source_name") != SOURCE_NAME]
existing_value_rows = [row for row in read_existing_rows(value_labels_path) if not (row.get("image_path") or "").startswith("detector/images/") or SOURCE_NAME not in (row.get("image_path") or "")]
existing_source_rows = [row for row in read_existing_rows(raw_sources_path) if row.get("source_name") != SOURCE_NAME]
detector_rows: list[dict[str, str]] = []
classifier_rows: list[dict[str, str]] = []
value_rows: list[dict[str, str]] = []
split_lookup: dict[str, str] = {}
annotations_by_stem: dict[str, dict[str, object]] = {}
with tarfile.open(archive_path, "r:gz") as tar:
for member in tar:
if not member.isfile():
continue
if member.name == "challenging/ImageSets/Main/train.txt":
update_split_lookup(split_lookup, "train", tar.extractfile(member).read().decode("utf-8", "ignore"))
continue
if member.name == "challenging/ImageSets/Main/val.txt":
update_split_lookup(split_lookup, "val", tar.extractfile(member).read().decode("utf-8", "ignore"))
continue
if member.name == "challenging/ImageSets/Main/test.txt":
update_split_lookup(split_lookup, "test", tar.extractfile(member).read().decode("utf-8", "ignore"))
continue
if not (member.name.startswith("challenging/Annotations/") and member.name.endswith(".xml")):
continue
stem = Path(member.name).stem
image_size, parsed_boxes = parse_annotation(tar.extractfile(member).read())
if not parsed_boxes:
continue
annotations_by_stem[stem] = {
"image_size": image_size,
"boxes": parsed_boxes,
"annotation_name": member.name,
}
if not split_lookup:
split_lookup = build_fallback_split_lookup(list(annotations_by_stem), args.train_split)
imported_images = 0
imported_boxes = 0
class_counts: dict[str, int] = defaultdict(int)
with tarfile.open(archive_path, "r:gz") as tar:
for member in tar:
if not member.isfile():
continue
if not (member.name.startswith("challenging/JPEGImages/") and member.name.endswith(".jpg")):
continue
stem = Path(member.name).stem
annotation = annotations_by_stem.get(stem)
if annotation is None:
continue
split = split_lookup.get(stem, "train")
image_bytes = tar.extractfile(member).read()
image_size = annotation["image_size"] # type: ignore[assignment]
parsed_boxes = annotation["boxes"] # type: ignore[assignment]
image_out = workspace / "detector" / "images" / split / f"{SOURCE_NAME}_{stem}.jpg"
label_out = workspace / "detector" / "labels" / split / f"{SOURCE_NAME}_{stem}.txt"
if args.overwrite or not image_out.exists():
ensure_dir(image_out.parent)
image_out.write_bytes(image_bytes)
yolo_lines: list[str] = []
for bbox_index, box in enumerate(parsed_boxes):
class_name = str(box["class_name"])
speed_value = box["speed_limit_mph"]
class_id = DETECTOR_CLASS_NAMES.index(class_name)
x_center, y_center, width, height = yolo_box(image_size, box["bbox"]) # type: ignore[arg-type]
yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
xmin, ymin, xmax, ymax = box["bbox"] # type: ignore[misc]
record_key = f"{SOURCE_NAME}:{stem}:{bbox_index}"
detector_rows.append({
"record_key": record_key,
"source_name": SOURCE_NAME,
"split": split,
"image_path": str(image_out.relative_to(workspace)),
"label_path": str(label_out.relative_to(workspace)),
"annotation_path": f"{archive_path}:{annotation['annotation_name']}",
"source_image_id": stem,
"class_name": class_name,
"speed_limit_mph": "" if speed_value is None else str(speed_value),
"sign_code": str(box["sign_code"]),
"bbox_left": str(xmin),
"bbox_top": str(ymin),
"bbox_right": str(xmax),
"bbox_bottom": str(ymax),
})
class_counts[class_name] += 1
imported_boxes += 1
if speed_value is not None:
classifier_rows.append({
"record_key": record_key,
"source_name": SOURCE_NAME,
"split": split,
"image_path": str(image_out.relative_to(workspace)),
"speed_limit_mph": str(speed_value),
"bbox_index": str(bbox_index),
"label_path": str(label_out.relative_to(workspace)),
"source_image_id": stem,
"sign_code": str(box["sign_code"]),
})
value_rows.append({
"image_path": str(image_out.relative_to(workspace)),
"split": split,
"speed_limit_mph": str(speed_value),
"bbox_index": str(bbox_index),
"padding": "0.10",
"label_path": str(label_out.relative_to(workspace)),
})
if yolo_lines and (args.overwrite or not label_out.exists()):
ensure_dir(label_out.parent)
label_out.write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
imported_images += 1
source_row = {
"source_name": SOURCE_NAME,
"source_version": SOURCE_VERSION,
"source_license": SOURCE_LICENSE,
"source_type": "public_detector_and_classifier_seed",
"raw_path": str(archive_path),
"notes": "ARTS challenging subset imported from VOC XML. Only mapped speed-limit classes were kept.",
}
write_rows(raw_sources_path, RAW_SOURCE_FIELDS, existing_source_rows + [source_row])
write_rows(detector_manifest_path, PUBLIC_DETECTOR_SAMPLE_FIELDS, existing_detector_rows + detector_rows)
write_rows(classifier_manifest_path, PUBLIC_CLASSIFIER_SAMPLE_FIELDS, existing_classifier_rows + classifier_rows)
write_rows(value_labels_path, VALUE_LABEL_FIELDS, existing_value_rows + value_rows)
class_summary = ", ".join(f"{name}={count}" for name, count in sorted(class_counts.items()))
print(f"Imported {imported_images} ARTS image(s) and {imported_boxes} box(es) from {archive_path}")
print(f" detector manifest: {detector_manifest_path}")
print(f" classifier manifest: {classifier_manifest_path}")
print(f" class counts: {class_summary or 'none'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
import cv2
import numpy as np
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
BOOKMARK_LEADIN_MANIFEST_FIELDS,
DEFAULT_WORKSPACE,
ensure_dir,
preferred_clip_root,
resolve_workspace,
write_csv_header,
)
else:
from .common import (
BOOKMARK_LEADIN_MANIFEST_FIELDS,
DEFAULT_WORKSPACE,
ensure_dir,
preferred_clip_root,
resolve_workspace,
write_csv_header,
)
DEFAULT_REPORT_JSON = Path(".tmp/live_route_clips/bookmark_windows_report.json")
DEFAULT_CLIP_ROOT = preferred_clip_root()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import sampled 5-second pre-bookmark route windows into the speed-limit training workspace.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--report-json", type=Path, default=DEFAULT_REPORT_JSON, help="JSON output from evaluate_bookmark_leadins.py.")
parser.add_argument("--clip-root", type=Path, default=DEFAULT_CLIP_ROOT, help="Copied route clip root used by evaluate_bookmark_leadins.py.")
parser.add_argument("--source-name", default="comma_bookmark", help="Logical source name for imported bookmark windows.")
parser.add_argument("--source-region", default="", help="Optional region or market label, e.g. us_midwest.")
parser.add_argument("--source-device", default="", help="Optional device identifier or platform label.")
parser.add_argument("--source-driver", default="", help="Optional contributor/driver identifier.")
parser.add_argument("--mode", choices=("misses", "hits", "all"), default="misses", help="Which bookmark windows to sample.")
parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled review frames.")
parser.add_argument("--max-samples", type=int, default=12, help="Optional cap on sampled frames per bookmark window.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing extracted review frames and contact sheets.")
return parser.parse_args()
def load_existing_rows(manifest_path: Path) -> dict[str, dict[str, str]]:
if not manifest_path.exists():
return {}
with manifest_path.open("r", encoding="utf-8", newline="") as manifest_file:
reader = csv.DictReader(manifest_file)
return {row["record_key"]: row for row in reader if row.get("record_key")}
def read_frame_at(video_path: Path, target_time_s: float):
capture = cv2.VideoCapture(str(video_path))
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
frame_index = max(int(round(target_time_s * fps)), 0)
capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
ok, frame_bgr = capture.read()
capture.release()
if not ok or frame_bgr is None:
return None
actual_time_s = frame_index / fps
return actual_time_s, frame_bgr
def sample_offsets(leadin_start_s: float, segment_offset_s: float, sample_every: float, max_samples: int):
if sample_every <= 0:
raise ValueError("sample_every must be positive")
duration_s = max(segment_offset_s - leadin_start_s, 0.0)
sample_count = int(math.floor(duration_s / sample_every)) + 1
if max_samples > 0:
sample_count = min(sample_count, max_samples)
if sample_count <= 1:
return [segment_offset_s]
if max_samples > 0 and sample_count == max_samples:
return [leadin_start_s + duration_s * index / (sample_count - 1) for index in range(sample_count)]
offsets = []
sample_offset_s = leadin_start_s
while sample_offset_s <= segment_offset_s + 1e-6:
offsets.append(sample_offset_s)
sample_offset_s += sample_every
return offsets[:sample_count]
def extract_window_frames(row: dict, clip_root: Path, sample_every: float, max_samples: int):
route = row["route"]
segment = int(row["segment"])
segment_offset_s = float(row["segmentOffsetS"])
leadin_start_s = float(row["leadinStartS"])
spans_previous_segment = bool(row.get("spansPreviousSegment"))
sampled = []
previous_clip = clip_root / f"{route}--{segment - 1}" / "fcamera.hevc"
current_clip = clip_root / f"{route}--{segment}" / "fcamera.hevc"
for relative_offset_s in sample_offsets(leadin_start_s, segment_offset_s, sample_every, max_samples):
if relative_offset_s < 0.0 and spans_previous_segment and segment > 0:
source_video = previous_clip
source_time_s = 60.0 + relative_offset_s
else:
source_video = current_clip
source_time_s = max(relative_offset_s, 0.0)
if not source_video.is_file():
continue
frame_info = read_frame_at(source_video, source_time_s)
if frame_info is None:
continue
actual_time_s, frame_bgr = frame_info
sampled.append({
"relative_offset_s": relative_offset_s,
"actual_time_s": actual_time_s,
"source_video": source_video,
"frame_bgr": frame_bgr,
})
return sampled
def write_contact_sheet(output_path: Path, frames: list[np.ndarray], labels: list[str], overwrite: bool):
if output_path.exists() and not overwrite:
return
ensure_dir(output_path.parent)
columns = 4
rows = max(int(math.ceil(len(frames) / columns)), 1)
tile_height, tile_width = 256, 456
header_height = 26
sheet = np.full((rows * (tile_height + header_height), columns * tile_width, 3), 24, dtype=np.uint8)
for index, (frame_bgr, label) in enumerate(zip(frames, labels, strict=False)):
row_index = index // columns
column_index = index % columns
y = row_index * (tile_height + header_height)
x = column_index * tile_width
resized = cv2.resize(frame_bgr, (tile_width, tile_height), interpolation=cv2.INTER_AREA)
sheet[y + header_height:y + header_height + tile_height, x:x + tile_width] = resized
cv2.putText(sheet, label, (x + 8, y + 18), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (235, 235, 235), 1, cv2.LINE_AA)
cv2.imwrite(str(output_path), sheet, [cv2.IMWRITE_JPEG_QUALITY, 88])
def include_row(row: dict, mode: str):
hit = bool(row.get("hit"))
if mode == "all":
return "segment" in row
if mode == "hits":
return "segment" in row and hit
return "segment" in row and not hit
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
report_rows = json.loads(args.report_json.expanduser().resolve().read_text(encoding="utf-8"))
clip_root = args.clip_root.expanduser().resolve()
frame_dir = ensure_dir(workspace / "review" / "leadins" / "frames")
contact_sheet_dir = ensure_dir(workspace / "review" / "leadins" / "contact_sheets")
manifest_path = workspace / "review" / "bookmark_leadins.csv"
write_csv_header(manifest_path, BOOKMARK_LEADIN_MANIFEST_FIELDS)
manifest_rows = load_existing_rows(manifest_path)
imported_windows = 0
imported_frames = 0
for row in report_rows:
if not include_row(row, args.mode):
continue
sampled_frames = extract_window_frames(row, clip_root, args.sample_every, args.max_samples)
if not sampled_frames:
continue
imported_windows += 1
window_result = "hit" if row.get("hit") else "miss"
session_id = row["sessionId"]
bookmark_number = int(row["bookmarkNumber"])
route = row["route"]
segment = int(row["segment"])
published_values = ",".join(str(value) for value in row.get("publishedValues", []))
candidate_values = ",".join(str(value) for value in row.get("candidateValues", []))
contact_sheet_name = f"{session_id}_bookmark_{bookmark_number:03d}_{window_result}.jpg"
contact_sheet_path = contact_sheet_dir / contact_sheet_name
contact_sheet_labels = []
contact_sheet_frames = []
for sample_index, sample in enumerate(sampled_frames, start=1):
frame_name = f"{session_id}_bookmark_{bookmark_number:03d}_sample_{sample_index:02d}.jpg"
frame_path = frame_dir / frame_name
if args.overwrite or not frame_path.exists():
cv2.imwrite(str(frame_path), sample["frame_bgr"], [cv2.IMWRITE_JPEG_QUALITY, 90])
imported_frames += 1
contact_sheet_frames.append(sample["frame_bgr"])
contact_sheet_labels.append(f"t={sample['relative_offset_s']:+.2f}s")
record_key = f"{session_id}:bookmark:{bookmark_number}:sample:{sample_index}"
manifest_rows[record_key] = {
"record_key": record_key,
"source_name": args.source_name,
"source_region": args.source_region,
"source_device": args.source_device,
"source_driver": args.source_driver,
"session_id": session_id,
"bookmark_number": str(bookmark_number),
"route": route,
"segment": str(segment),
"segment_offset_s": str(row["segmentOffsetS"]),
"leadin_start_s": str(row["leadinStartS"]),
"sample_offset_s": f"{sample['relative_offset_s']:.3f}",
"window_result": window_result,
"published_values": published_values,
"candidate_values": candidate_values,
"frame_path": str(frame_path.relative_to(workspace)),
"contact_sheet_path": str(contact_sheet_path.relative_to(workspace)),
"source_video_path": str(sample["source_video"]),
}
write_contact_sheet(contact_sheet_path, contact_sheet_frames, contact_sheet_labels, args.overwrite)
with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file:
writer = csv.DictWriter(manifest_file, fieldnames=BOOKMARK_LEADIN_MANIFEST_FIELDS)
writer.writeheader()
for row in sorted(manifest_rows.values(), key=lambda entry: entry["record_key"]):
writer.writerow(row)
print(f"Imported {imported_windows} lead-in window(s) and {imported_frames} sampled frame(s) into {manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import shutil
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
BOOKMARK_MANIFEST_FIELDS,
DEFAULT_DEBUG_BASE,
DEFAULT_WORKSPACE,
ensure_dir,
latest_debug_sessions,
read_jsonl,
resolve_workspace,
write_csv_header,
)
else:
from .common import (
BOOKMARK_MANIFEST_FIELDS,
DEFAULT_DEBUG_BASE,
DEFAULT_WORKSPACE,
ensure_dir,
latest_debug_sessions,
read_jsonl,
resolve_workspace,
write_csv_header,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import speed-limit debug sessions into a training workspace manifest.")
parser.add_argument("sessions", nargs="*", help="Session ids or full session paths. Defaults to the latest session under --debug-base.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--debug-base", type=Path, default=DEFAULT_DEBUG_BASE, help="Root directory containing vision debug sessions.")
parser.add_argument("--latest", type=int, default=1, help="How many latest sessions to import when no session ids are provided.")
parser.add_argument("--mode", choices=("symlink", "copy"), default="symlink", help="How to place snapshots into the workspace review/images directory.")
parser.add_argument("--force", action="store_true", help="Overwrite snapshot links/files if they already exist.")
parser.add_argument("--events", nargs="+", default=["bookmark", "auto_bookmark", "publish", "candidate"], help="Event types to include in the manifest.")
return parser.parse_args()
def resolve_sessions(session_args: list[str], debug_base: Path, latest_count: int) -> list[Path]:
if session_args:
resolved = []
for session_arg in session_args:
candidate = Path(session_arg).expanduser()
if candidate.is_dir():
resolved.append(candidate.resolve())
continue
candidate = (debug_base / session_arg).resolve()
if candidate.is_dir():
resolved.append(candidate)
continue
raise FileNotFoundError(f"Debug session not found: {session_arg}")
return resolved
latest = latest_debug_sessions(debug_base, latest_count)
if not latest:
raise FileNotFoundError(f"No debug sessions found in {debug_base}")
return latest
def load_existing_rows(manifest_path: Path) -> dict[str, dict[str, str]]:
if not manifest_path.exists():
return {}
with manifest_path.open("r", encoding="utf-8", newline="") as manifest_file:
reader = csv.DictReader(manifest_file)
return {row["record_key"]: row for row in reader if row.get("record_key")}
def stage_snapshot(source_path: Path, dest_path: Path, mode: str, force: bool) -> None:
ensure_dir(dest_path.parent)
if dest_path.exists() or dest_path.is_symlink():
if not force:
return
if dest_path.is_dir():
shutil.rmtree(dest_path)
else:
dest_path.unlink()
if mode == "copy":
shutil.copy2(source_path, dest_path)
else:
dest_path.symlink_to(source_path)
def event_row(event: dict, session_id: str, session_path: Path, event_index: int, snapshot_path: str) -> dict[str, str]:
return {
"record_key": f"{session_id}:{event_index}",
"session_id": session_id,
"event_index": str(event_index),
"event": str(event.get("event") or ""),
"session_seconds": str(event.get("sessionSeconds") or ""),
"wall_time": str(event.get("wallTime") or ""),
"road_name": str(event.get("roadName") or ""),
"stream": str(event.get("stream") or ""),
"status": str(event.get("status") or ""),
"candidate_speed_limit_mph": str(event.get("candidateSpeedLimitMph") or ""),
"candidate_confidence": str(event.get("candidateConfidence") or ""),
"speed_limit_mph": str(event.get("speedLimitMph") or ""),
"confidence": str(event.get("confidence") or ""),
"published_speed_limit_mph": str(event.get("publishedSpeedLimitMph") or ""),
"published_confidence": str(event.get("publishedConfidence") or ""),
"bookmark_count": str(event.get("bookmarkCount") or ""),
"snapshot_path": snapshot_path,
"source_session_path": str(session_path),
}
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
review_image_dir = ensure_dir(workspace / "review" / "images")
manifest_path = workspace / "review" / "bookmarks.csv"
write_csv_header(manifest_path, BOOKMARK_MANIFEST_FIELDS)
existing_rows = load_existing_rows(manifest_path)
sessions = resolve_sessions(args.sessions, args.debug_base.expanduser().resolve(), args.latest)
selected_events = set(args.events)
for session_path in sessions:
events_path = session_path / "events.jsonl"
if not events_path.is_file():
continue
session_id = session_path.name
for event_index, event in enumerate(read_jsonl(events_path)):
event_type = str(event.get("event") or "")
if event_type not in selected_events:
continue
snapshot_rel_path = ""
snapshot_name = event.get("snapshot")
if snapshot_name:
source_snapshot = session_path / str(snapshot_name)
if source_snapshot.is_file():
dest_name = f"{session_id}_{event_index:04d}_{event_type}{source_snapshot.suffix.lower()}"
dest_snapshot = review_image_dir / dest_name
stage_snapshot(source_snapshot, dest_snapshot, args.mode, args.force)
snapshot_rel_path = str(dest_snapshot.relative_to(workspace))
row = event_row(event, session_id, session_path, event_index, snapshot_rel_path)
existing_rows[row["record_key"]] = row
with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file:
writer = csv.DictWriter(manifest_file, fieldnames=BOOKMARK_MANIFEST_FIELDS)
writer.writeheader()
for row in sorted(existing_rows.values(), key=lambda entry: (entry["session_id"], int(entry["event_index"]))):
writer.writerow(row)
print(f"Imported {len(sessions)} debug session(s) into {manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import hashlib
import re
from collections import defaultdict
from pathlib import Path
import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
DEFAULT_WORKSPACE,
DETECTOR_CLASS_NAMES,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
VALUE_LABEL_FIELDS,
default_raw_root,
ensure_dir,
resolve_workspace,
)
else:
from .common import (
DEFAULT_WORKSPACE,
DETECTOR_CLASS_NAMES,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
VALUE_LABEL_FIELDS,
default_raw_root,
ensure_dir,
resolve_workspace,
)
SOURCE_NAME = "glare_images"
SOURCE_VERSION = "GLARE Images"
SOURCE_LICENSE = "CC BY 4.0"
SPEED_TAG_PATTERN = re.compile(r"(speedLimit|exitSpeedAdvisory|rampSpeedAdvisory)(\d+)$")
GLARE_IGNORE_TAGS = {"speedLimit55Ahead"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import GLARE image annotations into the speed-limit training workspace.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--images-root", type=Path, help="Path to the downloaded GLARE Images directory. Defaults to <raw>/glare_raw/Images.")
parser.add_argument("--train-split", type=float, default=0.85, help="Train split ratio by origin-track hash.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite previously imported GLARE samples.")
return parser.parse_args()
def default_images_root(workspace: Path) -> Path:
return default_raw_root(workspace) / "glare_raw" / "Images"
def read_existing_rows(path: Path) -> list[dict[str, str]]:
if not path.is_file():
return []
with path.open("r", encoding="utf-8", newline="") as csv_file:
return list(csv.DictReader(csv_file))
def write_rows(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
ensure_dir(path.parent)
with path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def infer_label(tag: str) -> tuple[str, int] | None:
if tag in GLARE_IGNORE_TAGS:
return None
match = SPEED_TAG_PATTERN.fullmatch(tag)
if not match:
return None
tag_type, speed_value_text = match.groups()
speed_value = int(speed_value_text)
if tag_type == "speedLimit":
return ("regulatory_speed_limit", speed_value)
return ("advisory_speed_limit", speed_value)
def split_for_track(track_name: str, train_ratio: float) -> str:
digest = hashlib.md5(track_name.encode("utf-8")).hexdigest()
value = int(digest[:8], 16) / 0xFFFFFFFF
return "train" if value < train_ratio else "val"
def yolo_box(image_width: int, image_height: int, xmin: int, ymin: int, xmax: int, ymax: int) -> tuple[float, float, float, float]:
box_width = max(xmax - xmin, 1)
box_height = max(ymax - ymin, 1)
x_center = xmin + box_width / 2.0
y_center = ymin + box_height / 2.0
return (
x_center / image_width,
y_center / image_height,
box_width / image_width,
box_height / image_height,
)
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
images_root = args.images_root.resolve() if args.images_root else default_images_root(workspace)
annotations_csv = images_root / "allAnnotations.csv"
if not annotations_csv.is_file():
raise FileNotFoundError(f"GLARE allAnnotations.csv not found: {annotations_csv}")
detector_manifest_path = workspace / "manifests" / "public_detector_samples.csv"
classifier_manifest_path = workspace / "manifests" / "public_classifier_samples.csv"
value_labels_path = workspace / "classifier" / "value_labels.csv"
raw_sources_path = workspace / "manifests" / "raw_sources.csv"
existing_detector_rows = [row for row in read_existing_rows(detector_manifest_path) if row.get("source_name") != SOURCE_NAME]
existing_classifier_rows = [row for row in read_existing_rows(classifier_manifest_path) if row.get("source_name") != SOURCE_NAME]
existing_value_rows = [row for row in read_existing_rows(value_labels_path) if SOURCE_NAME not in (row.get("image_path") or "")]
existing_source_rows = [row for row in read_existing_rows(raw_sources_path) if row.get("source_name") != SOURCE_NAME]
grouped: dict[str, list[dict[str, str]]] = defaultdict(list)
with annotations_csv.open("r", encoding="utf-8", newline="") as csv_file:
reader = csv.DictReader(csv_file)
for row in reader:
tag = (row.get("Annotation tag") or "").strip()
if infer_label(tag) is None:
continue
filename = (row.get("Filename") or "").strip()
if filename:
grouped[filename].append(row)
detector_rows: list[dict[str, str]] = []
classifier_rows: list[dict[str, str]] = []
value_rows: list[dict[str, str]] = []
class_counts: dict[str, int] = defaultdict(int)
imported_images = 0
imported_boxes = 0
for filename in sorted(grouped):
source_image = images_root / filename
if not source_image.is_file():
continue
box_rows = grouped[filename]
track_name = (box_rows[0].get("Origin track") or filename).strip()
split = split_for_track(track_name, args.train_split)
stem = Path(filename).stem
image_out = workspace / "detector" / "images" / split / f"{SOURCE_NAME}_{stem}.png"
label_out = workspace / "detector" / "labels" / split / f"{SOURCE_NAME}_{stem}.txt"
image_bgr = cv2.imread(str(source_image))
if image_bgr is None:
continue
image_height, image_width = image_bgr.shape[:2]
if args.overwrite or not image_out.exists():
ensure_dir(image_out.parent)
image_out.write_bytes(source_image.read_bytes())
yolo_lines: list[str] = []
for bbox_index, row in enumerate(box_rows):
tag = row["Annotation tag"].strip()
inferred = infer_label(tag)
if inferred is None:
continue
class_name, speed_value = inferred
class_id = DETECTOR_CLASS_NAMES.index(class_name)
xmin = int(float(row["Upper left corner X"]))
ymin = int(float(row["Upper left corner Y"]))
xmax = int(float(row["Lower right corner X"]))
ymax = int(float(row["Lower right corner Y"]))
x_center, y_center, width, height = yolo_box(image_width, image_height, xmin, ymin, xmax, ymax)
yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
record_key = f"{SOURCE_NAME}:{stem}:{bbox_index}"
detector_rows.append({
"record_key": record_key,
"source_name": SOURCE_NAME,
"split": split,
"image_path": str(image_out.relative_to(workspace)),
"label_path": str(label_out.relative_to(workspace)),
"annotation_path": str(annotations_csv),
"source_image_id": filename,
"class_name": class_name,
"speed_limit_mph": str(speed_value),
"sign_code": tag,
"bbox_left": str(xmin),
"bbox_top": str(ymin),
"bbox_right": str(xmax),
"bbox_bottom": str(ymax),
})
classifier_rows.append({
"record_key": record_key,
"source_name": SOURCE_NAME,
"split": split,
"image_path": str(image_out.relative_to(workspace)),
"speed_limit_mph": str(speed_value),
"bbox_index": str(bbox_index),
"label_path": str(label_out.relative_to(workspace)),
"source_image_id": filename,
"sign_code": tag,
})
value_rows.append({
"image_path": str(image_out.relative_to(workspace)),
"split": split,
"speed_limit_mph": str(speed_value),
"bbox_index": str(bbox_index),
"padding": "0.10",
"label_path": str(label_out.relative_to(workspace)),
})
class_counts[f"{class_name}:{speed_value}"] += 1
imported_boxes += 1
if yolo_lines and (args.overwrite or not label_out.exists()):
ensure_dir(label_out.parent)
label_out.write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
imported_images += 1
source_row = {
"source_name": SOURCE_NAME,
"source_version": SOURCE_VERSION,
"source_license": SOURCE_LICENSE,
"source_type": "public_detector_and_classifier_seed",
"raw_path": str(images_root),
"notes": "Imported GLARE Images/allAnnotations.csv speed-limit and advisory-speed tags.",
}
write_rows(raw_sources_path, RAW_SOURCE_FIELDS, existing_source_rows + [source_row])
write_rows(detector_manifest_path, PUBLIC_DETECTOR_SAMPLE_FIELDS, existing_detector_rows + detector_rows)
write_rows(classifier_manifest_path, PUBLIC_CLASSIFIER_SAMPLE_FIELDS, existing_classifier_rows + classifier_rows)
write_rows(value_labels_path, VALUE_LABEL_FIELDS, existing_value_rows + value_rows)
summary = ", ".join(f"{name}={count}" for name, count in sorted(class_counts.items()))
print(f"Imported {imported_images} GLARE image(s) and {imported_boxes} box(es) from {images_root}")
print(f" detector manifest: {detector_manifest_path}")
print(f" classifier manifest: {classifier_manifest_path}")
print(f" class counts: {summary or 'none'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import hashlib
import io
from collections import defaultdict
from pathlib import Path
from zipfile import ZipFile
import cv2
import numpy as np
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
DEFAULT_WORKSPACE,
DETECTOR_CLASS_NAMES,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
VALUE_LABEL_FIELDS,
default_raw_root,
ensure_dir,
resolve_workspace,
)
else:
from .common import (
DEFAULT_WORKSPACE,
DETECTOR_CLASS_NAMES,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
VALUE_LABEL_FIELDS,
default_raw_root,
ensure_dir,
resolve_workspace,
)
SOURCE_NAME = "lisa_traffic_sign"
SOURCE_VERSION = "Kaggle omkarnadkarni/lisa-traffic-sign"
SOURCE_LICENSE = "See Kaggle dataset page"
DEFAULT_ZIP_RELATIVE = Path("lisa/lisa_traffic_sign.zip")
LISA_LABEL_MAP: dict[str, tuple[str, int | None] | None] = {
"speedLimit15": ("regulatory_speed_limit", 15),
"speedLimit25": ("regulatory_speed_limit", 25),
"speedLimit30": ("regulatory_speed_limit", 30),
"speedLimit35": ("regulatory_speed_limit", 35),
"speedLimit40": ("regulatory_speed_limit", 40),
"speedLimit45": ("regulatory_speed_limit", 45),
"speedLimit50": ("regulatory_speed_limit", 50),
"speedLimit55": ("regulatory_speed_limit", 55),
"speedLimit65": ("regulatory_speed_limit", 65),
"speedLimitUrdbl": ("regulatory_speed_limit", None),
"schoolSpeedLimit25": ("school_zone_speed_limit", 25),
"rampSpeedAdvisory20": ("advisory_speed_limit", 20),
"rampSpeedAdvisory35": ("advisory_speed_limit", 35),
"rampSpeedAdvisory40": ("advisory_speed_limit", 40),
"rampSpeedAdvisory45": ("advisory_speed_limit", 45),
"rampSpeedAdvisory50": ("advisory_speed_limit", 50),
"rampSpeedAdvisoryUrdbl": ("advisory_speed_limit", None),
"truckSpeedLimit55": None,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import speed-related LISA samples from the Kaggle ZIP into the training workspace.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--zip-path", type=Path, help="Path to lisa_traffic_sign.zip. Defaults to the SSD raw-data layout.")
parser.add_argument("--train-split", type=float, default=0.85, help="Train split ratio by origin-track hash.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite previously imported LISA samples.")
return parser.parse_args()
def default_zip_path(workspace: Path) -> Path:
return default_raw_root(workspace) / DEFAULT_ZIP_RELATIVE
def read_existing_rows(path: Path) -> list[dict[str, str]]:
if not path.is_file():
return []
with path.open("r", encoding="utf-8", newline="") as csv_file:
return list(csv.DictReader(csv_file))
def write_rows(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
ensure_dir(path.parent)
with path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def split_for_track(track_name: str, train_ratio: float) -> str:
digest = hashlib.md5(track_name.encode("utf-8")).hexdigest()
value = int(digest[:8], 16) / 0xFFFFFFFF
return "train" if value < train_ratio else "val"
def yolo_box(image_width: int, image_height: int, xmin: int, ymin: int, xmax: int, ymax: int) -> tuple[float, float, float, float]:
box_width = max(xmax - xmin, 1)
box_height = max(ymax - ymin, 1)
x_center = xmin + box_width / 2.0
y_center = ymin + box_height / 2.0
return (
x_center / image_width,
y_center / image_height,
box_width / image_width,
box_height / image_height,
)
def parse_lisa_csv(csv_text: str) -> dict[str, list[dict[str, str]]]:
grouped: dict[str, list[dict[str, str]]] = defaultdict(list)
reader = csv.DictReader(io.StringIO(csv_text), delimiter=";")
for row in reader:
tag = (row.get("Annotation tag") or "").strip()
mapped = LISA_LABEL_MAP.get(tag)
if mapped is None:
continue
filename = (row.get("Filename") or "").strip()
if filename:
grouped[filename].append(row)
return grouped
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
zip_path = args.zip_path.resolve() if args.zip_path else default_zip_path(workspace)
if not zip_path.is_file():
raise FileNotFoundError(f"LISA ZIP not found: {zip_path}")
detector_manifest_path = workspace / "manifests" / "public_detector_samples.csv"
classifier_manifest_path = workspace / "manifests" / "public_classifier_samples.csv"
value_labels_path = workspace / "classifier" / "value_labels.csv"
raw_sources_path = workspace / "manifests" / "raw_sources.csv"
existing_detector_rows = [row for row in read_existing_rows(detector_manifest_path) if row.get("source_name") != SOURCE_NAME]
existing_classifier_rows = [row for row in read_existing_rows(classifier_manifest_path) if row.get("source_name") != SOURCE_NAME]
existing_value_rows = [row for row in read_existing_rows(value_labels_path) if SOURCE_NAME not in (row.get("image_path") or "")]
existing_source_rows = [row for row in read_existing_rows(raw_sources_path) if row.get("source_name") != SOURCE_NAME]
detector_rows: list[dict[str, str]] = []
classifier_rows: list[dict[str, str]] = []
value_rows: list[dict[str, str]] = []
class_counts: dict[str, int] = defaultdict(int)
imported_images = 0
imported_boxes = 0
with ZipFile(zip_path) as zip_file:
csv_members = sorted(name for name in zip_file.namelist() if name.endswith("frameAnnotations.csv"))
for csv_member in csv_members:
csv_text = zip_file.read(csv_member).decode("utf-8", "ignore")
grouped = parse_lisa_csv(csv_text)
csv_parent = Path(csv_member).parent
drive_slug = Path(csv_member).parts[0]
for filename in sorted(grouped):
source_image_member = str(csv_parent / filename)
try:
image_bytes = zip_file.read(source_image_member)
except KeyError:
continue
image_array = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if image_array is None:
continue
image_height, image_width = image_array.shape[:2]
box_rows = grouped[filename]
track_name = (box_rows[0].get("Origin track") or filename).strip()
split = split_for_track(f"{drive_slug}:{track_name}", args.train_split)
stem = Path(filename).stem
out_stem = f"{SOURCE_NAME}_{drive_slug}_{stem}"
image_out = workspace / "detector" / "images" / split / f"{out_stem}.png"
label_out = workspace / "detector" / "labels" / split / f"{out_stem}.txt"
if args.overwrite or not image_out.exists():
ensure_dir(image_out.parent)
image_out.write_bytes(image_bytes)
yolo_lines: list[str] = []
valid_boxes = 0
for bbox_index, row in enumerate(box_rows):
tag = row["Annotation tag"].strip()
mapped = LISA_LABEL_MAP.get(tag)
if mapped is None:
continue
class_name, speed_value = mapped
class_id = DETECTOR_CLASS_NAMES.index(class_name)
xmin = int(float(row["Upper left corner X"]))
ymin = int(float(row["Upper left corner Y"]))
xmax = int(float(row["Lower right corner X"]))
ymax = int(float(row["Lower right corner Y"]))
x_center, y_center, width, height = yolo_box(image_width, image_height, xmin, ymin, xmax, ymax)
yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
record_key = f"{SOURCE_NAME}:{drive_slug}:{stem}:{bbox_index}"
detector_rows.append({
"record_key": record_key,
"source_name": SOURCE_NAME,
"split": split,
"image_path": str(image_out.relative_to(workspace)),
"label_path": str(label_out.relative_to(workspace)),
"annotation_path": f"{zip_path}:{csv_member}",
"source_image_id": f"{drive_slug}/{filename}",
"class_name": class_name,
"speed_limit_mph": "" if speed_value is None else str(speed_value),
"sign_code": tag,
"bbox_left": str(xmin),
"bbox_top": str(ymin),
"bbox_right": str(xmax),
"bbox_bottom": str(ymax),
})
if speed_value is not None:
classifier_rows.append({
"record_key": record_key,
"source_name": SOURCE_NAME,
"split": split,
"image_path": str(image_out.relative_to(workspace)),
"speed_limit_mph": str(speed_value),
"bbox_index": str(bbox_index),
"label_path": str(label_out.relative_to(workspace)),
"source_image_id": f"{drive_slug}/{filename}",
"sign_code": tag,
})
value_rows.append({
"image_path": str(image_out.relative_to(workspace)),
"split": split,
"speed_limit_mph": str(speed_value),
"bbox_index": str(bbox_index),
"padding": "0.10",
"label_path": str(label_out.relative_to(workspace)),
})
class_counts[f"{class_name}:{'' if speed_value is None else speed_value}"] += 1
imported_boxes += 1
valid_boxes += 1
if valid_boxes and (args.overwrite or not label_out.exists()):
ensure_dir(label_out.parent)
label_out.write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
imported_images += 1
source_row = {
"source_name": SOURCE_NAME,
"source_version": SOURCE_VERSION,
"source_license": SOURCE_LICENSE,
"source_type": "public_detector_and_classifier_seed",
"raw_path": str(zip_path),
"notes": "Imported speed-related LISA samples directly from the Kaggle ZIP. Ignores truckSpeedLimit55.",
}
write_rows(raw_sources_path, RAW_SOURCE_FIELDS, existing_source_rows + [source_row])
write_rows(detector_manifest_path, PUBLIC_DETECTOR_SAMPLE_FIELDS, existing_detector_rows + detector_rows)
write_rows(classifier_manifest_path, PUBLIC_CLASSIFIER_SAMPLE_FIELDS, existing_classifier_rows + classifier_rows)
write_rows(value_labels_path, VALUE_LABEL_FIELDS, existing_value_rows + value_rows)
summary = ", ".join(f"{name}={count}" for name, count in sorted(class_counts.items()))
print(f"Imported {imported_images} LISA image(s) and {imported_boxes} box(es) from {zip_path}")
print(f" detector manifest: {detector_manifest_path}")
print(f" classifier manifest: {classifier_manifest_path}")
print(f" class counts: {summary or 'none'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import random
from pathlib import Path
import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
LOCALIZED_MANIFEST = Path(".tmp/bookmark_sign_localization/localized_bookmarks.csv")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Import high-confidence localized bookmark sign frames into the detector dataset.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--localized-manifest", type=Path, default=LOCALIZED_MANIFEST, help="CSV output from localize_bookmark_signs.py.")
parser.add_argument("--manifest-out", type=Path, help="Optional output CSV manifest path.")
parser.add_argument("--split", choices=("train", "val"), default="train", help="Target detector split.")
parser.add_argument("--source-name", default="comma_bookmark_localized", help="Logical source name for imported localized bookmarks.")
parser.add_argument("--source-region", default="", help="Optional region or market label, e.g. us_midwest.")
parser.add_argument("--source-device", default="", help="Optional device identifier or platform label.")
parser.add_argument("--source-driver", default="", help="Optional contributor/driver identifier.")
parser.add_argument("--session-id", action="append", default=[], help="Optional session_id filter. May be specified multiple times.")
parser.add_argument("--min-score", type=float, default=1.2, help="Minimum localization score to keep.")
parser.add_argument("--min-width", type=int, default=25, help="Minimum bbox width in pixels.")
parser.add_argument("--min-height", type=int, default=40, help="Minimum bbox height in pixels.")
parser.add_argument("--require-consistent", type=int, default=2, help="Require this many matching non-empty value reads across model/ocr/full_detection.")
parser.add_argument("--variants-per-example", type=int, default=6, help="Photometric variants to generate per localized frame.")
parser.add_argument("--temporal-radius-s", type=float, default=0.0, help="Optional radius in seconds to sample neighboring source-video frames around each localized hit.")
parser.add_argument("--temporal-step-s", type=float, default=0.15, help="Seconds between temporal neighbor samples when --temporal-radius-s is enabled.")
parser.add_argument("--temporal-box-expand", type=float, default=0.14, help="Fractional box expansion to apply to temporal neighbor labels.")
parser.add_argument("--seed", type=int, default=20260331, help="Random seed.")
parser.add_argument("--jpeg-quality", type=int, default=95, help="JPEG quality for written detector images.")
return parser.parse_args()
def detector_label_line(detector_class: int, x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> str:
image_h, image_w = image_shape[:2]
x_center = ((x1 + x2) / 2) / image_w
y_center = ((y1 + y2) / 2) / image_h
width = (x2 - x1) / image_w
height = (y2 - y1) / image_h
return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
def remove_appledouble_files(root: Path) -> None:
for path in root.rglob("._*"):
if path.is_file() or path.is_symlink():
path.unlink()
def dominant_read(row: dict[str, str]) -> tuple[str, int]:
counts: dict[str, int] = {}
for key in ("model_read", "ocr_read", "full_detection"):
raw = row.get(key, "")
value = raw.split("@", 1)[0].strip() if raw else ""
if value:
counts[value] = counts.get(value, 0) + 1
if not counts:
return "", 0
best_value, best_count = max(counts.items(), key=lambda item: (item[1], item[0]))
return best_value, best_count
def read_bbox(text: str) -> tuple[int, int, int, int]:
x1, y1, x2, y2 = (int(part) for part in text.split(","))
return x1, y1, x2, y2
def clamp_bbox(x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> tuple[int, int, int, int]:
image_h, image_w = image_shape[:2]
x1 = max(0, min(x1, image_w - 2))
y1 = max(0, min(y1, image_h - 2))
x2 = max(x1 + 1, min(x2, image_w - 1))
y2 = max(y1 + 1, min(y2, image_h - 1))
return x1, y1, x2, y2
def expand_bbox(x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int], expand_ratio: float) -> tuple[int, int, int, int]:
width = x2 - x1
height = y2 - y1
x_pad = int(round(width * max(expand_ratio, 0.0) * 0.5))
y_pad = int(round(height * max(expand_ratio, 0.0) * 0.5))
return clamp_bbox(x1 - x_pad, y1 - y_pad, x2 + x_pad, y2 + y_pad, image_shape)
def read_frame_at(video_path: Path, target_time_s: float):
capture = cv2.VideoCapture(str(video_path))
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
frame_index = max(int(round(target_time_s * fps)), 0)
capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
ok, frame_bgr = capture.read()
capture.release()
if not ok or frame_bgr is None:
return None
return frame_bgr
def apply_variant(frame_bgr, variant_index: int, rng: random.Random):
output = frame_bgr.copy()
mode = variant_index % 6
if mode == 0:
alpha = rng.uniform(0.82, 0.94)
beta = rng.randint(-18, 8)
output = cv2.convertScaleAbs(output, alpha=alpha, beta=beta)
elif mode == 1:
alpha = rng.uniform(1.04, 1.14)
beta = rng.randint(-8, 18)
output = cv2.convertScaleAbs(output, alpha=alpha, beta=beta)
elif mode == 2:
output = cv2.GaussianBlur(output, (3, 3), rng.uniform(0.2, 0.8))
elif mode == 3:
noise = rng.normalvariate(0.0, 8.0)
output = cv2.add(output, noise)
elif mode == 4:
hsv = cv2.cvtColor(output, cv2.COLOR_BGR2HSV)
hsv[..., 2] = cv2.convertScaleAbs(hsv[..., 2], alpha=rng.uniform(0.78, 0.9), beta=0)
output = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
else:
encode_params = [cv2.IMWRITE_JPEG_QUALITY, rng.randint(72, 88)]
ok, encoded = cv2.imencode(".jpg", output, encode_params)
if ok:
output = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
return output
def iter_temporal_offsets(radius_s: float, step_s: float) -> list[float]:
if radius_s <= 0.0 or step_s <= 0.0:
return []
steps = int(radius_s / step_s)
offsets: list[float] = []
for step in range(1, steps + 1):
delta = round(step * step_s, 3)
offsets.extend((-delta, delta))
return sorted(offsets)
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
localized_manifest = args.localized_manifest.expanduser().resolve()
if not localized_manifest.is_file():
raise FileNotFoundError(localized_manifest)
session_filter = set(args.session_id)
image_dir = ensure_dir(workspace / "detector" / "images" / args.split)
label_dir = ensure_dir(workspace / "detector" / "labels" / args.split)
manifest_out = args.manifest_out.expanduser().resolve() if args.manifest_out else (ensure_dir(workspace / "review") / "localized_bookmark_detector_examples.csv")
rng = random.Random(args.seed)
temporal_offsets = iter_temporal_offsets(args.temporal_radius_s, args.temporal_step_s)
records: list[dict[str, object]] = []
kept = 0
with localized_manifest.open("r", encoding="utf-8", newline="") as csv_file:
reader = csv.DictReader(csv_file)
for row in reader:
if session_filter and row.get("session_id") not in session_filter:
continue
dominant_value, consistent_count = dominant_read(row)
if consistent_count < max(args.require_consistent, 0):
continue
if row.get("is_regulatory", "").lower() != "true":
continue
score = float(row["score"])
if score < args.min_score:
continue
x1, y1, x2, y2 = read_bbox(row["box"])
width = x2 - x1
height = y2 - y1
if width < args.min_width or height < args.min_height:
continue
frame_path = Path(row["frame_path"])
frame_bgr = cv2.imread(str(frame_path))
if frame_bgr is None:
continue
class_id = int(row["class_id"])
stem_base = f"real_localized_{row['session_id']}_{int(row['bookmark_number']):03d}"
source_video_path = Path(row["source_video_path"]) if row.get("source_video_path") else None
relative_time_s = float(row["relative_time_s"]) if row.get("relative_time_s") else 0.0
source_frames = [("base", frame_bgr, (x1, y1, x2, y2))]
if source_video_path and source_video_path.is_file():
for offset_s in temporal_offsets:
temporal_frame = read_frame_at(source_video_path, max(relative_time_s + offset_s, 0.0))
if temporal_frame is None:
continue
temporal_bbox = expand_bbox(x1, y1, x2, y2, temporal_frame.shape, args.temporal_box_expand)
source_frames.append((f"t{offset_s:+.2f}s".replace(".", "p"), temporal_frame, temporal_bbox))
variants = []
for source_suffix, source_bgr, source_bbox in source_frames:
variants.append((source_suffix, source_bgr, source_bbox))
for variant_index in range(max(args.variants_per_example, 0)):
variants.append((f"{source_suffix}_var{variant_index:02d}", apply_variant(source_bgr, variant_index, rng), source_bbox))
for suffix, variant_bgr, variant_bbox in variants:
image_path = image_dir / f"{stem_base}_{suffix}.jpg"
label_path = label_dir / f"{stem_base}_{suffix}.txt"
cv2.imwrite(str(image_path), variant_bgr, [cv2.IMWRITE_JPEG_QUALITY, args.jpeg_quality])
vx1, vy1, vx2, vy2 = variant_bbox
label_path.write_text(detector_label_line(class_id, vx1, vy1, vx2, vy2, variant_bgr.shape), encoding="utf-8")
records.append({
"record_key": f"{row['session_id']}_{row['bookmark_number']}_{suffix}",
"split": args.split,
"source_name": row.get("source_name", args.source_name),
"source_region": row.get("source_region", args.source_region),
"source_device": row.get("source_device", args.source_device),
"source_driver": row.get("source_driver", args.source_driver),
"session_id": row["session_id"],
"bookmark_number": row["bookmark_number"],
"route": row["route"],
"segment": row["segment"],
"relative_time_s": row["relative_time_s"],
"score": row["score"],
"class_id": class_id,
"dominant_value": dominant_value,
"consistent_count": consistent_count,
"bbox_x1": vx1,
"bbox_y1": vy1,
"bbox_x2": vx2,
"bbox_y2": vy2,
"source_frame": str(frame_path),
"source_crop": row["crop_path"],
"source_video": str(source_video_path) if source_video_path else "",
"source_relative_time_s": relative_time_s,
"dataset_image": str(image_path),
"dataset_label": str(label_path),
})
kept += 1
remove_appledouble_files(image_dir)
remove_appledouble_files(label_dir)
fieldnames = [
"record_key",
"split",
"source_name",
"source_region",
"source_device",
"source_driver",
"session_id",
"bookmark_number",
"route",
"segment",
"relative_time_s",
"score",
"class_id",
"dominant_value",
"consistent_count",
"bbox_x1",
"bbox_y1",
"bbox_x2",
"bbox_y2",
"source_frame",
"source_crop",
"source_video",
"source_relative_time_s",
"dataset_image",
"dataset_label",
]
with manifest_out.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(records)
print(f"Imported {kept} localized bookmark example(s)")
print(f"Wrote {len(records)} detector image(s) into {args.split}")
print(f"Manifest: {manifest_out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
BOOKMARK_MANIFEST_FIELDS,
BOOKMARK_LEADIN_MANIFEST_FIELDS,
DEFAULT_SPEED_VALUES,
VALUE_LABEL_FIELDS,
DEFAULT_WORKSPACE,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
detector_dataset_yaml,
ensure_dir,
resolve_workspace,
workspace_readme,
write_csv_header,
write_text,
)
else:
from .common import (
BOOKMARK_MANIFEST_FIELDS,
BOOKMARK_LEADIN_MANIFEST_FIELDS,
DEFAULT_SPEED_VALUES,
VALUE_LABEL_FIELDS,
DEFAULT_WORKSPACE,
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
PUBLIC_DETECTOR_SAMPLE_FIELDS,
RAW_SOURCE_FIELDS,
detector_dataset_yaml,
ensure_dir,
resolve_workspace,
workspace_readme,
write_csv_header,
write_text,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Initialize a speed-limit detector/classifier training workspace.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Workspace root. Defaults to .tmp/speed_limit_training under the repo root.")
parser.add_argument("--force", action="store_true", help="Overwrite generated template files if they already exist.")
parser.add_argument("--speed-values", nargs="+", type=int, default=list(DEFAULT_SPEED_VALUES), help="Classifier speed values to document in the workspace README.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
for relative_dir in (
"detector/images/train",
"detector/images/val",
"detector/labels/train",
"detector/labels/val",
"classifier/train",
"classifier/val",
"review/images",
"review/leadins/frames",
"review/leadins/contact_sheets",
"manifests",
"raw",
"staging",
"exports",
"runs",
):
ensure_dir(workspace / relative_dir)
write_text(workspace / "detector" / "dataset.yaml", detector_dataset_yaml(workspace), force=args.force)
write_text(workspace / "README.md", workspace_readme(tuple(args.speed_values)), force=args.force)
write_csv_header(workspace / "review" / "bookmarks.csv", BOOKMARK_MANIFEST_FIELDS, force=args.force)
write_csv_header(workspace / "review" / "bookmark_leadins.csv", BOOKMARK_LEADIN_MANIFEST_FIELDS, force=args.force)
write_csv_header(workspace / "classifier" / "value_labels.csv", VALUE_LABEL_FIELDS, force=args.force)
write_csv_header(workspace / "manifests" / "raw_sources.csv", RAW_SOURCE_FIELDS, force=args.force)
write_csv_header(workspace / "manifests" / "public_detector_samples.csv", PUBLIC_DETECTOR_SAMPLE_FIELDS, force=args.force)
write_csv_header(workspace / "manifests" / "public_classifier_samples.csv", PUBLIC_CLASSIFIER_SAMPLE_FIELDS, force=args.force)
print(f"Initialized speed-limit training workspace at {workspace}")
print(f" detector dataset: {workspace / 'detector/dataset.yaml'}")
print(f" review manifest: {workspace / 'review/bookmarks.csv'}")
print(f" lead-in manifest: {workspace / 'review/bookmark_leadins.csv'}")
print(f" value labels: {workspace / 'classifier/value_labels.csv'}")
print(f" raw sources: {workspace / 'manifests/raw_sources.csv'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ( # type: ignore
CLASSIFIER_EXPORT_NAME,
DEFAULT_WORKSPACE,
DETECTOR_EXPORT_NAME,
resolve_workspace,
)
else:
from .common import (
CLASSIFIER_EXPORT_NAME,
DEFAULT_WORKSPACE,
DETECTOR_EXPORT_NAME,
resolve_workspace,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Copy exported speed-limit ONNX models to a comma device.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--detector", type=Path, help="Detector ONNX path. Defaults to <workspace>/exports/speed_limit_us_detector.onnx.")
parser.add_argument("--classifier", type=Path, help="Classifier ONNX path. Defaults to <workspace>/exports/speed_limit_us_value_classifier.onnx.")
parser.add_argument("--host", default="comma@192.168.3.110", help="scp target host.")
parser.add_argument("--remote-dir", default="/data/openpilot/starpilot/assets/vision_models", help="Remote vision model directory.")
return parser.parse_args()
def scp_file(local_path: Path, host: str, remote_dir: str) -> None:
subprocess.run(["scp", str(local_path), f"{host}:{remote_dir}/{local_path.name}"], check=True)
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
detector_path = args.detector.resolve() if args.detector else (workspace / "exports" / DETECTOR_EXPORT_NAME)
classifier_path = args.classifier.resolve() if args.classifier else (workspace / "exports" / CLASSIFIER_EXPORT_NAME)
copied = 0
for local_path in (detector_path, classifier_path):
if not local_path.is_file():
continue
scp_file(local_path, args.host, args.remote_dir)
print(f"Copied {local_path.name} to {args.host}:{args.remote_dir}")
copied += 1
if copied == 0:
raise SystemExit("No ONNX models found to copy")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from pathlib import Path
import cv2
import starpilot.system.speed_limit_vision as slv
from scripts.speed_limit_vision import common
from scripts.speed_limit_vision import evaluate_bookmark_leadins as ebl
DEFAULT_OUTPUT_DIR = Path(".tmp/bookmark_sign_localization")
def parse_args():
parser = argparse.ArgumentParser(description="Search around bookmarks for the most sign-like speed-limit frame and crop.")
parser.add_argument("--clip-root", type=Path, default=ebl.DEFAULT_CLIP_ROOT, help="Copied route clip root.")
parser.add_argument("--qlog-mtimes", type=Path, default=ebl.DEFAULT_QLOG_MTIMES, help="Text file with '<qlog path> <mtime epoch>' lines.")
parser.add_argument("--session-root", type=Path, default=ebl.DEFAULT_SESSION_ROOT, help="Directory containing debug session folders.")
parser.add_argument("--session-route-map", type=Path, default=common.preferred_session_route_map_path(), help="JSON file mapping debug session ids to route log ids.")
parser.add_argument("--models-dir", type=Path, help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.")
parser.add_argument("--search-before", type=float, default=18.0, help="Seconds before the bookmark to scan.")
parser.add_argument("--search-after", type=float, default=2.0, help="Seconds after the bookmark to scan.")
parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled frames.")
parser.add_argument("--top-k", type=int, default=1, help="How many candidate frames to save per bookmark.")
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Output directory for frames/crops/manifest.")
return parser.parse_args()
def configure_models(models_dir: Path | None):
if not models_dir:
return
models_dir = models_dir.expanduser().resolve()
detector_path = models_dir / "speed_limit_us_detector.onnx"
classifier_path = models_dir / "speed_limit_us_value_classifier.onnx"
if not detector_path.is_file():
raise FileNotFoundError(detector_path)
if not classifier_path.is_file():
raise FileNotFoundError(classifier_path)
slv.US_DETECTOR_MODEL_PATH = detector_path
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
def iter_video_samples(clip_path: Path, start_s: float, end_s: float, sample_every: float):
capture = cv2.VideoCapture(str(clip_path))
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
start_frame = max(int(start_s * fps), 0)
end_frame = max(int(end_s * fps), start_frame)
frame_index = 0
while frame_index < start_frame:
ok, _ = capture.read()
if not ok:
capture.release()
return
frame_index += 1
next_sample_s = start_s
while frame_index <= end_frame:
ok, frame_bgr = capture.read()
if not ok or frame_bgr is None:
break
frame_time_s = frame_index / fps
if frame_time_s + 1e-6 >= next_sample_s:
yield frame_time_s, frame_bgr
next_sample_s += sample_every
frame_index += 1
capture.release()
def iter_context_frames(clip_root: Path, window: ebl.BookmarkWindow, search_before: float, search_after: float, sample_every: float):
ranges: list[tuple[Path, float, float]] = []
start_s = window.segment_offset_s - search_before
end_s = window.segment_offset_s + search_after
if start_s < 0.0 and window.segment > 0:
previous_clip = clip_root / f"{window.route}--{window.segment - 1}" / "fcamera.hevc"
if previous_clip.is_file():
ranges.append((previous_clip, max(60.0 + start_s, 0.0), 60.0))
start_s = 0.0
current_clip = clip_root / f"{window.route}--{window.segment}" / "fcamera.hevc"
if current_clip.is_file():
ranges.append((current_clip, max(start_s, 0.0), min(end_s, 60.0)))
for clip_path, range_start_s, range_end_s in ranges:
for source_time_s, frame_bgr in iter_video_samples(clip_path, range_start_s, range_end_s, sample_every):
if clip_path.parent.name.endswith(f"--{window.segment - 1}"):
relative_time_s = source_time_s - 60.0
else:
relative_time_s = source_time_s
yield relative_time_s, clip_path, source_time_s, frame_bgr
def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, class_id: int, proposal_confidence: float, box, full_detection):
frame_height, frame_width = frame_bgr.shape[:2]
x1, y1, x2, y2 = box
box_width = x2 - x1
box_height = y2 - y1
if box_width <= 0 or box_height <= 0:
return None
best = None
for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in slv.DETECTOR_CLASSIFIER_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)
expanded_y2 = min(int(y2 + box_height * expand_bottom), frame_height)
if expanded_x2 <= expanded_x1 or expanded_y2 <= expanded_y1:
continue
sign_crop = frame_bgr[expanded_y1:expanded_y2, expanded_x1:expanded_x2]
if sign_crop.size == 0:
continue
is_regulatory = daemon._is_regulatory_speed_sign(sign_crop) or class_id == 2
model_read = daemon._classify_speed_limit_from_model(sign_crop)
ocr_read = daemon._read_speed_limit_from_crop(sign_crop)
if model_read is None and ocr_read is None:
continue
if class_id == 2:
read_result = model_read or ocr_read
if read_result is None or read_result[0] not in slv.SCHOOL_ZONE_SPEED_VALUES:
continue
elif not is_regulatory:
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
continue
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
else:
if model_read is not None and ocr_read is not None and model_read[0] == ocr_read[0]:
read_result = (model_read[0], max(model_read[1], ocr_read[1]))
else:
read_result = model_read or ocr_read
speed_limit_mph, read_confidence = read_result
area_ratio = ((expanded_x2 - expanded_x1) * (expanded_y2 - expanded_y1)) / max(frame_width * frame_height, 1)
score = read_confidence * 1.1 + proposal_confidence * 0.16 + expansion_weight * 0.08
score += min(area_ratio * 6.0, 0.18)
if is_regulatory:
score += 0.14
if model_read is not None and ocr_read is not None and model_read[0] == ocr_read[0]:
score += 0.24
if full_detection is not None and full_detection.speed_limit_mph == speed_limit_mph:
score += 0.18 + full_detection.confidence * 0.08
candidate = {
"score": score,
"box": (expanded_x1, expanded_y1, expanded_x2, expanded_y2),
"proposal_confidence": proposal_confidence,
"class_id": class_id,
"is_regulatory": is_regulatory,
"model_read": model_read,
"ocr_read": ocr_read,
"full_detection": full_detection,
"read_result": read_result,
}
if best is None or candidate["score"] > best["score"]:
best = candidate
return best
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
full_detection = daemon._detect_sign(frame_bgr)
best = None
for proposal_confidence, class_id, (x1, y1, x2, y2) in daemon._collect_detector_classifier_proposals(frame_bgr):
if class_id == 1:
continue
candidate = _score_expanded_candidate(daemon, frame_bgr, class_id, proposal_confidence, (x1, y1, x2, y2), full_detection)
if candidate is None:
continue
if best is None or candidate["score"] > best["score"]:
best = candidate
return best
def write_manifest(rows: list[dict], path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"session_id",
"bookmark_number",
"route",
"segment",
"relative_time_s",
"source_video_path",
"score",
"proposal_confidence",
"class_id",
"is_regulatory",
"model_read",
"ocr_read",
"full_detection",
"frame_path",
"crop_path",
"box",
])
writer.writeheader()
writer.writerows(rows)
def main():
args = parse_args()
configure_models(args.models_dir)
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
route_mtimes = ebl.load_qlog_mtimes(args.qlog_mtimes.expanduser().resolve())
session_route_map = common.load_session_route_map(args.session_route_map)
if not session_route_map:
raise FileNotFoundError(f"No session route map found at {args.session_route_map}")
clip_root = args.clip_root.expanduser().resolve()
session_root = args.session_root.expanduser().resolve()
output_dir = args.output_dir.expanduser().resolve()
frames_dir = output_dir / "frames"
crops_dir = output_dir / "crops"
frames_dir.mkdir(parents=True, exist_ok=True)
crops_dir.mkdir(parents=True, exist_ok=True)
manifest_rows = []
for session_id, route in session_route_map.items():
bookmarks = ebl.load_bookmarks(session_root / session_id)
for bookmark_number, event in enumerate(bookmarks, start=1):
window = ebl.locate_window(route, event, route_mtimes, 5.0)
if window is None:
continue
ranked = []
for relative_time_s, source_video_path, source_time_s, frame_bgr in iter_context_frames(
clip_root,
window,
args.search_before,
args.search_after,
args.sample_every,
):
scored = score_frame(daemon, frame_bgr)
if scored is None:
continue
ranked.append((scored["score"], relative_time_s, source_video_path, source_time_s, frame_bgr, scored))
ranked.sort(key=lambda item: item[0], reverse=True)
for rank_index, (_, relative_time_s, source_video_path, _, frame_bgr, scored) in enumerate(ranked[:max(args.top_k, 1)], start=1):
x1, y1, x2, y2 = scored["box"]
crop = frame_bgr[y1:y2, x1:x2]
frame_name = f"{session_id}_bookmark_{bookmark_number:03d}_rank_{rank_index:02d}.jpg"
crop_name = f"{session_id}_bookmark_{bookmark_number:03d}_rank_{rank_index:02d}_crop.jpg"
frame_path = frames_dir / frame_name
crop_path = crops_dir / crop_name
cv2.imwrite(str(frame_path), frame_bgr, [cv2.IMWRITE_JPEG_QUALITY, 90])
cv2.imwrite(str(crop_path), crop, [cv2.IMWRITE_JPEG_QUALITY, 90])
def fmt_detection(result):
if result is None:
return ""
return f"{result[0]}@{result[1]:.3f}"
full_detection = scored["full_detection"]
manifest_rows.append({
"session_id": session_id,
"bookmark_number": bookmark_number,
"route": route,
"segment": window.segment,
"relative_time_s": f"{relative_time_s:.3f}",
"source_video_path": str(source_video_path),
"score": f"{scored['score']:.4f}",
"proposal_confidence": f"{scored['proposal_confidence']:.4f}",
"class_id": str(scored["class_id"]),
"is_regulatory": str(bool(scored["is_regulatory"])),
"model_read": fmt_detection(scored["model_read"]),
"ocr_read": fmt_detection(scored["ocr_read"]),
"full_detection": "" if full_detection is None else f"{full_detection.speed_limit_mph}@{full_detection.confidence:.3f}",
"frame_path": str(frame_path),
"crop_path": str(crop_path),
"box": ",".join(str(value) for value in scored["box"]),
})
manifest_path = output_dir / "localized_bookmarks.csv"
write_manifest(manifest_rows, manifest_path)
print(f"Wrote {len(manifest_rows)} localized candidate(s) to {manifest_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from collections import defaultdict
from pathlib import Path
import cv2
import starpilot.system.speed_limit_vision as slv
DEFAULT_MANIFEST = Path(".tmp/speed_limit_training/review/bookmark_leadins.csv")
DEFAULT_OUTPUT = Path(".tmp/speed_limit_training/review/bookmark_leadin_shortlist.csv")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Rank sampled pre-bookmark lead-in frames by sign-likelihood for labeling.")
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST, help="CSV from import_bookmark_leadins.py.")
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Output CSV of top-ranked frames per bookmark.")
parser.add_argument("--top-k", type=int, default=3, help="How many frames to keep per bookmark window.")
return parser.parse_args()
def load_rows(path: Path):
with path.open("r", encoding="utf-8", newline="") as handle:
return [row for row in csv.DictReader(handle) if row.get("frame_path")]
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
detection = daemon._detect_sign_from_detector_classifier(frame_bgr)
ocr_detection = daemon._detect_sign_from_ocr_candidates(frame_bgr)
proposals = daemon._collect_detector_classifier_proposals(frame_bgr)
best_proposal_score = 0.0
best_box = None
frame_height, frame_width = frame_bgr.shape[:2]
for proposal_confidence, class_id, (x1, y1, x2, y2) in proposals[:8]:
box_width = x2 - x1
box_height = y2 - y1
if box_width <= 0 or box_height <= 0:
continue
crop = frame_bgr[y1:y2, x1:x2]
regulatory_bonus = 0.15 if daemon._is_regulatory_speed_sign(crop) else 0.0
class_bonus = 0.10 if class_id in (0, 2) else 0.0
area_bonus = min((box_width * box_height) / max(frame_width * frame_height, 1), 0.04)
score = proposal_confidence + regulatory_bonus + class_bonus + area_bonus
if score > best_proposal_score:
best_proposal_score = score
best_box = (x1, y1, x2, y2)
score = best_proposal_score
reason = "proposal"
predicted_speed = ""
if ocr_detection is not None:
score = max(score, 0.55 + ocr_detection.confidence)
reason = "ocr"
predicted_speed = str(ocr_detection.speed_limit_mph)
if detection is not None:
score = max(score, 1.0 + detection.confidence)
reason = "detector"
predicted_speed = str(detection.speed_limit_mph)
return {
"score": round(score, 4),
"reason": reason,
"predicted_speed": predicted_speed,
"best_box": "" if best_box is None else ",".join(str(v) for v in best_box),
}
def main() -> int:
args = parse_args()
manifest_path = args.manifest.expanduser().resolve()
output_path = args.output.expanduser().resolve()
rows = load_rows(manifest_path)
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
grouped_rows = defaultdict(list)
for row in rows:
grouped_rows[(row["session_id"], row["bookmark_number"])].append(row)
shortlist_rows = []
for (_, _), group_rows in grouped_rows.items():
scored_rows = []
for row in group_rows:
frame_path = (manifest_path.parents[1] / row["frame_path"]).resolve()
frame_bgr = cv2.imread(str(frame_path))
if frame_bgr is None:
continue
scored_rows.append((score_frame(daemon, frame_bgr), row))
scored_rows.sort(key=lambda item: (-item[0]["score"], float(item[1]["sample_offset_s"])))
for rank, (scored, row) in enumerate(scored_rows[:max(args.top_k, 1)], start=1):
shortlist_rows.append({
"session_id": row["session_id"],
"bookmark_number": row["bookmark_number"],
"rank": rank,
"score": scored["score"],
"reason": scored["reason"],
"predicted_speed": scored["predicted_speed"],
"sample_offset_s": row["sample_offset_s"],
"frame_path": row["frame_path"],
"contact_sheet_path": row["contact_sheet_path"],
"window_result": row["window_result"],
"best_box": scored["best_box"],
})
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"session_id",
"bookmark_number",
"rank",
"score",
"reason",
"predicted_speed",
"sample_offset_s",
"frame_path",
"contact_sheet_path",
"window_result",
"best_box",
])
writer.writeheader()
writer.writerows(shortlist_rows)
print(f"Wrote {len(shortlist_rows)} shortlisted frame(s) to {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import random
from collections import defaultdict
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, DETECTOR_CLASS_NAMES, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, DETECTOR_CLASS_NAMES, ensure_dir, resolve_workspace
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create a rebalanced detector dataset rooted in symlinks to the main workspace.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--output-root", type=Path, help="Output dataset root. Defaults to <workspace>/detector_rebalanced.")
parser.add_argument("--max-other-train", type=int, default=4000, help="Maximum number of non-real train images to keep.")
parser.add_argument("--real-val-count", type=int, default=0, help="Hold out this many real train images as extra validation examples.")
parser.add_argument(
"--source-cap",
action="append",
default=[],
metavar="PREFIX=COUNT",
help="Cap train images for a filename prefix, for example arts_challenging=1200 or lisa_traffic_sign=1000.",
)
parser.add_argument("--seed", type=int, default=0, help="Random seed for sampling non-real images.")
parser.add_argument("--copy", action="store_true", help="Copy files instead of creating symlinks.")
return parser.parse_args()
def link_or_copy(src: Path, dst: Path, copy_files: bool) -> None:
if dst.exists() or dst.is_symlink():
dst.unlink()
ensure_dir(dst.parent)
if copy_files:
dst.write_bytes(src.read_bytes())
else:
dst.symlink_to(src.resolve())
def write_dataset_yaml(dataset_root: Path) -> Path:
yaml_lines = [
f"path: {dataset_root}",
"train: images/train",
"val: images/val",
"names:",
]
for index, class_name in enumerate(DETECTOR_CLASS_NAMES):
yaml_lines.append(f" {index}: {class_name}")
dataset_yaml = dataset_root / "dataset.yaml"
dataset_yaml.write_text("\n".join(yaml_lines) + "\n", encoding="utf-8")
return dataset_yaml
def remove_appledouble_files(root: Path) -> None:
for path in root.rglob("._*"):
if path.is_file() or path.is_symlink():
path.unlink()
def visible_file_count(root: Path) -> int:
return sum(1 for path in root.glob("*") if path.is_file() and not path.name.startswith("._"))
def safe_unlink(path: Path) -> None:
try:
path.unlink()
except FileNotFoundError:
pass
def parse_source_caps(values: list[str]) -> dict[str, int]:
caps: dict[str, int] = {}
for value in values:
if "=" not in value:
raise ValueError(f"Invalid --source-cap '{value}', expected PREFIX=COUNT")
prefix, count_text = value.split("=", 1)
prefix = prefix.strip()
if not prefix:
raise ValueError(f"Invalid --source-cap '{value}', missing prefix")
caps[prefix] = max(int(count_text), 0)
return caps
def prefix_for_path(path: Path) -> str:
name = path.name
if name.startswith("real_"):
return "real"
if name.startswith("arts_challenging_"):
return "arts_challenging"
if name.startswith("lisa_traffic_sign_"):
return "lisa_traffic_sign"
if name.startswith("glare_images_"):
return "glare_images"
return name.split("_", 1)[0]
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
source_root = workspace / "detector"
output_root = args.output_root.resolve() if args.output_root else (workspace / "detector_rebalanced")
source_caps = parse_source_caps(args.source_cap)
rng = random.Random(args.seed)
train_images = sorted((source_root / "images" / "train").glob("*"))
train_labels = source_root / "labels" / "train"
val_image_dir = source_root / "images" / "val"
val_label_dir = source_root / "labels" / "val"
real_images = [path for path in train_images if path.name.startswith("real_")]
other_images = [path for path in train_images if not path.name.startswith("real_")]
rng.shuffle(real_images)
heldout_real_images = sorted(real_images[:max(args.real_val_count, 0)], key=lambda path: path.name)
target_real_train_images = sorted(real_images[max(args.real_val_count, 0):], key=lambda path: path.name)
grouped_other_images: dict[str, list[Path]] = defaultdict(list)
for image_path in other_images:
grouped_other_images[prefix_for_path(image_path)].append(image_path)
sampled_other_images: list[Path] = []
uncapped_other_images: list[Path] = []
for prefix, paths in grouped_other_images.items():
rng.shuffle(paths)
if prefix in source_caps:
sampled_other_images.extend(paths[:source_caps[prefix]])
else:
uncapped_other_images.extend(paths)
rng.shuffle(uncapped_other_images)
remaining_slots = max(args.max_other_train - len(sampled_other_images), 0)
sampled_other_images.extend(uncapped_other_images[:remaining_slots])
target_train_images = target_real_train_images + sampled_other_images
target_train_images.sort(key=lambda path: path.name)
output_train_image_dir = ensure_dir(output_root / "images" / "train")
output_train_label_dir = ensure_dir(output_root / "labels" / "train")
output_val_image_dir = ensure_dir(output_root / "images" / "val")
output_val_label_dir = ensure_dir(output_root / "labels" / "val")
for existing in output_train_image_dir.glob("*"):
safe_unlink(existing)
for existing in output_train_label_dir.glob("*"):
safe_unlink(existing)
for existing in output_val_image_dir.glob("*"):
safe_unlink(existing)
for existing in output_val_label_dir.glob("*"):
safe_unlink(existing)
for image_path in target_train_images:
label_path = train_labels / f"{image_path.stem}.txt"
if not label_path.is_file():
continue
link_or_copy(image_path, output_train_image_dir / image_path.name, args.copy)
link_or_copy(label_path, output_train_label_dir / label_path.name, args.copy)
for image_path in sorted(val_image_dir.glob("*")):
label_path = val_label_dir / f"{image_path.stem}.txt"
if not label_path.is_file():
continue
link_or_copy(image_path, output_val_image_dir / image_path.name, args.copy)
link_or_copy(label_path, output_val_label_dir / label_path.name, args.copy)
for image_path in heldout_real_images:
label_path = train_labels / f"{image_path.stem}.txt"
if not label_path.is_file():
continue
link_or_copy(image_path, output_val_image_dir / image_path.name, args.copy)
link_or_copy(label_path, output_val_label_dir / label_path.name, args.copy)
remove_appledouble_files(output_root)
dataset_yaml = write_dataset_yaml(output_root)
print(f"Created rebalanced detector dataset at {output_root}")
print(f"Dataset YAML: {dataset_yaml}")
print(f"Train images: {visible_file_count(output_train_image_dir)}")
print(f" real train: {len(target_real_train_images)}")
print(f" real held out to val: {len(heldout_real_images)}")
print(f" sampled other: {len(sampled_other_images)}")
if source_caps:
print(f" source caps: {source_caps}")
print(f"Val images: {visible_file_count(output_val_image_dir)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
#!/bin/zsh
set -euo pipefail
REPO_ROOT="/Users/dominickthompson/starpilot"
WORKSPACE="/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean"
LOG_DIR="$WORKSPACE/logs"
BASE_DETECTOR_NAME="yolo11n-comma-us-clean-v1"
GLARE_DETECTOR_NAME="yolo11n-comma-us-clean-glare-v1"
CLASSIFIER_NAME="yolo11n-cls-speed-limit-us-clean-v1"
EXPORT_DIR="$WORKSPACE/exports/overnight_latest"
mkdir -p "$LOG_DIR"
cd "$REPO_ROOT"
echo "[$(date)] waiting for GLARE raw download to finish"
while true; do
if pgrep -f "download_glare_raw.py --workspace $WORKSPACE" >/dev/null; then
sleep 30
else
break
fi
done
echo "[$(date)] importing completed GLARE images"
.venv/bin/python scripts/speed_limit_vision/import_glare_images.py --workspace "$WORKSPACE" --overwrite
.venv/bin/python scripts/speed_limit_vision/build_value_dataset.py --workspace "$WORKSPACE" --overwrite
echo "[$(date)] waiting for base detector run to finish"
while true; do
if pgrep -f "train_detector.py --workspace $WORKSPACE .*--name $BASE_DETECTOR_NAME" >/dev/null; then
sleep 30
else
break
fi
done
BASE_DETECTOR_WEIGHTS="$WORKSPACE/runs/detector/$BASE_DETECTOR_NAME/weights/best.pt"
if [[ ! -f "$BASE_DETECTOR_WEIGHTS" ]]; then
echo "[$(date)] missing base detector weights: $BASE_DETECTOR_WEIGHTS" >&2
exit 1
fi
echo "[$(date)] starting GLARE-augmented detector fine-tune"
.venv/bin/python scripts/speed_limit_vision/train_detector.py \
--workspace "$WORKSPACE" \
--device mps \
--epochs 20 \
--batch 24 \
--workers 4 \
--model "$BASE_DETECTOR_WEIGHTS" \
--name "$GLARE_DETECTOR_NAME" \
--exist-ok
DETECTOR_WEIGHTS="$WORKSPACE/runs/detector/$GLARE_DETECTOR_NAME/weights/best.pt"
if [[ ! -f "$DETECTOR_WEIGHTS" ]]; then
DETECTOR_WEIGHTS="$BASE_DETECTOR_WEIGHTS"
fi
echo "[$(date)] training value classifier"
.venv/bin/python scripts/speed_limit_vision/train_value_classifier.py \
--workspace "$WORKSPACE" \
--device mps \
--epochs 40 \
--batch 64 \
--workers 4 \
--name "$CLASSIFIER_NAME" \
--exist-ok
CLASSIFIER_WEIGHTS="$WORKSPACE/runs/classifier/$CLASSIFIER_NAME/weights/best.pt"
if [[ ! -f "$CLASSIFIER_WEIGHTS" ]]; then
echo "[$(date)] missing classifier weights: $CLASSIFIER_WEIGHTS" >&2
exit 1
fi
echo "[$(date)] exporting ONNX models into repo assets"
.venv/bin/python scripts/speed_limit_vision/export_models.py \
--workspace "$WORKSPACE" \
--detector-weights "$DETECTOR_WEIGHTS" \
--classifier-weights "$CLASSIFIER_WEIGHTS" \
--output-dir "$EXPORT_DIR" \
--install-repo-assets
echo "[$(date)] evaluating runtime saved-frame cases"
.venv/bin/python scripts/speed_limit_vision/evaluate_runtime_cases.py \
--models-dir "$EXPORT_DIR" \
--strict | tee "$LOG_DIR/runtime_cases_overnight.txt"
echo "[$(date)] evaluating bookmarked lead-ins"
.venv/bin/python scripts/speed_limit_vision/evaluate_bookmark_leadins.py \
--models-dir "$EXPORT_DIR" \
--lead-in 7 \
--sample-fps 5 \
--json-out "$LOG_DIR/bookmark_windows_overnight.json" | tee "$LOG_DIR/bookmark_windows_overnight.txt"
echo "[$(date)] overnight clean pipeline complete"
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
VIDEO_SUFFIXES = {".hevc", ".mp4", ".mov", ".mkv", ".avi"}
def iter_source_files(paths: list[Path]):
for input_path in paths:
if input_path.is_file():
yield input_path
continue
if not input_path.exists():
continue
for path in sorted(input_path.rglob("*")):
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES:
yield path
def sample_images(source_files: list[Path], output_dir: Path, max_per_file: int):
written = 0
for file_index, image_path in enumerate(source_files, start=1):
frame = cv2.imread(str(image_path))
if frame is None:
continue
stem = image_path.stem.replace(" ", "_")
output_path = output_dir / f"{file_index:05d}_{stem}.jpg"
cv2.imwrite(str(output_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
written += 1
if written >= max_per_file:
break
def sample_video(video_path: Path, output_dir: Path, seconds_between_frames: float, max_frames: int):
cap = cv2.VideoCapture(str(video_path))
fps = cap.get(cv2.CAP_PROP_FPS) or 20.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
frame_step = max(int(round(seconds_between_frames * fps)), 1)
frame_indices = range(0, total_frames if total_frames > 0 else frame_step * max_frames, frame_step)
written = 0
for frame_index in frame_indices:
if written >= max_frames:
break
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
ok, frame = cap.read()
if not ok or frame is None:
continue
timestamp = frame_index / max(fps, 1.0)
safe_stem = video_path.parent.name.replace(" ", "_")
output_path = output_dir / f"{safe_stem}_{timestamp:06.2f}s.jpg"
cv2.imwrite(str(output_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
written += 1
cap.release()
return written
def main():
parser = argparse.ArgumentParser(description="Sample comma route frames into a reusable background pool.")
parser.add_argument("inputs", nargs="*", help="Route/video/image directories. Defaults to .tmp/comma_speed_training/realdata")
parser.add_argument("--workspace", default=str(DEFAULT_WORKSPACE), help="Training workspace root.")
parser.add_argument("--output-dir", default=None, help="Override background output directory.")
parser.add_argument("--sample-seconds", type=float, default=1.25, help="Seconds between sampled frames for each video.")
parser.add_argument("--max-per-video", type=int, default=6, help="Maximum frames to sample from each video.")
parser.add_argument("--limit-files", type=int, default=0, help="Optional cap on the number of source files to sample.")
args = parser.parse_args()
workspace = resolve_workspace(args.workspace)
output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else workspace / "backgrounds"
ensure_dir(output_dir)
default_input = Path(".tmp/comma_speed_training/realdata")
input_paths = [Path(path).expanduser().resolve() for path in args.inputs] if args.inputs else [default_input.resolve()]
source_files = [path for path in iter_source_files(input_paths)]
if args.limit_files > 0:
source_files = source_files[:args.limit_files]
image_files = [path for path in source_files if path.suffix.lower() in IMAGE_SUFFIXES]
video_files = [path for path in source_files if path.suffix.lower() in VIDEO_SUFFIXES]
if not image_files and not video_files:
raise FileNotFoundError("No image or video sources found.")
written = 0
if image_files:
sample_images(image_files, output_dir, max_per_file=max(len(image_files), 1))
written += len(image_files)
for video_path in video_files:
written += sample_video(video_path, output_dir, max(args.sample_seconds, 0.1), max(args.max_per_video, 1))
print(f"Sampled {written} background frames into {output_dir}")
if __name__ == "__main__":
main()
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, resolve_workspace
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train the speed-limit detector using Ultralytics YOLO.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--data", type=Path, help="Detector dataset YAML. Defaults to <workspace>/detector/dataset.yaml.")
parser.add_argument("--model", default="yolo11n.pt", help="Ultralytics detector checkpoint to fine-tune.")
parser.add_argument("--epochs", type=int, default=80, help="Training epochs.")
parser.add_argument("--imgsz", type=int, default=640, help="Training image size.")
parser.add_argument("--batch", type=int, default=16, help="Batch size.")
parser.add_argument("--workers", type=int, default=8, help="Data loader workers.")
parser.add_argument("--device", default="cpu", help="Ultralytics device string, for example cpu, mps, 0, or 0,1.")
parser.add_argument("--project", type=Path, help="Training output directory. Defaults to <workspace>/runs/detector.")
parser.add_argument("--name", default="yolo11n-speed-limit-us", help="Run name under --project.")
parser.add_argument("--patience", type=int, default=20, help="Early stopping patience.")
parser.add_argument("--cache", action="store_true", help="Cache images in RAM if supported.")
parser.add_argument("--exist-ok", action="store_true", help="Allow overwriting an existing run directory.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
data_path = args.data.resolve() if args.data else (workspace / "detector" / "dataset.yaml")
project_path = args.project.resolve() if args.project else (workspace / "runs" / "detector")
try:
from ultralytics import YOLO
except Exception as exc:
raise SystemExit(
"Ultralytics is not installed. Run `uv sync --extra speedvision` in the repo root before training."
) from exc
model = YOLO(args.model)
model.train(
data=str(data_path),
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
workers=args.workers,
device=args.device,
project=str(project_path),
name=args.name,
patience=args.patience,
cache=args.cache,
exist_ok=args.exist_ok,
)
print(f"Detector training complete under {project_path / args.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, resolve_workspace # type: ignore
else:
from .common import DEFAULT_WORKSPACE, resolve_workspace
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train the speed-limit value classifier using Ultralytics YOLO classification.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--data", type=Path, help="Classifier dataset root. Defaults to <workspace>/classifier.")
parser.add_argument("--model", default="yolo11n-cls.pt", help="Ultralytics classification checkpoint to fine-tune.")
parser.add_argument("--epochs", type=int, default=60, help="Training epochs.")
parser.add_argument("--imgsz", type=int, default=128, help="Training image size.")
parser.add_argument("--batch", type=int, default=32, help="Batch size.")
parser.add_argument("--workers", type=int, default=8, help="Data loader workers.")
parser.add_argument("--device", default="cpu", help="Ultralytics device string, for example cpu, mps, 0, or 0,1.")
parser.add_argument("--project", type=Path, help="Training output directory. Defaults to <workspace>/runs/classifier.")
parser.add_argument("--name", default="yolo11n-cls-speed-limit-us", help="Run name under --project.")
parser.add_argument("--patience", type=int, default=15, help="Early stopping patience.")
parser.add_argument("--cache", action="store_true", help="Cache images in RAM if supported.")
parser.add_argument("--exist-ok", action="store_true", help="Allow overwriting an existing run directory.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
data_path = args.data.resolve() if args.data else (workspace / "classifier")
project_path = args.project.resolve() if args.project else (workspace / "runs" / "classifier")
try:
from ultralytics import YOLO
except Exception as exc:
raise SystemExit(
"Ultralytics is not installed. Run `uv sync --extra speedvision` in the repo root before training."
) from exc
model = YOLO(args.model)
model.train(
data=str(data_path),
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
workers=args.workers,
device=args.device,
project=str(project_path),
name=args.name,
patience=args.patience,
cache=args.cache,
exist_ok=args.exist_ok,
)
print(f"Classifier training complete under {project_path / args.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import cv2
from ultralytics import YOLO
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
from generate_value_roi_classifier_dataset import extract_value_mask # type: ignore
else:
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
from .generate_value_roi_classifier_dataset import extract_value_mask
EVAL_WINDOWS = (
("full", (0.0, 0.0, 1.0, 1.0)),
("roi3", (0.40, 0.12, 0.92, 0.86)),
)
SIGN_CLASSES = {
"regulatory_speed_limit",
"school_zone_speed_limit",
"speedLimit15",
"speedLimit20",
"speedLimit25",
"speedLimit30",
"speedLimit35",
"speedLimit40",
"speedLimit45",
"speedLimit50",
"speedLimit55",
"speedLimit60",
"speedLimit65",
"speedLimit70",
"speedLimit75",
"schoolSpeedLimit25",
"speedLimit55Ahead",
}
REAL_CASES = (
("live15", ".tmp/live_c4_capture/stopped_sign_road.jpg", 15),
("school20", ".tmp/route_vision/seg38_frames/frame_041.jpg", 20),
("highway40", ".tmp/speed_route_frames_seg2_10_20/t12.png", 40),
("town40", ".tmp/route_12c_seg9_10/seg10_early/frame_005.png", 40),
("town30", ".tmp/route_12c_seg9_10/seg10_early/frame_012.png", 30),
("town30_late", ".tmp/vision_iter/seg10_5fps/frame_054.png", 30),
)
def detect_best(detector: YOLO, classifier: YOLO, frame_bgr):
frame_height, frame_width = frame_bgr.shape[:2]
best = None
for window_name, (left_ratio, top_ratio, right_ratio, bottom_ratio) in EVAL_WINDOWS:
left = int(frame_width * left_ratio)
top = int(frame_height * top_ratio)
right = int(frame_width * right_ratio)
bottom = int(frame_height * bottom_ratio)
roi = frame_bgr[top:bottom, left:right]
if roi.size == 0:
continue
detector_result = detector.predict(source=roi, conf=0.03, imgsz=640, device="cpu", verbose=False)[0]
if detector_result.boxes is None:
continue
for box, cls, det_conf in zip(
detector_result.boxes.xyxy.cpu().numpy(),
detector_result.boxes.cls.cpu().numpy(),
detector_result.boxes.conf.cpu().numpy(),
):
class_name = detector.names[int(cls)]
if class_name not in SIGN_CLASSES:
continue
x1, y1, x2, y2 = box.astype(int)
x1 += left
x2 += left
y1 += top
y2 += top
box_width = x2 - x1
box_height = y2 - y1
if box_width <= 0 or box_height <= 0:
continue
expand = 0.18
crop_left = max(x1 - int(box_width * expand), 0)
crop_top = max(y1 - int(box_height * expand), 0)
crop_right = min(x2 + int(box_width * expand), frame_width)
crop_bottom = min(y2 + int(box_height * expand), frame_height)
crop = frame_bgr[crop_top:crop_bottom, crop_left:crop_right]
if crop.size == 0:
continue
mask = extract_value_mask(crop)
if mask is None:
continue
classifier_result = classifier.predict(source=cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR), imgsz=128, device="cpu", verbose=False)[0]
probabilities = classifier_result.probs
if probabilities is None:
continue
predicted_value = int(classifier_result.names[int(probabilities.top1)])
classifier_confidence = float(probabilities.top1conf)
score = min(classifier_confidence * 0.82 + float(det_conf) * 0.26, 0.95)
candidate = {
"window": window_name,
"detectorClass": class_name,
"detectorConfidence": round(float(det_conf), 4),
"predictedValue": predicted_value,
"classifierConfidence": round(classifier_confidence, 4),
"score": round(score, 4),
"box": [int(x1), int(y1), int(x2), int(y2)],
}
if best is None or candidate["score"] > best["score"]:
best = candidate
return best
def evaluate_once(detector_weights: Path, classifier_weights: Path):
detector = YOLO(str(detector_weights))
classifier = YOLO(str(classifier_weights))
records = []
for label, frame_path, expected in REAL_CASES:
frame = cv2.imread(frame_path)
best = detect_best(detector, classifier, frame) if frame is not None else None
correct = best is None if expected is None else bool(best is not None and best["predictedValue"] == expected)
records.append({
"case": label,
"frame": frame_path,
"expected": expected,
"best": best,
"correct": correct,
})
return records
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Watch a detector run and evaluate improved checkpoints on the saved comma examples.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--detector-weights", type=Path, required=True, help="Detector weights to watch, usually runs/.../weights/best.pt.")
parser.add_argument("--classifier-weights", type=Path, required=True, help="Classifier weights to use for evaluation.")
parser.add_argument("--interval", type=float, default=30.0, help="Polling interval in seconds.")
parser.add_argument("--output", type=Path, help="JSONL output log path. Defaults to <workspace>/review/detector_watch.jsonl.")
return parser.parse_args()
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
detector_weights = args.detector_weights.resolve()
classifier_weights = args.classifier_weights.resolve()
output_path = args.output.resolve() if args.output else (ensure_dir(workspace / "review") / "detector_watch.jsonl")
ensure_dir(output_path.parent)
last_mtime = None
while True:
if detector_weights.is_file():
mtime = detector_weights.stat().st_mtime
if last_mtime is None or mtime > last_mtime:
last_mtime = mtime
records = evaluate_once(detector_weights, classifier_weights)
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"detectorWeights": str(detector_weights),
"classifierWeights": str(classifier_weights),
"records": records,
}
with output_path.open("a", encoding="utf-8") as output_file:
output_file.write(json.dumps(payload, separators=(",", ":")) + "\n")
print(json.dumps(payload, indent=2))
time.sleep(max(args.interval, 1.0))
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.
+10 -5
View File
@@ -265,6 +265,15 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 1.5)
def speed_limit_changed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality, starpilot_toggles: SimpleNamespace) -> Alert:
speed_limit = sm["starpilotPlan"].unconfirmedSlcSpeedLimit or sm["starpilotPlan"].slcSpeedLimit
return Alert(
"Speed limit detected",
f"Confirm {get_display_speed(speed_limit, metric)}?",
StarPilotAlertStatus.starpilot, AlertSize.mid,
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 3.0)
def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality, starpilot_toggles: SimpleNamespace) -> Alert:
first_word = 'Recalibrating' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibrating'
return Alert(
@@ -1176,11 +1185,7 @@ STARPILOT_EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
},
StarPilotEventName.speedLimitChanged: {
ET.PERMANENT: Alert(
"Speed limit changed",
"",
StarPilotAlertStatus.starpilot, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 3.),
ET.PERMANENT: speed_limit_changed_alert,
},
StarPilotEventName.trafficModeActive: {
@@ -611,7 +611,7 @@ class StarPilotSpeedLimitControllerLayout(StarPilotPanel):
return f"{primary}, {secondary}"
def _on_priority_clicked(self):
primary_options = ["Dashboard", "Map Data", "Highest", "Lowest"]
primary_options = ["Dashboard", "Map Data", "Vision", "Highest", "Lowest"]
current_primary = self._params.get("SLCPriority1", encoding='utf-8') or "Map Data"
current_secondary = self._params.get("SLCPriority2", encoding='utf-8') or "None"
@@ -622,7 +622,7 @@ class StarPilotSpeedLimitControllerLayout(StarPilotPanel):
self._rebuild_grid()
def show_secondary_dialog(primary):
secondary_options = ["None"] + [option for option in ("Dashboard", "Map Data") if option != primary]
secondary_options = ["None"] + [option for option in ("Dashboard", "Map Data", "Vision") if option != primary]
selected_secondary = current_secondary if current_secondary in secondary_options else "None"
gui_app.set_modal_overlay(
SelectionDialog(
@@ -377,6 +377,7 @@ class AugmentedRoadView(CameraView):
return
in_reverse = self._is_in_reverse()
self._hud_renderer.prepare(self._content_rect)
# Draw all UI overlays
if not in_reverse:
@@ -384,6 +385,8 @@ class AugmentedRoadView(CameraView):
# Fade out bottom of overlays for looks
rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE)
if not in_reverse:
self._hud_renderer.render_background()
alert_to_render, not_animating_out = self._alert_renderer.will_render()
@@ -400,7 +403,7 @@ class AugmentedRoadView(CameraView):
if ui_state.started:
self._alert_renderer.render(self._content_rect)
if not in_reverse:
self._hud_renderer.render(self._content_rect)
self._hud_renderer.render_foreground()
if (not in_reverse) and alert_to_render is None:
self._experimental_mode_banner.render(self._content_rect)
if not in_reverse:
+278 -5
View File
@@ -2,6 +2,7 @@ import pyray as rl
from dataclasses import dataclass
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
from openpilot.selfdrive.ui.mici.onroad.speed_limit_utils import resolve_display_speed_limit_ms
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
@@ -19,6 +20,15 @@ CRUISE_DISABLED_CHAR = ''
SET_SPEED_PERSISTENCE = 2.5 # seconds
SPEED_LIMIT_PROMPT_CARD_WIDTH = 500
SPEED_LIMIT_PROMPT_CARD_HEIGHT = 208
SPEED_LIMIT_PROMPT_BUTTON_SIZE = 112
SPEED_LIMIT_PROMPT_BUTTON_GAP = 28
SPEED_LIMIT_PROMPT_CARD_PADDING = 34
SPEED_LIMIT_PROMPT_US_SIGN_WIDTH = 132
SPEED_LIMIT_PROMPT_US_SIGN_HEIGHT = 150
SPEED_LIMIT_PROMPT_EU_SIGN_SIZE = 148
@dataclass(frozen=True)
class FontSizes:
@@ -106,6 +116,15 @@ class HudRenderer(Widget):
self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self._engaged: bool = False
self._show_speed_limit: bool = False
self._speed_limit: float = 0.0
self._speed_limit_overridden: bool = False
self._pending_speed_limit: float = 0.0
self._prompt_visible: bool = False
self._prompt_card_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._prompt_sign_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._prompt_deny_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._prompt_accept_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._can_draw_top_icons = True
self._show_wheel_critical = False
@@ -169,16 +188,68 @@ class HudRenderer(Widget):
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
self.speed = max(0.0, v_ego * speed_conversion)
def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen."""
if sm.recv_frame["starpilotPlan"] >= ui_state.started_frame:
starpilot_plan = sm["starpilotPlan"]
self._show_speed_limit = ui_state.params.get_bool("ShowSpeedLimits") or ui_state.params.get_bool("SpeedLimitController")
if self._show_speed_limit:
dashboard_speed_limit = sm["starpilotCarState"].dashboardSpeedLimit if sm.valid.get("starpilotCarState", False) else 0.0
vision_speed_limit = ui_state.params_memory.get_float("VisionSpeedLimit") if ui_state.params.get_bool("VisionSpeedLimitDetection") else 0.0
primary_priority = ui_state.params.get("SLCPriority1", encoding='utf-8') or "Map Data"
secondary_priority = ui_state.params.get("SLCPriority2", encoding='utf-8') or "None"
source_limits = {
"Dashboard": dashboard_speed_limit,
"Map Data": starpilot_plan.slcMapSpeedLimit,
"Vision": vision_speed_limit,
"Mapbox": starpilot_plan.slcMapboxSpeedLimit if ui_state.params.get_bool("SLCMapboxFiller") else 0.0,
}
resolved_speed_limit = resolve_display_speed_limit_ms(
slc_speed_limit=starpilot_plan.slcSpeedLimit,
speed_limit_source=starpilot_plan.slcSpeedLimitSource,
source_limits=source_limits,
primary_priority=primary_priority,
secondary_priority=secondary_priority,
)
self._speed_limit = max(0.0, resolved_speed_limit * speed_conversion)
self._speed_limit_overridden = bool(starpilot_plan.slcOverriddenSpeed > 0 and starpilot_plan.slcSpeedLimit > 0)
self._pending_speed_limit = max(0.0, starpilot_plan.unconfirmedSlcSpeedLimit * speed_conversion)
else:
self._speed_limit = 0.0
self._speed_limit_overridden = False
self._pending_speed_limit = 0.0
self._prompt_visible = self._pending_speed_limit > 0
else:
self._show_speed_limit = False
self._speed_limit = 0.0
self._speed_limit_overridden = False
self._pending_speed_limit = 0.0
self._prompt_visible = False
def prepare(self, rect: rl.Rectangle) -> None:
"""Update HUD state once before drawing background/foreground passes."""
self.set_rect(rect)
self._update_state()
self._update_prompt_layout(rect)
def render_background(self) -> None:
"""Draw HUD elements that should sit behind alerts."""
self._draw_speed_limit(self._rect)
self._draw_speed_limit_prompt(self._rect)
def render_foreground(self) -> None:
"""Draw HUD elements that should sit above alerts."""
if ui_state.sm['controlsState'].lateralControlState.which() != 'angleState':
self._torque_bar.render(rect)
self._torque_bar.render(self._rect)
if self.is_cruise_set:
self._draw_set_speed(rect)
self._draw_set_speed(self._rect)
self._draw_steering_wheel(rect)
self._draw_steering_wheel(self._rect)
def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen."""
self.prepare(rect)
self.render_background()
self.render_foreground()
def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
@@ -264,6 +335,208 @@ class HudRenderer(Widget):
max_color,
)
def _draw_speed_limit(self, rect: rl.Rectangle) -> None:
if not self._show_speed_limit:
return
display_speed = self._speed_limit if self._speed_limit > 0 else self._pending_speed_limit
if display_speed <= 0:
return
sign_alpha = 72 if self._speed_limit_overridden and self._pending_speed_limit <= 0 else 255
use_vienna_speed_limit = ui_state.params.get_bool("UseVienna")
sign_width = 118 if use_vienna_speed_limit else 116
sign_height = 118 if use_vienna_speed_limit else 132
base_x = rect.x + rect.width - sign_width - 28
sign_x = base_x
sign_y = rect.y + (28 if use_vienna_speed_limit else 20)
speed_text = str(round(display_speed))
if use_vienna_speed_limit:
center_x = sign_x + sign_width / 2
center_y = sign_y + sign_height / 2
radius = sign_width / 2
rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, sign_alpha))
rl.draw_ring(
rl.Vector2(center_x, center_y),
radius - 12,
radius,
0,
360,
64,
rl.Color(201, 34, 49, sign_alpha),
)
font_size = 58 if len(speed_text) <= 2 else 48
text_size = measure_text_cached(self._font_bold, speed_text, font_size)
text_pos = rl.Vector2(center_x - text_size.x / 2, center_y - text_size.y / 2 + 4)
rl.draw_text_ex(self._font_bold, speed_text, text_pos, font_size, 0, rl.Color(0, 0, 0, sign_alpha))
else:
sign_rect = rl.Rectangle(sign_x, sign_y, sign_width, sign_height)
border_rect = rl.Rectangle(sign_x + 6, sign_y + 6, sign_width - 12, sign_height - 12)
rl.draw_rectangle_rounded(sign_rect, 0.18, 16, rl.Color(255, 255, 255, sign_alpha))
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.14, 16, 4, rl.Color(0, 0, 0, sign_alpha))
header_font_size = 20
header_gap = 18
speed_font_size = 50 if len(speed_text) <= 2 else 42
speed_label = tr("SPEED")
limit_label = tr("LIMIT")
speed_label_size = measure_text_cached(self._font_semi_bold, speed_label, header_font_size)
limit_label_size = measure_text_cached(self._font_semi_bold, limit_label, header_font_size)
speed_label_pos = rl.Vector2(sign_x + sign_width / 2 - speed_label_size.x / 2, sign_y + 18)
limit_label_pos = rl.Vector2(sign_x + sign_width / 2 - limit_label_size.x / 2, sign_y + 18 + header_gap)
rl.draw_text_ex(self._font_semi_bold, speed_label, speed_label_pos, header_font_size, 0, rl.Color(0, 0, 0, sign_alpha))
rl.draw_text_ex(self._font_semi_bold, limit_label, limit_label_pos, header_font_size, 0, rl.Color(0, 0, 0, sign_alpha))
speed_text_size = measure_text_cached(self._font_bold, speed_text, speed_font_size)
speed_text_pos = rl.Vector2(sign_x + sign_width / 2 - speed_text_size.x / 2, sign_y + 66)
rl.draw_text_ex(self._font_bold, speed_text, speed_text_pos, speed_font_size, 0, rl.Color(0, 0, 0, sign_alpha))
def _update_prompt_layout(self, rect: rl.Rectangle) -> None:
if not self._prompt_visible:
self._prompt_card_rect = rl.Rectangle(0, 0, 0, 0)
self._prompt_sign_rect = rl.Rectangle(0, 0, 0, 0)
self._prompt_deny_rect = rl.Rectangle(0, 0, 0, 0)
self._prompt_accept_rect = rl.Rectangle(0, 0, 0, 0)
return
use_vienna_speed_limit = ui_state.params.get_bool("UseVienna")
sign_width = SPEED_LIMIT_PROMPT_EU_SIGN_SIZE if use_vienna_speed_limit else SPEED_LIMIT_PROMPT_US_SIGN_WIDTH
sign_height = SPEED_LIMIT_PROMPT_EU_SIGN_SIZE if use_vienna_speed_limit else SPEED_LIMIT_PROMPT_US_SIGN_HEIGHT
button_size = SPEED_LIMIT_PROMPT_BUTTON_SIZE
card_width = max(
SPEED_LIMIT_PROMPT_CARD_WIDTH,
SPEED_LIMIT_PROMPT_CARD_PADDING * 2 + sign_width + button_size * 2 + SPEED_LIMIT_PROMPT_BUTTON_GAP * 2,
)
card_x = rect.x + (rect.width - card_width) / 2
card_y = rect.y + rect.height * 0.34
card_rect = rl.Rectangle(card_x, card_y, card_width, SPEED_LIMIT_PROMPT_CARD_HEIGHT)
controls_y = card_y + 68
deny_x = card_x + SPEED_LIMIT_PROMPT_CARD_PADDING
sign_x = card_x + (card_width - sign_width) / 2
accept_x = card_x + card_width - SPEED_LIMIT_PROMPT_CARD_PADDING - button_size
self._prompt_card_rect = card_rect
self._prompt_sign_rect = rl.Rectangle(sign_x, controls_y - (sign_height - button_size) / 2, sign_width, sign_height)
self._prompt_deny_rect = rl.Rectangle(deny_x, controls_y, button_size, button_size)
self._prompt_accept_rect = rl.Rectangle(accept_x, controls_y, button_size, button_size)
def _draw_prompt_button(self, rect: rl.Rectangle, symbol: str, fill: rl.Color, outline: rl.Color, pressed: bool) -> None:
scale = 0.95 if pressed else 1.0
width = rect.width * scale
height = rect.height * scale
x = rect.x + (rect.width - width) / 2
y = rect.y + (rect.height - height) / 2
button_rect = rl.Rectangle(x, y, width, height)
center = rl.Vector2(button_rect.x + button_rect.width / 2, button_rect.y + button_rect.height / 2)
radius = min(button_rect.width, button_rect.height) / 2
rl.draw_circle_gradient(int(center.x), int(center.y), radius, rl.Color(0, 0, 0, 90), rl.BLANK)
rl.draw_circle(int(center.x), int(center.y), radius, fill)
rl.draw_ring(center, radius - 6, radius, 0, 360, 48, outline)
symbol_size = 88 if symbol == "+" else 98
symbol_text = tr(symbol)
symbol_measure = measure_text_cached(self._font_display, symbol_text, symbol_size)
symbol_pos = rl.Vector2(center.x - symbol_measure.x / 2, center.y - symbol_measure.y / 2 - (6 if symbol == "-" else 0))
rl.draw_text_ex(self._font_display, symbol_text, symbol_pos, symbol_size, 0, rl.WHITE)
def _draw_speed_limit_prompt(self, rect: rl.Rectangle) -> None:
if not self._prompt_visible:
return
accent_color = rl.Color(255, 115, 0, 255)
card_rect = self._prompt_card_rect
rl.draw_rectangle_rounded(card_rect, 0.14, 18, rl.Color(10, 10, 10, 210))
rl.draw_rectangle_rounded_lines_ex(card_rect, 0.14, 18, 4, accent_color)
title_text = tr("NEW SPEED LIMIT")
title_size = measure_text_cached(self._font_semi_bold, title_text, 28)
title_pos = rl.Vector2(card_rect.x + card_rect.width / 2 - title_size.x / 2, card_rect.y + 18)
rl.draw_text_ex(self._font_semi_bold, title_text, title_pos, 28, 0, rl.Color(255, 255, 255, 235))
use_vienna_speed_limit = ui_state.params.get_bool("UseVienna")
speed_text = str(round(self._pending_speed_limit))
sign_rect = self._prompt_sign_rect
if use_vienna_speed_limit:
center_x = sign_rect.x + sign_rect.width / 2
center_y = sign_rect.y + sign_rect.height / 2
radius = sign_rect.width / 2
rl.draw_circle(int(center_x), int(center_y), radius, rl.WHITE)
rl.draw_ring(
rl.Vector2(center_x, center_y),
radius - 14,
radius,
0,
360,
64,
rl.Color(201, 34, 49, 255),
)
font_size = 78 if len(speed_text) <= 2 else 66
text_size = measure_text_cached(self._font_bold, speed_text, font_size)
text_pos = rl.Vector2(center_x - text_size.x / 2, center_y - text_size.y / 2 + 4)
rl.draw_text_ex(self._font_bold, speed_text, text_pos, font_size, 0, rl.BLACK)
else:
border_rect = rl.Rectangle(sign_rect.x + 8, sign_rect.y + 8, sign_rect.width - 16, sign_rect.height - 16)
rl.draw_rectangle_rounded(sign_rect, 0.18, 16, rl.WHITE)
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.14, 16, 5, rl.BLACK)
header_font_size = 24
header_gap = 20
speed_font_size = 72 if len(speed_text) <= 2 else 60
speed_label = tr("SPEED")
limit_label = tr("LIMIT")
speed_label_size = measure_text_cached(self._font_semi_bold, speed_label, header_font_size)
limit_label_size = measure_text_cached(self._font_semi_bold, limit_label, header_font_size)
rl.draw_text_ex(
self._font_semi_bold,
speed_label,
rl.Vector2(sign_rect.x + sign_rect.width / 2 - speed_label_size.x / 2, sign_rect.y + 20),
header_font_size,
0,
rl.BLACK,
)
rl.draw_text_ex(
self._font_semi_bold,
limit_label,
rl.Vector2(sign_rect.x + sign_rect.width / 2 - limit_label_size.x / 2, sign_rect.y + 20 + header_gap),
header_font_size,
0,
rl.BLACK,
)
speed_size = measure_text_cached(self._font_bold, speed_text, speed_font_size)
speed_pos = rl.Vector2(sign_rect.x + sign_rect.width / 2 - speed_size.x / 2, sign_rect.y + 80)
rl.draw_text_ex(self._font_bold, speed_text, speed_pos, speed_font_size, 0, rl.BLACK)
self._draw_prompt_button(
self._prompt_deny_rect,
"-",
rl.Color(104, 20, 20, 235),
rl.Color(255, 78, 78, 255),
False,
)
self._draw_prompt_button(
self._prompt_accept_rect,
"+",
rl.Color(12, 110, 66, 235),
rl.Color(78, 255, 173, 255),
False,
)
hint_text = tr("USE WHEEL - / +")
hint_size = measure_text_cached(self._font_medium, hint_text, 24)
hint_pos = rl.Vector2(card_rect.x + card_rect.width / 2 - hint_size.x / 2, card_rect.y + card_rect.height - 34)
rl.draw_text_ex(self._font_medium, hint_text, hint_pos, 24, 0, rl.Color(255, 255, 255, 180))
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
"""Draw the current vehicle speed and unit."""
speed_text = str(round(self.speed))
@@ -0,0 +1,41 @@
from __future__ import annotations
from collections.abc import Mapping
def _normalize_limit(limit: float) -> float:
return limit if limit >= 1.0 else 0.0
def resolve_display_speed_limit_ms(
slc_speed_limit: float,
speed_limit_source: str,
source_limits: Mapping[str, float],
primary_priority: str,
secondary_priority: str,
) -> float:
slc_speed_limit = _normalize_limit(slc_speed_limit)
if slc_speed_limit > 0.0:
return slc_speed_limit
normalized_limits = {source: _normalize_limit(limit) for source, limit in source_limits.items()}
active_source_limit = normalized_limits.get(speed_limit_source, 0.0)
if active_source_limit > 0.0:
return active_source_limit
available_limits = {source: limit for source, limit in normalized_limits.items() if limit > 0.0}
if not available_limits:
return 0.0
if primary_priority == "Highest":
return max(available_limits.values())
if primary_priority == "Lowest":
return min(available_limits.values())
for priority in (primary_priority, secondary_priority, "Mapbox"):
limit = available_limits.get(priority, 0.0)
if limit > 0.0:
return limit
return next(iter(available_limits.values()))
@@ -0,0 +1,77 @@
import importlib.util
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).resolve().parents[1] / "mici" / "onroad" / "speed_limit_utils.py"
SPEC = importlib.util.spec_from_file_location("speed_limit_utils_under_test", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC is not None and SPEC.loader is not None
SPEC.loader.exec_module(MODULE)
resolve_display_speed_limit_ms = MODULE.resolve_display_speed_limit_ms
class TestSpeedLimitUtils(unittest.TestCase):
def test_resolve_display_speed_limit_prefers_slc_target(self):
speed_limit = resolve_display_speed_limit_ms(
slc_speed_limit=24.6,
speed_limit_source="Dashboard",
source_limits={
"Dashboard": 22.4,
"Map Data": 20.1,
"Vision": 0.0,
"Mapbox": 0.0,
},
primary_priority="Map Data",
secondary_priority="Dashboard",
)
self.assertEqual(speed_limit, 24.6)
def test_resolve_display_speed_limit_uses_active_source_when_display_only(self):
speed_limit = resolve_display_speed_limit_ms(
slc_speed_limit=0.0,
speed_limit_source="Dashboard",
source_limits={
"Dashboard": 22.4,
"Map Data": 20.1,
"Vision": 0.0,
"Mapbox": 0.0,
},
primary_priority="Map Data",
secondary_priority="Vision",
)
self.assertEqual(speed_limit, 22.4)
def test_resolve_display_speed_limit_honors_priority_order(self):
speed_limit = resolve_display_speed_limit_ms(
slc_speed_limit=0.0,
speed_limit_source="None",
source_limits={
"Dashboard": 22.4,
"Map Data": 20.1,
"Vision": 24.6,
"Mapbox": 0.0,
},
primary_priority="Map Data",
secondary_priority="Vision",
)
self.assertEqual(speed_limit, 20.1)
def test_resolve_display_speed_limit_falls_back_to_mapbox(self):
speed_limit = resolve_display_speed_limit_ms(
slc_speed_limit=0.0,
speed_limit_source="None",
source_limits={
"Dashboard": 0.0,
"Map Data": 0.0,
"Vision": 0.0,
"Mapbox": 17.9,
},
primary_priority="Map Data",
secondary_priority="Dashboard",
)
self.assertEqual(speed_limit, 17.9)
BIN
View File
Binary file not shown.
+1
View File
@@ -57,6 +57,7 @@ class UIState:
"liveParameters",
"rawAudioData",
"starpilotCarState",
"starpilotPlan",
]
)
Binary file not shown.
Binary file not shown.
+7 -5
View File
@@ -883,8 +883,9 @@ class StarPilotVariables:
toggle.sng_hack = self.get_value("SNGHack", condition=toggle.openpilot_longitudinal and toggle.car_make == "toyota" and not toggle.has_pedal and not has_sng)
toggle.speed_limit_controller = toggle.openpilot_longitudinal and self.get_value("SpeedLimitController")
toggle.map_speed_lookahead_higher = self.get_value("SLCLookaheadHigher", cast=float, condition=toggle.speed_limit_controller)
toggle.map_speed_lookahead_lower = self.get_value("SLCLookaheadLower", cast=float, condition=toggle.speed_limit_controller)
speed_limit_display = toggle.show_speed_limits or toggle.speed_limit_controller
toggle.map_speed_lookahead_higher = self.get_value("SLCLookaheadHigher", cast=float, condition=speed_limit_display)
toggle.map_speed_lookahead_lower = self.get_value("SLCLookaheadLower", cast=float, condition=speed_limit_display)
toggle.set_speed_limit = self.get_value("SetSpeedLimit", condition=toggle.speed_limit_controller)
toggle.show_speed_limit_offset = self.get_value("ShowSLCOffset", condition=toggle.speed_limit_controller) or toggle.debug_mode
slc_fallback_method = self.get_value("SLCFallback", cast=float, condition=toggle.speed_limit_controller)
@@ -905,13 +906,14 @@ class StarPilotVariables:
toggle.speed_limit_offset5 = self.get_value("Offset5", cast=float, condition=toggle.speed_limit_controller, conversion=speed_conversion)
toggle.speed_limit_offset6 = self.get_value("Offset6", cast=float, condition=toggle.speed_limit_controller, conversion=speed_conversion)
toggle.speed_limit_offset7 = self.get_value("Offset7", cast=float, condition=toggle.speed_limit_controller, conversion=speed_conversion)
toggle.speed_limit_priority1 = self.get_value("SLCPriority1", cast=None, condition=toggle.speed_limit_controller)
toggle.speed_limit_priority2 = self.get_value("SLCPriority2", cast=None, condition=toggle.speed_limit_controller)
toggle.speed_limit_priority1 = self.get_value("SLCPriority1", cast=None, condition=speed_limit_display)
toggle.speed_limit_priority2 = self.get_value("SLCPriority2", cast=None, condition=speed_limit_display)
toggle.speed_limit_priority_highest = toggle.speed_limit_priority1 == "Highest"
toggle.speed_limit_priority_lowest = toggle.speed_limit_priority1 == "Lowest"
toggle.speed_limit_sources = self.get_value("SpeedLimitSources", condition=toggle.speed_limit_controller) or toggle.debug_mode
toggle.speed_limit_sources = self.get_value("SpeedLimitSources", condition=speed_limit_display) or toggle.debug_mode
toggle.speed_limit_filler = self.get_value("SpeedLimitFiller")
toggle.vision_speed_limit_detection = self.get_value("VisionSpeedLimitDetection")
toggle.startup_alert_top = self.get_value("StartupMessageTop", cast=str, default="")
toggle.startup_alert_bottom = self.get_value("StartupMessageBottom", cast=str, default="")
@@ -51,6 +51,7 @@ class SpeedLimitController:
self.speed_limit_changed_timer = 0
self.target = 0
self.unconfirmed_speed_limit = 0
self.vision_limit = 0
self.previous_source = "None"
self.source = "None"
@@ -255,12 +256,14 @@ class SpeedLimitController:
self.starpilot_planner.params.put_nonblocking("PreviousSpeedLimit", self.target)
def update_limits(self, dashboard_speed_limit, now, time_validated, v_cruise, v_ego, sm):
def update_limits(self, dashboard_speed_limit, now, time_validated, v_cruise, v_ego, sm, display_only=False):
self.update_map_speed_limit(v_ego, sm)
self.vision_limit = self.starpilot_planner.params_memory.get_float("VisionSpeedLimit") if getattr(self.starpilot_toggles, "vision_speed_limit_detection", False) else 0
limits = {
"Dashboard": dashboard_speed_limit,
"Map Data": self.map_speed_limit
"Map Data": self.map_speed_limit,
"Vision": self.vision_limit,
}
filtered_limits = {source: limit for source, limit in limits.items() if limit >= 1}
@@ -297,7 +300,7 @@ class SpeedLimitController:
desired_source = "Mapbox"
desired_target = self.mapbox_limit
if desired_target == 0 or self.target == 0:
if not display_only and (desired_target == 0 or self.target == 0):
if self.denied_target != self.previous_target > 0 and self.starpilot_toggles.slc_fallback_previous_speed_limit:
desired_source = self.previous_source
desired_target = self.previous_target
@@ -311,6 +314,20 @@ class SpeedLimitController:
self.mapbox_limit = 0
self.segment_distance = 0
if display_only:
self.speed_limit_changed_timer = 0
self.unconfirmed_speed_limit = 0
self.overridden_speed = 0
if desired_target >= 1:
self.source = desired_source
self.target = desired_target
else:
self.source = "None"
self.target = 0
return
if abs(desired_target - self.previous_target) >= 1:
self.handle_limit_change(desired_source, desired_target, sm)
elif desired_source != self.source and abs(desired_target - self.target) < 1:
+1 -1
View File
@@ -73,7 +73,7 @@ class StarPilotVCruise:
self.slc_offset = self.slc.offset
self.slc_target = self.slc.target
elif starpilot_toggles.show_speed_limits:
self.slc.update_limits(sm["starpilotCarState"].dashboardSpeedLimit, now, time_validated, v_cruise, v_ego, sm)
self.slc.update_limits(sm["starpilotCarState"].dashboardSpeedLimit, now, time_validated, v_cruise, v_ego, sm, display_only=True)
self.slc_offset = 0
self.slc_target = self.slc.target
+2
View File
@@ -127,7 +127,9 @@ class MapSpeedLogger:
self.filtered_dataset = list(self.cleanup_dataset(fallback_dataset))
def get_speed_limit_source(self):
vision_speed_limit = self.params_memory.get_float("VisionSpeedLimit") if self.params.get_bool("VisionSpeedLimitDetection") else 0
sources = [
(vision_speed_limit, "Vision"),
(self.sm["starpilotPlan"].slcMapboxSpeedLimit, "Mapbox"),
(self.sm["starpilotCarState"].dashboardSpeedLimit, "Dashboard")
]
File diff suppressed because it is too large Load Diff
@@ -1136,6 +1136,29 @@
"ui_type": "toggle",
"is_parent_toggle": true
},
{
"key": "VisionSpeedLimitDetection",
"label": "Vision Speed Limit Detection",
"description": "Use the road camera to detect speed limit signs for SLC and speed limit filling.",
"data_type": "bool",
"ui_type": "toggle"
},
{
"key": "VisionSpeedLimitAutoBookmark",
"label": "Auto-Bookmark Vision Signs",
"description": "Automatically save confirmed vision-detected speed limit signs into the speed-limit debug session so they can be imported into the training set later.",
"data_type": "bool",
"ui_type": "toggle",
"parent_key": "VisionSpeedLimitDetection"
},
{
"key": "VisionSpeedLimitAutoPreserveSegment",
"label": "Preserve Auto-Bookmarked Segments",
"description": "Also send a real bookmark for confirmed auto-bookmarks so loggerd preserves the route segment. Leave this off unless you specifically want the extra storage usage.",
"data_type": "bool",
"ui_type": "toggle",
"parent_key": "VisionSpeedLimitAutoBookmark"
},
{
"key": "SLCConfirmation",
"label": "Confirm New Speed Limits",
@@ -1180,6 +1203,62 @@
"ui_type": "toggle",
"parent_key": "SpeedLimitController"
},
{
"key": "SLCPriority1",
"label": "Primary Speed Limit Source",
"description": "Choose the first speed-limit source to trust when more than one is available.",
"data_type": "string",
"ui_type": "dropdown",
"options": [
{
"value": "Dashboard",
"label": "Dashboard"
},
{
"value": "Map Data",
"label": "Map Data"
},
{
"value": "Vision",
"label": "Vision"
},
{
"value": "Highest",
"label": "Highest"
},
{
"value": "Lowest",
"label": "Lowest"
}
],
"parent_key": "SpeedLimitController"
},
{
"key": "SLCPriority2",
"label": "Secondary Speed Limit Source",
"description": "Choose the backup speed-limit source when the primary one is unavailable.",
"data_type": "string",
"ui_type": "dropdown",
"options": [
{
"value": "None",
"label": "None"
},
{
"value": "Dashboard",
"label": "Dashboard"
},
{
"value": "Map Data",
"label": "Map Data"
},
{
"value": "Vision",
"label": "Vision"
}
],
"parent_key": "SpeedLimitController"
},
{
"key": "Offset1",
"label": "Speed Offset (0\u201324 mph)",
@@ -8,6 +8,15 @@ const state = reactive({
reason: "",
status: "Checking...",
submitting: false,
visionConfidence: 0,
visionBookmarkCount: 0,
visionDebugSession: "",
visionDisplaySpeed: 0,
visionEnabled: false,
visionLastEvent: "",
visionSpeedUnit: "mph",
visionStatus: "Checking...",
visionStream: "",
})
let pollTimer = null
@@ -21,11 +30,24 @@ async function fetchStatus() {
state.processing = Boolean(result.processing)
state.reason = result.reason || ""
state.status = result.status || "Idle"
state.visionBookmarkCount = Number(result.visionBookmarkCount || 0)
state.visionConfidence = Number(result.visionConfidence || 0)
state.visionDebugSession = result.visionDebugSession || ""
state.visionDisplaySpeed = Number(result.visionDisplaySpeed || 0)
state.visionEnabled = Boolean(result.visionEnabled)
state.visionLastEvent = result.visionLastEvent || ""
state.visionSpeedUnit = result.visionSpeedUnit || "mph"
state.visionStatus = result.visionStatus || (state.visionEnabled ? "Idle" : "Disabled")
state.visionStream = result.visionStream || ""
} catch (error) {
state.canProcessNow = false
state.processing = false
state.reason = "Failed to load processor status."
state.status = "Unavailable"
state.visionBookmarkCount = 0
state.visionDebugSession = ""
state.visionLastEvent = ""
state.visionStatus = "Unavailable"
}
state.loading = false
@@ -83,6 +105,27 @@ export function SpeedLimits() {
<p class="download-speed-limits-status">
${() => state.loading ? "Checking processor status..." : `Processor Status: ${state.status}`}
</p>
<p class="download-speed-limits-status">
${() => {
if (state.loading) {
return "Checking vision detector..."
}
const suffix = state.visionDisplaySpeed > 0
? ` (${state.visionDisplaySpeed} ${state.visionSpeedUnit}${state.visionConfidence > 0 ? `, ${Math.round(state.visionConfidence * 100)}%` : ""})`
: ""
const stream = state.visionStream ? ` on ${state.visionStream}` : ""
return `Vision Detector: ${state.visionStatus}${stream}${suffix}`
}}
</p>
${() => !state.loading && state.visionEnabled ? html`
<p class="download-speed-limits-status">
${`Vision Debug: ${state.visionDebugSession || "No active session"}${state.visionBookmarkCount ? `, ${state.visionBookmarkCount} bookmark${state.visionBookmarkCount === 1 ? "" : "s"}` : ""}`}
</p>
` : ""}
${() => !state.loading && state.visionEnabled && state.visionLastEvent ? html`
<p class="download-speed-limits-note">${`Latest Vision Event: ${state.visionLastEvent}`}</p>
` : ""}
${() => !state.loading && state.reason && state.reason !== state.status ? html`
<p class="download-speed-limits-note">${state.reason}</p>
` : ""}
+22
View File
@@ -30,6 +30,7 @@ from cereal import car, log, messaging
from opendbc.can.parser import CANParser
from opendbc.car.gm.values import GMFlags
from opendbc.car.toyota.carcontroller import LOCK_CMD, UNLOCK_CMD
from openpilot.common.constants import CV
from openpilot.common.params import ParamKeyType, Params
from openpilot.common.realtime import DT_HW
from openpilot.common.time_helpers import system_time_valid
@@ -3845,6 +3846,18 @@ def setup(app):
enabled = params.get_bool("SpeedLimitFiller")
is_onroad = params.get_bool("IsOnroad")
time_valid = system_time_valid()
is_metric = params.get_bool("IsMetric")
vision_enabled = params.get_bool("VisionSpeedLimitDetection")
vision_speed_limit = params_memory.get_float("VisionSpeedLimit") if vision_enabled else 0
vision_confidence = params_memory.get_float("VisionSpeedLimitConfidence") if vision_enabled else 0
vision_bookmark_count = params_memory.get_int("VisionSpeedLimitBookmarkCount") if vision_enabled else 0
vision_debug_session = params_memory.get("VisionSpeedLimitDebugSession", encoding="utf-8") if vision_enabled else ""
vision_last_event = params_memory.get("VisionSpeedLimitLastEvent", encoding="utf-8") if vision_enabled else ""
vision_status = params_memory.get("VisionSpeedLimitStatus", encoding="utf-8") or ("Idle" if vision_enabled else "Disabled")
vision_stream = params_memory.get("VisionSpeedLimitStream", encoding="utf-8") if vision_enabled else ""
vision_speed_unit = "km/h" if is_metric else "mph"
vision_display_speed = round(vision_speed_limit * (CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH)) if vision_speed_limit > 0 else 0
network_connected = True
try:
@@ -3894,6 +3907,15 @@ def setup(app):
"timeValid": time_valid,
"totalRequests": total_requests,
"maxRequests": max_requests,
"visionConfidence": vision_confidence,
"visionBookmarkCount": vision_bookmark_count,
"visionDebugSession": vision_debug_session,
"visionDisplaySpeed": vision_display_speed,
"visionEnabled": vision_enabled,
"visionLastEvent": vision_last_event,
"visionSpeedUnit": vision_speed_unit,
"visionStatus": vision_status,
"visionStream": vision_stream,
}
@app.route("/api/speed_limits/status", methods=["GET"])
@@ -186,7 +186,7 @@ StarPilotLongitudinalPanel::StarPilotLongitudinalPanel(StarPilotSettingsWindow *
{"ReduceLateralAccelerationSnow", tr("Reduce Speed in Curves by:"), tr("<b>Lower the desired speed while driving through curves in snow.</b> Increase for safer, gentler turns; decrease for more aggressive driving in curves."), ""},
{"SpeedLimitController", tr("Speed Limit Controller"), tr("<b>Limit openpilot's maximum driving speed to the current speed limit</b> obtained from downloaded maps, Mapbox, or the dashboard for supported vehicles (Ford, Genesis, Hyundai, Kia, Lexus, Toyota)."), "../../starpilot/assets/toggle_icons/icon_speed_limit.png"},
{"SpeedLimitController", tr("Speed Limit Controller"), tr("<b>Limit openpilot's maximum driving speed to the current speed limit</b> obtained from downloaded maps, Mapbox, the dashboard, or vision-detected signs."), "../../starpilot/assets/toggle_icons/icon_speed_limit.png"},
{"SLCFallback", tr("Fallback Speed"), tr("<b>The speed used by \"Speed Limit Controller\" when no speed limit is found.</b><br><br>- <b>Set Speed</b>: Use the cruise set speed<br>- <b>Experimental Mode</b>: Estimate the limit using the driving model<br>- <b>Previous Limit</b>: Keep using the last confirmed limit"), ""},
{"SLCOverride", tr("Override Speed"), tr("<b>The speed used by \"Speed Limit Controller\" after you manually drive faster than the posted limit.</b><br><br>- <b>Set with Gas Pedal</b>: Use the highest speed reached while pressing the gas<br>- <b>Max Set Speed</b>: Use the cruise set speed<br><br>Overrides clear when openpilot disengages."), ""},
{"SLCQOL", tr("Quality of Life"), tr("<b>Miscellaneous \"Speed Limit Controller\" changes</b> to fine-tune how openpilot drives."), ""},
@@ -195,6 +195,7 @@ StarPilotLongitudinalPanel::StarPilotLongitudinalPanel(StarPilotSettingsWindow *
{"SLCLookaheadLower", tr("Lower Limit Lookahead Time"), tr("<b>How far ahead openpilot anticipates upcoming lower speed limits</b> from downloaded map data."), ""},
{"SetSpeedLimit", tr("Match Speed Limit on Engage"), tr("<b>When openpilot is first enabled, automatically set the max speed to the current posted limit.</b>"), ""},
{"SLCMapboxFiller", tr("Use Mapbox as Fallback"), tr("<b>Use Mapbox speed-limit data when no other source is available.</b>"), ""},
{"VisionSpeedLimitDetection", tr("Vision Speed Limit Detection"), tr("<b>Use the road camera to detect speed limit signs</b> for SLC and speed limit filling."), ""},
{"SLCPriority", tr("Speed Limit Source Priority"), tr("<b>The source order for speed limits</b> when more than one is available."), ""},
{"SLCOffsets", tr("Speed Limit Offsets"), tr("<b>Add an offset to the posted speed limit</b> to better match your driving style."), ""},
{"Offset1", tr("Speed Offset (024 mph)"), tr("<b>How much to offset posted speed-limits</b> between 0 and 24 mph."), ""},
@@ -474,8 +475,8 @@ StarPilotLongitudinalPanel::StarPilotLongitudinalPanel(StarPilotSettingsWindow *
longitudinalToggle = overrideSelection;
} else if (param == "SLCPriority") {
ButtonControl *slcPriorityButton = new ButtonControl(title, tr("SELECT"), desc);
QStringList primaryPriorities = {tr("Dashboard"), tr("Map Data"), tr("Highest"), tr("Lowest")};
QStringList otherPriorities = {tr("None"), tr("Dashboard"), tr("Map Data")};
QStringList primaryPriorities = {tr("Dashboard"), tr("Map Data"), tr("Vision"), tr("Highest"), tr("Lowest")};
QStringList otherPriorities = {tr("None"), tr("Dashboard"), tr("Map Data"), tr("Vision")};
QStringList priorityPrompts = {tr("Select your primary priority"), tr("Select your secondary priority")};
QObject::connect(slcPriorityButton, &ButtonControl::clicked, [=]() {
@@ -38,7 +38,7 @@ private:
QSet<QString> relaxedPersonalityKeys = {"RelaxedFollow", "RelaxedFollowHigh", "RelaxedJerkAcceleration", "RelaxedJerkDeceleration", "RelaxedJerkDanger", "RelaxedJerkSpeed", "RelaxedJerkSpeedDecrease", "ResetRelaxedPersonality"};
QSet<QString> speedLimitControllerKeys = {"SLCOffsets", "SLCFallback", "SLCOverride", "SLCPriority", "SLCQOL", "SLCVisuals"};
QSet<QString> speedLimitControllerOffsetsKeys = {"Offset1", "Offset2", "Offset3", "Offset4", "Offset5", "Offset6", "Offset7"};
QSet<QString> speedLimitControllerQOLKeys = {"SetSpeedLimit", "SLCConfirmation", "SLCLookaheadHigher", "SLCLookaheadLower", "SLCMapboxFiller"};
QSet<QString> speedLimitControllerQOLKeys = {"SetSpeedLimit", "SLCConfirmation", "SLCLookaheadHigher", "SLCLookaheadLower", "SLCMapboxFiller", "VisionSpeedLimitDetection"};
QSet<QString> speedLimitControllerVisualKeys = {"ShowSLCOffset", "SpeedLimitSources"};
QSet<QString> standardPersonalityKeys = {"StandardFollow", "StandardFollowHigh", "StandardJerkAcceleration", "StandardJerkDeceleration", "StandardJerkDanger", "StandardJerkSpeed", "StandardJerkSpeedDecrease", "ResetStandardPersonality"};
QSet<QString> trafficPersonalityKeys = {"TrafficFollow", "TrafficJerkAcceleration", "TrafficJerkDeceleration", "TrafficJerkDanger", "TrafficJerkSpeed", "TrafficJerkSpeedDecrease", "ResetTrafficPersonality"};
@@ -19,6 +19,7 @@ StarPilotAnnotatedCameraWidget::StarPilotAnnotatedCameraWidget(QWidget *parent)
speedIcon = loadPixmap("../../starpilot/assets/other_images/speed_icon.png", {widget_size, widget_size});
stopSignImg = loadPixmap("../../starpilot/assets/other_images/stop_sign.png", {btn_size, btn_size});
turnIcon = loadPixmap("../../starpilot/assets/other_images/turn_icon.png", {widget_size, widget_size});
visionIcon = loadPixmap("../../starpilot/assets/other_images/speed_icon.png", {btn_size / 2, btn_size / 2}).scaled(iconSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
loadGif("../../starpilot/assets/other_images/curve_icon.gif", cemCurveIcon, QSize(widget_size, widget_size), this);
loadGif("../../starpilot/assets/other_images/lead_icon.gif", cemLeadIcon, QSize(widget_size, widget_size), this);
@@ -182,11 +183,12 @@ void StarPilotAnnotatedCameraWidget::updateState(const UIState &s, const StarPil
roadCurvature = starpilotPlan.getRoadCurvature();
roadName = QString::fromStdString(mapdOut.getRoadName());
slcOverriddenSpeed = starpilotPlan.getSlcOverriddenSpeed();
speedLimit = slcOverriddenSpeed != 0 ? slcOverriddenSpeed : starpilotPlan.getSlcSpeedLimit();
speedLimit = starpilotPlan.getSlcSpeedLimit();
speedLimitChanged = starpilotPlan.getSpeedLimitChanged();
speedLimitSource = starpilotPlan.getSlcSpeedLimitSource();
stoppingDistance = modelV2.getPosition().getX().size() > 33 - 1 ? modelV2.getPosition().getX()[33 - 1] : 0.0;
unconfirmedSpeedLimit = starpilotPlan.getUnconfirmedSlcSpeedLimit();
visionSpeedLimit = params.getBool("VisionSpeedLimitDetection") ? params_memory.getFloat("VisionSpeedLimit") : 0.0;
weatherDaytime = starpilotPlan.getWeatherDaytime();
weatherId = starpilotPlan.getWeatherId();
@@ -1028,11 +1030,13 @@ void StarPilotAnnotatedCameraWidget::paintSpeedLimitSources(QPainter &p) {
QRect dashboardRect(speedLimitRect.x() - signMargin, speedLimitRect.y() + speedLimitRect.height() + UI_BORDER_SIZE, 450, 60);
QRect mapDataRect(dashboardRect.x(), dashboardRect.y() + dashboardRect.height() + UI_BORDER_SIZE / 2, 450, 60);
QRect mapboxRect(mapDataRect.x(), mapDataRect.y() + mapDataRect.height() + UI_BORDER_SIZE / 2, 450, 60);
QRect visionRect(mapDataRect.x(), mapDataRect.y() + mapDataRect.height() + UI_BORDER_SIZE / 2, 450, 60);
QRect mapboxRect(visionRect.x(), visionRect.y() + visionRect.height() + UI_BORDER_SIZE / 2, 450, 60);
QRect nextLimitRect(mapboxRect.x(), mapboxRect.y() + mapboxRect.height() + UI_BORDER_SIZE / 2, 450, 60);
drawSource(dashboardRect, dashboardIcon, "Dashboard", dashboardSpeedLimit * speedConversion);
drawSource(mapDataRect, mapDataIcon, "Map Data", mapSpeedLimit * speedConversion);
drawSource(visionRect, visionIcon, "Vision", visionSpeedLimit * speedConversion);
drawSource(mapboxRect, mapboxIcon, "Mapbox", mapboxSpeedLimit * speedConversion);
drawSource(nextLimitRect, nextMapsIcon, "Upcoming", nextSpeedLimit * speedConversion);
@@ -119,6 +119,7 @@ private:
float speedLimit;
float stoppingDistance;
float unconfirmedSpeedLimit;
float visionSpeedLimit;
std::string speedLimitSource;
@@ -144,6 +145,7 @@ private:
QPixmap speedIcon;
QPixmap stopSignImg;
QPixmap turnIcon;
QPixmap visionIcon;
QPoint cemStatusPosition;
QPoint compassPosition;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -73,6 +73,9 @@ def allow_uploads(started: bool, params: Params, CP: car.CarParams, starpilot_to
def run_speed_limit_filler(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return starpilot_toggles.speed_limit_filler
def run_speed_limit_vision(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return starpilot_toggles.vision_speed_limit_detection
procs = [
DaemonProcess("manage_athenad", "system.athena.manage_athenad", "AthenadPid"),
@@ -140,6 +143,7 @@ procs += [
PythonProcess("the_pond", "starpilot.system.the_pond.the_pond", always_run, nice=19),
PythonProcess("galaxy", "starpilot.system.galaxy.galaxy", always_run, nice=19),
PythonProcess("speed_limit_filler", "starpilot.system.speed_limit_filler", run_speed_limit_filler),
PythonProcess("speed_limit_vision", "starpilot.system.speed_limit_vision", run_speed_limit_vision),
]
managed_processes = {p.name: p for p in procs}
Generated
+422 -5
View File
@@ -4,10 +4,12 @@ requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version >= '3.12' and sys_platform == 'win32'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version < '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version < '3.12' and sys_platform == 'win32'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
]
[[package]]
@@ -441,6 +443,71 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026, upload-time = "2024-10-18T15:58:11.916Z" },
]
[[package]]
name = "cuda-bindings"
version = "13.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "sys_platform != 'darwin'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" },
{ url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" },
{ url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" },
{ url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" },
]
[[package]]
name = "cuda-pathfinder"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/66/0c02bd330e7d976f83fa68583d6198d76f23581bcbb5c0e98a6148f326e5/cuda_pathfinder-1.5.0-py3-none-any.whl", hash = "sha256:498f90a9e9de36044a7924742aecce11c50c49f735f1bc53e05aa46de9ea4110", size = 49739, upload-time = "2026-03-24T21:14:30.869Z" },
]
[[package]]
name = "cuda-toolkit"
version = "13.0.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" },
]
[package.optional-dependencies]
cublas = [
{ name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
cudart = [
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
cufft = [
{ name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
cufile = [
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
]
cupti = [
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
curand = [
{ name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
cusolver = [
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
cusparse = [
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
nvtx = [
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
[[package]]
name = "cycler"
version = "0.12.1"
@@ -628,6 +695,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
]
[[package]]
name = "fsspec"
version = "2026.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" },
]
[[package]]
name = "future-fstrings"
version = "1.2.0"
@@ -1232,6 +1308,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
]
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]]
name = "numpy"
version = "2.3.3"
@@ -1269,6 +1354,155 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844, upload-time = "2025-09-09T15:58:57.359Z" },
]
[[package]]
name = "nvidia-cublas"
version = "13.1.0.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" },
{ url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" },
]
[[package]]
name = "nvidia-cuda-cupti"
version = "13.0.85"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" },
{ url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" },
]
[[package]]
name = "nvidia-cuda-nvrtc"
version = "13.0.88"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
{ url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
]
[[package]]
name = "nvidia-cuda-runtime"
version = "13.0.96"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" },
{ url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" },
]
[[package]]
name = "nvidia-cudnn-cu13"
version = "9.19.0.56"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
{ url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" },
]
[[package]]
name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
{ url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" },
]
[[package]]
name = "nvidia-cufile"
version = "1.15.1.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" },
{ url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" },
]
[[package]]
name = "nvidia-curand"
version = "10.4.0.35"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" },
]
[[package]]
name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin'" },
{ name = "nvidia-cusparse", marker = "sys_platform != 'darwin'" },
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
{ url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" },
]
[[package]]
name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
{ url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" },
]
[[package]]
name = "nvidia-cusparselt-cu13"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" },
{ url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" },
]
[[package]]
name = "nvidia-nccl-cu13"
version = "2.28.9"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" },
]
[[package]]
name = "nvidia-nvjitlink"
version = "13.0.88"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" },
]
[[package]]
name = "nvidia-nvshmem-cu13"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" },
{ url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" },
]
[[package]]
name = "nvidia-nvtx"
version = "13.0.85"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" },
{ url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" },
]
[[package]]
name = "onnx"
version = "1.19.0"
@@ -1295,6 +1529,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/37/ad500945b1b5c154fe9d7b826b30816ebd629d10211ea82071b5bcc30aa4/onnx-1.19.0-cp312-cp312-win_arm64.whl", hash = "sha256:efb768299580b786e21abe504e1652ae6189f0beed02ab087cd841cb4bb37e43", size = 16426022, upload-time = "2025-08-27T02:33:33.515Z" },
]
[[package]]
name = "opencv-python"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
]
[[package]]
name = "opencv-python-headless"
version = "4.11.0.86"
@@ -1332,6 +1584,7 @@ dependencies = [
{ name = "mapbox-earcut" },
{ name = "numpy" },
{ name = "onnx" },
{ name = "opencv-python-headless" },
{ name = "psutil" },
{ name = "pyaudio" },
{ name = "pycapnp" },
@@ -1365,7 +1618,6 @@ dev = [
{ name = "dbus-next" },
{ name = "dictdiffer" },
{ name = "matplotlib" },
{ name = "opencv-python-headless" },
{ name = "parameterized" },
{ name = "pyautogui" },
{ name = "pygame" },
@@ -1382,6 +1634,11 @@ docs = [
{ name = "mkdocs" },
{ name = "natsort" },
]
speedvision = [
{ name = "torch" },
{ name = "torchvision" },
{ name = "ultralytics" },
]
testing = [
{ name = "codespell" },
{ name = "coverage" },
@@ -1436,7 +1693,7 @@ requires-dist = [
{ name = "natsort", marker = "extra == 'docs'" },
{ name = "numpy", specifier = ">=2.0" },
{ name = "onnx", specifier = ">=1.14.0" },
{ name = "opencv-python-headless", marker = "extra == 'dev'" },
{ name = "opencv-python-headless" },
{ name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8,<0.9" },
{ name = "pre-commit-hooks", marker = "extra == 'testing'" },
{ name = "psutil" },
@@ -1475,14 +1732,17 @@ requires-dist = [
{ name = "spidev", marker = "sys_platform == 'linux'" },
{ name = "sympy" },
{ name = "tabulate", marker = "extra == 'dev'" },
{ name = "torch", marker = "extra == 'speedvision'", specifier = ">=2.4" },
{ name = "torchvision", marker = "extra == 'speedvision'", specifier = ">=0.19" },
{ name = "tqdm" },
{ name = "types-requests", marker = "extra == 'dev'" },
{ name = "types-tabulate", marker = "extra == 'dev'" },
{ name = "ultralytics", marker = "extra == 'speedvision'", specifier = ">=8.3,<9" },
{ name = "websocket-client" },
{ name = "xattr" },
{ name = "zstandard" },
]
provides-extras = ["docs", "testing", "dev", "tools"]
provides-extras = ["docs", "testing", "dev", "tools", "speedvision"]
[[package]]
name = "packaging"
@@ -1610,6 +1870,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "polars"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" },
{ url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" },
{ url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" },
{ url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" },
{ url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" },
]
[[package]]
name = "pre-commit-hooks"
version = "6.0.0"
@@ -4749,6 +5037,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
]
[[package]]
name = "scons"
version = "4.9.1"
@@ -4927,6 +5246,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
]
[[package]]
name = "torch"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/0d/98b410492609e34a155fa8b121b55c7dca229f39636851c3a9ec20edea21/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b6a60d48062809f58595509c524b88e6ddec3ebe25833d6462eeab81e5f2ce4", size = 80529712, upload-time = "2026-03-23T18:12:02.608Z" },
{ url = "https://files.pythonhosted.org/packages/84/03/acea680005f098f79fd70c1d9d5ccc0cb4296ec2af539a0450108232fc0c/torch-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d91aac77f24082809d2c5a93f52a5f085032740a1ebc9252a7b052ef5a4fddc6", size = 419718178, upload-time = "2026-03-23T18:10:46.675Z" },
{ url = "https://files.pythonhosted.org/packages/8c/8b/d7be22fbec9ffee6cff31a39f8750d4b3a65d349a286cf4aec74c2375662/torch-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7aa2f9bbc6d4595ba72138026b2074be1233186150e9292865e04b7a63b8c67a", size = 530604548, upload-time = "2026-03-23T18:10:03.569Z" },
{ url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" },
{ url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" },
{ url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" },
]
[[package]]
name = "torchvision"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "pillow" },
{ name = "torch" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/bd/d552a2521bade3295b2c6e7a4a0d1022261cab7ca7011f4e2a330dbb3caa/torchvision-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55bd6ad4ae77be01ba67a410b05b51f53b0d0ee45f146eb6a0dfb9007e70ab3c", size = 1863499, upload-time = "2026-03-23T18:12:58.696Z" },
{ url = "https://files.pythonhosted.org/packages/33/bf/21b899792b08cae7a298551c68398a79e333697479ed311b3b067aab4bdc/torchvision-0.26.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1c55dc8affbcc0eb2060fbabbe996ae9e5839b24bb6419777f17848945a411b1", size = 7767527, upload-time = "2026-03-23T18:12:44.348Z" },
{ url = "https://files.pythonhosted.org/packages/9a/45/57bbf9e216850d065e66dd31a50f57424b607f1d878ab8956e56a1f4e36b/torchvision-0.26.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd10b5f994c210f4f6d6761cf686f82d748554adf486cb0979770c3252868c8f", size = 7519925, upload-time = "2026-03-23T18:12:53.283Z" },
{ url = "https://files.pythonhosted.org/packages/10/58/ed8f7754299f3e91d6414b6dc09f62b3fa7c6e5d63dfe48d69ab81498a37/torchvision-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:de6424b12887ad884f39a0ee446994ae3cd3b6a00a9cafe1bead85a031132af0", size = 3983834, upload-time = "2026-03-23T18:13:00.224Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e7/56b47cc3b132aea90ccce22bcb8975dec688b002150012acc842846039d0/torchvision-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c409e1c3fdebec7a3834465086dbda8bf7680eff79abf7fd2f10c6b59520a7a4", size = 1863502, upload-time = "2026-03-23T18:12:57.326Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ec/5c31c92c08b65662fe9604a4067ae8232582805949f11ddc042cebe818ed/torchvision-0.26.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:406557718e62fdf10f5706e88d8a5ec000f872da913bf629aab9297622585547", size = 7767944, upload-time = "2026-03-23T18:12:42.805Z" },
{ url = "https://files.pythonhosted.org/packages/f5/d8/cb6ccda1a1f35a6597645818641701207b3e8e13553e75fce5d86bac74b2/torchvision-0.26.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d61a5abb6b42a0c0c311996c2ac4b83a94418a97182c83b055a2a4ae985e05aa", size = 7522205, upload-time = "2026-03-23T18:12:54.654Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a9/c272623a0f735c35f0f6cd6dc74784d4f970e800cf063bb76687895a2ab9/torchvision-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:7993c01648e7c61d191b018e84d38fe0825c8fcb2720cd0f37caf7ba14404aa1", size = 4255155, upload-time = "2026-03-23T18:12:32.652Z" },
]
[[package]]
name = "tqdm"
version = "4.67.1"
@@ -4939,6 +5309,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
name = "triton"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" },
{ url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" },
{ url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20250913"
@@ -4969,6 +5350,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "ultralytics"
version = "8.4.32"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "polars" },
{ name = "psutil" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scipy" },
{ name = "torch" },
{ name = "torchvision" },
{ name = "ultralytics-thop" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/80/b17c01884a5c3997775d40786efc81d94b2204298a6d8ef3f6a6e308fced/ultralytics-8.4.32.tar.gz", hash = "sha256:ae9d3c3fa2930248a4a93e65fb47803cf58b440e36f541d103b308f3774d574e", size = 1028243, upload-time = "2026-03-30T16:11:28.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/ee/7ba24da543b47971bf7e64c01e2f2b03ec25aec25cd3af0007922db35fe5/ultralytics-8.4.32-py3-none-any.whl", hash = "sha256:88093e6c311a1ea3dd3369481249fe202d4e8e946d24902b22c00850e2e29fae", size = 1219034, upload-time = "2026-03-30T16:11:24.343Z" },
]
[[package]]
name = "ultralytics-thop"
version = "2.0.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "torch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/63/21a32e1facfeee245dbdfb7b4669faf7a36ff7c00b50987932bdab126f4b/ultralytics_thop-2.0.18.tar.gz", hash = "sha256:21103bcd39cc9928477dc3d9374561749b66a1781b35f46256c8d8c4ac01d9cf", size = 34557, upload-time = "2025-10-29T16:58:13.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/c7/fb42228bb05473d248c110218ffb8b1ad2f76728ed8699856e5af21112ad/ultralytics_thop-2.0.18-py3-none-any.whl", hash = "sha256:2bb44851ad224b116c3995b02dd5e474a5ccf00acf237fe0edb9e1506ede04ec", size = 28941, upload-time = "2025-10-29T16:58:12.093Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
BIN
View File
Binary file not shown.