Compare commits

...

176 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
royjr aa636e75c8 Merge branch 'master' into ccnc-port 2026-07-16 00:23:49 -04:00
royjr 5ff3c3bd8d Update opendbc_repo 2026-07-16 00:23:43 -04:00
royjr 1e11b52290 Update opendbc_repo 2026-07-14 00:40:02 -04:00
royjr 76e0204025 Update opendbc_repo 2026-07-14 00:37:13 -04:00
royjr cc88f2cbd6 Update opendbc_repo 2026-07-10 22:00:25 -04:00
royjr ee0ac199a4 Update opendbc_repo 2026-07-03 08:08:29 -04:00
royjr d5ed828eaa Update opendbc_repo 2026-07-01 19:30:06 -04:00
royjr 9fca585f2a Merge branch 'master' into ccnc-port 2026-06-30 22:14:33 -04:00
royjr bb1259303e Update opendbc_repo 2026-06-30 22:14:23 -04:00
royjr e135051ca8 Merge branch 'master' into ccnc-port 2026-06-13 22:38:22 -04:00
royjr 12bef55d8a Update opendbc_repo 2026-06-13 22:12:00 -04:00
royjr 2f7a45e6c8 Merge branch 'master' into ccnc-port 2026-06-08 22:01:57 -04:00
royjr 936ebfc12b Update opendbc_repo 2026-06-08 22:01:44 -04:00
royjr aa0c9dc0eb Merge branch 'master' into ccnc-port 2026-06-04 09:52:35 -04:00
royjr 7476a866e7 Update opendbc_repo 2026-06-04 09:52:06 -04:00
royjr 610d857e33 Merge branch 'master' into ccnc-port 2026-05-28 08:30:40 -04:00
royjr d2f47407d0 Update opendbc_repo 2026-05-12 21:03:18 -04:00
royjr db75ec76ea Merge branch 'master' into ccnc-port 2026-05-12 20:44:57 -04:00
royjr 24066465d7 Update opendbc_repo 2026-05-12 20:44:48 -04:00
royjr a7abbd6e25 Update opendbc_repo 2026-04-20 18:22:12 -04:00
royjr 878982447c Merge branch 'master' into ccnc-port 2026-04-19 12:06:46 -04:00
royjr 576527a36b Merge branch 'master' into ccnc-port 2026-04-17 05:42:35 -04:00
royjr ef8c35da24 Update opendbc_repo 2026-04-17 05:41:21 -04:00
royjr 85688b1040 Merge branch 'master' into ccnc-port 2026-04-16 19:48:39 -04:00
royjr 0fb2199130 Update opendbc_repo 2026-04-16 19:47:57 -04:00
royjr d48d756c1d Update opendbc_repo 2026-04-08 00:56:54 -04:00
royjr 2ed298a0c9 Merge branch 'master' into ccnc-port 2026-04-08 00:51:13 -04:00
royjr d68f038949 Update opendbc_repo 2026-04-08 00:51:11 -04:00
royjr 7231571e57 Merge branch 'master' into ccnc-port 2026-04-03 23:34:00 -04:00
royjr b37f1419d3 Update opendbc_repo 2026-04-03 23:33:19 -04:00
royjr cd85a66790 Merge branch 'master' into ccnc-port 2026-03-26 00:53:07 -04:00
royjr 305ea87daf Update opendbc_repo 2026-03-26 00:52:44 -04:00
royjr 4bbfc793e0 Merge branch 'master' into ccnc-port 2026-03-15 15:44:59 -04:00
royjr d5d983676e Update opendbc_repo 2026-03-13 16:41:05 -04:00
royjr de8a96a398 Merge branch 'master' into ccnc-port 2026-03-13 16:41:00 -04:00
royjr 0cbf45f699 Merge branch 'master' into ccnc-port 2026-03-11 23:28:59 -04:00
royjr 0d68a3a2ab Merge branch 'master' into ccnc-port 2026-03-09 19:58:32 -04:00
royjr 9e85a85059 Update opendbc_repo 2026-03-09 19:58:18 -04:00
royjr 0373c327c0 Update opendbc_repo 2026-03-02 10:10:38 -05:00
royjr efe9e5c200 Update opendbc_repo 2026-03-02 02:15:05 -05:00
royjr 8a249a45dc Update opendbc_repo 2026-03-02 02:06:16 -05:00
royjr bdbefe67f6 Update opendbc_repo 2026-03-02 01:40:31 -05:00
royjr 675bb166ad Merge branch 'master' into ccnc-port 2026-03-01 17:18:11 -05:00
royjr 1b717a7e88 Merge branch 'master' into ccnc-port 2026-03-01 13:23:30 -05:00
royjr 86f55a8ba9 Update opendbc_repo 2026-03-01 13:23:24 -05:00
royjr 629392d2f7 Update opendbc_repo 2026-02-27 16:31:59 -05:00
royjr bc414bdc8b Update opendbc_repo 2026-02-26 23:53:26 -05:00
royjr 7ca5649f2c Merge branch 'master' into ccnc-port 2026-02-26 23:52:46 -05:00
royjr 641ee8fa87 Update opendbc_repo 2026-02-26 23:52:27 -05:00
royjr 56c276158c Merge branch 'master' into ccnc-port 2026-02-24 14:12:12 -05:00
royjr c65308a8bd Update opendbc_repo 2026-02-24 14:12:01 -05:00
royjr 994e526460 Merge branch 'master' into ccnc-port 2026-02-18 12:06:05 -05:00
royjr 1defae36b7 Update opendbc_repo 2026-02-18 12:05:53 -05:00
royjr 8f029fd0ef Merge branch 'master' into ccnc-port 2026-02-13 23:01:41 -05:00
royjr ddb46284dc Update opendbc_repo 2026-02-13 23:01:16 -05:00
royjr 9effc754d9 Merge branch 'master' into ccnc-port 2026-02-06 01:16:06 -05:00
royjr e49ffc2a2d Update opendbc_repo 2026-02-06 01:15:59 -05:00
royjr 2cacd0b3e5 Merge branch 'master' into ccnc-port 2026-01-24 12:50:18 -05:00
royjr c4b8859dff Update opendbc_repo 2026-01-24 12:50:11 -05:00
royjr 8fb0953205 Merge branch 'master' into ccnc-port 2026-01-11 22:17:38 -05:00
royjr 63d1c8835f Merge branch 'master' into ccnc-port 2026-01-10 13:03:48 -05:00
royjr 17a185606d Merge branch 'master' into ccnc-port 2026-01-09 16:40:32 -05:00
royjr da10131392 Merge branch 'master' into ccnc-port 2025-12-28 17:18:51 -05:00
royjr 7107c2ba14 Merge branch 'master' into ccnc-port 2025-12-23 12:13:48 -05:00
royjr 95b6e877ac Update opendbc_repo 2025-12-23 12:13:29 -05:00
royjr eb02c6570e Update opendbc_repo 2025-12-21 15:43:04 -05:00
royjr 1be8ae31c4 Merge branch 'master' into ccnc-port 2025-12-19 01:04:42 -05:00
royjr 04dcd38856 Update opendbc_repo 2025-12-19 01:04:30 -05:00
royjr 22ccf0d72f Merge branch 'master' into ccnc-port 2025-12-15 17:02:50 -05:00
royjr 3c969bb627 Merge branch 'master' into ccnc-port 2025-12-13 23:22:22 -05:00
royjr 20f8011feb Update opendbc_repo 2025-12-13 23:22:11 -05:00
royjr 9cf17e74a1 Merge branch 'master' into ccnc-port 2025-12-12 23:19:56 -05:00
royjr 2c4efdf557 Merge branch 'master' into ccnc-port 2025-12-07 13:29:48 -05:00
royjr 4cd3d3c16c Merge branch 'master' into ccnc-port 2025-12-02 12:56:21 -05:00
royjr 637f3ae9c8 Merge branch 'master' into ccnc-port 2025-12-01 14:41:03 -05:00
royjr 464ee80f71 Merge branch 'master' into ccnc-port 2025-11-26 00:27:53 -05:00
royjr 2743a04613 Merge branch 'master' into ccnc-port 2025-11-24 18:44:13 -05:00
royjr 7f9978d001 Merge branch 'master' into ccnc-port 2025-11-22 00:21:15 -05:00
royjr 4b83961c67 Merge branch 'master' into ccnc-port 2025-11-21 16:23:22 -05:00
royjr c00eaf428a Update opendbc_repo 2025-11-21 16:23:01 -05:00
royjr 0a9993e8d4 Merge branch 'master' into ccnc-port 2025-11-19 16:49:59 -05:00
royjr 0af214a985 Update opendbc_repo 2025-11-19 16:49:51 -05:00
royjr af43385e3a Merge branch 'master' into ccnc-port 2025-11-11 10:19:51 -05:00
royjr 0ab2b8c590 Update opendbc_repo 2025-11-07 19:59:50 -05:00
royjr 67ab18a0de Merge branch 'master' into ccnc-port 2025-11-07 19:23:51 -05:00
royjr e87dc15b30 Update opendbc_repo 2025-11-07 19:23:37 -05:00
royjr 192d08516c Merge branch 'master' into ccnc-port 2025-11-02 19:23:00 -05:00
royjr 3cf001c59c Update opendbc_repo 2025-11-02 19:22:49 -05:00
royjr f2ccd021da Merge branch 'master' into ccnc-port 2025-11-02 14:07:54 -05:00
royjr c9fc900f64 Update opendbc_repo 2025-11-02 14:07:44 -05:00
royjr 3c37c5ce5d Update opendbc_repo 2025-10-30 11:28:49 -04:00
royjr 7c45889e4e Merge branch 'master' into ccnc-port 2025-10-30 11:27:50 -04:00
royjr 2aabb7aee8 Merge branch 'master' into ccnc-port 2025-10-24 14:16:09 -04:00
royjr 3859e9962f Update opendbc_repo 2025-10-24 14:15:51 -04:00
royjr 810efbab72 Merge branch 'master' into ccnc-port 2025-10-18 07:33:11 -04:00
royjr ec27bec326 Update opendbc_repo 2025-10-18 07:32:33 -04:00
royjr 250d553157 Merge branch 'master' into ccnc-port 2025-10-14 21:57:32 -04:00
royjr cea54a0ca8 Update opendbc_repo 2025-10-14 21:57:26 -04:00
royjr 8e72d783bd Update opendbc_repo 2025-10-13 22:41:21 -04:00
royjr 1b0dc103dc Merge branch 'master' into ccnc-port 2025-10-11 23:51:46 -04:00
royjr 6c364d292b Update opendbc_repo 2025-10-11 23:51:31 -04:00
royjr bcdec2ce84 Merge branch 'master' into ccnc-port 2025-10-10 17:29:05 -04:00
royjr 3deaeb3759 Merge branch 'master' into ccnc-port 2025-10-10 15:02:32 -04:00
royjr c669f0984a Update opendbc_repo 2025-10-10 15:02:17 -04:00
royjr 46dd946740 Merge branch 'master' into ccnc-port 2025-10-07 01:36:06 -04:00
royjr 9da4b3653e Update opendbc_repo 2025-10-07 01:35:58 -04:00
royjr 4e21ae7c50 Update opendbc_repo 2025-10-05 06:21:56 -04:00
royjr bb91e92237 Update opendbc_repo 2025-10-05 06:01:15 -04:00
royjr 14b4c4f85b Update opendbc_repo 2025-10-02 09:20:32 -04:00
royjr 0660b542c3 Merge branch 'master' into ccnc-port 2025-10-01 16:01:32 -04:00
royjr 2b893b90c9 Update opendbc_repo 2025-10-01 16:01:26 -04:00
royjr f5139178ed Merge branch 'master' into ccnc-port 2025-09-30 14:39:57 -04:00
royjr fb43b755f2 Update opendbc_repo 2025-09-30 14:39:50 -04:00
royjr 07f5b967d8 Merge branch 'master' into ccnc-port 2025-09-24 21:30:29 -04:00
royjr ea19c7d3bb Update opendbc_repo 2025-09-24 21:30:17 -04:00
royjr e461842cbb Merge branch 'master' into ccnc-port 2025-09-23 05:55:02 -04:00
royjr a73c9659d5 Update opendbc_repo 2025-09-23 05:54:50 -04:00
royjr cb796fbc76 Merge branch 'master' into ccnc-port 2025-09-18 20:10:43 -04:00
royjr 6bf75fc557 Update opendbc_repo 2025-09-18 20:10:37 -04:00
royjr 9a1fc28819 Merge branch 'master' into ccnc-port 2025-09-18 13:54:36 -04:00
royjr 0741d05e92 Update opendbc_repo 2025-09-18 13:54:23 -04:00
royjr 1ad008107d Merge branch 'master' into ccnc-port 2025-09-15 01:40:27 -04:00
royjr feebd9df93 Reapply "UI: Developer UI (#1233)"
This reverts commit 15e5d2efb9.
2025-09-15 01:40:21 -04:00
royjr c2e5ced3e5 Update opendbc_repo 2025-09-15 01:39:51 -04:00
royjr 15e5d2efb9 Revert "UI: Developer UI (#1233)"
This reverts commit 1bb4ca2547.
2025-09-12 02:10:29 -04:00
royjr a3929d0b54 Merge branch 'master' into ccnc-port 2025-09-12 01:40:29 -04:00
royjr 794f8f9991 Update opendbc_repo 2025-09-08 09:27:31 -04:00
royjr 68fa5e3f21 Merge branch 'master' into ccnc-port 2025-09-07 13:13:37 -04:00
royjr 86c6cc1f48 Merge branch 'master' into ccnc-port 2025-09-03 22:22:12 -04:00
royjr eb7ffbf093 Update opendbc_repo 2025-09-03 10:14:58 -04:00
royjr 3919095752 Update opendbc_repo 2025-09-03 10:05:35 -04:00
royjr 74d63be1c3 Merge branch 'master' into ccnc-port 2025-09-03 09:50:55 -04:00
royjr 8894486a1a Update opendbc_repo 2025-09-03 09:50:49 -04:00
royjr 810599315d Merge branch 'master' into ccnc-port 2025-08-31 16:53:30 -04:00
royjr 6f3ab810c8 Update opendbc_repo 2025-08-31 16:53:24 -04:00
royjr 230f78b8d3 Merge branch 'master' into ccnc-port 2025-08-26 12:14:46 -04:00
royjr f1affec088 Update opendbc_repo 2025-08-26 12:14:22 -04:00
royjr 97d8ef242c Merge branch 'master' into ccnc-port 2025-08-24 15:12:57 -04:00
royjr a63fff9b45 Update opendbc_repo 2025-08-24 15:12:46 -04:00
royjr cb3893daaa Merge branch 'master' into ccnc-port 2025-08-23 10:33:24 -04:00
royjr 29f60df74b Merge branch 'master' into ccnc-port 2025-08-22 11:18:27 -04:00
royjr c6c072e1f4 Update opendbc_repo 2025-08-22 11:18:18 -04:00
royjr d101cbb83e Update opendbc_repo 2025-08-13 16:00:04 -04:00
royjr 1536d59633 Update opendbc_repo 2025-08-13 15:28:37 -04:00
royjr dc99b865ae Merge branch 'master' into ccnc-port 2025-08-13 12:31:14 -04:00
royjr e59bc027ff Merge branch 'master' into ccnc-port 2025-08-13 11:58:05 -04:00
royjr cf7e5efaca Update opendbc_repo 2025-08-13 11:57:57 -04:00
royjr 4b44f2eb31 Merge branch 'master' into ccnc-port 2025-08-10 09:18:24 -04:00
royjr 107d2ab400 Update opendbc_repo 2025-08-10 09:18:15 -04:00
royjr 5432d9062c Merge branch 'master' into ccnc-port 2025-08-04 11:52:29 -04:00
royjr f533f6c843 Merge branch 'master' into ccnc-port 2025-08-02 06:53:54 -04:00
royjr 58e9ac763c Update opendbc_repo 2025-08-02 06:53:43 -04:00
royjr cb50d54169 Merge branch 'master-new' into ccnc-port 2025-07-24 20:23:46 -04:00
royjr bd5de4ed0a Merge branch 'master-new' into ccnc-port 2025-07-20 23:49:36 -04:00
royjr 0d4073fadb Merge branch 'master-new' into ccnc-port 2025-07-19 23:18:26 -04:00
royjr ebc70dcb52 Merge branch 'master-new' into ccnc-port 2025-07-19 14:37:17 -04:00
royjr 4d0426999e Update opendbc_repo 2025-07-19 14:36:59 -04:00
royjr 286da42573 Merge branch 'master-new' into ccnc-port 2025-07-16 23:48:23 -04:00
royjr 8a836710a9 Update opendbc_repo 2025-07-16 23:48:06 -04:00
royjr 5d515bcf33 Merge branch 'master-new' into ccnc-port 2025-07-07 05:35:55 -04:00
royjr 1c7f6d5133 Update opendbc_repo 2025-07-03 21:24:01 -04:00
royjr 05d57c7aeb Update opendbc_repo 2025-07-01 18:30:32 -04:00
royjr e4b0eaf352 Update opendbc_repo 2025-06-28 19:30:28 -04:00
royjr a710276472 Merge branch 'master-new' into ccnc-port 2025-06-28 19:22:59 -04:00
royjr af086db671 Merge branch 'master-new' into ccnc-port 2025-06-28 12:13:26 -04:00
royjr 0d9eb0e25e Update opendbc_repo submodule to latest commit
Advanced the opendbc_repo submodule to commit d309f7ec96e37267c94d12fc4bfe2672ad505b06. This pulls in the latest changes from the opendbc repository.
2025-06-26 14:03:56 -04:00
royjr 0616caed6d Merge branch 'master-new' into ccnc-port 2025-06-25 19:33:53 -04:00
royjr 095337b3c1 Update opendbc_repo 2025-06-25 19:33:38 -04:00
royjr 1edec2d22c Update opendbc_repo 2025-06-14 16:14:02 -04:00
royjr affabb9ee0 Update opendbc_repo 2025-06-14 15:55:07 -04:00
royjr dc27e8711c Update opendbc_repo 2025-06-14 14:54:32 -04:00
royjr cf7329a264 Merge branch 'master-new' into ccnc-port 2025-06-11 21:36:06 -04:00
royjr 5ee5ecd820 Merge branch 'master-new' into ccnc-port 2025-06-08 23:25:13 -04:00
royjr b064f730dd Update opendbc_repo 2025-06-08 17:54:43 -04:00
22 changed files with 7053 additions and 1 deletions
+3
View File
@@ -12,3 +12,6 @@
selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text 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 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