Files
RealSense-D435i-G1/README.md
T
henrytherobotandClaude Opus 4.7 8bfdfb194a 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>
2026-05-27 16:29:30 +02:00

505 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RealSense D435i — API Server (G1)
Container Docker che espone una Intel RealSense D4xx via REST + WebSocket,
con la **stessa shape API del Nuwa-HP60C**: envelope `ApiResponse`,
endpoint `/api/...`, header `X-Api-Key`, WebSocket `/ws`. Pensato come
drop-in replacement del container Nuwa nella pipeline del pestello, così
i client esistenti (`pestello-pose-detector`, frontend React, ecc.)
consumano frame senza modifiche.
Questo repo è il deploy per il robot **Unitree G1** (Jetson Xavier NX,
JetPack 5.1.x): [git.humion.it/.../RealSense-D435i-G1](https://git.humion.it/Labotics-OnGianni/RealSense-D435i-G1).
Nasce come fork del repo H1 originale ([RealSense-D435-H1](https://git.humion.it/Labotics-OnGianni/RealSense-D435-H1)),
dal quale conserva la storia git completa fino al commit di porting su
G1. Da quel punto in poi i due progetti evolvono indipendentemente.
---
## Architettura
```
┌────────────────────┐ RGB JPEG (HTTP) ┌────────────────────────────┐
│ yolo-server │ ◄──────────────────── │ pestello-pose-detector │
│ (su harry) │ ──► bbox + conf │ (su harry) │
└────────────────────┘ │ │
│ │ │
│ │ /api/rgb │
│ │ /api/depth │
│ │ /api/intrinsics │
│ ▼ │
┌───────────────────┴────┐ │
│ RealSense-D435i-G1 ⬅️ │ ← QUESTO REPO │
│ (sul G1, port 8878) │ │
└────────────────────────┘ │
(era: Nuwa-HP60C) │
│
```
Il container fa **solo** acquisizione e serve frame — niente inferenza,
niente pose estimation. Quella logica vive in container separati su harry.
---
## Hardware — G1 vs H1
Il robot G1 ha la D435i nella testa, collegata a un bus **USB 3.2**
nativo del Jetson. È una situazione molto più semplice della H1, dove
la stessa famiglia di camera era cablata su USB 2 e richiedeva
workaround pesanti nel driver.
| | **G1 (questo deploy)** | H1 (vedi `main`) |
|---|---|---|
| Camera | Intel RealSense **D435i** | Intel RealSense D435 |
| Serial / FW | `243722073572` / `05.15.01.55` | `933322070696` / `05.16.00.01` |
| Bus | USB 3.2 (xhci `2-2.1`) | USB 2.1 (hub esterno frontale) |
| Color format | BGR8 nativo | YUYV (convertito in BGR via cv2) |
| Risoluzione effettiva | 640x480 @ 30 fps | 640x480 @ ~4 fps reali |
| Alignment depth↔color | Hardware (`rs.align`) | Approximate (resize numpy) |
| librealsense SDK | **v2.50.0** (pinned, vedi sotto) | v2.55.1 |
Il driver `src/backend/services/camera.py` rileva `usb_type_descriptor`
all'apertura e sceglie il profilo: il path USB 2 è ancora presente per
non rompere la build H1, ma su G1 non viene attivato.
### Perché librealsense v2.50.0 e non l'ultima
Il firmware `05.15.01.55` del D435i della G1 è più vecchio della
"recommended" (`05.13.00.50` ← sì, il numero più basso *è* più nuovo
nella numerazione D4xx, ma il punto è che la nostra è non-standard).
Con librealsense v2.55.x il device si apre regolarmente ma
`wait_for_frames` va in timeout indefinitamente — sembra essere una
regressione del nuovo USB backend contro firmware D4xx vecchio. Pinnando
il container a v2.50.0 (la stessa versione del clone di librealsense
installato sull'host in `/home/unitree/librealsense`) il flusso frame
torna a funzionare. Vedi `Dockerfile` riga 11.
---
## Quick start
### Prerequisito: liberare `/dev/video4`
Sul G1 c'è un servizio Unitree chiamato `video_hub_pc4` che apre
`/dev/video4` (uno dei nodi V4L2 esposti dal D435i) all'avvio del
sistema. Se è attivo, il container non riesce a inizializzare la
pipeline e si vede `RuntimeError: Failed to resolve request`. Fermarlo:
```bash
/unitree/sbin/mscli stopservice video_hub_pc4
```
⚠️ Questo va rifatto a ogni reboot. Per renderlo persistente bisognerebbe
disattivare l'autostart Unitree — non lo abbiamo ancora fatto perché
non sappiamo se serve a qualcos'altro nel sistema.
### Build del frontend (una tantum, lato host)
Il vecchio Dockerfile H1 buildava il React frontend in uno stage Docker.
Sul Jetson del G1, però, `npm` dentro al container non riesce a
risolvere `registry.npmjs.org` (il resolver di default di Docker —
`127.0.0.53` — è lo stub systemd-resolved dell'host, che non funziona
da dentro i container). Quindi buildiamo il frontend una volta sull'host,
e il Dockerfile copia solo il `dist/` già pronto.
```bash
cd ~/robot/RealSense-D435i-G1
docker run --rm --dns 8.8.8.8 \
-v "$(pwd)/src/frontend/react:/app" -w /app \
node:22-alpine sh -c 'npm install && npm run build'
# → produce src/frontend/react/dist/
```
Da rifare solo quando cambi qualcosa sotto `src/frontend/react/`.
### Run
```bash
cd ~/robot/RealSense-D435i-G1
# Build + run (la prima volta build di librealsense da source: ~25 min)
sg docker -c "docker compose up -d"
# Stato
curl http://localhost:8878/api/health
# → {"success":true,"data":{"status":"ok","sdk_initialized":true,"camera_open":true,...}}
# Stop
sg docker -c "docker compose down"
```
Il container ha `restart: unless-stopped`, quindi resta su dopo reboot
del robot (purché `docker.service` parta e tu abbia rifatto lo stop di
`video_hub_pc4`).
### Dal tuo PC (consumare lo stream)
Dall'IP LAN del robot (`192.168.123.99`, eth0) o `ubuntu.local` via mDNS:
```bash
# Verifica connettività
curl http://192.168.123.99:8878/api/health
# Frame singoli
curl http://192.168.123.99:8878/api/rgb -o rgb.jpg
curl http://192.168.123.99:8878/api/depth -o depth.png
# Frontend interattivo
firefox http://192.168.123.99:8878/gui
# Swagger UI
firefox http://192.168.123.99:8878/apidocs
```
Da harry (192.168.123.100), nel `pestello-pose-detector` sostituisci nel
suo `.env` l'URL della camera con `http://192.168.123.99:8878`.
---
## Endpoints
| Endpoint | Metodo | Descrizione |
|---|---|---|
| `/api/health` | GET | Health check (no API key) |
| `/api/status` | GET | Stato SDK/camera, `color_available`, `usb_type`, intrinsics |
| `/api/discover` | GET | Enumera D4xx su USB |
| `/api/open` | POST | Apre camera per `device_index` (di default partita in auto-open) |
| `/api/close` | POST | Chiude camera |
| `/api/capture` | GET | RGB+depth in JSON (base64) |
| `/api/rgb` | GET | RGB come JPEG |
| `/api/depth` | GET | Depth come PNG 16-bit (mm) |
| `/api/depth/color` | GET | Depth colormap JET come JPEG |
| `/api/intrinsics` | GET | Calibrazione completa (`ir`+`rgb`+`extrinsics_ir_to_rgb`) |
| `/api/pointcloud` | GET | Point cloud `[x,y,z,r,g,b]` (param `step`, `max_depth_mm`) |
| `/api/pointcloud/roi` | GET | Point cloud su ROI pixel-space |
| `/api/measure` | GET | Backprojection 3D di un pixel |
| `/api/snapshot` | POST | Salva snapshot RGB su disco |
| `/api/recording/{start,stop,status}` | POST/GET | Registrazione continua |
| `/ws` | WebSocket | Streaming (text: `start`, `stop`, `close`) |
| `/gui` | GET | Frontend React |
| `/apidocs` | GET | Swagger UI |
### Header autenticazione
Tutti gli endpoint sotto `/api/` (eccetto `/api/health`) richiedono
header `X-Api-Key: <valore di API_KEY nel .env>`. Per disabilitare l'auth
in dev, mettere `API_KEY=` (vuoto) — sconsigliato in produzione. Sul G1
attualmente `.env` ha `API_KEY=` vuoto, niente auth.
### Payload calibrazione
`/api/intrinsics` ritorna i campi `ir` e `rgb` per compatibilità con il
frontend Nuwa, ma su D4xx significano:
- `ir` = intrinseci del **sensore depth** (post-rectification dello stereo IR)
- `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`)
```env
API_HOST=0.0.0.0
API_PORT=8878
API_KEY=
# NATS (disabled by default — attivabile col profilo `nats` del compose)
NATS_URL=
NATS_TOKEN=
# Identità servizio
SERVICE_NAME=realsense-g1
SERVICE_DOMAIN=vision
# Stream
CAMERA_WIDTH=640
CAMERA_HEIGHT=480
CAMERA_FPS=30
CAMERA_AUTO_OPEN=1
# CORS
CORS_ORIGINS=*
```
Note:
- `CAMERA_WIDTH/HEIGHT/FPS` su G1 vengono **rispettati** (USB 3 → tutto
il range supportato dalla D435i è disponibile). Sul repo H1 erano
ignorati e forzati a 640x480@15 YUYV.
- `.env` è nel `.gitignore` — non viene committato. Tieni una copia di
backup; `.env.example` è la struttura di riferimento.
---
## Modello hardware corrente del robot G1
```
Jetson Xavier NX (G1)
├─ Bus 1 (USB 2.0, 480M)
│ └─ (varia in base a periferiche collegate — non usato dalla D435i)
└─ Bus 2 (USB 3.0, 10000M)
└─ Hub interno testa → D435i ← qui, /dev/video0…video5
```
- D435i serial: `243722073572`, firmware `05.15.01.55`, `usb_type = 3.2`
- Bus path: `/sys/devices/platform/3610000.xhci/usb2/2-2/2-2.1`
- VID:PID `8086:0b3a` (D435i depth+IR+RGB module)
- Robot IP: `192.168.123.99` (eth0), `192.168.1.85` (wlan0 DHCP)
### Servizio Unitree da fermare
`video_hub_pc4` apre `/dev/video4` all'avvio (vedi
[Prerequisito](#prerequisito-liberare-devvideo4)). Se al boot lasci che
parta, il container backend non riesce a inizializzare la pipeline.
---
## Workflow di sviluppo
Il codice "live" gira sul robot. Per modifiche:
### Opzione A — Modifichi sul robot, sync su harry, push da harry
```bash
# 1. Sul robot G1
cd ~/robot/RealSense-D435i-G1
# ...modifiche...
# (se hai toccato il frontend)
docker run --rm --dns 8.8.8.8 \
-v "$(pwd)/src/frontend/react:/app" -w /app \
node:22-alpine sh -c 'npm install && npm run build'
sg docker -c "docker compose build backend && docker compose up -d --force-recreate"
curl http://localhost:8878/api/health # verifica
# 2. Rsync su harry (esclude .env, models/, images/, __pycache__, dist/)
rsync -avz --delete \
--exclude='.env' --exclude='__pycache__' --exclude='*.pyc' \
--exclude='.pytest_cache' --exclude='.venv' \
--exclude='node_modules' --exclude='src/frontend/react/dist' \
--exclude='images/*' --exclude='models/*' \
--exclude='.claude' --exclude='.omc' \
~/robot/RealSense-D435i-G1/ \
harry@192.168.123.100:/opt/docker/robotserver/RealSense-D435i-G1/
# 3. Push da harry (il G1 non può push diretto a git.humion.it — vedi sotto)
# Assicurati che harry abbia un clone del nuovo repo RealSense-D435i-G1
# in /opt/docker/robotserver/RealSense-D435i-G1 (clone una tantum).
ssh harry@192.168.123.100
cd /opt/docker/robotserver/RealSense-D435i-G1
git add . && git commit -m "..." && git push
```
### Opzione B — Modifichi su harry, deploy sul robot
```bash
# 1. Su harry
cd /opt/docker/robotserver/RealSense-D435i-G1
git pull # o sviluppa direttamente
# ...modifiche...
git add . && git commit -m "..." && git push
# 2. Sul robot G1
cd ~/robot/RealSense-D435i-G1
rsync -avz --delete \
--exclude='.env' --exclude='__pycache__' --exclude='*.pyc' \
--exclude='images/*' --exclude='models/*' \
--exclude='.claude' --exclude='.omc' \
harry@192.168.123.100:/opt/docker/robotserver/RealSense-D435i-G1/ \
~/robot/RealSense-D435i-G1/
# rifare la build frontend se è cambiata, poi:
sg docker -c "docker compose build backend && docker compose up -d --force-recreate"
```
Nota: come sull'H1, anche sul G1 l'SSH verso `git.humion.it:222` passa
tipicamente da tinyproxy locale e non funziona "out of the box". Se in
futuro vuoi pushare diretto dal robot, va aggiunta una regola in
`~/.ssh/config`. Per ora si passa da harry.
---
## Risoluzione di problemi noti
### `wait_for_frames failed: Frame didn't arrive within 5000`
Su G1 questo significa quasi sempre **una di due cose**:
1. `video_hub_pc4` è ancora attivo e tiene `/dev/video4`. Verifica con
`lsof /dev/video4` o `fuser /dev/video4`. Stoppalo come sopra.
2. Hai (re)buildato il container con una librealsense diversa da
v2.50.0. Il D435i fw `05.15.x` non funziona con v2.55+. Controlla
`Dockerfile` riga 11 — deve essere `ARG RS_VERSION=v2.50.0`.
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
in pratica significa: device non disponibile (occupato da
`video_hub_pc4`) o cavo USB scollegato.
### `pyrealsense2` ha `AttributeError: module ... has no attribute 'context'`
Il bug install di `librealsense` (il `.so` principale non viene installato
dal CMake in alcune versioni) è risolto nel `Dockerfile` copiando
direttamente i `.so` dal build dir. Per verificare:
```bash
sg docker -c "docker compose exec backend python3 -c \
'import pyrealsense2 as rs; print(hasattr(rs, \"context\"))'"
# → True
```
Vedi `Dockerfile` righe ~40-50 (sezione "Collect both the python
bindings and the native runtime .so").
### `npm install` fallisce con `getaddrinfo ENOTFOUND registry.npmjs.org`
Stai cercando di buildare il frontend dentro Docker senza override DNS.
Il resolver di default di Docker eredita `127.0.0.53` (systemd-resolved
stub), che non funziona dentro i container. Usa `--dns 8.8.8.8`:
```bash
docker run --rm --dns 8.8.8.8 -v "$(pwd)/src/frontend/react:/app" \
-w /app node:22-alpine sh -c 'npm install && npm run build'
```
### Voglio attivare il bottone "Predict" nella GUI
Quel bottone è un avanzo del codice copiato dal Nuwa, ma in produzione
non viene usato — l'inferenza YOLO gira sul container `yolo-server` su
harry (con il modello custom `best.onnx` per il pestello). Vedi
[architettura](#architettura).
Per attivare il detector inline (sconsigliato per la pipeline pestello,
ma utile per test):
1. Aggiungere `onnxruntime` nel `Dockerfile`
2. Copiare un modello in `models/best.onnx`
3. Settare `YOLO_*` nel `.env`
### Posso pushare/pullare git direttamente dal robot?
Al momento no — il tinyproxy del robot blocca l'SSH verso `git.humion.it:222`.
Workaround: `rsync` da/verso harry e push da lì. Vedi
[Workflow di sviluppo](#workflow-di-sviluppo).
---
## Struttura del repo
```
RealSense-D435i-G1/
├── Dockerfile # multi-stage: librealsense v2.50.0 + runtime
├── docker-compose.yml # backend + nats opzionale + frontend-dev
├── .env / .env.example # config (NON committare .env)
├── pyproject.toml # deps Python (per uv/pip)
├── README.md # questo file (G1)
├── STATUS.md # snapshot ultima sessione di lavoro (H1)
├── scripts/
│ └── entrypoint.sh
├── src/backend/
│ ├── main.py # FastAPI app
│ ├── config.py # pydantic settings
│ ├── api/
│ │ ├── routes.py # REST endpoints
│ │ ├── websocket.py # /ws streaming
│ │ └── dependencies.py # API key auth
│ └── services/
│ ├── camera.py # ⭐ driver RealSense (detection USB 2/3, YUYV, etc.)
│ ├── detector.py # wrapper ONNX YOLO (non usato in produzione)
│ └── image_saver.py
├── src/frontend/react/ # GUI a /gui
│ └── dist/ # ⚠️ buildare a mano sull'host (vedi Quick start)
├── tests/ # pytest, da rilanciare quando si tocca services/
├── models/ # vuoto — modello sta su yolo-server, non qui
└── images/ # output snapshot/recording, gitignored
```
---
## Repo correlati
- **[RealSense-D435i-G1](https://git.humion.it/Labotics-OnGianni/RealSense-D435i-G1)** — questo repo. Deploy G1 (D435i su USB 3.2, BGR8, 30 fps).
- **[RealSense-D435-H1](https://git.humion.it/Labotics-OnGianni/RealSense-D435-H1)** — repo di origine, deploy storico H1 (D435 su USB 2, YUYV, ~4 fps).
Il driver Python è lo stesso su entrambi i repo (almeno al momento del
fork): distingue il path runtime leggendo `usb_type_descriptor` del
device. Le differenze tra i due repo sono nel `Dockerfile` (versione
SDK librealsense, stage di build del frontend) e nel `.env` (identità
servizio). Patch ai due path runtime andrebbero portate manualmente da
un repo all'altro quando rilevanti.
---
## Riferimenti
- [librealsense](https://github.com/IntelRealSense/librealsense) — buildato da source perché manca wheel ARM64. Su G1 pinnato a `v2.50.0` per compatibilità col firmware `05.15.x` del D435i.
- [Nuwa-HP60C](https://git.humion.it/Labotics-gianniact/) — container originale di cui questo è il drop-in equivalent
- [yolo-server](https://git.humion.it/Labotics-gianniact/) — microservizio YOLO usato da pestello-pose-detector
- [pestello-pose-detector](https://git.humion.it/Labotics-gianniact/pestello-pose-detector) — consumer reale di questo stream