From 767d91acdc268db89297316c3bf9ab4fb82eafa9 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Wed, 13 May 2026 11:20:03 +0200 Subject: [PATCH] feat(work_plane gui): show fit inliers as blue scatter in 3D, drop wrong plane mesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/presa_mano/api/work_plane_routes.py | 26 ++-------- web/index.html | 2 +- web/work_plane.js | 69 ++++++++++++++----------- 3 files changed, 44 insertions(+), 53 deletions(-) diff --git a/src/presa_mano/api/work_plane_routes.py b/src/presa_mano/api/work_plane_routes.py index 011cb59..1b167c3 100644 --- a/src/presa_mano/api/work_plane_routes.py +++ b/src/presa_mano/api/work_plane_routes.py @@ -11,7 +11,6 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from ..perception import filter_outliers, nuwa_to_cv_frame -from ..perception.projection import K_from_intrinsics, project_camera_to_pixels log = logging.getLogger(__name__) from ..perception.work_plane import ( @@ -113,32 +112,13 @@ def fit(req: FitReq) -> dict[str, Any]: idx = np.random.choice(len(scene_cv), req.max_preview_points, replace=False) scene_cv = scene_cv[idx] - # Project the inliers used for the fit back to RGB pixels so the GUI can - # overlay them on the live image (verify visually which depth pixels were - # actually fed to fit_plane_svd). NOTE: pointcloud_roi's docstring claims - # CV frame, but in practice the raw `pc` arrives in Nuwa native frame - # (Z<0 forward). After nuwa_to_cv_frame + outlier mask, `pts_cv` is the - # canonical CV-frame inlier set, so use it. - fit_points_px: list[list[int]] = [] - try: - intr_full = nuwa.intrinsics() - rgb_intr = intr_full.get("rgb") - if rgb_intr and rgb_intr.get("fx") and rgb_intr.get("cx"): - # Inject width/height so K_from_intrinsics has all required keys. - W = int(rgb_intr.get("width", 640)) - H = int(rgb_intr.get("height", 480)) - rgb_intr = {**rgb_intr, "width": W, "height": H} - K = K_from_intrinsics(rgb_intr) - with sw.step("project_inliers"): - fit_points_px = project_camera_to_pixels(pts_cv, K, (W, H)).tolist() - except Exception as e: - log.warning("could not project fit inliers to RGB pixels: %s", e) - return { "plane": asdict(wp), "preview": True, "points_camera": scene_cv.tolist(), - "fit_points_px": fit_points_px, + # 3D inliers actually used for fit_plane_svd, in CV camera frame. + # The GUI scatters these in blue inside the 3D viewer. + "fit_points_camera": pts_cv.tolist(), "timings_ms": sw.as_dict(), } diff --git a/web/index.html b/web/index.html index dde0087..f4a8b13 100644 --- a/web/index.html +++ b/web/index.html @@ -192,7 +192,7 @@
-
vista camera · piano viola · ciano = punti tenuti · rosso = esclusi (sotto piano−offset) · drag = ruota · wheel = zoom
+
vista camera · blu = punti usati per il fit · ciano = scena tenuta · rosso = scena esclusa (sotto piano−offset) · drag = ruota · wheel = zoom

Calibrazione

diff --git a/web/work_plane.js b/web/work_plane.js index 8a8b5ac..72806a8 100644 --- a/web/work_plane.js +++ b/web/work_plane.js @@ -10,7 +10,7 @@ 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 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) { @@ -90,12 +90,6 @@ function startRgbLoop() { 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; @@ -106,15 +100,14 @@ function startRgbLoop() { 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; + // 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'); @@ -143,7 +136,8 @@ function startRgbLoop() { } // ROI already set: start a new selection on the next click. roi = null; - fitPointsPx = null; + fitPointsCv = null; + three.setFitInliers(null); firstCorner = p; drawOverlay(); setRoiInfo(); @@ -173,26 +167,19 @@ const three = (function setupThree() { 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); + // 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) { 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; + 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(); } @@ -231,7 +218,29 @@ const three = (function setupThree() { scene.add(scatter); } - return { setPlane, setScatter }; + 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 ---------------- @@ -289,10 +298,10 @@ async function doFit() { if (r.detail) throw new Error(formatDetail(r.detail)); previewPlane = r.plane; previewPoints = r.points_camera; - fitPointsPx = r.fit_points_px || null; + fitPointsCv = r.fit_points_camera || null; three.setPlane(previewPlane); three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm); - if (window.__wpDrawOverlay) window.__wpDrawOverlay(); + three.setFitInliers(fitPointsCv); renderDetails(previewPlane); $('wpStateBlock').innerHTML = `preview · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm} mm · non salvato`; setChip('preview pronto', 'ok'); @@ -332,8 +341,10 @@ async function doClear() { savedPlane = null; previewPlane = null; previewPoints = null; + fitPointsCv = null; three.setPlane(null); three.setScatter(null); + three.setFitInliers(null); $('wpStateBlock').innerHTML = '
nessun piano
'; $('wpDetails').textContent = '—'; setChip('nessun piano');