The fit endpoint now returns fit_points_px (the post-outlier-filter inliers projected to RGB pixels via Nuwa's RGB intrinsics). The GUI draws them on the same overlay as the ROI rectangle so the user can visually verify which depth pixels were fed to fit_plane_svd. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
364 lines
13 KiB
JavaScript
364 lines
13 KiB
JavaScript
// 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 fitPointsPx = null; // list of [u,v] pixels of inliers used for the fit
|
||
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);
|
||
// Inliers used for the last fit, drawn first so the ROI rectangle stays
|
||
// on top.
|
||
if (fitPointsPx && fitPointsPx.length) {
|
||
ctx.fillStyle = '#4da3ff';
|
||
for (const [u, v] of fitPointsPx) ctx.fillRect(u, v, 2, 2);
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
// Re-export so doFit can refresh the overlay after a successful fit.
|
||
window.__wpDrawOverlay = drawOverlay;
|
||
|
||
viewport.addEventListener('click', (e) => {
|
||
const p = imgPixelFromEvent(e);
|
||
if (firstCorner === null && roi === null) {
|
||
// First click: place corner A. Drop any stale fit-points overlay so we
|
||
// don't suggest the new ROI is being analyzed yet.
|
||
fitPointsPx = 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;
|
||
fitPointsPx = 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));
|
||
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;
|
||
// Move the orbit target onto the plane centroid so rotation feels natural.
|
||
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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// 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;
|
||
fitPointsPx = r.fit_points_px || null;
|
||
three.setPlane(previewPlane);
|
||
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
|
||
if (window.__wpDrawOverlay) window.__wpDrawOverlay();
|
||
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;
|
||
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();
|