feat(server): ingest temps réel WS + GUI live + client PC

Serveur FastAPI reçoit le flux JSONL (sim ou ROV réel) sur /ws/ingest,
SLAM incrémental, rediffuse carte+poses sur /ws/live, GUI live et export PLY.
Déployé Docker sur caddy-net, exposé /moulin-live/. Client PC stream_client.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Flag
2026-06-06 20:27:17 +00:00
parent 06e198c7d9
commit 6e83bbd73f
15 changed files with 12675 additions and 0 deletions

487
server/static/index.html Normal file
View File

@@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moulin Mapper — Live</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background: #161b22;
border-bottom: 1px solid #30363d;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
header h1 { font-size: 15px; font-weight: 600; color: #58a6ff; letter-spacing: 1px; }
#conn-dot { width: 10px; height: 10px; border-radius: 50%; background: #f85149; flex-shrink: 0; }
#conn-dot.ok { background: #3fb950; }
#conn-label { font-size: 12px; }
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Panneau gauche: canvas */
#canvas-wrap {
flex: 1;
position: relative;
background: #010409;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
/* Panneau droit: stats + contrôles */
aside {
width: 260px;
background: #161b22;
border-left: 1px solid #30363d;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
flex-shrink: 0;
}
.section {
padding: 12px 14px;
border-bottom: 1px solid #21262d;
}
.section h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #8b949e;
margin-bottom: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
border-bottom: 1px solid #0d1117;
}
.stat-row:last-child { border-bottom: none; }
.stat-label { color: #8b949e; }
.stat-value { color: #f0f6fc; font-weight: 600; }
.stat-value.accent { color: #58a6ff; }
.stat-value.warn { color: #e3b341; }
input[type=text] {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
padding: 5px 8px;
font-family: inherit;
font-size: 12px;
margin-bottom: 8px;
}
input[type=text]:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
}
.btn-danger {
background: #21262d;
border-color: #f85149;
color: #f85149;
}
.btn-danger:hover { background: #3d1c1c; }
.btn-primary {
background: #21262d;
border-color: #58a6ff;
color: #58a6ff;
text-decoration: none;
display: block;
text-align: center;
}
.btn-primary:hover { background: #1c2a3d; }
#log {
font-size: 11px;
color: #8b949e;
max-height: 120px;
overflow-y: auto;
padding: 8px 14px;
flex: 1;
word-break: break-all;
}
#log p { margin-bottom: 2px; line-height: 1.4; }
#log p.err { color: #f85149; }
#log p.ok { color: #3fb950; }
.legend {
display: flex; flex-direction: column; gap: 4px;
font-size: 11px; color: #8b949e;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.leg-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
</style>
</head>
<body>
<header>
<h1>MOULIN MAPPER</h1>
<div id="conn-dot"></div>
<span id="conn-label">Déconnecté</span>
</header>
<div class="main">
<div id="canvas-wrap">
<canvas id="map-canvas"></canvas>
</div>
<aside>
<div class="section">
<h2>Stats</h2>
<div class="stat-row"><span class="stat-label">Enregistrements</span><span class="stat-value accent" id="s-records">0</span></div>
<div class="stat-row"><span class="stat-label">Sweeps</span><span class="stat-value" id="s-sweeps">0</span></div>
<div class="stat-row"><span class="stat-label">Points carte</span><span class="stat-value" id="s-points">0</span></div>
<div class="stat-row"><span class="stat-label">Fermetures boucle</span><span class="stat-value warn" id="s-loops">0</span></div>
<div class="stat-row"><span class="stat-label">Dernier t</span><span class="stat-value" id="s-t"></span></div>
<div class="stat-row"><span class="stat-label">Débit</span><span class="stat-value" id="s-debit">0</span></div>
</div>
<div class="section">
<h2>Pose ROV</h2>
<div class="stat-row"><span class="stat-label">x (m)</span><span class="stat-value" id="s-x">0.00</span></div>
<div class="stat-row"><span class="stat-label">y (m)</span><span class="stat-value" id="s-y">0.00</span></div>
<div class="stat-row"><span class="stat-label">cap (°)</span><span class="stat-value" id="s-h">0.0</span></div>
</div>
<div class="section">
<h2>Légende</h2>
<div class="legend">
<div class="legend-item"><div class="leg-dot" style="background:#58a6ff;"></div><span>Points carte (murs)</span></div>
<div class="legend-item"><div class="leg-dot" style="background:#3fb950;"></div><span>Trajectoire corrigée</span></div>
<div class="legend-item"><div class="leg-dot" style="background:#f0f6fc; border-radius:0;"></div><span>ROV courant</span></div>
</div>
</div>
<div class="section">
<h2>Contrôles</h2>
<input type="text" id="token-input" placeholder="Token (défaut: moulin-2026)" value="moulin-2026">
<button class="btn-danger" onclick="resetSession()">Reset session</button>
<a id="ply-link" class="btn-primary" href="./cloud.ply" download="moulin_cloud.ply">Télécharger nuage .ply</a>
</div>
<div class="section" style="flex:1;padding:0;">
<div id="log"></div>
</div>
</aside>
</div>
<script>
// ---------------------------------------------------------------------------
// Données en mémoire
// ---------------------------------------------------------------------------
let mapPoints = []; // [[x, y], ...]
let trajectory = []; // [[t, x, y, h], ...]
let poseCorrect = [0, 0, 0];
let stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
// ---------------------------------------------------------------------------
// Canvas + viewport
// ---------------------------------------------------------------------------
const canvas = document.getElementById('map-canvas');
const ctx = canvas.getContext('2d');
let viewX = 4.0; // centre monde visible
let viewY = 4.0;
let viewScale = 50; // px/m
let dragging = false;
let dragStart = null;
let dragView = null;
function worldToCanvas(wx, wy) {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
return [
cx + (wx - viewX) * viewScale,
cy - (wy - viewY) * viewScale,
];
}
function resizeCanvas() {
const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth;
canvas.height = wrap.clientHeight;
draw();
}
window.addEventListener('resize', resizeCanvas);
// Zoom molette
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.85 : 1.18;
viewScale = Math.max(5, Math.min(500, viewScale * factor));
draw();
}, { passive: false });
// Pan souris
canvas.addEventListener('mousedown', (e) => {
dragging = true;
dragStart = [e.clientX, e.clientY];
dragView = [viewX, viewY];
});
canvas.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = (e.clientX - dragStart[0]) / viewScale;
const dy = (e.clientY - dragStart[1]) / viewScale;
viewX = dragView[0] - dx;
viewY = dragView[1] + dy;
draw();
});
canvas.addEventListener('mouseup', () => { dragging = false; });
canvas.addEventListener('mouseleave', () => { dragging = false; });
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Grille légère
ctx.strokeStyle = '#1c2128';
ctx.lineWidth = 1;
const gridStep = 1; // 1m
const xMin = viewX - canvas.width / (2 * viewScale) - gridStep;
const xMax = viewX + canvas.width / (2 * viewScale) + gridStep;
const yMin = viewY - canvas.height / (2 * viewScale) - gridStep;
const yMax = viewY + canvas.height / (2 * viewScale) + gridStep;
for (let gx = Math.floor(xMin); gx <= xMax; gx += gridStep) {
ctx.beginPath();
const [px, ] = worldToCanvas(gx, 0);
ctx.moveTo(px, 0); ctx.lineTo(px, canvas.height);
ctx.stroke();
}
for (let gy = Math.floor(yMin); gy <= yMax; gy += gridStep) {
ctx.beginPath();
const [, py] = worldToCanvas(0, gy);
ctx.moveTo(0, py); ctx.lineTo(canvas.width, py);
ctx.stroke();
}
// Axes origine
ctx.strokeStyle = '#21262d';
ctx.lineWidth = 1;
const [ox, oy] = worldToCanvas(0, 0);
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, canvas.height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(canvas.width, oy); ctx.stroke();
// Points carte (murs)
const ptRadius = Math.max(1, viewScale * 0.04);
ctx.fillStyle = 'rgba(88, 166, 255, 0.7)';
for (const [wx, wy] of mapPoints) {
const [px, py] = worldToCanvas(wx, wy);
ctx.beginPath();
ctx.arc(px, py, ptRadius, 0, 2 * Math.PI);
ctx.fill();
}
// Trajectoire
if (trajectory.length > 1) {
ctx.strokeStyle = 'rgba(63, 185, 80, 0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
const [x0, y0] = worldToCanvas(trajectory[0][1], trajectory[0][2]);
ctx.moveTo(x0, y0);
for (let i = 1; i < trajectory.length; i++) {
const [px, py] = worldToCanvas(trajectory[i][1], trajectory[i][2]);
ctx.lineTo(px, py);
}
ctx.stroke();
}
// ROV courant
const [rx, ry] = worldToCanvas(poseCorrect[0], poseCorrect[1]);
const hRad = (90 - poseCorrect[2]) * Math.PI / 180; // cap → angle canvas
const arrowLen = Math.max(8, viewScale * 0.3);
ctx.save();
ctx.translate(rx, ry);
ctx.rotate(-hRad);
ctx.fillStyle = '#f0f6fc';
ctx.strokeStyle = '#f0f6fc';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -arrowLen);
ctx.lineTo(arrowLen * 0.5, arrowLen * 0.5);
ctx.lineTo(0, 0);
ctx.lineTo(-arrowLen * 0.5, arrowLen * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// ---------------------------------------------------------------------------
// Stats UI
// ---------------------------------------------------------------------------
function updateStats(data) {
if (data.records !== undefined) {
stats.records = data.records;
stats.sweeps = data.sweeps;
stats.loop_closures = data.loop_closures;
stats.last_t = data.last_t;
stats.debit = data.debit || 0;
}
document.getElementById('s-records').textContent = stats.records;
document.getElementById('s-sweeps').textContent = stats.sweeps;
document.getElementById('s-loops').textContent = stats.loop_closures;
document.getElementById('s-t').textContent = stats.last_t ? stats.last_t.toFixed(1) + 's' : '—';
document.getElementById('s-debit').textContent = stats.debit.toFixed(1) + ' rec/s';
document.getElementById('s-points').textContent = mapPoints.length;
if (data.pose_corrected) {
poseCorrect = data.pose_corrected;
document.getElementById('s-x').textContent = poseCorrect[0].toFixed(2);
document.getElementById('s-y').textContent = poseCorrect[1].toFixed(2);
document.getElementById('s-h').textContent = poseCorrect[2].toFixed(1);
}
}
// ---------------------------------------------------------------------------
// Gestion message WS
// ---------------------------------------------------------------------------
function handleMessage(data) {
if (data.type === 'snapshot') {
// État complet
mapPoints = data.map_points || [];
trajectory = data.trajectory || [];
poseCorrect = data.pose_corrected || [0, 0, 0];
updateStats(data);
draw();
log('Snapshot reçu — ' + mapPoints.length + ' pts, ' + trajectory.length + ' poses', 'ok');
} else if (data.type === 'delta') {
// Nouveaux points
const newPts = data.new_map_points || [];
for (const pt of newPts) mapPoints.push(pt);
// Trajectoire : ajout des dernières poses
const tail = data.trajectory_tail || [];
for (const pose of tail) {
// Évite les doublons par t
if (trajectory.length === 0 || trajectory[trajectory.length - 1][0] < pose[0]) {
trajectory.push(pose);
}
}
updateStats(data);
draw();
} else if (data.type === 'reset') {
mapPoints = [];
trajectory = [];
poseCorrect = [0, 0, 0];
stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
updateStats({});
draw();
log('Session réinitialisée', 'ok');
} else if (data.type === 'ping') {
// keepalive silencieux
}
}
// ---------------------------------------------------------------------------
// Connexion WS /ws/live
// ---------------------------------------------------------------------------
let ws = null;
let reconnectTimer = null;
function wsConnect() {
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + base + '/ws/live';
log('Connexion ' + url + '…');
ws = new WebSocket(url);
ws.onopen = () => {
document.getElementById('conn-dot').classList.add('ok');
document.getElementById('conn-label').textContent = 'Connecté';
log('WS ouvert', 'ok');
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
};
ws.onmessage = (e) => {
try {
handleMessage(JSON.parse(e.data));
} catch (err) {
console.error('parse WS:', err);
}
};
ws.onclose = () => {
document.getElementById('conn-dot').classList.remove('ok');
document.getElementById('conn-label').textContent = 'Déconnecté — reconnexion dans 3s…';
log('WS fermé, tentative dans 3s…', 'err');
reconnectTimer = setTimeout(wsConnect, 3000);
};
ws.onerror = (e) => {
log('Erreur WS', 'err');
};
}
// ---------------------------------------------------------------------------
// Reset session
// ---------------------------------------------------------------------------
async function resetSession() {
const token = document.getElementById('token-input').value.trim() || 'moulin-2026';
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
try {
const form = new FormData();
form.append('token', token);
const resp = await fetch(base + '/session/reset', { method: 'POST', body: form });
if (resp.ok) {
log('Reset OK', 'ok');
} else {
log('Reset KO: ' + resp.status, 'err');
}
} catch (e) {
log('Reset erreur: ' + e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// Bouton PLY — chemin relatif
// ---------------------------------------------------------------------------
(function() {
const base = location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '');
document.getElementById('ply-link').href = base + '/cloud.ply';
})();
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
function log(msg, cls) {
const el = document.getElementById('log');
const p = document.createElement('p');
if (cls) p.className = cls;
const now = new Date();
p.textContent = now.toTimeString().slice(0, 8) + ' ' + msg;
el.appendChild(p);
el.scrollTop = el.scrollHeight;
// Garde les 100 dernières lignes
while (el.children.length > 100) el.removeChild(el.firstChild);
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
resizeCanvas();
wsConnect();
</script>
</body>
</html>