diff --git a/src/presa_mano/api/work_plane_routes.py b/src/presa_mano/api/work_plane_routes.py
index 99f3382..edd0b1b 100644
--- a/src/presa_mano/api/work_plane_routes.py
+++ b/src/presa_mano/api/work_plane_routes.py
@@ -11,6 +11,7 @@ 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 (
@@ -66,7 +67,16 @@ def fit(req: FitReq) -> dict[str, Any]:
)
pts_cv = nuwa_to_cv_frame(pc[:, :3])
with sw.step("filter_outliers"):
- pts_cv = filter_outliers(pts_cv, n_std=2.0)
+ # Inline filter_outliers to keep the inlier mask, so we can project
+ # the SAME points back to RGB pixels for the GUI overlay.
+ if len(pts_cv) < 10:
+ inlier_mask = np.ones(len(pts_cv), dtype=bool)
+ else:
+ xyz = pts_cv[:, :3]
+ c_ = xyz.mean(axis=0)
+ d_ = np.linalg.norm(xyz - c_, axis=1)
+ inlier_mask = d_ < (d_.mean() + 2.0 * d_.std())
+ pts_cv = pts_cv[inlier_mask]
if len(pts_cv) < 10:
raise HTTPException(
status_code=422,
@@ -103,10 +113,31 @@ 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). pc is in OpenCV camera frame already
+ # (per pointcloud_roi docstring); use Nuwa's RGB intrinsics.
+ 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)
+ inliers_cv = pc[inlier_mask, :3] # original CV-frame coords
+ with sw.step("project_inliers"):
+ fit_points_px = project_camera_to_pixels(inliers_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,
"timings_ms": sw.as_dict(),
}
diff --git a/web/work_plane.js b/web/work_plane.js
index 3325b65..8a8b5ac 100644
--- a/web/work_plane.js
+++ b/web/work_plane.js
@@ -10,6 +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 savedPlane = null; // last GET /api/work_plane response.plane
function setChip(text, level) {
@@ -89,6 +90,12 @@ 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;
@@ -99,11 +106,15 @@ 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.
+ // 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');
@@ -132,6 +143,7 @@ function startRgbLoop() {
}
// ROI already set: start a new selection on the next click.
roi = null;
+ fitPointsPx = null;
firstCorner = p;
drawOverlay();
setRoiInfo();
@@ -277,8 +289,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;
three.setPlane(previewPlane);
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
+ if (window.__wpDrawOverlay) window.__wpDrawOverlay();
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');