feat(gui): extraction murs RANSAC + dimensions pièce en direct

GUI live : détecte les segments de mur dans le nuage Ping360 (RANSAC 2D),
trace les murs cotés, estime emprise/surface/longueur murs, toggle vue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Flag
2026-06-07 08:31:12 +00:00
parent aad91d5fc5
commit 59f498f9f3

View File

@@ -160,6 +160,14 @@
<div class="stat-row"><span class="stat-label">Débit</span><span class="stat-value" id="s-debit">0</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>
<div class="section">
<h2>Géométrie pièce</h2>
<div class="stat-row"><span class="stat-label">Emprise (L×l)</span><span class="stat-value accent" id="s-bbox"></span></div>
<div class="stat-row"><span class="stat-label">Surface au sol</span><span class="stat-value accent" id="s-area"></span></div>
<div class="stat-row"><span class="stat-label">Murs détectés</span><span class="stat-value" id="s-walls">0</span></div>
<div class="stat-row"><span class="stat-label">Long. totale murs</span><span class="stat-value" id="s-wlen"></span></div>
</div>
<div class="section"> <div class="section">
<h2>Pose ROV</h2> <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">x (m)</span><span class="stat-value" id="s-x">0.00</span></div>
@@ -170,12 +178,18 @@
<div class="section"> <div class="section">
<h2>Légende</h2> <h2>Légende</h2>
<div class="legend"> <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:#9ba3ad;"></div><span>Nuage brut (sonar)</span></div>
<div class="legend-item"><div class="leg-dot" style="background:#1f6feb; border-radius:0; width:14px; height:4px;"></div><span>Murs détectés</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:#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 class="legend-item"><div class="leg-dot" style="background:#f0f6fc; border-radius:0;"></div><span>ROV courant</span></div>
</div> </div>
</div> </div>
<div class="section">
<h2>Affichage</h2>
<button class="btn-primary" id="view-toggle" onclick="cycleViewMode()">Vue : Les deux</button>
</div>
<div class="section"> <div class="section">
<h2>Contrôles</h2> <h2>Contrôles</h2>
<input type="text" id="token-input" placeholder="Token (défaut: moulin-2026)" value="moulin-2026"> <input type="text" id="token-input" placeholder="Token (défaut: moulin-2026)" value="moulin-2026">
@@ -198,6 +212,198 @@ let trajectory = []; // [[t, x, y, h], ...]
let poseCorrect = [0, 0, 0]; let poseCorrect = [0, 0, 0];
let stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 }; let stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
// Résultats géométrie (murs + métriques)
let walls = []; // [{x1,y1,x2,y2,length,inliers}, ...]
let geom = { bboxW: 0, bboxH: 0, area: 0, wallTotal: 0 };
// Mode affichage : 'both' | 'walls' | 'points'
let viewMode = 'both';
// Throttle recalcul murs
let lastWallCompute = 0;
let lastWallPointCount = 0;
const WALL_RECOMPUTE_MS = 500; // au max toutes les 500 ms
const WALL_RECOMPUTE_DELTA = 30; // ou dès +30 nouveaux points
// ---------------------------------------------------------------------------
// Géométrie : extraction de murs (RANSAC de droites) — JS vanilla
// ---------------------------------------------------------------------------
// Exposé sur window.MoulinGeom pour pouvoir être testé hors-ligne (Node).
const MoulinGeom = (function () {
// RANSAC itératif : extrait des segments de mur depuis un nuage [[x,y],...]
function extractWalls(points, opts) {
opts = opts || {};
const distThresh = opts.distThresh != null ? opts.distThresh : 0.10; // m
const maxWalls = opts.maxWalls != null ? opts.maxWalls : 10;
const minInliers = opts.minInliers != null ? opts.minInliers : 15;
const minLength = opts.minLength != null ? opts.minLength : 0.5; // m
const iters = opts.iters != null ? opts.iters : 400;
// Un mur doit rassembler une fraction minimale du nuage TOTAL initial,
// sinon on s'arrête (évite de "fabriquer" des murs parasites dans le bruit).
const minInlierFrac = opts.minInlierFrac != null ? opts.minInlierFrac : 0.035;
// copie des points encore disponibles
let remaining = points.map(p => [p[0], p[1]]);
const walls = [];
// seuil d'inliers : max(absolu, fraction du nuage total)
const need = Math.max(minInliers, Math.floor(points.length * minInlierFrac));
for (let w = 0; w < maxWalls; w++) {
if (remaining.length < need) break;
const best = ransacLine(remaining, distThresh, iters, minInliers);
if (!best || best.inlierIdx.length < need) break;
// Inliers de la meilleure droite
const inPts = best.inlierIdx.map(i => remaining[i]);
// Direction de la droite (normale (nx,ny) → dir = (-ny, nx))
const dx = -best.ny, dy = best.nx;
// Projette les inliers sur la direction, garde min/max → segment
let tMin = Infinity, tMax = -Infinity, pMin = null, pMax = null;
for (const p of inPts) {
const t = p[0] * dx + p[1] * dy;
if (t < tMin) { tMin = t; pMin = p; }
if (t > tMax) { tMax = t; pMax = p; }
}
const length = Math.hypot(pMax[0] - pMin[0], pMax[1] - pMin[1]);
// Retire les inliers du pool restant (avant filtrage longueur,
// pour ne pas re-tirer la même droite)
const inSet = new Set(best.inlierIdx);
remaining = remaining.filter((_, i) => !inSet.has(i));
if (length < minLength) continue; // segment trop court → ignore
walls.push({
x1: pMin[0], y1: pMin[1],
x2: pMax[0], y2: pMax[1],
length: length,
inliers: inPts.length,
});
}
return walls;
}
// Un tour de RANSAC : meilleure droite (modèle normale (nx,ny), offset c)
// distance point-droite = |nx*x + ny*y - c|
function ransacLine(pts, distThresh, iters, minInliers) {
const n = pts.length;
if (n < 2) return null;
let best = null;
for (let it = 0; it < iters; it++) {
const i = (Math.random() * n) | 0;
let j = (Math.random() * n) | 0;
if (i === j) j = (j + 1) % n;
const a = pts[i], b = pts[j];
let vx = b[0] - a[0], vy = b[1] - a[1];
const len = Math.hypot(vx, vy);
if (len < 1e-6) continue;
vx /= len; vy /= len;
// normale unitaire
const nx = -vy, ny = vx;
const c = nx * a[0] + ny * a[1];
const inlierIdx = [];
for (let k = 0; k < n; k++) {
const d = Math.abs(nx * pts[k][0] + ny * pts[k][1] - c);
if (d < distThresh) inlierIdx.push(k);
}
if (inlierIdx.length >= minInliers &&
(!best || inlierIdx.length > best.inlierIdx.length)) {
best = { nx, ny, c, inlierIdx };
}
}
if (!best) return null;
// Raffinement : refit total-least-squares sur les inliers (PCA)
refitLine(pts, best);
return best;
}
// Refit de la droite par PCA sur les inliers, ré-évalue les inliers
function refitLine(pts, model) {
const idx = model.inlierIdx;
let mx = 0, my = 0;
for (const i of idx) { mx += pts[i][0]; my += pts[i][1]; }
mx /= idx.length; my /= idx.length;
let sxx = 0, syy = 0, sxy = 0;
for (const i of idx) {
const ddx = pts[i][0] - mx, ddy = pts[i][1] - my;
sxx += ddx * ddx; syy += ddy * ddy; sxy += ddx * ddy;
}
// direction principale = vecteur propre de la plus grande valeur propre
const theta = 0.5 * Math.atan2(2 * sxy, sxx - syy);
const dx = Math.cos(theta), dy = Math.sin(theta);
// normale = perpendiculaire à la direction
model.nx = -dy; model.ny = dx;
model.c = model.nx * mx + model.ny * my;
}
// Aire de l'enveloppe convexe (Andrew monotone chain + shoelace)
function convexHullArea(points) {
if (points.length < 3) return 0;
const pts = points.map(p => [p[0], p[1]]).sort((a, b) =>
a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]);
const cross = (o, a, b) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
upper.push(p);
}
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
// shoelace
let area2 = 0;
for (let i = 0; i < hull.length; i++) {
const a = hull[i], b = hull[(i + 1) % hull.length];
area2 += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(area2) / 2;
}
function bbox(points) {
let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
for (const p of points) {
if (p[0] < minx) minx = p[0]; if (p[0] > maxx) maxx = p[0];
if (p[1] < miny) miny = p[1]; if (p[1] > maxy) maxy = p[1];
}
return { minx, maxx, miny, maxy, w: maxx - minx, h: maxy - miny };
}
return { extractWalls, convexHullArea, bbox };
})();
if (typeof window !== 'undefined') window.MoulinGeom = MoulinGeom;
if (typeof module !== 'undefined') module.exports = MoulinGeom;
// Recalcule murs + métriques (avec throttle)
function computeGeometry(force) {
const now = (typeof performance !== 'undefined') ? performance.now() : Date.now();
const enoughNew = (mapPoints.length - lastWallPointCount) >= WALL_RECOMPUTE_DELTA;
if (!force && !(enoughNew && (now - lastWallCompute) >= WALL_RECOMPUTE_MS)) return;
if (mapPoints.length < 15) { walls = []; geom = { bboxW: 0, bboxH: 0, area: 0, wallTotal: 0 }; return; }
lastWallCompute = now;
lastWallPointCount = mapPoints.length;
walls = MoulinGeom.extractWalls(mapPoints);
const bb = MoulinGeom.bbox(mapPoints);
geom.bboxW = bb.w;
geom.bboxH = bb.h;
geom.area = MoulinGeom.convexHullArea(mapPoints);
geom.wallTotal = walls.reduce((s, w) => s + w.length, 0);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Canvas + viewport // Canvas + viewport
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -284,14 +490,52 @@ function draw() {
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, canvas.height); ctx.stroke(); 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(); ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(canvas.width, oy); ctx.stroke();
// Points carte (murs) // Nuage de points brut (fond léger) — affiché en mode 'points' ou 'both'
const ptRadius = Math.max(1, viewScale * 0.04); if (viewMode === 'points' || viewMode === 'both') {
ctx.fillStyle = 'rgba(88, 166, 255, 0.7)'; // gris clair + petit quand les murs sont aussi visibles, plus net sinon
for (const [wx, wy] of mapPoints) { const showWalls = (viewMode === 'both');
const [px, py] = worldToCanvas(wx, wy); const ptRadius = showWalls ? Math.max(0.8, viewScale * 0.02)
ctx.beginPath(); : Math.max(1, viewScale * 0.04);
ctx.arc(px, py, ptRadius, 0, 2 * Math.PI); ctx.fillStyle = showWalls ? 'rgba(155, 163, 173, 0.35)'
ctx.fill(); : '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();
}
}
// Murs détectés (segments épais + longueur) — mode 'walls' ou 'both'
if (viewMode === 'walls' || viewMode === 'both') {
ctx.lineCap = 'round';
for (const wll of walls) {
const [ax, ay] = worldToCanvas(wll.x1, wll.y1);
const [bx, by] = worldToCanvas(wll.x2, wll.y2);
ctx.strokeStyle = '#1f6feb';
ctx.lineWidth = Math.max(3, viewScale * 0.06);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
// Étiquettes longueur au milieu de chaque segment
ctx.font = '12px Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const wll of walls) {
const [ax, ay] = worldToCanvas(wll.x1, wll.y1);
const [bx, by] = worldToCanvas(wll.x2, wll.y2);
const mx = (ax + bx) / 2, my = (ay + by) / 2;
const label = wll.length.toFixed(2) + ' m';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(1, 4, 9, 0.75)';
ctx.fillRect(mx - tw / 2 - 3, my - 8, tw + 6, 16);
ctx.fillStyle = '#79c0ff';
ctx.fillText(label, mx, my);
}
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
} }
// Trajectoire // Trajectoire
@@ -346,6 +590,16 @@ function updateStats(data) {
document.getElementById('s-debit').textContent = stats.debit.toFixed(1) + ' rec/s'; document.getElementById('s-debit').textContent = stats.debit.toFixed(1) + ' rec/s';
document.getElementById('s-points').textContent = mapPoints.length; document.getElementById('s-points').textContent = mapPoints.length;
// Géométrie pièce
if (geom.bboxW > 0) {
document.getElementById('s-bbox').textContent =
geom.bboxW.toFixed(1) + ' × ' + geom.bboxH.toFixed(1) + ' m';
document.getElementById('s-area').textContent = geom.area.toFixed(1) + ' m²';
}
document.getElementById('s-walls').textContent = walls.length;
document.getElementById('s-wlen').textContent =
geom.wallTotal > 0 ? geom.wallTotal.toFixed(1) + ' m' : '—';
if (data.pose_corrected) { if (data.pose_corrected) {
poseCorrect = data.pose_corrected; poseCorrect = data.pose_corrected;
document.getElementById('s-x').textContent = poseCorrect[0].toFixed(2); document.getElementById('s-x').textContent = poseCorrect[0].toFixed(2);
@@ -363,6 +617,7 @@ function handleMessage(data) {
mapPoints = data.map_points || []; mapPoints = data.map_points || [];
trajectory = data.trajectory || []; trajectory = data.trajectory || [];
poseCorrect = data.pose_corrected || [0, 0, 0]; poseCorrect = data.pose_corrected || [0, 0, 0];
computeGeometry(true); // recalcul complet sur snapshot
updateStats(data); updateStats(data);
draw(); draw();
log('Snapshot reçu — ' + mapPoints.length + ' pts, ' + trajectory.length + ' poses', 'ok'); log('Snapshot reçu — ' + mapPoints.length + ' pts, ' + trajectory.length + ' poses', 'ok');
@@ -378,12 +633,16 @@ function handleMessage(data) {
trajectory.push(pose); trajectory.push(pose);
} }
} }
computeGeometry(false); // throttlé : recalcul murs en direct
updateStats(data); updateStats(data);
draw(); draw();
} else if (data.type === 'reset') { } else if (data.type === 'reset') {
mapPoints = []; mapPoints = [];
trajectory = []; trajectory = [];
poseCorrect = [0, 0, 0]; poseCorrect = [0, 0, 0];
walls = [];
geom = { bboxW: 0, bboxH: 0, area: 0, wallTotal: 0 };
lastWallPointCount = 0;
stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 }; stats = { records: 0, sweeps: 0, loop_closures: 0, last_t: 0, debit: 0 };
updateStats({}); updateStats({});
draw(); draw();
@@ -434,6 +693,17 @@ function wsConnect() {
}; };
} }
// ---------------------------------------------------------------------------
// Toggle affichage : Les deux → Murs → Points → …
// ---------------------------------------------------------------------------
function cycleViewMode() {
const order = { both: 'walls', walls: 'points', points: 'both' };
const labels = { both: 'Vue : Les deux', walls: 'Vue : Murs', points: 'Vue : Points' };
viewMode = order[viewMode];
document.getElementById('view-toggle').textContent = labels[viewMode];
draw();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reset session // Reset session
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------