fix: use color intrinsics for back-projection when depth is aligned
The depth stream is aligned to the color frame via rs.align(rs.stream.color), so the depth map lives on the color pixel grid. /api/measure, /api/pointcloud and /api/pointcloud/roi were de-projecting those pixels with the *depth* (ir) intrinsics, inflating X/Y by fx_rgb/fx_ir (~1.54x on the D435i). Z was correct since it only depends on the depth value, not the intrinsics. A 103mm ArUco marker at ~0.73m measured ~157mm; back-projected point clouds did not overlap other calibrated cameras. Fix: centralize intrinsics selection in _backprojection_intrinsics(), keyed on the new RealSenseD435.is_depth_aligned_to_color(). Aligned (USB 3 default) -> rgb intrinsics; no alignment (USB 2 fallback, where color is resized onto the depth grid) -> ir intrinsics. An unconditional ir->rgb swap would have broken the USB 2 path symmetrically. Verified live: /api/measure?u=377&v=68 now returns x=+0.0686 (rgb) instead of +0.1092 (ir). Documented root cause + non-regression check in README and bug_d435i_intrinsics_mismatch.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f1f1277755
commit
8bfdfb194a
4 files changed
+239
-26
No files matched your search
@@ -193,6 +193,14 @@ frontend Nuwa, ma su D4xx significano:
|
|||||||
- `rgb` = intrinseci del **sensore color**
|
- `rgb` = intrinseci del **sensore color**
|
||||||
- `extrinsics_ir_to_rgb` = trasformazione **depth → color** (translation in mm)
|
- `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`)
|
## 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
|
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.
|
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`
|
### `RuntimeError: Failed to resolve request`
|
||||||
|
|
||||||
Pipeline non riesce a fare match tra config richiesta e device. Su G1
|
Pipeline non riesce a fare match tra config richiesta e device. Su G1
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
+21
-26
@@ -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")
|
@router.get("/status")
|
||||||
async def status():
|
async def status():
|
||||||
"""Camera status and intrinsics."""
|
"""Camera status and intrinsics."""
|
||||||
@@ -267,16 +285,7 @@ async def get_pointcloud(
|
|||||||
depth = frame.depth
|
depth = frame.depth
|
||||||
rgb = frame.rgb # BGR format
|
rgb = frame.rgb # BGR format
|
||||||
|
|
||||||
# Use intrinsics if available, otherwise use typical HP60C defaults
|
fx, fy, cx, cy = _backprojection_intrinsics()
|
||||||
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
|
|
||||||
|
|
||||||
h, w = depth.shape
|
h, w = depth.shape
|
||||||
points = []
|
points = []
|
||||||
@@ -338,14 +347,7 @@ async def get_pointcloud_roi(
|
|||||||
if u1 <= u0 or v1 <= v0:
|
if u1 <= u0 or v1 <= v0:
|
||||||
return fail("INVALID_ROI", f"Empty ROI ({u0},{v0})-({u1},{v1}) in {w}x{h}")
|
return fail("INVALID_ROI", f"Empty ROI ({u0},{v0})-({u1},{v1}) in {w}x{h}")
|
||||||
|
|
||||||
if _camera.intrinsics:
|
fx, fy, cx, cy = _backprojection_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
|
|
||||||
|
|
||||||
# Vectorized: restrict both arrays to the ROI and filter by depth mask.
|
# Vectorized: restrict both arrays to the ROI and filter by depth mask.
|
||||||
roi_depth = depth[v0:v1:step, u0:u1:step].astype(np.float32)
|
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])
|
v_found = int(abs_ys[idx])
|
||||||
z_mm = int(depth[v_found, u_found])
|
z_mm = int(depth[v_found, u_found])
|
||||||
|
|
||||||
if _camera.intrinsics:
|
fx, fy, cx, cy = _backprojection_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
|
|
||||||
|
|
||||||
z_m = z_mm / 1000.0
|
z_m = z_mm / 1000.0
|
||||||
x_m = (u_found - cx) * z_m / fx
|
x_m = (u_found - cx) * z_m / fx
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ class RealSenseD435:
|
|||||||
"""False when the pipeline runs depth-only (USB 2.x fallback)."""
|
"""False when the pipeline runs depth-only (USB 2.x fallback)."""
|
||||||
return self._color_available
|
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:
|
def usb_type(self) -> str:
|
||||||
return self._usb_type
|
return self._usb_type
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user