Files
Presa-Mano/web/work_plane.js
T
Adriano Dal PastroandClaude Opus 4.7 767d91acdc feat(work_plane gui): show fit inliers as blue scatter in 3D, drop wrong plane mesh
The 0.4x0.4 PlaneGeometry square was a fixed-size proxy that did not
reflect the actual ROI extent — visually misleading. Replace it with
an explicit blue point cloud of the inliers fed to fit_plane_svd, so
the user can see *exactly* which depth pixels the plane was fit on.

- Backend: fit response now returns fit_points_camera (CV frame) instead
  of the (parallax-shifted) RGB pixel projection.
- Frontend: drop the purple plane mesh; new three.setFitInliers paints
  the inliers in blue. The RGB overlay no longer carries blue dots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:20:03 +02:00

375 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 fitPointsCv = null; // list of [x,y,z] inliers used for the fit (CV frame)
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 = "— · clicca 2 angoli sull'RGB"; 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() {
// Same-origin RGB proxy exposed by presa-mano (no CORS issues).
return `/api/calibration/rgb.jpg?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() {
// Setting width/height on a canvas wipes its content. Only re-set when
// the image resolution actually changes (rare) so the ROI overlay survives
// each RGB live-refresh tick.
const w = img.naturalWidth || 640;
const h = img.naturalHeight || 480;
if (overlay.width !== w) overlay.width = w;
if (overlay.height !== h) overlay.height = h;
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
}
img.addEventListener('load', () => { syncOverlaySize(); drawOverlay(); });
// Two-click ROI: 1st click = corner A, 2nd click = corner B (rectangle
// defined). 3rd click starts a new selection.
let firstCorner = null;
function drawCross(ctx, u, v) {
ctx.strokeStyle = '#9b6dff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(u - 8, v); ctx.lineTo(u + 8, v);
ctx.moveTo(u, v - 8); ctx.lineTo(u, v + 8);
ctx.stroke();
}
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);
drawCross(ctx, roi.u0, roi.v0);
drawCross(ctx, roi.u1, roi.v1);
} else if (firstCorner) {
drawCross(ctx, firstCorner.u, firstCorner.v);
}
}
viewport.addEventListener('click', (e) => {
const p = imgPixelFromEvent(e);
if (firstCorner === null && roi === null) {
// First click: place corner A. Drop any stale fit-inliers from the 3D
// viewer so we don't suggest the new ROI is being analyzed yet.
fitPointsCv = null;
three.setFitInliers(null);
firstCorner = p;
drawOverlay();
setChip('punto 1/2 piazzato — clicca il secondo angolo');
return;
}
if (firstCorner !== null) {
// Second click: complete the rectangle.
roi = {
u0: Math.min(firstCorner.u, p.u),
v0: Math.min(firstCorner.v, p.v),
u1: Math.max(firstCorner.u, p.u),
v1: Math.max(firstCorner.v, p.v),
};
firstCorner = null;
// Reject degenerate rectangles (<10 px on either side).
if (roi.u1 - roi.u0 < 10 || roi.v1 - roi.v0 < 10) {
roi = null;
setChip('rettangolo troppo piccolo, riprova', 'err');
} else {
setChip('ROI pronto — clicca "Calcola piano"', 'ok');
}
drawOverlay();
setRoiInfo();
updateButtonStates();
return;
}
// ROI already set: start a new selection on the next click.
roi = null;
fitPointsCv = null;
three.setFitInliers(null);
firstCorner = p;
drawOverlay();
setRoiInfo();
updateButtonStates();
setChip('punto 1/2 piazzato — clicca il secondo angolo');
});
})();
// ---------------- Three.js 3D viewer — camera-POV ----------------
//
// CV camera frame: X right, Y down, Z forward.
// We render the scene so the user sees it from the camera's vantage point:
// camera at the origin, looking at +Z, with screen-up = CV -Y. OrbitControls
// can rotate around the scene centroid once it's defined; before any fit the
// view stays at camera-POV.
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-POV: stand just behind the camera origin so OrbitControls has a
// non-zero distance to its target. Screen-up = CV -Y.
camera.up.set(0, -1, 0);
camera.position.set(0, 0, -0.001);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.width, canvas.height);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 1);
controls.update();
scene.add(new THREE.AxesHelper(0.1));
// The fitted plane is implied by the blue inlier cloud; no extra mesh is
// drawn (the previous PlaneGeometry was a fixed 0.4x0.4 square that did
// not reflect the actual ROI extent and was misleading).
let scatter = null;
let fitInliersScatter = null;
function render() { renderer.render(scene, camera); }
(function loop() { controls.update(); render(); requestAnimationFrame(loop); })();
function setPlane(plane) {
if (!plane) return;
// Move the orbit target onto the plane centroid so rotation feels natural.
const c = plane.centroid;
controls.target.set(c[0], c[1], c[2]);
controls.update();
}
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;
// Two-tone colouring: filter-excluded points (signed < -offset_m) red,
// everything else cyan-white. This matches the backend filter contract
// (keep == signed >= -offset_m).
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, g, b;
if (plane && signed < -offsetM) {
r = 0.93; g = 0.27; b = 0.27; // red: excluded by below-plane filter
} else {
r = 0.75; g = 0.92; b = 1.00; // cyan-white: kept
}
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);
}
function setFitInliers(pointsCv) {
if (fitInliersScatter) {
scene.remove(fitInliersScatter);
fitInliersScatter.geometry.dispose();
fitInliersScatter.material.dispose();
fitInliersScatter = null;
}
if (!pointsCv || pointsCv.length === 0) return;
const n = pointsCv.length;
const positions = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
positions[3 * i] = pointsCv[i][0];
positions[3 * i + 1] = pointsCv[i][1];
positions[3 * i + 2] = pointsCv[i][2];
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({ color: 0x4da3ff, size: 0.006 });
fitInliersScatter = new THREE.Points(geom, mat);
scene.add(fitInliersScatter);
}
return { setPlane, setScatter, setFitInliers };
})();
// ---------------- 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);
}
// FastAPI 422 returns `detail` as an array of validation errors:
// [{loc, msg, type, ...}, ...]
// Plain HTTPException returns it as a string. Render either case usefully so
// the status chip never says "[object Object]".
function formatDetail(detail) {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail.map(e => {
const loc = Array.isArray(e.loc) ? e.loc.join('.') : e.loc;
return loc ? `${loc}: ${e.msg}` : (e.msg || JSON.stringify(e));
}).join('; ');
}
return JSON.stringify(detail);
}
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(formatDetail(r.detail));
previewPlane = r.plane;
previewPoints = r.points_camera;
fitPointsCv = r.fit_points_camera || null;
three.setPlane(previewPlane);
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
three.setFitInliers(fitPointsCv);
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(formatDetail(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;
fitPointsCv = null;
three.setPlane(null);
three.setScatter(null);
three.setFitInliers(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();