OliviaCandClaude Opus 5.5 11e5bf5af5 fix: compare_host_align refuses pyrealsense2 without software depth units
Tried on pyrealsense2 2.50 (PC2's version): software_video_frame has no
depth_units there, the software depth frames get scale 0 and rs.align
returns an almost empty depth (1 valid pixel instead of 306565) with NO
error. The read-only depth_units option on the sensor is not enough.
The script now checks for the attribute and stops with an explicit
message; 2.58 (this PC) works. rs.__version__ does not exist in 2.50
either: read with getattr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:18:04 +02:00

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. Nasce come fork del repo H1 originale (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:

/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.

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

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:

# 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 (colore del frame allineato: stesso frame di /api/depth)
/api/raw/latest GET Frameset grezzo per allineare sull'host: JSON con colore (JPEG, griglia colore) e profondità (PNG 16 bit in mm, griglia depth) dello stesso frameset, senza align, più frameset_seq (stessa numerazione di /api/color/latest e del frameset_seq di /api/capture), camera_timestamp_ms, timestamp_domain, host_timestamp. after=N / timeout_ms come /api/color/latest. Calibrazione per allineare: /api/intrinsics
/api/color/latest GET Canale colore veloce: JPEG dell'ultimo frame colore, senza align, al ritmo della camera. after=N aspetta un frame numerato oltre N (fino a timeout_ms, default 1000, max 5000; 504 se non arriva). Header X-Frame-Seq, X-Camera-Timestamp-Ms (orologio della camera), X-Camera-Timestamp-Domain, X-Host-Timestamp (arrivo sull'host, s). Non va usato insieme a /api/depth: non è lo stesso frame
/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). 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)

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). 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

# 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

# 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), 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) 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):

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:

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:

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.

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.


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

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 — 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 — container originale di cui questo è il drop-in equivalent
  • yolo-server — microservizio YOLO usato da pestello-pose-detector
  • pestello-pose-detector — consumer reale di questo stream
S
Description
No description provided
Readme
389 KiB
0 Stars 2 Watchers 0 Forks
Languages
Python 72.8%
JavaScript 23.5%
Dockerfile 2.7%
CSS 0.5%
Shell 0.3%
Other 0.2%