add typed Arrow payloads and a dora-image-view dataflow

dora-image-view takes std/media/v1/Image, and dora's compatibility graph
has no edge from std/core/v1/UInt8 to it, so wiring the two failed
`dora validate` whatever the node actually sent. Its flat-buffer fallback
would not have decoded these frames either: it reads width/height
metadata, and this node only published shape.

image and depth now go out as the Image struct, camera_info and
point_cloud as Float32 arrays and camera_state as a string, all built
against the Arrow C data interface in src/arrow so the node still links
no Arrow C++. Shape, dtype and encoding metadata are dropped where the
payload describes itself; intrinsics, frame_id and both timestamps are
unchanged. tests/run.sh covers the packing without a camera or the SDK,
round-tripping each array through pyarrow and rebuilding it under the
sanitizers.

Breaking: image_bgr is renamed to image and every payload changes shape.
dataflow_plot.yml is replaced by dataflow_view.yml, which wires the
viewer from a tag and repeats its contract in input_types so validate
checks both sides of the wiring.
This commit is contained in:
2026-09-15 10:52:08 -05:00
parent d4c9f153c1
commit c1a7d70ba6
13 changed files with 648 additions and 86 deletions
+19 -5
View File
@@ -1,5 +1,6 @@
# Checks both dataflows against the typed contract in dora-node.yml. This runs # Two gates, neither of which needs a camera, the ZED SDK or CUDA: the dataflows
# no node, so it needs neither a camera nor the ZED SDK. # are checked against the typed contract in dora-node.yml without running the
# node, and the payload packing is compiled and tested on its own.
name: validate name: validate
on: [push, pull_request] on: [push, pull_request]
@@ -28,9 +29,22 @@ jobs:
# strict_types: true in each dataflow turns a type warning into a # strict_types: true in each dataflow turns a type warning into a
# non-zero exit, so a mismatched annotation fails here rather than at # non-zero exit, so a mismatched annotation fails here rather than at
# runtime. The plot file is checked too: it wires the same outputs, and a # runtime. The viewer file is checked too: it wires the same outputs, and
# rename that breaks only the viewer should still fail the build. # a rename that breaks only the consumer should still fail the build.
- name: Validate the dataflows - name: Validate the dataflows
run: | run: |
dora validate dataflow.yml dora validate dataflow.yml
dora validate dataflow_plot.yml dora validate dataflow_view.yml
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Test the payload packing
run: tests/run.sh
+2 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(dora_zed VERSION 0.2.0 LANGUAGES C CXX) project(dora_zed VERSION 0.3.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_FLAGS "-fPIC") set(CMAKE_CXX_FLAGS "-fPIC")
@@ -24,6 +24,7 @@ link_directories(${ZED_LIBRARY_DIR})
add_executable(dora_zed add_executable(dora_zed
src/main.cc src/main.cc
src/arrow/array.cc
src/zed/camera.cc src/zed/camera.cc
src/zed/capture.cc src/zed/capture.cc
${node_bridge} ${node_bridge}
+2 -2
View File
@@ -3,7 +3,7 @@
# A consumer wires the node from a tag instead — see README.md. # A consumer wires the node from a tag instead — see README.md.
# #
# The camera alone, with no viewer and no Python dependency. For a run that # The camera alone, with no viewer and no Python dependency. For a run that
# also displays the stream, use dataflow_plot.yml. # also displays the stream, use dataflow_view.yml.
strict_types: true strict_types: true
nodes: nodes:
@@ -13,7 +13,7 @@ nodes:
inputs: inputs:
tick: dora/timer/millis/100 tick: dora/timer/millis/100
outputs: outputs:
- image_bgr - image
- depth - depth
- point_cloud - point_cloud
- camera_info - camera_info
-27
View File
@@ -1,27 +0,0 @@
# The camera plus an opencv-plot viewer on image_bgr.
#
# Separate from dataflow.yml because the viewer pulls dora-hub and installs it
# with pip, and needs a display: the camera on its own should cost neither.
# dora has no cross-dataflow wiring, so the camera node is declared here too
# rather than referenced — run this file or dataflow.yml, not both.
strict_types: true
nodes:
- id: zed-camera
path: bin/dora_zed
build: scripts/build.sh
inputs:
tick: dora/timer/millis/100
outputs:
- image_bgr
- depth
- point_cloud
- camera_info
- camera_state
- id: plot
git: https://github.com/dora-rs/dora-hub.git
build: pip install ./node-hub/opencv-plot
path: opencv-plot
inputs:
image: zed-camera/image_bgr
+46
View File
@@ -0,0 +1,46 @@
# The camera into dora-image-view, and the worked example of wiring this node to
# a consumer.
#
# dora has no cross-dataflow wiring, so the camera node is declared here too
# rather than referenced — run this file or dataflow.yml, not both.
#
# Run it with `--uv`, which gives the viewer its own managed environment:
#
# dora run dataflow_view.yml --uv
#
# Fetching the viewer needs anonymous read access to its repository. dora-cli
# 1.0.1 fetches through libgit2 with no credential callback, so ssh:// cannot
# authenticate at all; on Gitea, marking the repository public is not enough
# either, since a repository inside a private organization answers an anonymous
# clone with 401.
strict_types: true
nodes:
- id: zed-camera
path: bin/dora_zed
build: scripts/build.sh
inputs:
tick: dora/timer/millis/100
outputs:
- image
- depth
- point_cloud
- camera_info
- camera_state
- id: view
git: https://gitea.liria.cl/liria/dora-image-view.git
tag: v0.1.0
build: uv pip install .
path: dora-image-view
inputs:
image: zed-camera/image
depth: zed-camera/depth
camera_info: zed-camera/camera_info
# The viewer's own manifest says this, but a `git:` node's manifest is not
# read at validate time — only at build. Repeating it here is what makes the
# two contracts checked against each other now rather than at runtime.
input_types:
image: std/media/v1/Image
depth: std/media/v1/Image
camera_info: std/core/v1/Float32
+35 -8
View File
@@ -7,33 +7,60 @@
apiVersion: 1 apiVersion: 1
name: dora-zed name: dora-zed
namespace: liria namespace: liria
description: ZED stereo camera source publishing a BGR image, a depth map, intrinsics and an optional point cloud description: ZED stereo camera source publishing an image, a depth map, intrinsics and an optional point cloud
categories: [sensor] categories: [sensor]
keywords: [zed, stereolabs, camera, depth, stereo, jetson] keywords: [zed, stereolabs, camera, depth, stereo, jetson]
runtime: cpp runtime: cpp
entrypoint: bin/dora_zed entrypoint: bin/dora_zed
build: scripts/build.sh build: scripts/build.sh
dora: ">=1.0"
inputs: inputs:
tick: tick:
type: std/core/v1/UInt64 type: std/core/v1/UInt64
required: true required: true
description: Each tick triggers one camera grab. Connect to dora/timer/millis/N. description: >-
Publishes the newest frame the capture thread has grabbed, or nothing if
none arrived since the last tick. It does not drive the camera: capture is
free-running at ZED_FPS, so this sets the publish rate and anything
captured between two ticks is dropped. Connect to dora/timer/millis/N.
outputs: outputs:
image_bgr: image:
type: std/core/v1/UInt8 type: std/media/v1/Image
description: BGR image bytes, [H, W, 3]. Shape and encoding are in the metadata. description: >-
Captured frame as an Arrow struct {width, height, encoding, data}. The
encoding is always bgr8, three bytes per pixel.
depth: depth:
type: std/core/v1/Float32 type: std/media/v1/Image
description: Per-pixel depth in millimetres, [H, W]. Missing depth is 0, never inf or nan. description: >-
Depth map as an Arrow struct {width, height, encoding, data}, one channel
per pixel -- dora has no depth type, and a depth map is an image whose
pixels are distances, which is how ROS spells it too. The encoding is
32FC1 and the `units` metadata is mm. Missing depth is 0, never inf or
nan.
camera_info: camera_info:
type: std/core/v1/Float32 type: std/core/v1/Float32
description: Intrinsics [fx, fy, cx, cy], distortion in the metadata. Published once per camera session. description: Intrinsics [fx, fy, cx, cy], distortion in the metadata. Published once per camera session.
point_cloud: point_cloud:
type: std/core/v1/Float32 type: std/core/v1/Float32
description: XYZRGBA point cloud. Published only when ZED_POINT_CLOUD is on. description: XYZRGBA point cloud, shape in the metadata. Published only when ZED_POINT_CLOUD is on.
camera_state: camera_state:
type: std/core/v1/String type: std/core/v1/String
description: One of opening, streaming, recovering. Published on every change. description: One of opening, streaming, recovering. Published on every change.
example: |
- id: zed-camera
git: https://gitea.liria.cl/liria/dora-zed.git
tag: v0.3.0
build: scripts/build.sh
path: bin/dora_zed
inputs:
tick: dora/timer/millis/100
outputs:
- image
- depth
- point_cloud
- camera_info
- camera_state
+179
View File
@@ -0,0 +1,179 @@
#include "arrow/array.h"
#include <cstring>
#include <string>
#include <utility>
#include <vector>
namespace arrow_c {
namespace {
// Producer-owned storage for one schema node. The struct's `format`, `name` and
// `children` pointers point in here, which is why it outlives the call that
// built it and is freed by the release callback rather than by the builder.
struct SchemaData {
std::string format;
std::string name;
std::vector<ArrowSchema*> children;
};
// Producer-owned storage for one array node. `owned` holds the bytes this
// node's own buffers point into - offset arrays, and scalars that have no
// storage elsewhere. The payload itself is borrowed from the caller.
struct ArrayData {
std::vector<uint8_t> owned;
std::vector<const void*> buffers;
std::vector<ArrowArray*> children;
};
// A node's release callback releases its children before freeing itself, so a
// consumer that only releases the root - which is all the interface requires -
// still frees the whole tree.
void release_schema(ArrowSchema* schema) {
auto* data = static_cast<SchemaData*>(schema->private_data);
for (ArrowSchema* child : data->children) {
if (child->release != nullptr) {
child->release(child);
}
delete child;
}
delete data;
schema->release = nullptr;
}
void release_array(ArrowArray* array) {
auto* data = static_cast<ArrayData*>(array->private_data);
for (ArrowArray* child : data->children) {
if (child->release != nullptr) {
child->release(child);
}
delete child;
}
delete data;
array->release = nullptr;
}
void set_schema(ArrowSchema& schema, const char* format, const char* name,
std::vector<ArrowSchema*> children) {
auto* data = new SchemaData{format, name, std::move(children)};
schema.format = data->format.c_str();
schema.name = data->name.c_str();
schema.metadata = nullptr;
// Nullable everywhere, which is what pyarrow's defaults produce: the same
// struct built by a Python node has to compare equal to this one.
schema.flags = ARROW_FLAG_NULLABLE;
schema.n_children = static_cast<int64_t>(data->children.size());
schema.children = data->children.empty() ? nullptr : data->children.data();
schema.dictionary = nullptr;
schema.release = release_schema;
schema.private_data = data;
}
// `buffers` may point into `owned`: moving a vector keeps its heap block, so
// pointers taken before the move stay valid after it.
void set_array(ArrowArray& array, int64_t length, std::vector<uint8_t> owned,
std::vector<const void*> buffers, std::vector<ArrowArray*> children) {
auto* data = new ArrayData{std::move(owned), std::move(buffers), std::move(children)};
array.length = length;
// Nothing this node sends is null, so the validity bitmap - the first
// buffer of every node below - is left out entirely.
array.null_count = 0;
array.offset = 0;
array.n_buffers = static_cast<int64_t>(data->buffers.size());
array.n_children = static_cast<int64_t>(data->children.size());
array.buffers = data->buffers.data();
array.children = data->children.empty() ? nullptr : data->children.data();
array.dictionary = nullptr;
array.release = release_array;
array.private_data = data;
}
// One field of a struct, owned by its parent.
struct Child {
ArrowSchema* schema;
ArrowArray* array;
};
Child new_child() {
return {new ArrowSchema{}, new ArrowArray{}};
}
void fill_uint32(ArrowSchema& schema, ArrowArray& array, const char* name, uint32_t value) {
std::vector<uint8_t> owned(sizeof(value));
std::memcpy(owned.data(), &value, sizeof(value));
const void* values = owned.data();
set_schema(schema, "I", name, {});
set_array(array, 1, std::move(owned), {nullptr, values}, {});
}
// One string, offsets and characters in a single allocation.
void fill_utf8(ArrowSchema& schema, ArrowArray& array, const char* name, const char* text,
size_t size) {
const int32_t offsets[2] = {0, static_cast<int32_t>(size)};
std::vector<uint8_t> owned(sizeof(offsets) + size);
std::memcpy(owned.data(), offsets, sizeof(offsets));
std::memcpy(owned.data() + sizeof(offsets), text, size);
const void* offsets_buffer = owned.data();
const void* values_buffer = owned.data() + sizeof(offsets);
set_schema(schema, "u", name, {});
set_array(array, 1, std::move(owned), {nullptr, offsets_buffer, values_buffer}, {});
}
// One byte string. Only the offsets are owned; the bytes are the caller's.
void fill_large_binary(ArrowSchema& schema, ArrowArray& array, const char* name,
const uint8_t* data, size_t size) {
const int64_t offsets[2] = {0, static_cast<int64_t>(size)};
std::vector<uint8_t> owned(sizeof(offsets));
std::memcpy(owned.data(), offsets, sizeof(offsets));
const void* offsets_buffer = owned.data();
set_schema(schema, "Z", name, {});
set_array(array, 1, std::move(owned), {nullptr, offsets_buffer, data}, {});
}
} // namespace
Exported image(uint32_t width, uint32_t height, const char* encoding, const uint8_t* data,
size_t size) {
Child w = new_child();
fill_uint32(*w.schema, *w.array, "width", width);
Child h = new_child();
fill_uint32(*h.schema, *h.array, "height", height);
Child e = new_child();
fill_utf8(*e.schema, *e.array, "encoding", encoding, std::strlen(encoding));
Child d = new_child();
fill_large_binary(*d.schema, *d.array, "data", data, size);
Exported out;
set_schema(out.schema, "+s", "", {w.schema, h.schema, e.schema, d.schema});
set_array(out.array, 1, {}, {nullptr}, {w.array, h.array, e.array, d.array});
return out;
}
Exported float32(const float* values, size_t count) {
Exported out;
set_schema(out.schema, "f", "", {});
set_array(out.array, static_cast<int64_t>(count), {}, {nullptr, values}, {});
return out;
}
Exported string(const char* text, size_t size) {
Exported out;
fill_utf8(out.schema, out.array, "", text, size);
return out;
}
} // namespace arrow_c
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <cstddef>
#include <cstdint>
// The Arrow C data interface, verbatim from the Arrow specification.
//
// These two structs are the whole ABI dora's send_arrow_output takes, so
// defining them here is what lets this node publish typed Arrow arrays without
// linking Arrow C++ - a heavy dependency for a producer that exports one array
// per output and never reads one.
#ifndef ARROW_C_DATA_INTERFACE
#define ARROW_C_DATA_INTERFACE
#define ARROW_FLAG_DICTIONARY_ORDERED 1
#define ARROW_FLAG_NULLABLE 2
#define ARROW_FLAG_MAP_KEYS_SORTED 4
struct ArrowSchema {
const char* format;
const char* name;
const char* metadata;
int64_t flags;
int64_t n_children;
struct ArrowSchema** children;
struct ArrowSchema* dictionary;
void (*release)(struct ArrowSchema*);
void* private_data;
};
struct ArrowArray {
int64_t length;
int64_t null_count;
int64_t offset;
int64_t n_buffers;
int64_t n_children;
const void** buffers;
struct ArrowArray** children;
struct ArrowArray* dictionary;
void (*release)(struct ArrowArray*);
void* private_data;
};
#endif // ARROW_C_DATA_INTERFACE
namespace arrow_c {
// One array with the schema describing it.
//
// dora moves both structs out of here and calls their release callbacks once it
// has copied the payload into shared memory, so an Exported is built, sent
// once, and never touched again.
struct Exported {
ArrowArray array{};
ArrowSchema schema{};
};
// std/media/v1/Image: one row of {width, height, encoding, data}.
//
// The payload is borrowed rather than copied, so it has to stay valid until the
// send returns. A capture-thread frame buffer does: the event loop holds the
// frame across the call.
Exported image(uint32_t width, uint32_t height, const char* encoding, const uint8_t* data,
size_t size);
// std/core/v1/Float32: `count` values, borrowed on the same terms as image().
Exported float32(const float* values, size_t count);
// std/core/v1/String: one UTF-8 string. Copied, since every string this node
// sends is a short state name.
Exported string(const char* text, size_t size);
} // namespace arrow_c
+41 -42
View File
@@ -1,12 +1,15 @@
#include "arrow/array.h"
#include "zed/capture.h" #include "zed/capture.h"
#include <dora-node-api.h> #include <dora-node-api.h>
#include <chrono> #include <chrono>
#include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <initializer_list> #include <initializer_list>
#include <iostream> #include <iostream>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
namespace { namespace {
@@ -47,9 +50,18 @@ void set_frame_stamps(Metadata& metadata, const zed::Frame& frame) {
metadata.set_timestamp("host_timestamp_ns", frame.host_ns); metadata.set_timestamp("host_timestamp_ns", frame.host_ns);
} }
void report(const char* output, const DoraResult& result) { // Publish one Arrow array. dora moves both structs out and releases them once
// the payload is in shared memory, so the buffers they borrow only have to
// outlive this call - which the frame the caller is holding does.
void send(DoraNode& dora_node, const char* id, arrow_c::Exported payload,
rust::Box<Metadata> metadata) {
auto result = send_arrow_output(dora_node.send_output, id,
reinterpret_cast<uint8_t*>(&payload.array),
reinterpret_cast<uint8_t*>(&payload.schema),
std::move(metadata));
if (!std::string(result.error).empty()) { if (!std::string(result.error).empty()) {
std::cerr << "Error sending " << output << ": " << std::string(result.error) << std::endl; std::cerr << "Error sending " << id << ": " << std::string(result.error) << std::endl;
} }
} }
@@ -90,17 +102,11 @@ int main() {
std::string state; std::string state;
if (capture.take_state_change(state)) { if (capture.take_state_change(state)) {
rust::Slice<const uint8_t> state_slice{
reinterpret_cast<const uint8_t*>(state.data()), state.size()};
auto state_metadata = new_metadata(); auto state_metadata = new_metadata();
state_metadata->set_string("dtype", "string");
state_metadata->set_string("encoding", "utf8");
state_metadata->set_timestamp("host_timestamp_ns", host_timestamp_ns()); state_metadata->set_timestamp("host_timestamp_ns", host_timestamp_ns());
report("camera_state", send(dora_node, "camera_state", arrow_c::string(state.data(), state.size()),
send_output_with_metadata(dora_node.send_output, "camera_state", state_slice, std::move(state_metadata));
std::move(state_metadata)));
} }
// No new frame since the last tick: the camera is slower than the timer, // No new frame since the last tick: the camera is slower than the timer,
@@ -109,44 +115,41 @@ int main() {
continue; continue;
} }
// --- image_bgr --- auto width = static_cast<uint32_t>(frame.width);
rust::Slice<const uint8_t> image_slice{frame.image_bgr.data(), frame.image_bgr.size()}; auto height = static_cast<uint32_t>(frame.height);
// --- image ---
// The payload carries its own width, height and encoding, so none of
// that travels as metadata: what is left is what describes the frame
// rather than the picture.
auto image_metadata = new_metadata(); auto image_metadata = new_metadata();
image_metadata->set_string("dtype", "uint8");
image_metadata->set_string("encoding", "bgr8");
image_metadata->set_string("layout", "HWC");
image_metadata->set_list_int("shape", make_vector_int({frame.height, frame.width, 3}));
set_intrinsics(*image_metadata, frame.calib); set_intrinsics(*image_metadata, frame.calib);
set_frame_stamps(*image_metadata, frame); set_frame_stamps(*image_metadata, frame);
report("image_bgr", send_output_with_metadata(dora_node.send_output, "image_bgr", send(dora_node, "image",
image_slice, std::move(image_metadata))); arrow_c::image(width, height, "bgr8", frame.image_bgr.data(), frame.image_bgr.size()),
std::move(image_metadata));
// --- depth --- // --- depth ---
rust::Slice<const uint8_t> depth_slice{ // A depth map is an image whose pixels are distances, which is how ROS
reinterpret_cast<const uint8_t*>(frame.depth.data()), // spells it too: 32FC1 is one float32 per pixel, and `units` says what
frame.depth.size() * sizeof(float)}; // those floats measure.
auto depth_metadata = new_metadata(); auto depth_metadata = new_metadata();
depth_metadata->set_string("dtype", "float32");
depth_metadata->set_string("layout", "HW");
depth_metadata->set_string("units", "mm"); depth_metadata->set_string("units", "mm");
depth_metadata->set_list_int("shape", make_vector_int({frame.height, frame.width}));
set_intrinsics(*depth_metadata, frame.calib); set_intrinsics(*depth_metadata, frame.calib);
set_frame_stamps(*depth_metadata, frame); set_frame_stamps(*depth_metadata, frame);
report("depth", send_output_with_metadata(dora_node.send_output, "depth", depth_slice, send(dora_node, "depth",
std::move(depth_metadata))); arrow_c::image(width, height, "32FC1",
reinterpret_cast<const uint8_t*>(frame.depth.data()),
frame.depth.size() * sizeof(float)),
std::move(depth_metadata));
// --- point_cloud --- // --- point_cloud ---
if (frame.has_point_cloud) { if (frame.has_point_cloud) {
rust::Slice<const uint8_t> pc_slice{ // Unlike the two images, a cloud is a flat array of values: its
reinterpret_cast<const uint8_t*>(frame.point_cloud.data()), // grid shape is only in the metadata.
frame.point_cloud.size() * sizeof(float)};
auto pc_metadata = new_metadata(); auto pc_metadata = new_metadata();
pc_metadata->set_string("dtype", "float32");
pc_metadata->set_string("channels", "XYZRGBA"); pc_metadata->set_string("channels", "XYZRGBA");
pc_metadata->set_string("units", "mm"); pc_metadata->set_string("units", "mm");
pc_metadata->set_string("layout", "HWC"); pc_metadata->set_string("layout", "HWC");
@@ -155,26 +158,22 @@ int main() {
set_intrinsics(*pc_metadata, frame.calib); set_intrinsics(*pc_metadata, frame.calib);
set_frame_stamps(*pc_metadata, frame); set_frame_stamps(*pc_metadata, frame);
report("point_cloud", send_output_with_metadata(dora_node.send_output, "point_cloud", send(dora_node, "point_cloud",
pc_slice, std::move(pc_metadata))); arrow_c::float32(frame.point_cloud.data(), frame.point_cloud.size()),
std::move(pc_metadata));
} }
// --- camera_info --- // --- camera_info ---
if (frame.camera_info_first) { if (frame.camera_info_first) {
float camera_data[4] = {frame.calib.fx, frame.calib.fy, frame.calib.cx, const float camera_data[4] = {frame.calib.fx, frame.calib.fy, frame.calib.cx,
frame.calib.cy}; frame.calib.cy};
rust::Slice<const uint8_t> cam_slice{
reinterpret_cast<const uint8_t*>(camera_data), sizeof(camera_data)};
auto cam_metadata = new_metadata(); auto cam_metadata = new_metadata();
cam_metadata->set_string("dtype", "float32");
cam_metadata->set_string("layout", "C");
cam_metadata->set_list_int("shape", make_vector_int({4}));
cam_metadata->set_list_float("distortion", make_distortion(frame.calib.distortion)); cam_metadata->set_list_float("distortion", make_distortion(frame.calib.distortion));
set_frame_stamps(*cam_metadata, frame); set_frame_stamps(*cam_metadata, frame);
report("camera_info", send_output_with_metadata(dora_node.send_output, "camera_info", send(dora_node, "camera_info", arrow_c::float32(camera_data, 4),
cam_slice, std::move(cam_metadata))); std::move(cam_metadata));
} }
} }
+36
View File
@@ -0,0 +1,36 @@
// C entry points for tests/test_arrow.py.
//
// The Python side cannot call the C++ builders, so each one is wrapped here and
// the payload is passed in from the test rather than fixed in this file.
#include "arrow/array.h"
#include <cstring>
namespace {
void hand_over(void* array, void* schema, const arrow_c::Exported& exported) {
// The caller owns the two structs, exactly as dora does: it reads them and
// is then responsible for releasing what they point at.
std::memcpy(array, &exported.array, sizeof(exported.array));
std::memcpy(schema, &exported.schema, sizeof(exported.schema));
}
} // namespace
extern "C" {
void zed_export_image(void* array, void* schema, uint32_t width, uint32_t height,
const char* encoding, const uint8_t* data, size_t size) {
hand_over(array, schema, arrow_c::image(width, height, encoding, data, size));
}
void zed_export_float32(void* array, void* schema, const float* values, size_t count) {
hand_over(array, schema, arrow_c::float32(values, count));
}
void zed_export_string(void* array, void* schema, const char* text, size_t size) {
hand_over(array, schema, arrow_c::string(text, size));
}
} // extern "C"
+54
View File
@@ -0,0 +1,54 @@
// Release has to free the whole tree: the array nodes, the schema nodes, and
// the storage their buffers point into. Nothing here asserts that directly -
// the sanitizers tests/run.sh builds this with are the assertion, and a node
// that leaks per frame is a node that dies on a long run.
#include "arrow/array.h"
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
namespace {
void release(arrow_c::Exported& exported) {
exported.array.release(&exported.array);
exported.schema.release(&exported.schema);
// A released struct marks itself, which is how a consumer knows not to
// release it twice.
assert(exported.array.release == nullptr);
assert(exported.schema.release == nullptr);
}
} // namespace
int main() {
const std::vector<uint8_t> pixels(4 * 4 * 3, 7);
const std::vector<float> depth(4 * 4, 1500.0F);
const std::string state = "streaming";
for (int i = 0; i < 100; ++i) {
arrow_c::Exported image =
arrow_c::image(4, 4, "bgr8", pixels.data(), pixels.size());
assert(image.array.length == 1);
assert(image.array.n_children == 4);
release(image);
arrow_c::Exported depth_image =
arrow_c::image(4, 4, "32FC1", reinterpret_cast<const uint8_t*>(depth.data()),
depth.size() * sizeof(float));
release(depth_image);
arrow_c::Exported values = arrow_c::float32(depth.data(), depth.size());
assert(values.array.length == static_cast<int64_t>(depth.size()));
release(values);
arrow_c::Exported text = arrow_c::string(state.data(), state.size());
assert(text.array.length == 1);
release(text);
}
return 0;
}
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Build and run the payload tests.
#
# They cover src/arrow only, which is the part of the node that has no ZED SDK,
# no CUDA and no dora dependency - so this runs anywhere, and is what CI runs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
OUT_DIR="${REPO_DIR}/build/tests"
mkdir -p "$OUT_DIR"
# Sanitized: the release callbacks have to free the whole exported tree, and a
# leak there would be one per frame.
g++ -std=c++17 -Wall -Wextra -Werror -g -fsanitize=address,undefined \
-I "${REPO_DIR}/src" \
-o "${OUT_DIR}/arrow_test" \
"${REPO_DIR}/src/arrow/array.cc" "${REPO_DIR}/tests/arrow_test.cc"
"${OUT_DIR}/arrow_test"
echo "ok arrow_test"
# Unsanitized: pyarrow is the consumer standing in for dora, and loads the same
# code as a shared library.
g++ -std=c++17 -Wall -Wextra -Werror -O2 -shared -fPIC \
-I "${REPO_DIR}/src" \
-o "${OUT_DIR}/libzed_arrow.so" \
"${REPO_DIR}/src/arrow/array.cc" "${REPO_DIR}/tests/arrow_export.cc"
uv run --no-project --with pyarrow python "${REPO_DIR}/tests/test_arrow.py"
+129
View File
@@ -0,0 +1,129 @@
"""Round-trip every exported payload through a real Arrow consumer.
The node builds its Arrow arrays by hand against the C data interface rather
than linking Arrow C++, so this is what shows that what dora imports on the
other side of `send_arrow_output` is what went in. It needs neither a camera nor
the ZED SDK.
"""
import ctypes
import pathlib
import sys
import pyarrow as pa
LIBRARY = pathlib.Path(__file__).resolve().parent.parent / "build" / "tests" / "libzed_arrow.so"
# ArrowArray is ten machine words and ArrowSchema nine, and both want the
# 8-byte alignment an array of uint64 gives.
WORDS = 16
# Index of the `release` field in ArrowArray: a consumer that took ownership
# zeroes it, which is how this test sees the move happen.
RELEASE = 8
IMAGE_TYPE = pa.struct([
("width", pa.uint32()),
("height", pa.uint32()),
("encoding", pa.utf8()),
("data", pa.large_binary()),
])
def load():
"""Load the test library and declare the argument types of its exports."""
library = ctypes.CDLL(str(LIBRARY))
library.zed_export_image.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32,
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t,
]
library.zed_export_float32.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_float), ctypes.c_size_t,
]
library.zed_export_string.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t,
]
return library
def structs():
"""Allocate the ArrowArray and ArrowSchema the producer fills in."""
return (ctypes.c_uint64 * WORDS)(), (ctypes.c_uint64 * WORDS)()
def imported(array, schema):
"""Import the pair as pyarrow does, taking ownership of both."""
result = pa.Array._import_from_c(ctypes.addressof(array), ctypes.addressof(schema))
assert array[RELEASE] == 0, "the consumer should have taken ownership of the array"
return result
def test_image(library):
"""An image row carries its own dimensions, encoding and bytes."""
array, schema = structs()
pixels = bytes(range(12))
library.zed_export_image(
ctypes.byref(array), ctypes.byref(schema), 2, 2, b"bgr8", pixels, len(pixels)
)
result = imported(array, schema)
assert result.type == IMAGE_TYPE, result.type
assert result.to_pylist() == [
{"width": 2, "height": 2, "encoding": "bgr8", "data": pixels}
]
def test_depth(library):
"""A depth map is an image whose bytes are float32 millimetres."""
array, schema = structs()
millimetres = [1500.0, 0.0, 250.5, 4000.0]
payload = pa.array(millimetres, type=pa.float32()).buffers()[1].to_pybytes()
library.zed_export_image(
ctypes.byref(array), ctypes.byref(schema), 2, 2, b"32FC1", payload, len(payload)
)
row = imported(array, schema).to_pylist()[0]
assert row["encoding"] == "32FC1"
assert pa.Array.from_buffers(
pa.float32(), 4, [None, pa.py_buffer(row["data"])]
).to_pylist() == millimetres
def test_float32(library):
"""Intrinsics and the point cloud go out as plain float32 values."""
array, schema = structs()
values = (ctypes.c_float * 4)(700.5, 700.5, 640.0, 360.0)
library.zed_export_float32(ctypes.byref(array), ctypes.byref(schema), values, 4)
result = imported(array, schema)
assert result.type == pa.float32()
assert result.to_pylist() == [700.5, 700.5, 640.0, 360.0]
def test_string(library):
"""The camera state goes out as one UTF-8 string."""
array, schema = structs()
library.zed_export_string(ctypes.byref(array), ctypes.byref(schema), b"streaming", 9)
result = imported(array, schema)
assert result.type == pa.utf8()
assert result.to_pylist() == ["streaming"]
def main():
"""Run every test, reporting the first failure."""
library = load()
for test in (test_image, test_depth, test_float32, test_string):
test(library)
print(f"ok {test.__name__}")
return 0
if __name__ == "__main__":
sys.exit(main())