diff --git a/server/static/index.html b/server/static/index.html index 98779fa..d7d093f 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -160,6 +160,14 @@
Débit0
+
+

Géométrie pièce

+
Emprise (L×l)
+
Surface au sol
+
Murs détectés0
+
Long. totale murs
+
+

Pose ROV

x (m)0.00
@@ -170,12 +178,18 @@

Légende

-
Points carte (murs)
+
Nuage brut (sonar)
+
Murs détectés
Trajectoire corrigée
ROV courant
+
+

Affichage

+ +
+

Contrôles

@@ -198,6 +212,198 @@ 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 }; +// 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 // --------------------------------------------------------------------------- @@ -284,14 +490,52 @@ function draw() { 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(); + // Nuage de points brut (fond léger) — affiché en mode 'points' ou 'both' + if (viewMode === 'points' || viewMode === 'both') { + // gris clair + petit quand les murs sont aussi visibles, plus net sinon + const showWalls = (viewMode === 'both'); + const ptRadius = showWalls ? Math.max(0.8, viewScale * 0.02) + : Math.max(1, viewScale * 0.04); + ctx.fillStyle = showWalls ? 'rgba(155, 163, 173, 0.35)' + : '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 @@ -346,6 +590,16 @@ function updateStats(data) { document.getElementById('s-debit').textContent = stats.debit.toFixed(1) + ' rec/s'; 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) { poseCorrect = data.pose_corrected; document.getElementById('s-x').textContent = poseCorrect[0].toFixed(2); @@ -363,6 +617,7 @@ function handleMessage(data) { mapPoints = data.map_points || []; trajectory = data.trajectory || []; poseCorrect = data.pose_corrected || [0, 0, 0]; + computeGeometry(true); // recalcul complet sur snapshot updateStats(data); draw(); log('Snapshot reçu — ' + mapPoints.length + ' pts, ' + trajectory.length + ' poses', 'ok'); @@ -378,12 +633,16 @@ function handleMessage(data) { trajectory.push(pose); } } + computeGeometry(false); // throttlé : recalcul murs en direct updateStats(data); draw(); } else if (data.type === 'reset') { mapPoints = []; trajectory = []; 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 }; updateStats({}); 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 // ---------------------------------------------------------------------------