diff --git a/README.md b/README.md index e321da8..0aa455f 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,14 @@ frontend Nuwa, ma su D4xx significano: - `rgb` = intrinseci del **sensore color** - `extrinsics_ir_to_rgb` = trasformazione **depth → color** (translation in mm) +**Quali intrinseci usa la back-projection** (`/api/measure`, `/api/pointcloud`, +`/api/pointcloud/roi`): dipende dall'allineamento depth→color (vedi +[bug intrinsics mismatch](#punti-3d-stirati-del-54-su-xy-intrinsics-mismatch)). +Su USB 3 (default) la depth è allineata al frame color, quindi si usano gli +intrinseci **`rgb`**; su USB 2 (no alignment) si usano gli **`ir`**. La scelta +è centralizzata in `_backprojection_intrinsics()` e il campo `intrinsics` nella +risposta riporta sempre quelli effettivamente usati. + --- ## Configurazione (`.env`) @@ -333,6 +341,62 @@ Su G1 questo significa quasi sempre **una di due cose**: Se invece stai lavorando sul repo H1, su USB 2 questo errore è quasi sempre un mismatch di profilo color (BGR8 vs YUYV) — vedi STATUS.md. +### Punti 3D stirati del ~54% su XY (intrinsics mismatch) + +Riguarda `/api/measure`, `/api/pointcloud`, `/api/pointcloud/roi`. + +**Sintomo.** Le coordinate `(X, Y, Z)` restituite dalla back-projection sono +gonfiate lateralmente di un fattore ~1.54 su X e Y, mentre Z (depth) è corretto. +Un marker ArUco da 103 mm a ~0.73 m veniva misurato ~157 mm; i point cloud del +D435i non si sovrapponevano a quelli di altre camere calibrate. Diagnosticato e +fixato il 2026-05-27 (vedi `bug_d435i_intrinsics_mismatch.md`). + +**Root cause.** La pipeline allinea la depth al frame **color** con +`rs.align(rs.stream.color)` ([`services/camera.py`](src/backend/services/camera.py)), +quindi dopo l'alignment la depth vive sulla **griglia di pixel del color**. Ma +i tre endpoint di back-projection usavano gli intrinseci del **sensore depth** +(`fx_ir`, `cx_ir`, …) per de-proiettare quei pixel. Risultato: X e Y inflati +esattamente del rapporto delle focali + +``` +fx_rgb / fx_ir = 603.88 / 392.31 ≈ 1.539 +``` + +Z resta corretto perché dipende solo dal valore di depth letto al pixel, non +dagli intrinseci. Verifica numerica sul pixel `(377, 68)`, `z = 0.732 m`: + +``` +IR (errato): X = (377 − 318.48) × 0.732 / 392.31 = 0.1092 m ← valore restituito (buggy) +RGB (corretto): X = (377 − 320.62) × 0.732 / 603.88 = 0.0683 m ← valore atteso +``` + +**Fix.** La scelta degli intrinseci è ora condizionale all'allineamento, in un +unico helper `_backprojection_intrinsics()` ([`api/routes.py`](src/backend/api/routes.py)) +usato da `/api/measure`, `/api/pointcloud`, `/api/pointcloud/roi`: + +- depth allineata al color (USB 3, default) → intrinseci **`rgb`**; +- nessun alignment (fallback USB 2, dove il color è invece *resize*-ato sulla + griglia depth) → intrinseci **`ir`** (comportamento corretto per quel ramo). + +Lo stato di allineamento è esposto dal metodo `is_depth_aligned_to_color()` +(`= self._align is not None`) su `RealSenseD435`. Un fix incondizionato IR→RGB +avrebbe rotto il percorso USB 2 in modo speculare: per questo è condizionale. + +**Verifica di non-regressione.** Dopo il fix, sul pixel `(377, 68)`: + +```bash +curl -sS "http://localhost:8878/api/measure?u=377&v=68&radius=8" +# point_m.x ≈ +0.069 (RGB, corretto) — non più +0.109 (IR, buggy) +``` + +In generale, per ogni pixel `(u, v)` con depth `z` deve valere +`x ≈ (u − cx_rgb)·z / fx_rgb` (entro la tolleranza di distorsione), e un marker +di lato noto back-proiettato deve dare lo stesso lato ±5 mm a 0.7 m. + +> **Nota lato client.** Il workaround temporaneo nel viewport 3D di presa-mano +> (campo `D435i scale XY` con default `0.65 = fx_ir/fx_rgb`) va riportato a +> `1.0` ora che il server è corretto. + ### `RuntimeError: Failed to resolve request` Pipeline non riesce a fare match tra config richiesta e device. Su G1 diff --git a/bug_d435i_intrinsics_mismatch.md b/bug_d435i_intrinsics_mismatch.md new file mode 100644 index 0000000..b7eb03f --- /dev/null +++ b/bug_d435i_intrinsics_mismatch.md @@ -0,0 +1,144 @@ +# Bug report — D435i camera service: intrinsics mismatch nel back-projection 3D + +**Servizio:** `front_camera` (RealSense D435i) — `http://192.168.123.99:8878` +**Severità:** alta — punti 3D ritornati con XY sovrastimati di ~54% +**Endpoint affetti:** almeno `/api/measure`; molto probabilmente anche `/api/pointcloud` e `/api/pointcloud/roi` +**Endpoint NON affetti:** `/api/intrinsics` (espone i parametri corretti) +**Data test:** 2026-05-27 +**Reporter:** sviluppo presa-mano + +--- + +## 1. Sintomo + +Le coordinate 3D `(X, Y, Z)` ritornate dal back-projection lato server sono **stirate** lateralmente di un fattore **~1.54** sugli assi X e Y, mentre Z (depth) è plausibile. + +Conseguenza: oggetti di dimensioni note appaiono ~50% più grandi del reale in qualsiasi misurazione 3D, e i point cloud non si sovrappongono ad altre camere correttamente calibrate. + +--- + +## 2. Test di verifica + +Marker ArUco fisico di **lato 103.0 mm** (`id=20`, dictionary DICT_4X4_50) posizionato davanti al D435i a ~735 mm. + +### 2.1 Intrinsics riportate dal servizio (`GET /api/intrinsics`) + +```json +{ + "ir": { "fx": 392.30963134765625, "fy": 392.30963134765625, + "cx": 318.48004150390625, "cy": 242.43441772460938 }, + "rgb": { "fx": 603.88037109375, "fy": 603.6130981445312, + "cx": 320.6182556152344, "cy": 250.35804748535156 }, + "extrinsics_ir_to_rgb": { "rotation": [...], "translation": [14.68, -0.07, 0.29] } +} +``` + +### 2.2 Detect del marker (su RGB image del D435i) + +Marker-finder ritorna le 4 corner del tag nel sistema **pixel del frame RGB**: + +``` +corners (RGB px): [(377, 68), (372, 152), (289, 147), (294, 63)] +``` + +### 2.3 `GET /api/measure?u=&v=&radius=3` per ciascuna corner + +``` +pixel (377, 68) → x=+0.1092 y=-0.3255 z=0.732 m +pixel (372, 152) → x=+0.0999 y=-0.1687 z=0.732 m +pixel (289, 147) → x=-0.0554 y=-0.1793 z=0.737 m +pixel (294, 63) → x=-0.0461 y=-0.3375 z=0.738 m +``` + +### 2.4 Misurazioni geometriche dai punti 3D ritornati + +| Misura | Atteso | Ottenuto | Errore | +|-----------------|---------------|---------------|----------| +| Lato C0–C1 | 103.00 mm | 157.13 mm | **+52.6%** | +| Lato C1–C2 | 103.00 mm | 155.71 mm | **+51.2%** | +| Lato C2–C3 | 103.00 mm | 158.48 mm | **+53.9%** | +| Lato C3–C0 | 103.00 mm | 155.92 mm | **+51.4%** | +| Diagonale 0–2 | 145.66 mm | 220.19 mm | **+51.2%** | +| Diagonale 1–3 | 145.66 mm | 223.32 mm | **+53.3%** | +| Z medio (depth) | — | 735 mm | plausibile | + +L'errore è **omogeneo ~52%** su tutte le coppie. Z (depth) è coerente — l'errore è confinato a X, Y. + +--- + +## 3. Diagnosi (root cause) + +Il rapporto fra le focali è esattamente: + +``` +fx_rgb / fx_ir = 603.88037 / 392.30963 = 1.5393 +``` + +L'errore osservato sui lati è **1.524 ± 0.014**. + +Verifica numerica della corner `(u=377, v=68)`, depth `z=0.732 m`: + +``` +Con RGB intrinsics (corretto): X = (377 − 320.62) × 0.732 / 603.88 = 0.0683 m +Con IR intrinsics (errato): X = (377 − 318.48) × 0.732 / 392.31 = 0.1091 m +Server ritorna: = 0.1092 m ← coincide con IR +``` + +**Conclusione:** il servizio sta usando le **intrinsics IR** (`fx=392.31, cx=318.48`) per back-projettare pixel che sono in **coordinate RGB** (allineamento depth-to-color attivo). Il risultato è X e Y inflati di `fx_rgb/fx_ir ≈ 1.54×`. + +Il Z è corretto perché dipende solo dal valore di depth letto al pixel, non dalle intrinsics. + +--- + +## 4. Fix consigliato + +Nella routine di back-projection (probabilmente in `/api/measure`, `/api/pointcloud`, `/api/pointcloud/roi`): + +```python +# ERRATO (sospetto attuale): +X = (u - cx_ir) * z / fx_ir +Y = (v - cy_ir) * z / fy_ir + +# CORRETTO (depth allineata al frame color → usa RGB intrinsics): +X = (u - cx_rgb) * z / fx_rgb +Y = (v - cy_rgb) * z / fy_rgb +``` + +Se la pipeline NON sta facendo `align_to(color)` sul depth stream, allora il fix opposto: i pixel passati devono essere quelli del frame IR e si usano le intrinsics IR. Ma la semantica attuale (`/api/rgb` espone il frame color, `/api/measure` accetta `(u,v)` con range coerente col color frame ~640×480) suggerisce che l'API è progettata per RGB-aligned depth — quindi è il back-projection lato server che ha l'intrinsics sbagliata. + +--- + +## 5. Test di non-regressione consigliati + +Dopo il fix, verificare che: + +1. **Tag di dimensione nota** (es. 103mm): le 4 corner back-proiettate formano un quadrato con lato `103.00 ± 5 mm` a 0.7m di distanza. +2. **Coerenza con `/api/intrinsics`**: per ogni pixel `(u, v)` con depth `z`, vale: + ``` + x_returned ≈ (u - cx_rgb) * z / fx_rgb (entro tolleranza distorsione) + y_returned ≈ (v - cy_rgb) * z / fy_rgb + ``` +3. **Cross-camera consistency**: il point cloud del D435i di un oggetto sovrapposto al cloud Nuwa (eye-in-hand polso DX) dello stesso oggetto deve avere errore residuo `< 30 mm` dopo hand-eye calibration. + +--- + +## 6. Workaround temporaneo (lato client) + +Nel viewport 3D di presa-mano è stato aggiunto un campo `D435i scale XY` con default **0.65** (`= fx_ir / fx_rgb`) che riscala il cloud per compensare il bug. Da rimuovere/portare a 1.0 quando il server è fixato. + +--- + +## 7. Comandi per riprodurre + +```bash +# 1) Verifica intrinsics +curl -sS http://192.168.123.99:8878/api/intrinsics + +# 2) Detect marker (presuppone marker-finder a :8082 e tag 20 visibile) +curl -sS -X POST http://localhost:8082/detect \ + -F "image_url=http://192.168.123.99:8878/api/rgb" \ + -F 'markers=[{"id":20,"size_mm":103}]' + +# 3) Per ogni corner (u,v) ritornata, misura 3D +curl -sS "http://192.168.123.99:8878/api/measure?u=377&v=68&radius=3" +``` diff --git a/src/backend/api/routes.py b/src/backend/api/routes.py index 4e9d5f0..4aaed83 100644 --- a/src/backend/api/routes.py +++ b/src/backend/api/routes.py @@ -117,6 +117,24 @@ def _calibration_payload() -> dict | None: } +def _backprojection_intrinsics() -> tuple[float, float, float, float]: + """(fx, fy, cx, cy) for back-projecting a depth pixel to 3D. + + Picks the intrinsics that match the grid the depth map lives on: + - depth aligned to color (USB 3 default) -> color (rgb) intrinsics + - no alignment (USB 2 fallback) -> depth (ir) intrinsics + Using the ir intrinsics on an aligned (color-grid) depth map inflates + X/Y by fx_rgb/fx_ir (~1.54x on the D435i). + """ + if _camera and _camera.intrinsics: + i = _camera.intrinsics + if _camera.is_depth_aligned_to_color(): + return i.fx_rgb, i.fy_rgb, i.cx_rgb, i.cy_rgb + return i.fx_ir, i.fy_ir, i.cx_ir, i.cy_ir + # Default intrinsics for 640x480 when calibration is unavailable. + return 445.0, 445.0, 320.0, 240.0 + + @router.get("/status") async def status(): """Camera status and intrinsics.""" @@ -267,16 +285,7 @@ async def get_pointcloud( depth = frame.depth rgb = frame.rgb # BGR format - # Use intrinsics if available, otherwise use typical HP60C defaults - if _camera.intrinsics: - fx = _camera.intrinsics.fx_ir - fy = _camera.intrinsics.fy_ir - cx = _camera.intrinsics.cx_ir - cy = _camera.intrinsics.cy_ir - else: - # Default intrinsics for 640x480 HP60C - fx, fy = 445.0, 445.0 - cx, cy = 320.0, 240.0 + fx, fy, cx, cy = _backprojection_intrinsics() h, w = depth.shape points = [] @@ -338,14 +347,7 @@ async def get_pointcloud_roi( if u1 <= u0 or v1 <= v0: return fail("INVALID_ROI", f"Empty ROI ({u0},{v0})-({u1},{v1}) in {w}x{h}") - if _camera.intrinsics: - fx = _camera.intrinsics.fx_ir - fy = _camera.intrinsics.fy_ir - cx = _camera.intrinsics.cx_ir - cy = _camera.intrinsics.cy_ir - else: - fx, fy = 445.0, 445.0 - cx, cy = 320.0, 240.0 + fx, fy, cx, cy = _backprojection_intrinsics() # Vectorized: restrict both arrays to the ROI and filter by depth mask. roi_depth = depth[v0:v1:step, u0:u1:step].astype(np.float32) @@ -431,14 +433,7 @@ async def measure_point( v_found = int(abs_ys[idx]) z_mm = int(depth[v_found, u_found]) - if _camera.intrinsics: - fx = _camera.intrinsics.fx_ir - fy = _camera.intrinsics.fy_ir - cx = _camera.intrinsics.cx_ir - cy = _camera.intrinsics.cy_ir - else: - fx, fy = 445.0, 445.0 - cx, cy = 320.0, 240.0 + fx, fy, cx, cy = _backprojection_intrinsics() z_m = z_mm / 1000.0 x_m = (u_found - cx) * z_m / fx diff --git a/src/backend/services/camera.py b/src/backend/services/camera.py index cf4a5c3..5ff5ae4 100644 --- a/src/backend/services/camera.py +++ b/src/backend/services/camera.py @@ -132,6 +132,16 @@ class RealSenseD435: """False when the pipeline runs depth-only (USB 2.x fallback).""" return self._color_available + def is_depth_aligned_to_color(self) -> bool: + """True when depth is hardware-aligned to the color frame. + + When aligned (USB 3 default), the depth map lives on the **color** + pixel grid, so back-projecting a pixel must use the color (rgb) + intrinsics. On USB 2 the align step is skipped and color is resized + onto the depth grid instead, so the depth (ir) intrinsics apply. + """ + return self._align is not None + def usb_type(self) -> str: return self._usb_type