feat(cosma-nav-tools): v1 parser + Leaflet viewer USV La Ciotat 2026-04-08
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
data/
|
||||||
|
output/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
10
README.md
Normal file
10
README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# cosma-nav-tools
|
||||||
|
|
||||||
|
Visualisation trajectoire USV — mission La Ciotat, 8 avril 2026.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
python3 tools/parse_usv_nav.py --input data/<file>.csv --output output/
|
||||||
|
cd viewer && python3 -m http.server 8765
|
||||||
|
|
||||||
|
Viewer: http://localhost:8765
|
||||||
162
tools/parse_usv_nav.py
Normal file
162
tools/parse_usv_nav.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse USV long-format CSV → track.geojson + points.json"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
MAX_SLIDER_POINTS = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="Parse USV nav CSV")
|
||||||
|
p.add_argument("--input", required=True, help="CSV navigation log")
|
||||||
|
p.add_argument("--output", required=True, help="Output directory")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv(path):
|
||||||
|
"""Load long-format CSV into {timestamp: {field: value}}"""
|
||||||
|
rows_by_ts = defaultdict(dict)
|
||||||
|
with open(path, newline="", encoding="utf-8") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
ts = row["timestamp"]
|
||||||
|
field = row["data"]
|
||||||
|
val = row["value"]
|
||||||
|
rows_by_ts[ts][field] = val
|
||||||
|
return rows_by_ts
|
||||||
|
|
||||||
|
|
||||||
|
def get_float(d, *keys):
|
||||||
|
for k in keys:
|
||||||
|
v = d.get(k)
|
||||||
|
if v is not None:
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_points(rows_by_ts):
|
||||||
|
"""Build sorted list of {t, lat, lon, heading} where lat/lon valid."""
|
||||||
|
# We need to track last known lat/lon/heading per timestamp cluster.
|
||||||
|
# Strategy: walk timestamps in order, emit a point each time we see a Lat or Lon update.
|
||||||
|
# Accumulate state across timestamps.
|
||||||
|
timestamps = sorted(rows_by_ts.keys())
|
||||||
|
|
||||||
|
state = {}
|
||||||
|
points = []
|
||||||
|
|
||||||
|
for ts in timestamps:
|
||||||
|
updates = rows_by_ts[ts]
|
||||||
|
state.update(updates)
|
||||||
|
|
||||||
|
# Only emit point if we have both Lat and Lon from this or earlier ts
|
||||||
|
lat = get_float(state, "Lat", "RAW_Lat")
|
||||||
|
lon = get_float(state, "Lon", "RAW_Lon")
|
||||||
|
heading = get_float(state, "Heading", "Yaw")
|
||||||
|
|
||||||
|
if lat is None or lon is None:
|
||||||
|
continue
|
||||||
|
if lat == 0.0 and lon == 0.0:
|
||||||
|
continue
|
||||||
|
# GPS_RAW_INT fallback (1e-7 degrees)
|
||||||
|
if abs(lat) < 1 and abs(lon) < 1:
|
||||||
|
raw_lat = get_float(state, "GPS_RAW_INT_lat")
|
||||||
|
raw_lon = get_float(state, "GPS_RAW_INT_lon")
|
||||||
|
if raw_lat and raw_lon:
|
||||||
|
lat = raw_lat / 1e7
|
||||||
|
lon = raw_lon / 1e7
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Only emit if Lat or Lon just updated (reduce duplicate consecutive points)
|
||||||
|
if "Lat" in updates or "Lon" in updates or "RAW_Lat" in updates or "RAW_Lon" in updates:
|
||||||
|
points.append({
|
||||||
|
"t": ts,
|
||||||
|
"lat": round(lat, 8),
|
||||||
|
"lon": round(lon, 8),
|
||||||
|
"heading": round(heading, 2) if heading is not None else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def sample_points(points, max_n):
|
||||||
|
if len(points) <= max_n:
|
||||||
|
return points
|
||||||
|
step = len(points) / max_n
|
||||||
|
return [points[int(i * step)] for i in range(max_n)]
|
||||||
|
|
||||||
|
|
||||||
|
def write_geojson(points, path):
|
||||||
|
coords = [[p["lon"], p["lat"]] for p in points]
|
||||||
|
geojson = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {"type": "LineString", "coordinates": coords},
|
||||||
|
"properties": {
|
||||||
|
"start": points[0]["t"] if points else None,
|
||||||
|
"end": points[-1]["t"] if points else None,
|
||||||
|
"n_points": len(points),
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(geojson, f)
|
||||||
|
print(f" track.geojson: {len(coords)} coords → {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_points_json(points, path):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(points, f)
|
||||||
|
print(f" points.json: {len(points)} points → {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_stats(points):
|
||||||
|
if not points:
|
||||||
|
print("No valid points found!")
|
||||||
|
return
|
||||||
|
lats = [p["lat"] for p in points]
|
||||||
|
lons = [p["lon"] for p in points]
|
||||||
|
print(f"\n=== Stats ===")
|
||||||
|
print(f" N points (full): {len(points)}")
|
||||||
|
print(f" First ts: {points[0]['t']}")
|
||||||
|
print(f" Last ts: {points[-1]['t']}")
|
||||||
|
print(f" Bbox lat: {min(lats):.6f} → {max(lats):.6f}")
|
||||||
|
print(f" Bbox lon: {min(lons):.6f} → {max(lons):.6f}")
|
||||||
|
headings = [p["heading"] for p in points if p["heading"] is not None]
|
||||||
|
print(f" Heading data: {'yes' if headings else 'no'} ({len(headings)} values)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
os.makedirs(args.output, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Loading {args.input} ...")
|
||||||
|
rows = load_csv(args.input)
|
||||||
|
print(f" {len(rows)} unique timestamps")
|
||||||
|
|
||||||
|
points = build_points(rows)
|
||||||
|
print_stats(points)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
write_geojson(points, os.path.join(args.output, "track.geojson"))
|
||||||
|
|
||||||
|
sampled = sample_points(points, MAX_SLIDER_POINTS)
|
||||||
|
if len(sampled) < len(points):
|
||||||
|
print(f" Sampled {len(sampled)} points for slider (from {len(points)})")
|
||||||
|
write_points_json(sampled, os.path.join(args.output, "points.json"))
|
||||||
|
|
||||||
|
print("\nDone.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
142
viewer/index.html
Normal file
142
viewer/index.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>COSMA — USV Track Viewer</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
#map { flex: 1; }
|
||||||
|
#controls {
|
||||||
|
background: #16213e;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
#slider { flex: 1; cursor: pointer; }
|
||||||
|
#info { font-size: 11px; min-width: 340px; color: #a0c4ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
<div id="controls">
|
||||||
|
<span id="title">USV Track</span>
|
||||||
|
<input type="range" id="slider" min="0" value="0">
|
||||||
|
<div id="info">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Map init ──────────────────────────────────────────────────────────────
|
||||||
|
const map = L.map('map', { zoomControl: true });
|
||||||
|
|
||||||
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
});
|
||||||
|
osm.addTo(map);
|
||||||
|
|
||||||
|
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenSeaMap',
|
||||||
|
maxZoom: 18,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
// GEBCO bathymetry (tile-based, simpler than WMS)
|
||||||
|
const gebco = L.tileLayer(
|
||||||
|
'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
{
|
||||||
|
attribution: 'GEBCO / NCEI',
|
||||||
|
maxZoom: 13,
|
||||||
|
opacity: 0.6,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
L.control.layers(
|
||||||
|
{ 'OpenStreetMap': osm },
|
||||||
|
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymétrie)': gebco },
|
||||||
|
{ collapsed: false }
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
// ── SVG arrow marker factory ──────────────────────────────────────────────
|
||||||
|
function makeArrowIcon(heading) {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32">
|
||||||
|
<g transform="rotate(${heading || 0})">
|
||||||
|
<polygon points="0,-12 6,8 0,4 -6,8" fill="#e94560" stroke="#fff" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
return L.divIcon({
|
||||||
|
html: svg,
|
||||||
|
className: '',
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────────────────
|
||||||
|
let points = [];
|
||||||
|
let trackLayer = null;
|
||||||
|
let marker = null;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [trackResp, pointsResp] = await Promise.all([
|
||||||
|
fetch('track.geojson'),
|
||||||
|
fetch('points.json'),
|
||||||
|
]);
|
||||||
|
if (!trackResp.ok) throw new Error('track.geojson not found');
|
||||||
|
if (!pointsResp.ok) throw new Error('points.json not found');
|
||||||
|
|
||||||
|
const trackGeo = await trackResp.json();
|
||||||
|
points = await pointsResp.json();
|
||||||
|
|
||||||
|
// Draw track
|
||||||
|
trackLayer = L.geoJSON(trackGeo, {
|
||||||
|
style: { color: '#00b4d8', weight: 2, opacity: 0.8 },
|
||||||
|
}).addTo(map);
|
||||||
|
map.fitBounds(trackLayer.getBounds(), { padding: [40, 40] });
|
||||||
|
|
||||||
|
// Init slider
|
||||||
|
const slider = document.getElementById('slider');
|
||||||
|
slider.max = points.length - 1;
|
||||||
|
slider.value = 0;
|
||||||
|
slider.addEventListener('input', () => updateMarker(+slider.value));
|
||||||
|
|
||||||
|
// Init marker
|
||||||
|
if (points.length > 0) {
|
||||||
|
const p = points[0];
|
||||||
|
marker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0) }).addTo(map);
|
||||||
|
updateInfo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('title').textContent = `USV Track — ${points.length} pts`;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('info').textContent = 'Erreur: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarker(idx) {
|
||||||
|
const p = points[idx];
|
||||||
|
if (!p || !marker) return;
|
||||||
|
marker.setLatLng([p.lat, p.lon]);
|
||||||
|
marker.setIcon(makeArrowIcon(p.heading || 0));
|
||||||
|
updateInfo(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInfo(idx) {
|
||||||
|
const p = points[idx];
|
||||||
|
if (!p) return;
|
||||||
|
const hdg = p.heading !== null ? `${p.heading.toFixed(1)}°` : 'N/A';
|
||||||
|
document.getElementById('info').textContent =
|
||||||
|
`[${idx+1}/${points.length}] ${p.t} | Lat: ${p.lat.toFixed(6)} Lon: ${p.lon.toFixed(6)} | Cap: ${hdg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user