feat: routes /map /nav — Leaflet GPS/USBL + Chart.js depth viewer

This commit is contained in:
Ubuntu
2026-04-25 16:02:01 +00:00
parent 8ef1c3d8a6
commit 276c850893
5 changed files with 253 additions and 0 deletions

View File

@@ -162,5 +162,76 @@ def _main():
_PLY_PATH = args.ply _PLY_PATH = args.ply
app.run(host="0.0.0.0", port=args.port, debug=False) app.run(host="0.0.0.0", port=args.port, debug=False)
# ── COSMA QC Platform additions ──────────────────────────
import os
from pathlib import Path
_DATA_DIR = Path(os.environ.get("COSMA_DATA_DIR", "/data/cosma"))
def _load_nav_data(job_id: int) -> dict:
out = {"job_id": job_id}
poses_path = _DATA_DIR / f"job_{job_id}_poses.npz"
ply_path = _DATA_DIR / f"job_{job_id}_decimated.ply"
if poses_path.exists():
d = np.load(str(poses_path))
poses = d["poses"] # (N, 3, 4)
xyz = poses[:, :3, 3]
out["track"] = {
"x": xyz[:, 0].tolist(),
"y": xyz[:, 1].tolist(),
"z": xyz[:, 2].tolist(),
}
out["n_poses"] = int(len(poses))
out["ply_ready"] = ply_path.exists()
out["ply_path"] = str(ply_path) if ply_path.exists() else None
return out
@app.route("/map")
def map_view():
from flask import render_template
return render_template("map.html")
@app.route("/nav")
def nav_view():
from flask import render_template
return render_template("nav.html")
@app.route("/api/jobs")
def api_jobs():
jobs = []
for p in sorted(_DATA_DIR.glob("job_*_decimated.ply")):
parts = p.stem.split("_")
jid = int(parts[1])
jobs.append({
"id": jid,
"ply": str(p),
"poses": str(_DATA_DIR / f"job_{jid}_poses.npz"),
})
return jsonify(jobs)
@app.route("/api/job/<int:job_id>/nav")
def api_job_nav(job_id: int):
return jsonify(_load_nav_data(job_id))
@app.route("/api/job/<int:job_id>/ply")
def api_job_ply(job_id: int):
ply_path = _DATA_DIR / f"job_{job_id}_decimated.ply"
if not ply_path.exists():
return jsonify({"error": "PLY non disponible"}), 404
try:
import open3d as o3d
pcd = o3d.io.read_point_cloud(str(ply_path))
pts = np.asarray(pcd.points)
return jsonify({"x": pts[:, 0].tolist(), "y": pts[:, 1].tolist(),
"z": pts[:, 2].tolist(), "n": int(len(pts))})
except ImportError:
return jsonify({"error": "open3d not installed"}), 503
if __name__ == "__main__": if __name__ == "__main__":
_main() _main()

50
viz/static/js/map.js Normal file
View File

@@ -0,0 +1,50 @@
const map = L.map('map').setView([43.17, 5.70], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OSM contributors', maxZoom: 19
}).addTo(map);
const status = document.getElementById('status');
let trackLayer = null;
async function loadJobs() {
const res = await fetch('/nav/api/jobs');
const jobs = await res.json();
const sel = document.getElementById('job-select');
jobs.forEach(j => {
const opt = document.createElement('option');
opt.value = j.id;
opt.textContent = 'job_' + j.id;
sel.appendChild(opt);
});
const urlJob = new URLSearchParams(window.location.search).get('job');
const target = urlJob ? parseInt(urlJob) : (jobs.length > 0 ? jobs[0].id : null);
if (target) {
sel.value = target;
loadJob(target);
} else {
status.textContent = 'Aucun job disponible';
}
}
async function loadJob(jobId) {
status.textContent = 'Chargement job_' + jobId + '...';
const res = await fetch('/nav/api/job/' + jobId + '/nav');
const d = await res.json();
if (trackLayer) map.removeLayer(trackLayer);
if (!d.track || !d.track.x.length) {
status.textContent = 'Pas de données track pour job_' + jobId;
return;
}
const cx = d.track.x.reduce((a,b)=>a+b,0)/d.track.x.length;
const cy = d.track.y.reduce((a,b)=>a+b,0)/d.track.y.length;
const pts = d.track.x.map((x,i) => [43.17 + (d.track.y[i]-cy)*0.000009, 5.70 + (x-cx)*0.000009]);
trackLayer = L.polyline(pts, { color: '#4ade80', weight: 2 }).addTo(map);
map.fitBounds(trackLayer.getBounds());
status.textContent = 'job_' + jobId + ' — ' + d.n_poses + ' poses | PLY: ' + (d.ply_ready ? 'prêt' : 'non dispo');
}
document.getElementById('job-select').addEventListener('change', e => {
if (e.target.value) loadJob(parseInt(e.target.value));
});
loadJobs();

View File

