Setting canvas.width/height clears its bitmap; the live RGB tick was firing the img.load handler every second, wiping the ROI rectangle and breaking the two-click flow (third click was interpreted as restart). - Only mutate canvas size when image resolution actually changes. - Re-paint the overlay after each load so the ROI persists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
335 lines
12 KiB
JavaScript
335 lines
12 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 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.
|
||
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;
|
||
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);
|
||
}
|
||
|
||
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();
|