feat(work_plane gui): show fit inliers as blue scatter in 3D, drop wrong plane mesh
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
4a83305b5e
commit
767d91acdc
3 files changed
+44
-53
No files matched your search
@@ -11,7 +11,6 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..perception import filter_outliers, nuwa_to_cv_frame
|
from ..perception import filter_outliers, nuwa_to_cv_frame
|
||||||
from ..perception.projection import K_from_intrinsics, project_camera_to_pixels
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
from ..perception.work_plane import (
|
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)
|
idx = np.random.choice(len(scene_cv), req.max_preview_points, replace=False)
|
||||||
scene_cv = scene_cv[idx]
|
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 {
|
return {
|
||||||
"plane": asdict(wp),
|
"plane": asdict(wp),
|
||||||
"preview": True,
|
"preview": True,
|
||||||
"points_camera": scene_cv.tolist(),
|
"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(),
|
"timings_ms": sw.as_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@
|
|||||||
<div class="canvas-wrap" style="width:min(100%,720px)">
|
<div class="canvas-wrap" style="width:min(100%,720px)">
|
||||||
<canvas id="wp3dCanvas" width="720" height="360" style="display:block;background:#0a0d11;border:1px solid var(--border);border-radius:4px"></canvas>
|
<canvas id="wp3dCanvas" width="720" height="360" style="display:block;background:#0a0d11;border:1px solid var(--border);border-radius:4px"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="marker-info">vista camera · piano viola · ciano = punti tenuti · rosso = esclusi (sotto piano−offset) · drag = ruota · wheel = zoom</div>
|
<div class="marker-info">vista camera · blu = punti usati per il fit · ciano = scena tenuta · rosso = scena esclusa (sotto piano−offset) · drag = ruota · wheel = zoom</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Calibrazione</h2>
|
<h2>Calibrazione</h2>
|
||||||
|
|||||||
+40
-29
@@ -10,7 +10,7 @@ const RGB_REFRESH_MS = 1000;
|
|||||||
let roi = null; // {u0,v0,u1,v1} in image pixel coords
|
let roi = null; // {u0,v0,u1,v1} in image pixel coords
|
||||||
let previewPlane = null; // last response.plane (preview, not saved)
|
let previewPlane = null; // last response.plane (preview, not saved)
|
||||||
let previewPoints = null; // list of [x,y,z] in CV camera frame, meters
|
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
|
let savedPlane = null; // last GET /api/work_plane response.plane
|
||||||
|
|
||||||
function setChip(text, level) {
|
function setChip(text, level) {
|
||||||
@@ -90,12 +90,6 @@ function startRgbLoop() {
|
|||||||
function drawOverlay() {
|
function drawOverlay() {
|
||||||
const ctx = overlay.getContext('2d');
|
const ctx = overlay.getContext('2d');
|
||||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
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) {
|
if (roi) {
|
||||||
ctx.strokeStyle = '#9b6dff';
|
ctx.strokeStyle = '#9b6dff';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
@@ -106,15 +100,14 @@ function startRgbLoop() {
|
|||||||
drawCross(ctx, firstCorner.u, firstCorner.v);
|
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) => {
|
viewport.addEventListener('click', (e) => {
|
||||||
const p = imgPixelFromEvent(e);
|
const p = imgPixelFromEvent(e);
|
||||||
if (firstCorner === null && roi === null) {
|
if (firstCorner === null && roi === null) {
|
||||||
// First click: place corner A. Drop any stale fit-points overlay so we
|
// First click: place corner A. Drop any stale fit-inliers from the 3D
|
||||||
// don't suggest the new ROI is being analyzed yet.
|
// viewer so we don't suggest the new ROI is being analyzed yet.
|
||||||
fitPointsPx = null;
|
fitPointsCv = null;
|
||||||
|
three.setFitInliers(null);
|
||||||
firstCorner = p;
|
firstCorner = p;
|
||||||
drawOverlay();
|
drawOverlay();
|
||||||
setChip('punto 1/2 piazzato — clicca il secondo angolo');
|
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 already set: start a new selection on the next click.
|
||||||
roi = null;
|
roi = null;
|
||||||
fitPointsPx = null;
|
fitPointsCv = null;
|
||||||
|
three.setFitInliers(null);
|
||||||
firstCorner = p;
|
firstCorner = p;
|
||||||
drawOverlay();
|
drawOverlay();
|
||||||
setRoiInfo();
|
setRoiInfo();
|
||||||
@@ -173,26 +167,19 @@ const three = (function setupThree() {
|
|||||||
controls.target.set(0, 0, 1);
|
controls.target.set(0, 0, 1);
|
||||||
controls.update();
|
controls.update();
|
||||||
scene.add(new THREE.AxesHelper(0.1));
|
scene.add(new THREE.AxesHelper(0.1));
|
||||||
const planeMesh = new THREE.Mesh(
|
// The fitted plane is implied by the blue inlier cloud; no extra mesh is
|
||||||
new THREE.PlaneGeometry(0.4, 0.4),
|
// drawn (the previous PlaneGeometry was a fixed 0.4x0.4 square that did
|
||||||
new THREE.MeshBasicMaterial({ color: 0x9b6dff, transparent: true, opacity: 0.3, side: THREE.DoubleSide }),
|
// not reflect the actual ROI extent and was misleading).
|
||||||
);
|
|
||||||
planeMesh.visible = false;
|
|
||||||
scene.add(planeMesh);
|
|
||||||
let scatter = null;
|
let scatter = null;
|
||||||
|
let fitInliersScatter = null;
|
||||||
|
|
||||||
function render() { renderer.render(scene, camera); }
|
function render() { renderer.render(scene, camera); }
|
||||||
(function loop() { controls.update(); render(); requestAnimationFrame(loop); })();
|
(function loop() { controls.update(); render(); requestAnimationFrame(loop); })();
|
||||||
|
|
||||||
function setPlane(plane) {
|
function setPlane(plane) {
|
||||||
if (!plane) { planeMesh.visible = false; return; }
|
if (!plane) 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.
|
// 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.target.set(c[0], c[1], c[2]);
|
||||||
controls.update();
|
controls.update();
|
||||||
}
|
}
|
||||||
@@ -231,7 +218,29 @@ const three = (function setupThree() {
|
|||||||
scene.add(scatter);
|
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 ----------------
|
// ---------------- State + button handlers ----------------
|
||||||
@@ -289,10 +298,10 @@ async function doFit() {
|
|||||||
if (r.detail) throw new Error(formatDetail(r.detail));
|
if (r.detail) throw new Error(formatDetail(r.detail));
|
||||||
previewPlane = r.plane;
|
previewPlane = r.plane;
|
||||||
previewPoints = r.points_camera;
|
previewPoints = r.points_camera;
|
||||||
fitPointsPx = r.fit_points_px || null;
|
fitPointsCv = r.fit_points_camera || null;
|
||||||
three.setPlane(previewPlane);
|
three.setPlane(previewPlane);
|
||||||
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
|
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
|
||||||
if (window.__wpDrawOverlay) window.__wpDrawOverlay();
|
three.setFitInliers(fitPointsCv);
|
||||||
renderDetails(previewPlane);
|
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>`;
|
$('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');
|
setChip('preview pronto', 'ok');
|
||||||
@@ -332,8 +341,10 @@ async function doClear() {
|
|||||||
savedPlane = null;
|
savedPlane = null;
|
||||||
previewPlane = null;
|
previewPlane = null;
|
||||||
previewPoints = null;
|
previewPoints = null;
|
||||||
|
fitPointsCv = null;
|
||||||
three.setPlane(null);
|
three.setPlane(null);
|
||||||
three.setScatter(null);
|
three.setScatter(null);
|
||||||
|
three.setFitInliers(null);
|
||||||
$('wpStateBlock').innerHTML = '<div class="dim">nessun piano</div>';
|
$('wpStateBlock').innerHTML = '<div class="dim">nessun piano</div>';
|
||||||
$('wpDetails').textContent = '—';
|
$('wpDetails').textContent = '—';
|
||||||
setChip('nessun piano');
|
setChip('nessun piano');
|
||||||
|
|||||||
Reference in new issue
Block a user