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');