@@ -0,0 +1,60 @@
let chartXY = null, chartZ = null;
function initCharts() {
const base = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#888' } }, title: { display: true, color: '#ccc' } },
scales: {
x: { ticks: { color: '#666' }, grid: { color: '#1a2a1a' } },
y: { ticks: { color: '#666' }, grid: { color: '#1a2a1a' } }
}
};
chartXY = new Chart(document.getElementById('chart-xy'), {
type: 'scatter',
data: { datasets: [{ label: 'Track XY (m)', data: [], borderColor: '#4ade80',
backgroundColor: 'rgba(74,222,128,0.3)', pointRadius: 1 }] },
options: { ...base, plugins: { ...base.plugins,
title: { display: true, text: 'Track XY lingbot (m, local)', color: '#ccc' } } }
});
chartZ = new Chart(document.getElementById('chart-z'), {
type: 'line',
data: { datasets: [{ label: 'Z camera (m)', data: [], borderColor: '#60a5fa',
backgroundColor: 'transparent', pointRadius: 0, borderWidth: 1.5 }] },
options: { ...base,
plugins: { ...base.plugins,
title: { display: true, text: 'Z camera / profondeur approx (m)', color: '#ccc' } },
scales: { ...base.scales, y: { ...base.scales.y, reverse: true } } }
});
}
async function loadJobs() {
const res = await fetch('/nav/api/jobs');
const jobs = await res.json();
const sel = document.getElementById('job-select');
jobs.forEach(j => {
const opt = document.createElement('option');
opt.value = j.id;
opt.textContent = 'job_' + j.id;
sel.appendChild(opt);
});
const urlJob = new URLSearchParams(window.location.search).get('job');
const target = urlJob ? parseInt(urlJob) : (jobs.length > 0 ? jobs[0].id : null);
if (target) { sel.value = target; loadJob(target); }
}
async function loadJob(jobId) {
const res = await fetch('/nav/api/job/' + jobId + '/nav');
const d = await res.json();
if (!d.track) return;
chartXY.data.datasets[0].data = d.track.x.map((x,i) => ({ x, y: d.track.y[i] }));
chartXY.update();
chartZ.data.datasets[0].data = d.track.z.map((z,i) => ({ x: i, y: z }));
chartZ.update();
}
document.getElementById('job-select').addEventListener('change', e => {
if (e.target.value) loadJob(parseInt(e.target.value));
});
initCharts();
loadJobs();

36
viz/templates/map.html Normal file
View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>COSMA NAV — Carte</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">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d1f0d; color: #ccc; font-family: monospace; }
header { padding: 8px 16px; background: #0a140a; border-bottom: 1px solid #4ade80;
display: flex; align-items: center; gap: 16px; height: 44px; }
header h1 { color: #4ade80; font-size: 1rem; }
header a { color: #60a5fa; font-size: 0.85rem; text-decoration: none; }
header a:hover { color: #93c5fd; }
select { background: #1a2a1a; color: #ccc; border: 1px solid #4ade80;
border-radius: 3px; padding: 2px 8px; font-family: monospace; }
#map { height: calc(100vh - 44px); }
#status { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.7); color: #4ade80; padding: 4px 12px;
border-radius: 4px; font-size: 0.8rem; z-index: 1000; }
</style>
</head>
<body>
<header>
<h1>COSMA NAV — Carte GPS</h1>
<select id="job-select"><option value="">-- job --</option></select>
<a href="/trajectory">3D &#8599;</a>
<a href="/nav">Graphes &#8599;</a>
</header>
<div id="map"></div>
<div id="status">Chargement...</div>
<script src="/static/js/map.js"></script>
</body>
</html>

36
viz/templates/nav.html Normal file
View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>COSMA NAV — Données nav</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d1f0d; color: #ccc; font-family: monospace; }
header { padding: 8px 16px; background: #0a140a; border-bottom: 1px solid #4ade80;
display: flex; align-items: center; gap: 16px; height: 44px; }
header h1 { color: #4ade80; font-size: 1rem; }
header a { color: #60a5fa; font-size: 0.85rem; text-decoration: none; }
select { background: #1a2a1a; color: #ccc; border: 1px solid #4ade80;
border-radius: 3px; padding: 2px 8px; font-family: monospace; }
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px;
height: calc(100vh - 60px); }
.chart-box { background: #0a1a0a; border-radius: 6px; border: 1px solid #1f3a1f; padding: 8px; }
canvas { max-height: 100%; }
</style>
</head>
<body>
<header>
<h1>COSMA NAV — Navigation</h1>
<select id="job-select"><option value="">-- job --</option></select>
<a href="/map">Carte &#8599;</a>
<a href="/trajectory">3D &#8599;</a>
</header>
<div class="charts">
<div class="chart-box"><canvas id="chart-xy"></canvas></div>
<div class="chart-box"><canvas id="chart-z"></canvas></div>
</div>
<script src="/static/js/nav_charts.js"></script>
</body>
</html>