Compare commits

...

2 Commits

Author SHA1 Message Date
royjr 2bf5f45ffe clustermaps 2026-07-19 16:35:02 -04:00
royjr f76da384cd live ui 2026-07-19 03:29:11 -04:00
21 changed files with 7052 additions and 0 deletions
+3
View File
@@ -12,3 +12,6 @@
selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text
common/hardware/tici/updater filter=lfs diff=lfs merge=lfs -text
# Small vendored Leaflet UI icons are kept directly in Git.
tools/clustermaps/web/vendor/leaflet/images/*.png -filter -diff -merge -text
+117
View File
@@ -0,0 +1,117 @@
# clustermaps
clustermaps is an offline, read-only route-analysis tool for full openpilot/sunnypilot rlogs. It converts high-rate IMU, vehicle-state, pose, and GPS messages into a compact per-route JSON cache, GeoJSON exports, and a static HTML5 map.
## Quick start
From the repository root:
```bash
.venv/bin/python tools/clustermaps/build_map.py \
0000000000000000/00000000--0000000000 \
--label "Complete sample route · 13 segments" \
--serve
```
Then open [http://127.0.0.1:8765](http://127.0.0.1:8765).
The page includes a local Route Manager. Paste a route, load its available segment list, select segments, and watch the checkpointed import progress. Imports survive a server restart: each completed segment is stored separately under the gitignored data directory, and the next `--serve` run resumes unfinished jobs before assembling one route-level map entry.
The identifier determines how much is imported:
- `DONGLE/ROUTE` imports every available segment in the route.
- `DONGLE/ROUTE/3` imports only segment 3.
- `DONGLE/ROUTE/0:5` imports the half-open segment range 0 through 4.
Import additional routes by passing more identifiers or by running the command again. Existing cached routes remain in the atlas, so this is how observations from multiple drives and drivers accumulate:
```bash
.venv/bin/python tools/clustermaps/build_map.py ROUTE_A ROUTE_B ROUTE_C
```
Inspect or remove local entries using their privacy-preserving cache ids:
```bash
.venv/bin/python tools/clustermaps/build_map.py --list
.venv/bin/python tools/clustermaps/build_map.py --remove CACHE_ID
```
Routes can also be removed from the webpage with the × control on each drive card. Removal only deletes the derived local JSON and GeoJSON entry; it never changes the source rlogs.
If the server is already running, import from a second terminal and refresh the browser. The server disables HTTP caching so updated route indexes and scripts are picked up immediately.
Only full rlogs are accepted. `LogReader` is explicitly configured for `ReadMode.RLOG`; it never falls back to qlogs because qlog IMU decimation is too aggressive for impact analysis.
## Cache and exports
Generated files live under `web/data/` and are intentionally gitignored because they contain precise route geometry:
- `index.json`: the multi-route manifest and cross-route repeat-location clusters.
- `routes/<id>.json`: compact track, layer, event, diagnostic, and source-coverage data.
- `geojson/<id>.geojson`: route line plus event and rough-road point features.
- `repeated_locations.geojson`: locations independently observed on at least two cached routes.
Route names are retained in the local browser cache for recognizable route management; cache ids and driver ids remain hashed, and a friendly `--label` can be supplied. Treat `web/data/` as private because route names and map geometry identify drives. The browser loads compact derived files and never downloads, decompresses, or parses rlogs itself.
## Device rlogs
To copy full rlogs from a reachable comma without modifying the device:
```bash
mkdir -p tools/clustermaps/private/device_rlogs/DEVICE
rsync -a --partial --progress \
-e 'ssh -o BatchMode=yes -o ConnectTimeout=5' \
--include='*/' --include='rlog.*' --exclude='*' \
comma@DEVICE:/data/media/0/realdata/ \
tools/clustermaps/private/device_rlogs/DEVICE/
```
The transfer is resumable and copies only rlogs—no camera files or qlogs. Analyze the copied route directories with:
```bash
.venv/bin/python tools/clustermaps/build_map.py \
--import-device-dir tools/clustermaps/private/device_rlogs/DEVICE \
--device-key YOUR_DEVICE_KEY
```
Device analysis also checkpoints one derived file per segment, so rerunning the command skips completed work. The copied rlogs and derived checkpoints are gitignored.
## Layers
- **Route traces:** GPS-derived path, colored per imported route.
- **Road roughness:** two-second RMS windows of high-pass-filtered vertical acceleration.
- **Sharp impacts:** route-adaptive vertical IMU peaks while the vehicle is moving.
- **Hard acceleration / braking:** sustained, low-pass-filtered `carState.aEgo` threshold crossings.
- **Body motion:** roll/pitch angular-rate activity from `livePose`, excluding normal yaw motion.
- **GPS samples:** the exact absolute-position cadence used for interpolation.
- **IMU detail:** geolocated accelerometer and gyroscope components plus magnitudes.
- **Vehicle dynamics:** speed, acceleration, pedals, steering, yaw, wheel speeds, blinkers, and blind-spot state where present.
- **Pose and visual odometry:** fused device motion and camera-derived translation/rotation.
- **Controls and calibration:** desired/estimated curvature and learned roll, steering ratio, stiffness, and angle offset.
- **Road vision:** compact lane probabilities, lane width, and model action curvature.
- **Radar:** primary lead distance, relative speed, lateral position, and track status.
- **Sound, magnetic, thermal, and power:** cabin sound pressure, magnetic field, sensor temperature, device temperature, battery, and utilization.
- **Repeated locations:** impact or roughness observations clustered within 20 meters across two or more cached routes.
The “All recorded services” panel inventories every service found in every imported rlog, including services not copied into the browser cache. High-volume or sensitive payloads such as raw CAN, raw GNSS measurements, full vision-model tensors, camera/encoder metadata, driver-monitoring output, and system logs remain inventory-only. This exposes what the source logs contain without turning the local map into a raw-log mirror.
The basemap selector offers OpenStreetMap streets, Esri World Imagery satellite tiles, and a neutral coordinate-grid data-only canvas. Streets and Satellite require a network connection; Data only leaves every local sensor overlay working without tile requests. The selected mode is remembered in the browser.
The Visuals panel includes Point bleed. Roughness samples are interpolated into one low-resolution scalar field with a Gaussian edge falloff and a continuous color ramp across every selected route; other point layers use a softly blurred density canvas. The Heat, Corridor, and Night presets enable it automatically; Signal keeps precise individual markers.
## Visualization styles
Open **Visuals** above the map to switch between:
- **Signal:** precise centerline rendering with the least visual bleed.
- **Heat:** coverage glow plus overlapping impact halos.
- **Corridor:** extra-wide road bands for area-scale coverage inspection.
- **Night:** high-contrast street tiles with bright sensor overlays.
Road width and signal intensity can be adjusted independently. Coverage glow, impact heat halos, and the high-contrast map can also be combined manually; display preferences are kept in local browser storage.
## Interpretation
The current thresholds are screening heuristics, not labeled pothole classifications. Vehicle suspension, tire pressure, device mount, road speed, and sensor generation all affect magnitude. Repeated observations across independent passes are stronger evidence than a single adaptive-threshold event.
For calibration, keep ordinary full rlogs and record ground-truth route times for known potholes, expansion joints, speed bumps, and false positives. Device or logger changes should only be considered if validation reveals clipping, missing samples, or inadequate positioning.
+2
View File
@@ -0,0 +1,2 @@
"""Offline road-quality analysis tools for openpilot routes."""
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import http.server
import json
import re
import secrets
import shutil
import socketserver
import threading
from functools import partial
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from openpilot.tools.clustermaps.analyzer import (
AnalyzerConfig,
analyze_route,
merge_segment_routes,
rebuild_index,
write_route_cache,
)
from openpilot.tools.lib.route import SegmentRange
TOOL_ROOT = Path(__file__).resolve().parent
DEFAULT_CACHE_DIR = TOOL_ROOT / "web" / "data"
CACHE_ID_PATTERN = re.compile(r"[0-9a-f]{16}")
DEVICE_SEGMENT_PATTERN = re.compile(r"(?P<route>.+)--(?P<segment>[0-9]+)")
IMPORT_JOBS: dict[str, dict] = {}
IMPORT_JOBS_LOCK = threading.Lock()
CACHE_WRITE_LOCK = threading.Lock()
def _public_job(job: dict) -> dict:
return {key: value for key, value in job.items() if not key.startswith("_")}
def _persist_job(job: dict) -> None:
manifest_path = Path(job["_manifestPath"])
manifest_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = manifest_path.with_suffix(".tmp")
temporary_path.write_text(json.dumps(_public_job(job), separators=(",", ":")))
temporary_path.replace(manifest_path)
def _update_job(job_id: str, **updates) -> None:
with IMPORT_JOBS_LOCK:
IMPORT_JOBS[job_id].update(updates)
_persist_job(IMPORT_JOBS[job_id])
def _run_import_job(job_id: str, route_identifier: str, label: str | None, segments: list[int], cache_dir: Path) -> None:
try:
available = SegmentRange(route_identifier).seg_idxs
cache_key = route_identifier if segments == available else f"{route_identifier}/segments={','.join(map(str, segments))}"
route_cache_id = hashlib.sha256(cache_key.encode()).hexdigest()[:16]
segment_dir = cache_dir / "route_analysis" / route_cache_id / "segments"
segment_dir.mkdir(parents=True, exist_ok=True)
analyzed_segments: list[dict] = []
_update_job(job_id, status="running", stage="Locating full rlogs", percent=2)
for position, segment_index in enumerate(segments, start=1):
segment_path = segment_dir / f"{segment_index}.json"
if segment_path.exists():
try:
segment_route = json.loads(segment_path.read_text())
except (json.JSONDecodeError, OSError):
segment_path.unlink(missing_ok=True)
segment_route = None
else:
segment_route = None
if segment_route is None:
_update_job(
job_id,
stage=f"Analyzing segment {segment_index} ({position} of {len(segments)})",
percent=round(80 * (position - 1) / len(segments)),
)
segment_route = analyze_route(route_identifier, segment_indexes=[segment_index])
temporary_path = segment_path.with_suffix(".tmp")
temporary_path.write_text(json.dumps(segment_route, separators=(",", ":")))
temporary_path.replace(segment_path)
analyzed_segments.append(segment_route)
_update_job(
job_id,
stage=f"Checkpointed segment {position} of {len(segments)}",
completedSegments=position,
percent=round(80 * position / len(segments)),
)
_update_job(job_id, stage="Assembling route and map layers", percent=88)
route = merge_segment_routes(
route_identifier,
analyzed_segments,
label=label,
cache_key=cache_key,
segment_indexes=segments,
)
with CACHE_WRITE_LOCK:
route_path, geojson_path = write_route_cache(route, cache_dir)
_update_job(
job_id,
status="complete",
stage="Ready",
percent=100,
cacheId=route["id"],
routeFile=route_path.name,
geojsonFile=geojson_path.name,
summary=route["summary"],
)
except Exception as error:
_update_job(job_id, status="error", stage="Import failed", error=str(error))
def _load_and_resume_jobs(cache_dir: Path) -> None:
imports_dir = cache_dir / "imports"
for manifest_path in imports_dir.glob("*/job.json"):
try:
job = json.loads(manifest_path.read_text())
except (json.JSONDecodeError, OSError):
continue
job["_manifestPath"] = str(manifest_path)
with IMPORT_JOBS_LOCK:
IMPORT_JOBS[job["id"]] = job
if job["status"] in ("queued", "running"):
_update_job(job["id"], status="queued", stage="Resuming from segment checkpoints")
threading.Thread(
target=_run_import_job,
args=(job["id"], job["route"], job.get("label"), job["segments"], cache_dir),
daemon=True,
).start()
class RoadQualityRequestHandler(http.server.SimpleHTTPRequestHandler):
cache_dir = DEFAULT_CACHE_DIR
def end_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
super().end_headers()
def _json_response(self, payload: dict | list, status: int = 200) -> None:
encoded = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _error_response(self, message: str, status: int = 400) -> None:
self._json_response({"error": message}, status)
def _read_json(self) -> dict:
if self.headers.get("X-Road-Quality-Request") != "1":
raise ValueError("Missing local request header")
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 65_536:
raise ValueError("Invalid request size")
payload = json.loads(self.rfile.read(length))
if not isinstance(payload, dict):
raise ValueError("Expected a JSON object")
return payload
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path.startswith(("/data/device_rlogs/", "/data/device_analysis/", "/data/route_analysis/", "/data/imports/")):
self._error_response("Private analysis data is not browser-accessible", 404)
return
if parsed.path == "/api/segments":
try:
route_identifier = parse_qs(parsed.query).get("route", [""])[0].strip()
segment_range = SegmentRange(route_identifier)
base_identifier = f"{segment_range.dongle_id}/{segment_range.log_id}"
segments = SegmentRange(base_identifier).seg_idxs
self._json_response({
"route": base_identifier,
"segmentCount": len(segments),
"segments": [{"index": index, "label": f"Segment {index}"} for index in segments],
})
except Exception as error:
self._error_response(str(error))
return
if parsed.path.startswith("/api/jobs/"):
job_id = parsed.path.removeprefix("/api/jobs/")
with IMPORT_JOBS_LOCK:
job = IMPORT_JOBS.get(job_id)
payload = _public_job(job) if job is not None else None
if payload is None:
self._error_response("Import job not found", 404)
else:
self._json_response(payload)
return
super().do_GET()
def do_POST(self) -> None:
try:
payload = self._read_json()
if self.path == "/api/import":
route_identifier = str(payload.get("route", "")).strip()
segment_range = SegmentRange(route_identifier)
base_identifier = f"{segment_range.dongle_id}/{segment_range.log_id}"
available = SegmentRange(base_identifier).seg_idxs
raw_segments = payload.get("segments", available)
if not isinstance(raw_segments, list) or any(not isinstance(index, int) for index in raw_segments):
raise ValueError("segments must be a list of integer indexes")
segments = sorted(set(raw_segments))
if not segments or any(index not in available for index in segments):
raise ValueError("Select at least one available segment")
label_value = payload.get("label")
label = str(label_value).strip()[:120] if label_value else None
job_id = secrets.token_hex(8)
manifest_path = self.cache_dir / "imports" / job_id / "job.json"
with IMPORT_JOBS_LOCK:
IMPORT_JOBS[job_id] = {
"id": job_id,
"route": base_identifier,
"label": label,
"segments": segments,
"status": "queued",
"stage": "Queued",
"percent": 0,
"completedSegments": 0,
"totalSegments": len(segments),
"_manifestPath": str(manifest_path),
}
_persist_job(IMPORT_JOBS[job_id])
threading.Thread(
target=_run_import_job,
args=(job_id, base_identifier, label, segments, self.cache_dir),
daemon=True,
).start()
self._json_response({"jobId": job_id}, 202)
return
if self.path == "/api/remove":
cache_id = str(payload.get("cacheId", ""))
if CACHE_ID_PATTERN.fullmatch(cache_id) is None:
raise ValueError("Invalid cache id")
with CACHE_WRITE_LOCK:
remove_routes([cache_id], self.cache_dir, AnalyzerConfig())
self._json_response({"removed": cache_id})
return
self._error_response("API endpoint not found", 404)
except (AssertionError, ValueError, json.JSONDecodeError) as error:
self._error_response(str(error))
def import_routes(routes: list[str], cache_dir: Path, label: str | None) -> None:
for identifier in routes:
print(f"Analyzing {identifier} from full rlogs...")
route = analyze_route(identifier, label=label if len(routes) == 1 else None)
route_path, geojson_path = write_route_cache(route, cache_dir)
counts = route["summary"]["eventCounts"]
result = f"Cached {route_path.name}: {route['summary']['distanceM']:.0f} m, "
result += f"{counts['impact']} impacts, {counts['roughRoad']} rough windows"
print(result)
print(f"GeoJSON: {geojson_path}")
def import_device_directory(device_dir: Path, cache_dir: Path, device_key: str) -> None:
grouped: dict[str, list[tuple[int, Path]]] = {}
for path in sorted(device_dir.glob("*/rlog.*")):
match = DEVICE_SEGMENT_PATTERN.fullmatch(path.parent.name)
if match is None:
continue
grouped.setdefault(match.group("route"), []).append((int(match.group("segment")), path))
if not grouped:
raise ValueError(f"No device rlogs found under {device_dir}")
checkpoint_root = cache_dir / "device_analysis"
for route_name, segment_files in grouped.items():
identifier = f"{device_key}/{route_name}"
checkpoint_dir = checkpoint_root / hashlib.sha256(identifier.encode()).hexdigest()[:16]
checkpoint_dir.mkdir(parents=True, exist_ok=True)
analyzed_segments: list[dict] = []
analyzed_indexes: list[int] = []
print(f"Analyzing device route {identifier} ({len(segment_files)} segments)...")
for position, (segment_index, rlog_path) in enumerate(sorted(segment_files), start=1):
checkpoint_path = checkpoint_dir / f"{segment_index}.json"
try:
if checkpoint_path.exists():
segment_route = json.loads(checkpoint_path.read_text())
else:
segment_route = analyze_route(
identifier,
segment_indexes=[segment_index],
log_files=[str(rlog_path)],
)
temporary_path = checkpoint_path.with_suffix(".tmp")
temporary_path.write_text(json.dumps(segment_route, separators=(",", ":")))
temporary_path.replace(checkpoint_path)
analyzed_segments.append(segment_route)
analyzed_indexes.append(segment_index)
print(f" [{position}/{len(segment_files)}] segment {segment_index} ready")
except Exception as error:
print(f" [{position}/{len(segment_files)}] segment {segment_index} skipped: {error}")
if not analyzed_segments:
print(" No analyzable segments; route skipped.")
continue
route = merge_segment_routes(identifier, analyzed_segments, segment_indexes=analyzed_indexes)
route_path, _ = write_route_cache(route, cache_dir)
print(f"Cached {route_path.name}: {route['summary']['distanceM'] / 1000:.2f} km")
def remove_routes(cache_ids: list[str], cache_dir: Path, config: AnalyzerConfig) -> None:
for cache_id in cache_ids:
if CACHE_ID_PATTERN.fullmatch(cache_id) is None:
raise ValueError(f"Invalid cache id {cache_id!r}; use the 16-character id printed by --list")
removed = False
for path in (cache_dir / "routes" / f"{cache_id}.json", cache_dir / "geojson" / f"{cache_id}.geojson"):
if path.exists():
path.unlink()
removed = True
shutil.rmtree(cache_dir / "device_analysis" / cache_id, ignore_errors=True)
shutil.rmtree(cache_dir / "route_analysis" / cache_id, ignore_errors=True)
for manifest_path in (cache_dir / "imports").glob("*/job.json"):
try:
manifest = json.loads(manifest_path.read_text())
except (json.JSONDecodeError, OSError):
continue
if manifest.get("cacheId") == cache_id:
shutil.rmtree(manifest_path.parent, ignore_errors=True)
print(f"{'Removed' if removed else 'Not found'}: {cache_id}")
rebuild_index(cache_dir, config)
def list_routes(cache_dir: Path, config: AnalyzerConfig) -> None:
index = rebuild_index(cache_dir, config)
if not index["routes"]:
print("No routes are cached.")
return
print(f"{index['routeCount']} cached route(s), {index['driverCount']} driver(s):")
for route in index["routes"]:
summary = route["summary"]
segments = route["metadata"].get("segmentCount")
segment_text = f"{segments} segment(s), " if segments is not None else ""
route_description = (
f" {route['id']} {route['label']} {segment_text}"
+ f"{summary['durationS'] / 60:.1f} min, {summary['distanceM'] / 1000:.2f} km"
)
print(route_description)
def serve(port: int) -> None:
RoadQualityRequestHandler.cache_dir = DEFAULT_CACHE_DIR
_load_and_resume_jobs(DEFAULT_CACHE_DIR)
handler = partial(RoadQualityRequestHandler, directory=str(TOOL_ROOT / "web"))
with socketserver.ThreadingTCPServer(("127.0.0.1", port), handler) as server:
print(f"clustermaps: http://127.0.0.1:{port}")
print("Press Ctrl-C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
def main() -> None:
parser = argparse.ArgumentParser(
description="Analyze full rlogs into a multi-route road-quality map cache.",
)
parser.add_argument("routes", nargs="*", help="Route or segment identifiers. Full rlogs are required.")
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR, help="Generated JSON/GeoJSON cache directory.")
parser.add_argument("--label", help="Friendly label to use when importing one route.")
parser.add_argument("--import-device-dir", type=Path, help="Import a previously copied on-device realdata directory.")
parser.add_argument("--device-key", help="Stable local device/driver key used with --import-device-dir.")
parser.add_argument("--list", action="store_true", help="List cached routes and their removable cache ids.")
parser.add_argument("--remove", action="append", default=[], metavar="CACHE_ID", help="Remove one cached route; repeat as needed.")
parser.add_argument("--serve", action="store_true", help="Serve the local HTML5 map after importing.")
parser.add_argument("--port", type=int, default=8765, help="Local HTTP port used with --serve.")
args = parser.parse_args()
config = AnalyzerConfig()
if args.import_device_dir:
device_key = args.device_key or f"device-{args.import_device_dir.name}"
try:
import_device_directory(args.import_device_dir, args.cache_dir, device_key)
except ValueError as error:
parser.error(str(error))
if args.remove:
try:
remove_routes(args.remove, args.cache_dir, config)
except ValueError as error:
parser.error(str(error))
if args.routes:
import_routes(args.routes, args.cache_dir, args.label)
elif not args.remove and not args.import_device_dir:
rebuild_index(args.cache_dir, config)
if args.list:
list_routes(args.cache_dir, config)
if args.serve:
if args.cache_dir.resolve() != DEFAULT_CACHE_DIR.resolve():
parser.error("--serve currently requires the default cache directory")
serve(args.port)
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+106
View File
@@ -0,0 +1,106 @@
import numpy as np
from openpilot.tools.clustermaps.analyzer import (
AnalyzerConfig,
LocationInterpolator,
_analyze_impacts,
_cluster_problem_locations,
route_to_geojson,
)
def test_location_interpolator():
location = LocationInterpolator([
{"t": 0.0, "latitude": 40.0, "longitude": -74.0},
{"t": 1.0, "latitude": 40.001, "longitude": -73.999},
{"t": 2.0, "latitude": 40.002, "longitude": -73.998},
])
midpoint = location.at(0.5)
assert midpoint is not None
assert midpoint["latitude"] == 40.0005
assert midpoint["longitude"] == -73.9995
assert location.at(-0.1) is None
assert location.at(2.1) is None
def test_impact_detection_finds_synthetic_peak():
times = np.arange(0.0, 10.0, 0.01)
vertical = -9.81 + 0.03 * np.sin(times * 11.0)
vertical[np.argmin(np.abs(times - 5.0))] -= 2.5
accelerometer = [(float(t), 0.0, 0.0, float(z)) for t, z in zip(times, vertical, strict=True)]
car_state = [(float(t), 12.0, 0.0, False, False) for t in times]
track = [{
"t": float(t),
"latitude": 40.0 + float(t) * 0.0001,
"longitude": -74.0,
} for t in np.arange(0.0, 10.1, 1.0)]
impacts, _, diagnostics = _analyze_impacts(
accelerometer,
car_state,
LocationInterpolator(track),
start_time=0.0,
config=AnalyzerConfig(),
)
assert len(impacts) == 1
assert 4.9 <= impacts[0]["t"] <= 5.1
assert impacts[0]["peakVerticalMps2"] > 2.0
assert diagnostics["thresholdMps2"] >= AnalyzerConfig().impact_min_peak_mps2
def test_repeated_locations_require_independent_routes():
def route(route_id, driver_id, latitude):
return {
"id": route_id,
"driverId": driver_id,
"layers": {
"events": [{
"kind": "impact",
"latitude": latitude,
"longitude": -74.0,
"severity": 1.2,
}],
"roughness": [],
},
}
one_route = _cluster_problem_locations([route("a", "driver-a", 40.0)], radius_m=20.0)
two_routes = _cluster_problem_locations([
route("a", "driver-a", 40.0),
route("b", "driver-b", 40.00005),
], radius_m=20.0)
assert one_route == []
assert len(two_routes) == 1
assert two_routes[0]["routeCount"] == 2
assert two_routes[0]["driverCount"] == 2
def test_geojson_contains_route_and_events():
route = {
"id": "route-a",
"label": "Route A",
"driverId": "driver-a",
"summary": {"distanceM": 10.0},
"track": [
{"latitude": 40.0, "longitude": -74.0},
{"latitude": 40.001, "longitude": -74.001},
],
"layers": {
"events": [{
"kind": "impact",
"latitude": 40.0005,
"longitude": -74.0005,
"severity": 1.3,
}],
"roughness": [],
},
}
geojson = route_to_geojson(route)
assert geojson["type"] == "FeatureCollection"
assert geojson["features"][0]["geometry"]["type"] == "LineString"
assert geojson["features"][1]["geometry"]["type"] == "Point"
assert geojson["features"][1]["properties"]["kind"] == "impact"
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+306
View File
@@ -0,0 +1,306 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An offline-first atlas of road impacts, roughness, body motion, and driving events from openpilot rlogs.">
<title>clustermaps</title>
<link rel="stylesheet" href="vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="styles.css?v=5">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div>
<h1>clustermaps</h1>
</div>
</div>
<div class="atlas-stats" aria-label="Atlas summary">
<div><strong id="route-count"></strong><span>routes</span></div>
<div><strong id="driver-count"></strong><span>drivers</span></div>
<div><strong id="distance-total"></strong><span>mapped</span></div>
<div><strong id="observation-count"></strong><span>signals</span></div>
</div>
<div class="data-state">
<span class="status-dot" aria-hidden="true"></span>
<span id="data-status">Loading local cache…</span>
</div>
</header>
<main>
<aside class="sidebar" aria-label="Map controls">
<section class="control-section">
<div class="section-heading">
<div>
<p class="eyebrow">Legend</p>
<h2>Sensor layers</h2>
</div>
</div>
<div class="layer-list">
<label class="layer-control">
<input type="checkbox" data-layer="route" checked>
<span class="legend-line route-line" aria-hidden="true"></span>
<span><strong>Route traces</strong><small>GPS path by driver</small></span>
<output data-count="route">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="roughness" checked>
<span class="legend-gradient" aria-hidden="true"></span>
<span><strong>Road roughness</strong><small>2-second vertical vibration</small></span>
<output data-count="roughness">0</output>
</label>
<div class="roughness-scale" aria-label="Road roughness color scale">
<span>Smoother</span><i aria-hidden="true"></i><span>Rougher</span>
</div>
<label class="layer-control">
<input type="checkbox" data-layer="impact" checked>
<span class="legend-dot impact-dot" aria-hidden="true"></span>
<span><strong>Sharp impacts</strong><small>Adaptive IMU peaks</small></span>
<output data-count="impact">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="hardAcceleration" checked>
<span class="legend-dot acceleration-dot" aria-hidden="true"></span>
<span><strong>Hard acceleration</strong><small>Sustained longitudinal force</small></span>
<output data-count="hardAcceleration">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="hardBraking" checked>
<span class="legend-dot braking-dot" aria-hidden="true"></span>
<span><strong>Hard braking</strong><small>Sustained deceleration</small></span>
<output data-count="hardBraking">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="bodyMotion">
<span class="legend-dot motion-dot" aria-hidden="true"></span>
<span><strong>Body motion</strong><small>Roll and pitch activity</small></span>
<output data-count="bodyMotion">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="gps">
<span class="legend-dot gps-dot" aria-hidden="true"></span>
<span><strong>GPS samples</strong><small>Recorded location cadence</small></span>
<output data-count="gps">0</output>
</label>
<p class="layer-group-title">Deep telemetry</p>
<label class="layer-control repeated-control">
<input type="checkbox" data-layer="repeated" checked>
<span class="legend-ring" aria-hidden="true"></span>
<span><strong>Repeated locations</strong><small>Seen on 2+ imported routes</small></span>
<output data-count="repeated">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="imu">
<span class="legend-dot imu-dot" aria-hidden="true"></span>
<span><strong>Raw IMU vectors</strong><small>Acceleration + rotation at 5 Hz</small></span>
<output data-count="imu">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="vehicle">
<span class="legend-dot vehicle-dot" aria-hidden="true"></span>
<span><strong>Vehicle dynamics</strong><small>Wheels, steering, pedals, yaw</small></span>
<output data-count="vehicle">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="pose">
<span class="legend-dot pose-dot" aria-hidden="true"></span>
<span><strong>Fused pose</strong><small>Filtered acceleration + rotation</small></span>
<output data-count="pose">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="visualOdometry">
<span class="legend-dot odometry-dot" aria-hidden="true"></span>
<span><strong>Visual odometry</strong><small>Camera motion + uncertainty</small></span>
<output data-count="visualOdometry">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="controls">
<span class="legend-dot controls-dot" aria-hidden="true"></span>
<span><strong>Path curvature</strong><small>Estimated versus desired path</small></span>
<output data-count="controls">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="calibration">
<span class="legend-dot calibration-dot" aria-hidden="true"></span>
<span><strong>Vehicle calibration</strong><small>Roll, steer ratio, stiffness</small></span>
<output data-count="calibration">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="roadVision">
<span class="legend-dot vision-dot" aria-hidden="true"></span>
<span><strong>Road vision</strong><small>Lanes, action, frame health</small></span>
<output data-count="roadVision">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="radar">
<span class="legend-dot radar-dot" aria-hidden="true"></span>
<span><strong>Radar leads</strong><small>Distance + relative motion</small></span>
<output data-count="radar">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="sound">
<span class="legend-dot sound-dot" aria-hidden="true"></span>
<span><strong>Cabin sound</strong><small>A-weighted sound pressure</small></span>
<output data-count="sound">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="magnetic">
<span class="legend-dot magnetic-dot" aria-hidden="true"></span>
<span><strong>Magnetic field</strong><small>Three-axis field magnitude</small></span>
<output data-count="magnetic">0</output>
</label>
<label class="layer-control">
<input type="checkbox" data-layer="thermal">
<span class="legend-dot thermal-dot" aria-hidden="true"></span>
<span><strong>Thermal + power</strong><small>IMU/device temperature and load</small></span>
<output data-count="thermal">0</output>
</label>
</div>
</section>
<section class="control-section">
<div class="section-heading">
<div>
<p class="eyebrow">Coverage</p>
<h2>Imported drives</h2>
</div>
<div class="route-actions">
<button class="text-button" id="select-all-routes" type="button">Select all</button>
<button class="text-button" id="unselect-all-routes" type="button">Unselect all</button>
<button class="text-button" id="fit-all" type="button">Fit all</button>
</div>
</div>
<div id="route-list" class="route-list">
<div class="route-skeleton"></div>
<div class="route-skeleton short"></div>
</div>
</section>
<section class="control-section service-section">
<details class="service-catalog">
<summary>
<span>
<span class="eyebrow">Rlog inventory</span>
<strong>All recorded services</strong>
</span>
<output id="service-count">0</output>
</summary>
<p>Every service is listed even when its full payload is too large, redundant, transient, or sensitive for browser export.</p>
<div id="service-list" class="service-list"></div>
</details>
</section>
<section class="import-help">
<p class="eyebrow">Add data</p>
<h2>Route manager</h2>
<form id="route-manager">
<label class="field-label" for="route-input">comma route</label>
<div class="field-row">
<input id="route-input" name="route" required autocomplete="off" placeholder="dongle/route">
<button id="discover-route" type="submit">Load segments</button>
</div>
<label class="field-label" for="route-label">Local label <span>optional</span></label>
<input id="route-label" name="label" maxlength="120" autocomplete="off" placeholder="Morning survey">
</form>
<div id="segment-picker" class="segment-picker" hidden>
<div class="segment-heading">
<strong id="segment-summary">0 segments</strong>
<span>
<button id="select-all-segments" type="button">All</button>
<button id="select-no-segments" type="button">None</button>
</span>
</div>
<div id="segment-list" class="segment-list"></div>
<button id="import-selected" class="primary-button" type="button">Import selected segments</button>
</div>
<div id="import-progress" class="import-progress" hidden>
<div><strong id="progress-stage">Preparing import</strong><output id="progress-percent">0%</output></div>
<progress id="progress-bar" max="100" value="0">0%</progress>
<p id="progress-detail">Full rlogs are parsed locally.</p>
</div>
<p id="route-manager-status" class="route-manager-status" aria-live="polite">Imports run locally and are added to this atlas.</p>
</section>
<section class="method-note">
<p class="eyebrow">How to read this</p>
<p>Scores are route-adaptive screening signals, not confirmed pothole labels. Repeated observations across independent drives are the strongest evidence.</p>
</section>
</aside>
<section class="map-panel" aria-label="Road quality map">
<div id="map" role="application" aria-label="Interactive map of road-quality observations"></div>
<div class="map-toolbar">
<div class="basemap-picker" role="group" aria-label="Basemap">
<button type="button" data-basemap="streets" aria-pressed="true">Streets</button>
<button type="button" data-basemap="satellite" aria-pressed="false">Satellite</button>
<button type="button" data-basemap="data" aria-pressed="false">Data only</button>
</div>
<button id="visuals-toggle" type="button" aria-expanded="false" aria-controls="visuals-panel">Visuals</button>
<button id="reset-view" type="button">Reset view</button>
</div>
<section id="visuals-panel" class="visuals-panel" aria-label="Map visualization options" hidden>
<div class="visuals-heading">
<div>
<p class="eyebrow">Map rendering</p>
<h2>Visual styles</h2>
</div>
<button id="visuals-close" type="button" aria-label="Close visualization options">×</button>
</div>
<div class="visual-presets" role="group" aria-label="Visualization presets">
<button type="button" data-viz-preset="signal" aria-pressed="true">Signal</button>
<button type="button" data-viz-preset="heat" aria-pressed="false">Heat</button>
<button type="button" data-viz-preset="corridor" aria-pressed="false">Corridor</button>
<button type="button" data-viz-preset="night" aria-pressed="false">Night</button>
</div>
<label class="visual-range" for="visual-width">
<span>Road width <output id="visual-width-value">1.0×</output></span>
<input id="visual-width" type="range" min="0.7" max="3.5" value="1" step="0.1">
</label>
<label class="visual-range" for="visual-intensity">
<span>Signal intensity <output id="visual-intensity-value">80%</output></span>
<input id="visual-intensity" type="range" min="0.25" max="1" value="0.8" step="0.05">
</label>
<div class="visual-switches">
<label>
<input id="route-glow-toggle" type="checkbox">
<span><strong>Coverage glow</strong><small>Bleed routes beyond the road centerline</small></span>
</label>
<label>
<input id="impact-halo-toggle" type="checkbox">
<span><strong>Impact heat halos</strong><small>Overlaps become stronger problem areas</small></span>
</label>
<label>
<input id="point-bleed-toggle" type="checkbox">
<span><strong>Point bleed</strong><small>Blend neighboring sensor samples into a continuous field</small></span>
</label>
<label>
<input id="night-map-toggle" type="checkbox">
<span><strong>High-contrast map</strong><small>Dark background for bright sensor signals</small></span>
</label>
</div>
</section>
<div id="map-scale-note" class="map-scale-note">Street tiles require a connection. Sensor overlays remain available offline.</div>
<article id="inspector" class="inspector" aria-live="polite">
<button id="inspector-close" class="inspector-close" type="button" aria-label="Close observation details">×</button>
<p class="eyebrow" id="inspector-kicker">Selected observation</p>
<h2 id="inspector-title">Choose a point on the map</h2>
<p id="inspector-copy">Impact, roughness, motion, and driving-event markers reveal their measurements here.</p>
<dl id="inspector-metrics"></dl>
</article>
<div id="map-message" class="map-message" hidden>
<strong>Local cache unavailable</strong>
<span>Import an rlog and open this site through the local map server.</span>
</div>
</section>
</main>
</div>
<noscript>This map requires JavaScript to render the cached route data.</noscript>
<script src="vendor/leaflet/leaflet.js"></script>
<script type="module" src="app.js?v=10"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
+199
View File
@@ -0,0 +1,199 @@
# Live UI
View a comma device's live sunnypilot UI over Wi-Fi. Replay, ADB, and USB are
not used.
| Mode | Output | Device bridge |
| --- | --- | --- |
| Mimic (default) | Local UI rendered from live device data | Required |
| Exact (`--exact`) | Actual pixels shown on the device | Not required |
Mimic mode does not directly require SSH, but the device bridge must be started
somehow; the examples use SSH. Exact mode, touch control, and `--stop-exact`
require key-based SSH access.
Choose **mimic** when developing or inspecting the local UI with live device
data. Choose **exact** when you need to see or control what is physically shown
on the device.
## Before the first run
1. Replace `192.168.43.1` in the examples if your device uses a different IP.
2. Connect the computer and device to networks that can reach each other.
3. Run local commands from the sunnypilot repository root.
4. For exact mode, verify non-interactive SSH access:
```bash
ssh -o BatchMode=yes comma@192.168.43.1 true
```
The script automatically switches to the repository's `.venv` when present.
## Mimic UI
Mimic mode receives live cereal messages and encoded road-camera frames, then
renders a new UI instance on the computer. It resembles the device UI but is
not a pixel-for-pixel screen mirror.
Build the messaging bridge once on the computer:
```bash
scons -u cereal/messaging/bridge
```
Use two terminals. In the first, start the bridge on the device and leave it
running:
```bash
ssh comma@192.168.43.1
./cereal/messaging/bridge
```
In the second terminal, start the local UI:
```bash
BIG=0 ./tools/live/ui.py 192.168.43.1
```
`BIG` is inherited from your environment and is not forced by the tool. The
first run may generate missing local font assets before opening the window.
## Exact device screen
Exact mode streams the actual device display. It does not need either
messaging bridge:
```bash
./tools/live/ui.py --exact 192.168.43.1
```
The tool runs a temporary capture helper through SSH using AGNOS's existing
DRM broker. It does not install software or modify openpilot source on the
device. Exact mode requires a compatible AGNOS DRM writeback connector and
captures the post-processed display output when that mode is available.
Exact mode is experimental. Some Qualcomm/AGNOS combinations can time out
during DRM writeback, freezing both the mirrored window and physical device UI.
This can happen without `--control`; mouse, keyboard, and shortcut forwarding
are not required to trigger the capture failure.
The tool stops its verified helper when capture stalls, but the kernel display
state may require a full device reboot before exact mode can be tried again.
Restarting openpilot alone does not reset the display driver.
Set the capture rate from 1 to 60 FPS; the default is 15:
```bash
./tools/live/ui.py --exact --exact-fps 30 192.168.43.1
./tools/live/ui.py --exact --exact-fps 60 192.168.43.1
```
Start with 15 or 30 FPS. Higher rates use more device CPU and Wi-Fi bandwidth.
Use `SCALE` to resize the local window:
```bash
SCALE=2 ./tools/live/ui.py --exact 192.168.43.1
```
Only one exact session can run at a time.
## Touch control
Touch control is available only with exact mode:
```bash
./tools/live/ui.py --exact --control 192.168.43.1
```
Left-clicks and drags are forwarded to the physical touchscreen over SSH.
These inputs affect the real device, including settings and confirmation
buttons.
When the device's on-screen keyboard is visible, type with the computer
keyboard. Letters, numbers, symbols, Space, Backspace, and Return are converted
to taps on the device keyboard.
Exact control also provides simulator-style shortcuts:
| Shortcut | Action |
| --- | --- |
| `` | Go back with a swipe-down gesture |
| `` | Swipe right |
| `` | Swipe left |
| `Command`+`K` (macOS) or `Ctrl`+`K` (Linux/Windows) | Force keyboard forwarding on, or return to automatic detection |
Hold an arrow key to repeat its gesture until the key is released.
Escape has no action and does not close the program; use `Ctrl-C` or the window
close button to exit. `Command`+`K`/`Ctrl`+`K` enables manual keyboard forwarding
regardless of exact-screen resolution; touch locations scale to the captured
screen. Automatic keyboard detection targets the 536x240 mici UI. Turn forced
forwarding back off after typing so ordinary keys cannot produce unintended
taps on incompatible keyboard layouts.
## Connection status
Status appears in the window title and terminal:
- `connected`: mimic telemetry and camera frames, or exact screen frames, are
arriving.
- `keyboard`: exact-mode keyboard forwarding is currently active. This appears
only while the device keyboard is detected or forwarding is forced on.
- `no camera`: mimic telemetry is arriving, but road-camera frames are not.
- `disconnected`: expected mimic-mode live data has stopped.
At the default capture rate, exact mode reports a stopped screen stream in
about one second. Lower `--exact-fps` values allow more time between frames.
If Qualcomm DRM writeback stalls, exact mode immediately stops its verified
device helper and exits to avoid leaving the physical display frozen.
## Stop and clean up
Press `Ctrl-C` or close the window. If an exact helper remains orphaned, run:
```bash
./tools/live/ui.py --stop-exact 192.168.43.1
```
`--kill-exact` is an alias. Stop exact mode before restarting or rebuilding
openpilot to avoid blocking the physical UI during a display transition.
## Options
```text
--exact Show the actual device screen
--control Forward mouse and keyboard input; requires --exact
--exact-fps FPS Exact capture rate, 1-60 (default: 15)
--stop-exact Stop orphaned exact helpers
--kill-exact Alias for --stop-exact
--ip ADDRESS Alternative to the positional address
```
See the complete command help:
```bash
./tools/live/ui.py --help
```
## Troubleshooting
- **`cereal/messaging/bridge` is not built:** Run:
```bash
scons -u cereal/messaging/bridge
```
- **Mimic is disconnected:** Confirm the device bridge is still running and
the IP is reachable.
- **Mimic reports no camera:** Confirm the device is publishing
`roadEncodeData`. Camera display may also wait briefly for the next keyframe.
- **Exact says another session is running:** Close the other exact window. If
none is open, run `--stop-exact`.
- **Exact cannot connect:** Recheck key-based SSH and the device IP using the
command in "Before the first run."
- **Exact stalls or the physical UI freezes:** Stop the exact window and run
`--stop-exact`. If the device UI remains unstable or exact immediately stalls
again, fully reboot the device; restarting openpilot is not sufficient. If
the problem returns after reboot, use mimic mode because continuous DRM
writeback is not reliable on that device/AGNOS combination. Removing
`--control` does not address this capture-layer failure.
+1410
View File
File diff suppressed because it is too large Load Diff