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
@@ -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