diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index e7e8878..24ed5ef 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -1,5 +1,6 @@ -# Checks both dataflows against the typed contract in dora-node.yml. This runs -# no node, so it needs neither a camera nor the ZED SDK. +# Two gates, neither of which needs a camera, the ZED SDK or CUDA: the dataflows +# 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 on: [push, pull_request] @@ -28,9 +29,22 @@ jobs: # strict_types: true in each dataflow turns a type warning into a # 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 - # rename that breaks only the viewer should still fail the build. + # runtime. The viewer file is checked too: it wires the same outputs, and + # a rename that breaks only the consumer should still fail the build. - name: Validate the dataflows run: | 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 0fbd2c8..e2dc86f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ 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_FLAGS "-fPIC") @@ -24,6 +24,7 @@ link_directories(${ZED_LIBRARY_DIR}) add_executable(dora_zed src/main.cc + src/arrow/array.cc src/zed/camera.cc src/zed/capture.cc ${node_bridge} diff --git a/dataflow.yml b/dataflow.yml index a1001e2..fd64efa 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -3,7 +3,7 @@ # 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 -# also displays the stream, use dataflow_plot.yml. +# also displays the stream, use dataflow_view.yml. strict_types: true nodes: @@ -13,7 +13,7 @@ nodes: inputs: tick: dora/timer/millis/100 outputs: - - image_bgr + - image - depth - point_cloud - camera_info diff --git a/dataflow_plot.yml b/dataflow_plot.yml deleted file mode 100644 index 570cada..0000000 --- a/dataflow_plot.yml +++ /dev/null @@ -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 diff --git a/dataflow_view.yml b/dataflow_view.yml new file mode 100644 index 0000000..11d44df --- /dev/null +++ b/dataflow_view.yml @@ -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 diff --git a/dora-node.yml b/dora-node.yml index 336e30a..ccf2916 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -7,33 +7,60 @@ apiVersion: 1 name: dora-zed 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] keywords: [zed, stereolabs, camera, depth, stereo, jetson] runtime: cpp entrypoint: bin/dora_zed build: scripts/build.sh +dora: ">=1.0" inputs: tick: type: std/core/v1/UInt64 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: - image_bgr: - type: std/core/v1/UInt8 - description: BGR image bytes, [H, W, 3]. Shape and encoding are in the metadata. + image: + type: std/media/v1/Image + description: >- + Captured frame as an Arrow struct {width, height, encoding, data}. The + encoding is always bgr8, three bytes per pixel. depth: - type: std/core/v1/Float32 - description: Per-pixel depth in millimetres, [H, W]. Missing depth is 0, never inf or nan. + type: std/media/v1/Image + 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: type: std/core/v1/Float32 description: Intrinsics [fx, fy, cx, cy], distortion in the metadata. Published once per camera session. point_cloud: 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: type: std/core/v1/String 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 diff --git a/src/arrow/array.cc b/src/arrow/array.cc new file mode 100644 index 0000000..3336f90 --- /dev/null +++ b/src/arrow/array.cc @@ -0,0 +1,179 @@ +#include "arrow/array.h" + +#include +#include +#include +#include + +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 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 owned; + std::vector buffers; + std::vector 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(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(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 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(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 owned, + std::vector buffers, std::vector 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(data->buffers.size()); + array.n_children = static_cast(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 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(size)}; + + std::vector 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(size)}; + + std::vector 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(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 diff --git a/src/arrow/array.h b/src/arrow/array.h new file mode 100644 index 0000000..e3502f0 --- /dev/null +++ b/src/arrow/array.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +// 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 diff --git a/src/main.cc b/src/main.cc index e9cd256..1ae38b0 100644 --- a/src/main.cc +++ b/src/main.cc @@ -1,12 +1,15 @@ +#include "arrow/array.h" #include "zed/capture.h" #include #include +#include #include #include #include #include +#include #include namespace { @@ -47,9 +50,18 @@ void set_frame_stamps(Metadata& metadata, const zed::Frame& frame) { 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) { + auto result = send_arrow_output(dora_node.send_output, id, + reinterpret_cast(&payload.array), + reinterpret_cast(&payload.schema), + std::move(metadata)); + 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; if (capture.take_state_change(state)) { - rust::Slice state_slice{ - reinterpret_cast(state.data()), state.size()}; - 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()); - report("camera_state", - send_output_with_metadata(dora_node.send_output, "camera_state", state_slice, - std::move(state_metadata))); + send(dora_node, "camera_state", arrow_c::string(state.data(), state.size()), + std::move(state_metadata)); } // No new frame since the last tick: the camera is slower than the timer, @@ -109,44 +115,41 @@ int main() { continue; } - // --- image_bgr --- - rust::Slice image_slice{frame.image_bgr.data(), frame.image_bgr.size()}; + auto width = static_cast(frame.width); + auto height = static_cast(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(); - 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_frame_stamps(*image_metadata, frame); - report("image_bgr", send_output_with_metadata(dora_node.send_output, "image_bgr", - image_slice, std::move(image_metadata))); + send(dora_node, "image", + arrow_c::image(width, height, "bgr8", frame.image_bgr.data(), frame.image_bgr.size()), + std::move(image_metadata)); // --- depth --- - rust::Slice depth_slice{ - reinterpret_cast(frame.depth.data()), - frame.depth.size() * sizeof(float)}; - + // A depth map is an image whose pixels are distances, which is how ROS + // spells it too: 32FC1 is one float32 per pixel, and `units` says what + // those floats measure. 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_list_int("shape", make_vector_int({frame.height, frame.width})); set_intrinsics(*depth_metadata, frame.calib); set_frame_stamps(*depth_metadata, frame); - report("depth", send_output_with_metadata(dora_node.send_output, "depth", depth_slice, - std::move(depth_metadata))); + send(dora_node, "depth", + arrow_c::image(width, height, "32FC1", + reinterpret_cast(frame.depth.data()), + frame.depth.size() * sizeof(float)), + std::move(depth_metadata)); // --- point_cloud --- if (frame.has_point_cloud) { - rust::Slice pc_slice{ - reinterpret_cast(frame.point_cloud.data()), - frame.point_cloud.size() * sizeof(float)}; - + // Unlike the two images, a cloud is a flat array of values: its + // grid shape is only in the metadata. auto pc_metadata = new_metadata(); - pc_metadata->set_string("dtype", "float32"); pc_metadata->set_string("channels", "XYZRGBA"); pc_metadata->set_string("units", "mm"); pc_metadata->set_string("layout", "HWC"); @@ -155,26 +158,22 @@ int main() { set_intrinsics(*pc_metadata, frame.calib); set_frame_stamps(*pc_metadata, frame); - report("point_cloud", send_output_with_metadata(dora_node.send_output, "point_cloud", - pc_slice, std::move(pc_metadata))); + send(dora_node, "point_cloud", + arrow_c::float32(frame.point_cloud.data(), frame.point_cloud.size()), + std::move(pc_metadata)); } // --- camera_info --- if (frame.camera_info_first) { - float camera_data[4] = {frame.calib.fx, frame.calib.fy, frame.calib.cx, - frame.calib.cy}; - rust::Slice cam_slice{ - reinterpret_cast(camera_data), sizeof(camera_data)}; + const float camera_data[4] = {frame.calib.fx, frame.calib.fy, frame.calib.cx, + frame.calib.cy}; 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)); set_frame_stamps(*cam_metadata, frame); - report("camera_info", send_output_with_metadata(dora_node.send_output, "camera_info", - cam_slice, std::move(cam_metadata))); + send(dora_node, "camera_info", arrow_c::float32(camera_data, 4), + std::move(cam_metadata)); } } diff --git a/tests/arrow_export.cc b/tests/arrow_export.cc new file mode 100644 index 0000000..9c594f8 --- /dev/null +++ b/tests/arrow_export.cc @@ -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 + +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" diff --git a/tests/arrow_test.cc b/tests/arrow_test.cc new file mode 100644 index 0000000..e5a2b92 --- /dev/null +++ b/tests/arrow_test.cc @@ -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 +#include +#include +#include + +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 pixels(4 * 4 * 3, 7); + const std::vector 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(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(depth.size())); + release(values); + + arrow_c::Exported text = arrow_c::string(state.data(), state.size()); + assert(text.array.length == 1); + release(text); + } + + return 0; +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..aa46a79 --- /dev/null +++ b/tests/run.sh @@ -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" diff --git a/tests/test_arrow.py b/tests/test_arrow.py new file mode 100644 index 0000000..07dd786 --- /dev/null +++ b/tests/test_arrow.py @@ -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())