From ae84739c64433bd79a204d5343394255acfbc3b8 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 11 May 2026 17:08:32 +0200 Subject: [PATCH] feat(gui): Calibrazione Piano tab (ROI + fit preview + 3D viewer) New tab replaces the removed Servo Visuale. Shift+drag on the RGB image defines a rectangle, 'Calcola piano' calls POST /api/work_plane/fit and renders the plane + a colored scatter in 3D. Adjusting the offset slider re-colors the scatter client-side. 'Salva piano' persists; 'Rimuovi piano salvato' clears. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/index.html | 52 +++++++++ web/work_plane.js | 285 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 web/work_plane.js diff --git a/web/index.html b/web/index.html index 1ee5cb1..0f5fea8 100644 --- a/web/index.html +++ b/web/index.html @@ -13,6 +13,7 @@ + @@ -173,6 +174,56 @@ + +
+
+ — +
+
+
+
+
+ Nuwa RGB + +
+
+
RGB live · shift+drag = ROI · drag = pan · wheel = zoom · doppio click = reset
+

Vista 3D (frame camera)

+
+ +
+
piano viola · verde = inlier · giallo = sopra piano+offset · rosso = sotto piano-offset · grigio = buffer · drag = ruota · wheel = zoom
+
+
+

Calibrazione

+
+
+ Disegna con shift+drag un rettangolo sul tavolo (senza oggetti), poi + clicca Calcola piano. Regola l'offset finché i punti + gialli rappresentano l'oggetto e i rossi spariscono. Poi Salva piano. +
+
+ + +
+
+ + + +
+
+ +

Stato

+
+
nessun piano
+
+ +

Dettagli piano

