From 3198164affda1c1c2441a038f07f013261fee9e6 Mon Sep 17 00:00:00 2001 From: Poulpe Date: Sat, 25 Apr 2026 21:40:05 +0000 Subject: [PATCH] feat(viewer): v3 AUV track + USBL vector overlay --- tools/usbl_to_json.py | 125 +++++++++++++++ viewer/index.html | 352 ++++++++++++++++++++++++++++++------------ 2 files changed, 376 insertions(+), 101 deletions(-) create mode 100644 tools/usbl_to_json.py diff --git a/tools/usbl_to_json.py b/tools/usbl_to_json.py new file mode 100644 index 0000000..a3ed7af --- /dev/null +++ b/tools/usbl_to_json.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""usbl_to_json.py - Convert combined_nav_usbl.csv to usbl.json + auv_track.geojson""" +import csv, json, math, argparse, statistics +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_INPUT = ROOT / "output" / "combined_nav_usbl.csv" +OUTPUT_DIR = ROOT / "output" + +def parse_ts(s): + for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): + try: + dt = datetime.strptime(s.strip(), fmt).replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except ValueError: + pass + return None + +def haversine_m(lat1, lon1, lat2, lon2): + R = 6371000.0 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input", default=str(DEFAULT_INPUT)) + ap.add_argument("--max-points", type=int, default=10000) + args = ap.parse_args() + + rows = [] + with open(args.input, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + try: + dist = float(row["Dist"]) + auv_lat = float(row["auv_lat"]) + auv_lon = float(row["auv_lon"]) + except (ValueError, KeyError): + continue + if dist <= 0 or auv_lat == 0 or auv_lon == 0: + continue + t_ms = parse_ts(row["Timestamp"]) + if t_ms is None: + continue + rows.append({ + "t": row["Timestamp"].strip(), + "t_ms": t_ms, + "usv_lat": float(row["lat"]), + "usv_lon": float(row["lon"]), + "heading": float(row["Heading"]), + "dist": dist, + "az": float(row["Azimuth"]), + "elev": float(row["Elev"]), + "snr": float(row["SNR"]), + "auv_lat": auv_lat, + "auv_lon": auv_lon, + }) + + rows.sort(key=lambda r: r["t_ms"]) + n_raw = len(rows) + + # Sample if > max-points (preserve begin/end) + if n_raw > args.max_points: + step = n_raw / args.max_points + indices = set() + indices.add(0) + indices.add(n_raw - 1) + for i in range(1, args.max_points - 1): + indices.add(int(i * step)) + rows = [rows[i] for i in sorted(indices)] + + n = len(rows) + dists = [r["dist"] for r in rows] + snrs = [r["snr"] for r in rows] + auv_lats = [r["auv_lat"] for r in rows] + auv_lons = [r["auv_lon"] for r in rows] + + auv_bbox = [min(auv_lons), min(auv_lats), max(auv_lons), max(auv_lats)] + + out = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "n_points": n, + "n_raw": n_raw, + "auv_bbox": auv_bbox, + "stats": { + "dist_min": round(min(dists), 3), + "dist_max": round(max(dists), 3), + "dist_median": round(statistics.median(dists), 3), + "snr_min": round(min(snrs), 4), + "snr_max": round(max(snrs), 4), + }, + "points": rows, + } + + usbl_out = OUTPUT_DIR / "usbl.json" + with open(usbl_out, "w") as f: + json.dump(out, f, separators=(",", ":")) + print(f"usbl.json: {n} points (raw={n_raw}) -> {usbl_out}") + + # AUV track GeoJSON + coords = [[r["auv_lon"], r["auv_lat"]] for r in rows] + geojson = { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": {"name": "AUV track (USBL projection)", "color": "#ff8800"}, + }] + } + track_out = OUTPUT_DIR / "auv_track.geojson" + with open(track_out, "w") as f: + json.dump(geojson, f, separators=(",", ":")) + print(f"auv_track.geojson: {len(coords)} coords -> {track_out}") + + # Sanity check: first point haversine vs USBL dist + r0 = rows[0] + hav = haversine_m(r0["usv_lat"], r0["usv_lon"], r0["auv_lat"], r0["auv_lon"]) + print(f"Sanity [0]: USV=({r0['usv_lat']:.6f},{r0['usv_lon']:.6f}) AUV=({r0['auv_lat']:.6f},{r0['auv_lon']:.6f}) hav={hav:.2f}m USBL_dist={r0['dist']:.2f}m diff={abs(hav-r0['dist']):.3f}m") + +if __name__ == "__main__": + main() diff --git a/viewer/index.html b/viewer/index.html index 70f59ba..d5c7201 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -2,7 +2,7 @@ -COSMA — USV Multi-Track Viewer v2 +COSMA — NAV Viewer v3 @@ -33,28 +33,14 @@ border-radius: 2px; } #btn-reset:hover { background: #00b4d8; color: #1a1a2e; } - - /* Range slider row */ - #range-row { - display: flex; - align-items: center; - gap: 10px; - } + #range-row { display: flex; align-items: center; gap: 10px; } #range-label { font-size: 10px; color: #666; white-space: nowrap; } #range-slider-wrap { flex: 1; padding: 4px 0; } #range-dates { display: flex; justify-content: space-between; font-size: 10px; color: #a0c4ff; } - - /* Cursor slider row */ - #cursor-row { - display: flex; - align-items: center; - gap: 10px; - } + #cursor-row { display: flex; align-items: center; gap: 10px; } #cursor-label { font-size: 10px; color: #666; white-space: nowrap; } #cursor-slider-wrap { flex: 1; } #cursor-info { font-size: 11px; color: #e0e0e0; min-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - - /* noUiSlider overrides */ .noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; } .noUi-horizontal { height: 6px; } .noUi-connect { background: #00b4d8; } @@ -67,72 +53,108 @@ .noUi-handle::before, .noUi-handle::after { display: none; } #cursor-slider .noUi-connect { background: #e94560; } #cursor-slider .noUi-handle { background: #e94560; } - - /* Legend */ #legend { - position: absolute; bottom: 200px; left: 10px; z-index: 1000; + position: absolute; bottom: 210px; left: 10px; z-index: 1000; background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460; font-size: 11px; font-family: monospace; - display: none; } .legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; } - .legend-dot { width: 12px; height: 4px; border-radius: 2px; flex-shrink: 0; } + .legend-dot { width: 14px; height: 4px; border-radius: 2px; flex-shrink: 0; } + .legend-dashed { border-top: 2px dashed #888; width: 14px; flex-shrink: 0; } + /* USBL stats panel */ + #usbl-panel { + position: absolute; top: 10px; left: 50px; z-index: 1000; + background: rgba(22,33,62,0.88); padding: 8px 12px; border: 1px solid #0f3460; + font-size: 11px; font-family: monospace; min-width: 180px; + display: none; + } + #usbl-panel .uprow { display: flex; justify-content: space-between; gap: 16px; margin: 1px 0; } + #usbl-panel .uplabel { color: #666; } + #usbl-panel .upval { color: #ff8800; font-weight: bold; } + #usbl-panel .badge { + display: inline-block; background: #ff8800; color: #1a1a2e; + font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px; + } + /* Layer toggles */ + #layer-toggles { + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; + } + .layer-btn { + font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer; + border-radius: 2px; border: 1px solid; background: transparent; + } + .layer-btn.active { opacity: 1; } + .layer-btn.inactive { opacity: 0.35; } + #btn-usv { color: #00b4d8; border-color: #00b4d8; } + #btn-auv { color: #ff8800; border-color: #ff8800; } + #btn-vec { color: #888; border-color: #888; } + #btn-usbl-panel { color: #aaa; border-color: #444; }
+
+
Dist
+
Az (rel)
+
Elev
+
SNR
+
REL HEADING
+
- USV Multi-Track v2 - Chargement… + COSMA NAV v3 + Chargement... +
+ + + + +
- Fenêtre + Fenetre
- - + - + -
Curseur
-
+
-