Files
dora-zed/README.md
T
cesarandClaude Opus 5 ee48948de9 add a dora-node.yml manifest and a dora validate CI gate
No source package is shared between node repositories, so the manifest and its
type URNs are the only contract a consumer has. Declaring them makes a wiring
mistake fail at validate time with the producer and consumer URNs, instead of
at runtime.

Every payload this node sends is a raw buffer whose shape and dtype live in the
metadata rather than an Arrow struct, so the URNs name the element type:
UInt8 for image_bgr, Float32 for depth, camera_info and point_cloud, String for
camera_state. tick is UInt64, matching how dora types timer inputs.

dataflow.yml now builds the node from the checkout with path: + build:. A
git:-sourced node does not pick up an adjacent manifest, so validating the
previous form checked nothing; with path: the CLI reports "injected 6 contract
type(s)" and a deliberately mismatched annotation exits non-zero under
strict_types. The README keeps the tag: form for consumers.

The workflow needs no camera and no ZED SDK, since validation never runs the
node. Also ignores the git/ and out/ working directories dora build creates,
and drops the ignore rules for the deleted test assets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7g2cBJ5e3r15ZbJ9WZn8c
2026-09-08 09:34:12 -03:00

223 lines
10 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.
# dora-zed
Dora node for ZED stereo cameras. Publishes BGR images, point clouds and camera intrinsics.
## Usage
Add to your dataflow:
```yaml
nodes:
- id: zed-camera
git: ssh://git@gitea.liria.cl:5001/liria/dora-zed.git
tag: v0.2.0
build: scripts/build.sh
path: bin/dora_zed
inputs:
tick: dora/timer/millis/100
outputs:
- image_bgr
- depth
- point_cloud
- camera_info
- camera_state
```
See [`dataflow.yml`](dataflow.yml) for a complete runnable example with a camera display
node. That file builds the node from the checkout (`path:` + `build:`) rather than from a
tag, which is what lets `dora validate` pick up the manifest described below.
### Node manifest
[`dora-node.yml`](dora-node.yml) declares the node's inputs and outputs with type URNs.
No source package is shared between node repositories, so it is the only contract a
consumer has, and it is what makes a wiring mistake fail at build time instead of at
runtime:
```
$ dora validate dataflow.yml
node "zed-camera": injected 6 contract type(s) from .../dora-node.yml
All type annotations OK.
```
Types are `std/core/v1/UInt8` for `image_bgr`, `std/core/v1/Float32` for `depth`,
`camera_info` and `point_cloud`, and `std/core/v1/String` for `camera_state`; every
payload is a raw buffer whose shape and dtype are in the metadata, so the URN names the
element type rather than a struct. The `tick` input is `std/core/v1/UInt64`, matching how
dora types timer inputs.
`strict_types: true` in `dataflow.yml` makes a type warning a non-zero exit.
[`.gitea/workflows/validate.yml`](.gitea/workflows/validate.yml) runs
`dora validate dataflow.yml` on every push and pull request; it needs no camera and no
ZED SDK, since validation never runs the node.
## Dependencies
| Dependency | Version | Notes |
|---|---|---|
| [dora-rs](https://github.com/dora-rs/dora) | 1.0+ | Verified against `dora-cli 1.0.1`; the C++ bindings are built from source at configure time |
| [CMake](https://cmake.org/download/) | 3.21+ | |
| [ZED SDK](https://www.stereolabs.com/developers/release) | 5.x | Bundles OpenCV; default path `/usr/local/zed` |
| [CUDA Toolkit](https://developer.nvidia.com/cuda-downloads) | — | Required by ZED SDK |
| [Rust toolchain](https://rustup.rs) | stable | Builds dora-rs C++ bindings |
### Installation scripts
| Platform | Script |
|---|---|
| Ubuntu 24.04 x86_64 | `sudo scripts/install_linux_x64.sh` |
| NVIDIA Jetson (ARM64) | `sudo scripts/install_linux_arm.sh` |
| Windows x64 (PowerShell admin) | `scripts/install_windows.ps1` |
> **Windows:** Install the ZED SDK manually from [stereolabs.com](https://www.stereolabs.com/developers/release).
## Local build
```bash
git clone ssh://git@gitea.liria.cl:5001/liria/dora-zed.git
cd dora-zed
scripts/build.sh
```
Binary installed to `bin/dora_zed`.
`scripts/build.sh` is also the `build:` command in the dataflow. dora 1.0 runs build
commands directly rather than through a shell, so `cd`, `&&` and command substitution are
not available there; keeping the sequence in one script is what makes the dataflow build
work. It configures a `Release` build, which is also what links the optimized dora
bindings.
### Containerized build
[`Dockerfile`](Dockerfile) provides the full toolchain — CUDA, ZED SDK and Rust — on Ubuntu 24.04, the newest release Stereolabs ships a ZED SDK for. No camera is needed.
```bash
docker build -t dora-zed-build .
docker run --rm --gpus all --user "$(id -u):$(id -g)" \
-v "$PWD:/src" -v dora-zed-cargo:/usr/local/cargo/registry dora-zed-build
```
The container runs the same `scripts/build.sh`.
The first run clones dora-rs and compiles it with Cargo; the named volume keeps that cache across runs.
`--user` keeps `build/` and `bin/` owned by you rather than root.
x86-64 only. On Jetson, run [`scripts/install_linux_arm.sh`](scripts/install_linux_arm.sh) on the
device and build natively — there is no ARM64 image here.
Needs an NVIDIA driver and the
[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
on the host, as the ZED SDK does anyway. The image sets
`NVIDIA_DRIVER_CAPABILITIES=compute,utility,video`: `libsl_zed.so` links against NVDEC
(`libnvcuvid`) and NVENC (`libnvidia-encode`), which the container runtime mounts from the host
only when `video` is in the capability list.
## Configuration
All behavior is controlled via environment variables.
### Camera
| Variable | Default | Description |
|---|---|---|
| `ZED_RESOLUTION` | `1080` | `720`, `1080`, or `2000` |
| `ZED_FPS` | `15` | Frame rate |
| `ZED_DEPTH_MODE` | `NEURAL` | `NEURAL`, `NEURAL_PLUS`, `NEURAL_LIGHT`, `PERFORMANCE`, `QUALITY`, or `NONE`. An unrecognised value is a startup error, not a fallback. |
| `ZED_DEPTH_MIN_MM` | `200` | Minimum depth (mm) |
| `ZED_DEPTH_MAX_MM` | `600` | Maximum depth (mm) |
| `ZED_CONFIDENCE_THRESHOLD` | `60` | Reject depth less certain than this (1–100, lower is stricter) |
| `ZED_TEXTURE_CONFIDENCE_THRESHOLD` | `100` | Reject depth on surfaces less textured than this (1–100) |
| `ZED_DEPTH_FILL` | `false` | Interpolate depth over holes. Overrides both confidence thresholds. |
| `ZED_FLIP` | `ON` | `ON` or `OFF` |
| `ZED_WARMUP_FRAMES` | `30` | Frames to discard on startup |
The resolved depth mode, both confidence thresholds and the depth range are logged at
startup. The defaults match the reference camera driver the perception stack was tuned
against, so depth quality on the robot matches what it expects.
### Point cloud
Off by default. With `ZED_POINT_CLOUD` unset the XYZRGBA measure is not retrieved at all
and no `point_cloud` output is published — at 1080p a full cloud is roughly 14.7 MB per
frame, and retrieving it costs capture time whether or not anything consumes it.
Consumers that need 3D back-project `depth` with the published intrinsics instead.
| Variable | Default | Description |
|---|---|---|
| `ZED_POINT_CLOUD` | `false` | Retrieve and publish the point cloud |
| `ZED_PC_MODE` | `FULL` | Only applies when `ZED_POINT_CLOUD` is on |
| `ZED_PC_MODE` | Shape | Description |
|---|---|---|
| `FULL` (default) | `[H, W, 4]` | Full resolution grid (~14.7 MB/frame at 1080p) |
| `HALF` | `[H/2, W/2, 4]` | Stride-2 downsample (~4× smaller) |
## Inputs
| Input | Description |
|---|---|
| `tick` | Triggers a camera grab. Connect to `dora/timer/millis/N`. |
## Outputs
| Output | Type | Description |
|---|---|---|
| `image_bgr` | `uint8 [H, W, 3]` | BGR image. Metadata: `shape`, `encoding=bgr8`, intrinsics, timestamps. |
| `depth` | `float32 [H, W]` | Per-pixel depth in millimetres, published on every frame `image_bgr` is. Metadata: `dtype`, `layout=HW`, `shape`, `units=mm`, intrinsics, timestamps. `H` and `W` match `image_bgr` for the same `frame_id`. Missing depth is `0` — the payload contains no `inf` and no `nan`. Holes are not interpolated unless `ZED_DEPTH_FILL` is set. |
| `point_cloud` | `float32 [*, 4]` | XYZRGBA point cloud, only when `ZED_POINT_CLOUD` is on. Shape depends on `ZED_PC_MODE`. Metadata: `units=mm`, intrinsics, timestamps. |
| `camera_info` | `float32 [4]` | `[fx, fy, cx, cy]`. Metadata: `distortion` coefficients, timestamps. Published **once per camera session** — with the first frame after the camera opens, and again after a reconnection — not on every frame, since the intrinsics are fixed for a given camera and resolution. |
| `camera_state` | `string` | UTF-8 state name, published on each change. Metadata: `dtype=string`, `encoding=utf8`, `host_timestamp_ns`. |
### Camera state and recovery
`camera_state` lets a downstream node tell a camera that has not delivered its first
frame yet from one that has been lost:
| State | Meaning |
|---|---|
| `opening` | The camera has not delivered a usable frame yet in this run |
| `streaming` | Frames are being published |
| `recovering` | The camera was streaming and has stopped; the node is trying to bring it back |
Recovery runs inside the dora event loop rather than in a retry loop of its own, so the
node keeps reading events — and honours `Stop` — while the camera is down. Reopening
never blocks the loop between attempts: the tick timer paces them, and a backoff deadline
of 1, 2, 5 then 10 seconds decides which ticks actually try.
The node treats an all-black frame as a failed grab: a camera whose link has dropped does
not necessarily fail its grabs, and can keep reporting success while handing back a
uniform frame. A camera that delivers no usable frame for 5 seconds is recovered — 30
seconds if the SDK reports it is reconnecting the link itself, since taking the handle
away mid-reconnect aborts a recovery that was already under way.
The first recovery attempt closes the handle. Past that the device itself is wedged, and
the node escalates to `sl::Camera::reboot()`, which resets it over USB without unplugging
it. That call reaches the ZED 2 and newer only; on the first-generation ZED and the ZED
Mini the node closes the handle and lets the backoff give the endpoint time to clear.
Every attempt and every escalation is logged.
Consumers that need 3D back-project `depth` with the published intrinsics rather than
consuming `point_cloud`; the point cloud is not a prerequisite for 3D.
### Timestamps
Every output carries both clocks, as dora `timestamp` metadata parameters:
| Key | Epoch | Unit | Source |
|---|---|---|---|
| `device_timestamp_ns` | Unix epoch (UTC) | nanoseconds | `sl::Camera::getTimestamp(TIME_REFERENCE::IMAGE)` — when the camera captured the frame |
| `host_timestamp_ns` | Unix epoch (UTC) | nanoseconds | `std::chrono::system_clock` on the host — when this node published the frame |
Both are needed. The device timestamp is the capture instant and is the value two
nodes reading the same frame agree on; the host timestamp is the one the rest of the
dataflow and the session recorder share. On a Jetson the wall clock can step when NTP
synchronises after boot, so the device timestamp is recorded alongside the host one
rather than in place of it.
The node calls `sl::setTimestampClock(SYSTEM_CLOCK)` before opening the camera, which
is what makes the device timestamp a Unix-epoch value rather than an SDK-default clock.
Every output for a frame also carries the same `frame_id`, an integer counter starting
at 0.