+
—
+
+
+
+
@@ -337,5 +388,6 @@ } + diff --git a/web/work_plane.js b/web/work_plane.js new file mode 100644 index 0000000..cda9f4e --- /dev/null +++ b/web/work_plane.js @@ -0,0 +1,285 @@ +// Calibrazione Piano tab: shift+drag ROI on RGB, fit preview, offset +// slider, save / clear, and a 3D scatter with the plane mesh. + +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +const $ = (id) => document.getElementById(id); +const RGB_REFRESH_MS = 1000; + +let roi = null; // {u0,v0,u1,v1} in image pixel coords +let previewPlane = null; // last response.plane (preview, not saved) +let previewPoints = null; // list of [x,y,z] in CV camera frame, meters +let savedPlane = null; // last GET /api/work_plane response.plane + +function setChip(text, level) { + const el = $('wpStatusChip'); + if (!el) return; + el.textContent = text; + el.className = 'post-health ' + (level || ''); +} + +function setRoiInfo() { + const el = $('wpRoiInfo'); + if (!roi) { el.textContent = '— · disegna con shift+drag'; el.classList.add('dim'); return; } + el.classList.remove('dim'); + el.textContent = `u=${roi.u0}…${roi.u1} v=${roi.v0}…${roi.v1} (${roi.u1 - roi.u0}×${roi.v1 - roi.v0} px)`; +} + +function updateButtonStates() { + $('wpFitBtn').disabled = !roi; + $('wpSaveBtn').disabled = !previewPlane; + $('wpClearBtn').disabled = !savedPlane; +} + +function rgbUrl() { + // Nuwa is reachable on host network on port 8877; the GUI is same-origin to + // presa-mano (8890). Use the upstream host directly. + const host = window.location.hostname; + return `http://${host}:8877/api/rgb?t=${Date.now()}`; +} + +function startRgbLoop() { + const tick = () => { $('wpImg').src = rgbUrl(); }; + tick(); + setInterval(tick, RGB_REFRESH_MS); +} + +// ---------------- ROI drawing on the RGB image ---------------- +(function setupRoiDrawing() { + const viewport = $('wpViewport'); + const img = $('wpImg'); + const overlay = $('wpOverlay'); + let drawing = null; + + function imgPixelFromEvent(e) { + const rect = img.getBoundingClientRect(); + const sx = img.naturalWidth / rect.width; + const sy = img.naturalHeight / rect.height; + return { + u: Math.round((e.clientX - rect.left) * sx), + v: Math.round((e.clientY - rect.top) * sy), + }; + } + + function syncOverlaySize() { + overlay.width = img.naturalWidth || 640; + overlay.height = img.naturalHeight || 480; + overlay.style.width = img.clientWidth + 'px'; + overlay.style.height = img.clientHeight + 'px'; + } + img.addEventListener('load', syncOverlaySize); + + function drawOverlay() { + const ctx = overlay.getContext('2d'); + ctx.clearRect(0, 0, overlay.width, overlay.height); + if (roi) { + ctx.strokeStyle = '#9b6dff'; + ctx.lineWidth = 2; + ctx.strokeRect(roi.u0, roi.v0, roi.u1 - roi.u0, roi.v1 - roi.v0); + } + } + + viewport.addEventListener('mousedown', (e) => { + if (!e.shiftKey) return; // shift+drag = ROI; plain drag = pan (no-op here) + const p = imgPixelFromEvent(e); + drawing = { u0: p.u, v0: p.v, u1: p.u, v1: p.v }; + e.preventDefault(); + }); + viewport.addEventListener('mousemove', (e) => { + if (!drawing) return; + const p = imgPixelFromEvent(e); + drawing.u1 = p.u; + drawing.v1 = p.v; + roi = { + u0: Math.min(drawing.u0, drawing.u1), + v0: Math.min(drawing.v0, drawing.v1), + u1: Math.max(drawing.u0, drawing.u1), + v1: Math.max(drawing.v0, drawing.v1), + }; + drawOverlay(); + setRoiInfo(); + updateButtonStates(); + }); + viewport.addEventListener('mouseup', () => { drawing = null; }); + viewport.addEventListener('mouseleave', () => { drawing = null; }); +})(); + +// ---------------- Three.js 3D viewer ---------------- +const three = (function setupThree() { + const canvas = $('wp3dCanvas'); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 0.01, 20); + camera.position.set(0.5, -0.5, -1.2); + camera.lookAt(0, 0, 1); + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + renderer.setSize(canvas.width, canvas.height); + const controls = new OrbitControls(camera, canvas); + scene.add(new THREE.AxesHelper(0.1)); + const planeMesh = new THREE.Mesh( + new THREE.PlaneGeometry(0.4, 0.4), + new THREE.MeshBasicMaterial({ color: 0x9b6dff, transparent: true, opacity: 0.3, side: THREE.DoubleSide }), + ); + planeMesh.visible = false; + scene.add(planeMesh); + let scatter = null; + + function render() { renderer.render(scene, camera); } + (function loop() { controls.update(); render(); requestAnimationFrame(loop); })(); + + function setPlane(plane) { + if (!plane) { planeMesh.visible = false; return; } + const c = plane.centroid; + const n = plane.normal; + planeMesh.position.set(c[0], c[1], c[2]); + // Align plane to its normal. + planeMesh.lookAt(c[0] + n[0], c[1] + n[1], c[2] + n[2]); + planeMesh.visible = true; + } + + function setScatter(pointsCv, plane, offsetMm) { + if (scatter) { scene.remove(scatter); scatter.geometry.dispose(); scatter.material.dispose(); scatter = null; } + if (!pointsCv || pointsCv.length === 0) return; + const n = pointsCv.length; + const positions = new Float32Array(n * 3); + const colors = new Float32Array(n * 3); + const nx = plane ? plane.normal[0] : 0; + const ny = plane ? plane.normal[1] : 0; + const nz = plane ? plane.normal[2] : 0; + const d = plane ? plane.offset : 0; + const offsetM = (offsetMm || 0) / 1000.0; + const rmsM = plane ? (plane.fit_rms_mm / 1000.0) * 2.0 : 0.01; + for (let i = 0; i < n; i++) { + const x = pointsCv[i][0], y = pointsCv[i][1], z = pointsCv[i][2]; + positions[3 * i] = x; positions[3 * i + 1] = y; positions[3 * i + 2] = z; + const signed = plane ? (nx * x + ny * y + nz * z + d) : 0; + let r = 0.5, g = 0.5, b = 0.5; + if (plane) { + if (signed > offsetM) { r = 1.0; g = 0.83; b = 0.31; } // yellow: above plane+offset + else if (signed < -offsetM) { r = 0.93; g = 0.27; b = 0.27; } // red: below plane-offset + else if (Math.abs(signed) <= rmsM) { r = 0.30; g = 0.78; b = 0.40; } // green: inlier + else { r = 0.55; g = 0.55; b = 0.55; } // grey: buffer + } + colors[3 * i] = r; colors[3 * i + 1] = g; colors[3 * i + 2] = b; + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geom.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + const mat = new THREE.PointsMaterial({ size: 0.005, vertexColors: true }); + scatter = new THREE.Points(geom, mat); + scene.add(scatter); + } + + return { setPlane, setScatter }; +})(); + +// ---------------- State + button handlers ---------------- +async function fetchSaved() { + try { + const r = await fetch('/api/work_plane').then((r) => r.json()); + savedPlane = r.plane || null; + } catch (e) { + savedPlane = null; + } + if (savedPlane && !previewPlane) { + three.setPlane(savedPlane); + renderDetails(savedPlane); + $('wpStateBlock').innerHTML = `piano salvato · RMS ${savedPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${savedPlane.n_inliers} · offset ${savedPlane.offset_mm} mm`; + setChip('piano salvato', 'ok'); + } else if (!savedPlane && !previewPlane) { + $('wpStateBlock').innerHTML = '
nessun piano
'; + setChip('nessun piano'); + } + updateButtonStates(); +} + +function renderDetails(plane) { + $('wpDetails').textContent = JSON.stringify(plane, null, 2); +} + +async function doFit() { + if (!roi) return; + setChip('fit…'); + $('wpFitBtn').disabled = true; + try { + const body = { + u_min: roi.u0, v_min: roi.v0, u_max: roi.u1, v_max: roi.v1, + offset_mm: parseFloat($('wpOffset').value || '5'), + }; + const r = await fetch('/api/work_plane/fit', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then((r) => r.json()); + if (r.detail) throw new Error(r.detail); + previewPlane = r.plane; + previewPoints = r.points_camera; + three.setPlane(previewPlane); + three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm); + renderDetails(previewPlane); + $('wpStateBlock').innerHTML = `preview · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm} mm · non salvato`; + setChip('preview pronto', 'ok'); + } catch (e) { + setChip('fit failed: ' + e.message, 'err'); + } finally { + updateButtonStates(); + } +} + +async function doSave() { + if (!previewPlane) return; + setChip('save…'); + try { + const r = await fetch('/api/work_plane/save', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plane: previewPlane }), + }).then((r) => r.json()); + if (r.detail) throw new Error(r.detail); + savedPlane = r.plane; + previewPlane = null; + $('wpStateBlock').innerHTML = `piano salvato · RMS ${savedPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${savedPlane.n_inliers} · offset ${savedPlane.offset_mm} mm`; + renderDetails(savedPlane); + setChip('piano salvato', 'ok'); + } catch (e) { + setChip('save failed: ' + e.message, 'err'); + } finally { + updateButtonStates(); + } +} + +async function doClear() { + if (!confirm('Rimuovere il piano salvato?')) return; + setChip('clear…'); + try { + await fetch('/api/work_plane', { method: 'DELETE' }).then((r) => r.json()); + savedPlane = null; + previewPlane = null; + previewPoints = null; + three.setPlane(null); + three.setScatter(null); + $('wpStateBlock').innerHTML = '
nessun piano
'; + $('wpDetails').textContent = '—'; + setChip('nessun piano'); + } catch (e) { + setChip('clear failed: ' + e.message, 'err'); + } finally { + updateButtonStates(); + } +} + +// ---------------- Offset slider: re-color in real time ---------------- +$('wpOffset').addEventListener('input', () => { + if (previewPlane && previewPoints) { + const off = parseFloat($('wpOffset').value || '0'); + previewPlane.offset_mm = off; + three.setScatter(previewPoints, previewPlane, off); + $('wpStateBlock').innerHTML = `preview · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm.toFixed(1)} mm · non salvato`; + } +}); + +$('wpFitBtn').addEventListener('click', doFit); +$('wpSaveBtn').addEventListener('click', doSave); +$('wpClearBtn').addEventListener('click', doClear); + +// Initial load. +startRgbLoop(); +fetchSaved();