docs: design work-plane calibration + below-plane filter
Repurpose the "Servo Visuale" tab into "Calibrazione Piano". Reuses the existing WorkPlane + fit_plane_svd infrastructure (moved out of grasp/visual_servo.py into perception/work_plane.py), extends WorkPlane with offset_mm, splits fit (preview) from save (commit), and applies a below-plane + offset filter to every object cloud (sam21_bridge, _points_via_bbox, _object_cloud_cv bbox branch). Visual-servo feature removed entirely; the only survivors are the plane bits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b96589bf8
commit
f7661a782a
1 file changed
+311
@@ -0,0 +1,311 @@
|
|||||||
|
# Work-plane calibration + below-plane filter
|
||||||
|
|
||||||
|
**Status:** approved (brainstorming → ready for implementation plan)
|
||||||
|
**Date:** 2026-05-11
|
||||||
|
**Scope:** `presa-mano` orchestrator + GUI
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Calibrate the work-plane (table top) from a user-drawn rectangle on the RGB
|
||||||
|
image, persist it with a configurable `offset_mm`, and use it to drop points
|
||||||
|
below the plane from every object point cloud produced by the grasp /
|
||||||
|
perception pipeline. The current "Servo Visuale" tab is repurposed into a
|
||||||
|
"Calibrazione Piano" tab focused exclusively on this calibration.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replace `fit_plane_svd` or the SVD-based plane-fitting maths.
|
||||||
|
- Persist multiple work planes (a single `data/work_plane.json` per
|
||||||
|
deployment).
|
||||||
|
- Per-side calibration. The work plane is a property of the world, not the
|
||||||
|
robot side.
|
||||||
|
- Keep the marker-relative visual-servoing feature — removed entirely.
|
||||||
|
|
||||||
|
## Pre-existing infrastructure (kept)
|
||||||
|
|
||||||
|
The repo already has most of the plane machinery (currently inside
|
||||||
|
`grasp/visual_servo.py`). This design moves it to `perception/work_plane.py`
|
||||||
|
and extends it with `offset_mm`. Kept verbatim:
|
||||||
|
|
||||||
|
- `WorkPlane` dataclass (`normal`, `offset`, `centroid`, `n_inliers`,
|
||||||
|
`fit_rms_mm`, `roi`, `created_at`).
|
||||||
|
- `fit_plane_svd(points_xyz)` — SVD-based plane fit.
|
||||||
|
- `set_work_plane(wp)` / `get_work_plane()` — in-memory singleton with
|
||||||
|
disk-backed lazy load.
|
||||||
|
- Disk persistence at `data/work_plane.json` via
|
||||||
|
`_save_work_plane_to_disk` / `_load_work_plane_from_disk`.
|
||||||
|
|
||||||
|
## Removed (visual servo, entirely)
|
||||||
|
|
||||||
|
The marker-relative servoing feature is removed:
|
||||||
|
|
||||||
|
- GUI tab "Servo Visuale" (the buttons "Plana", "Step verso oggetto",
|
||||||
|
"Rileva/ricalcola", marker rvec controls, etc.).
|
||||||
|
- `web/servo.js` (replaced by `web/work_plane.js`).
|
||||||
|
- Endpoints `POST /api/grasp/visual_servo/plan`, `/step`, and the existing
|
||||||
|
`/plane/fit`, `/plane`, `DELETE /plane`.
|
||||||
|
- `_servo_plan_from_req`, `_detect_object_point_cam` and the surrounding
|
||||||
|
visual-servo logic in `grasp_routes.py`.
|
||||||
|
- The bulk of `grasp/visual_servo.py` (everything except the plane bits,
|
||||||
|
which move to `perception/work_plane.py`).
|
||||||
|
|
||||||
|
What survives the removal:
|
||||||
|
- `WorkPlane`, `fit_plane_svd`, `set/get_work_plane`, disk persistence —
|
||||||
|
moved to `perception/work_plane.py`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`WorkPlane` is extended with one new field:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class WorkPlane:
|
||||||
|
normal: list[float] # unit vector in CV frame, points toward camera
|
||||||
|
offset: float # plane equation: normal·X + offset = 0
|
||||||
|
centroid: list[float] # centroid of the ROI inliers, CV frame
|
||||||
|
n_inliers: int # count of points used in the fit
|
||||||
|
fit_rms_mm: float # RMS distance of inliers from the fitted plane
|
||||||
|
roi: list[int] # [u_min, v_min, u_max, v_max]
|
||||||
|
offset_mm: float # NEW: filter tolerance, default 5.0
|
||||||
|
created_at: float # unix timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
On-disk JSON format mirrors the dataclass; loaders that encounter an old
|
||||||
|
file without `offset_mm` default it to `5.0`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Four new endpoints under `/api/work_plane/`. They replace the existing
|
||||||
|
`/api/grasp/visual_servo/plane*` set.
|
||||||
|
|
||||||
|
| Method | Path | Body | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/work_plane/fit` | `{u_min, v_min, u_max, v_max, offset_mm?}` | Compute & return a candidate `WorkPlane`. **Does not write to disk.** Also returns a downsampled cloud (≤5000 points) for GUI 3D preview. |
|
||||||
|
| POST | `/api/work_plane/save` | `{plane: WorkPlane}` | Persist the given plane to `data/work_plane.json` via `set_work_plane`. |
|
||||||
|
| GET | `/api/work_plane` | — | `{plane: WorkPlane | null}` |
|
||||||
|
| DELETE | `/api/work_plane` | — | Remove `data/work_plane.json` and clear the in-memory singleton. |
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Calibration (preview → save)
|
||||||
|
|
||||||
|
```
|
||||||
|
[GUI: shift+drag on RGB image]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
roi = (u_min, v_min, u_max, v_max), offset_mm
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/work_plane/fit { roi, offset_mm }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[backend]
|
||||||
|
pc = nuwa.pointcloud_roi(roi)
|
||||||
|
pts_cv = nuwa_to_cv_frame(pc[:, :3])
|
||||||
|
pts_cv = filter_outliers(pts_cv)
|
||||||
|
normal, offset, centroid, rms_mm = fit_plane_svd(pts_cv)
|
||||||
|
candidate = WorkPlane(normal, offset, centroid, n_inliers, rms_mm,
|
||||||
|
list(roi), offset_mm, time.time())
|
||||||
|
preview_points = subsample(pts_cv, 5000)
|
||||||
|
return { plane: candidate, preview: true,
|
||||||
|
points_camera: preview_points, timings_ms }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[GUI: render plane mesh + colored scatter in 3D]
|
||||||
|
│
|
||||||
|
│ user adjusts offset_mm slider, colors recompute client-side
|
||||||
|
│ user clicks "Salva piano"
|
||||||
|
▼
|
||||||
|
POST /api/work_plane/save { plane: candidate }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[backend: set_work_plane(wp) → write data/work_plane.json]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
return { ok: true, plane: wp, saved: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Below-plane filter applied to object clouds
|
||||||
|
|
||||||
|
A single helper in `perception/work_plane.py` does the actual filtering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def apply_below_plane_filter(
|
||||||
|
pts_cv: np.ndarray, # (N, 3) in CV camera frame, meters
|
||||||
|
wp: WorkPlane | None,
|
||||||
|
) -> tuple[np.ndarray, dict]:
|
||||||
|
"""Returns (filtered_pts_cv, info). info has:
|
||||||
|
plane_applied: bool — true iff wp is not None and filter ran
|
||||||
|
n_points_before_plane: int
|
||||||
|
"""
|
||||||
|
info = {"plane_applied": False,
|
||||||
|
"n_points_before_plane": int(len(pts_cv))}
|
||||||
|
if wp is None or len(pts_cv) == 0:
|
||||||
|
return pts_cv, info
|
||||||
|
n_vec = np.asarray(wp.normal, dtype=np.float64)
|
||||||
|
d_off = float(wp.offset)
|
||||||
|
offset_m = float(wp.offset_mm) / 1000.0
|
||||||
|
signed = pts_cv @ n_vec + d_off # (N,), positive = above plane
|
||||||
|
keep = signed >= -offset_m
|
||||||
|
info["plane_applied"] = True
|
||||||
|
return pts_cv[keep], info
|
||||||
|
```
|
||||||
|
|
||||||
|
This helper is called from **three call sites**, each immediately after the
|
||||||
|
raw cloud is in CV frame and before any `<10` check or `max_points`
|
||||||
|
subsample:
|
||||||
|
|
||||||
|
1. **`points_via_sam21`** (sam21_bridge.py) — after `Sam21Client.segment_pointcloud`
|
||||||
|
returns. On `<10` post-filter, raises `Sam21Fallback("too few points
|
||||||
|
after plane filter")`. The caller falls back to the bbox path, which
|
||||||
|
applies the same filter and may end up with `<10` too → `422` from the
|
||||||
|
bbox helper, which is the right user-facing error.
|
||||||
|
2. **`_points_via_bbox`** (grasp_routes.py) — after `nuwa_to_cv_frame` and
|
||||||
|
before the existing `<10` check. The existing 422 message stays; the
|
||||||
|
meaning is now "after outlier + plane filter".
|
||||||
|
3. **Bbox branch of `_object_cloud_cv`** (perception_routes.py) — after
|
||||||
|
`nuwa_to_cv_frame`, before the `max_points` subsample. If the filter
|
||||||
|
drops the cloud below `<10`, return `(np.empty((0,3)), 'bbox_roi',
|
||||||
|
extra)` and let the caller surface an empty `n_points`; `/scan` is
|
||||||
|
tolerant of empty clouds today (no 422).
|
||||||
|
|
||||||
|
Each call site populates `out["plane_applied"]` and
|
||||||
|
`out["n_points_before_plane"]` from the helper's `info` dict.
|
||||||
|
|
||||||
|
### Response enrichment
|
||||||
|
|
||||||
|
`/api/grasp/plan`, `/api/grasp/execute`, `/api/perception/scan`,
|
||||||
|
`/api/perception/object_points` add three new meta keys:
|
||||||
|
|
||||||
|
- `plane_applied: bool` — true if a `WorkPlane` was loaded and the filter
|
||||||
|
ran (even if it kept all points).
|
||||||
|
- `n_points_before_plane: int` — count before the filter, useful for
|
||||||
|
debugging "I drew the ROI badly".
|
||||||
|
- `n_points` — keeps current meaning (count after every filter).
|
||||||
|
|
||||||
|
## Frame conventions
|
||||||
|
|
||||||
|
- `WorkPlane.normal` and `WorkPlane.offset` live in **CV/OpenCV RGB-camera
|
||||||
|
frame** (the same frame the cloud uses post-`nuwa_to_cv_frame` flip /
|
||||||
|
SAM21 output). No base-frame conversion needed for the filter.
|
||||||
|
- The fit ensures `normal` points "toward the camera" (negative Z in
|
||||||
|
OpenCV), i.e. "up" relative to the table. Objects sitting on the table
|
||||||
|
have positive `signed_distance`.
|
||||||
|
- The filter keeps `signed_distance >= -offset_m`, so `offset_mm > 0`
|
||||||
|
preserves a buffer below the plane. `offset_mm = 0` is an exact cut at
|
||||||
|
the plane; `offset_mm = 10` keeps points up to 10 mm below the plane.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/presa_mano/perception/work_plane.py` | new | `WorkPlane` dataclass (extended with `offset_mm`), `fit_plane_svd`, `set/get_work_plane`, disk persistence. Moved from `grasp/visual_servo.py`. |
|
||||||
|
| `src/presa_mano/grasp/visual_servo.py` | delete | Visual servo removed. |
|
||||||
|
| `src/presa_mano/api/work_plane_routes.py` | new | Router with the four new endpoints (`/fit`, `/save`, `GET`, `DELETE`). |
|
||||||
|
| `src/presa_mano/api/grasp_routes.py` | modify | Remove all visual-servo endpoints and helpers. Apply the below-plane filter inside `_points_via_bbox`. |
|
||||||
|
| `src/presa_mano/perception/sam21_bridge.py` | modify | Apply the below-plane filter on `pts_cv` before the `<10` guard. |
|
||||||
|
| `src/presa_mano/api/perception_routes.py` | modify | Surface `plane_applied`/`n_points_before_plane` in `/scan` and `/object_points` responses. The filter itself runs inside the bridge / bbox helpers (already shared), so no logic change here. |
|
||||||
|
| `src/presa_mano/main.py` | modify | Register the new `work_plane_routes` router; update the cache-bust list (`servo.js` → `work_plane.js`). |
|
||||||
|
| `web/index.html` | modify | Rename the "Servo Visuale" tab to "Calibrazione Piano"; replace the panel content with the new layout. |
|
||||||
|
| `web/servo.js` | delete | Replaced by `web/work_plane.js`. |
|
||||||
|
| `web/work_plane.js` | new | ROI drawing on RGB, fit-preview call, 3D rendering of plane + colored scatter, offset slider, save / clear. |
|
||||||
|
| `web/style.css` | no change expected | Reuse existing `ctrl-block`, `kv compact`, `actions`, `info-box`, `marker-info` selectors. |
|
||||||
|
| `tests/test_work_plane_filter.py` | new | Unit test: synthetic plane + cloud, verify the filter keeps the right points for various offsets. |
|
||||||
|
|
||||||
|
## GUI layout (Calibrazione Piano tab)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Tab: Calibrazione Piano status: piano salvato │
|
||||||
|
├──────────────────────────────────────────┬─────────────────────────────┤
|
||||||
|
│ LEFT │ RIGHT │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─ RGB live (pan/zoom) ──────────────┐ │ ┌─ Info ─────────────────┐ │
|
||||||
|
│ │ IMG + overlay rettangolo viola │ │ │ Calibra il piano: │ │
|
||||||
|
│ │ shift+drag = disegna ROI │ │ │ shift+drag su RGB per │ │
|
||||||
|
│ │ drag = pan · wheel = zoom │ │ │ definire una zona del │ │
|
||||||
|
│ │ doppio click = reset │ │ │ tavolo (no oggetti), │ │
|
||||||
|
│ └────────────────────────────────────┘ │ │ poi "Calcola piano". │ │
|
||||||
|
│ │ └────────────────────────┘ │
|
||||||
|
│ Vista 3D (camera frame) │ │
|
||||||
|
│ ┌─ canvas Three.js ──────────────────┐ │ ROI: u=132,v=220 → 540,420 │
|
||||||
|
│ │ • piano viola translucido │ │ offset (mm): [ 5.0 ] │
|
||||||
|
│ │ • punti ROI: │ │ │
|
||||||
|
│ │ verde = inliers │ │ [Calcola piano (preview)] │
|
||||||
|
│ │ giallo = sopra plane + offset │ │ [Salva piano] │
|
||||||
|
│ │ (filtro tiene) │ │ [Rimuovi piano salvato] │
|
||||||
|
│ │ rosso = sotto plane − offset │ │ │
|
||||||
|
│ │ (filtro scarta) │ │ Stato │
|
||||||
|
│ │ grigio = buffer │ │ piano: saved (RMS 1.8mm) │
|
||||||
|
│ │ drag = ruota · wheel = zoom │ │ n_inliers: 1240 │
|
||||||
|
│ │ RMS fit: 1.8 mm │ │ preview not saved ⚠ │
|
||||||
|
│ └────────────────────────────────────┘ │ │
|
||||||
|
│ │ Dettagli piano │
|
||||||
|
│ │ <pre> JSON </pre> │
|
||||||
|
└──────────────────────────────────────────┴─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Button states:
|
||||||
|
|
||||||
|
| State | Calcola | Salva | Rimuovi |
|
||||||
|
|---|---|---|---|
|
||||||
|
| No ROI | disabled | disabled | enabled iff a saved plane exists |
|
||||||
|
| ROI drawn, no preview | enabled | disabled | (same) |
|
||||||
|
| Preview computed, not saved | enabled (re-fit) | enabled | (same) |
|
||||||
|
| Plane saved | enabled (re-calibrate) | disabled (preview == saved) | enabled |
|
||||||
|
|
||||||
|
The 3D viewer re-colours the scatter client-side when the offset slider
|
||||||
|
changes — no new backend call. The viewer initialises on tab open: if a
|
||||||
|
saved plane exists, it renders the plane mesh alone (no scatter, because the
|
||||||
|
ROI cloud is not persisted).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Origin | Cause | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| Nuwa ROI fetch fails | camera down | 502 from `/work_plane/fit`, GUI shows error toast |
|
||||||
|
| `fit_plane_svd` `<10` inliers | ROI too small / depth mostly zero | 422 from `/work_plane/fit` with "ROI too sparse" |
|
||||||
|
| Negative `offset_mm` | client bug | 422 from `/work_plane/fit` and `/save` |
|
||||||
|
| ROI rectangle outside image | client bug | 422 (Pydantic validation) |
|
||||||
|
| `get_work_plane()` returns `None` | no plane calibrated | filter is a no-op, response has `plane_applied: false` |
|
||||||
|
| Filter drops `<10` points | bad calibration / object too small | `Sam21Fallback("too few points after plane filter")` for SAM21 path, `HTTPException(422)` for bbox path |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Unit** (`tests/test_work_plane_filter.py`):
|
||||||
|
- Synthetic plane at z=1.0 m with normal `[0, 0, -1]` (camera looking down).
|
||||||
|
Synthetic cloud of 100 points sampled in the band `0.95 ≤ z ≤ 1.05`.
|
||||||
|
- `offset_mm=0`: keeps exactly the points with `z ≤ 1.0` (above plane in
|
||||||
|
the camera-facing convention).
|
||||||
|
- `offset_mm=20`: keeps everything (band is ±50 mm but filter window is
|
||||||
|
±20 mm and the plane sits at z=1.0).
|
||||||
|
- `offset_mm=100`: keeps everything (filter wider than the band).
|
||||||
|
- `get_work_plane()` with no file → `None`.
|
||||||
|
- `save` then `get` round-trip preserves `offset_mm`.
|
||||||
|
- Loading an old JSON without `offset_mm` field returns `WorkPlane` with
|
||||||
|
`offset_mm = 5.0`.
|
||||||
|
|
||||||
|
**Live integration** (manual, against the running stack):
|
||||||
|
1. Open GUI tab "Calibrazione Piano", draw ROI on table, click "Calcola
|
||||||
|
piano". 3D shows green inliers tight along the plane mesh, RMS reported.
|
||||||
|
2. Adjust offset to 0; verify yellow points appear above the plane in
|
||||||
|
real-time without a new backend call.
|
||||||
|
3. Click "Salva piano". `data/work_plane.json` exists, content matches the
|
||||||
|
preview.
|
||||||
|
4. Switch to "TrovaUtensile", click "Pianifica grasp". Response has
|
||||||
|
`plane_applied: true`, `n_points_before_plane > n_points`. Scatter views
|
||||||
|
no longer show table-surface points.
|
||||||
|
5. Click "Rimuovi piano salvato". File is gone. `/api/grasp/plan` returns
|
||||||
|
`plane_applied: false`, `n_points_before_plane == n_points`.
|
||||||
|
|
||||||
|
## Out-of-scope follow-ups (logged, not done)
|
||||||
|
|
||||||
|
- The work plane lives in CV camera frame. If we ever need base-frame
|
||||||
|
reasoning ("filter below z=table in base frame"), we can add a
|
||||||
|
base-frame projection — but the current camera-frame filter is sufficient
|
||||||
|
because the object cloud lives in CV frame too.
|
||||||
|
- The current GUI does not visualise the *object* cloud post-filter in the
|
||||||
|
Calibrazione Piano tab. To see that, the user opens the TrovaUtensile
|
||||||
|
tab. Adding a "Test filter on object" button could be done later.
|
||||||
|
- The deleted `visual_servo` code remains in git history at the commit
|
||||||
|
before the removal, so a future re-introduction is a `git revert` away.
|
||||||
Reference in new issue
Block a user