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) <noreply@anthropic.com>
This commit is contained in:
1 parent
eb53f30e23
commit
ae84739c64
2 files changed
+337
No files matched your search
@@ -13,6 +13,7 @@
|
|||||||
<button class="tab active" data-tab="home">Home</button>
|
<button class="tab active" data-tab="home">Home</button>
|
||||||
<button class="tab" data-tab="agent">Assistente AI</button>
|
<button class="tab" data-tab="agent">Assistente AI</button>
|
||||||
<button class="tab" data-tab="ai-settings">Impostazioni AI</button>
|
<button class="tab" data-tab="ai-settings">Impostazioni AI</button>
|
||||||
|
<button class="tab" data-tab="workplane">Calibrazione Piano</button>
|
||||||
<button class="tab" data-tab="grasp">TrovaUtensile</button>
|
<button class="tab" data-tab="grasp">TrovaUtensile</button>
|
||||||
<button class="tab" data-tab="view3d">Vista 3D</button>
|
<button class="tab" data-tab="view3d">Vista 3D</button>
|
||||||
<button class="tab" data-tab="ee">Movimento EE</button>
|
<button class="tab" data-tab="ee">Movimento EE</button>
|
||||||
@@ -173,6 +174,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================== CALIBRAZIONE PIANO ============================== -->
|
||||||
|
<section class="panel" id="tab-workplane">
|
||||||
|
<div class="diag3d-toolbar">
|
||||||
|
<span class="post-health" id="wpStatusChip">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2 diag3d-layout">
|
||||||
|
<div>
|
||||||
|
<div id="wpViewport" style="position:relative;width:min(100%,720px);height:440px;overflow:hidden;background:#000;border:1px solid var(--border);border-radius:4px;cursor:grab;user-select:none">
|
||||||
|
<div id="wpPanZoom" style="position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform">
|
||||||
|
<img id="wpImg" alt="Nuwa RGB" style="display:block;max-width:none;pointer-events:none" draggable="false" />
|
||||||
|
<canvas id="wpOverlay" style="position:absolute;left:0;top:0;pointer-events:none"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="marker-info">RGB live · shift+drag = ROI · drag = pan · wheel = zoom · doppio click = reset</div>
|
||||||
|
<h3 style="margin-top:14px">Vista 3D (frame camera)</h3>
|
||||||
|
<div class="canvas-wrap" style="width:min(100%,720px)">
|
||||||
|
<canvas id="wp3dCanvas" width="720" height="360" style="display:block;background:#0a0d11;border:1px solid var(--border);border-radius:4px"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="marker-info">piano viola · verde = inlier · giallo = sopra piano+offset · rosso = sotto piano-offset · grigio = buffer · drag = ruota · wheel = zoom</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Calibrazione</h2>
|
||||||
|
<div class="ctrl-block">
|
||||||
|
<div class="info-box">
|
||||||
|
Disegna con shift+drag un rettangolo sul tavolo (senza oggetti), poi
|
||||||
|
clicca <strong>Calcola piano</strong>. Regola l'offset finché i punti
|
||||||
|
gialli rappresentano l'oggetto e i rossi spariscono. Poi <strong>Salva piano</strong>.
|
||||||
|
</div>
|
||||||
|
<div class="kv compact">
|
||||||
|
<label>ROI <span id="wpRoiInfo" class="dim">— · disegna con shift+drag</span></label>
|
||||||
|
<label>offset (mm) <input type="number" id="wpOffset" value="5.0" step="0.5" min="0" max="200" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="wpFitBtn" class="primary" disabled>Calcola piano (preview)</button>
|
||||||
|
<button id="wpSaveBtn" disabled>Salva piano</button>
|
||||||
|
<button id="wpClearBtn" class="danger">Rimuovi piano salvato</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Stato</h2>
|
||||||
|
<div id="wpStateBlock" class="ctrl-block">
|
||||||
|
<div class="dim">nessun piano</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Dettagli piano</h2>
|
||||||
|
<pre id="wpDetails" class="log small">—</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ============================== TROVA UTENSILE ============================== -->
|
<!-- ============================== TROVA UTENSILE ============================== -->
|
||||||
<section class="panel" id="tab-grasp">
|
<section class="panel" id="tab-grasp">
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -337,5 +388,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
<script type="module" src="/static/work_plane.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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 = '<div class="dim">nessun piano</div>';
|
||||||
|
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 = `<strong>preview</strong> · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm} mm · <span class="warn">non salvato</span>`;
|
||||||
|
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 = '<div class="dim">nessun piano</div>';
|
||||||
|
$('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 = `<strong>preview</strong> · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm.toFixed(1)} mm · <span class="warn">non salvato</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('wpFitBtn').addEventListener('click', doFit);
|
||||||
|
$('wpSaveBtn').addEventListener('click', doSave);
|
||||||
|
$('wpClearBtn').addEventListener('click', doClear);
|
||||||
|
|
||||||
|
// Initial load.
|
||||||
|
startRgbLoop();
|
||||||
|
fetchSaved();
|
||||||
Reference in new issue
Block a user