From 06b53482390250d61dce63ff1546fd52efbb4916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Mon, 11 May 2026 09:44:22 +0200 Subject: [PATCH 01/18] =?UTF-8?q?feat(sdk):=20canonical-object=20pipeline?= =?UTF-8?q?=20=E2=80=94=20SchemaHandler=20table,=20BufferAnchor,=20push=5F?= =?UTF-8?q?message=5Fv2=20ABI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A coherent set of changes across pj_base and pj_plugins that establishes the canonical-object pipeline. Canonical-object SDK (pj_base/sdk/canonical_object.hpp): - BufferAnchor + PayloadView for zero-copy payload sharing (Span view + shared_ptr anchor). - sdk::Image, CompressedImage, PointCloud canonical types built around the view+anchor pattern so parsers can return them without copying the source bytes. - CanonicalObjectKind enum + SchemaClassification descriptor. - PixelFormat with kRGB888/kRGBA8888/kBGR888/kBGRA8888/kMono8/kMono16. MessageParser plugin base: - SchemaHandler table: per-schema registration with parse_scalars and parse_object callables. The base's classifySchema / parseScalars / parseObject methods are now table lookups. Plugins call registerSchemaHandler() in their constructor (or in bindSchema) to declare what they know about each type name. - parse() is no longer pure virtual: default implementation routes through parseScalars + writeHost.appendRecord, so plugins that register all their schemas via the table inherit parse() for free. C ABI: - PJ_payload_t / PJ_payload_anchor_t / PJ_payload_fetcher_t cross-ABI types: idempotent byte-fetcher with a release callback. - New push_message_v2 tail slot on PJ_data_source_runtime_host_vtable_t: the DataSource hands the host an idempotent fetcher; the host applies the active ObjectIngestPolicy (kPureLazy / kLazyObjectsEagerScalars / kEager) to decide when (and whether) to invoke it. - vtable size sentinel updated deliberately: 80 -> 104 bytes (MIN_VTABLE_SIZE pinned at the v4.0 baseline of 80). SDK C++ helpers: - DataSourceRuntimeHostView::pushMessage template: wraps a C++ closure (returning either PayloadView or vector) into a PJ_payload_fetcher_t and delegates to push_message_v2. Returns an explicit error when the host doesn't expose the slot — no silent fallback to the legacy raw-message path. - ObjectIngestPolicy + ObjectIngestPolicyResolver with hierarchical override cascade: topic > data_source > kind > default. - MessageParserHandle::classifySchema wrapper for the tail-slot call. Canonical-object blob serialization: - Flat byte layout for Image / CompressedImage / PointCloud crossing the C ABI. Writer/reader pair under sdk/detail/. Tests: - object_ingest_policy_test: cascade rules at all four levels + last-write-wins. - push_message_v2_test: mock host exercising the template's fetcher wrap (vector and PayloadView shapes), idempotency under repeated fetch, ctx lifetime via shared_ptr canary, anchor propagation past fetcher release, and the explicit error when the host predates the slot. Status: design sketch posted as a draft. Compiles cleanly with the companion parser/runtime work; not yet exercised end-to-end against real data sources. --- pj_base/CMakeLists.txt | 2 + .../include/pj_base/canonical_object_abi.h | 136 +++++++ .../include/pj_base/data_source_protocol.h | 97 +++++ .../include/pj_base/message_parser_protocol.h | 48 ++- .../include/pj_base/sdk/canonical_object.hpp | 283 ++++++++++++++ .../pj_base/sdk/data_source_host_views.hpp | 110 ++++++ .../detail/canonical_object_serialization.hpp | 345 ++++++++++++++++++ .../sdk/detail/message_parser_trampolines.hpp | 103 ++++++ .../sdk/message_parser_plugin_base.hpp | 223 ++++++++++- .../pj_base/sdk/object_ingest_policy.hpp | 119 ++++++ pj_base/tests/abi_layout_sentinels_test.cpp | 3 +- pj_base/tests/object_ingest_policy_test.cpp | 92 +++++ pj_base/tests/push_message_v2_test.cpp | 226 ++++++++++++ .../pj_plugins/host/message_parser_handle.hpp | 21 ++ pj_plugins/tests/data_source_library_test.cpp | 2 + .../tests/file_source_integration_test.cpp | 1 + 16 files changed, 1805 insertions(+), 6 deletions(-) create mode 100644 pj_base/include/pj_base/canonical_object_abi.h create mode 100644 pj_base/include/pj_base/sdk/canonical_object.hpp create mode 100644 pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp create mode 100644 pj_base/include/pj_base/sdk/object_ingest_policy.hpp create mode 100644 pj_base/tests/object_ingest_policy_test.cpp create mode 100644 pj_base/tests/push_message_v2_test.cpp diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 12876b9..b3dece2 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -56,6 +56,8 @@ if(PJ_BUILD_TESTS) tests/platform_test.cpp tests/arrow_holders_test.cpp tests/media_metadata_test.cpp + tests/object_ingest_policy_test.cpp + tests/push_message_v2_test.cpp ) foreach(test_src ${PJ_BASE_TESTS}) diff --git a/pj_base/include/pj_base/canonical_object_abi.h b/pj_base/include/pj_base/canonical_object_abi.h new file mode 100644 index 0000000..b370951 --- /dev/null +++ b/pj_base/include/pj_base/canonical_object_abi.h @@ -0,0 +1,136 @@ +/** + * @file canonical_object_abi.h + * @brief C ABI representation of canonical objects produced by parsers. + * + * The C++ vocabulary lives in pj_base/sdk/canonical_object.hpp + * (sdk::CanonicalObject = std::variant). + * This file defines the wire format used to cross the plugin C ABI boundary + * for that variant: parser plugins produce a flat byte blob with a small + * header describing the kind, and the host deserializes it back to the + * C++ type. + * + * The blob layout is little-endian, packed, with no implementation-defined + * padding. Trampolines and host loader use it directly. + */ +#ifndef PJ_CANONICAL_OBJECT_ABI_H +#define PJ_CANONICAL_OBJECT_ABI_H + +#include +#include +#include + +#include "pj_base/plugin_data_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Owned buffer of named field values produced by the parse_scalars slot. + * The plugin owns the @p fields array; the host calls @p release(alloc_handle) + * when done. release MAY be NULL if the plugin manages the buffer in a way + * that does not require explicit release between calls. + */ +typedef struct PJ_named_field_value_buffer_t { + const PJ_named_field_value_t* fields; + size_t count; + void* alloc_handle; + void (*release)(void* alloc_handle); +} PJ_named_field_value_buffer_t; + +/** + * Canonical object kinds. Numeric values are stable across releases — never + * renumber. Mirror of PJ::sdk::CanonicalObjectKind for use across the C ABI. + */ +typedef enum PJ_canonical_object_kind_t { + PJ_CANONICAL_OBJECT_KIND_NONE = 0, + PJ_CANONICAL_OBJECT_KIND_IMAGE = 1, + PJ_CANONICAL_OBJECT_KIND_COMPRESSED_IMAGE = 2, + PJ_CANONICAL_OBJECT_KIND_POINTCLOUD = 3, + /* Reserve future kinds; appended at the tail. */ + /* PJ_CANONICAL_OBJECT_KIND_MARKERS = 4, */ + /* PJ_CANONICAL_OBJECT_KIND_OCCUPANCY_GRID = 5, */ +} PJ_canonical_object_kind_t; + +/** + * Schema classification — what kind a parser declares for a given schema. + * Returned a priori (without parsing payload) by the classify_schema slot. + * + * Currently a single field plus reserved padding to keep the struct size + * stable across future minor extensions (declarative metadata can attach + * via additional structs returned by other slots, not by growing this one). + */ +typedef struct PJ_schema_classification_t { + uint16_t object_kind; /**< PJ_canonical_object_kind_t. */ + uint16_t reserved; /**< Must be zero. */ +} PJ_schema_classification_t; + +/** + * Canonical object as a flat byte blob produced by the parse_object slot. + * + * Layout of @p data: + * + * header (12 bytes, little-endian): + * uint16_t kind // PJ_canonical_object_kind_t + * uint16_t reserved + * int64_t timestamp_ns + * + * body (varies by kind, immediately follows the header): + * + * KIND_IMAGE: + * uint32_t width + * uint32_t height + * uint16_t pixel_format + * uint16_t reserved + * uint32_t pixels_size + * uint8_t pixels[pixels_size] // tightly packed, no row stride + * + * KIND_COMPRESSED_IMAGE: + * uint8_t format // 0=unknown, 1=JPEG, 2=PNG, 3=QOI + * uint8_t has_depth_min + * uint8_t has_depth_max + * uint8_t reserved + * float depth_min // valid iff has_depth_min + * float depth_max // valid iff has_depth_max + * uint32_t bytes_size + * uint8_t bytes[bytes_size] + * + * KIND_POINTCLOUD: + * uint32_t width + * uint32_t height + * uint32_t point_step + * uint32_t row_step + * uint8_t is_bigendian + * uint8_t is_dense + * uint16_t fields_count + * fields[fields_count]: + * uint32_t name_size + * char name[name_size] + * uint32_t offset + * uint8_t datatype // 0=unknown,1=i8,2=u8,3=i16,4=u16, + * // 5=i32,6=u32,7=f32,8=f64 + * uint8_t reserved[3] + * uint32_t count + * uint32_t data_size + * uint8_t data[data_size] + * + * Memory ownership: + * The blob's @p data is owned by the parser plugin. The plugin allocates + * it during parse_object and the host calls @p release(ctx, data) when it + * is done with the bytes. release MAY be NULL if data points into a + * plugin-internal buffer that the plugin manages itself across calls. + */ +typedef struct PJ_canonical_object_blob_t { + const uint8_t* data; + uint64_t size; + /** Opaque handle the plugin uses to identify the allocation. */ + void* alloc_handle; + /** Release callback invoked by the host. NULL means no release needed. */ + void (*release)(void* alloc_handle); +} PJ_canonical_object_blob_t; + +#ifdef __cplusplus +} +#endif + +#endif /* PJ_CANONICAL_OBJECT_ABI_H */ diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 584ede1..03dd463 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -128,6 +128,58 @@ typedef struct { uint32_t id; } PJ_parser_binding_handle_t; +/** + * Ownership token kept alive while a non-owning byte buffer is in use. + * `ctx` is opaque to the host; `release(ctx)` is invoked once when the host + * no longer needs the bytes referenced by the buffer. `ctx` MAY be NULL — + * meaning the buffer was static / borrowed from an external lifetime — in + * which case `release` is also expected to be NULL. + * + * Mirrors the pattern of PJ_canonical_object_blob_t but applies to raw + * payload bytes, not to serialized canonical objects. + */ +typedef struct PJ_payload_anchor_t { + void* ctx; + void (*release)(void* ctx); +} PJ_payload_anchor_t; + +/** + * Payload bytes plus an ownership anchor. The host treats `data` as a + * non-owning view, valid until `anchor.release(anchor.ctx)` is invoked. + * + * For zero-copy ingest, the producer (DataSource plugin) returns a payload + * whose anchor keeps the source buffer (mcap chunk, mmap, …) alive. The + * host hands the same payload to a parser (which can build canonical + * objects holding spans into the buffer) and only releases the anchor when + * everyone done with the bytes. + */ +typedef struct PJ_payload_t { + const uint8_t* data; + size_t size; + PJ_payload_anchor_t anchor; +} PJ_payload_t; + +/** + * Idempotent fetcher of payload bytes. The host invokes `fetch(ctx, &out, + * &err)` zero, one, or many times depending on the active + * ObjectIngestPolicy and on consumer pulls. Returns true and populates + * `*out` on success; returns false and (optionally) populates `*err` on + * failure (file read error, source torn down, etc.). + * + * The host ALWAYS calls `release(ctx)` exactly once when it no longer + * needs the fetcher — at the end of ingest for kEager, when the + * corresponding ObjectStore entry is dropped for lazy modes. `release` + * MAY be NULL if the plugin manages the ctx via some external mechanism. + * + * `fetch` MUST be thread-safe: the host may invoke it from the ingest + * thread (kEager) or from consumer threads (lazy pull). + */ +typedef struct PJ_payload_fetcher_t { + void* ctx; + bool (*fetch)(void* ctx, PJ_payload_t* out_payload, PJ_error_t* out_error) PJ_NOEXCEPT; + void (*release)(void* ctx); +} PJ_payload_fetcher_t; + /** * Request to bind (or look up) a parser for a given topic. * All string views must remain valid for the duration of the call. @@ -232,6 +284,51 @@ typedef struct PJ_data_source_runtime_host_vtable_t { * are loaded. */ const char* (*list_available_encodings)(void* ctx)PJ_NOEXCEPT; + + /* --------------------------------------------------------------------- + * Tail slots — appended after v4.0. Readers MUST gate access on + * `vtable->struct_size > offsetof(slot)` before calling. + * --------------------------------------------------------------------- */ + + /** + * [stream-thread] Push a message via a deferred byte fetcher. The plugin + * hands the host a callable that produces the payload bytes when + * invoked; the host applies the active ObjectIngestPolicy (resolved via + * the application-configured ObjectIngestPolicyResolver against + * source_id, topic, and the parser's classifySchema kind) to decide: + * + * - kEager: invoke fetcher now, parser.parseScalars + * writes columns, parser.parseObject + * materializes the canonical object into + * the ObjectStore via pushOwned. Fetcher + * released after. + * - kLazyObjectsEagerScalars: invoke fetcher now, parser.parseScalars + * writes columns. ObjectStore.pushLazy + * retains the fetcher closure for pull-time + * re-invocation; bytes dropped after + * parseScalars. + * - kPureLazy: do not invoke fetcher at ingest. Register + * ObjectStore entry that defers fetcher + * invocation until consumer pull. No + * scalar columns produced. + * + * The plugin is policy-agnostic: it does not query the policy nor + * track which mode is active. Just constructs the fetcher and hands + * it off via this slot. + * + * Lifetime: the fetcher's `ctx` is allocated by the plugin. The host + * is responsible for calling `fetcher.release(fetcher.ctx)` exactly + * once when the fetcher is no longer needed (kEager: after the + * single fetch; lazy modes: when the ObjectStore entry it backs is + * dropped). `fetcher.fetch` must be thread-safe. + * + * Returns false + error on failure (binding handle invalid, + * ObjectStore push failed, etc.). On failure the host still calls + * `fetcher.release` so the plugin's ctx leaks no resources. + */ + bool (*push_message_v2)( + void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, + PJ_payload_fetcher_t fetcher, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_data_source_runtime_host_vtable_t; /** Fat pointer pairing a runtime host context with its vtable. */ diff --git a/pj_base/include/pj_base/message_parser_protocol.h b/pj_base/include/pj_base/message_parser_protocol.h index e3c06ae..bf8d290 100644 --- a/pj_base/include/pj_base/message_parser_protocol.h +++ b/pj_base/include/pj_base/message_parser_protocol.h @@ -8,9 +8,16 @@ * append_arrow_ipc — see plugin_data_api.h. Parsers stay per-record; * the host coalesces into Arrow batches internally. * + * v4 appendable tail (no version bump — protocol stays at 4): + * - classify_schema, parse_scalars, parse_object: pure-functional API + * that returns typed values instead of writing to host views. Enables + * lazy materialization and removes the parser's coupling to push policy. + * See pj_base/canonical_object_abi.h for the wire format. + * * The host obtains the plugin's vtable via `PJ_get_message_parser_vtable()` * and drives the plugin through: create -> bind(registry) -> - * (bind_schema) -> parse* -> destroy. + * (bind_schema) -> (classify_schema) -> parse* / parseScalars / parseObject + * -> destroy. */ #ifndef PJ_MESSAGE_PARSER_PROTOCOL_H #define PJ_MESSAGE_PARSER_PROTOCOL_H @@ -19,6 +26,7 @@ #include #include +#include "pj_base/canonical_object_abi.h" #include "pj_base/plugin_data_api.h" #ifdef __cplusplus @@ -110,6 +118,44 @@ typedef struct PJ_message_parser_vtable_t { * Tail slots beyond here are OPTIONAL. Host reads MUST check both * struct_size and slot-nullability via PJ_HAS_TAIL_SLOT. * ==================================================================== */ + + /** + * [thread-safe] A priori classification of the bound schema. Cheap; no + * payload required. Host invokes this after bind_schema(). Returns + * @p out_classification by value (POD). + * + * NULL or absent (struct_size too small) → host treats as + * PJ_CANONICAL_OBJECT_KIND_NONE. + * + * Pure-functional contract: no host side-effects. + */ + bool (*classify_schema)(void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, + PJ_schema_classification_t* out_classification, PJ_error_t* out_error) PJ_NOEXCEPT; + + /** + * [stream-thread] Pure-functional alternative to parse(): returns the + * scalar fields by value (out parameter) instead of writing them to the + * parser write host. The host invokes this in preference to parse() when + * available; legacy plugins keep using parse(). + * + * The plugin owns @p out_fields.fields buffer; @p out_fields.release is + * called by the host when done. release MAY be NULL. + */ + bool (*parse_scalars)(void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, + PJ_named_field_value_buffer_t* out_fields, PJ_error_t* out_error) PJ_NOEXCEPT; + + /** + * [stream-thread] Pure-functional production of a canonical object from + * the payload. Fills @p out_blob with the serialized object (see layout + * in canonical_object_abi.h). Only meaningful when classify_schema() + * returned a non-zero kind. + * + * Pure-functional contract: no writes to the object write host. The + * caller (DataSource / app) decides whether to push the blob eagerly, + * capture it inside a lazy lambda, or hand it directly to a consumer. + */ + bool (*parse_object)(void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, + PJ_canonical_object_blob_t* out_blob, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_message_parser_vtable_t; /* The vtable above is ABI-APPENDABLE: new slots may be added at the tail; * host reads guard with PJ_HAS_TAIL_SLOT. See PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE. */ diff --git a/pj_base/include/pj_base/sdk/canonical_object.hpp b/pj_base/include/pj_base/sdk/canonical_object.hpp new file mode 100644 index 0000000..b55e84e --- /dev/null +++ b/pj_base/include/pj_base/sdk/canonical_object.hpp @@ -0,0 +1,283 @@ +/** + * @file canonical_object.hpp + * @brief Canonical object types produced by MessageParser plugins and consumed + * by widgets and toolboxes. + * + * This header defines the vocabulary that bridges parser plugins (which + * understand wire formats: ROS, Foxglove, Protobuf, etc.) and consumer code + * (widgets, toolboxes) that renders or processes the result. The ObjectStore + * itself remains agnostic to these types — it stores opaque bytes; the + * decoding into a CanonicalObject happens in the consumer at pull time, by + * invoking the parser's parseObject() against the bytes. + * + * Reference report: docs/claude_reports/2026.05.07-arquitectura-objectstore-pipeline-misalignment.md + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "pj_base/span.hpp" +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +// ----------------------------------------------------------------------------- +// Schema classification +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Buffer anchor — type-erased ownership token shared between a payload buffer +// and any non-owning views derived from it. Carries no data, only keeps the +// underlying allocation alive while at least one anchor copy exists. Concrete +// typical type erased here is std::shared_ptr>; consumers +// never need to know. +// ----------------------------------------------------------------------------- + +using BufferAnchor = std::shared_ptr; + +/// Non-owning view + ownership anchor of a payload buffer. Used by the host +/// to hand a parser a message payload without committing to a copy: the parser +/// reads `bytes` and, in the canonical object it returns, may keep a Span into +/// the same memory plus a copy of `anchor` so the bytes outlive the parse call. +/// +/// `anchor` may be empty when the caller does not share ownership — in that +/// case the parser must materialize any bytes it wants to retain (the C ABI +/// trampoline path is the typical case; in-process direct calls are expected +/// to provide a non-empty anchor). +struct PayloadView { + Span bytes; + BufferAnchor anchor; +}; + +/// What kind of canonical object a parser produces for a given schema. +/// Returned a priori (without parsing payload) by classifySchema(). kNone means +/// the parser only produces scalars for the Datastore — no ObjectTopic to +/// register. +enum class CanonicalObjectKind : uint16_t { + kNone = 0, + kImage = 1, ///< sdk::Image — pixels already in canonical PixelFormat. + kCompressedImage = 2, ///< sdk::CompressedImage — JPEG/PNG/QOI bytes, undecoded. + kPointCloud = 3, ///< sdk::PointCloud — packed points + per-channel field layout. + // Reserved for future kinds; keep numeric values stable across releases. + // kMarkers = 4, + // kOccupancyGrid = 5, +}; + +/// A priori classification of a schema, returned by MessageParser::classifySchema(). +/// Currently a single field; struct (vs raw enum) leaves room to attach +/// declarative metadata later (preferred cache size, expected rate, etc.) without +/// breaking the API. What deliberately does NOT belong here: parse cost hints +/// (the DataSource knows the payload size), retention policy, eager/lazy choice. +struct SchemaClassification { + CanonicalObjectKind object_kind = CanonicalObjectKind::kNone; +}; + +// ----------------------------------------------------------------------------- +// Pixel formats — canonical for sdk::Image +// ----------------------------------------------------------------------------- + +/// Canonical pixel format for sdk::Image. The buffer may include row padding +/// (sdk::Image::row_step >= width * bytesPerPixel(format)); consumers must +/// honor row_step rather than assuming tightly-packed. +/// +/// Both R-G-B and B-G-R orderings are first-class citizens. ROS bgr8/bgra8 +/// (and many machine-vision sources) deliver bytes in B-G-R order natively; +/// keeping the byte order in the format tag (instead of swizzling at parse +/// time) lets the consumer hand bytes straight to a renderer that supports +/// GL_BGR / GL_BGRA texture uploads — zero-copy all the way. +/// +/// Note: pj_scene2D (and other consumers) currently define their own pixel +/// format. Harmonizing on this canonical enum is part of consumer-side +/// migration; this header defines the SDK-level vocabulary. +enum class PixelFormat : uint16_t { + kUnknown = 0, + kRGB888 = 1, ///< 3 bytes/pixel, R-G-B order. + kRGBA8888 = 2, ///< 4 bytes/pixel, R-G-B-A order. + kMono8 = 3, ///< 1 byte/pixel, grayscale. + kMono16 = 4, ///< 2 bytes/pixel, grayscale (depth, etc.); see is_bigendian. + kBGR888 = 5, ///< 3 bytes/pixel, B-G-R order (ROS bgr8, OpenCV native). + kBGRA8888 = 6, ///< 4 bytes/pixel, B-G-R-A order (ROS bgra8). +}; + +/// Bytes per pixel for a given format. Returns 0 for kUnknown. +[[nodiscard]] constexpr uint32_t bytesPerPixel(PixelFormat format) noexcept { + switch (format) { + case PixelFormat::kRGB888: + case PixelFormat::kBGR888: + return 3; + case PixelFormat::kRGBA8888: + case PixelFormat::kBGRA8888: + return 4; + case PixelFormat::kMono8: + return 1; + case PixelFormat::kMono16: + return 2; + case PixelFormat::kUnknown: + return 0; + } + return 0; +} + +// ----------------------------------------------------------------------------- +// sdk::Image — already-decoded image +// ----------------------------------------------------------------------------- + +/// Image already decoded into a canonical pixel format. If the producer +/// (parser) returns this, the consumer can upload the pixels directly to a +/// renderer (QRhi or otherwise) without going through any codec. +/// +/// Layout: `pixels` is a non-owning view of size at least `row_step * height`. +/// `row_step` may exceed `width * bytesPerPixel(pixel_format)` when the wire +/// format included per-row padding; consumers must honor it. `anchor` keeps +/// the underlying buffer alive — the parser may have made `pixels` a view +/// into the source payload (zero-copy) or into a freshly-allocated vector +/// (when the wire format required conversion); consumers don't need to know +/// which. +/// +/// For mono16 buffers `is_bigendian` indicates the byte order of each sample; +/// otherwise it is unused. RGB/BGR ordering is encoded in `pixel_format`. +struct Image { + uint32_t width = 0; + uint32_t height = 0; + PixelFormat pixel_format = PixelFormat::kUnknown; + uint32_t row_step = 0; + bool is_bigendian = false; + Span pixels; + BufferAnchor anchor; + Timestamp timestamp_ns = 0; +}; + +// ----------------------------------------------------------------------------- +// sdk::CompressedImage — undecoded compressed image bytes +// ----------------------------------------------------------------------------- + +/// Image still in compressed wire format (JPEG/PNG/QOI). The consumer is +/// expected to run it through the appropriate codec (pj_scene2D::JpegCodec, +/// PngCodec, etc.) to obtain an sdk::Image. +/// +/// The parser does NOT decompress: it only extracts the compressed payload +/// from whatever wrapper the wire format used (CDR for ROS2, etc.) and tags it +/// with the format. +struct CompressedImage { + enum class Format : uint8_t { + kUnknown = 0, + kJPEG = 1, + kPNG = 2, + kQOI = 3, + }; + + /// Auxiliary metadata that some wrappers attach to the compressed bytes + /// and that the consumer needs to decode correctly. The parser fills the + /// fields it can; consumers ignore those they don't care about. + struct Extras { + /// For ROS compressedDepth: the depth-quantization range to use after + /// PNG decoding. Both nullopt for non-depth compressed images. + std::optional compressed_depth_min; + std::optional compressed_depth_max; + }; + + Format format = Format::kUnknown; + Span bytes; + BufferAnchor anchor; + Timestamp timestamp_ns = 0; + Extras extras; +}; + +// ----------------------------------------------------------------------------- +// sdk::PointCloud — packed point cloud +// ----------------------------------------------------------------------------- + +/// Description of one channel inside a packed point cloud (x, y, z, intensity, +/// rgb, ring, time, …). Mirrors the shape of sensor_msgs/PointField but the +/// type is canonical PJ vocabulary, not a ROS-specific enum. +struct PointField { + enum class Datatype : uint8_t { + kUnknown = 0, + kInt8 = 1, + kUint8 = 2, + kInt16 = 3, + kUint16 = 4, + kInt32 = 5, + kUint32 = 6, + kFloat32 = 7, + kFloat64 = 8, + }; + + std::string name; + uint32_t offset = 0; ///< Byte offset of this field within a single point. + Datatype datatype = Datatype::kUnknown; + uint32_t count = 1; ///< Number of elements of `datatype` (typically 1). +}; + +/// Bytes per element for a given PointField datatype. Returns 0 for kUnknown. +[[nodiscard]] constexpr uint32_t bytesPerElement(PointField::Datatype dt) noexcept { + switch (dt) { + case PointField::Datatype::kInt8: + case PointField::Datatype::kUint8: + return 1; + case PointField::Datatype::kInt16: + case PointField::Datatype::kUint16: + return 2; + case PointField::Datatype::kInt32: + case PointField::Datatype::kUint32: + case PointField::Datatype::kFloat32: + return 4; + case PointField::Datatype::kFloat64: + return 8; + case PointField::Datatype::kUnknown: + return 0; + } + return 0; +} + +/// Packed point cloud. The `data` buffer holds `width * height` points, each +/// occupying `point_step` bytes laid out per `fields`. `is_dense=false` means +/// some points may be invalid (typically NaN-filled). +struct PointCloud { + uint32_t width = 0; + uint32_t height = 1; + uint32_t point_step = 0; ///< Bytes per point. + uint32_t row_step = 0; ///< Bytes per row (= point_step * width when no padding). + bool is_bigendian = false; + bool is_dense = true; + std::vector fields; + Span data; + BufferAnchor anchor; + Timestamp timestamp_ns = 0; +}; + +// ----------------------------------------------------------------------------- +// CanonicalObject — variant carried by parser->parseObject() +// ----------------------------------------------------------------------------- + +/// Sum type of all canonical objects a parser may produce. Closed for now; +/// extending it (kMarkers, kOccupancyGrid, …) requires bumping +/// PJ_MESSAGE_PARSER_PROTOCOL_VERSION (compatible append at the end). +using CanonicalObject = std::variant; + +/// Helper: get the kind tag for a CanonicalObject without unpacking it. +[[nodiscard]] inline CanonicalObjectKind kindOf(const CanonicalObject& obj) noexcept { + return std::visit( + [](const auto& concrete) -> CanonicalObjectKind { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return CanonicalObjectKind::kImage; + } else if constexpr (std::is_same_v) { + return CanonicalObjectKind::kCompressedImage; + } else if constexpr (std::is_same_v) { + return CanonicalObjectKind::kPointCloud; + } else { + return CanonicalObjectKind::kNone; + } + }, + obj); +} + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index 7dc191d..0710833 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -21,6 +21,7 @@ #include "pj_base/data_source_protocol.h" #include "pj_base/expected.hpp" +#include "pj_base/sdk/object_ingest_policy.hpp" #include "pj_base/sdk/plugin_data_api.hpp" namespace PJ { @@ -217,6 +218,108 @@ class DataSourceRuntimeHostView { return okStatus(); } + /// Push a message via a deferred byte fetcher. The DataSource hands the + /// host a callable that produces the payload bytes when invoked. The host + /// applies the active ObjectIngestPolicy (resolved via the + /// ObjectIngestPolicyResolver below for source_id, topic, kind) to decide + /// whether to invoke the fetcher at ingest, only on consumer pull, or + /// never. The DataSource is policy-agnostic — it neither queries the + /// policy nor tracks which mode is active. + /// + /// The fetcher MUST be idempotent — the host may invoke it zero, one, or + /// many times depending on policy and consumer pulls. It MUST be + /// thread-safe: invocations may come from the ingest thread (kEager) or + /// from consumer threads (lazy pulls). Capture by shared_ptr (file + /// readers, mcap chunks) so the source buffer outlives every pending + /// pull. + /// + /// Fetcher return type: + /// - sdk::PayloadView { bytes, anchor } — preferred, zero-copy. The + /// anchor is propagated through the C ABI as a heap-held shared_ptr + /// copy that the host releases when no longer needed. + /// - std::vector — legacy form. The vector is + /// heap-relocated and used as its own anchor; bytes survive across + /// the C ABI boundary at the cost of one alloc-and-move. + /// + /// The host MUST advertise the push_message_v2 tail slot. We wrap the + /// closure into a PJ_payload_fetcher_t and hand it over verbatim; the + /// host applies ObjectIngestPolicy and decides when (and whether) to + /// invoke it. There is no legacy fallback: a host that doesn't expose + /// the slot returns an explicit error here rather than silently + /// degrading to a kEager push_raw_message. + template + [[nodiscard]] Status pushMessage( + ParserBindingHandle handle, Timestamp host_timestamp_ns, Fetcher&& fetcher) const { + if (!valid()) { + return unexpected(std::string("runtime host is not bound")); + } + if (!PJ_HAS_TAIL_SLOT(PJ_data_source_runtime_host_vtable_t, host_.vtable, push_message_v2)) { + return unexpected(std::string("runtime host does not expose push_message_v2")); + } + + using FetcherT = std::decay_t; + auto* ctx = new FetcherT(std::forward(fetcher)); + + PJ_payload_fetcher_t abi_fetcher{ + .ctx = ctx, + .fetch = +[](void* c, PJ_payload_t* out, PJ_error_t* err) noexcept -> bool { + try { + auto& fn = *static_cast(c); + using Result = std::decay_t; + if constexpr (std::is_same_v) { + // Zero-copy path: hold a heap copy of the BufferAnchor so it + // survives across the C ABI; release_fn deletes the holder + // (and decrements the underlying shared_ptr ref count). + auto pv = fn(); + auto* held = new sdk::BufferAnchor(std::move(pv.anchor)); + out->data = pv.bytes.data(); + out->size = pv.bytes.size(); + out->anchor.ctx = held; + out->anchor.release = +[](void* h) noexcept { + delete static_cast(h); + }; + } else { + // Closure returns std::vector: heap-hold the vector; + // it owns its bytes. + auto* held = new std::vector(fn()); + out->data = held->data(); + out->size = held->size(); + out->anchor.ctx = held; + out->anchor.release = +[](void* h) noexcept { + delete static_cast*>(h); + }; + } + return true; + } catch (const std::exception& e) { + sdk::fillError(err, 1, "plugin", e.what()); + return false; + } catch (...) { + sdk::fillError(err, 1, "plugin", "unknown exception in payload fetcher"); + return false; + } + }, + .release = +[](void* c) noexcept { delete static_cast(c); }, + }; + + PJ_error_t err{}; + if (!host_.vtable->push_message_v2(host_.ctx, handle, host_timestamp_ns, abi_fetcher, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Access (mutable) the resolver of ObjectIngestPolicy for this runtime. + /// The application configures it during setup; the host (when the + /// push_message_v2 dispatch lands) consults it per message. + /// + /// Implementation status (RFC): + /// The resolver is a per-DataSourceRuntimeHostView local instance for + /// now. In production it will be host-owned and shared across views; + /// the SDK surface stays the same. + [[nodiscard]] sdk::ObjectIngestPolicyResolver& objectIngestPolicy() const { + return policy_resolver_; + } + /** * Display a modal message box and wait for user response. * @return The button clicked, or kOk if the host does not support dialogs. @@ -277,6 +380,13 @@ class DataSourceRuntimeHostView { private: PJ_data_source_runtime_host_t host_{}; + + // RFC-only: local-to-view policy resolver. Production wiring will move + // this to a host-side singleton accessed through the service registry; + // the public surface (objectIngestPolicy()) stays the same. mutable + // because configuring the policy is conceptually a side concern, not + // a mutation of the view. + mutable sdk::ObjectIngestPolicyResolver policy_resolver_{}; }; } // namespace PJ diff --git a/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp b/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp new file mode 100644 index 0000000..e57f1a4 --- /dev/null +++ b/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp @@ -0,0 +1,345 @@ +/** + * @file detail/canonical_object_serialization.hpp + * @brief (De)serialization of PJ::sdk::CanonicalObject to/from the byte + * layout defined in pj_base/canonical_object_abi.h. + * + * The blob crosses the C ABI as raw bytes; this header turns it into the + * C++ variant on the host side and back into bytes on the plugin side. + * + * Endianness: writes/reads multi-byte integers using std::memcpy under the + * assumption that the host architecture is little-endian (the ABI mandates + * little-endian). Big-endian targets would need an explicit byte-swap layer + * here; documented as a known limitation in this iteration. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "pj_base/canonical_object_abi.h" +#include "pj_base/expected.hpp" +#include "pj_base/sdk/canonical_object.hpp" + +namespace PJ { +namespace sdk { +namespace detail { + +// ----------------------------------------------------------------------------- +// Low-level write helpers (host-endian = little-endian assumed) +// ----------------------------------------------------------------------------- + +inline void appendBytes(std::vector& out, const void* src, size_t n) { + if (n == 0) return; + const auto* p = static_cast(src); + out.insert(out.end(), p, p + n); +} + +template +inline void appendPod(std::vector& out, T value) { + static_assert(std::is_trivially_copyable_v, "appendPod requires trivially copyable"); + appendBytes(out, &value, sizeof(T)); +} + +// ----------------------------------------------------------------------------- +// Low-level read helpers +// ----------------------------------------------------------------------------- + +class BlobReader { + public: + BlobReader(const uint8_t* data, size_t size) : ptr_(data), end_(data + size) {} + + [[nodiscard]] bool remaining(size_t n) const noexcept { + return static_cast(end_ - ptr_) >= n; + } + + template + Expected readPod() { + static_assert(std::is_trivially_copyable_v, "readPod requires trivially copyable"); + if (!remaining(sizeof(T))) { + return unexpected(std::string("blob truncated")); + } + T value; + std::memcpy(&value, ptr_, sizeof(T)); + ptr_ += sizeof(T); + return value; + } + + Expected> readBytes(size_t n) { + if (!remaining(n)) { + return unexpected(std::string("blob truncated reading bytes")); + } + std::vector out(ptr_, ptr_ + n); + ptr_ += n; + return out; + } + + Expected readString(size_t n) { + if (!remaining(n)) { + return unexpected(std::string("blob truncated reading string")); + } + std::string out(reinterpret_cast(ptr_), n); + ptr_ += n; + return out; + } + + private: + const uint8_t* ptr_; + const uint8_t* end_; +}; + +// ----------------------------------------------------------------------------- +// Serialization (C++ → bytes) +// ----------------------------------------------------------------------------- + +inline void writeImageBody(std::vector& out, const Image& img) { + appendPod(out, img.width); + appendPod(out, img.height); + appendPod(out, static_cast(img.pixel_format)); + appendPod(out, img.is_bigendian ? 1 : 0); + appendPod(out, 0); // reserved + appendPod(out, img.row_step); + const uint32_t pixels_size = static_cast(img.pixels.size()); + appendPod(out, pixels_size); + if (pixels_size > 0) { + appendBytes(out, img.pixels.data(), pixels_size); + } +} + +inline void writeCompressedImageBody(std::vector& out, const CompressedImage& ci) { + appendPod(out, static_cast(ci.format)); + appendPod(out, ci.extras.compressed_depth_min.has_value() ? 1 : 0); + appendPod(out, ci.extras.compressed_depth_max.has_value() ? 1 : 0); + appendPod(out, 0); // reserved + appendPod(out, ci.extras.compressed_depth_min.value_or(0.0f)); + appendPod(out, ci.extras.compressed_depth_max.value_or(0.0f)); + const uint32_t bytes_size = static_cast(ci.bytes.size()); + appendPod(out, bytes_size); + if (bytes_size > 0) { + appendBytes(out, ci.bytes.data(), bytes_size); + } +} + +inline void writePointCloudBody(std::vector& out, const PointCloud& pc) { + appendPod(out, pc.width); + appendPod(out, pc.height); + appendPod(out, pc.point_step); + appendPod(out, pc.row_step); + appendPod(out, pc.is_bigendian ? 1 : 0); + appendPod(out, pc.is_dense ? 1 : 0); + appendPod(out, static_cast(pc.fields.size())); + for (const auto& f : pc.fields) { + const uint32_t name_size = static_cast(f.name.size()); + appendPod(out, name_size); + appendBytes(out, f.name.data(), name_size); + appendPod(out, f.offset); + appendPod(out, static_cast(f.datatype)); + appendPod(out, 0); // reserved + appendPod(out, 0); // reserved + appendPod(out, 0); // reserved + appendPod(out, f.count); + } + const uint32_t data_size = static_cast(pc.data.size()); + appendPod(out, data_size); + if (data_size > 0) { + appendBytes(out, pc.data.data(), data_size); + } +} + +/// Serialize a CanonicalObject into a flat byte buffer matching the layout +/// in canonical_object_abi.h. Caller owns the returned vector. +inline std::vector serializeCanonicalObject(const CanonicalObject& obj) { + std::vector out; + out.reserve(64); // header + small body; body grows for image/pointcloud + + // Header: kind (u16), reserved (u16), timestamp (i64). + const auto kind = kindOf(obj); + appendPod(out, static_cast(kind)); + appendPod(out, 0); // reserved + std::visit( + [&](const auto& concrete) { + appendPod(out, concrete.timestamp_ns); + using T = std::decay_t; + if constexpr (std::is_same_v) { + writeImageBody(out, concrete); + } else if constexpr (std::is_same_v) { + writeCompressedImageBody(out, concrete); + } else if constexpr (std::is_same_v) { + writePointCloudBody(out, concrete); + } + }, + obj); + + return out; +} + +// ----------------------------------------------------------------------------- +// Deserialization (bytes → C++) +// ----------------------------------------------------------------------------- + +// On the deserialize side we don't have a foreign anchor — the bytes come +// from the blob buffer. Wrap them in a shared_ptr and use that as +// the anchor; the Span points into the wrapped vector. Net cost: one alloc +// per object, same as before the iter-3 SDK change. +inline Expected readImageBody(BlobReader& r, Timestamp ts) { + auto width = r.readPod(); + if (!width) return unexpected(width.error()); + auto height = r.readPod(); + if (!height) return unexpected(height.error()); + auto pixel_format_raw = r.readPod(); + if (!pixel_format_raw) return unexpected(pixel_format_raw.error()); + auto is_be = r.readPod(); + if (!is_be) return unexpected(is_be.error()); + /*reserved*/ if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + auto row_step = r.readPod(); + if (!row_step) return unexpected(row_step.error()); + auto pixels_size = r.readPod(); + if (!pixels_size) return unexpected(pixels_size.error()); + auto pixels = r.readBytes(*pixels_size); + if (!pixels) return unexpected(pixels.error()); + + auto owned = std::make_shared>(std::move(*pixels)); + Span view(owned->data(), owned->size()); + return Image{ + .width = *width, + .height = *height, + .pixel_format = static_cast(*pixel_format_raw), + .row_step = *row_step, + .is_bigendian = (*is_be != 0), + .pixels = view, + .anchor = owned, + .timestamp_ns = ts, + }; +} + +inline Expected readCompressedImageBody(BlobReader& r, Timestamp ts) { + auto format_raw = r.readPod(); + if (!format_raw) return unexpected(format_raw.error()); + auto has_min = r.readPod(); + if (!has_min) return unexpected(has_min.error()); + auto has_max = r.readPod(); + if (!has_max) return unexpected(has_max.error()); + /*reserved*/ if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + auto depth_min = r.readPod(); + if (!depth_min) return unexpected(depth_min.error()); + auto depth_max = r.readPod(); + if (!depth_max) return unexpected(depth_max.error()); + auto bytes_size = r.readPod(); + if (!bytes_size) return unexpected(bytes_size.error()); + auto bytes = r.readBytes(*bytes_size); + if (!bytes) return unexpected(bytes.error()); + + auto owned = std::make_shared>(std::move(*bytes)); + CompressedImage ci{}; + ci.format = static_cast(*format_raw); + ci.bytes = Span(owned->data(), owned->size()); + ci.anchor = owned; + ci.timestamp_ns = ts; + if (*has_min != 0) ci.extras.compressed_depth_min = *depth_min; + if (*has_max != 0) ci.extras.compressed_depth_max = *depth_max; + return ci; +} + +inline Expected readPointCloudBody(BlobReader& r, Timestamp ts) { + auto width = r.readPod(); + if (!width) return unexpected(width.error()); + auto height = r.readPod(); + if (!height) return unexpected(height.error()); + auto point_step = r.readPod(); + if (!point_step) return unexpected(point_step.error()); + auto row_step = r.readPod(); + if (!row_step) return unexpected(row_step.error()); + auto is_be = r.readPod(); + if (!is_be) return unexpected(is_be.error()); + auto is_dense = r.readPod(); + if (!is_dense) return unexpected(is_dense.error()); + auto fields_count = r.readPod(); + if (!fields_count) return unexpected(fields_count.error()); + + std::vector fields; + fields.reserve(*fields_count); + for (uint16_t i = 0; i < *fields_count; ++i) { + auto name_size = r.readPod(); + if (!name_size) return unexpected(name_size.error()); + auto name = r.readString(*name_size); + if (!name) return unexpected(name.error()); + auto offset = r.readPod(); + if (!offset) return unexpected(offset.error()); + auto datatype_raw = r.readPod(); + if (!datatype_raw) return unexpected(datatype_raw.error()); + /*reserved×3*/ for (int j = 0; j < 3; ++j) { + if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + } + auto count = r.readPod(); + if (!count) return unexpected(count.error()); + fields.push_back(PointField{ + .name = std::move(*name), + .offset = *offset, + .datatype = static_cast(*datatype_raw), + .count = *count, + }); + } + + auto data_size = r.readPod(); + if (!data_size) return unexpected(data_size.error()); + auto data = r.readBytes(*data_size); + if (!data) return unexpected(data.error()); + + auto owned = std::make_shared>(std::move(*data)); + Span view(owned->data(), owned->size()); + return PointCloud{ + .width = *width, + .height = *height, + .point_step = *point_step, + .row_step = *row_step, + .is_bigendian = (*is_be != 0), + .is_dense = (*is_dense != 0), + .fields = std::move(fields), + .data = view, + .anchor = owned, + .timestamp_ns = ts, + }; +} + +/// Deserialize a flat byte buffer into a CanonicalObject. Returns unexpected +/// on truncation, unknown kind, or any inconsistency. +inline Expected deserializeCanonicalObject(const uint8_t* data, size_t size) { + if (data == nullptr) { + return unexpected(std::string("null blob")); + } + BlobReader r(data, size); + + auto kind_raw = r.readPod(); + if (!kind_raw) return unexpected(kind_raw.error()); + /*reserved*/ if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + auto ts = r.readPod(); + if (!ts) return unexpected(ts.error()); + + switch (static_cast(*kind_raw)) { + case CanonicalObjectKind::kImage: { + auto img = readImageBody(r, *ts); + if (!img) return unexpected(img.error()); + return CanonicalObject{std::move(*img)}; + } + case CanonicalObjectKind::kCompressedImage: { + auto ci = readCompressedImageBody(r, *ts); + if (!ci) return unexpected(ci.error()); + return CanonicalObject{std::move(*ci)}; + } + case CanonicalObjectKind::kPointCloud: { + auto pc = readPointCloudBody(r, *ts); + if (!pc) return unexpected(pc.error()); + return CanonicalObject{std::move(*pc)}; + } + case CanonicalObjectKind::kNone: + default: + return unexpected(std::string("unknown or unsupported canonical object kind")); + } +} + +} // namespace detail +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp b/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp index caa56b6..8264cc1 100644 --- a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp +++ b/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp @@ -7,6 +7,8 @@ */ #pragma once +#include "pj_base/sdk/detail/canonical_object_serialization.hpp" + namespace PJ { inline void MessageParserPluginBase::trampoline_destroy(void* ctx) noexcept { @@ -127,4 +129,105 @@ inline const void* MessageParserPluginBase::trampoline_get_plugin_extension(void } } +// ----------------------------------------------------------------------------- +// Pure-functional API trampolines (canonical-object tail of the vtable) +// ----------------------------------------------------------------------------- + +inline bool MessageParserPluginBase::trampoline_classify_schema( + void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, + PJ_schema_classification_t* out_classification, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (out_classification == nullptr) { + self->storeError(out_error, 2, "plugin", "classify_schema called with null out_classification"); + return false; + } + try { + auto name_sv = type_name.data == nullptr ? std::string_view{} : std::string_view(type_name.data, type_name.size); + Span schema_span(schema.data, schema.size); + const auto cls = self->classifySchema(name_sv, schema_span); + out_classification->object_kind = static_cast(cls.object_kind); + out_classification->reserved = 0; + return true; + } catch (const std::exception& e) { + self->storeError(out_error, 1, "plugin", std::string("classify_schema threw: ") + e.what()); + return false; + } catch (...) { + self->storeError(out_error, 1, "plugin", "unknown exception in classify_schema"); + return false; + } +} + +inline bool MessageParserPluginBase::trampoline_parse_scalars( + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, + PJ_named_field_value_buffer_t* out_fields, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (out_fields == nullptr) { + self->storeError(out_error, 2, "plugin", "parse_scalars called with null out_fields"); + return false; + } + try { + Span payload_span(payload.data, payload.size); + auto result = self->parseScalars(timestamp_ns, payload_span); + if (!result) { + self->storeError(out_error, 1, "plugin", std::move(result).error()); + return false; + } + // Hand the C++ vector to the plugin-owned buffer so PJ_string_view_t + // entries inside the ABI structs remain valid until the next call. + self->scalars_owned_buf_ = std::move(*result); + self->scalars_abi_buf_ = sdk::toAbiNamed( + Span(self->scalars_owned_buf_.data(), self->scalars_owned_buf_.size())); + + out_fields->fields = self->scalars_abi_buf_.data(); + out_fields->count = self->scalars_abi_buf_.size(); + out_fields->alloc_handle = nullptr; // buffer kept alive by the plugin instance + out_fields->release = nullptr; + return true; + } catch (const std::exception& e) { + self->storeError(out_error, 1, "plugin", std::string("parse_scalars threw: ") + e.what()); + return false; + } catch (...) { + self->storeError(out_error, 1, "plugin", "unknown exception in parse_scalars"); + return false; + } +} + +inline bool MessageParserPluginBase::trampoline_parse_object( + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, + PJ_canonical_object_blob_t* out_blob, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (out_blob == nullptr) { + self->storeError(out_error, 2, "plugin", "parse_object called with null out_blob"); + return false; + } + try { + // C ABI path: caller does not share ownership of the payload buffer. + // Pass an empty anchor; the plugin must materialize anything it wants + // to retain past this call. The serialized blob written to out_blob is + // copied into self->object_blob_buf_ before we return, so a span-into- + // payload that the plugin keeps inside its CanonicalObject is fine for + // the duration of the serialize call below. + Span payload_span(payload.data, payload.size); + sdk::PayloadView payload_view{payload_span, sdk::BufferAnchor{}}; + auto result = self->parseObject(timestamp_ns, payload_view); + if (!result) { + self->storeError(out_error, 1, "plugin", std::move(result).error()); + return false; + } + self->object_blob_buf_ = sdk::detail::serializeCanonicalObject(*result); + + out_blob->data = self->object_blob_buf_.data(); + out_blob->size = self->object_blob_buf_.size(); + out_blob->alloc_handle = nullptr; // buffer kept alive by the plugin instance + out_blob->release = nullptr; + return true; + } catch (const std::exception& e) { + self->storeError(out_error, 1, "plugin", std::string("parse_object threw: ") + e.what()); + return false; + } catch (...) { + self->storeError(out_error, 1, "plugin", "unknown exception in parse_object"); + return false; + } +} + } // namespace PJ diff --git a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp index f2b88a9..bf68690 100644 --- a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp @@ -13,18 +13,51 @@ #include #include +#include #include #include +#include #include +#include #include "pj_base/expected.hpp" #include "pj_base/message_parser_protocol.h" #include "pj_base/plugin_abi_export.h" +#include "pj_base/sdk/canonical_object.hpp" #include "pj_base/sdk/plugin_data_api.hpp" #include "pj_base/sdk/service_registry.hpp" #include "pj_base/sdk/service_traits.hpp" namespace PJ { +namespace sdk { + +/// Per-schema handler bundle: classification + the two parse routes for one +/// schema type. Plugins build a table of these in their constructor; the +/// MessageParserPluginBase base class then implements classifySchema / +/// parseScalars / parseObject as final lookups into the table. +/// +/// Either parse_scalars or parse_object may be null (or both), reflecting +/// schemas that produce only scalars, only objects, or that the plugin +/// recognizes but routes through the legacy parse() path. +struct SchemaHandler { + CanonicalObjectKind object_kind = CanonicalObjectKind::kNone; + + /// Scalar route: returns owned column data — no anchor needed because the + /// returned vector and any string_views inside it are materialized by the + /// parser, independent of the caller's payload buffer. + std::function>(Timestamp, Span)> + parse_scalars; + + /// Canonical-object route: takes a PayloadView so the parser can return a + /// CanonicalObject whose internal Span(s) reference the same underlying + /// buffer (zero-copy). The parser propagates `payload.anchor` into the + /// returned object so its bytes outlive this call. When the caller passes + /// an empty anchor, the parser must materialize whatever it wants to retain. + std::function(Timestamp, PayloadView)> + parse_object; +}; + +} // namespace sdk /** * Base class for MessageParser plugins (protocol v4). @@ -59,10 +92,26 @@ class MessageParserPluginBase { return okStatus(); } - /// Bind a message schema. Default is no-op (for parsers that don't need schema). + /// Bind a message schema. The base implementation records the type name + /// verbatim so subsequent parseScalars / parseObject calls can dispatch + /// against the registered handler table without needing it as a parameter. + /// + /// The base does NO domain-specific normalization on the type name — + /// the SDK has no idea whether a name like \"pkg/msg/Type\" is valid or + /// equivalent to \"pkg/Type\" in some plugin's domain (that\'s a ROS-2 + /// convention, not a general one). Plugins that have their own naming + /// convention should apply it here, in their override, before delegating + /// to MessageParserPluginBase::bindSchema with the canonical form. They + /// must also use that same canonical form when calling + /// registerSchemaHandler. + /// + /// Subclasses that override this MUST call MessageParserPluginBase::bindSchema() + /// first (or set bound_type_name_ themselves) before any plugin-specific + /// schema setup, otherwise the table-based dispatch will fail to find the + /// schema's handler. virtual Status bindSchema(std::string_view type_name, Span schema) { - (void)type_name; (void)schema; + bound_type_name_.assign(type_name); return okStatus(); } @@ -75,8 +124,140 @@ class MessageParserPluginBase { return okStatus(); } - /// Parse one raw message and write decoded fields via writeHost(). PURE VIRTUAL. - virtual Status parse(Timestamp timestamp_ns, Span payload) = 0; + /// Parse one raw message and write decoded fields via writeHost(). + /// + /// The default implementation dispatches through the SchemaHandler table: + /// it invokes parseScalars() (which looks up the registered handler for + /// bound_type_name_) and shovels the returned vector to + /// writeHost().appendRecord(). Plugins that register all their schemas + /// via registerSchemaHandler() therefore inherit a working parse() for + /// free — no override needed. + /// + /// Subclasses MAY override to (a) add a fallback for type names not in + /// the registered table (e.g. a ROS-style generic flattener that handles + /// any message whose schema definition is known to the plugin), or + /// (b) retain a fully imperative implementation during migration to the + /// table-based dispatch. Plugins that have already migrated do not need + /// to override. + /// + /// This entry point exists for compatibility with the legacy v4 ingest + /// path (host calls parser.parse() directly to push fields to writeHost). + /// New host code should prefer pushing through parseScalars() / parseObject() + /// — the pure-functional pair enables lazy materialization, because the + /// caller (DataSource / app) needs the result returned, not pushed. Once + /// every host migrates to that path, parse() will be deprecated. + virtual Status parse(Timestamp timestamp_ns, Span payload) { + if (!writeHostBound()) { + return unexpected(std::string("write host not bound")); + } + auto fields = parseScalars(timestamp_ns, payload); + if (!fields) { + return unexpected(std::move(fields).error()); + } + if (fields->empty()) { + return okStatus(); + } + return writeHost().appendRecord( + timestamp_ns, + Span(fields->data(), fields->size())); + } + + // --------------------------------------------------------------------------- + // Pure-functional API (added in protocol v5, ABI-appendable) + // --------------------------------------------------------------------------- + // + // Design principle: the parser does NOT decide push policy (eager vs lazy) + // and does NOT decide where the result goes (Datastore, ObjectStore, none). + // Both decisions belong to the caller (DataSource / app). The parser is + // strictly a translator: bytes in, typed values out. Always eager when + // invoked — there is no internal deferral. Lazyness is modeled by callers + // wrapping these methods inside a lambda that fires on pull. + // + // Plugins extend the parser by populating a per-schema handler table in + // the constructor (registerSchemaHandler). The base class implements + // classifySchema / parseScalars / parseObject as final lookups into that + // table. Plugins do NOT override the three methods. + + /// Register a handler for one schema type name. Typically called once per + /// supported schema in the plugin's constructor. + /// + /// The type_name is stored verbatim — the base class does no domain- + /// specific normalization. Plugins that have their own naming convention + /// (e.g. ROS-2 \"pkg/msg/Type\" vs ROS-1 \"pkg/Type\") must register and + /// look up using a single canonical form they pick. The base class will + /// look up handlers using the bound_type_name_ value the plugin set in + /// bindSchema, so the two must agree on the convention. + /// + /// Either `handler.parse_scalars` or `handler.parse_object` may be null — + /// the base class returns the appropriate unexpected when an absent route + /// is invoked for that schema. + void registerSchemaHandler(std::string_view type_name, sdk::SchemaHandler handler) { + handlers_.insert_or_assign(std::string(type_name), std::move(handler)); + } + + /// Strict lookup — returns nullptr if no handler is registered for this + /// exact type name. Caller must not retain the pointer past the next + /// mutation of the handler table. There is no fallback / default + /// mechanism in the SDK: a plugin that wants behaviour for unknown + /// types is expected to register a handler under the bound name itself + /// (typically inside its bindSchema override). + [[nodiscard]] const sdk::SchemaHandler* findSchemaHandler(std::string_view type_name) const { + auto it = handlers_.find(std::string(type_name)); + if (it == handlers_.end()) { + return nullptr; + } + return &it->second; + } + + /// Lookup against the registered handler table. Non-virtual: plugins + /// populate the table via registerSchemaHandler() rather than overriding; + /// the C ABI trampolines invoke this directly on MessageParserPluginBase*. + /// Returns kNone when no handler is registered for this type name. + sdk::SchemaClassification classifySchema( + std::string_view type_name, Span schema) const { + (void)schema; + if (const auto* h = findSchemaHandler(type_name)) { + return {h->object_kind}; + } + return {}; + } + + /// Invoke the registered scalar handler for the currently-bound schema. + /// Returns unexpected if no handler is registered, or if the registered + /// handler did not provide a parse_scalars callable. Non-virtual — see + /// classifySchema above for the rationale. + Expected> parseScalars( + Timestamp timestamp_ns, Span payload) { + const auto* h = findSchemaHandler(bound_type_name_); + if (h == nullptr) { + return unexpected(std::string("parser does not register schema: ") + bound_type_name_); + } + if (!h->parse_scalars) { + return unexpected(std::string("registered handler has no parse_scalars: ") + bound_type_name_); + } + return h->parse_scalars(timestamp_ns, payload); + } + + /// Invoke the registered object handler for the currently-bound schema. + /// Returns unexpected if no handler is registered, or if the registered + /// handler did not provide a parse_object callable (i.e. this schema + /// produces only scalars). Non-virtual — see classifySchema above. + /// + /// `payload.anchor` may be empty; in that case the parser is expected to + /// materialize anything it wants to outlive this call. In-process callers + /// that already own the payload buffer should pass a non-empty anchor so + /// the parser can return a zero-copy CanonicalObject. + Expected parseObject( + Timestamp timestamp_ns, sdk::PayloadView payload) { + const auto* h = findSchemaHandler(bound_type_name_); + if (h == nullptr) { + return unexpected(std::string("parser does not register schema: ") + bound_type_name_); + } + if (!h->parse_object) { + return unexpected(std::string("registered handler has no parse_object: ") + bound_type_name_); + } + return h->parse_object(timestamp_ns, payload); + } /// Return a pointer to a static plugin-exposed extension for @p id, or /// nullptr if unknown. Default returns nullptr. @@ -104,6 +285,10 @@ class MessageParserPluginBase { trampoline_load_config, trampoline_parse, trampoline_get_plugin_extension, + // Tail slots: pure-functional API (canonical-object). + trampoline_classify_schema, + trampoline_parse_scalars, + trampoline_parse_object, }; return &vt; } @@ -130,12 +315,33 @@ class MessageParserPluginBase { return write_host_view_.valid(); } + protected: + /// Last type name received by bindSchema, stored verbatim. Used by the + /// table-based dispatch in classifySchema / parseScalars / parseObject: + /// the base looks up the handler for this string in the registered table. + /// + /// Subclasses that override bindSchema must either call the base class + /// implementation or set this member themselves. If the plugin has its + /// own naming convention, the canonical form it picks must be the same + /// here and at registerSchemaHandler — the base does not normalize. + std::string bound_type_name_; + private: sdk::ServiceRegistry service_registry_{}; sdk::ParserWriteHostView write_host_view_{PJ_parser_write_host_t{}}; sdk::ParserObjectWriteHostView object_write_host_view_{}; std::string config_buf_; + // Schema handler table populated by the plugin via registerSchemaHandler(). + std::unordered_map handlers_; + + // Buffers kept alive between parse_scalars / parse_object calls so the host + // can read the returned slices safely. release callbacks in the ABI structs + // are NULL — the plugin owns the buffers and overwrites them on each call. + std::vector scalars_owned_buf_; + std::vector scalars_abi_buf_; + std::vector object_blob_buf_; + static void storeError(PJ_error_t* out_error, int32_t code, std::string_view domain, std::string_view message) { sdk::fillError(out_error, code, domain, message); } @@ -149,6 +355,15 @@ class MessageParserPluginBase { static bool trampoline_parse( void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_error_t* out_error) noexcept; static const void* trampoline_get_plugin_extension(void* ctx, PJ_string_view_t id) noexcept; + static bool trampoline_classify_schema( + void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, + PJ_schema_classification_t* out_classification, PJ_error_t* out_error) noexcept; + static bool trampoline_parse_scalars( + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, + PJ_named_field_value_buffer_t* out_fields, PJ_error_t* out_error) noexcept; + static bool trampoline_parse_object( + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, + PJ_canonical_object_blob_t* out_blob, PJ_error_t* out_error) noexcept; }; } // namespace PJ diff --git a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp b/pj_base/include/pj_base/sdk/object_ingest_policy.hpp new file mode 100644 index 0000000..bce03c7 --- /dev/null +++ b/pj_base/include/pj_base/sdk/object_ingest_policy.hpp @@ -0,0 +1,119 @@ +/** + * @file object_ingest_policy.hpp + * @brief Configurable policy that the host applies when a DataSource hands + * it a deferred byte fetcher via DataSourceRuntimeHostView::pushMessage. + * + * The DataSource is policy-agnostic: it only fabricates a callable that + * produces the raw payload bytes when invoked. The host decides — based on + * the policy resolved for (source_id, topic, kind) — whether to invoke the + * fetcher immediately (parse and store now), invoke it once for scalars + * and again on each pull, or never invoke it during ingest and only on + * consumer pulls. + * + * Reference design: docs/claude_reports/2026.05.07-arquitectura-objectstore-pipeline-misalignment.md + */ +#pragma once + +#include +#include +#include + +#include "pj_base/sdk/canonical_object.hpp" + +namespace PJ { +namespace sdk { + +enum class ObjectIngestPolicy : uint8_t { + /// Host never invokes the fetcher during ingest. The (timestamp, fetcher) + /// pair is registered in the ObjectStore and the fetcher fires only when a + /// consumer pulls. No scalar timeseries are produced for this topic — its + /// scalar fields (header.stamp, width, height, …) do not appear in the + /// Datastore. The topic shows up as an ObjectTopic without children in the + /// unified curve tree. Best for very large blobs (point clouds, 4K video) + /// when scalar timeseries are not interesting. + kPureLazy, + + /// Host invokes the fetcher once during ingest to obtain bytes; parser's + /// parseScalars runs and writes scalar fields to the Datastore; bytes are + /// then dropped from RAM. The ObjectStore retains only the fetcher closure + /// for re-invocation on pull (which means the file/source is read again). + /// Best for the common case: scalar timeseries appear in the tree, the + /// blob does not stay in RAM, and pulls re-read on demand. + kLazyObjectsEagerScalars, + + /// Host invokes the fetcher once during ingest, parser's parseScalars and + /// parseObject both run, the canonical object is serialized into the + /// ObjectStore via pushOwned. Pull is trivial — bytes are already there. + /// Highest memory cost; the only viable mode for streaming sources that + /// have no persistent reader to re-read from. Streaming-only fallback. + kEager, +}; + +/// Resolver with hierarchical overrides: +/// +/// topic > data_source > kind > default +/// +/// The application sets the levels it cares about during setup; the host +/// queries resolve(source_id, topic, kind) for each message. The resolver +/// is intentionally an opaque carrier — its policy decisions are the +/// host's concern, not the DataSource plugin's. +/// +/// Typical setup: +/// +/// resolver.setDefault(kLazyObjectsEagerScalars); +/// resolver.setForKind(CanonicalObjectKind::kCompressedImage, kPureLazy); +/// resolver.setForKind(CanonicalObjectKind::kPointCloud, kPureLazy); +/// // kImage stays at kLazyObjectsEagerScalars: width/height/encoding columns are useful +/// +class ObjectIngestPolicyResolver { + public: + /// Default policy applied when no more specific override matches. + void setDefault(ObjectIngestPolicy policy) { + default_ = policy; + } + + /// Override the default for a specific canonical object kind. Useful when + /// (e.g.) all PointCloud2 topics should be lazy regardless of source. + void setForKind(CanonicalObjectKind kind, ObjectIngestPolicy policy) { + by_kind_[kind] = policy; + } + + /// Override the default for all topics of a specific DataSource, keyed by + /// the plugin manifest "id". + void setForDataSource(std::string_view source_id, ObjectIngestPolicy policy) { + by_source_[std::string(source_id)] = policy; + } + + /// Override the default for a specific topic name. Highest precedence. + void setForTopic(std::string_view topic_name, ObjectIngestPolicy policy) { + by_topic_[std::string(topic_name)] = policy; + } + + /// Resolve the policy for a given (source_id, topic_name, object_kind). + /// Precedence: topic > source > kind > default. The first match wins — + /// no merging or composition between levels. + [[nodiscard]] ObjectIngestPolicy resolve( + std::string_view source_id, + std::string_view topic_name, + CanonicalObjectKind object_kind) const { + if (auto it = by_topic_.find(std::string(topic_name)); it != by_topic_.end()) { + return it->second; + } + if (auto it = by_source_.find(std::string(source_id)); it != by_source_.end()) { + return it->second; + } + if (auto it = by_kind_.find(object_kind); it != by_kind_.end()) { + return it->second; + } + return default_; + } + + private: + ObjectIngestPolicy default_ = ObjectIngestPolicy::kLazyObjectsEagerScalars; + std::unordered_map by_kind_; + std::unordered_map by_source_; + std::unordered_map by_topic_; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index ccbf2fe..2be7936 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -79,7 +79,8 @@ static_assert(offsetof(PJ_message_parser_vtable_t, struct_size) == 4, "v4 prefix static_assert(offsetof(PJ_message_parser_vtable_t, bind) == 32, "v4 bind slot pinned"); static_assert(offsetof(PJ_message_parser_vtable_t, parse) == 64, "v4 parse slot pinned"); static_assert(offsetof(PJ_message_parser_vtable_t, get_plugin_extension) == 72, "v4 last baseline slot pinned"); -static_assert(sizeof(PJ_message_parser_vtable_t) == 80, "MessageParser vtable size (update deliberately on append)"); +// 80 baseline (v4.0) + 3 canonical-object tail slots × 8 bytes each = 104. +static_assert(sizeof(PJ_message_parser_vtable_t) == 104, "MessageParser vtable size (update deliberately on append)"); static_assert(PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE == 80, "MIN vtable size is pinned at v4.0 — NEVER INCREASE"); static_assert(PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE <= sizeof(PJ_message_parser_vtable_t), "MIN must never exceed current"); diff --git a/pj_base/tests/object_ingest_policy_test.cpp b/pj_base/tests/object_ingest_policy_test.cpp new file mode 100644 index 0000000..184f114 --- /dev/null +++ b/pj_base/tests/object_ingest_policy_test.cpp @@ -0,0 +1,92 @@ +#include "pj_base/sdk/object_ingest_policy.hpp" + +#include + +using PJ::sdk::CanonicalObjectKind; +using PJ::sdk::ObjectIngestPolicy; +using PJ::sdk::ObjectIngestPolicyResolver; + +TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyScalars) { + ObjectIngestPolicyResolver r; + EXPECT_EQ(r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), + ObjectIngestPolicy::kLazyObjectsEagerScalars); +} + +TEST(ObjectIngestPolicyResolverTest, SetDefaultIsRespected) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), + ObjectIngestPolicy::kEager); +} + +TEST(ObjectIngestPolicyResolverTest, KindOverrideFiresOnMatch) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + + EXPECT_EQ(r.resolve("src", "/lidar/points", CanonicalObjectKind::kPointCloud), + ObjectIngestPolicy::kPureLazy); + // Different kind falls through to default. + EXPECT_EQ(r.resolve("src", "/cam/image", CanonicalObjectKind::kImage), + ObjectIngestPolicy::kLazyObjectsEagerScalars); +} + +TEST(ObjectIngestPolicyResolverTest, SourceOverridesKind) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); + + // Source matches → kEager beats the kPointCloud kind override. + EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", CanonicalObjectKind::kPointCloud), + ObjectIngestPolicy::kEager); + // Different source → kind override fires. + EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", CanonicalObjectKind::kPointCloud), + ObjectIngestPolicy::kPureLazy); +} + +TEST(ObjectIngestPolicyResolverTest, TopicOverridesEverything) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); + r.setForTopic("/diagnostics/lidar", ObjectIngestPolicy::kPureLazy); + + // Topic match wins over source and kind. + EXPECT_EQ(r.resolve("mcap_source", "/diagnostics/lidar", CanonicalObjectKind::kPointCloud), + ObjectIngestPolicy::kPureLazy); + // Different topic → source override fires. + EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", CanonicalObjectKind::kPointCloud), + ObjectIngestPolicy::kEager); +} + +TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { + // Mirror the recommended setup: large blobs lazy by default, raw images keep + // their metadata as columns. + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(CanonicalObjectKind::kCompressedImage, ObjectIngestPolicy::kPureLazy); + r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + + EXPECT_EQ(r.resolve("mcap", "/cam/raw", CanonicalObjectKind::kImage), + ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", CanonicalObjectKind::kCompressedImage), + ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("mcap", "/lidar", CanonicalObjectKind::kPointCloud), + ObjectIngestPolicy::kPureLazy); + // Scalar-only topic (no canonical) takes the default. + EXPECT_EQ(r.resolve("mcap", "/diagnostics", CanonicalObjectKind::kNone), + ObjectIngestPolicy::kLazyObjectsEagerScalars); +} + +TEST(ObjectIngestPolicyResolverTest, LastWriteWinsForSameKey) { + ObjectIngestPolicyResolver r; + r.setForKind(CanonicalObjectKind::kImage, ObjectIngestPolicy::kEager); + r.setForKind(CanonicalObjectKind::kImage, ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("src", "/topic", CanonicalObjectKind::kImage), + ObjectIngestPolicy::kPureLazy); + + r.setForTopic("/x", ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForTopic("/x", ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("src", "/x", CanonicalObjectKind::kImage), ObjectIngestPolicy::kEager); +} diff --git a/pj_base/tests/push_message_v2_test.cpp b/pj_base/tests/push_message_v2_test.cpp new file mode 100644 index 0000000..9a56c59 --- /dev/null +++ b/pj_base/tests/push_message_v2_test.cpp @@ -0,0 +1,226 @@ +// Tests for the SDK template `DataSourceRuntimeHostView::pushMessage` and +// its delegation to the C ABI slot `push_message_v2`. We exercise: +// +// 1. Vector closure → captured fetcher in the host yields the same bytes. +// 2. PayloadView closure → ditto, with the producer-supplied anchor +// flowing through the C ABI. +// 3. Multiple fetcher invocations are idempotent (same bytes each time). +// 4. The heap-held closure context is destroyed exactly once when the +// host calls fetcher.release. +// 5. When the host predates the slot (struct_size short OR field NULL), +// the SDK template falls back to push_raw_message — both for vector +// and for PayloadView closures. + +#include "pj_base/data_source_protocol.h" +#include "pj_base/sdk/canonical_object.hpp" +#include "pj_base/sdk/data_source_host_views.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +// Captured state from a push_message_v2 invocation. +struct CapturedPush { + PJ_parser_binding_handle_t handle{}; + int64_t timestamp_ns = 0; + PJ_payload_fetcher_t fetcher{}; + bool received = false; +}; + +// Mock runtime host — exposes a vtable that captures push_message_v2 calls +// and, alternatively, push_raw_message calls (for the legacy fallback). +class MockHost { + public: + MockHost() { + vtable_.protocol_version = PJ_DATA_SOURCE_PROTOCOL_VERSION; + vtable_.struct_size = sizeof(PJ_data_source_runtime_host_vtable_t); + vtable_.push_raw_message = &MockHost::pushRawMessageThunk; + vtable_.push_message_v2 = &MockHost::pushMessageV2Thunk; + host_.ctx = this; + host_.vtable = &vtable_; + } + + // Drop the v2 slot — both clearing the field and shrinking struct_size, + // matching the runtime scenario where the host predates the addition. + void disablePushMessageV2() { + vtable_.push_message_v2 = nullptr; + vtable_.struct_size = offsetof(PJ_data_source_runtime_host_vtable_t, push_message_v2); + } + + PJ::DataSourceRuntimeHostView view() const { + return PJ::DataSourceRuntimeHostView(host_); + } + + CapturedPush& captured() { return captured_; } + std::vector& receivedRawBytes() { return raw_bytes_; } + + private: + static bool pushRawMessageThunk( + void* ctx, PJ_parser_binding_handle_t /*handle*/, int64_t /*ts*/, + PJ_bytes_view_t payload, PJ_error_t* /*err*/) noexcept { + auto* self = static_cast(ctx); + self->raw_bytes_.assign(payload.data, payload.data + payload.size); + return true; + } + + static bool pushMessageV2Thunk( + void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, + PJ_payload_fetcher_t fetcher, PJ_error_t* /*err*/) noexcept { + auto* self = static_cast(ctx); + self->captured_.handle = handle; + self->captured_.timestamp_ns = ts; + self->captured_.fetcher = fetcher; + self->captured_.received = true; + return true; + } + + PJ_data_source_runtime_host_vtable_t vtable_{}; + PJ_data_source_runtime_host_t host_{}; + CapturedPush captured_; + std::vector raw_bytes_; +}; + +// Helper: invoke a captured fetcher and assert the produced bytes match +// the expected content. Releases the payload anchor. +void invokeFetcherAndExpect(PJ_payload_fetcher_t& fetcher, const std::vector& expected) { + PJ_payload_t payload{}; + PJ_error_t err{}; + ASSERT_NE(fetcher.fetch, nullptr); + ASSERT_TRUE(fetcher.fetch(fetcher.ctx, &payload, &err)); + ASSERT_EQ(payload.size, expected.size()); + EXPECT_EQ(0, std::memcmp(payload.data, expected.data(), expected.size())); + if (payload.anchor.release) { + payload.anchor.release(payload.anchor.ctx); + } +} + +// ---------- Tests against the new push_message_v2 path ---------- + +TEST(PushMessageV2Test, VectorClosureFlowsThroughSlot) { + MockHost host; + std::vector expected{1, 2, 3, 4, 5}; + + auto status = host.view().pushMessage( + PJ::ParserBindingHandle{42}, 1000, + [bytes = expected]() { return bytes; }); + + ASSERT_TRUE(status); + ASSERT_TRUE(host.captured().received); + EXPECT_EQ(host.captured().handle.id, 42U); + EXPECT_EQ(host.captured().timestamp_ns, 1000); + invokeFetcherAndExpect(host.captured().fetcher, expected); + host.captured().fetcher.release(host.captured().fetcher.ctx); +} + +TEST(PushMessageV2Test, PayloadViewClosureFlowsThroughSlot) { + MockHost host; + std::vector expected{10, 20, 30}; + auto owned = std::make_shared>(expected); + + auto status = host.view().pushMessage( + PJ::ParserBindingHandle{7}, 2000, + [owned]() -> PJ::sdk::PayloadView { + return {PJ::Span(owned->data(), owned->size()), owned}; + }); + + ASSERT_TRUE(status); + invokeFetcherAndExpect(host.captured().fetcher, expected); + host.captured().fetcher.release(host.captured().fetcher.ctx); +} + +TEST(PushMessageV2Test, FetchIsIdempotent) { + MockHost host; + std::vector expected{0x42, 0x43}; + + ASSERT_TRUE(host.view().pushMessage( + PJ::ParserBindingHandle{1}, 0, + [bytes = expected]() { return bytes; })); + + // Multiple invocations must yield the same bytes each time. + for (int i = 0; i < 3; ++i) { + invokeFetcherAndExpect(host.captured().fetcher, expected); + } + host.captured().fetcher.release(host.captured().fetcher.ctx); +} + +TEST(PushMessageV2Test, FetcherCtxReleasedAfterHostCalls) { + MockHost host; + auto canary = std::make_shared(42); + std::weak_ptr witness = canary; + + ASSERT_TRUE(host.view().pushMessage( + PJ::ParserBindingHandle{1}, 0, + [canary]() { return std::vector{}; })); + + // Drop our local reference; the heap-held closure copy keeps the canary + // alive while the fetcher is owned by the host. + canary.reset(); + EXPECT_FALSE(witness.expired()) + << "closure should still keep the canary alive (held in heap fetcher ctx)"; + + // Host releases the fetcher → closure destroyed → captured shared_ptr + // destroyed → canary's last reference drops. + host.captured().fetcher.release(host.captured().fetcher.ctx); + EXPECT_TRUE(witness.expired()) + << "after release, the captured shared_ptr should have been the last reference"; +} + +TEST(PushMessageV2Test, PayloadAnchorPropagates) { + MockHost host; + auto owned = std::make_shared>(std::vector{0x99, 0x9A}); + std::weak_ptr> witness = owned; + + ASSERT_TRUE(host.view().pushMessage( + PJ::ParserBindingHandle{1}, 0, + [owned]() -> PJ::sdk::PayloadView { + return {PJ::Span(owned->data(), owned->size()), owned}; + })); + + // The closure holds the owned vector via its shared_ptr capture. + // After releasing our local owned, the closure's copy keeps it alive. + owned.reset(); + EXPECT_FALSE(witness.expired()); + + // Invoke the fetcher: it builds a PayloadView into the same buffer; the + // anchor returned to the host is yet another shared_ptr copy, so the + // buffer survives even past the closure's release. + PJ_payload_t payload{}; + PJ_error_t err{}; + ASSERT_TRUE(host.captured().fetcher.fetch( + host.captured().fetcher.ctx, &payload, &err)); + EXPECT_EQ(payload.size, 2U); + + // Releasing the fetcher (closure dies) does NOT kill the buffer because + // the active payload anchor still holds a reference. + host.captured().fetcher.release(host.captured().fetcher.ctx); + EXPECT_FALSE(witness.expired()) + << "active payload anchor should still keep the buffer alive"; + + // Releasing the payload anchor drops the last reference. + if (payload.anchor.release) { + payload.anchor.release(payload.anchor.ctx); + } + EXPECT_TRUE(witness.expired()); +} + +// ---------- Host without push_message_v2 returns explicit error ---------- + +TEST(PushMessageV2Test, ReturnsErrorWhenSlotMissing) { + MockHost host; + host.disablePushMessageV2(); + + std::vector expected{0xA, 0xB, 0xC}; + auto status = host.view().pushMessage( + PJ::ParserBindingHandle{1}, 100, + [bytes = expected]() { return bytes; }); + EXPECT_FALSE(status); // explicit failure — no silent fallback to push_raw_message + EXPECT_FALSE(host.captured().received); + EXPECT_TRUE(host.receivedRawBytes().empty()); +} + +} // namespace diff --git a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp index 4d7e50d..d4c4ffd 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp @@ -4,10 +4,12 @@ */ #pragma once +#include #include #include #include +#include #include #include #include @@ -103,6 +105,25 @@ class MessageParserHandle { return okStatus(); } + /// A priori classification of the bound schema. Tail-slot gated; when + /// the plugin doesn't expose classify_schema (older protocol header) + /// returns kNone, matching the host contract documented in + /// message_parser_protocol.h. + [[nodiscard]] sdk::CanonicalObjectKind classifySchema( + std::string_view type_name, Span schema) const { + if (!PJ_HAS_TAIL_SLOT(PJ_message_parser_vtable_t, vt_, classify_schema)) { + return sdk::CanonicalObjectKind::kNone; + } + PJ_string_view_t tn{type_name.data(), type_name.size()}; + PJ_bytes_view_t sc{schema.data(), schema.size()}; + PJ_schema_classification_t out{}; + PJ_error_t err{}; + if (!vt_->classify_schema(ctx_, tn, sc, &out, &err)) { + return sdk::CanonicalObjectKind::kNone; + } + return static_cast(out.object_kind); + } + /// Query a plugin-exposed extension by reverse-DNS id. Tail-slot gated. [[nodiscard]] const void* getPluginExtension(std::string_view id) const { if (!PJ_HAS_TAIL_SLOT(PJ_message_parser_vtable_t, vt_, get_plugin_extension)) { diff --git a/pj_plugins/tests/data_source_library_test.cpp b/pj_plugins/tests/data_source_library_test.cpp index 2f6854d..b2cd9c8 100644 --- a/pj_plugins/tests/data_source_library_test.cpp +++ b/pj_plugins/tests/data_source_library_test.cpp @@ -100,6 +100,7 @@ PJ_data_source_runtime_host_t makeRuntimeHost(bool with_encodings) { .push_raw_message = rhPushRawMessage, .show_message_box = rhShowMessageBox, .list_available_encodings = rhListEncodings, + .push_message_v2 = nullptr, }; static const PJ_data_source_runtime_host_vtable_t no_enc_vt = { .protocol_version = 1, @@ -115,6 +116,7 @@ PJ_data_source_runtime_host_t makeRuntimeHost(bool with_encodings) { .push_raw_message = rhPushRawMessage, .show_message_box = rhShowMessageBox, .list_available_encodings = nullptr, + .push_message_v2 = nullptr, }; return PJ_data_source_runtime_host_t{ .ctx = reinterpret_cast(0x2), diff --git a/pj_plugins/tests/file_source_integration_test.cpp b/pj_plugins/tests/file_source_integration_test.cpp index d1fbef5..99b1954 100644 --- a/pj_plugins/tests/file_source_integration_test.cpp +++ b/pj_plugins/tests/file_source_integration_test.cpp @@ -154,6 +154,7 @@ PJ_data_source_runtime_host_t makeRuntimeHost(RuntimeHostState* state) { .push_raw_message = rhPushRawMessage, .show_message_box = rhShowMessageBox, .list_available_encodings = nullptr, + .push_message_v2 = nullptr, }; return PJ_data_source_runtime_host_t{.ctx = state, .vtable = &vtable}; } From 65962ed7cbefc9faa594adaaed1fdccf587a9935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Tue, 12 May 2026 11:18:44 +0200 Subject: [PATCH 02/18] chore: apply clang-format to canonical-object-pipeline sources No behavior change. Reformats SDK headers, ABI headers, message parser plumbing, and the new ingest policy / push_message_v2 tests per .clang-format (Google style, 120-col, InsertBraces: true). Output of running pre-commit's clang-format hook over the files touched in this branch. --- .../include/pj_base/data_source_protocol.h | 4 +- .../include/pj_base/message_parser_protocol.h | 15 +- .../include/pj_base/sdk/canonical_object.hpp | 8 +- .../pj_base/sdk/data_source_host_views.hpp | 11 +- .../detail/canonical_object_serialization.hpp | 167 +++++++++++++----- .../sdk/detail/message_parser_trampolines.hpp | 12 +- .../sdk/message_parser_plugin_base.hpp | 31 ++-- .../pj_base/sdk/object_ingest_policy.hpp | 4 +- pj_base/tests/object_ingest_policy_test.cpp | 42 ++--- pj_base/tests/push_message_v2_test.cpp | 68 +++---- .../pj_plugins/host/message_parser_handle.hpp | 3 +- 11 files changed, 203 insertions(+), 162 deletions(-) diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 03dd463..04d57e0 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -327,8 +327,8 @@ typedef struct PJ_data_source_runtime_host_vtable_t { * `fetcher.release` so the plugin's ctx leaks no resources. */ bool (*push_message_v2)( - void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, - PJ_payload_fetcher_t fetcher, PJ_error_t* out_error) PJ_NOEXCEPT; + void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, PJ_payload_fetcher_t fetcher, + PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_data_source_runtime_host_vtable_t; /** Fat pointer pairing a runtime host context with its vtable. */ diff --git a/pj_base/include/pj_base/message_parser_protocol.h b/pj_base/include/pj_base/message_parser_protocol.h index bf8d290..897dc30 100644 --- a/pj_base/include/pj_base/message_parser_protocol.h +++ b/pj_base/include/pj_base/message_parser_protocol.h @@ -129,8 +129,9 @@ typedef struct PJ_message_parser_vtable_t { * * Pure-functional contract: no host side-effects. */ - bool (*classify_schema)(void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, - PJ_schema_classification_t* out_classification, PJ_error_t* out_error) PJ_NOEXCEPT; + bool (*classify_schema)( + void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, PJ_schema_classification_t* out_classification, + PJ_error_t* out_error) PJ_NOEXCEPT; /** * [stream-thread] Pure-functional alternative to parse(): returns the @@ -141,8 +142,9 @@ typedef struct PJ_message_parser_vtable_t { * The plugin owns @p out_fields.fields buffer; @p out_fields.release is * called by the host when done. release MAY be NULL. */ - bool (*parse_scalars)(void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, - PJ_named_field_value_buffer_t* out_fields, PJ_error_t* out_error) PJ_NOEXCEPT; + bool (*parse_scalars)( + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_named_field_value_buffer_t* out_fields, + PJ_error_t* out_error) PJ_NOEXCEPT; /** * [stream-thread] Pure-functional production of a canonical object from @@ -154,8 +156,9 @@ typedef struct PJ_message_parser_vtable_t { * caller (DataSource / app) decides whether to push the blob eagerly, * capture it inside a lazy lambda, or hand it directly to a consumer. */ - bool (*parse_object)(void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, - PJ_canonical_object_blob_t* out_blob, PJ_error_t* out_error) PJ_NOEXCEPT; + bool (*parse_object)( + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_canonical_object_blob_t* out_blob, + PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_message_parser_vtable_t; /* The vtable above is ABI-APPENDABLE: new slots may be added at the tail; * host reads guard with PJ_HAS_TAIL_SLOT. See PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE. */ diff --git a/pj_base/include/pj_base/sdk/canonical_object.hpp b/pj_base/include/pj_base/sdk/canonical_object.hpp index b55e84e..7b41d68 100644 --- a/pj_base/include/pj_base/sdk/canonical_object.hpp +++ b/pj_base/include/pj_base/sdk/canonical_object.hpp @@ -210,9 +210,9 @@ struct PointField { }; std::string name; - uint32_t offset = 0; ///< Byte offset of this field within a single point. + uint32_t offset = 0; ///< Byte offset of this field within a single point. Datatype datatype = Datatype::kUnknown; - uint32_t count = 1; ///< Number of elements of `datatype` (typically 1). + uint32_t count = 1; ///< Number of elements of `datatype` (typically 1). }; /// Bytes per element for a given PointField datatype. Returns 0 for kUnknown. @@ -242,8 +242,8 @@ struct PointField { struct PointCloud { uint32_t width = 0; uint32_t height = 1; - uint32_t point_step = 0; ///< Bytes per point. - uint32_t row_step = 0; ///< Bytes per row (= point_step * width when no padding). + uint32_t point_step = 0; ///< Bytes per point. + uint32_t row_step = 0; ///< Bytes per row (= point_step * width when no padding). bool is_bigendian = false; bool is_dense = true; std::vector fields; diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index 0710833..f6c4bbf 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -248,8 +248,7 @@ class DataSourceRuntimeHostView { /// the slot returns an explicit error here rather than silently /// degrading to a kEager push_raw_message. template - [[nodiscard]] Status pushMessage( - ParserBindingHandle handle, Timestamp host_timestamp_ns, Fetcher&& fetcher) const { + [[nodiscard]] Status pushMessage(ParserBindingHandle handle, Timestamp host_timestamp_ns, Fetcher&& fetcher) const { if (!valid()) { return unexpected(std::string("runtime host is not bound")); } @@ -275,9 +274,7 @@ class DataSourceRuntimeHostView { out->data = pv.bytes.data(); out->size = pv.bytes.size(); out->anchor.ctx = held; - out->anchor.release = +[](void* h) noexcept { - delete static_cast(h); - }; + out->anchor.release = +[](void* h) noexcept { delete static_cast(h); }; } else { // Closure returns std::vector: heap-hold the vector; // it owns its bytes. @@ -285,9 +282,7 @@ class DataSourceRuntimeHostView { out->data = held->data(); out->size = held->size(); out->anchor.ctx = held; - out->anchor.release = +[](void* h) noexcept { - delete static_cast*>(h); - }; + out->anchor.release = +[](void* h) noexcept { delete static_cast*>(h); }; } return true; } catch (const std::exception& e) { diff --git a/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp b/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp index e57f1a4..dd56d72 100644 --- a/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp +++ b/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp @@ -33,7 +33,9 @@ namespace detail { // ----------------------------------------------------------------------------- inline void appendBytes(std::vector& out, const void* src, size_t n) { - if (n == 0) return; + if (n == 0) { + return; + } const auto* p = static_cast(src); out.insert(out.end(), p, p + n); } @@ -186,20 +188,34 @@ inline std::vector serializeCanonicalObject(const CanonicalObject& obj) // per object, same as before the iter-3 SDK change. inline Expected readImageBody(BlobReader& r, Timestamp ts) { auto width = r.readPod(); - if (!width) return unexpected(width.error()); + if (!width) { + return unexpected(width.error()); + } auto height = r.readPod(); - if (!height) return unexpected(height.error()); + if (!height) { + return unexpected(height.error()); + } auto pixel_format_raw = r.readPod(); - if (!pixel_format_raw) return unexpected(pixel_format_raw.error()); + if (!pixel_format_raw) { + return unexpected(pixel_format_raw.error()); + } auto is_be = r.readPod(); - if (!is_be) return unexpected(is_be.error()); - /*reserved*/ if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + if (!is_be) { + return unexpected(is_be.error()); + } + /*reserved*/ if (auto rsv = r.readPod(); !rsv) { return unexpected(rsv.error()); } auto row_step = r.readPod(); - if (!row_step) return unexpected(row_step.error()); + if (!row_step) { + return unexpected(row_step.error()); + } auto pixels_size = r.readPod(); - if (!pixels_size) return unexpected(pixels_size.error()); + if (!pixels_size) { + return unexpected(pixels_size.error()); + } auto pixels = r.readBytes(*pixels_size); - if (!pixels) return unexpected(pixels.error()); + if (!pixels) { + return unexpected(pixels.error()); + } auto owned = std::make_shared>(std::move(*pixels)); Span view(owned->data(), owned->size()); @@ -217,20 +233,34 @@ inline Expected readImageBody(BlobReader& r, Timestamp ts) { inline Expected readCompressedImageBody(BlobReader& r, Timestamp ts) { auto format_raw = r.readPod(); - if (!format_raw) return unexpected(format_raw.error()); + if (!format_raw) { + return unexpected(format_raw.error()); + } auto has_min = r.readPod(); - if (!has_min) return unexpected(has_min.error()); + if (!has_min) { + return unexpected(has_min.error()); + } auto has_max = r.readPod(); - if (!has_max) return unexpected(has_max.error()); - /*reserved*/ if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + if (!has_max) { + return unexpected(has_max.error()); + } + /*reserved*/ if (auto rsv = r.readPod(); !rsv) { return unexpected(rsv.error()); } auto depth_min = r.readPod(); - if (!depth_min) return unexpected(depth_min.error()); + if (!depth_min) { + return unexpected(depth_min.error()); + } auto depth_max = r.readPod(); - if (!depth_max) return unexpected(depth_max.error()); + if (!depth_max) { + return unexpected(depth_max.error()); + } auto bytes_size = r.readPod(); - if (!bytes_size) return unexpected(bytes_size.error()); + if (!bytes_size) { + return unexpected(bytes_size.error()); + } auto bytes = r.readBytes(*bytes_size); - if (!bytes) return unexpected(bytes.error()); + if (!bytes) { + return unexpected(bytes.error()); + } auto owned = std::make_shared>(std::move(*bytes)); CompressedImage ci{}; @@ -238,55 +268,90 @@ inline Expected readCompressedImageBody(BlobReader& r, Timestam ci.bytes = Span(owned->data(), owned->size()); ci.anchor = owned; ci.timestamp_ns = ts; - if (*has_min != 0) ci.extras.compressed_depth_min = *depth_min; - if (*has_max != 0) ci.extras.compressed_depth_max = *depth_max; + if (*has_min != 0) { + ci.extras.compressed_depth_min = *depth_min; + } + if (*has_max != 0) { + ci.extras.compressed_depth_max = *depth_max; + } return ci; } inline Expected readPointCloudBody(BlobReader& r, Timestamp ts) { auto width = r.readPod(); - if (!width) return unexpected(width.error()); + if (!width) { + return unexpected(width.error()); + } auto height = r.readPod(); - if (!height) return unexpected(height.error()); + if (!height) { + return unexpected(height.error()); + } auto point_step = r.readPod(); - if (!point_step) return unexpected(point_step.error()); + if (!point_step) { + return unexpected(point_step.error()); + } auto row_step = r.readPod(); - if (!row_step) return unexpected(row_step.error()); + if (!row_step) { + return unexpected(row_step.error()); + } auto is_be = r.readPod(); - if (!is_be) return unexpected(is_be.error()); + if (!is_be) { + return unexpected(is_be.error()); + } auto is_dense = r.readPod(); - if (!is_dense) return unexpected(is_dense.error()); + if (!is_dense) { + return unexpected(is_dense.error()); + } auto fields_count = r.readPod(); - if (!fields_count) return unexpected(fields_count.error()); + if (!fields_count) { + return unexpected(fields_count.error()); + } std::vector fields; fields.reserve(*fields_count); for (uint16_t i = 0; i < *fields_count; ++i) { auto name_size = r.readPod(); - if (!name_size) return unexpected(name_size.error()); + if (!name_size) { + return unexpected(name_size.error()); + } auto name = r.readString(*name_size); - if (!name) return unexpected(name.error()); + if (!name) { + return unexpected(name.error()); + } auto offset = r.readPod(); - if (!offset) return unexpected(offset.error()); + if (!offset) { + return unexpected(offset.error()); + } auto datatype_raw = r.readPod(); - if (!datatype_raw) return unexpected(datatype_raw.error()); + if (!datatype_raw) { + return unexpected(datatype_raw.error()); + } /*reserved×3*/ for (int j = 0; j < 3; ++j) { - if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + if (auto rsv = r.readPod(); !rsv) { + return unexpected(rsv.error()); + } } auto count = r.readPod(); - if (!count) return unexpected(count.error()); - fields.push_back(PointField{ - .name = std::move(*name), - .offset = *offset, - .datatype = static_cast(*datatype_raw), - .count = *count, - }); + if (!count) { + return unexpected(count.error()); + } + fields.push_back( + PointField{ + .name = std::move(*name), + .offset = *offset, + .datatype = static_cast(*datatype_raw), + .count = *count, + }); } auto data_size = r.readPod(); - if (!data_size) return unexpected(data_size.error()); + if (!data_size) { + return unexpected(data_size.error()); + } auto data = r.readBytes(*data_size); - if (!data) return unexpected(data.error()); + if (!data) { + return unexpected(data.error()); + } auto owned = std::make_shared>(std::move(*data)); Span view(owned->data(), owned->size()); @@ -313,25 +378,35 @@ inline Expected deserializeCanonicalObject(const uint8_t* data, BlobReader r(data, size); auto kind_raw = r.readPod(); - if (!kind_raw) return unexpected(kind_raw.error()); - /*reserved*/ if (auto rsv = r.readPod(); !rsv) return unexpected(rsv.error()); + if (!kind_raw) { + return unexpected(kind_raw.error()); + } + /*reserved*/ if (auto rsv = r.readPod(); !rsv) { return unexpected(rsv.error()); } auto ts = r.readPod(); - if (!ts) return unexpected(ts.error()); + if (!ts) { + return unexpected(ts.error()); + } switch (static_cast(*kind_raw)) { case CanonicalObjectKind::kImage: { auto img = readImageBody(r, *ts); - if (!img) return unexpected(img.error()); + if (!img) { + return unexpected(img.error()); + } return CanonicalObject{std::move(*img)}; } case CanonicalObjectKind::kCompressedImage: { auto ci = readCompressedImageBody(r, *ts); - if (!ci) return unexpected(ci.error()); + if (!ci) { + return unexpected(ci.error()); + } return CanonicalObject{std::move(*ci)}; } case CanonicalObjectKind::kPointCloud: { auto pc = readPointCloudBody(r, *ts); - if (!pc) return unexpected(pc.error()); + if (!pc) { + return unexpected(pc.error()); + } return CanonicalObject{std::move(*pc)}; } case CanonicalObjectKind::kNone: diff --git a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp b/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp index 8264cc1..01a4422 100644 --- a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp +++ b/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp @@ -134,8 +134,8 @@ inline const void* MessageParserPluginBase::trampoline_get_plugin_extension(void // ----------------------------------------------------------------------------- inline bool MessageParserPluginBase::trampoline_classify_schema( - void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, - PJ_schema_classification_t* out_classification, PJ_error_t* out_error) noexcept { + void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, PJ_schema_classification_t* out_classification, + PJ_error_t* out_error) noexcept { auto* self = static_cast(ctx); if (out_classification == nullptr) { self->storeError(out_error, 2, "plugin", "classify_schema called with null out_classification"); @@ -158,8 +158,8 @@ inline bool MessageParserPluginBase::trampoline_classify_schema( } inline bool MessageParserPluginBase::trampoline_parse_scalars( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, - PJ_named_field_value_buffer_t* out_fields, PJ_error_t* out_error) noexcept { + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_named_field_value_buffer_t* out_fields, + PJ_error_t* out_error) noexcept { auto* self = static_cast(ctx); if (out_fields == nullptr) { self->storeError(out_error, 2, "plugin", "parse_scalars called with null out_fields"); @@ -193,8 +193,8 @@ inline bool MessageParserPluginBase::trampoline_parse_scalars( } inline bool MessageParserPluginBase::trampoline_parse_object( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, - PJ_canonical_object_blob_t* out_blob, PJ_error_t* out_error) noexcept { + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_canonical_object_blob_t* out_blob, + PJ_error_t* out_error) noexcept { auto* self = static_cast(ctx); if (out_blob == nullptr) { self->storeError(out_error, 2, "plugin", "parse_object called with null out_blob"); diff --git a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp index bf68690..93672cd 100644 --- a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp @@ -45,16 +45,14 @@ struct SchemaHandler { /// Scalar route: returns owned column data — no anchor needed because the /// returned vector and any string_views inside it are materialized by the /// parser, independent of the caller's payload buffer. - std::function>(Timestamp, Span)> - parse_scalars; + std::function>(Timestamp, Span)> parse_scalars; /// Canonical-object route: takes a PayloadView so the parser can return a /// CanonicalObject whose internal Span(s) reference the same underlying /// buffer (zero-copy). The parser propagates `payload.anchor` into the /// returned object so its bytes outlive this call. When the caller passes /// an empty anchor, the parser must materialize whatever it wants to retain. - std::function(Timestamp, PayloadView)> - parse_object; + std::function(Timestamp, PayloadView)> parse_object; }; } // namespace sdk @@ -157,9 +155,7 @@ class MessageParserPluginBase { if (fields->empty()) { return okStatus(); } - return writeHost().appendRecord( - timestamp_ns, - Span(fields->data(), fields->size())); + return writeHost().appendRecord(timestamp_ns, Span(fields->data(), fields->size())); } // --------------------------------------------------------------------------- @@ -213,8 +209,7 @@ class MessageParserPluginBase { /// populate the table via registerSchemaHandler() rather than overriding; /// the C ABI trampolines invoke this directly on MessageParserPluginBase*. /// Returns kNone when no handler is registered for this type name. - sdk::SchemaClassification classifySchema( - std::string_view type_name, Span schema) const { + sdk::SchemaClassification classifySchema(std::string_view type_name, Span schema) const { (void)schema; if (const auto* h = findSchemaHandler(type_name)) { return {h->object_kind}; @@ -226,8 +221,7 @@ class MessageParserPluginBase { /// Returns unexpected if no handler is registered, or if the registered /// handler did not provide a parse_scalars callable. Non-virtual — see /// classifySchema above for the rationale. - Expected> parseScalars( - Timestamp timestamp_ns, Span payload) { + Expected> parseScalars(Timestamp timestamp_ns, Span payload) { const auto* h = findSchemaHandler(bound_type_name_); if (h == nullptr) { return unexpected(std::string("parser does not register schema: ") + bound_type_name_); @@ -247,8 +241,7 @@ class MessageParserPluginBase { /// materialize anything it wants to outlive this call. In-process callers /// that already own the payload buffer should pass a non-empty anchor so /// the parser can return a zero-copy CanonicalObject. - Expected parseObject( - Timestamp timestamp_ns, sdk::PayloadView payload) { + Expected parseObject(Timestamp timestamp_ns, sdk::PayloadView payload) { const auto* h = findSchemaHandler(bound_type_name_); if (h == nullptr) { return unexpected(std::string("parser does not register schema: ") + bound_type_name_); @@ -356,14 +349,14 @@ class MessageParserPluginBase { void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_error_t* out_error) noexcept; static const void* trampoline_get_plugin_extension(void* ctx, PJ_string_view_t id) noexcept; static bool trampoline_classify_schema( - void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, - PJ_schema_classification_t* out_classification, PJ_error_t* out_error) noexcept; + void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, PJ_schema_classification_t* out_classification, + PJ_error_t* out_error) noexcept; static bool trampoline_parse_scalars( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, - PJ_named_field_value_buffer_t* out_fields, PJ_error_t* out_error) noexcept; + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_named_field_value_buffer_t* out_fields, + PJ_error_t* out_error) noexcept; static bool trampoline_parse_object( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, - PJ_canonical_object_blob_t* out_blob, PJ_error_t* out_error) noexcept; + void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_canonical_object_blob_t* out_blob, + PJ_error_t* out_error) noexcept; }; } // namespace PJ diff --git a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp b/pj_base/include/pj_base/sdk/object_ingest_policy.hpp index bce03c7..f7821cd 100644 --- a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp +++ b/pj_base/include/pj_base/sdk/object_ingest_policy.hpp @@ -93,9 +93,7 @@ class ObjectIngestPolicyResolver { /// Precedence: topic > source > kind > default. The first match wins — /// no merging or composition between levels. [[nodiscard]] ObjectIngestPolicy resolve( - std::string_view source_id, - std::string_view topic_name, - CanonicalObjectKind object_kind) const { + std::string_view source_id, std::string_view topic_name, CanonicalObjectKind object_kind) const { if (auto it = by_topic_.find(std::string(topic_name)); it != by_topic_.end()) { return it->second; } diff --git a/pj_base/tests/object_ingest_policy_test.cpp b/pj_base/tests/object_ingest_policy_test.cpp index 184f114..3f7a10c 100644 --- a/pj_base/tests/object_ingest_policy_test.cpp +++ b/pj_base/tests/object_ingest_policy_test.cpp @@ -8,15 +8,14 @@ using PJ::sdk::ObjectIngestPolicyResolver; TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyScalars) { ObjectIngestPolicyResolver r; - EXPECT_EQ(r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), - ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ( + r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); } TEST(ObjectIngestPolicyResolverTest, SetDefaultIsRespected) { ObjectIngestPolicyResolver r; r.setDefault(ObjectIngestPolicy::kEager); - EXPECT_EQ(r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), - ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kEager); } TEST(ObjectIngestPolicyResolverTest, KindOverrideFiresOnMatch) { @@ -24,11 +23,9 @@ TEST(ObjectIngestPolicyResolverTest, KindOverrideFiresOnMatch) { r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("src", "/lidar/points", CanonicalObjectKind::kPointCloud), - ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("src", "/lidar/points", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); // Different kind falls through to default. - EXPECT_EQ(r.resolve("src", "/cam/image", CanonicalObjectKind::kImage), - ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("src", "/cam/image", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); } TEST(ObjectIngestPolicyResolverTest, SourceOverridesKind) { @@ -38,11 +35,9 @@ TEST(ObjectIngestPolicyResolverTest, SourceOverridesKind) { r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); // Source matches → kEager beats the kPointCloud kind override. - EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", CanonicalObjectKind::kPointCloud), - ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kEager); // Different source → kind override fires. - EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", CanonicalObjectKind::kPointCloud), - ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); } TEST(ObjectIngestPolicyResolverTest, TopicOverridesEverything) { @@ -53,11 +48,10 @@ TEST(ObjectIngestPolicyResolverTest, TopicOverridesEverything) { r.setForTopic("/diagnostics/lidar", ObjectIngestPolicy::kPureLazy); // Topic match wins over source and kind. - EXPECT_EQ(r.resolve("mcap_source", "/diagnostics/lidar", CanonicalObjectKind::kPointCloud), - ObjectIngestPolicy::kPureLazy); + EXPECT_EQ( + r.resolve("mcap_source", "/diagnostics/lidar", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); // Different topic → source override fires. - EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", CanonicalObjectKind::kPointCloud), - ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kEager); } TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { @@ -68,23 +62,19 @@ TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { r.setForKind(CanonicalObjectKind::kCompressedImage, ObjectIngestPolicy::kPureLazy); r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("mcap", "/cam/raw", CanonicalObjectKind::kImage), - ObjectIngestPolicy::kLazyObjectsEagerScalars); - EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", CanonicalObjectKind::kCompressedImage), - ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("mcap", "/lidar", CanonicalObjectKind::kPointCloud), - ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("mcap", "/cam/raw", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", CanonicalObjectKind::kCompressedImage), ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("mcap", "/lidar", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); // Scalar-only topic (no canonical) takes the default. - EXPECT_EQ(r.resolve("mcap", "/diagnostics", CanonicalObjectKind::kNone), - ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ( + r.resolve("mcap", "/diagnostics", CanonicalObjectKind::kNone), ObjectIngestPolicy::kLazyObjectsEagerScalars); } TEST(ObjectIngestPolicyResolverTest, LastWriteWinsForSameKey) { ObjectIngestPolicyResolver r; r.setForKind(CanonicalObjectKind::kImage, ObjectIngestPolicy::kEager); r.setForKind(CanonicalObjectKind::kImage, ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("src", "/topic", CanonicalObjectKind::kImage), - ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("src", "/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kPureLazy); r.setForTopic("/x", ObjectIngestPolicy::kLazyObjectsEagerScalars); r.setForTopic("/x", ObjectIngestPolicy::kEager); diff --git a/pj_base/tests/push_message_v2_test.cpp b/pj_base/tests/push_message_v2_test.cpp index 9a56c59..b0b6722 100644 --- a/pj_base/tests/push_message_v2_test.cpp +++ b/pj_base/tests/push_message_v2_test.cpp @@ -11,10 +11,6 @@ // the SDK template falls back to push_raw_message — both for vector // and for PayloadView closures. -#include "pj_base/data_source_protocol.h" -#include "pj_base/sdk/canonical_object.hpp" -#include "pj_base/sdk/data_source_host_views.hpp" - #include #include @@ -22,6 +18,10 @@ #include #include +#include "pj_base/data_source_protocol.h" +#include "pj_base/sdk/canonical_object.hpp" +#include "pj_base/sdk/data_source_host_views.hpp" + namespace { // Captured state from a push_message_v2 invocation. @@ -56,21 +56,25 @@ class MockHost { return PJ::DataSourceRuntimeHostView(host_); } - CapturedPush& captured() { return captured_; } - std::vector& receivedRawBytes() { return raw_bytes_; } + CapturedPush& captured() { + return captured_; + } + std::vector& receivedRawBytes() { + return raw_bytes_; + } private: static bool pushRawMessageThunk( - void* ctx, PJ_parser_binding_handle_t /*handle*/, int64_t /*ts*/, - PJ_bytes_view_t payload, PJ_error_t* /*err*/) noexcept { + void* ctx, PJ_parser_binding_handle_t /*handle*/, int64_t /*ts*/, PJ_bytes_view_t payload, + PJ_error_t* /*err*/) noexcept { auto* self = static_cast(ctx); self->raw_bytes_.assign(payload.data, payload.data + payload.size); return true; } static bool pushMessageV2Thunk( - void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, - PJ_payload_fetcher_t fetcher, PJ_error_t* /*err*/) noexcept { + void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, PJ_payload_fetcher_t fetcher, + PJ_error_t* /*err*/) noexcept { auto* self = static_cast(ctx); self->captured_.handle = handle; self->captured_.timestamp_ns = ts; @@ -105,9 +109,7 @@ TEST(PushMessageV2Test, VectorClosureFlowsThroughSlot) { MockHost host; std::vector expected{1, 2, 3, 4, 5}; - auto status = host.view().pushMessage( - PJ::ParserBindingHandle{42}, 1000, - [bytes = expected]() { return bytes; }); + auto status = host.view().pushMessage(PJ::ParserBindingHandle{42}, 1000, [bytes = expected]() { return bytes; }); ASSERT_TRUE(status); ASSERT_TRUE(host.captured().received); @@ -122,11 +124,9 @@ TEST(PushMessageV2Test, PayloadViewClosureFlowsThroughSlot) { std::vector expected{10, 20, 30}; auto owned = std::make_shared>(expected); - auto status = host.view().pushMessage( - PJ::ParserBindingHandle{7}, 2000, - [owned]() -> PJ::sdk::PayloadView { - return {PJ::Span(owned->data(), owned->size()), owned}; - }); + auto status = host.view().pushMessage(PJ::ParserBindingHandle{7}, 2000, [owned]() -> PJ::sdk::PayloadView { + return {PJ::Span(owned->data(), owned->size()), owned}; + }); ASSERT_TRUE(status); invokeFetcherAndExpect(host.captured().fetcher, expected); @@ -137,9 +137,7 @@ TEST(PushMessageV2Test, FetchIsIdempotent) { MockHost host; std::vector expected{0x42, 0x43}; - ASSERT_TRUE(host.view().pushMessage( - PJ::ParserBindingHandle{1}, 0, - [bytes = expected]() { return bytes; })); + ASSERT_TRUE(host.view().pushMessage(PJ::ParserBindingHandle{1}, 0, [bytes = expected]() { return bytes; })); // Multiple invocations must yield the same bytes each time. for (int i = 0; i < 3; ++i) { @@ -153,21 +151,17 @@ TEST(PushMessageV2Test, FetcherCtxReleasedAfterHostCalls) { auto canary = std::make_shared(42); std::weak_ptr witness = canary; - ASSERT_TRUE(host.view().pushMessage( - PJ::ParserBindingHandle{1}, 0, - [canary]() { return std::vector{}; })); + ASSERT_TRUE(host.view().pushMessage(PJ::ParserBindingHandle{1}, 0, [canary]() { return std::vector{}; })); // Drop our local reference; the heap-held closure copy keeps the canary // alive while the fetcher is owned by the host. canary.reset(); - EXPECT_FALSE(witness.expired()) - << "closure should still keep the canary alive (held in heap fetcher ctx)"; + EXPECT_FALSE(witness.expired()) << "closure should still keep the canary alive (held in heap fetcher ctx)"; // Host releases the fetcher → closure destroyed → captured shared_ptr // destroyed → canary's last reference drops. host.captured().fetcher.release(host.captured().fetcher.ctx); - EXPECT_TRUE(witness.expired()) - << "after release, the captured shared_ptr should have been the last reference"; + EXPECT_TRUE(witness.expired()) << "after release, the captured shared_ptr should have been the last reference"; } TEST(PushMessageV2Test, PayloadAnchorPropagates) { @@ -175,11 +169,9 @@ TEST(PushMessageV2Test, PayloadAnchorPropagates) { auto owned = std::make_shared>(std::vector{0x99, 0x9A}); std::weak_ptr> witness = owned; - ASSERT_TRUE(host.view().pushMessage( - PJ::ParserBindingHandle{1}, 0, - [owned]() -> PJ::sdk::PayloadView { - return {PJ::Span(owned->data(), owned->size()), owned}; - })); + ASSERT_TRUE(host.view().pushMessage(PJ::ParserBindingHandle{1}, 0, [owned]() -> PJ::sdk::PayloadView { + return {PJ::Span(owned->data(), owned->size()), owned}; + })); // The closure holds the owned vector via its shared_ptr capture. // After releasing our local owned, the closure's copy keeps it alive. @@ -191,15 +183,13 @@ TEST(PushMessageV2Test, PayloadAnchorPropagates) { // buffer survives even past the closure's release. PJ_payload_t payload{}; PJ_error_t err{}; - ASSERT_TRUE(host.captured().fetcher.fetch( - host.captured().fetcher.ctx, &payload, &err)); + ASSERT_TRUE(host.captured().fetcher.fetch(host.captured().fetcher.ctx, &payload, &err)); EXPECT_EQ(payload.size, 2U); // Releasing the fetcher (closure dies) does NOT kill the buffer because // the active payload anchor still holds a reference. host.captured().fetcher.release(host.captured().fetcher.ctx); - EXPECT_FALSE(witness.expired()) - << "active payload anchor should still keep the buffer alive"; + EXPECT_FALSE(witness.expired()) << "active payload anchor should still keep the buffer alive"; // Releasing the payload anchor drops the last reference. if (payload.anchor.release) { @@ -215,9 +205,7 @@ TEST(PushMessageV2Test, ReturnsErrorWhenSlotMissing) { host.disablePushMessageV2(); std::vector expected{0xA, 0xB, 0xC}; - auto status = host.view().pushMessage( - PJ::ParserBindingHandle{1}, 100, - [bytes = expected]() { return bytes; }); + auto status = host.view().pushMessage(PJ::ParserBindingHandle{1}, 100, [bytes = expected]() { return bytes; }); EXPECT_FALSE(status); // explicit failure — no silent fallback to push_raw_message EXPECT_FALSE(host.captured().received); EXPECT_TRUE(host.receivedRawBytes().empty()); diff --git a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp index d4c4ffd..f299e6f 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp @@ -109,8 +109,7 @@ class MessageParserHandle { /// the plugin doesn't expose classify_schema (older protocol header) /// returns kNone, matching the host contract documented in /// message_parser_protocol.h. - [[nodiscard]] sdk::CanonicalObjectKind classifySchema( - std::string_view type_name, Span schema) const { + [[nodiscard]] sdk::CanonicalObjectKind classifySchema(std::string_view type_name, Span schema) const { if (!PJ_HAS_TAIL_SLOT(PJ_message_parser_vtable_t, vt_, classify_schema)) { return sdk::CanonicalObjectKind::kNone; } From 31a0c608059f52ebd403907a20d82411ca80d0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Tue, 12 May 2026 11:21:17 +0200 Subject: [PATCH 03/18] chore(sdk): drop dead doc references from SDK header comments The file-level docstrings of canonical_object.hpp and object_ingest_policy.hpp pointed at a private report path that does not exist in this repository. Remove the dangling references; the surrounding prose already explains the design. --- pj_base/include/pj_base/sdk/canonical_object.hpp | 2 -- pj_base/include/pj_base/sdk/object_ingest_policy.hpp | 2 -- 2 files changed, 4 deletions(-) diff --git a/pj_base/include/pj_base/sdk/canonical_object.hpp b/pj_base/include/pj_base/sdk/canonical_object.hpp index 7b41d68..348bfe9 100644 --- a/pj_base/include/pj_base/sdk/canonical_object.hpp +++ b/pj_base/include/pj_base/sdk/canonical_object.hpp @@ -9,8 +9,6 @@ * itself remains agnostic to these types — it stores opaque bytes; the * decoding into a CanonicalObject happens in the consumer at pull time, by * invoking the parser's parseObject() against the bytes. - * - * Reference report: docs/claude_reports/2026.05.07-arquitectura-objectstore-pipeline-misalignment.md */ #pragma once diff --git a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp b/pj_base/include/pj_base/sdk/object_ingest_policy.hpp index f7821cd..7ab5bda 100644 --- a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp +++ b/pj_base/include/pj_base/sdk/object_ingest_policy.hpp @@ -9,8 +9,6 @@ * fetcher immediately (parse and store now), invoke it once for scalars * and again on each pull, or never invoke it during ingest and only on * consumer pulls. - * - * Reference design: docs/claude_reports/2026.05.07-arquitectura-objectstore-pipeline-misalignment.md */ #pragma once From 346fd3a9702b0be135ef18d0b3d21a74189dc9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Wed, 13 May 2026 13:55:45 +0200 Subject: [PATCH 04/18] refactor(sdk): drop C ABI wire format for canonical objects and pure-functional scalars The canonical-object pipeline and the pure-functional scalar path are C++ SDK contracts: MessageParserPluginBase::parseObject() and parseScalars() are called directly on the C++ pointer by the in-process runtime host, preserving zero-copy via BufferAnchor. The C ABI slots (parse_object, parse_scalars) and their wire-format support are removed; pure-C plugins emit scalars via the parse() slot writing to writeHost. Removed: - detail/canonical_object_serialization.hpp (full file). - PJ_canonical_object_blob_t and PJ_named_field_value_buffer_t from canonical_object_abi.h. - parse_object and parse_scalars slots from PJ_message_parser_vtable_t. - trampoline_parse_object and trampoline_parse_scalars. - Per-instance buffers (scalars_owned_buf_, scalars_abi_buf_, object_blob_buf_) that only the trampolines used. Kept: - SchemaHandler::parse_scalars / parse_object (C++ callables registered by plugins) and MessageParserPluginBase::parseScalars / parseObject (C++ methods invoked by the host). - classify_schema C ABI slot (used by MessageParserHandle::classifySchema). - The parse() slot for pure-C plugins that emit scalars to writeHost. Vtable size: PJ_message_parser_vtable_t shrinks from 104 to 88 bytes (80 v4.0 baseline + 1 tail slot for classify_schema). PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE stays pinned at 80. --- .../include/pj_base/canonical_object_abi.h | 111 +---- .../include/pj_base/data_source_protocol.h | 3 - .../include/pj_base/message_parser_protocol.h | 41 +- .../detail/canonical_object_serialization.hpp | 420 ------------------ .../sdk/detail/message_parser_trampolines.hpp | 75 ---- .../sdk/message_parser_plugin_base.hpp | 16 - pj_base/tests/abi_layout_sentinels_test.cpp | 4 +- 7 files changed, 29 insertions(+), 641 deletions(-) delete mode 100644 pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp diff --git a/pj_base/include/pj_base/canonical_object_abi.h b/pj_base/include/pj_base/canonical_object_abi.h index b370951..f42b2e9 100644 --- a/pj_base/include/pj_base/canonical_object_abi.h +++ b/pj_base/include/pj_base/canonical_object_abi.h @@ -1,16 +1,20 @@ /** * @file canonical_object_abi.h - * @brief C ABI representation of canonical objects produced by parsers. + * @brief C ABI vocabulary for schema classification. * - * The C++ vocabulary lives in pj_base/sdk/canonical_object.hpp - * (sdk::CanonicalObject = std::variant). - * This file defines the wire format used to cross the plugin C ABI boundary - * for that variant: parser plugins produce a flat byte blob with a small - * header describing the kind, and the host deserializes it back to the - * C++ type. + * The host invokes classify_schema (a slot in PJ_message_parser_vtable_t) + * after bind_schema to learn what kind of canonical object the parser will + * produce for that schema. The parser returns a PJ_schema_classification_t + * carrying a PJ_canonical_object_kind_t. * - * The blob layout is little-endian, packed, with no implementation-defined - * padding. Trampolines and host loader use it directly. + * Canonical-object production (sdk::Image / sdk::CompressedImage / + * sdk::PointCloud) and the pure-functional scalar production + * (Expected>) are C++ SDK contracts: plugins + * inheriting from MessageParserPluginBase register handlers in + * SchemaHandler, and the in-process host consumes them via + * MessageParserPluginBase::parseObject() and parseScalars() called + * directly on the C++ pointer. Pure-C plugins emit scalars via the + * parse() slot (writing to writeHost). */ #ifndef PJ_CANONICAL_OBJECT_ABI_H #define PJ_CANONICAL_OBJECT_ABI_H @@ -25,22 +29,11 @@ extern "C" { #endif -/** - * Owned buffer of named field values produced by the parse_scalars slot. - * The plugin owns the @p fields array; the host calls @p release(alloc_handle) - * when done. release MAY be NULL if the plugin manages the buffer in a way - * that does not require explicit release between calls. - */ -typedef struct PJ_named_field_value_buffer_t { - const PJ_named_field_value_t* fields; - size_t count; - void* alloc_handle; - void (*release)(void* alloc_handle); -} PJ_named_field_value_buffer_t; - /** * Canonical object kinds. Numeric values are stable across releases — never - * renumber. Mirror of PJ::sdk::CanonicalObjectKind for use across the C ABI. + * renumber. Returned by the classify_schema slot to advertise what kind of + * canonical object the parser will produce for this schema (or kNone if + * the parser only produces scalars). */ typedef enum PJ_canonical_object_kind_t { PJ_CANONICAL_OBJECT_KIND_NONE = 0, @@ -56,79 +49,15 @@ typedef enum PJ_canonical_object_kind_t { * Schema classification — what kind a parser declares for a given schema. * Returned a priori (without parsing payload) by the classify_schema slot. * - * Currently a single field plus reserved padding to keep the struct size - * stable across future minor extensions (declarative metadata can attach - * via additional structs returned by other slots, not by growing this one). + * Single field plus reserved padding to keep the struct size stable across + * future minor extensions. The reserved byte must be zero today; readers + * accept any value (forward compat). */ typedef struct PJ_schema_classification_t { uint16_t object_kind; /**< PJ_canonical_object_kind_t. */ - uint16_t reserved; /**< Must be zero. */ + uint16_t reserved; } PJ_schema_classification_t; -/** - * Canonical object as a flat byte blob produced by the parse_object slot. - * - * Layout of @p data: - * - * header (12 bytes, little-endian): - * uint16_t kind // PJ_canonical_object_kind_t - * uint16_t reserved - * int64_t timestamp_ns - * - * body (varies by kind, immediately follows the header): - * - * KIND_IMAGE: - * uint32_t width - * uint32_t height - * uint16_t pixel_format - * uint16_t reserved - * uint32_t pixels_size - * uint8_t pixels[pixels_size] // tightly packed, no row stride - * - * KIND_COMPRESSED_IMAGE: - * uint8_t format // 0=unknown, 1=JPEG, 2=PNG, 3=QOI - * uint8_t has_depth_min - * uint8_t has_depth_max - * uint8_t reserved - * float depth_min // valid iff has_depth_min - * float depth_max // valid iff has_depth_max - * uint32_t bytes_size - * uint8_t bytes[bytes_size] - * - * KIND_POINTCLOUD: - * uint32_t width - * uint32_t height - * uint32_t point_step - * uint32_t row_step - * uint8_t is_bigendian - * uint8_t is_dense - * uint16_t fields_count - * fields[fields_count]: - * uint32_t name_size - * char name[name_size] - * uint32_t offset - * uint8_t datatype // 0=unknown,1=i8,2=u8,3=i16,4=u16, - * // 5=i32,6=u32,7=f32,8=f64 - * uint8_t reserved[3] - * uint32_t count - * uint32_t data_size - * uint8_t data[data_size] - * - * Memory ownership: - * The blob's @p data is owned by the parser plugin. The plugin allocates - * it during parse_object and the host calls @p release(ctx, data) when it - * is done with the bytes. release MAY be NULL if data points into a - * plugin-internal buffer that the plugin manages itself across calls. - */ -typedef struct PJ_canonical_object_blob_t { - const uint8_t* data; - uint64_t size; - /** Opaque handle the plugin uses to identify the allocation. */ - void* alloc_handle; - /** Release callback invoked by the host. NULL means no release needed. */ - void (*release)(void* alloc_handle); -} PJ_canonical_object_blob_t; - #ifdef __cplusplus } #endif diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 04d57e0..07eaa67 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -134,9 +134,6 @@ typedef struct { * no longer needs the bytes referenced by the buffer. `ctx` MAY be NULL — * meaning the buffer was static / borrowed from an external lifetime — in * which case `release` is also expected to be NULL. - * - * Mirrors the pattern of PJ_canonical_object_blob_t but applies to raw - * payload bytes, not to serialized canonical objects. */ typedef struct PJ_payload_anchor_t { void* ctx; diff --git a/pj_base/include/pj_base/message_parser_protocol.h b/pj_base/include/pj_base/message_parser_protocol.h index 897dc30..fc34507 100644 --- a/pj_base/include/pj_base/message_parser_protocol.h +++ b/pj_base/include/pj_base/message_parser_protocol.h @@ -8,16 +8,16 @@ * append_arrow_ipc — see plugin_data_api.h. Parsers stay per-record; * the host coalesces into Arrow batches internally. * - * v4 appendable tail (no version bump — protocol stays at 4): - * - classify_schema, parse_scalars, parse_object: pure-functional API - * that returns typed values instead of writing to host views. Enables - * lazy materialization and removes the parser's coupling to push policy. - * See pj_base/canonical_object_abi.h for the wire format. + * Pure-functional production (scalars by value, canonical objects by + * value with BufferAnchor) is a C++ SDK contract: parsers inheriting from + * MessageParserPluginBase register handlers in SchemaHandler and the + * in-process host calls parseScalars() / parseObject() directly on the + * C++ pointer. Pure-C plugins use the parse() slot to write scalars to + * writeHost. * * The host obtains the plugin's vtable via `PJ_get_message_parser_vtable()` * and drives the plugin through: create -> bind(registry) -> - * (bind_schema) -> (classify_schema) -> parse* / parseScalars / parseObject - * -> destroy. + * (bind_schema) -> (classify_schema) -> parse -> destroy. */ #ifndef PJ_MESSAGE_PARSER_PROTOCOL_H #define PJ_MESSAGE_PARSER_PROTOCOL_H @@ -132,33 +132,6 @@ typedef struct PJ_message_parser_vtable_t { bool (*classify_schema)( void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, PJ_schema_classification_t* out_classification, PJ_error_t* out_error) PJ_NOEXCEPT; - - /** - * [stream-thread] Pure-functional alternative to parse(): returns the - * scalar fields by value (out parameter) instead of writing them to the - * parser write host. The host invokes this in preference to parse() when - * available; legacy plugins keep using parse(). - * - * The plugin owns @p out_fields.fields buffer; @p out_fields.release is - * called by the host when done. release MAY be NULL. - */ - bool (*parse_scalars)( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_named_field_value_buffer_t* out_fields, - PJ_error_t* out_error) PJ_NOEXCEPT; - - /** - * [stream-thread] Pure-functional production of a canonical object from - * the payload. Fills @p out_blob with the serialized object (see layout - * in canonical_object_abi.h). Only meaningful when classify_schema() - * returned a non-zero kind. - * - * Pure-functional contract: no writes to the object write host. The - * caller (DataSource / app) decides whether to push the blob eagerly, - * capture it inside a lazy lambda, or hand it directly to a consumer. - */ - bool (*parse_object)( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_canonical_object_blob_t* out_blob, - PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_message_parser_vtable_t; /* The vtable above is ABI-APPENDABLE: new slots may be added at the tail; * host reads guard with PJ_HAS_TAIL_SLOT. See PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE. */ diff --git a/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp b/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp deleted file mode 100644 index dd56d72..0000000 --- a/pj_base/include/pj_base/sdk/detail/canonical_object_serialization.hpp +++ /dev/null @@ -1,420 +0,0 @@ -/** - * @file detail/canonical_object_serialization.hpp - * @brief (De)serialization of PJ::sdk::CanonicalObject to/from the byte - * layout defined in pj_base/canonical_object_abi.h. - * - * The blob crosses the C ABI as raw bytes; this header turns it into the - * C++ variant on the host side and back into bytes on the plugin side. - * - * Endianness: writes/reads multi-byte integers using std::memcpy under the - * assumption that the host architecture is little-endian (the ABI mandates - * little-endian). Big-endian targets would need an explicit byte-swap layer - * here; documented as a known limitation in this iteration. - */ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "pj_base/canonical_object_abi.h" -#include "pj_base/expected.hpp" -#include "pj_base/sdk/canonical_object.hpp" - -namespace PJ { -namespace sdk { -namespace detail { - -// ----------------------------------------------------------------------------- -// Low-level write helpers (host-endian = little-endian assumed) -// ----------------------------------------------------------------------------- - -inline void appendBytes(std::vector& out, const void* src, size_t n) { - if (n == 0) { - return; - } - const auto* p = static_cast(src); - out.insert(out.end(), p, p + n); -} - -template -inline void appendPod(std::vector& out, T value) { - static_assert(std::is_trivially_copyable_v, "appendPod requires trivially copyable"); - appendBytes(out, &value, sizeof(T)); -} - -// ----------------------------------------------------------------------------- -// Low-level read helpers -// ----------------------------------------------------------------------------- - -class BlobReader { - public: - BlobReader(const uint8_t* data, size_t size) : ptr_(data), end_(data + size) {} - - [[nodiscard]] bool remaining(size_t n) const noexcept { - return static_cast(end_ - ptr_) >= n; - } - - template - Expected readPod() { - static_assert(std::is_trivially_copyable_v, "readPod requires trivially copyable"); - if (!remaining(sizeof(T))) { - return unexpected(std::string("blob truncated")); - } - T value; - std::memcpy(&value, ptr_, sizeof(T)); - ptr_ += sizeof(T); - return value; - } - - Expected> readBytes(size_t n) { - if (!remaining(n)) { - return unexpected(std::string("blob truncated reading bytes")); - } - std::vector out(ptr_, ptr_ + n); - ptr_ += n; - return out; - } - - Expected readString(size_t n) { - if (!remaining(n)) { - return unexpected(std::string("blob truncated reading string")); - } - std::string out(reinterpret_cast(ptr_), n); - ptr_ += n; - return out; - } - - private: - const uint8_t* ptr_; - const uint8_t* end_; -}; - -// ----------------------------------------------------------------------------- -// Serialization (C++ → bytes) -// ----------------------------------------------------------------------------- - -inline void writeImageBody(std::vector& out, const Image& img) { - appendPod(out, img.width); - appendPod(out, img.height); - appendPod(out, static_cast(img.pixel_format)); - appendPod(out, img.is_bigendian ? 1 : 0); - appendPod(out, 0); // reserved - appendPod(out, img.row_step); - const uint32_t pixels_size = static_cast(img.pixels.size()); - appendPod(out, pixels_size); - if (pixels_size > 0) { - appendBytes(out, img.pixels.data(), pixels_size); - } -} - -inline void writeCompressedImageBody(std::vector& out, const CompressedImage& ci) { - appendPod(out, static_cast(ci.format)); - appendPod(out, ci.extras.compressed_depth_min.has_value() ? 1 : 0); - appendPod(out, ci.extras.compressed_depth_max.has_value() ? 1 : 0); - appendPod(out, 0); // reserved - appendPod(out, ci.extras.compressed_depth_min.value_or(0.0f)); - appendPod(out, ci.extras.compressed_depth_max.value_or(0.0f)); - const uint32_t bytes_size = static_cast(ci.bytes.size()); - appendPod(out, bytes_size); - if (bytes_size > 0) { - appendBytes(out, ci.bytes.data(), bytes_size); - } -} - -inline void writePointCloudBody(std::vector& out, const PointCloud& pc) { - appendPod(out, pc.width); - appendPod(out, pc.height); - appendPod(out, pc.point_step); - appendPod(out, pc.row_step); - appendPod(out, pc.is_bigendian ? 1 : 0); - appendPod(out, pc.is_dense ? 1 : 0); - appendPod(out, static_cast(pc.fields.size())); - for (const auto& f : pc.fields) { - const uint32_t name_size = static_cast(f.name.size()); - appendPod(out, name_size); - appendBytes(out, f.name.data(), name_size); - appendPod(out, f.offset); - appendPod(out, static_cast(f.datatype)); - appendPod(out, 0); // reserved - appendPod(out, 0); // reserved - appendPod(out, 0); // reserved - appendPod(out, f.count); - } - const uint32_t data_size = static_cast(pc.data.size()); - appendPod(out, data_size); - if (data_size > 0) { - appendBytes(out, pc.data.data(), data_size); - } -} - -/// Serialize a CanonicalObject into a flat byte buffer matching the layout -/// in canonical_object_abi.h. Caller owns the returned vector. -inline std::vector serializeCanonicalObject(const CanonicalObject& obj) { - std::vector out; - out.reserve(64); // header + small body; body grows for image/pointcloud - - // Header: kind (u16), reserved (u16), timestamp (i64). - const auto kind = kindOf(obj); - appendPod(out, static_cast(kind)); - appendPod(out, 0); // reserved - std::visit( - [&](const auto& concrete) { - appendPod(out, concrete.timestamp_ns); - using T = std::decay_t; - if constexpr (std::is_same_v) { - writeImageBody(out, concrete); - } else if constexpr (std::is_same_v) { - writeCompressedImageBody(out, concrete); - } else if constexpr (std::is_same_v) { - writePointCloudBody(out, concrete); - } - }, - obj); - - return out; -} - -// ----------------------------------------------------------------------------- -// Deserialization (bytes → C++) -// ----------------------------------------------------------------------------- - -// On the deserialize side we don't have a foreign anchor — the bytes come -// from the blob buffer. Wrap them in a shared_ptr and use that as -// the anchor; the Span points into the wrapped vector. Net cost: one alloc -// per object, same as before the iter-3 SDK change. -inline Expected readImageBody(BlobReader& r, Timestamp ts) { - auto width = r.readPod(); - if (!width) { - return unexpected(width.error()); - } - auto height = r.readPod(); - if (!height) { - return unexpected(height.error()); - } - auto pixel_format_raw = r.readPod(); - if (!pixel_format_raw) { - return unexpected(pixel_format_raw.error()); - } - auto is_be = r.readPod(); - if (!is_be) { - return unexpected(is_be.error()); - } - /*reserved*/ if (auto rsv = r.readPod(); !rsv) { return unexpected(rsv.error()); } - auto row_step = r.readPod(); - if (!row_step) { - return unexpected(row_step.error()); - } - auto pixels_size = r.readPod(); - if (!pixels_size) { - return unexpected(pixels_size.error()); - } - auto pixels = r.readBytes(*pixels_size); - if (!pixels) { - return unexpected(pixels.error()); - } - - auto owned = std::make_shared>(std::move(*pixels)); - Span view(owned->data(), owned->size()); - return Image{ - .width = *width, - .height = *height, - .pixel_format = static_cast(*pixel_format_raw), - .row_step = *row_step, - .is_bigendian = (*is_be != 0), - .pixels = view, - .anchor = owned, - .timestamp_ns = ts, - }; -} - -inline Expected readCompressedImageBody(BlobReader& r, Timestamp ts) { - auto format_raw = r.readPod(); - if (!format_raw) { - return unexpected(format_raw.error()); - } - auto has_min = r.readPod(); - if (!has_min) { - return unexpected(has_min.error()); - } - auto has_max = r.readPod(); - if (!has_max) { - return unexpected(has_max.error()); - } - /*reserved*/ if (auto rsv = r.readPod(); !rsv) { return unexpected(rsv.error()); } - auto depth_min = r.readPod(); - if (!depth_min) { - return unexpected(depth_min.error()); - } - auto depth_max = r.readPod(); - if (!depth_max) { - return unexpected(depth_max.error()); - } - auto bytes_size = r.readPod(); - if (!bytes_size) { - return unexpected(bytes_size.error()); - } - auto bytes = r.readBytes(*bytes_size); - if (!bytes) { - return unexpected(bytes.error()); - } - - auto owned = std::make_shared>(std::move(*bytes)); - CompressedImage ci{}; - ci.format = static_cast(*format_raw); - ci.bytes = Span(owned->data(), owned->size()); - ci.anchor = owned; - ci.timestamp_ns = ts; - if (*has_min != 0) { - ci.extras.compressed_depth_min = *depth_min; - } - if (*has_max != 0) { - ci.extras.compressed_depth_max = *depth_max; - } - return ci; -} - -inline Expected readPointCloudBody(BlobReader& r, Timestamp ts) { - auto width = r.readPod(); - if (!width) { - return unexpected(width.error()); - } - auto height = r.readPod(); - if (!height) { - return unexpected(height.error()); - } - auto point_step = r.readPod(); - if (!point_step) { - return unexpected(point_step.error()); - } - auto row_step = r.readPod(); - if (!row_step) { - return unexpected(row_step.error()); - } - auto is_be = r.readPod(); - if (!is_be) { - return unexpected(is_be.error()); - } - auto is_dense = r.readPod(); - if (!is_dense) { - return unexpected(is_dense.error()); - } - auto fields_count = r.readPod(); - if (!fields_count) { - return unexpected(fields_count.error()); - } - - std::vector fields; - fields.reserve(*fields_count); - for (uint16_t i = 0; i < *fields_count; ++i) { - auto name_size = r.readPod(); - if (!name_size) { - return unexpected(name_size.error()); - } - auto name = r.readString(*name_size); - if (!name) { - return unexpected(name.error()); - } - auto offset = r.readPod(); - if (!offset) { - return unexpected(offset.error()); - } - auto datatype_raw = r.readPod(); - if (!datatype_raw) { - return unexpected(datatype_raw.error()); - } - /*reserved×3*/ for (int j = 0; j < 3; ++j) { - if (auto rsv = r.readPod(); !rsv) { - return unexpected(rsv.error()); - } - } - auto count = r.readPod(); - if (!count) { - return unexpected(count.error()); - } - fields.push_back( - PointField{ - .name = std::move(*name), - .offset = *offset, - .datatype = static_cast(*datatype_raw), - .count = *count, - }); - } - - auto data_size = r.readPod(); - if (!data_size) { - return unexpected(data_size.error()); - } - auto data = r.readBytes(*data_size); - if (!data) { - return unexpected(data.error()); - } - - auto owned = std::make_shared>(std::move(*data)); - Span view(owned->data(), owned->size()); - return PointCloud{ - .width = *width, - .height = *height, - .point_step = *point_step, - .row_step = *row_step, - .is_bigendian = (*is_be != 0), - .is_dense = (*is_dense != 0), - .fields = std::move(fields), - .data = view, - .anchor = owned, - .timestamp_ns = ts, - }; -} - -/// Deserialize a flat byte buffer into a CanonicalObject. Returns unexpected -/// on truncation, unknown kind, or any inconsistency. -inline Expected deserializeCanonicalObject(const uint8_t* data, size_t size) { - if (data == nullptr) { - return unexpected(std::string("null blob")); - } - BlobReader r(data, size); - - auto kind_raw = r.readPod(); - if (!kind_raw) { - return unexpected(kind_raw.error()); - } - /*reserved*/ if (auto rsv = r.readPod(); !rsv) { return unexpected(rsv.error()); } - auto ts = r.readPod(); - if (!ts) { - return unexpected(ts.error()); - } - - switch (static_cast(*kind_raw)) { - case CanonicalObjectKind::kImage: { - auto img = readImageBody(r, *ts); - if (!img) { - return unexpected(img.error()); - } - return CanonicalObject{std::move(*img)}; - } - case CanonicalObjectKind::kCompressedImage: { - auto ci = readCompressedImageBody(r, *ts); - if (!ci) { - return unexpected(ci.error()); - } - return CanonicalObject{std::move(*ci)}; - } - case CanonicalObjectKind::kPointCloud: { - auto pc = readPointCloudBody(r, *ts); - if (!pc) { - return unexpected(pc.error()); - } - return CanonicalObject{std::move(*pc)}; - } - case CanonicalObjectKind::kNone: - default: - return unexpected(std::string("unknown or unsupported canonical object kind")); - } -} - -} // namespace detail -} // namespace sdk -} // namespace PJ diff --git a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp b/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp index 01a4422..90cd721 100644 --- a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp +++ b/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp @@ -7,8 +7,6 @@ */ #pragma once -#include "pj_base/sdk/detail/canonical_object_serialization.hpp" - namespace PJ { inline void MessageParserPluginBase::trampoline_destroy(void* ctx) noexcept { @@ -157,77 +155,4 @@ inline bool MessageParserPluginBase::trampoline_classify_schema( } } -inline bool MessageParserPluginBase::trampoline_parse_scalars( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_named_field_value_buffer_t* out_fields, - PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (out_fields == nullptr) { - self->storeError(out_error, 2, "plugin", "parse_scalars called with null out_fields"); - return false; - } - try { - Span payload_span(payload.data, payload.size); - auto result = self->parseScalars(timestamp_ns, payload_span); - if (!result) { - self->storeError(out_error, 1, "plugin", std::move(result).error()); - return false; - } - // Hand the C++ vector to the plugin-owned buffer so PJ_string_view_t - // entries inside the ABI structs remain valid until the next call. - self->scalars_owned_buf_ = std::move(*result); - self->scalars_abi_buf_ = sdk::toAbiNamed( - Span(self->scalars_owned_buf_.data(), self->scalars_owned_buf_.size())); - - out_fields->fields = self->scalars_abi_buf_.data(); - out_fields->count = self->scalars_abi_buf_.size(); - out_fields->alloc_handle = nullptr; // buffer kept alive by the plugin instance - out_fields->release = nullptr; - return true; - } catch (const std::exception& e) { - self->storeError(out_error, 1, "plugin", std::string("parse_scalars threw: ") + e.what()); - return false; - } catch (...) { - self->storeError(out_error, 1, "plugin", "unknown exception in parse_scalars"); - return false; - } -} - -inline bool MessageParserPluginBase::trampoline_parse_object( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_canonical_object_blob_t* out_blob, - PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (out_blob == nullptr) { - self->storeError(out_error, 2, "plugin", "parse_object called with null out_blob"); - return false; - } - try { - // C ABI path: caller does not share ownership of the payload buffer. - // Pass an empty anchor; the plugin must materialize anything it wants - // to retain past this call. The serialized blob written to out_blob is - // copied into self->object_blob_buf_ before we return, so a span-into- - // payload that the plugin keeps inside its CanonicalObject is fine for - // the duration of the serialize call below. - Span payload_span(payload.data, payload.size); - sdk::PayloadView payload_view{payload_span, sdk::BufferAnchor{}}; - auto result = self->parseObject(timestamp_ns, payload_view); - if (!result) { - self->storeError(out_error, 1, "plugin", std::move(result).error()); - return false; - } - self->object_blob_buf_ = sdk::detail::serializeCanonicalObject(*result); - - out_blob->data = self->object_blob_buf_.data(); - out_blob->size = self->object_blob_buf_.size(); - out_blob->alloc_handle = nullptr; // buffer kept alive by the plugin instance - out_blob->release = nullptr; - return true; - } catch (const std::exception& e) { - self->storeError(out_error, 1, "plugin", std::string("parse_object threw: ") + e.what()); - return false; - } catch (...) { - self->storeError(out_error, 1, "plugin", "unknown exception in parse_object"); - return false; - } -} - } // namespace PJ diff --git a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp index 93672cd..9b82a74 100644 --- a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp @@ -278,10 +278,7 @@ class MessageParserPluginBase { trampoline_load_config, trampoline_parse, trampoline_get_plugin_extension, - // Tail slots: pure-functional API (canonical-object). trampoline_classify_schema, - trampoline_parse_scalars, - trampoline_parse_object, }; return &vt; } @@ -328,13 +325,6 @@ class MessageParserPluginBase { // Schema handler table populated by the plugin via registerSchemaHandler(). std::unordered_map handlers_; - // Buffers kept alive between parse_scalars / parse_object calls so the host - // can read the returned slices safely. release callbacks in the ABI structs - // are NULL — the plugin owns the buffers and overwrites them on each call. - std::vector scalars_owned_buf_; - std::vector scalars_abi_buf_; - std::vector object_blob_buf_; - static void storeError(PJ_error_t* out_error, int32_t code, std::string_view domain, std::string_view message) { sdk::fillError(out_error, code, domain, message); } @@ -351,12 +341,6 @@ class MessageParserPluginBase { static bool trampoline_classify_schema( void* ctx, PJ_string_view_t type_name, PJ_bytes_view_t schema, PJ_schema_classification_t* out_classification, PJ_error_t* out_error) noexcept; - static bool trampoline_parse_scalars( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_named_field_value_buffer_t* out_fields, - PJ_error_t* out_error) noexcept; - static bool trampoline_parse_object( - void* ctx, int64_t timestamp_ns, PJ_bytes_view_t payload, PJ_canonical_object_blob_t* out_blob, - PJ_error_t* out_error) noexcept; }; } // namespace PJ diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 2be7936..8018af5 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -79,8 +79,8 @@ static_assert(offsetof(PJ_message_parser_vtable_t, struct_size) == 4, "v4 prefix static_assert(offsetof(PJ_message_parser_vtable_t, bind) == 32, "v4 bind slot pinned"); static_assert(offsetof(PJ_message_parser_vtable_t, parse) == 64, "v4 parse slot pinned"); static_assert(offsetof(PJ_message_parser_vtable_t, get_plugin_extension) == 72, "v4 last baseline slot pinned"); -// 80 baseline (v4.0) + 3 canonical-object tail slots × 8 bytes each = 104. -static_assert(sizeof(PJ_message_parser_vtable_t) == 104, "MessageParser vtable size (update deliberately on append)"); +// 80 baseline (v4.0) + 1 tail slot × 8 bytes = 88. +static_assert(sizeof(PJ_message_parser_vtable_t) == 88, "MessageParser vtable size (update deliberately on append)"); static_assert(PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE == 80, "MIN vtable size is pinned at v4.0 — NEVER INCREASE"); static_assert(PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE <= sizeof(PJ_message_parser_vtable_t), "MIN must never exceed current"); From 1ff337e3f90acd43a4686556c0a02304355ffc2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Wed, 13 May 2026 14:21:33 +0200 Subject: [PATCH 05/18] refactor(sdk): drop objectIngestPolicy from DataSourceRuntimeHostView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectIngestPolicy is a host-owned concern: the host instantiates its own ObjectIngestPolicyResolver during file-load setup and consults it on each push_message_v2 dispatch. DataSource plugins are policy-agnostic — they fabricate a payload fetcher via runtimeHost().pushMessage() and hand it off without inspecting or configuring policy. Exposing a per-view resolver in DataSourceRuntimeHostView contradicted that contract: mutations went to a local instance the host never read. The accessor is removed; the type sdk::ObjectIngestPolicyResolver stays in pj_base/sdk/object_ingest_policy.hpp as host-side vocabulary. Changes: - Drop the objectIngestPolicy() accessor. - Drop the mutable policy_resolver_ member. - Replace transitive include of object_ingest_policy.hpp with an explicit include of canonical_object.hpp (needed for PayloadView in pushMessage). --- .../pj_base/sdk/data_source_host_views.hpp | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index f6c4bbf..ceb63e3 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -21,7 +21,7 @@ #include "pj_base/data_source_protocol.h" #include "pj_base/expected.hpp" -#include "pj_base/sdk/object_ingest_policy.hpp" +#include "pj_base/sdk/canonical_object.hpp" #include "pj_base/sdk/plugin_data_api.hpp" namespace PJ { @@ -303,18 +303,6 @@ class DataSourceRuntimeHostView { return okStatus(); } - /// Access (mutable) the resolver of ObjectIngestPolicy for this runtime. - /// The application configures it during setup; the host (when the - /// push_message_v2 dispatch lands) consults it per message. - /// - /// Implementation status (RFC): - /// The resolver is a per-DataSourceRuntimeHostView local instance for - /// now. In production it will be host-owned and shared across views; - /// the SDK surface stays the same. - [[nodiscard]] sdk::ObjectIngestPolicyResolver& objectIngestPolicy() const { - return policy_resolver_; - } - /** * Display a modal message box and wait for user response. * @return The button clicked, or kOk if the host does not support dialogs. @@ -375,13 +363,6 @@ class DataSourceRuntimeHostView { private: PJ_data_source_runtime_host_t host_{}; - - // RFC-only: local-to-view policy resolver. Production wiring will move - // this to a host-side singleton accessed through the service registry; - // the public surface (objectIngestPolicy()) stays the same. mutable - // because configuring the policy is conceptually a side concern, not - // a mutation of the view. - mutable sdk::ObjectIngestPolicyResolver policy_resolver_{}; }; } // namespace PJ From f6002cee6e28858342ca26e97e24aa439f59dfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Wed, 13 May 2026 14:53:49 +0200 Subject: [PATCH 06/18] chore(sdk): enforce final on pure-functional FINAL methods + sync stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark classifySchema / parseScalars / parseObject as virtual...final so derived plugins get a compile error if they try to override (the methods are invoked on MessageParserPluginBase*, so an override would silently not run). Matches the FINAL contract from the RFC. - Adapt the "Pure-functional API" section header to reflect the current state (no "added in protocol v5" — those slots are not at the C ABI level; the contract is C++ SDK direct-call only). - Rename test DefaultPolicyIsLazyScalars to DefaultPolicyIsLazyObjectsEagerScalars to match the enum value. - Rewrite the push_message_v2_test.cpp header summary so case 5 matches the assertion (explicit error when the host doesn't expose the slot; no silent fallback). --- .../sdk/message_parser_plugin_base.hpp | 29 ++++++++++++------- pj_base/tests/object_ingest_policy_test.cpp | 2 +- pj_base/tests/push_message_v2_test.cpp | 6 ++-- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp index 9b82a74..e5ca74e 100644 --- a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp @@ -159,7 +159,7 @@ class MessageParserPluginBase { } // --------------------------------------------------------------------------- - // Pure-functional API (added in protocol v5, ABI-appendable) + // Pure-functional API // --------------------------------------------------------------------------- // // Design principle: the parser does NOT decide push policy (eager vs lazy) @@ -171,8 +171,9 @@ class MessageParserPluginBase { // // Plugins extend the parser by populating a per-schema handler table in // the constructor (registerSchemaHandler). The base class implements - // classifySchema / parseScalars / parseObject as final lookups into that - // table. Plugins do NOT override the three methods. + // classifySchema / parseScalars / parseObject as `final` lookups into that + // table, invoked by the host directly on a MessageParserPluginBase* pointer + // (no vtable indirection, no cross-ABI copy). /// Register a handler for one schema type name. Typically called once per /// supported schema in the plugin's constructor. @@ -205,11 +206,16 @@ class MessageParserPluginBase { return &it->second; } - /// Lookup against the registered handler table. Non-virtual: plugins - /// populate the table via registerSchemaHandler() rather than overriding; - /// the C ABI trampolines invoke this directly on MessageParserPluginBase*. + /// Lookup against the registered handler table. Marked `final`: plugins + /// populate the table via registerSchemaHandler() rather than overriding. + /// The C ABI trampolines call this on MessageParserPluginBase*; a derived + /// override would never be invoked, so the compiler rejects it explicitly. /// Returns kNone when no handler is registered for this type name. - sdk::SchemaClassification classifySchema(std::string_view type_name, Span schema) const { + /// + /// `type_name` is passed as a parameter (rather than using bound_type_name_) + /// because classification may be queried for any schema this parser handles, + /// including before bindSchema has fixed the instance to one. + virtual sdk::SchemaClassification classifySchema(std::string_view type_name, Span schema) const final { (void)schema; if (const auto* h = findSchemaHandler(type_name)) { return {h->object_kind}; @@ -219,9 +225,10 @@ class MessageParserPluginBase { /// Invoke the registered scalar handler for the currently-bound schema. /// Returns unexpected if no handler is registered, or if the registered - /// handler did not provide a parse_scalars callable. Non-virtual — see + /// handler did not provide a parse_scalars callable. Marked `final` — see /// classifySchema above for the rationale. - Expected> parseScalars(Timestamp timestamp_ns, Span payload) { + virtual Expected> parseScalars( + Timestamp timestamp_ns, Span payload) final { const auto* h = findSchemaHandler(bound_type_name_); if (h == nullptr) { return unexpected(std::string("parser does not register schema: ") + bound_type_name_); @@ -235,13 +242,13 @@ class MessageParserPluginBase { /// Invoke the registered object handler for the currently-bound schema. /// Returns unexpected if no handler is registered, or if the registered /// handler did not provide a parse_object callable (i.e. this schema - /// produces only scalars). Non-virtual — see classifySchema above. + /// produces only scalars). Marked `final` — see classifySchema above. /// /// `payload.anchor` may be empty; in that case the parser is expected to /// materialize anything it wants to outlive this call. In-process callers /// that already own the payload buffer should pass a non-empty anchor so /// the parser can return a zero-copy CanonicalObject. - Expected parseObject(Timestamp timestamp_ns, sdk::PayloadView payload) { + virtual Expected parseObject(Timestamp timestamp_ns, sdk::PayloadView payload) final { const auto* h = findSchemaHandler(bound_type_name_); if (h == nullptr) { return unexpected(std::string("parser does not register schema: ") + bound_type_name_); diff --git a/pj_base/tests/object_ingest_policy_test.cpp b/pj_base/tests/object_ingest_policy_test.cpp index 3f7a10c..a6c9f68 100644 --- a/pj_base/tests/object_ingest_policy_test.cpp +++ b/pj_base/tests/object_ingest_policy_test.cpp @@ -6,7 +6,7 @@ using PJ::sdk::CanonicalObjectKind; using PJ::sdk::ObjectIngestPolicy; using PJ::sdk::ObjectIngestPolicyResolver; -TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyScalars) { +TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyObjectsEagerScalars) { ObjectIngestPolicyResolver r; EXPECT_EQ( r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); diff --git a/pj_base/tests/push_message_v2_test.cpp b/pj_base/tests/push_message_v2_test.cpp index b0b6722..509650a 100644 --- a/pj_base/tests/push_message_v2_test.cpp +++ b/pj_base/tests/push_message_v2_test.cpp @@ -7,9 +7,9 @@ // 3. Multiple fetcher invocations are idempotent (same bytes each time). // 4. The heap-held closure context is destroyed exactly once when the // host calls fetcher.release. -// 5. When the host predates the slot (struct_size short OR field NULL), -// the SDK template falls back to push_raw_message — both for vector -// and for PayloadView closures. +// 5. When the host does not expose push_message_v2 (struct_size short +// or field NULL), pushMessage returns an explicit error rather than +// degrading silently. #include From 31634baffe7541bbba49d32238de338d0d1b209e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Wed, 13 May 2026 14:54:17 +0200 Subject: [PATCH 07/18] docs(sdk): tighten doc + safety around push_raw_message, payload size, variant extension, fetcher type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - push_raw_message: clarify it's the eager-only path; plugins needing lazy materialization or ObjectIngestPolicy dispatch use push_message_v2. - PJ_payload_t::size: size_t -> uint64_t for ABI-explicit 64-bit width (the protocol pins everything else to 64-bit; staying with size_t was a portability hazard on cross-compiles). - CanonicalObject variant: rewrite the extension-policy comment. Appending alternatives is forward-compatible — older hosts receiving an unknown kind reject the message, no protocol bump required. - pushMessage template: static_assert that the Fetcher returns either sdk::PayloadView or std::vector. Catches misuse at compile time instead of falling through to UB in the else branch. --- pj_base/include/pj_base/data_source_protocol.h | 8 +++++++- pj_base/include/pj_base/sdk/canonical_object.hpp | 9 ++++++--- pj_base/include/pj_base/sdk/data_source_host_views.hpp | 8 +++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 07eaa67..0161c91 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -152,7 +152,7 @@ typedef struct PJ_payload_anchor_t { */ typedef struct PJ_payload_t { const uint8_t* data; - size_t size; + uint64_t size; PJ_payload_anchor_t anchor; } PJ_payload_t; @@ -254,6 +254,12 @@ typedef struct PJ_data_source_runtime_host_vtable_t { * @p handle must have been obtained from ensure_parser_binding. * @p host_timestamp_ns is nanoseconds since the Unix epoch * (1970-01-01T00:00:00Z). Returns false + error on failure. + * + * Eager-only push: the host parses immediately and the bytes are not + * retained for later replay. Plugins that need lazy materialization or + * ObjectIngestPolicy dispatch should use push_message_v2 instead. This + * slot remains for sources that fan-out raw bytes without an associated + * fetcher (streaming or eager-only consumers). */ bool (*push_raw_message)( void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, PJ_bytes_view_t payload, diff --git a/pj_base/include/pj_base/sdk/canonical_object.hpp b/pj_base/include/pj_base/sdk/canonical_object.hpp index 348bfe9..5e5aae0 100644 --- a/pj_base/include/pj_base/sdk/canonical_object.hpp +++ b/pj_base/include/pj_base/sdk/canonical_object.hpp @@ -254,9 +254,12 @@ struct PointCloud { // CanonicalObject — variant carried by parser->parseObject() // ----------------------------------------------------------------------------- -/// Sum type of all canonical objects a parser may produce. Closed for now; -/// extending it (kMarkers, kOccupancyGrid, …) requires bumping -/// PJ_MESSAGE_PARSER_PROTOCOL_VERSION (compatible append at the end). +/// Sum type of all canonical objects a parser may produce. New alternatives +/// (kMarkers, kOccupancyGrid, …) are appended at the tail and announced via +/// CanonicalObjectKind. Plugins built against an older SDK keep producing +/// the alternatives they know; hosts built against an older SDK that receive +/// an unknown kind reject the message rather than crashing. Forward-compatible +/// — no protocol bump required. using CanonicalObject = std::variant; /// Helper: get the kind tag for a CanonicalObject without unpacking it. diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index ceb63e3..bce4836 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -18,6 +18,7 @@ #include #include #include +#include #include "pj_base/data_source_protocol.h" #include "pj_base/expected.hpp" @@ -249,6 +250,12 @@ class DataSourceRuntimeHostView { /// degrading to a kEager push_raw_message. template [[nodiscard]] Status pushMessage(ParserBindingHandle handle, Timestamp host_timestamp_ns, Fetcher&& fetcher) const { + using FetcherT = std::decay_t; + using FetcherResult = std::decay_t>; + static_assert( + std::is_same_v || std::is_same_v>, + "Fetcher must return sdk::PayloadView (zero-copy) or std::vector"); + if (!valid()) { return unexpected(std::string("runtime host is not bound")); } @@ -256,7 +263,6 @@ class DataSourceRuntimeHostView { return unexpected(std::string("runtime host does not expose push_message_v2")); } - using FetcherT = std::decay_t; auto* ctx = new FetcherT(std::forward(fetcher)); PJ_payload_fetcher_t abi_fetcher{ From 1c778de047f2e499bb8bcb721edb9b7c0a8e7554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Wed, 13 May 2026 14:56:24 +0200 Subject: [PATCH 08/18] test(abi): pin layout of canonical-object pipeline structs and runtime host vtable Compile-time sentinels for the public ABI types added in the canonical-object pipeline series: - PJ_canonical_object_kind_t: enum size pinned at 4 bytes. - PJ_schema_classification_t: 4 bytes; field offsets pinned. - PJ_payload_anchor_t: 16 bytes (ctx + release fn ptr). - PJ_payload_t: 32 bytes (data + size + anchor); field offsets pinned. - PJ_payload_fetcher_t: 24 bytes (ctx + fetch + release fn ptr). - PJ_data_source_runtime_host_vtable_t: 104 bytes (12 fn ptrs + prefix). Offsets of report_message, push_raw_message, list_available_encodings, and the push_message_v2 tail slot are pinned. struct size grows deliberately as future tail slots append. Any unintended reorder or size change of these public ABI types now fails the build instead of silently breaking binary compatibility with existing plugins. --- pj_base/tests/abi_layout_sentinels_test.cpp | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 8018af5..59777ef 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -94,6 +94,44 @@ static_assert(sizeof(PJ_toolbox_vtable_t) == 88, "Toolbox vtable size (update de static_assert(PJ_TOOLBOX_MIN_VTABLE_SIZE == 88, "MIN vtable size is pinned at v4.0 — NEVER INCREASE"); static_assert(PJ_TOOLBOX_MIN_VTABLE_SIZE <= sizeof(PJ_toolbox_vtable_t), "MIN must never exceed current"); +// --- Canonical-object pipeline structs --------------------------------------- +// Public ABI types crossing the boundary for the v4 canonical-object pipeline. +// Sizes and offsets are pinned; any change is a deliberate ABI revision. +static_assert(sizeof(PJ_canonical_object_kind_t) == 4, "enum layout pinned"); +static_assert(sizeof(PJ_schema_classification_t) == 4, "PJ_schema_classification_t layout pinned"); +static_assert(offsetof(PJ_schema_classification_t, object_kind) == 0, "object_kind at offset 0"); +static_assert(offsetof(PJ_schema_classification_t, reserved) == 2, "reserved at offset 2"); + +static_assert(sizeof(PJ_payload_anchor_t) == 16, "PJ_payload_anchor_t pinned (ctx + release fn ptr)"); +static_assert(offsetof(PJ_payload_anchor_t, ctx) == 0, "ctx at offset 0"); +static_assert(offsetof(PJ_payload_anchor_t, release) == 8, "release at offset 8"); + +static_assert(sizeof(PJ_payload_t) == 32, "PJ_payload_t pinned (data + size + anchor)"); +static_assert(offsetof(PJ_payload_t, data) == 0, "data at offset 0"); +static_assert(offsetof(PJ_payload_t, size) == 8, "size at offset 8"); +static_assert(offsetof(PJ_payload_t, anchor) == 16, "anchor at offset 16"); + +static_assert(sizeof(PJ_payload_fetcher_t) == 24, "PJ_payload_fetcher_t pinned (ctx + fetch + release)"); +static_assert(offsetof(PJ_payload_fetcher_t, ctx) == 0, "ctx at offset 0"); +static_assert(offsetof(PJ_payload_fetcher_t, fetch) == 8, "fetch at offset 8"); +static_assert(offsetof(PJ_payload_fetcher_t, release) == 16, "release at offset 16"); + +// --- DataSource runtime host vtable (ABI-APPENDABLE within v4) --------------- +// The vtable the host exposes to plugins under "pj.runtime.v1". Offsets of +// existing slots are pinned; size grows deliberately as tail slots append. +static_assert(offsetof(PJ_data_source_runtime_host_vtable_t, protocol_version) == 0, "v1 prefix pinned"); +static_assert(offsetof(PJ_data_source_runtime_host_vtable_t, struct_size) == 4, "v1 prefix pinned"); +static_assert(offsetof(PJ_data_source_runtime_host_vtable_t, report_message) == 8, "v1 first slot pinned"); +static_assert( + offsetof(PJ_data_source_runtime_host_vtable_t, push_raw_message) == 72, "v1 push_raw_message slot pinned"); +static_assert( + offsetof(PJ_data_source_runtime_host_vtable_t, list_available_encodings) == 88, + "v1 list_available_encodings slot pinned"); +static_assert( + offsetof(PJ_data_source_runtime_host_vtable_t, push_message_v2) == 96, "v1 push_message_v2 tail slot pinned"); +static_assert( + sizeof(PJ_data_source_runtime_host_vtable_t) == 104, "Runtime host vtable size (update deliberately on append)"); + // --- ABI version symbol ------------------------------------------------------ static_assert(PJ_ABI_VERSION == 4, "v4 ABI version"); From cbdbdecd297ff5d7e311fee7b79cd1e33edb611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 14 May 2026 15:00:57 +0200 Subject: [PATCH 09/18] refactor(sdk): restructure builtin objects (renames, type unification, magic_enum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pervasive restructure of the canonical-object SDK. Zero behaviour changes — rename, relocation and type unification only. The contract (fetcher, in-process dispatch, ObjectIngestPolicy, anchor zero-copy) is preserved. Naming - canonical-object → builtin-object across the SDK (headers, types, macros, ABI symbols, CMake targets). - PJ_payload_fetcher_t → PJ_message_data_fetcher_t; field .fetch → .fetchMessageData. Layout - Builtin object types live in pj_scene_protocol/builtin/, one file per type (BuiltinObject, BuiltinObjectKind, Image, DepthImage, PointCloud, ImageAnnotations, CommonImageEncoding, depth_image_utils). - MessageParser SDK moves out of pj_base into pj_plugins/sdk/ (message_parser_plugin_base, object_ingest_policy, trampolines). - New pj_plugin_sdk INTERFACE library aggregates pj_base + pj_scene_protocol for plugin authors. Type model - Image and CompressedImage unified into a single Image with std::string encoding ("rgb8", "jpeg", "png", "compressedDepth", ...). PixelFormat enum dropped; encoding is open-ended. - Image::pixels -> Image::data. - New DepthImage builtin with K + D + distortion_model; R and P are derived via free helpers in depth_image_utils.h. - ImageAnnotations promoted to top-level variant alternative. Vocabulary helpers - magic_enum-backed name/parse helpers for BuiltinObjectKind and CommonImageEncoding. magic_enum/0.9.7 added to conanfile. ABI - Layout sentinels updated to pin the renamed PJ_message_data_fetcher_t and PJ_builtin_object_kind_t. v4 vtable offsets unchanged. Build: PJ4 green (69/69 tests pass). --- CMakeLists.txt | 2 +- conanfile.txt | 1 + pj_base/CMakeLists.txt | 1 - pj_base/include/pj_base/buffer_anchor.hpp | 42 +++ ...ical_object_abi.h => builtin_object_abi.h} | 28 +- .../include/pj_base/data_source_protocol.h | 24 +- .../include/pj_base/message_parser_protocol.h | 4 +- .../include/pj_base/sdk/canonical_object.hpp | 284 ------------------ .../pj_base/sdk/data_source_host_views.hpp | 8 +- pj_base/tests/abi_layout_sentinels_test.cpp | 13 +- .../tests/message_parser_plugin_base_test.cpp | 2 +- pj_base/tests/object_ingest_policy_test.cpp | 82 ----- pj_base/tests/push_message_v2_test.cpp | 14 +- pj_datastore/CMakeLists.txt | 10 +- .../tests/plugin_parser_object_write_test.cpp | 2 +- pj_plugins/CMakeLists.txt | 26 +- pj_plugins/examples/mock_json_parser.cpp | 2 +- pj_plugins/examples/mock_schema_parser.cpp | 2 +- .../pj_plugins/host/message_parser_handle.hpp | 12 +- .../sdk/detail/message_parser_trampolines.hpp | 2 +- .../sdk/message_parser_plugin_base.hpp | 14 +- .../pj_plugins}/sdk/object_ingest_policy.hpp | 12 +- .../tests/object_ingest_policy_test.cpp | 83 +++++ pj_scene_protocol/CMakeLists.txt | 7 +- .../pj_scene_protocol/builtin/BuiltinObject.h | 48 +++ .../builtin/BuiltinObjectKind.h | 54 ++++ .../builtin/CommonImageEncoding.h | 60 ++++ .../pj_scene_protocol/builtin/DepthImage.h | 72 +++++ .../include/pj_scene_protocol/builtin/Image.h | 65 ++++ .../builtin/ImageAnnotations.h | 28 ++ .../pj_scene_protocol/builtin/PointCloud.h | 78 +++++ .../builtin/depth_image_utils.h | 50 +++ 32 files changed, 690 insertions(+), 442 deletions(-) create mode 100644 pj_base/include/pj_base/buffer_anchor.hpp rename pj_base/include/pj_base/{canonical_object_abi.h => builtin_object_abi.h} (74%) delete mode 100644 pj_base/include/pj_base/sdk/canonical_object.hpp delete mode 100644 pj_base/tests/object_ingest_policy_test.cpp rename {pj_base/include/pj_base => pj_plugins/include/pj_plugins}/sdk/detail/message_parser_trampolines.hpp (98%) rename {pj_base/include/pj_base => pj_plugins/include/pj_plugins}/sdk/message_parser_plugin_base.hpp (97%) rename {pj_base/include/pj_base => pj_plugins/include/pj_plugins}/sdk/object_ingest_policy.hpp (90%) create mode 100644 pj_plugins/tests/object_ingest_policy_test.cpp create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObjectKind.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/CommonImageEncoding.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/PointCloud.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 61c4015..4e7f1f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,8 +158,8 @@ add_subdirectory(pj_base) if(PJ_BUILD_DATASTORE) add_subdirectory(pj_datastore) endif() -add_subdirectory(pj_plugins) add_subdirectory(pj_scene_protocol) +add_subdirectory(pj_plugins) if(PJ_BUILD_PORTED_PLUGINS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/pj_ported_plugins/CMakeLists.txt") set(PJ_HAS_PORTED_PLUGINS TRUE) diff --git a/conanfile.txt b/conanfile.txt index 410e4b8..ddee196 100644 --- a/conanfile.txt +++ b/conanfile.txt @@ -6,6 +6,7 @@ benchmark/1.9.4 arrow/23.0.1 nanoarrow/0.7.0 nlohmann_json/3.12.0 +magic_enum/0.9.7 [options] arrow/*:parquet=True diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index b3dece2..9390d20 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -56,7 +56,6 @@ if(PJ_BUILD_TESTS) tests/platform_test.cpp tests/arrow_holders_test.cpp tests/media_metadata_test.cpp - tests/object_ingest_policy_test.cpp tests/push_message_v2_test.cpp ) diff --git a/pj_base/include/pj_base/buffer_anchor.hpp b/pj_base/include/pj_base/buffer_anchor.hpp new file mode 100644 index 0000000..d02f061 --- /dev/null +++ b/pj_base/include/pj_base/buffer_anchor.hpp @@ -0,0 +1,42 @@ +/** + * @file buffer_anchor.hpp + * @brief Zero-copy ownership pattern: a type-erased anchor + a non-owning view. + * + * BufferAnchor + PayloadView are the general-purpose ownership token used + * across the SDK to let consumers hold a span of bytes without committing to + * a copy. The producer owns the underlying allocation (typically through a + * shared_ptr>); the BufferAnchor erases the concrete type + * and keeps the allocation alive while at least one anchor copy survives. + */ +#pragma once + +#include +#include + +#include "pj_base/span.hpp" + +namespace PJ { +namespace sdk { + +/// Type-erased ownership token. A copy keeps the underlying allocation alive +/// while at least one BufferAnchor referencing it exists. The concrete type +/// erased is typically std::shared_ptr>, but any +/// shared_ptr works — consumers never need to know which. +using BufferAnchor = std::shared_ptr; + +/// Non-owning view + ownership anchor of a payload buffer. The view is valid +/// for as long as a copy of `anchor` is alive somewhere. Producers fabricate +/// these when handing bytes to a consumer that needs to outlive the call +/// (e.g. a parser returning a sdk::Image whose pixel span references the +/// same buffer). +/// +/// `anchor` may be empty when the caller does not share ownership — in that +/// case the consumer must materialize any bytes it wants to retain past +/// the call. +struct PayloadView { + Span bytes; + BufferAnchor anchor; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/canonical_object_abi.h b/pj_base/include/pj_base/builtin_object_abi.h similarity index 74% rename from pj_base/include/pj_base/canonical_object_abi.h rename to pj_base/include/pj_base/builtin_object_abi.h index f42b2e9..9f927fb 100644 --- a/pj_base/include/pj_base/canonical_object_abi.h +++ b/pj_base/include/pj_base/builtin_object_abi.h @@ -1,11 +1,11 @@ /** - * @file canonical_object_abi.h + * @file builtin_object_abi.h * @brief C ABI vocabulary for schema classification. * * The host invokes classify_schema (a slot in PJ_message_parser_vtable_t) * after bind_schema to learn what kind of canonical object the parser will * produce for that schema. The parser returns a PJ_schema_classification_t - * carrying a PJ_canonical_object_kind_t. + * carrying a PJ_builtin_object_kind_t. * * Canonical-object production (sdk::Image / sdk::CompressedImage / * sdk::PointCloud) and the pure-functional scalar production @@ -16,8 +16,8 @@ * directly on the C++ pointer. Pure-C plugins emit scalars via the * parse() slot (writing to writeHost). */ -#ifndef PJ_CANONICAL_OBJECT_ABI_H -#define PJ_CANONICAL_OBJECT_ABI_H +#ifndef PJ_BUILTIN_OBJECT_ABI_H +#define PJ_BUILTIN_OBJECT_ABI_H #include #include @@ -35,15 +35,15 @@ extern "C" { * canonical object the parser will produce for this schema (or kNone if * the parser only produces scalars). */ -typedef enum PJ_canonical_object_kind_t { - PJ_CANONICAL_OBJECT_KIND_NONE = 0, - PJ_CANONICAL_OBJECT_KIND_IMAGE = 1, - PJ_CANONICAL_OBJECT_KIND_COMPRESSED_IMAGE = 2, - PJ_CANONICAL_OBJECT_KIND_POINTCLOUD = 3, +typedef enum PJ_builtin_object_kind_t { + PJ_BUILTIN_OBJECT_KIND_NONE = 0, + PJ_BUILTIN_OBJECT_KIND_IMAGE = 1, + PJ_BUILTIN_OBJECT_KIND_POINTCLOUD = 3, + PJ_BUILTIN_OBJECT_KIND_DEPTH_IMAGE = 4, + PJ_BUILTIN_OBJECT_KIND_IMAGE_ANNOTATIONS = 5, /* Reserve future kinds; appended at the tail. */ - /* PJ_CANONICAL_OBJECT_KIND_MARKERS = 4, */ - /* PJ_CANONICAL_OBJECT_KIND_OCCUPANCY_GRID = 5, */ -} PJ_canonical_object_kind_t; + /* PJ_BUILTIN_OBJECT_KIND_OCCUPANCY_GRID = 6, */ +} PJ_builtin_object_kind_t; /** * Schema classification — what kind a parser declares for a given schema. @@ -54,7 +54,7 @@ typedef enum PJ_canonical_object_kind_t { * accept any value (forward compat). */ typedef struct PJ_schema_classification_t { - uint16_t object_kind; /**< PJ_canonical_object_kind_t. */ + uint16_t object_kind; /**< PJ_builtin_object_kind_t. */ uint16_t reserved; } PJ_schema_classification_t; @@ -62,4 +62,4 @@ typedef struct PJ_schema_classification_t { } #endif -#endif /* PJ_CANONICAL_OBJECT_ABI_H */ +#endif /* PJ_BUILTIN_OBJECT_ABI_H */ diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 0161c91..9bcbaf5 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -157,25 +157,25 @@ typedef struct PJ_payload_t { } PJ_payload_t; /** - * Idempotent fetcher of payload bytes. The host invokes `fetch(ctx, &out, - * &err)` zero, one, or many times depending on the active - * ObjectIngestPolicy and on consumer pulls. Returns true and populates - * `*out` on success; returns false and (optionally) populates `*err` on - * failure (file read error, source torn down, etc.). + * Idempotent fetcher of one message's payload bytes. The host invokes + * `fetchMessageData(ctx, &out, &err)` zero, one, or many times depending + * on the active ObjectIngestPolicy and on consumer pulls. Returns true + * and populates `*out` on success; returns false and (optionally) + * populates `*err` on failure (file read error, source torn down, etc.). * * The host ALWAYS calls `release(ctx)` exactly once when it no longer * needs the fetcher — at the end of ingest for kEager, when the * corresponding ObjectStore entry is dropped for lazy modes. `release` * MAY be NULL if the plugin manages the ctx via some external mechanism. * - * `fetch` MUST be thread-safe: the host may invoke it from the ingest - * thread (kEager) or from consumer threads (lazy pull). + * `fetchMessageData` MUST be thread-safe: the host may invoke it from + * the ingest thread (kEager) or from consumer threads (lazy pull). */ -typedef struct PJ_payload_fetcher_t { +typedef struct PJ_message_data_fetcher_t { void* ctx; - bool (*fetch)(void* ctx, PJ_payload_t* out_payload, PJ_error_t* out_error) PJ_NOEXCEPT; + bool (*fetchMessageData)(void* ctx, PJ_payload_t* out_payload, PJ_error_t* out_error) PJ_NOEXCEPT; void (*release)(void* ctx); -} PJ_payload_fetcher_t; +} PJ_message_data_fetcher_t; /** * Request to bind (or look up) a parser for a given topic. @@ -323,14 +323,14 @@ typedef struct PJ_data_source_runtime_host_vtable_t { * is responsible for calling `fetcher.release(fetcher.ctx)` exactly * once when the fetcher is no longer needed (kEager: after the * single fetch; lazy modes: when the ObjectStore entry it backs is - * dropped). `fetcher.fetch` must be thread-safe. + * dropped). `fetcher.fetchMessageData` must be thread-safe. * * Returns false + error on failure (binding handle invalid, * ObjectStore push failed, etc.). On failure the host still calls * `fetcher.release` so the plugin's ctx leaks no resources. */ bool (*push_message_v2)( - void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, PJ_payload_fetcher_t fetcher, + void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, PJ_message_data_fetcher_t fetcher, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_data_source_runtime_host_vtable_t; diff --git a/pj_base/include/pj_base/message_parser_protocol.h b/pj_base/include/pj_base/message_parser_protocol.h index fc34507..d29cc9f 100644 --- a/pj_base/include/pj_base/message_parser_protocol.h +++ b/pj_base/include/pj_base/message_parser_protocol.h @@ -26,7 +26,7 @@ #include #include -#include "pj_base/canonical_object_abi.h" +#include "pj_base/builtin_object_abi.h" #include "pj_base/plugin_data_api.h" #ifdef __cplusplus @@ -125,7 +125,7 @@ typedef struct PJ_message_parser_vtable_t { * @p out_classification by value (POD). * * NULL or absent (struct_size too small) → host treats as - * PJ_CANONICAL_OBJECT_KIND_NONE. + * PJ_BUILTIN_OBJECT_KIND_NONE. * * Pure-functional contract: no host side-effects. */ diff --git a/pj_base/include/pj_base/sdk/canonical_object.hpp b/pj_base/include/pj_base/sdk/canonical_object.hpp deleted file mode 100644 index 5e5aae0..0000000 --- a/pj_base/include/pj_base/sdk/canonical_object.hpp +++ /dev/null @@ -1,284 +0,0 @@ -/** - * @file canonical_object.hpp - * @brief Canonical object types produced by MessageParser plugins and consumed - * by widgets and toolboxes. - * - * This header defines the vocabulary that bridges parser plugins (which - * understand wire formats: ROS, Foxglove, Protobuf, etc.) and consumer code - * (widgets, toolboxes) that renders or processes the result. The ObjectStore - * itself remains agnostic to these types — it stores opaque bytes; the - * decoding into a CanonicalObject happens in the consumer at pull time, by - * invoking the parser's parseObject() against the bytes. - */ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "pj_base/span.hpp" -#include "pj_base/types.hpp" - -namespace PJ { -namespace sdk { - -// ----------------------------------------------------------------------------- -// Schema classification -// ----------------------------------------------------------------------------- - -// ----------------------------------------------------------------------------- -// Buffer anchor — type-erased ownership token shared between a payload buffer -// and any non-owning views derived from it. Carries no data, only keeps the -// underlying allocation alive while at least one anchor copy exists. Concrete -// typical type erased here is std::shared_ptr>; consumers -// never need to know. -// ----------------------------------------------------------------------------- - -using BufferAnchor = std::shared_ptr; - -/// Non-owning view + ownership anchor of a payload buffer. Used by the host -/// to hand a parser a message payload without committing to a copy: the parser -/// reads `bytes` and, in the canonical object it returns, may keep a Span into -/// the same memory plus a copy of `anchor` so the bytes outlive the parse call. -/// -/// `anchor` may be empty when the caller does not share ownership — in that -/// case the parser must materialize any bytes it wants to retain (the C ABI -/// trampoline path is the typical case; in-process direct calls are expected -/// to provide a non-empty anchor). -struct PayloadView { - Span bytes; - BufferAnchor anchor; -}; - -/// What kind of canonical object a parser produces for a given schema. -/// Returned a priori (without parsing payload) by classifySchema(). kNone means -/// the parser only produces scalars for the Datastore — no ObjectTopic to -/// register. -enum class CanonicalObjectKind : uint16_t { - kNone = 0, - kImage = 1, ///< sdk::Image — pixels already in canonical PixelFormat. - kCompressedImage = 2, ///< sdk::CompressedImage — JPEG/PNG/QOI bytes, undecoded. - kPointCloud = 3, ///< sdk::PointCloud — packed points + per-channel field layout. - // Reserved for future kinds; keep numeric values stable across releases. - // kMarkers = 4, - // kOccupancyGrid = 5, -}; - -/// A priori classification of a schema, returned by MessageParser::classifySchema(). -/// Currently a single field; struct (vs raw enum) leaves room to attach -/// declarative metadata later (preferred cache size, expected rate, etc.) without -/// breaking the API. What deliberately does NOT belong here: parse cost hints -/// (the DataSource knows the payload size), retention policy, eager/lazy choice. -struct SchemaClassification { - CanonicalObjectKind object_kind = CanonicalObjectKind::kNone; -}; - -// ----------------------------------------------------------------------------- -// Pixel formats — canonical for sdk::Image -// ----------------------------------------------------------------------------- - -/// Canonical pixel format for sdk::Image. The buffer may include row padding -/// (sdk::Image::row_step >= width * bytesPerPixel(format)); consumers must -/// honor row_step rather than assuming tightly-packed. -/// -/// Both R-G-B and B-G-R orderings are first-class citizens. ROS bgr8/bgra8 -/// (and many machine-vision sources) deliver bytes in B-G-R order natively; -/// keeping the byte order in the format tag (instead of swizzling at parse -/// time) lets the consumer hand bytes straight to a renderer that supports -/// GL_BGR / GL_BGRA texture uploads — zero-copy all the way. -/// -/// Note: pj_scene2D (and other consumers) currently define their own pixel -/// format. Harmonizing on this canonical enum is part of consumer-side -/// migration; this header defines the SDK-level vocabulary. -enum class PixelFormat : uint16_t { - kUnknown = 0, - kRGB888 = 1, ///< 3 bytes/pixel, R-G-B order. - kRGBA8888 = 2, ///< 4 bytes/pixel, R-G-B-A order. - kMono8 = 3, ///< 1 byte/pixel, grayscale. - kMono16 = 4, ///< 2 bytes/pixel, grayscale (depth, etc.); see is_bigendian. - kBGR888 = 5, ///< 3 bytes/pixel, B-G-R order (ROS bgr8, OpenCV native). - kBGRA8888 = 6, ///< 4 bytes/pixel, B-G-R-A order (ROS bgra8). -}; - -/// Bytes per pixel for a given format. Returns 0 for kUnknown. -[[nodiscard]] constexpr uint32_t bytesPerPixel(PixelFormat format) noexcept { - switch (format) { - case PixelFormat::kRGB888: - case PixelFormat::kBGR888: - return 3; - case PixelFormat::kRGBA8888: - case PixelFormat::kBGRA8888: - return 4; - case PixelFormat::kMono8: - return 1; - case PixelFormat::kMono16: - return 2; - case PixelFormat::kUnknown: - return 0; - } - return 0; -} - -// ----------------------------------------------------------------------------- -// sdk::Image — already-decoded image -// ----------------------------------------------------------------------------- - -/// Image already decoded into a canonical pixel format. If the producer -/// (parser) returns this, the consumer can upload the pixels directly to a -/// renderer (QRhi or otherwise) without going through any codec. -/// -/// Layout: `pixels` is a non-owning view of size at least `row_step * height`. -/// `row_step` may exceed `width * bytesPerPixel(pixel_format)` when the wire -/// format included per-row padding; consumers must honor it. `anchor` keeps -/// the underlying buffer alive — the parser may have made `pixels` a view -/// into the source payload (zero-copy) or into a freshly-allocated vector -/// (when the wire format required conversion); consumers don't need to know -/// which. -/// -/// For mono16 buffers `is_bigendian` indicates the byte order of each sample; -/// otherwise it is unused. RGB/BGR ordering is encoded in `pixel_format`. -struct Image { - uint32_t width = 0; - uint32_t height = 0; - PixelFormat pixel_format = PixelFormat::kUnknown; - uint32_t row_step = 0; - bool is_bigendian = false; - Span pixels; - BufferAnchor anchor; - Timestamp timestamp_ns = 0; -}; - -// ----------------------------------------------------------------------------- -// sdk::CompressedImage — undecoded compressed image bytes -// ----------------------------------------------------------------------------- - -/// Image still in compressed wire format (JPEG/PNG/QOI). The consumer is -/// expected to run it through the appropriate codec (pj_scene2D::JpegCodec, -/// PngCodec, etc.) to obtain an sdk::Image. -/// -/// The parser does NOT decompress: it only extracts the compressed payload -/// from whatever wrapper the wire format used (CDR for ROS2, etc.) and tags it -/// with the format. -struct CompressedImage { - enum class Format : uint8_t { - kUnknown = 0, - kJPEG = 1, - kPNG = 2, - kQOI = 3, - }; - - /// Auxiliary metadata that some wrappers attach to the compressed bytes - /// and that the consumer needs to decode correctly. The parser fills the - /// fields it can; consumers ignore those they don't care about. - struct Extras { - /// For ROS compressedDepth: the depth-quantization range to use after - /// PNG decoding. Both nullopt for non-depth compressed images. - std::optional compressed_depth_min; - std::optional compressed_depth_max; - }; - - Format format = Format::kUnknown; - Span bytes; - BufferAnchor anchor; - Timestamp timestamp_ns = 0; - Extras extras; -}; - -// ----------------------------------------------------------------------------- -// sdk::PointCloud — packed point cloud -// ----------------------------------------------------------------------------- - -/// Description of one channel inside a packed point cloud (x, y, z, intensity, -/// rgb, ring, time, …). Mirrors the shape of sensor_msgs/PointField but the -/// type is canonical PJ vocabulary, not a ROS-specific enum. -struct PointField { - enum class Datatype : uint8_t { - kUnknown = 0, - kInt8 = 1, - kUint8 = 2, - kInt16 = 3, - kUint16 = 4, - kInt32 = 5, - kUint32 = 6, - kFloat32 = 7, - kFloat64 = 8, - }; - - std::string name; - uint32_t offset = 0; ///< Byte offset of this field within a single point. - Datatype datatype = Datatype::kUnknown; - uint32_t count = 1; ///< Number of elements of `datatype` (typically 1). -}; - -/// Bytes per element for a given PointField datatype. Returns 0 for kUnknown. -[[nodiscard]] constexpr uint32_t bytesPerElement(PointField::Datatype dt) noexcept { - switch (dt) { - case PointField::Datatype::kInt8: - case PointField::Datatype::kUint8: - return 1; - case PointField::Datatype::kInt16: - case PointField::Datatype::kUint16: - return 2; - case PointField::Datatype::kInt32: - case PointField::Datatype::kUint32: - case PointField::Datatype::kFloat32: - return 4; - case PointField::Datatype::kFloat64: - return 8; - case PointField::Datatype::kUnknown: - return 0; - } - return 0; -} - -/// Packed point cloud. The `data` buffer holds `width * height` points, each -/// occupying `point_step` bytes laid out per `fields`. `is_dense=false` means -/// some points may be invalid (typically NaN-filled). -struct PointCloud { - uint32_t width = 0; - uint32_t height = 1; - uint32_t point_step = 0; ///< Bytes per point. - uint32_t row_step = 0; ///< Bytes per row (= point_step * width when no padding). - bool is_bigendian = false; - bool is_dense = true; - std::vector fields; - Span data; - BufferAnchor anchor; - Timestamp timestamp_ns = 0; -}; - -// ----------------------------------------------------------------------------- -// CanonicalObject — variant carried by parser->parseObject() -// ----------------------------------------------------------------------------- - -/// Sum type of all canonical objects a parser may produce. New alternatives -/// (kMarkers, kOccupancyGrid, …) are appended at the tail and announced via -/// CanonicalObjectKind. Plugins built against an older SDK keep producing -/// the alternatives they know; hosts built against an older SDK that receive -/// an unknown kind reject the message rather than crashing. Forward-compatible -/// — no protocol bump required. -using CanonicalObject = std::variant; - -/// Helper: get the kind tag for a CanonicalObject without unpacking it. -[[nodiscard]] inline CanonicalObjectKind kindOf(const CanonicalObject& obj) noexcept { - return std::visit( - [](const auto& concrete) -> CanonicalObjectKind { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return CanonicalObjectKind::kImage; - } else if constexpr (std::is_same_v) { - return CanonicalObjectKind::kCompressedImage; - } else if constexpr (std::is_same_v) { - return CanonicalObjectKind::kPointCloud; - } else { - return CanonicalObjectKind::kNone; - } - }, - obj); -} - -} // namespace sdk -} // namespace PJ diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index bce4836..5274547 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -20,9 +20,9 @@ #include #include +#include "pj_base/buffer_anchor.hpp" #include "pj_base/data_source_protocol.h" #include "pj_base/expected.hpp" -#include "pj_base/sdk/canonical_object.hpp" #include "pj_base/sdk/plugin_data_api.hpp" namespace PJ { @@ -243,7 +243,7 @@ class DataSourceRuntimeHostView { /// the C ABI boundary at the cost of one alloc-and-move. /// /// The host MUST advertise the push_message_v2 tail slot. We wrap the - /// closure into a PJ_payload_fetcher_t and hand it over verbatim; the + /// closure into a PJ_message_data_fetcher_t and hand it over verbatim; the /// host applies ObjectIngestPolicy and decides when (and whether) to /// invoke it. There is no legacy fallback: a host that doesn't expose /// the slot returns an explicit error here rather than silently @@ -265,9 +265,9 @@ class DataSourceRuntimeHostView { auto* ctx = new FetcherT(std::forward(fetcher)); - PJ_payload_fetcher_t abi_fetcher{ + PJ_message_data_fetcher_t abi_fetcher{ .ctx = ctx, - .fetch = +[](void* c, PJ_payload_t* out, PJ_error_t* err) noexcept -> bool { + .fetchMessageData = +[](void* c, PJ_payload_t* out, PJ_error_t* err) noexcept -> bool { try { auto& fn = *static_cast(c); using Result = std::decay_t; diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 59777ef..a24e51b 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -95,9 +95,9 @@ static_assert(PJ_TOOLBOX_MIN_VTABLE_SIZE == 88, "MIN vtable size is pinned at v4 static_assert(PJ_TOOLBOX_MIN_VTABLE_SIZE <= sizeof(PJ_toolbox_vtable_t), "MIN must never exceed current"); // --- Canonical-object pipeline structs --------------------------------------- -// Public ABI types crossing the boundary for the v4 canonical-object pipeline. +// Public ABI types crossing the boundary for the v4 builtin-object pipeline. // Sizes and offsets are pinned; any change is a deliberate ABI revision. -static_assert(sizeof(PJ_canonical_object_kind_t) == 4, "enum layout pinned"); +static_assert(sizeof(PJ_builtin_object_kind_t) == 4, "enum layout pinned"); static_assert(sizeof(PJ_schema_classification_t) == 4, "PJ_schema_classification_t layout pinned"); static_assert(offsetof(PJ_schema_classification_t, object_kind) == 0, "object_kind at offset 0"); static_assert(offsetof(PJ_schema_classification_t, reserved) == 2, "reserved at offset 2"); @@ -111,10 +111,11 @@ static_assert(offsetof(PJ_payload_t, data) == 0, "data at offset 0"); static_assert(offsetof(PJ_payload_t, size) == 8, "size at offset 8"); static_assert(offsetof(PJ_payload_t, anchor) == 16, "anchor at offset 16"); -static_assert(sizeof(PJ_payload_fetcher_t) == 24, "PJ_payload_fetcher_t pinned (ctx + fetch + release)"); -static_assert(offsetof(PJ_payload_fetcher_t, ctx) == 0, "ctx at offset 0"); -static_assert(offsetof(PJ_payload_fetcher_t, fetch) == 8, "fetch at offset 8"); -static_assert(offsetof(PJ_payload_fetcher_t, release) == 16, "release at offset 16"); +static_assert( + sizeof(PJ_message_data_fetcher_t) == 24, "PJ_message_data_fetcher_t pinned (ctx + fetchMessageData + release)"); +static_assert(offsetof(PJ_message_data_fetcher_t, ctx) == 0, "ctx at offset 0"); +static_assert(offsetof(PJ_message_data_fetcher_t, fetchMessageData) == 8, "fetchMessageData at offset 8"); +static_assert(offsetof(PJ_message_data_fetcher_t, release) == 16, "release at offset 16"); // --- DataSource runtime host vtable (ABI-APPENDABLE within v4) --------------- // The vtable the host exposes to plugins under "pj.runtime.v1". Offsets of diff --git a/pj_base/tests/message_parser_plugin_base_test.cpp b/pj_base/tests/message_parser_plugin_base_test.cpp index 2e5f43d..5ce84cd 100644 --- a/pj_base/tests/message_parser_plugin_base_test.cpp +++ b/pj_base/tests/message_parser_plugin_base_test.cpp @@ -1,4 +1,4 @@ -#include "pj_base/sdk/message_parser_plugin_base.hpp" +#include "pj_plugins/sdk/message_parser_plugin_base.hpp" #include diff --git a/pj_base/tests/object_ingest_policy_test.cpp b/pj_base/tests/object_ingest_policy_test.cpp deleted file mode 100644 index a6c9f68..0000000 --- a/pj_base/tests/object_ingest_policy_test.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include "pj_base/sdk/object_ingest_policy.hpp" - -#include - -using PJ::sdk::CanonicalObjectKind; -using PJ::sdk::ObjectIngestPolicy; -using PJ::sdk::ObjectIngestPolicyResolver; - -TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyObjectsEagerScalars) { - ObjectIngestPolicyResolver r; - EXPECT_EQ( - r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); -} - -TEST(ObjectIngestPolicyResolverTest, SetDefaultIsRespected) { - ObjectIngestPolicyResolver r; - r.setDefault(ObjectIngestPolicy::kEager); - EXPECT_EQ(r.resolve("any_source", "/any/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kEager); -} - -TEST(ObjectIngestPolicyResolverTest, KindOverrideFiresOnMatch) { - ObjectIngestPolicyResolver r; - r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); - - EXPECT_EQ(r.resolve("src", "/lidar/points", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); - // Different kind falls through to default. - EXPECT_EQ(r.resolve("src", "/cam/image", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); -} - -TEST(ObjectIngestPolicyResolverTest, SourceOverridesKind) { - ObjectIngestPolicyResolver r; - r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); - r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); - - // Source matches → kEager beats the kPointCloud kind override. - EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kEager); - // Different source → kind override fires. - EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); -} - -TEST(ObjectIngestPolicyResolverTest, TopicOverridesEverything) { - ObjectIngestPolicyResolver r; - r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); - r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); - r.setForTopic("/diagnostics/lidar", ObjectIngestPolicy::kPureLazy); - - // Topic match wins over source and kind. - EXPECT_EQ( - r.resolve("mcap_source", "/diagnostics/lidar", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); - // Different topic → source override fires. - EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kEager); -} - -TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { - // Mirror the recommended setup: large blobs lazy by default, raw images keep - // their metadata as columns. - ObjectIngestPolicyResolver r; - r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(CanonicalObjectKind::kCompressedImage, ObjectIngestPolicy::kPureLazy); - r.setForKind(CanonicalObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); - - EXPECT_EQ(r.resolve("mcap", "/cam/raw", CanonicalObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); - EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", CanonicalObjectKind::kCompressedImage), ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("mcap", "/lidar", CanonicalObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); - // Scalar-only topic (no canonical) takes the default. - EXPECT_EQ( - r.resolve("mcap", "/diagnostics", CanonicalObjectKind::kNone), ObjectIngestPolicy::kLazyObjectsEagerScalars); -} - -TEST(ObjectIngestPolicyResolverTest, LastWriteWinsForSameKey) { - ObjectIngestPolicyResolver r; - r.setForKind(CanonicalObjectKind::kImage, ObjectIngestPolicy::kEager); - r.setForKind(CanonicalObjectKind::kImage, ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("src", "/topic", CanonicalObjectKind::kImage), ObjectIngestPolicy::kPureLazy); - - r.setForTopic("/x", ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForTopic("/x", ObjectIngestPolicy::kEager); - EXPECT_EQ(r.resolve("src", "/x", CanonicalObjectKind::kImage), ObjectIngestPolicy::kEager); -} diff --git a/pj_base/tests/push_message_v2_test.cpp b/pj_base/tests/push_message_v2_test.cpp index 509650a..558727a 100644 --- a/pj_base/tests/push_message_v2_test.cpp +++ b/pj_base/tests/push_message_v2_test.cpp @@ -18,8 +18,8 @@ #include #include +#include "pj_base/buffer_anchor.hpp" #include "pj_base/data_source_protocol.h" -#include "pj_base/sdk/canonical_object.hpp" #include "pj_base/sdk/data_source_host_views.hpp" namespace { @@ -28,7 +28,7 @@ namespace { struct CapturedPush { PJ_parser_binding_handle_t handle{}; int64_t timestamp_ns = 0; - PJ_payload_fetcher_t fetcher{}; + PJ_message_data_fetcher_t fetcher{}; bool received = false; }; @@ -73,7 +73,7 @@ class MockHost { } static bool pushMessageV2Thunk( - void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, PJ_payload_fetcher_t fetcher, + void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, PJ_message_data_fetcher_t fetcher, PJ_error_t* /*err*/) noexcept { auto* self = static_cast(ctx); self->captured_.handle = handle; @@ -91,11 +91,11 @@ class MockHost { // Helper: invoke a captured fetcher and assert the produced bytes match // the expected content. Releases the payload anchor. -void invokeFetcherAndExpect(PJ_payload_fetcher_t& fetcher, const std::vector& expected) { +void invokeFetcherAndExpect(PJ_message_data_fetcher_t& fetcher, const std::vector& expected) { PJ_payload_t payload{}; PJ_error_t err{}; - ASSERT_NE(fetcher.fetch, nullptr); - ASSERT_TRUE(fetcher.fetch(fetcher.ctx, &payload, &err)); + ASSERT_NE(fetcher.fetchMessageData, nullptr); + ASSERT_TRUE(fetcher.fetchMessageData(fetcher.ctx, &payload, &err)); ASSERT_EQ(payload.size, expected.size()); EXPECT_EQ(0, std::memcmp(payload.data, expected.data(), expected.size())); if (payload.anchor.release) { @@ -183,7 +183,7 @@ TEST(PushMessageV2Test, PayloadAnchorPropagates) { // buffer survives even past the closure's release. PJ_payload_t payload{}; PJ_error_t err{}; - ASSERT_TRUE(host.captured().fetcher.fetch(host.captured().fetcher.ctx, &payload, &err)); + ASSERT_TRUE(host.captured().fetcher.fetchMessageData(host.captured().fetcher.ctx, &payload, &err)); EXPECT_EQ(payload.size, 2U); // Releasing the fetcher (closure dies) does NOT kill the buffer because diff --git a/pj_datastore/CMakeLists.txt b/pj_datastore/CMakeLists.txt index 3463d57..cc7e5ed 100644 --- a/pj_datastore/CMakeLists.txt +++ b/pj_datastore/CMakeLists.txt @@ -60,7 +60,6 @@ if(PJ_BUILD_TESTS) tests/object_store_test.cpp tests/plugin_data_host_object_test.cpp tests/plugin_data_host_object_read_test.cpp - tests/plugin_parser_object_write_test.cpp # tests/plugin_host_read_test.cpp # disabled until Phase 1b lands # (exercises v3 toolbox read path; rewrite for read_series_arrow) ) @@ -72,6 +71,15 @@ if(PJ_BUILD_TESTS) add_test(NAME ${test_name} COMMAND ${test_name}) endforeach() + # plugin_parser_object_write_test uses MessageParserPluginBase → needs + # the plugin SDK (pj_plugin_sdk gives access to message_parser_plugin_base.hpp + # and transitively pulls pj_scene_protocol for the BuiltinObject types). + add_executable(plugin_parser_object_write_test tests/plugin_parser_object_write_test.cpp) + target_link_libraries(plugin_parser_object_write_test PRIVATE + pj_datastore pj_plugin_sdk GTest::gtest_main + ) + add_test(NAME plugin_parser_object_write_test COMMAND plugin_parser_object_write_test) + # Arrow import test (needs nanoarrow explicitly for building test IPC data) add_executable(arrow_import_test tests/arrow_import_test.cpp) target_link_libraries(arrow_import_test PRIVATE diff --git a/pj_datastore/tests/plugin_parser_object_write_test.cpp b/pj_datastore/tests/plugin_parser_object_write_test.cpp index 81f032c..c2f7954 100644 --- a/pj_datastore/tests/plugin_parser_object_write_test.cpp +++ b/pj_datastore/tests/plugin_parser_object_write_test.cpp @@ -11,12 +11,12 @@ #include #include -#include "pj_base/sdk/message_parser_plugin_base.hpp" #include "pj_base/sdk/plugin_data_api.hpp" #include "pj_base/sdk/service_registry.hpp" #include "pj_datastore/engine.hpp" #include "pj_datastore/object_store.hpp" #include "pj_datastore/plugin_data_host.hpp" +#include "pj_plugins/sdk/message_parser_plugin_base.hpp" namespace PJ { namespace { diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index e8f2d68..bd2d5b2 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -1,5 +1,17 @@ add_subdirectory(dialog_protocol) +# --------------------------------------------------------------------------- +# pj_plugin_sdk — INTERFACE library that exposes the C++ SDK headers +# (MessageParserPluginBase, ObjectIngestPolicy, parser trampolines, ...) +# and the dependencies they need (pj_base, pj_scene_protocol). Plugins link +# this to get access to the SDK without having to repeat the include paths +# and the transitive link to pj_scene_protocol's builtin object types. +# --------------------------------------------------------------------------- + +add_library(pj_plugin_sdk INTERFACE) +target_include_directories(pj_plugin_sdk INTERFACE include) +target_link_libraries(pj_plugin_sdk INTERFACE pj_base pj_scene_protocol) + # --------------------------------------------------------------------------- # pj_plugin_catalog — host-side embedded-manifest discovery # --------------------------------------------------------------------------- @@ -100,6 +112,7 @@ target_link_libraries(pj_message_parser_host PUBLIC pj_base pj_dialog_protocol + pj_scene_protocol PRIVATE ${CMAKE_DL_LIBS} ) @@ -113,7 +126,7 @@ if(PJ_BUILD_TESTS) add_library(mock_json_parser_plugin SHARED examples/mock_json_parser.cpp) target_compile_features(mock_json_parser_plugin PRIVATE cxx_std_20) target_compile_options(mock_json_parser_plugin PRIVATE ${PJ_WARNING_FLAGS}) -target_link_libraries(mock_json_parser_plugin PRIVATE pj_base) +target_link_libraries(mock_json_parser_plugin PRIVATE pj_plugin_sdk) # --------------------------------------------------------------------------- # Mock Schema Parser plugin (shared library, high-throughput bound-fields example) @@ -122,7 +135,7 @@ target_link_libraries(mock_json_parser_plugin PRIVATE pj_base) add_library(mock_schema_parser_plugin SHARED examples/mock_schema_parser.cpp) target_compile_features(mock_schema_parser_plugin PRIVATE cxx_std_20) target_compile_options(mock_schema_parser_plugin PRIVATE ${PJ_WARNING_FLAGS}) -target_link_libraries(mock_schema_parser_plugin PRIVATE pj_base) +target_link_libraries(mock_schema_parser_plugin PRIVATE pj_plugin_sdk) endif() # PJ_BUILD_TESTS @@ -222,6 +235,15 @@ target_link_libraries(message_parser_library_test PRIVATE ) add_test(NAME message_parser_library_test COMMAND message_parser_library_test) +# Unit test: ObjectIngestPolicyResolver cascade rules. +add_executable(object_ingest_policy_test tests/object_ingest_policy_test.cpp) +target_compile_options(object_ingest_policy_test PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(object_ingest_policy_test PRIVATE + pj_base pj_scene_protocol GTest::gtest_main +) +target_include_directories(object_ingest_policy_test PRIVATE include) +add_test(NAME object_ingest_policy_test COMMAND object_ingest_policy_test) + # TODO(v3-port): delegated_ingest_integration_test.cpp uses old bindWriteHost / # bindRuntimeHost methods and get_last_error slots removed in v3. Pending port # to the service registry + PJ_error_t* pattern. Its coverage (parser binding + diff --git a/pj_plugins/examples/mock_json_parser.cpp b/pj_plugins/examples/mock_json_parser.cpp index 84e0e60..5de1ee2 100644 --- a/pj_plugins/examples/mock_json_parser.cpp +++ b/pj_plugins/examples/mock_json_parser.cpp @@ -1,7 +1,7 @@ #include #include -#include "pj_base/sdk/message_parser_plugin_base.hpp" +#include "pj_plugins/sdk/message_parser_plugin_base.hpp" namespace { diff --git a/pj_plugins/examples/mock_schema_parser.cpp b/pj_plugins/examples/mock_schema_parser.cpp index 1572f25..88f6dc7 100644 --- a/pj_plugins/examples/mock_schema_parser.cpp +++ b/pj_plugins/examples/mock_schema_parser.cpp @@ -3,7 +3,7 @@ #include #include -#include "pj_base/sdk/message_parser_plugin_base.hpp" +#include "pj_plugins/sdk/message_parser_plugin_base.hpp" namespace { diff --git a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp index f299e6f..46e26c0 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp @@ -4,12 +4,12 @@ */ #pragma once -#include +#include #include +#include #include #include -#include #include #include #include @@ -109,18 +109,18 @@ class MessageParserHandle { /// the plugin doesn't expose classify_schema (older protocol header) /// returns kNone, matching the host contract documented in /// message_parser_protocol.h. - [[nodiscard]] sdk::CanonicalObjectKind classifySchema(std::string_view type_name, Span schema) const { + [[nodiscard]] sdk::BuiltinObjectKind classifySchema(std::string_view type_name, Span schema) const { if (!PJ_HAS_TAIL_SLOT(PJ_message_parser_vtable_t, vt_, classify_schema)) { - return sdk::CanonicalObjectKind::kNone; + return sdk::BuiltinObjectKind::kNone; } PJ_string_view_t tn{type_name.data(), type_name.size()}; PJ_bytes_view_t sc{schema.data(), schema.size()}; PJ_schema_classification_t out{}; PJ_error_t err{}; if (!vt_->classify_schema(ctx_, tn, sc, &out, &err)) { - return sdk::CanonicalObjectKind::kNone; + return sdk::BuiltinObjectKind::kNone; } - return static_cast(out.object_kind); + return static_cast(out.object_kind); } /// Query a plugin-exposed extension by reverse-DNS id. Tail-slot gated. diff --git a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp b/pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp similarity index 98% rename from pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp rename to pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp index 90cd721..fcabb28 100644 --- a/pj_base/include/pj_base/sdk/detail/message_parser_trampolines.hpp +++ b/pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp @@ -128,7 +128,7 @@ inline const void* MessageParserPluginBase::trampoline_get_plugin_extension(void } // ----------------------------------------------------------------------------- -// Pure-functional API trampolines (canonical-object tail of the vtable) +// Pure-functional API trampolines (builtin-object tail of the vtable) // ----------------------------------------------------------------------------- inline bool MessageParserPluginBase::trampoline_classify_schema( diff --git a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp b/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp similarity index 97% rename from pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp rename to pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp index e5ca74e..6e5a834 100644 --- a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp +++ b/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp @@ -23,10 +23,10 @@ #include "pj_base/expected.hpp" #include "pj_base/message_parser_protocol.h" #include "pj_base/plugin_abi_export.h" -#include "pj_base/sdk/canonical_object.hpp" #include "pj_base/sdk/plugin_data_api.hpp" #include "pj_base/sdk/service_registry.hpp" #include "pj_base/sdk/service_traits.hpp" +#include "pj_scene_protocol/builtin/BuiltinObject.h" namespace PJ { namespace sdk { @@ -40,7 +40,7 @@ namespace sdk { /// schemas that produce only scalars, only objects, or that the plugin /// recognizes but routes through the legacy parse() path. struct SchemaHandler { - CanonicalObjectKind object_kind = CanonicalObjectKind::kNone; + BuiltinObjectKind object_kind = BuiltinObjectKind::kNone; /// Scalar route: returns owned column data — no anchor needed because the /// returned vector and any string_views inside it are materialized by the @@ -48,11 +48,11 @@ struct SchemaHandler { std::function>(Timestamp, Span)> parse_scalars; /// Canonical-object route: takes a PayloadView so the parser can return a - /// CanonicalObject whose internal Span(s) reference the same underlying + /// BuiltinObject whose internal Span(s) reference the same underlying /// buffer (zero-copy). The parser propagates `payload.anchor` into the /// returned object so its bytes outlive this call. When the caller passes /// an empty anchor, the parser must materialize whatever it wants to retain. - std::function(Timestamp, PayloadView)> parse_object; + std::function(Timestamp, PayloadView)> parse_object; }; } // namespace sdk @@ -247,8 +247,8 @@ class MessageParserPluginBase { /// `payload.anchor` may be empty; in that case the parser is expected to /// materialize anything it wants to outlive this call. In-process callers /// that already own the payload buffer should pass a non-empty anchor so - /// the parser can return a zero-copy CanonicalObject. - virtual Expected parseObject(Timestamp timestamp_ns, sdk::PayloadView payload) final { + /// the parser can return a zero-copy BuiltinObject. + virtual Expected parseObject(Timestamp timestamp_ns, sdk::PayloadView payload) final { const auto* h = findSchemaHandler(bound_type_name_); if (h == nullptr) { return unexpected(std::string("parser does not register schema: ") + bound_type_name_); @@ -352,7 +352,7 @@ class MessageParserPluginBase { } // namespace PJ -#include "pj_base/sdk/detail/message_parser_trampolines.hpp" +#include "pj_plugins/sdk/detail/message_parser_trampolines.hpp" #define PJ_MESSAGE_PARSER_PLUGIN(ClassName, manifest) \ PJ_EXPORT_PLUGIN_ABI_VERSION(PJ_MESSAGE_PARSER_EXPORT) \ diff --git a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp similarity index 90% rename from pj_base/include/pj_base/sdk/object_ingest_policy.hpp rename to pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp index 7ab5bda..452ea4a 100644 --- a/pj_base/include/pj_base/sdk/object_ingest_policy.hpp +++ b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp @@ -16,7 +16,7 @@ #include #include -#include "pj_base/sdk/canonical_object.hpp" +#include "pj_scene_protocol/builtin/BuiltinObject.h" namespace PJ { namespace sdk { @@ -59,8 +59,8 @@ enum class ObjectIngestPolicy : uint8_t { /// Typical setup: /// /// resolver.setDefault(kLazyObjectsEagerScalars); -/// resolver.setForKind(CanonicalObjectKind::kCompressedImage, kPureLazy); -/// resolver.setForKind(CanonicalObjectKind::kPointCloud, kPureLazy); +/// resolver.setForKind(BuiltinObjectKind::kCompressedImage, kPureLazy); +/// resolver.setForKind(BuiltinObjectKind::kPointCloud, kPureLazy); /// // kImage stays at kLazyObjectsEagerScalars: width/height/encoding columns are useful /// class ObjectIngestPolicyResolver { @@ -72,7 +72,7 @@ class ObjectIngestPolicyResolver { /// Override the default for a specific canonical object kind. Useful when /// (e.g.) all PointCloud2 topics should be lazy regardless of source. - void setForKind(CanonicalObjectKind kind, ObjectIngestPolicy policy) { + void setForKind(BuiltinObjectKind kind, ObjectIngestPolicy policy) { by_kind_[kind] = policy; } @@ -91,7 +91,7 @@ class ObjectIngestPolicyResolver { /// Precedence: topic > source > kind > default. The first match wins — /// no merging or composition between levels. [[nodiscard]] ObjectIngestPolicy resolve( - std::string_view source_id, std::string_view topic_name, CanonicalObjectKind object_kind) const { + std::string_view source_id, std::string_view topic_name, BuiltinObjectKind object_kind) const { if (auto it = by_topic_.find(std::string(topic_name)); it != by_topic_.end()) { return it->second; } @@ -106,7 +106,7 @@ class ObjectIngestPolicyResolver { private: ObjectIngestPolicy default_ = ObjectIngestPolicy::kLazyObjectsEagerScalars; - std::unordered_map by_kind_; + std::unordered_map by_kind_; std::unordered_map by_source_; std::unordered_map by_topic_; }; diff --git a/pj_plugins/tests/object_ingest_policy_test.cpp b/pj_plugins/tests/object_ingest_policy_test.cpp new file mode 100644 index 0000000..e396611 --- /dev/null +++ b/pj_plugins/tests/object_ingest_policy_test.cpp @@ -0,0 +1,83 @@ +#include "pj_plugins/sdk/object_ingest_policy.hpp" + +#include + +using PJ::sdk::BuiltinObjectKind; +using PJ::sdk::ObjectIngestPolicy; +using PJ::sdk::ObjectIngestPolicyResolver; + +TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyObjectsEagerScalars) { + ObjectIngestPolicyResolver r; + EXPECT_EQ( + r.resolve("any_source", "/any/topic", BuiltinObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); +} + +TEST(ObjectIngestPolicyResolverTest, SetDefaultIsRespected) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("any_source", "/any/topic", BuiltinObjectKind::kImage), ObjectIngestPolicy::kEager); +} + +TEST(ObjectIngestPolicyResolverTest, KindOverrideFiresOnMatch) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + + EXPECT_EQ(r.resolve("src", "/lidar/points", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); + // Different kind falls through to default. + EXPECT_EQ(r.resolve("src", "/cam/image", BuiltinObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); +} + +TEST(ObjectIngestPolicyResolverTest, SourceOverridesKind) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); + + // Source matches → kEager beats the kPointCloud kind override. + EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kEager); + // Different source → kind override fires. + EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); +} + +TEST(ObjectIngestPolicyResolverTest, TopicOverridesEverything) { + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); + r.setForTopic("/diagnostics/lidar", ObjectIngestPolicy::kPureLazy); + + // Topic match wins over source and kind. + EXPECT_EQ( + r.resolve("mcap_source", "/diagnostics/lidar", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); + // Different topic → source override fires. + EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kEager); +} + +TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { + // Mirror the recommended setup: large blobs lazy by default, images keep + // their metadata as columns. PointCloud is always pure-lazy; specific + // compressed-image topics can be demoted via per-topic overrides when + // their scalars aren't worth materializing. + ObjectIngestPolicyResolver r; + r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForTopic("/cam/jpeg", ObjectIngestPolicy::kPureLazy); + + EXPECT_EQ(r.resolve("mcap", "/cam/raw", BuiltinObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", BuiltinObjectKind::kImage), ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("mcap", "/lidar", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); + // Scalar-only topic (no builtin object) takes the default. + EXPECT_EQ(r.resolve("mcap", "/diagnostics", BuiltinObjectKind::kNone), ObjectIngestPolicy::kLazyObjectsEagerScalars); +} + +TEST(ObjectIngestPolicyResolverTest, LastWriteWinsForSameKey) { + ObjectIngestPolicyResolver r; + r.setForKind(BuiltinObjectKind::kImage, ObjectIngestPolicy::kEager); + r.setForKind(BuiltinObjectKind::kImage, ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("src", "/topic", BuiltinObjectKind::kImage), ObjectIngestPolicy::kPureLazy); + + r.setForTopic("/x", ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.setForTopic("/x", ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("src", "/x", BuiltinObjectKind::kImage), ObjectIngestPolicy::kEager); +} diff --git a/pj_scene_protocol/CMakeLists.txt b/pj_scene_protocol/CMakeLists.txt index 85a1275..df705d8 100644 --- a/pj_scene_protocol/CMakeLists.txt +++ b/pj_scene_protocol/CMakeLists.txt @@ -2,9 +2,12 @@ # pj_scene_protocol — schema + canonical wire codec (writer + reader) for # foxglove.ImageAnnotations and forthcoming scene primitive types. SDK # boundary for plugin authors that produce or consume markers / scene data. -# Depends on pj_base only. +# Depends on pj_base and magic_enum (header-only, for the builtin/ +# vocabulary reflection helpers). # --------------------------------------------------------------------------- +find_package(magic_enum CONFIG REQUIRED) + add_library(pj_scene_protocol STATIC src/image_annotation_codec.cpp src/scene_decoder.cpp @@ -15,7 +18,7 @@ target_include_directories(pj_scene_protocol PUBLIC $ ) target_compile_options(pj_scene_protocol PRIVATE ${PJ_WARNING_FLAGS}) -target_link_libraries(pj_scene_protocol PUBLIC pj_base) +target_link_libraries(pj_scene_protocol PUBLIC pj_base magic_enum::magic_enum) set_target_properties(pj_scene_protocol PROPERTIES POSITION_INDEPENDENT_CODE ON ) diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h new file mode 100644 index 0000000..476bd48 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h @@ -0,0 +1,48 @@ +/** + * @file BuiltinObject.h + * @brief Sum type of all builtin objects a MessageParser may produce. + * + * New alternatives (kDepthImage, kMarkers, kOccupancyGrid, …) are appended + * at the tail and announced via BuiltinObjectKind. Plugins built against + * an older SDK keep producing the alternatives they know; hosts built + * against an older SDK that receive an unknown kind reject the message + * rather than crashing. Forward-compatible — no protocol bump required. + */ +#pragma once + +#include +#include + +#include "pj_scene_protocol/builtin/BuiltinObjectKind.h" +#include "pj_scene_protocol/builtin/DepthImage.h" +#include "pj_scene_protocol/builtin/Image.h" +#include "pj_scene_protocol/builtin/ImageAnnotations.h" +#include "pj_scene_protocol/builtin/PointCloud.h" + +namespace PJ { +namespace sdk { + +using BuiltinObject = std::variant; + +/// Get the kind tag for a BuiltinObject without unpacking it. +[[nodiscard]] inline BuiltinObjectKind kindOf(const BuiltinObject& obj) noexcept { + return std::visit( + [](const auto& concrete) -> BuiltinObjectKind { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return BuiltinObjectKind::kImage; + } else if constexpr (std::is_same_v) { + return BuiltinObjectKind::kPointCloud; + } else if constexpr (std::is_same_v) { + return BuiltinObjectKind::kDepthImage; + } else if constexpr (std::is_same_v) { + return BuiltinObjectKind::kImageAnnotations; + } else { + return BuiltinObjectKind::kNone; + } + }, + obj); +} + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObjectKind.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObjectKind.h new file mode 100644 index 0000000..bb32c4d --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObjectKind.h @@ -0,0 +1,54 @@ +/** + * @file BuiltinObjectKind.h + * @brief A-priori tag identifying which builtin object a parser produces + * for a given schema. + * + * The kind is returned by MessageParser::classifySchema() without parsing + * any payload — the host uses it to decide whether to register an + * ObjectTopic and how to route the message. + * + * Numeric values are stable across releases; mirror of the C ABI enum + * PJ_builtin_object_kind_t in pj_base/builtin_object_abi.h. + */ +#pragma once + +#include +#include +#include +#include + +namespace PJ { +namespace sdk { + +enum class BuiltinObjectKind : uint16_t { + kNone = 0, + kImage = 1, ///< sdk::Image — raw or compressed, distinguished by encoding string. + kPointCloud = 3, ///< sdk::PointCloud — packed points + per-channel field layout. + kDepthImage = 4, ///< sdk::DepthImage — depth pixels + camera intrinsics. + kImageAnnotations = 5, ///< sdk::ImageAnnotations — 2D overlays (points, lines, text). + // Reserved for future kinds; keep numeric values stable across releases. + // kOccupancyGrid = 6, +}; + +/// A-priori classification of a schema. Currently carries only the kind; +/// the struct (instead of a raw enum) leaves room to attach declarative +/// metadata later without breaking the API. +struct SchemaClassification { + BuiltinObjectKind object_kind = BuiltinObjectKind::kNone; +}; + +/// Canonical string for a kind value (without the leading `k`). Uses +/// magic_enum for reflection. e.g. name(kImage) == "kImage" — callers +/// that want the bare token strip the prefix. +[[nodiscard]] inline constexpr std::string_view name(BuiltinObjectKind kind) noexcept { + return magic_enum::enum_name(kind); +} + +/// Parse a kind name into the enum. Accepts the same strings name() +/// emits (e.g. "kImage"). Returns nullopt for unknown names. +[[nodiscard]] inline constexpr std::optional parseBuiltinObjectKind(std::string_view s) noexcept { + return magic_enum::enum_cast(s); +} + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/CommonImageEncoding.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/CommonImageEncoding.h new file mode 100644 index 0000000..40134b3 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/CommonImageEncoding.h @@ -0,0 +1,60 @@ +/** + * @file CommonImageEncoding.h + * @brief Optional vocabulary of canonical image encoding strings. + * + * sdk::Image::encoding is an open std::string — producers and consumers + * are not required to use the values listed here. This header documents + * the encodings the in-tree producers (parser_ros, future plugins) emit + * and the consumers (pj_scene2D) recognise, plus a magic_enum-backed + * round-trip helper. + * + * The enumerator NAMES are the canonical strings (so + * magic_enum::enum_name(rgb8) == "rgb8"). Use parse() to convert a + * string from the wire into the enum, and name() to produce a string + * to emit. + */ +#pragma once + +#include +#include +#include +#include + +namespace PJ { +namespace sdk { + +// NOLINTBEGIN(readability-identifier-naming) +// Enumerator names are deliberately the canonical encoding strings, not +// the project's kCamelCase constants. +enum class CommonImageEncoding : uint8_t { + // Raw pixel layouts + rgb8, ///< 3 bytes/pixel, R-G-B order. + rgba8, ///< 4 bytes/pixel, R-G-B-A order. + bgr8, ///< 3 bytes/pixel, B-G-R order. + bgra8, ///< 4 bytes/pixel, B-G-R-A order. + mono8, ///< 1 byte/pixel, grayscale. + mono16, ///< 2 bytes/pixel, grayscale. + // Compressed wire formats + jpeg, + png, + qoi, + // ROS-specific + compressedDepth, ///< PNG payload + depth quantization range in extras. +}; +// NOLINTEND(readability-identifier-naming) + +/// Canonical string for an encoding value. Same as +/// magic_enum::enum_name(e) but kept as a one-liner for callers that +/// don't want to know about magic_enum. +[[nodiscard]] inline constexpr std::string_view name(CommonImageEncoding e) noexcept { + return magic_enum::enum_name(e); +} + +/// Parse an encoding string into the enum. Returns nullopt if the +/// string isn't one of the documented vocabulary entries. +[[nodiscard]] inline constexpr std::optional parseImageEncoding(std::string_view s) noexcept { + return magic_enum::enum_cast(s); +} + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h new file mode 100644 index 0000000..3238a31 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h @@ -0,0 +1,72 @@ +/** + * @file DepthImage.h + * @brief Depth image with camera intrinsics. + */ +#pragma once + +#include +#include +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// Depth image. The `encoding` string carries the depth representation +/// (e.g. "16UC1" = millimeters as uint16, "32FC1" = meters as float). +/// +/// Intrinsics: `K` is the 3×3 row-major intrinsic camera matrix. +/// +/// K = [ fx 0 cx ] +/// [ 0 fy cy ] +/// [ 0 0 1 ] +/// +/// Back-projection of pixel (u, v) with depth value z: +/// +/// X = (u - cx) * z / fx +/// Y = (v - cy) * z / fy +/// Z = z +/// +/// Distortion: when `distortion_model` is non-empty, `D` carries the +/// distortion coefficients for that model ("plumb_bob": 5 coeffs +/// k1, k2, p1, p2, k3; "equidistant": 4 coeffs k1, k2, k3, k4). An empty +/// distortion_model means the image is rectified and `D` is unused. +/// +/// Derived matrices NOT stored on the struct (caller computes from K): +/// +/// R: rectification rotation. Identity for rectified images. +/// P: projection matrix [K | 0_3] (3×4) for rectified images. +/// +/// Helpers in pj_scene_protocol/builtin/depth_image_utils.h produce R +/// and P when a consumer wants them precomputed. +/// +/// `anchor` keeps the underlying buffer alive — the producer may have +/// made `data` a view into the source payload (zero-copy) or into a +/// freshly allocated vector (when the wire format required conversion); +/// consumers don't need to know which. +struct DepthImage { + uint32_t width = 0; + uint32_t height = 0; + std::string encoding; ///< typically "16UC1" (mm depth) or "32FC1" (meters). + Span data; + BufferAnchor anchor; + + /// 3×3 row-major intrinsic camera matrix (K). + std::array K{}; + + /// Distortion model identifier; empty means rectified (D unused). + std::string distortion_model; + + /// Distortion coefficients per `distortion_model`. Size depends on + /// the model (5 for plumb_bob, 4 for equidistant, …). + std::vector D; + + Timestamp timestamp_ns = 0; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h new file mode 100644 index 0000000..b18111f --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h @@ -0,0 +1,65 @@ +/** + * @file Image.h + * @brief Image built-in object: raw or compressed, identified by an + * encoding string. + */ +#pragma once + +#include +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// Image. The `encoding` string distinguishes raw pixel layouts from +/// compressed wire formats; the producer decides which. +/// +/// - Raw encodings: "rgb8", "rgba8", "bgr8", "bgra8", "mono8", "mono16". +/// `data` is `row_step * height` bytes laid out per the encoding. +/// `row_step` may exceed `width * bytes_per_pixel(encoding)` when the +/// wire format includes per-row padding; consumers must honor it. +/// `is_bigendian` is meaningful only for mono16 (and any future +/// multi-byte raw encoding). +/// +/// - Compressed encodings: "jpeg", "png", "qoi". `data` carries the +/// compressed payload; consumers run the appropriate codec to obtain +/// decoded pixels. `row_step` and `is_bigendian` are unused. +/// +/// - Compressed depth: "compressedDepth" (ROS-style). `data` carries a +/// PNG payload that decodes to grayscale; `compressed_depth_min` and +/// `compressed_depth_max` carry the quantization range needed to map +/// the grayscale back to depth values. +/// +/// See pj_scene_protocol/builtin/CommonImageEncoding.h for the documented +/// vocabulary of canonical encoding strings, with helpers to parse and +/// emit them. +/// +/// `anchor` keeps the underlying buffer alive — the producer may have made +/// `data` a view into the source payload (zero-copy) or into a freshly +/// allocated vector (when the wire format required conversion); consumers +/// don't need to know which. +struct Image { + uint32_t width = 0; + uint32_t height = 0; + std::string encoding; ///< raw or compressed; see vocabulary above. + uint32_t row_step = 0; ///< raw encodings only; 0 for compressed. + bool is_bigendian = false; ///< mono16 only. + Span data; + BufferAnchor anchor; + + /// ROS compressedDepth metadata: depth-quantization range used after + /// PNG decoding to map grayscale back to depth values. Both nullopt for + /// regular images. + std::optional compressed_depth_min; + std::optional compressed_depth_max; + + Timestamp timestamp_ns = 0; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h new file mode 100644 index 0000000..940b46d --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h @@ -0,0 +1,28 @@ +/** + * @file ImageAnnotations.h + * @brief Vector primitives (points, lines, circles, text) overlaid on a + * specific image at a specific timestamp. + * + * ImageAnnotations is the 2D overlay builtin. Unlike Image / DepthImage / + * PointCloud — which carry potentially-megabyte buffers and use the + * BufferAnchor zero-copy pattern — annotation data is small (a few + * hundred bytes typically) so the type owns its contents via std::vector + * outright. Eager ingestion is the natural default; no anchor lifetime + * concerns to worry about. + * + * The concrete type lives in pj_scene_protocol/image_annotation.h + * (PJ::ImageAnnotation) for historical reasons. This header re-exposes it + * as PJ::sdk::ImageAnnotations so it sits next to the other builtin + * objects in the same namespace. + */ +#pragma once + +#include "pj_scene_protocol/image_annotation.h" + +namespace PJ { +namespace sdk { + +using ImageAnnotations = ::PJ::ImageAnnotation; + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/PointCloud.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/PointCloud.h new file mode 100644 index 0000000..2520916 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/PointCloud.h @@ -0,0 +1,78 @@ +/** + * @file PointCloud.h + * @brief Packed point cloud with per-channel field layout. + */ +#pragma once + +#include +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// Description of one channel inside a packed point cloud (x, y, z, intensity, +/// rgb, ring, time, …). Mirrors the shape of sensor_msgs/PointField but the +/// type is canonical PJ vocabulary, not a ROS-specific enum. +struct PointField { + enum class Datatype : uint8_t { + kUnknown = 0, + kInt8 = 1, + kUint8 = 2, + kInt16 = 3, + kUint16 = 4, + kInt32 = 5, + kUint32 = 6, + kFloat32 = 7, + kFloat64 = 8, + }; + + std::string name; + uint32_t offset = 0; ///< Byte offset of this field within a single point. + Datatype datatype = Datatype::kUnknown; + uint32_t count = 1; ///< Number of elements of `datatype` (typically 1). +}; + +/// Bytes per element for a given PointField datatype. Returns 0 for kUnknown. +[[nodiscard]] constexpr uint32_t bytesPerElement(PointField::Datatype dt) noexcept { + switch (dt) { + case PointField::Datatype::kInt8: + case PointField::Datatype::kUint8: + return 1; + case PointField::Datatype::kInt16: + case PointField::Datatype::kUint16: + return 2; + case PointField::Datatype::kInt32: + case PointField::Datatype::kUint32: + case PointField::Datatype::kFloat32: + return 4; + case PointField::Datatype::kFloat64: + return 8; + case PointField::Datatype::kUnknown: + return 0; + } + return 0; +} + +/// Packed point cloud. The `data` buffer holds `width * height` points, each +/// occupying `point_step` bytes laid out per `fields`. `is_dense=false` means +/// some points may be invalid (typically NaN-filled). +struct PointCloud { + uint32_t width = 0; + uint32_t height = 1; + uint32_t point_step = 0; ///< Bytes per point. + uint32_t row_step = 0; ///< Bytes per row (= point_step * width when no padding). + bool is_bigendian = false; + bool is_dense = true; + std::vector fields; + Span data; + BufferAnchor anchor; + Timestamp timestamp_ns = 0; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h new file mode 100644 index 0000000..727f580 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h @@ -0,0 +1,50 @@ +/** + * @file depth_image_utils.h + * @brief Free-function helpers that derive conventional matrices (R, P) + * from sdk::DepthImage's intrinsics. + * + * The DepthImage struct stores K and the distortion description only; + * R (rectification rotation) and P (3×4 projection matrix) are derivable + * from those when the image is rectified. Consumers that prefer to read + * R/P pre-built call these helpers; consumers that go directly to K + * ignore this header. + */ +#pragma once + +#include + +#include "pj_scene_protocol/builtin/DepthImage.h" + +namespace PJ { +namespace sdk { + +/// Rectification rotation. For a DepthImage with empty distortion_model +/// (i.e. rectified) this returns the identity rotation. Unrectified +/// depth has no canonical rectification rotation — the caller has the +/// external knowledge — so the same identity is returned as a sensible +/// default; treat the result as meaningful only when distortion_model +/// is empty. +[[nodiscard]] inline std::array rectificationRotation(const DepthImage& /*img*/) noexcept { + return {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; +} + +/// 3×4 row-major projection matrix derived from K. Equals [K | 0_3]: +/// +/// P = [ fx 0 cx 0 ] +/// [ 0 fy cy 0 ] +/// [ 0 0 1 0 ] +/// +/// Meaningful when the image is rectified (distortion_model empty); +/// otherwise it represents the projection without the rectification +/// step the caller would need to apply separately. +[[nodiscard]] inline std::array projectionMatrix(const DepthImage& img) noexcept { + const auto& k = img.K; + return { + k[0], k[1], k[2], 0.0, // + k[3], k[4], k[5], 0.0, // + k[6], k[7], k[8], 0.0, + }; +} + +} // namespace sdk +} // namespace PJ From d324c5986a2f9efeb0890ea4efa2b9774f76b667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 14 May 2026 15:28:44 +0200 Subject: [PATCH 10/18] refactor(scene_protocol): ImageAnnotations as first-class builtin Promote ImageAnnotations from a typedef alias into a fully-defined SDK builtin, aligned with the shape of Image / DepthImage / PointCloud. - Define struct PJ::sdk::ImageAnnotations and its companions (Point2, ColorRGBA, AnnotationTopology, PointsAnnotation, CircleAnnotation, TextAnnotation) directly in pj_scene_protocol/builtin/ImageAnnotations.h. - Delete the legacy pj_scene_protocol/image_annotation.h. - Move SceneFrame (codec output wrapper) into a dedicated pj_scene_protocol/scene_frame.h, now wrapping std::vector. - Codec: serializeImageAnnotation -> serializeImageAnnotations, signature switched to PJ::sdk::ImageAnnotations. - Update consumers (codec impl, decoder, tests, docs). Build green; 69/69 PJ4 tests pass. --- pj_scene_protocol/docs/ARCHITECTURE.md | 14 +-- pj_scene_protocol/docs/USER_GUIDE.md | 26 ++--- .../builtin/ImageAnnotations.h | 85 ++++++++++++++-- .../pj_scene_protocol/image_annotation.h | 99 ------------------- .../image_annotation_codec.h | 8 +- .../include/pj_scene_protocol/scene_decoder.h | 4 +- .../include/pj_scene_protocol/scene_frame.h | 23 +++++ .../src/image_annotation_codec.cpp | 10 +- .../src/scene_decoder_protobuf.cpp | 14 ++- .../tests/image_annotation_codec_test.cpp | 42 ++++---- .../tests/scene_decoder_test.cpp | 10 +- 11 files changed, 181 insertions(+), 154 deletions(-) delete mode 100644 pj_scene_protocol/include/pj_scene_protocol/image_annotation.h create mode 100644 pj_scene_protocol/include/pj_scene_protocol/scene_frame.h diff --git a/pj_scene_protocol/docs/ARCHITECTURE.md b/pj_scene_protocol/docs/ARCHITECTURE.md index 5a7fc4b..9030ee7 100644 --- a/pj_scene_protocol/docs/ARCHITECTURE.md +++ b/pj_scene_protocol/docs/ARCHITECTURE.md @@ -7,12 +7,12 @@ Today the module covers 2D image annotations (points, lines, polygons, circles, text). It is named for forthcoming scope: 3D scene primitives (arrows, cubes, lines, meshes, text) are documented as the next addition, and the type system is laid out to accommodate them next to the 2D types without breaking existing wire bytes. **In scope:** -- Schema (vocabulary types — `Point2`, `ColorRGBA`, `ImageAnnotation`, `SceneFrame`, …). +- Schema (vocabulary types — `Point2`, `ColorRGBA`, `sdk::ImageAnnotations`, `SceneFrame`, …). - A canonical wire format (`foxglove.ImageAnnotations` Protobuf) and a hand-rolled writer + reader for it. - The schema-name string constant that producers stamp on stored topics. **Out of scope (deliberately):** -- Per-source-format conversion. Translating from CDR `vision_msgs/Detection2DArray`, YOLO message types, CSV, RLDS, etc. into `ImageAnnotation` happens **loader-side**, never inside this module. PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}` are reference adapters. +- Per-source-format conversion. Translating from CDR `vision_msgs/Detection2DArray`, YOLO message types, CSV, RLDS, etc. into `sdk::ImageAnnotations` happens **loader-side**, never inside this module. PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}` are reference adapters. - Storage / time-anchoring of scene frames (lives in PJ4's `pj_media/core/ScenePipelineSource` + `ObjectStore` from `pj_datastore`). - Rendering (lives in PJ4's `pj_media/qt/MediaViewerWidget`). @@ -20,7 +20,7 @@ This split keeps `pj_scene_protocol` linkable by a streaming-source plugin or a ## Type catalog -All types are POD-shaped, default-constructible, and compare with `operator==`. They live in `pj_scene_protocol/image_annotation.h`. +All types are POD-shaped, default-constructible, and compare with `operator==`. They live in `pj_scene_protocol/scene_frame.h`. | Type | Purpose | |---|---| @@ -30,8 +30,8 @@ All types are POD-shaped, default-constructible, and compare with `operator==`. | `PointsAnnotation` | Vertices + topology + uniform `color` + optional per-vertex `colors` + `fill_color` (for `kLineLoop`) + `thickness`. | | `CircleAnnotation` | `center` + `radius` (the wire format carries diameter; see below) + `thickness` + outline `color` + `fill_color`. | | `TextAnnotation` | Anchor `position`, `text`, `font_size`, `color`. | -| `ImageAnnotation` | Bag of `points` + `circles` + `texts` for one image at one timestamp; refers to its base image via `image_topic`. | -| `SceneFrame` | Top-level decoder output. Wraps `vector`; future expansion will add 3D primitives, grids, etc. as sibling fields. | +| `sdk::ImageAnnotations` | Bag of `points` + `circles` + `texts` for one image at one timestamp; refers to its base image via `image_topic`. | +| `SceneFrame` | Top-level decoder output. Wraps `vector`; future expansion will add 3D primitives, grids, etc. as sibling fields. | ## Wire format @@ -73,7 +73,7 @@ The wire types used are `VARINT(0)`, `I64(1)`, and `LEN(2)`. `I32(5)` is unused - **Color quantization is lossy.** `ColorRGBA` stores `uint8 [0, 255]`; the wire stores `double [0, 1]`. The writer divides by 255.0; the reader multiplies. A round-trip can drift up to 1 LSB on each channel. Tests assert with 1-LSB tolerance. - **`CircleAnnotation::radius` ↔ wire `diameter`.** The writer emits `radius * 2`; the reader halves on read. The C++ surface always exposes radius. - **Empty `colors` is preserved.** A `PointsAnnotation` with `colors.empty()` emits zero field-5 entries. Emitting a default `Color` for an empty vector would smuggle a phantom entry into the reader, breaking per-vertex coloring semantics. There is a regression test (`EmptyColorsVectorDoesNotInjectDefaultEntry`). -- **`ImageAnnotation::timestamp` and `::image_topic` do not cross the wire.** Those fields belong to the surrounding transport (the timestamp arrives via `ObjectStore`'s push; the topic identity is the topic). They are populated on read by the consumer pipeline, not by the codec. +- **`sdk::ImageAnnotations::timestamp` and `::image_topic` do not cross the wire.** Those fields belong to the surrounding transport (the timestamp arrives via `ObjectStore`'s push; the topic identity is the topic). They are populated on read by the consumer pipeline, not by the codec. - **`TextAnnotation::background_color` is intentionally absent from the C++ struct.** The wire format defines field 6, but the schema struct has no equivalent. The writer never emits it; the reader skips it. ## API surface @@ -83,7 +83,7 @@ The wire types used are `VARINT(0)`, `I64(1)`, and `LEN(2)`. `I32(5)` is unused inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; // Producer side. -[[nodiscard]] std::vector serializeImageAnnotation(const ImageAnnotation& ia); +[[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); // Consumer side. class ISceneDecoder { diff --git a/pj_scene_protocol/docs/USER_GUIDE.md b/pj_scene_protocol/docs/USER_GUIDE.md index 760f6b2..18aa9c3 100644 --- a/pj_scene_protocol/docs/USER_GUIDE.md +++ b/pj_scene_protocol/docs/USER_GUIDE.md @@ -5,7 +5,7 @@ How to produce or consume marker / scene data over PlotJuggler's canonical wire The module exposes one schema header and one codec header: ```cpp -#include "pj_scene_protocol/image_annotation.h" // value types +#include "pj_scene_protocol/scene_frame.h" // value types #include "pj_scene_protocol/image_annotation_codec.h" // writer + schema name #include "pj_scene_protocol/scene_decoder.h" // reader (consumers only) ``` @@ -18,14 +18,14 @@ For the wire format reference, type catalog, and design rationale, see `ARCHITEC ## 1. Producer recipe (loader / data source) -A loader fills an `ImageAnnotation` from its source format and serializes to canonical bytes before pushing into the host's data store. +A loader fills an `sdk::ImageAnnotations` from its source format and serializes to canonical bytes before pushing into the host's data store. ```cpp -#include "pj_scene_protocol/image_annotation.h" +#include "pj_scene_protocol/scene_frame.h" #include "pj_scene_protocol/image_annotation_codec.h" -PJ::ImageAnnotation buildAnnotation(const Detection& det) { - PJ::ImageAnnotation ia; +PJ::sdk::ImageAnnotations buildAnnotation(const Detection& det) { + PJ::sdk::ImageAnnotations ia; // Bounding box as a 4-point line loop. PJ::PointsAnnotation rect; @@ -50,7 +50,7 @@ PJ::ImageAnnotation buildAnnotation(const Detection& det) { } // In your loader's per-message callback: -auto bytes = PJ::serializeImageAnnotation(buildAnnotation(detection)); +auto bytes = PJ::serializeImageAnnotations(buildAnnotation(detection)); host.pushObject(topic_id, ts_ns, bytes.data(), bytes.size()); ``` @@ -86,7 +86,7 @@ if (!result.has_value()) { } const PJ::SceneFrame& sf = *result; -for (const PJ::ImageAnnotation& ia : sf.annotations) { +for (const PJ::sdk::ImageAnnotations& ia : sf.annotations) { for (const auto& pa : ia.points) { renderPoints(pa); } for (const auto& ca : ia.circles) { renderCircle(ca); } for (const auto& ta : ia.texts) { renderText(ta); } @@ -107,22 +107,22 @@ The decoder is stateless — keep one per layer for the layer's lifetime, or bui **`fill_color` only fires for `kLineLoop`.** Other topologies ignore `fill_color`. Setting an alpha-zero default fill is the convention for "no fill." -**Non-serialized fields.** `ImageAnnotation::timestamp` and `::image_topic` are populated by the consumer pipeline (timestamp comes from the store push; topic identity from the topic id). The codec does not round-trip them — equality on a freshly decoded annotation will see those fields as zero / empty. This is intentional; see `ARCHITECTURE.md §Wire format / Encoding rules`. +**Non-serialized fields.** `sdk::ImageAnnotations::timestamp` and `::image_topic` are populated by the consumer pipeline (timestamp comes from the store push; topic identity from the topic id). The codec does not round-trip them — equality on a freshly decoded annotation will see those fields as zero / empty. This is intentional; see `ARCHITECTURE.md §Wire format / Encoding rules`. **`CircleAnnotation::radius`, not diameter.** The C++ surface is radius. The wire carries diameter. Don't double the value yourself when constructing. -**Empty annotations.** `serializeImageAnnotation()` on an `ImageAnnotation` with no primitives produces zero bytes. Pushing zero bytes is a valid "no overlays at this timestamp" signal; the decoder handles a non-empty buffer or returns an empty `SceneFrame`. Sending an empty buffer through `decode()` returns an error — guard the producer side or skip the push. +**Empty annotations.** `serializeImageAnnotations()` on an `sdk::ImageAnnotations` with no primitives produces zero bytes. Pushing zero bytes is a valid "no overlays at this timestamp" signal; the decoder handles a non-empty buffer or returns an empty `SceneFrame`. Sending an empty buffer through `decode()` returns an error — guard the producer side or skip the push. --- ## 4. Translating from a custom message format -Per-source-format conversion is intentionally outside this module. A loader that reads, say, ROS 2 `vision_msgs/msg/Detection2DArray` is responsible for translating into `ImageAnnotation` itself. +Per-source-format conversion is intentionally outside this module. A loader that reads, say, ROS 2 `vision_msgs/msg/Detection2DArray` is responsible for translating into `sdk::ImageAnnotations` itself. For a working reference, see PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}`: -- `cdr_detection2d_to_image_annotation` — `vision_msgs/msg/Detection2DArray` → `ImageAnnotation`. Maps the first hypothesis's `class_id` to a stable palette colour and emits a `" "` text label above each bbox. -- `cdr_yolo_to_image_annotation` — `yolo_msgs/msg/DetectionArray` → `ImageAnnotation`. Same pattern, uses `class_name` for the label. +- `cdr_detection2d_to_image_annotation` — `vision_msgs/msg/Detection2DArray` → `sdk::ImageAnnotations`. Maps the first hypothesis's `class_id` to a stable palette colour and emits a `" "` text label above each bbox. +- `cdr_yolo_to_image_annotation` — `yolo_msgs/msg/DetectionArray` → `sdk::ImageAnnotations`. Same pattern, uses `class_name` for the label. - `marker_palette` — FNV-1a class-id → `ColorRGBA` palette and label-string formatter. Reuse-friendly. -These adapters live in PJ4 because they consume PJ4-side fixtures (MCAP demo). The pattern transfers to any plugin: read your message, fill an `ImageAnnotation`, serialize with `serializeImageAnnotation()`. +These adapters live in PJ4 because they consume PJ4-side fixtures (MCAP demo). The pattern transfers to any plugin: read your message, fill an `sdk::ImageAnnotations`, serialize with `serializeImageAnnotations()`. diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h index 940b46d..5a47a27 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h @@ -9,20 +9,91 @@ * hundred bytes typically) so the type owns its contents via std::vector * outright. Eager ingestion is the natural default; no anchor lifetime * concerns to worry about. - * - * The concrete type lives in pj_scene_protocol/image_annotation.h - * (PJ::ImageAnnotation) for historical reasons. This header re-exposes it - * as PJ::sdk::ImageAnnotations so it sits next to the other builtin - * objects in the same namespace. */ #pragma once -#include "pj_scene_protocol/image_annotation.h" +#include +#include +#include + +#include "pj_base/types.hpp" namespace PJ { namespace sdk { -using ImageAnnotations = ::PJ::ImageAnnotation; +/// Vertex topology for vector annotations. +enum class AnnotationTopology : uint8_t { + kPoints, ///< Each point is independent. + kLineList, ///< Consecutive pairs form segments (0-1, 2-3, ...). + kLineStrip, ///< Connected polyline 0-1, 1-2, ..., n-1-n. + kLineLoop, ///< Like LineStrip but closes back to the first point. 4-point loop = rectangle. +}; + +/// 2D point in image-pixel coordinates (origin top-left). +struct Point2 { + double x = 0.0; + double y = 0.0; + bool operator==(const Point2&) const = default; +}; + +/// 8-bit per-channel RGBA color. a=0 means transparent / disabled. +struct ColorRGBA { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 255; + bool operator==(const ColorRGBA&) const = default; +}; + +/// Vector primitive (points, lines, polygons) over an image's pixel space. +/// +/// Color semantics: if `colors` is empty, the uniform `color` applies to all +/// vertices. If `colors.size() == points.size()`, per-vertex coloring is used. +/// Anything in between is implementation-defined (renderers may splat-last). +struct PointsAnnotation { + AnnotationTopology topology = AnnotationTopology::kPoints; + std::vector points; + double thickness = 2.0; + ColorRGBA color = {0, 255, 0, 255}; + std::vector colors; + ColorRGBA fill_color = {0, 0, 0, 0}; ///< a=0 means no fill (LineLoop only). + bool operator==(const PointsAnnotation&) const = default; +}; + +/// Filled or stroked circle in image-pixel space. +struct CircleAnnotation { + Point2 center; + double radius = 1.0; + double thickness = 2.0; + ColorRGBA color = {0, 255, 0, 255}; + ColorRGBA fill_color = {0, 0, 0, 0}; + bool operator==(const CircleAnnotation&) const = default; +}; + +/// Text label anchored at a pixel position. +struct TextAnnotation { + Point2 position; + double font_size = 14.0; + ColorRGBA color = {255, 255, 255, 255}; + std::string text; + bool operator==(const TextAnnotation&) const = default; +}; + +/// All annotations for one image at one timestamp. References its base image +/// explicitly via `image_topic` so the renderer knows which frame to overlay. +struct ImageAnnotations { + Timestamp timestamp = 0; + std::string image_topic; + std::vector points; + std::vector circles; + std::vector texts; + bool operator==(const ImageAnnotations&) const = default; + + /// True if no primitives are present. + [[nodiscard]] bool empty() const noexcept { + return points.empty() && circles.empty() && texts.empty(); + } +}; } // namespace sdk } // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/image_annotation.h b/pj_scene_protocol/include/pj_scene_protocol/image_annotation.h deleted file mode 100644 index 39d1da1..0000000 --- a/pj_scene_protocol/include/pj_scene_protocol/image_annotation.h +++ /dev/null @@ -1,99 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "pj_base/types.hpp" - -namespace PJ { - -/// Vertex topology for vector annotations. See `docs/ARCHITECTURE.md` -/// for the full type catalog and wire format spec. -enum class AnnotationTopology : uint8_t { - kPoints, ///< Each point is independent. - kLineList, ///< Consecutive pairs form segments (0-1, 2-3, ...). - kLineStrip, ///< Connected polyline 0-1, 1-2, ..., n-1-n. - kLineLoop, ///< Like LineStrip but closes back to the first point. 4-point loop = rectangle. -}; - -/// 2D point in image-pixel coordinates (origin top-left). -struct Point2 { - double x = 0.0; - double y = 0.0; - bool operator==(const Point2&) const = default; -}; - -/// 8-bit per-channel RGBA color. a=0 means transparent / disabled. -struct ColorRGBA { - uint8_t r = 0; - uint8_t g = 0; - uint8_t b = 0; - uint8_t a = 255; - bool operator==(const ColorRGBA&) const = default; -}; - -/// Vector primitive (points, lines, polygons) over an image's pixel space. -/// -/// Color semantics: if `colors` is empty, the uniform `color` applies to all -/// vertices. If `colors.size() == points.size()`, per-vertex coloring is used. -/// Anything in between is implementation-defined (renderers may splat-last). -struct PointsAnnotation { - AnnotationTopology topology = AnnotationTopology::kPoints; - std::vector points; - double thickness = 2.0; - ColorRGBA color = {0, 255, 0, 255}; - std::vector colors; - ColorRGBA fill_color = {0, 0, 0, 0}; ///< a=0 means no fill (LineLoop only). - bool operator==(const PointsAnnotation&) const = default; -}; - -/// Filled or stroked circle in image-pixel space. -struct CircleAnnotation { - Point2 center; - double radius = 1.0; - double thickness = 2.0; - ColorRGBA color = {0, 255, 0, 255}; - ColorRGBA fill_color = {0, 0, 0, 0}; - bool operator==(const CircleAnnotation&) const = default; -}; - -/// Text label anchored at a pixel position. -struct TextAnnotation { - Point2 position; - double font_size = 14.0; - ColorRGBA color = {255, 255, 255, 255}; - std::string text; - bool operator==(const TextAnnotation&) const = default; -}; - -/// All annotations for one image at one timestamp. References its base image -/// explicitly via `image_topic` so the renderer knows which frame to overlay. -struct ImageAnnotation { - Timestamp timestamp = 0; - std::string image_topic; - std::vector points; - std::vector circles; - std::vector texts; - bool operator==(const ImageAnnotation&) const = default; - - /// True if no primitives are present. - [[nodiscard]] bool empty() const noexcept { - return points.empty() && circles.empty() && texts.empty(); - } -}; - -/// Top-level output of a SceneDecoder. Wraps a list of ImageAnnotations for -/// this iteration; future iterations will extend with 3D ScenePrimitive (§7), -/// Grid (§9), etc. -struct SceneFrame { - Timestamp timestamp = 0; - std::vector annotations; - bool operator==(const SceneFrame&) const = default; - - [[nodiscard]] bool empty() const noexcept { - return annotations.empty(); - } -}; - -} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h b/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h index 3fac8c9..3293868 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h +++ b/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h @@ -4,7 +4,7 @@ #include #include -#include "pj_scene_protocol/image_annotation.h" +#include "pj_scene_protocol/builtin/ImageAnnotations.h" namespace PJ { @@ -15,13 +15,13 @@ namespace PJ { /// interop with Foxglove Studio and other tools. inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; -/// Serializes an ImageAnnotation to canonical foxglove.ImageAnnotations +/// Serializes a sdk::ImageAnnotations to canonical foxglove.ImageAnnotations /// Protobuf bytes. /// -/// `timestamp` and `image_topic` on the input are NOT serialized — the +/// `timestamp_ns` and `image_topic` on the input are NOT serialized — the /// timestamp travels with ObjectStore's push, topic identity with the topic /// registration. Round-trip equality holds when the caller leaves both at /// default values. -[[nodiscard]] std::vector serializeImageAnnotation(const ImageAnnotation& ia); +[[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); } // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h b/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h index f4391b7..64aadc7 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h +++ b/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h @@ -5,13 +5,13 @@ #include #include "pj_base/expected.hpp" -#include "pj_scene_protocol/image_annotation.h" #include "pj_scene_protocol/image_annotation_codec.h" // for kSchemaImageAnnotations +#include "pj_scene_protocol/scene_frame.h" namespace PJ { /// Decodes canonical wire-format bytes (foxglove.ImageAnnotations Protobuf, -/// serialized by `pj_scene_protocol::serializeImageAnnotation`) into a +/// serialized by `pj_scene_protocol::serializeImageAnnotations`) into a /// `SceneFrame` of vector primitives. Stateless — one instance per /// scene/annotation layer. See `docs/ARCHITECTURE.md` for the wire format spec. /// diff --git a/pj_scene_protocol/include/pj_scene_protocol/scene_frame.h b/pj_scene_protocol/include/pj_scene_protocol/scene_frame.h new file mode 100644 index 0000000..43c352f --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/scene_frame.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "pj_base/types.hpp" +#include "pj_scene_protocol/builtin/ImageAnnotations.h" + +namespace PJ { + +/// Top-level output of a SceneDecoder. Wraps a list of ImageAnnotations for +/// this iteration; future iterations will extend with 3D ScenePrimitive, +/// Grid, etc. +struct SceneFrame { + Timestamp timestamp = 0; + std::vector annotations; + bool operator==(const SceneFrame&) const = default; + + [[nodiscard]] bool empty() const noexcept { + return annotations.empty(); + } +}; + +} // namespace PJ diff --git a/pj_scene_protocol/src/image_annotation_codec.cpp b/pj_scene_protocol/src/image_annotation_codec.cpp index 12914f8..e596fe0 100644 --- a/pj_scene_protocol/src/image_annotation_codec.cpp +++ b/pj_scene_protocol/src/image_annotation_codec.cpp @@ -7,6 +7,14 @@ namespace PJ { namespace { +using sdk::AnnotationTopology; +using sdk::CircleAnnotation; +using sdk::ColorRGBA; +using sdk::ImageAnnotations; +using sdk::Point2; +using sdk::PointsAnnotation; +using sdk::TextAnnotation; + // Hand-rolled Protobuf wire emission. Mirror of the reader at // `src/scene_decoder_protobuf.cpp` (same module). // @@ -177,7 +185,7 @@ std::vector buildTextAnnotation(const TextAnnotation& ta) { // foxglove.ImageAnnotations { 1: repeated CircleAnnotation, // 2: repeated PointsAnnotation, // 3: repeated TextAnnotation } -std::vector serializeImageAnnotation(const ImageAnnotation& ia) { +std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia) { std::vector out; for (const auto& c : ia.circles) { diff --git a/pj_scene_protocol/src/scene_decoder_protobuf.cpp b/pj_scene_protocol/src/scene_decoder_protobuf.cpp index d03f262..0dfc7ed 100644 --- a/pj_scene_protocol/src/scene_decoder_protobuf.cpp +++ b/pj_scene_protocol/src/scene_decoder_protobuf.cpp @@ -10,6 +10,14 @@ namespace PJ { namespace { +using sdk::AnnotationTopology; +using sdk::CircleAnnotation; +using sdk::ColorRGBA; +using sdk::ImageAnnotations; +using sdk::Point2; +using sdk::PointsAnnotation; +using sdk::TextAnnotation; + // Minimal Protobuf wire-format reader for foxglove.ImageAnnotations. Decodes // PointsAnnotation, CircleAnnotation, and TextAnnotation in full. Round-trips // against the sibling writer at `src/image_annotation_codec.cpp` are covered @@ -299,7 +307,7 @@ bool decodeCircleAnnotation(Reader& r, size_t len, CircleAnnotation& out) { if (sub_end > r.end) { return false; } - // Defaults match pj_scene_protocol/image_annotation.h. + // Defaults match pj_scene_protocol/builtin/ImageAnnotations.h. out.color = {0, 255, 0, 255}; out.fill_color = {0, 0, 0, 0}; out.thickness = 2.0; @@ -357,7 +365,7 @@ bool decodeCircleAnnotation(Reader& r, size_t len, CircleAnnotation& out) { // Decode one foxglove.TextAnnotation sub-message: // timestamp(1)=Time, position(2)=Point2, text(3)=string, font_size(4)=double, // text_color(5)=Color, background_color(6)=Color (background_color skipped — not -// present in pj_scene_protocol/image_annotation.h::TextAnnotation). +// present in pj_scene_protocol/builtin/ImageAnnotations.h::sdk::TextAnnotation). bool decodeTextAnnotation(Reader& r, size_t len, TextAnnotation& out) { const uint8_t* sub_end = r.p + len; if (sub_end > r.end) { @@ -420,7 +428,7 @@ class ProtobufImageAnnotationsDecoder final : public ISceneDecoder { } Reader r{data, data + size}; - ImageAnnotation ia; + sdk::ImageAnnotations ia; while (!r.eof()) { uint64_t tag = 0; if (!r.readVarint(tag)) { diff --git a/pj_scene_protocol/tests/image_annotation_codec_test.cpp b/pj_scene_protocol/tests/image_annotation_codec_test.cpp index f520610..4f6061b 100644 --- a/pj_scene_protocol/tests/image_annotation_codec_test.cpp +++ b/pj_scene_protocol/tests/image_annotation_codec_test.cpp @@ -7,12 +7,20 @@ #include #include -#include "pj_scene_protocol/image_annotation.h" #include "pj_scene_protocol/scene_decoder.h" // existing reader, used for round-trips +#include "pj_scene_protocol/scene_frame.h" namespace PJ { namespace { +using sdk::AnnotationTopology; +using sdk::CircleAnnotation; +using sdk::ColorRGBA; +using sdk::ImageAnnotations; +using sdk::Point2; +using sdk::PointsAnnotation; +using sdk::TextAnnotation; + // ----------------------------------------------------------------------------- // Hand-rolled Protobuf helpers — same style as the sibling decoder test // (`tests/scene_decoder_test.cpp`). Used to build expected byte sequences for @@ -47,11 +55,11 @@ inline void appendLenDelim(std::vector& out, const std::vector } // namespace pb -// Decode the bytes produced by serializeImageAnnotation back into an -// ImageAnnotation. Returns the inner annotation; assumes the SceneFrame wraps -// exactly one ImageAnnotation (the reader's contract). -ImageAnnotation roundTrip(const ImageAnnotation& input) { - auto bytes = serializeImageAnnotation(input); +// Decode the bytes produced by serializeImageAnnotations back into an +// sdk::ImageAnnotations. Returns the inner annotation; assumes the SceneFrame wraps +// exactly one sdk::ImageAnnotations (the reader's contract). +sdk::ImageAnnotations roundTrip(const sdk::ImageAnnotations& input) { + auto bytes = serializeImageAnnotations(input); auto decoder = makeSceneDecoder(kSchemaImageAnnotations); EXPECT_NE(decoder.get(), nullptr); auto result = decoder->decode(bytes.data(), bytes.size()); @@ -76,8 +84,8 @@ ::testing::AssertionResult ColorEq(const ColorRGBA& a, const ColorRGBA& b) { // ----------------------------------------------------------------------------- TEST(ImageAnnotationCodecTest, EmptyAnnotationProducesEmptyBytes) { - ImageAnnotation ia; - auto bytes = serializeImageAnnotation(ia); + sdk::ImageAnnotations ia; + auto bytes = serializeImageAnnotations(ia); EXPECT_TRUE(bytes.empty()); } @@ -89,7 +97,7 @@ TEST(ImageAnnotationCodecTest, GoldenBytes_SinglePointsAnnotation) { // Build the canonical input: one PointsAnnotation, kLineLoop, two points, // outline color = pure red (255, 0, 0, 255), fill = transparent default, // thickness = 2.0. - ImageAnnotation ia; + sdk::ImageAnnotations ia; PointsAnnotation pa; pa.topology = AnnotationTopology::kLineLoop; pa.points = {{1.0, 2.0}, {3.0, 4.0}}; @@ -156,7 +164,7 @@ TEST(ImageAnnotationCodecTest, GoldenBytes_SinglePointsAnnotation) { pb::appendTag(expected, 2, 2); pb::appendLenDelim(expected, pa_body); - auto actual = serializeImageAnnotation(ia); + auto actual = serializeImageAnnotations(ia); EXPECT_EQ(actual, expected) << "wire format mismatch"; } @@ -165,7 +173,7 @@ TEST(ImageAnnotationCodecTest, GoldenBytes_SinglePointsAnnotation) { // ----------------------------------------------------------------------------- TEST(ImageAnnotationCodecTest, RoundTrip_LineLoopFourPoints) { - ImageAnnotation in; + sdk::ImageAnnotations in; PointsAnnotation pa; pa.topology = AnnotationTopology::kLineLoop; pa.points = {{75.0, 185.0}, {125.0, 185.0}, {125.0, 215.0}, {75.0, 215.0}}; @@ -187,7 +195,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_AllTopologies) { for (auto topology : {AnnotationTopology::kPoints, AnnotationTopology::kLineList, AnnotationTopology::kLineStrip, AnnotationTopology::kLineLoop}) { - ImageAnnotation in; + sdk::ImageAnnotations in; PointsAnnotation pa; pa.topology = topology; pa.points = {{0.0, 0.0}, {10.0, 10.0}}; @@ -203,7 +211,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_AllTopologies) { } TEST(ImageAnnotationCodecTest, RoundTrip_CirclePreservesDiameterRadiusInverse) { - ImageAnnotation in; + sdk::ImageAnnotations in; CircleAnnotation ca; ca.center = {50.0, 60.0}; ca.radius = 10.0; // wire emits diameter = 20; reader halves back to 10. @@ -223,7 +231,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_CirclePreservesDiameterRadiusInverse) { } TEST(ImageAnnotationCodecTest, RoundTrip_TextUtf8) { - ImageAnnotation in; + sdk::ImageAnnotations in; TextAnnotation ta; ta.position = {320.5, 240.25}; ta.font_size = 14.0; @@ -241,7 +249,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_TextUtf8) { } TEST(ImageAnnotationCodecTest, RoundTrip_FullImageAnnotationAllThreeKinds) { - ImageAnnotation in; + sdk::ImageAnnotations in; // Two points annotations. PointsAnnotation pa1; @@ -292,7 +300,7 @@ TEST(ImageAnnotationCodecTest, EmptyColorsVectorDoesNotInjectDefaultEntry) { // A PointsAnnotation with empty `colors` must round-trip to empty `colors`. // If the writer emitted a default Color for the empty vector, the reader // would push a phantom entry, breaking per-vertex coloring semantics. - ImageAnnotation in; + sdk::ImageAnnotations in; PointsAnnotation pa; pa.topology = AnnotationTopology::kPoints; pa.points = {{1.0, 1.0}, {2.0, 2.0}}; @@ -307,7 +315,7 @@ TEST(ImageAnnotationCodecTest, EmptyColorsVectorDoesNotInjectDefaultEntry) { } TEST(ImageAnnotationCodecTest, RoundTrip_PerVertexColors) { - ImageAnnotation in; + sdk::ImageAnnotations in; PointsAnnotation pa; pa.topology = AnnotationTopology::kLineStrip; pa.points = {{0.0, 0.0}, {10.0, 10.0}, {20.0, 0.0}}; diff --git a/pj_scene_protocol/tests/scene_decoder_test.cpp b/pj_scene_protocol/tests/scene_decoder_test.cpp index 028ce2e..e96849d 100644 --- a/pj_scene_protocol/tests/scene_decoder_test.cpp +++ b/pj_scene_protocol/tests/scene_decoder_test.cpp @@ -6,11 +6,19 @@ #include #include -#include "pj_scene_protocol/image_annotation.h" +#include "pj_scene_protocol/scene_frame.h" namespace PJ { namespace { +using sdk::AnnotationTopology; +using sdk::CircleAnnotation; +using sdk::ColorRGBA; +using sdk::ImageAnnotations; +using sdk::Point2; +using sdk::PointsAnnotation; +using sdk::TextAnnotation; + TEST(SceneDecoderTest, FactoryReturnsNullForUnknownSchema) { auto dec = makeSceneDecoder("nonsense/Schema"); EXPECT_EQ(dec.get(), nullptr); From ffe754f3ce078a70ae933f12421f3e8f3e85fabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Thu, 14 May 2026 22:30:20 +0200 Subject: [PATCH 11/18] refactor(sdk): reduce "fetcher" vocabulary; prefer FetchMessageData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue the rename started by PJ_payload_fetcher_t → PJ_message_data_fetcher_t. Drop the "fetcher" shorthand in identifiers and prose; refer to the deferred byte-producing callable by its concrete name FetchMessageData (template/type) or fetch_message_data (parameter, snake_case per coding convention). - pushMessage template parameter and the local FetchMessageDataT / FetchMessageDataResult aliases. - Local variable abi_fetch_message_data in the wrapper construction site. - Docstrings in data_source_protocol.h, data_source_host_views.hpp and object_ingest_policy.hpp refer to "the FetchMessageData callable" / "the callable" rather than "the fetcher". - Tests renamed: invokeFetchMessageDataAndExpect, FetchMessageDataCtxReleasedAfterHostCalls. No ABI change; the C struct PJ_message_data_fetcher_t and its field fetchMessageData were already in their final form. Build green; tests pass. --- .../include/pj_base/data_source_protocol.h | 57 ++++++++++--------- .../pj_base/sdk/data_source_host_views.hpp | 38 +++++++------ pj_base/tests/push_message_v2_test.cpp | 53 +++++++++-------- .../pj_plugins/sdk/object_ingest_policy.hpp | 35 ++++++------ 4 files changed, 96 insertions(+), 87 deletions(-) diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 9bcbaf5..cbc8d43 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -157,14 +157,15 @@ typedef struct PJ_payload_t { } PJ_payload_t; /** - * Idempotent fetcher of one message's payload bytes. The host invokes - * `fetchMessageData(ctx, &out, &err)` zero, one, or many times depending - * on the active ObjectIngestPolicy and on consumer pulls. Returns true - * and populates `*out` on success; returns false and (optionally) - * populates `*err` on failure (file read error, source torn down, etc.). + * Idempotent FetchMessageData callable for one message's payload bytes. + * The host invokes `fetchMessageData(ctx, &out, &err)` zero, one, or many + * times depending on the active ObjectIngestPolicy and on consumer pulls. + * Returns true and populates `*out` on success; returns false and + * (optionally) populates `*err` on failure (file read error, source torn + * down, etc.). * - * The host ALWAYS calls `release(ctx)` exactly once when it no longer - * needs the fetcher — at the end of ingest for kEager, when the + * The host ALWAYS calls `release(ctx)` exactly once when the callable is + * no longer needed — at the end of ingest for kEager, when the * corresponding ObjectStore entry is dropped for lazy modes. `release` * MAY be NULL if the plugin manages the ctx via some external mechanism. * @@ -259,7 +260,7 @@ typedef struct PJ_data_source_runtime_host_vtable_t { * retained for later replay. Plugins that need lazy materialization or * ObjectIngestPolicy dispatch should use push_message_v2 instead. This * slot remains for sources that fan-out raw bytes without an associated - * fetcher (streaming or eager-only consumers). + * FetchMessageData callable (streaming or eager-only consumers). */ bool (*push_raw_message)( void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, PJ_bytes_view_t payload, @@ -294,44 +295,44 @@ typedef struct PJ_data_source_runtime_host_vtable_t { * --------------------------------------------------------------------- */ /** - * [stream-thread] Push a message via a deferred byte fetcher. The plugin - * hands the host a callable that produces the payload bytes when - * invoked; the host applies the active ObjectIngestPolicy (resolved via - * the application-configured ObjectIngestPolicyResolver against + * [stream-thread] Push a message via a deferred FetchMessageData callable. + * The plugin hands the host a callable that produces the payload bytes + * when invoked; the host applies the active ObjectIngestPolicy (resolved + * via the application-configured ObjectIngestPolicyResolver against * source_id, topic, and the parser's classifySchema kind) to decide: * - * - kEager: invoke fetcher now, parser.parseScalars + * - kEager: invoke the callable now, parser.parseScalars * writes columns, parser.parseObject * materializes the canonical object into - * the ObjectStore via pushOwned. Fetcher - * released after. - * - kLazyObjectsEagerScalars: invoke fetcher now, parser.parseScalars + * the ObjectStore via pushOwned. The + * callable is released after. + * - kLazyObjectsEagerScalars: invoke the callable now, parser.parseScalars * writes columns. ObjectStore.pushLazy - * retains the fetcher closure for pull-time + * retains the callable for pull-time * re-invocation; bytes dropped after * parseScalars. - * - kPureLazy: do not invoke fetcher at ingest. Register - * ObjectStore entry that defers fetcher + * - kPureLazy: do not invoke the callable at ingest. + * Register an ObjectStore entry that defers * invocation until consumer pull. No * scalar columns produced. * * The plugin is policy-agnostic: it does not query the policy nor - * track which mode is active. Just constructs the fetcher and hands + * track which mode is active. It just constructs the callable and hands * it off via this slot. * - * Lifetime: the fetcher's `ctx` is allocated by the plugin. The host - * is responsible for calling `fetcher.release(fetcher.ctx)` exactly - * once when the fetcher is no longer needed (kEager: after the - * single fetch; lazy modes: when the ObjectStore entry it backs is - * dropped). `fetcher.fetchMessageData` must be thread-safe. + * Lifetime: the callable's `ctx` is allocated by the plugin. The host is + * responsible for calling `release(ctx)` exactly once when the callable + * is no longer needed (kEager: after the single fetch; lazy modes: when + * the ObjectStore entry it backs is dropped). `fetchMessageData` must + * be thread-safe. * * Returns false + error on failure (binding handle invalid, * ObjectStore push failed, etc.). On failure the host still calls - * `fetcher.release` so the plugin's ctx leaks no resources. + * `release` so the plugin's ctx leaks no resources. */ bool (*push_message_v2)( - void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, PJ_message_data_fetcher_t fetcher, - PJ_error_t* out_error) PJ_NOEXCEPT; + void* ctx, PJ_parser_binding_handle_t handle, int64_t host_timestamp_ns, + PJ_message_data_fetcher_t fetch_message_data, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_data_source_runtime_host_vtable_t; /** Fat pointer pairing a runtime host context with its vtable. */ diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index 5274547..3916e95 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -219,22 +219,22 @@ class DataSourceRuntimeHostView { return okStatus(); } - /// Push a message via a deferred byte fetcher. The DataSource hands the - /// host a callable that produces the payload bytes when invoked. The host - /// applies the active ObjectIngestPolicy (resolved via the + /// Push a message via a deferred FetchMessageData callable. The DataSource + /// hands the host a callable that produces the payload bytes when invoked. + /// The host applies the active ObjectIngestPolicy (resolved via the /// ObjectIngestPolicyResolver below for source_id, topic, kind) to decide - /// whether to invoke the fetcher at ingest, only on consumer pull, or + /// whether to invoke the callable at ingest, only on consumer pull, or /// never. The DataSource is policy-agnostic — it neither queries the /// policy nor tracks which mode is active. /// - /// The fetcher MUST be idempotent — the host may invoke it zero, one, or + /// The callable MUST be idempotent — the host may invoke it zero, one, or /// many times depending on policy and consumer pulls. It MUST be /// thread-safe: invocations may come from the ingest thread (kEager) or /// from consumer threads (lazy pulls). Capture by shared_ptr (file /// readers, mcap chunks) so the source buffer outlives every pending /// pull. /// - /// Fetcher return type: + /// FetchMessageData return type: /// - sdk::PayloadView { bytes, anchor } — preferred, zero-copy. The /// anchor is propagated through the C ABI as a heap-held shared_ptr /// copy that the host releases when no longer needed. @@ -248,13 +248,15 @@ class DataSourceRuntimeHostView { /// invoke it. There is no legacy fallback: a host that doesn't expose /// the slot returns an explicit error here rather than silently /// degrading to a kEager push_raw_message. - template - [[nodiscard]] Status pushMessage(ParserBindingHandle handle, Timestamp host_timestamp_ns, Fetcher&& fetcher) const { - using FetcherT = std::decay_t; - using FetcherResult = std::decay_t>; + template + [[nodiscard]] Status pushMessage( + ParserBindingHandle handle, Timestamp host_timestamp_ns, FetchMessageData&& fetch_message_data) const { + using FetchMessageDataT = std::decay_t; + using FetchMessageDataResult = std::decay_t>; static_assert( - std::is_same_v || std::is_same_v>, - "Fetcher must return sdk::PayloadView (zero-copy) or std::vector"); + std::is_same_v || + std::is_same_v>, + "FetchMessageData must return sdk::PayloadView (zero-copy) or std::vector"); if (!valid()) { return unexpected(std::string("runtime host is not bound")); @@ -263,13 +265,13 @@ class DataSourceRuntimeHostView { return unexpected(std::string("runtime host does not expose push_message_v2")); } - auto* ctx = new FetcherT(std::forward(fetcher)); + auto* ctx = new FetchMessageDataT(std::forward(fetch_message_data)); - PJ_message_data_fetcher_t abi_fetcher{ + PJ_message_data_fetcher_t abi_fetch_message_data{ .ctx = ctx, .fetchMessageData = +[](void* c, PJ_payload_t* out, PJ_error_t* err) noexcept -> bool { try { - auto& fn = *static_cast(c); + auto& fn = *static_cast(c); using Result = std::decay_t; if constexpr (std::is_same_v) { // Zero-copy path: hold a heap copy of the BufferAnchor so it @@ -295,15 +297,15 @@ class DataSourceRuntimeHostView { sdk::fillError(err, 1, "plugin", e.what()); return false; } catch (...) { - sdk::fillError(err, 1, "plugin", "unknown exception in payload fetcher"); + sdk::fillError(err, 1, "plugin", "unknown exception in FetchMessageData callable"); return false; } }, - .release = +[](void* c) noexcept { delete static_cast(c); }, + .release = +[](void* c) noexcept { delete static_cast(c); }, }; PJ_error_t err{}; - if (!host_.vtable->push_message_v2(host_.ctx, handle, host_timestamp_ns, abi_fetcher, &err)) { + if (!host_.vtable->push_message_v2(host_.ctx, handle, host_timestamp_ns, abi_fetch_message_data, &err)) { return unexpected(errorToString(err)); } return okStatus(); diff --git a/pj_base/tests/push_message_v2_test.cpp b/pj_base/tests/push_message_v2_test.cpp index 558727a..577c98c 100644 --- a/pj_base/tests/push_message_v2_test.cpp +++ b/pj_base/tests/push_message_v2_test.cpp @@ -1,12 +1,13 @@ // Tests for the SDK template `DataSourceRuntimeHostView::pushMessage` and // its delegation to the C ABI slot `push_message_v2`. We exercise: // -// 1. Vector closure → captured fetcher in the host yields the same bytes. +// 1. Vector closure → the captured FetchMessageData callable yields the +// same bytes when invoked from the host. // 2. PayloadView closure → ditto, with the producer-supplied anchor // flowing through the C ABI. -// 3. Multiple fetcher invocations are idempotent (same bytes each time). +// 3. Multiple invocations are idempotent (same bytes each time). // 4. The heap-held closure context is destroyed exactly once when the -// host calls fetcher.release. +// host calls `release`. // 5. When the host does not expose push_message_v2 (struct_size short // or field NULL), pushMessage returns an explicit error rather than // degrading silently. @@ -28,7 +29,7 @@ namespace { struct CapturedPush { PJ_parser_binding_handle_t handle{}; int64_t timestamp_ns = 0; - PJ_message_data_fetcher_t fetcher{}; + PJ_message_data_fetcher_t fetch_message_data{}; bool received = false; }; @@ -73,12 +74,12 @@ class MockHost { } static bool pushMessageV2Thunk( - void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, PJ_message_data_fetcher_t fetcher, + void* ctx, PJ_parser_binding_handle_t handle, int64_t ts, PJ_message_data_fetcher_t fetch_message_data, PJ_error_t* /*err*/) noexcept { auto* self = static_cast(ctx); self->captured_.handle = handle; self->captured_.timestamp_ns = ts; - self->captured_.fetcher = fetcher; + self->captured_.fetch_message_data = fetch_message_data; self->captured_.received = true; return true; } @@ -89,13 +90,14 @@ class MockHost { std::vector raw_bytes_; }; -// Helper: invoke a captured fetcher and assert the produced bytes match +// Helper: invoke a captured FetchMessageData callable and assert the produced bytes match // the expected content. Releases the payload anchor. -void invokeFetcherAndExpect(PJ_message_data_fetcher_t& fetcher, const std::vector& expected) { +void invokeFetchMessageDataAndExpect( + PJ_message_data_fetcher_t& fetch_message_data, const std::vector& expected) { PJ_payload_t payload{}; PJ_error_t err{}; - ASSERT_NE(fetcher.fetchMessageData, nullptr); - ASSERT_TRUE(fetcher.fetchMessageData(fetcher.ctx, &payload, &err)); + ASSERT_NE(fetch_message_data.fetchMessageData, nullptr); + ASSERT_TRUE(fetch_message_data.fetchMessageData(fetch_message_data.ctx, &payload, &err)); ASSERT_EQ(payload.size, expected.size()); EXPECT_EQ(0, std::memcmp(payload.data, expected.data(), expected.size())); if (payload.anchor.release) { @@ -115,8 +117,8 @@ TEST(PushMessageV2Test, VectorClosureFlowsThroughSlot) { ASSERT_TRUE(host.captured().received); EXPECT_EQ(host.captured().handle.id, 42U); EXPECT_EQ(host.captured().timestamp_ns, 1000); - invokeFetcherAndExpect(host.captured().fetcher, expected); - host.captured().fetcher.release(host.captured().fetcher.ctx); + invokeFetchMessageDataAndExpect(host.captured().fetch_message_data, expected); + host.captured().fetch_message_data.release(host.captured().fetch_message_data.ctx); } TEST(PushMessageV2Test, PayloadViewClosureFlowsThroughSlot) { @@ -129,8 +131,8 @@ TEST(PushMessageV2Test, PayloadViewClosureFlowsThroughSlot) { }); ASSERT_TRUE(status); - invokeFetcherAndExpect(host.captured().fetcher, expected); - host.captured().fetcher.release(host.captured().fetcher.ctx); + invokeFetchMessageDataAndExpect(host.captured().fetch_message_data, expected); + host.captured().fetch_message_data.release(host.captured().fetch_message_data.ctx); } TEST(PushMessageV2Test, FetchIsIdempotent) { @@ -141,12 +143,12 @@ TEST(PushMessageV2Test, FetchIsIdempotent) { // Multiple invocations must yield the same bytes each time. for (int i = 0; i < 3; ++i) { - invokeFetcherAndExpect(host.captured().fetcher, expected); + invokeFetchMessageDataAndExpect(host.captured().fetch_message_data, expected); } - host.captured().fetcher.release(host.captured().fetcher.ctx); + host.captured().fetch_message_data.release(host.captured().fetch_message_data.ctx); } -TEST(PushMessageV2Test, FetcherCtxReleasedAfterHostCalls) { +TEST(PushMessageV2Test, FetchMessageDataCtxReleasedAfterHostCalls) { MockHost host; auto canary = std::make_shared(42); std::weak_ptr witness = canary; @@ -154,13 +156,13 @@ TEST(PushMessageV2Test, FetcherCtxReleasedAfterHostCalls) { ASSERT_TRUE(host.view().pushMessage(PJ::ParserBindingHandle{1}, 0, [canary]() { return std::vector{}; })); // Drop our local reference; the heap-held closure copy keeps the canary - // alive while the fetcher is owned by the host. + // alive while the fetch_message_data is owned by the host. canary.reset(); - EXPECT_FALSE(witness.expired()) << "closure should still keep the canary alive (held in heap fetcher ctx)"; + EXPECT_FALSE(witness.expired()) << "closure should still keep the canary alive (held in heap fetch_message_data ctx)"; - // Host releases the fetcher → closure destroyed → captured shared_ptr + // Host releases the fetch_message_data → closure destroyed → captured shared_ptr // destroyed → canary's last reference drops. - host.captured().fetcher.release(host.captured().fetcher.ctx); + host.captured().fetch_message_data.release(host.captured().fetch_message_data.ctx); EXPECT_TRUE(witness.expired()) << "after release, the captured shared_ptr should have been the last reference"; } @@ -178,17 +180,18 @@ TEST(PushMessageV2Test, PayloadAnchorPropagates) { owned.reset(); EXPECT_FALSE(witness.expired()); - // Invoke the fetcher: it builds a PayloadView into the same buffer; the + // Invoke the fetch_message_data: it builds a PayloadView into the same buffer; the // anchor returned to the host is yet another shared_ptr copy, so the // buffer survives even past the closure's release. PJ_payload_t payload{}; PJ_error_t err{}; - ASSERT_TRUE(host.captured().fetcher.fetchMessageData(host.captured().fetcher.ctx, &payload, &err)); + ASSERT_TRUE( + host.captured().fetch_message_data.fetchMessageData(host.captured().fetch_message_data.ctx, &payload, &err)); EXPECT_EQ(payload.size, 2U); - // Releasing the fetcher (closure dies) does NOT kill the buffer because + // Releasing the fetch_message_data (closure dies) does NOT kill the buffer because // the active payload anchor still holds a reference. - host.captured().fetcher.release(host.captured().fetcher.ctx); + host.captured().fetch_message_data.release(host.captured().fetch_message_data.ctx); EXPECT_FALSE(witness.expired()) << "active payload anchor should still keep the buffer alive"; // Releasing the payload anchor drops the last reference. diff --git a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp index 452ea4a..cc7354f 100644 --- a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp +++ b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp @@ -1,12 +1,13 @@ /** * @file object_ingest_policy.hpp * @brief Configurable policy that the host applies when a DataSource hands - * it a deferred byte fetcher via DataSourceRuntimeHostView::pushMessage. + * it a deferred FetchMessageData callable via + * DataSourceRuntimeHostView::pushMessage. * * The DataSource is policy-agnostic: it only fabricates a callable that * produces the raw payload bytes when invoked. The host decides — based on * the policy resolved for (source_id, topic, kind) — whether to invoke the - * fetcher immediately (parse and store now), invoke it once for scalars + * callable immediately (parse and store now), invoke it once for scalars * and again on each pull, or never invoke it during ingest and only on * consumer pulls. */ @@ -22,24 +23,26 @@ namespace PJ { namespace sdk { enum class ObjectIngestPolicy : uint8_t { - /// Host never invokes the fetcher during ingest. The (timestamp, fetcher) - /// pair is registered in the ObjectStore and the fetcher fires only when a - /// consumer pulls. No scalar timeseries are produced for this topic — its - /// scalar fields (header.stamp, width, height, …) do not appear in the - /// Datastore. The topic shows up as an ObjectTopic without children in the - /// unified curve tree. Best for very large blobs (point clouds, 4K video) - /// when scalar timeseries are not interesting. + /// Host never invokes the FetchMessageData callable during ingest. The + /// (timestamp, callable) pair is registered in the ObjectStore and the + /// callable fires only when a consumer pulls. No scalar timeseries are + /// produced for this topic — its scalar fields (header.stamp, width, + /// height, …) do not appear in the Datastore. The topic shows up as an + /// ObjectTopic without children in the unified curve tree. Best for very + /// large blobs (point clouds, 4K video) when scalar timeseries are not + /// interesting. kPureLazy, - /// Host invokes the fetcher once during ingest to obtain bytes; parser's - /// parseScalars runs and writes scalar fields to the Datastore; bytes are - /// then dropped from RAM. The ObjectStore retains only the fetcher closure - /// for re-invocation on pull (which means the file/source is read again). - /// Best for the common case: scalar timeseries appear in the tree, the - /// blob does not stay in RAM, and pulls re-read on demand. + /// Host invokes the FetchMessageData callable once during ingest to + /// obtain bytes; parser's parseScalars runs and writes scalar fields to + /// the Datastore; bytes are then dropped from RAM. The ObjectStore + /// retains only the callable for re-invocation on pull (which means the + /// file/source is read again). Best for the common case: scalar + /// timeseries appear in the tree, the blob does not stay in RAM, and + /// pulls re-read on demand. kLazyObjectsEagerScalars, - /// Host invokes the fetcher once during ingest, parser's parseScalars and + /// Host invokes the FetchMessageData callable once during ingest, parser's parseScalars and /// parseObject both run, the canonical object is serialized into the /// ObjectStore via pushOwned. Pull is trivial — bytes are already there. /// Highest memory cost; the only viable mode for streaming sources that From b0921c66d6fa0dfd4175f518e383d882b920fe09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 15 May 2026 10:34:23 +0200 Subject: [PATCH 12/18] refactor(scene_protocol): BuiltinObject is std::any, not std::variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per client feedback on the SDK's binary-compatibility story: replace the closed variant with an opaque std::any holder. Why A std::variant alternative list is part of the SDK type and binds every consumer to know the full set of alternatives at compile time. Appending a kind would technically be a forward-compatible source change but is a hard binary-compat boundary in practice — older plugins linking the SDK would still produce the *old* variant type, which is a different ABI from the host's *new* variant of the same name. std::any sidesteps this entirely: producers and consumers only need to agree on the concrete types they actually use; kindOf() does the dispatch via RTTI. How - BuiltinObject is `std::any`. - kindOf(obj) inspects obj.type() and maps it to BuiltinObjectKind; returns kNone for an empty any or an unknown type. - Producers continue to write `BuiltinObject{sdk::Image{...}}`, `BuiltinObject{sdk::PointCloud{...}}`, etc., unchanged. - Consumers use std::any_cast(&obj) to recover the concrete value. No behaviour change in the demos / host. Build green; tests pass (modulo the pre-existing RosParserTest.{Embedded,GenericEmbedded}Timestamp fails, unrelated to this change). --- .../pj_scene_protocol/builtin/BuiltinObject.h | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h b/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h index 476bd48..264bc7f 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h +++ b/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h @@ -1,17 +1,23 @@ /** * @file BuiltinObject.h - * @brief Sum type of all builtin objects a MessageParser may produce. + * @brief Type-erased holder for any builtin object a MessageParser may produce. * - * New alternatives (kDepthImage, kMarkers, kOccupancyGrid, …) are appended - * at the tail and announced via BuiltinObjectKind. Plugins built against - * an older SDK keep producing the alternatives they know; hosts built - * against an older SDK that receive an unknown kind reject the message - * rather than crashing. Forward-compatible — no protocol bump required. + * BuiltinObject is `std::any`. A producer constructs it by passing a + * concrete builtin value (`sdk::Image`, `sdk::PointCloud`, `sdk::DepthImage`, + * `sdk::ImageAnnotations`, …); a consumer recovers the concrete type via + * `std::any_cast(&obj)` and obtains the kind tag via `kindOf(obj)`. + * + * The type erasure is deliberate: choosing `std::any` over `std::variant` + * keeps the SDK forward-compatible. Plugins built against an older SDK can + * keep producing the alternatives they know without any TU referencing the + * (later-extended) full alternative list; hosts built against an older SDK + * that receive an unknown kind simply see `BuiltinObjectKind::kNone` from + * `kindOf` and reject the message. No protocol bump required when a new + * builtin kind is appended to BuiltinObjectKind and its header. */ #pragma once -#include -#include +#include #include "pj_scene_protocol/builtin/BuiltinObjectKind.h" #include "pj_scene_protocol/builtin/DepthImage.h" @@ -22,26 +28,29 @@ namespace PJ { namespace sdk { -using BuiltinObject = std::variant; +using BuiltinObject = std::any; -/// Get the kind tag for a BuiltinObject without unpacking it. +/// Get the kind tag for a BuiltinObject without copying it. +/// Returns kNone for an empty BuiltinObject or one that wraps a type +/// unknown to this SDK build. [[nodiscard]] inline BuiltinObjectKind kindOf(const BuiltinObject& obj) noexcept { - return std::visit( - [](const auto& concrete) -> BuiltinObjectKind { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return BuiltinObjectKind::kImage; - } else if constexpr (std::is_same_v) { - return BuiltinObjectKind::kPointCloud; - } else if constexpr (std::is_same_v) { - return BuiltinObjectKind::kDepthImage; - } else if constexpr (std::is_same_v) { - return BuiltinObjectKind::kImageAnnotations; - } else { - return BuiltinObjectKind::kNone; - } - }, - obj); + if (!obj.has_value()) { + return BuiltinObjectKind::kNone; + } + const auto& t = obj.type(); + if (t == typeid(Image)) { + return BuiltinObjectKind::kImage; + } + if (t == typeid(PointCloud)) { + return BuiltinObjectKind::kPointCloud; + } + if (t == typeid(DepthImage)) { + return BuiltinObjectKind::kDepthImage; + } + if (t == typeid(ImageAnnotations)) { + return BuiltinObjectKind::kImageAnnotations; + } + return BuiltinObjectKind::kNone; } } // namespace sdk From 93795479c1f5667f084c34189c122f2faadce51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Fri, 15 May 2026 17:35:54 +0200 Subject: [PATCH 13/18] docs(plugins): document builtin-object pipeline Add sections to the plugin SDK docs that cover the runtime-host slot introduced by the builtin-object pipeline: - data-source-guide.md: `pushMessage(handle, ts, fetchMessageData)` + PayloadView / BufferAnchor, `PJ_message_data_fetcher_t` C ABI, policy-agnostic plugin contract, thread-safety expectations. - message-parser-guide.md: `classifySchema` + `parseScalars` + `parseObject` virtual entry points, `BuiltinObject` as `std::any`, builtin type catalog (`Image` unified by encoding string, `DepthImage`, `PointCloud`, `ImageAnnotations`). - ARCHITECTURE.md: push_message_v2 tail slot at offset 96, host-side ObjectIngestPolicyResolver cascade (topic > source > kind > default), forward-compatibility rationale for the type-erased BuiltinObject. --- pj_plugins/docs/ARCHITECTURE.md | 46 +++++++++++++++++++++ pj_plugins/docs/data-source-guide.md | 53 +++++++++++++++++++++++++ pj_plugins/docs/message-parser-guide.md | 50 +++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index d1be28a..b5d5a4c 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -483,3 +483,49 @@ out_array, err)`: | `widget_event_builder_test.cpp` | Event JSON generation | | `widget_event_test.cpp` | Event parsing | | `plugin_lifecycle_test.cpp` | Plugin create/destroy lifecycle | + +## Builtin-object pipeline (PR #86) + +The v4 DataSource runtime host adds a tail slot `push_message_v2` +(offset 96 in `PJ_data_source_runtime_host_vtable_t`) that takes a +deferred byte-fetch callable instead of bytes: + +```c +typedef struct PJ_message_data_fetcher_t { + void* ctx; + bool (*fetchMessageData)(void* ctx, PJ_payload_t* out, PJ_error_t* err) PJ_NOEXCEPT; + void (*release)(void* ctx); +} PJ_message_data_fetcher_t; + +bool (*push_message_v2)( + void* ctx, PJ_parser_binding_handle_t handle, int64_t timestamp_ns, + PJ_message_data_fetcher_t fetch_message_data, + PJ_error_t* out_error) PJ_NOEXCEPT; +``` + +The C++ SDK exposes this through +`DataSourceRuntimeHostView::pushMessage(handle, ts, fetch_callable)`, +which wraps any callable returning `PayloadView` (preferred, zero-copy) +or `std::vector` into the C ABI struct. + +The host orchestrates dispatch through an `ObjectIngestPolicyResolver` +that cascades `topic > source > kind > default`: + +- `kEager`: invoke `fetchMessageData` now, run `parseScalars` + + `parseObject`, persist via `ObjectStore::pushOwned`. +- `kLazyObjectsEagerScalars`: invoke once for scalars, keep the + callable behind `ObjectStore::pushLazy` for on-pull materialisation. +- `kPureLazy`: skip the callable at ingest, register a lazy + ObjectStore entry only. + +Parsers participate via three optional virtual entry points on +`MessageParserPluginBase` — `classifySchema`, `parseScalars`, +`parseObject` — that map to the per-schema slots in the +`SchemaHandler` table. The shape that crosses both ABI boundaries (C +struct on the DataSource side, in-process variant on the parser side) +is opaque-payload-by-default: `BuiltinObject` is `std::any`, so +appending a new builtin kind does not change the public type and +forward compatibility is automatic. Concrete builtins live under +`pj_scene_protocol/builtin/` (`Image`, `DepthImage`, `PointCloud`, +`ImageAnnotations`); see `pj_scene_protocol/docs/USER_GUIDE.md` for +producer/consumer recipes. diff --git a/pj_plugins/docs/data-source-guide.md b/pj_plugins/docs/data-source-guide.md index 791ba94..8b16d68 100644 --- a/pj_plugins/docs/data-source-guide.md +++ b/pj_plugins/docs/data-source-guide.md @@ -837,3 +837,56 @@ See `pj_plugins/docs/dialog-plugin-guide.md` for the dialog protocol itself. - `pj_plugins/examples/mock_source_with_dialog.cpp` demonstrates the DataSource-owned dialog pattern: a combined `.so` with two vtables, shared state via member ownership, and dialog read-only accessors. + +## Builtin-object pipeline (PR #86) — `pushMessage` + FetchMessageData + +For sources that fan out raw bytes to a `MessageParser` (MCAP, foxglove +bridge, future ROS-bag streamers), the runtime host exposes a v2 ingest +slot that takes a deferred callable instead of bytes. The plugin builds +a closure that knows how to materialise the payload, hands it to the +host, and stays policy-agnostic: + +```cpp +// One call per message — the host applies the active +// ObjectIngestPolicy (kEager / kLazyObjectsEagerScalars / kPureLazy) +// to decide whether to invoke the closure now, once for scalars, +// or only on consumer pull. +runtimeHost().pushMessage( + binding_handle, timestamp_ns, + [reader = reader_shared_ptr_, + offset = msg.offset]() -> PJ::sdk::PayloadView { + // Idempotent — may be called 0, 1, or N times. + return readMessageBytesAt(reader, offset); + }); +``` + +The closure may return: + +- `PJ::sdk::PayloadView { bytes, anchor }` — preferred, zero-copy. + `bytes` is a `Span` over a buffer the plugin keeps + alive via `anchor` (`PJ::sdk::BufferAnchor` = `std::shared_ptr`). +- `std::vector` — convenience. The SDK template heap-allocates + the vector and treats it as its own anchor. + +The plugin is policy-agnostic: + +- It does **not** consult `ObjectIngestPolicy`. +- It does **not** invoke the parser. +- It does **not** push to the `ObjectStore`. + +The runtime host orchestrates all three behind the slot. C-ABI +counterpart: `PJ_message_data_fetcher_t { ctx, fetchMessageData, +release }` in `pj_base/data_source_protocol.h`. The C++ template wraps +the closure into that struct; the host releases the context exactly +once when the callable is no longer needed (after the single fetch in +`kEager`, or when the `ObjectStore` entry is dropped in lazy modes). + +`fetchMessageData` MUST be thread-safe — the host may invoke it from +the ingest thread (`kEager`) or from consumer threads (lazy pulls). +Capture file readers / decompressed chunks by `shared_ptr` so the +source survives every pending pull. + +Reference implementation: `pj_ported_plugins/data_load_mcap` — closure +captures the open `mcap::McapReader` and the message offset, reads the +bytes on demand. See `PLUGIN_DEVELOPMENT.md` in that repo for the +catalog-side companion (`parser_ros`) and a top-down walkthrough. diff --git a/pj_plugins/docs/message-parser-guide.md b/pj_plugins/docs/message-parser-guide.md index 1ede495..6ec1b59 100644 --- a/pj_plugins/docs/message-parser-guide.md +++ b/pj_plugins/docs/message-parser-guide.md @@ -464,3 +464,53 @@ dispatch code. - `pj_base/tests/message_parser_plugin_base_test.cpp` — comprehensive test fixture exercising the full SDK surface: vtable generation, bind/parse round-trip, schema binding, config persistence, and exception safety. + +## Builtin-object pipeline (PR #86) + +Beyond the scalar-column output that `parse()` and the +`sdk::ParserWriteHostService` cover, the SDK adds a second, narrow +output channel for media-like payloads: a *builtin object* (image, +depth image, point cloud, image annotations) returned by name from the +parser, decoded once, and visualised by widgets that never learn the +wire format. + +Three optional virtual entry points on `MessageParserPluginBase` +participate: + +```cpp +// In your subclass — every override is optional. The base provides +// safe `unexpected(...)` defaults so legacy parsers compile unchanged. +PJ::Expected +classifySchema(std::string_view type_name, + PJ::Span schema) override; + +PJ::Expected> +parseScalars(PJ::Timestamp ts, PJ::sdk::PayloadView payload) override; + +PJ::Expected +parseObject(PJ::Timestamp ts, PJ::sdk::PayloadView payload) override; +``` + +- `classifySchema` is the *a-priori* declaration — given a type name + + schema bytes, announce which `BuiltinObjectKind` (`kImage`, + `kDepthImage`, `kPointCloud`, `kImageAnnotations`, `kNone`) this + schema produces. The host consults the answer **before** it ever sees + the payload, so it can pick the right `ObjectIngestPolicy` for the + topic. +- `parseScalars` writes the small-metadata fields (`width`, `height`, + `frame_id`, …) that should land in the curve tree as scalar columns. +- `parseObject` returns the actual builtin value, type-erased as + `PJ::sdk::BuiltinObject` (which is `std::any`). The host downcasts + with `std::any_cast(&obj)` to dispatch to the + matching viewer. + +Builtin types live under `pj_scene_protocol/builtin/`, one header per +type. `sdk::Image` carries an open-ended `std::string encoding` +(`"rgb8"`, `"bgr8"`, `"mono8"`, `"jpeg"`, `"png"`, `"compressedDepth"`, +…) so raw and compressed images share a single type. New kinds are +appended without changing the `BuiltinObject` type (its `std::any` +nature is forward-compatible by construction). + +Reference implementation: `pj_ported_plugins/parser_ros` — +`SchemaHandler` table driven by a static `catalog()`. See +`PLUGIN_DEVELOPMENT.md` in that repo for a top-down walkthrough. From c3eeeeeba4bfb05bbda4726f7bc494b066360120 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sat, 16 May 2026 17:38:13 +0200 Subject: [PATCH 14/18] refactor: fold scene protocol into pj_base --- CLAUDE.md | 45 +++- CMakeLists.txt | 1 - cmake/PlotJugglerSDKConfig.cmake.in | 6 +- docs/builtin_type.md | 214 ++++++++++++++++++ docs/image_annotations_format.md | 129 +++++++++++ pj_base/CMakeLists.txt | 9 +- .../include/pj_base}/builtin/BuiltinObject.h | 10 +- .../pj_base}/builtin/BuiltinObjectKind.h | 0 .../pj_base}/builtin/CommonImageEncoding.h | 0 .../include/pj_base}/builtin/DepthImage.h | 2 +- .../include/pj_base}/builtin/Image.h | 2 +- .../pj_base}/builtin/ImageAnnotations.h | 0 .../include/pj_base}/builtin/PointCloud.h | 0 .../pj_base}/builtin/depth_image_utils.h | 2 +- .../pj_base/builtin/image_annotations_codec.h | 27 +++ .../src/builtin/image_annotations_codec.cpp | 12 +- .../src/builtin/image_annotations_decoder.cpp | 127 +++++------ .../tests/image_annotations_codec_test.cpp | 26 +-- .../tests/image_annotations_decoder_test.cpp | 78 ++----- pj_datastore/CMakeLists.txt | 5 +- pj_plugins/CMakeLists.txt | 10 +- pj_plugins/docs/ARCHITECTURE.md | 6 +- pj_plugins/docs/message-parser-guide.md | 2 +- .../pj_plugins/host/message_parser_handle.hpp | 2 +- .../sdk/message_parser_plugin_base.hpp | 2 +- .../pj_plugins/sdk/object_ingest_policy.hpp | 2 +- pj_scene_protocol/CMakeLists.txt | 54 ----- pj_scene_protocol/docs/ARCHITECTURE.md | 123 ---------- pj_scene_protocol/docs/USER_GUIDE.md | 128 ----------- .../image_annotation_codec.h | 27 --- .../include/pj_scene_protocol/scene_decoder.h | 32 --- .../include/pj_scene_protocol/scene_frame.h | 23 -- pj_scene_protocol/src/scene_decoder.cpp | 18 -- 33 files changed, 533 insertions(+), 591 deletions(-) create mode 100644 docs/builtin_type.md create mode 100644 docs/image_annotations_format.md rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/BuiltinObject.h (87%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/BuiltinObjectKind.h (100%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/CommonImageEncoding.h (100%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/DepthImage.h (96%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/Image.h (96%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/ImageAnnotations.h (100%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/PointCloud.h (100%) rename {pj_scene_protocol/include/pj_scene_protocol => pj_base/include/pj_base}/builtin/depth_image_utils.h (97%) create mode 100644 pj_base/include/pj_base/builtin/image_annotations_codec.h rename pj_scene_protocol/src/image_annotation_codec.cpp => pj_base/src/builtin/image_annotations_codec.cpp (93%) rename pj_scene_protocol/src/scene_decoder_protobuf.cpp => pj_base/src/builtin/image_annotations_decoder.cpp (79%) rename pj_scene_protocol/tests/image_annotation_codec_test.cpp => pj_base/tests/image_annotations_codec_test.cpp (91%) rename pj_scene_protocol/tests/scene_decoder_test.cpp => pj_base/tests/image_annotations_decoder_test.cpp (68%) delete mode 100644 pj_scene_protocol/CMakeLists.txt delete mode 100644 pj_scene_protocol/docs/ARCHITECTURE.md delete mode 100644 pj_scene_protocol/docs/USER_GUIDE.md delete mode 100644 pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h delete mode 100644 pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h delete mode 100644 pj_scene_protocol/include/pj_scene_protocol/scene_frame.h delete mode 100644 pj_scene_protocol/src/scene_decoder.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 09a459d..c075e63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,16 +6,35 @@ PlotJuggler Core — C++20 foundation libraries for PlotJuggler storage, plugin ### Modules -- **pj_base** — vocabulary types, plugin ABI headers, plugin SDK headers (zero external deps) +- **pj_base** — foundational SDK/ABI types, canonical builtin object vocabulary, plugin ABI headers, + host-view helpers, ImageAnnotations codec, and shared utilities; public `magic_enum` dependency for enum/string helpers - **pj_datastore** — columnar storage engine + `ObjectStore` (for media blobs) + `DerivedEngine` (fmt, tsl::robin_map, nanoarrow) -- **pj_plugins** — C-ABI plugin protocol, C++ SDK base classes, plugin discovery, host-side loaders, config envelope helpers, and dialog protocol primitives; four plugin families: DataSource, MessageParser, Dialog, Toolbox -- **pj_scene_protocol** — canonical schema + Foxglove `ImageAnnotations` Protobuf codec (writer + reader); SDK boundary for plugin authors producing or consuming 2D markers / scene primitives. `pj_base`-only deps. +- **pj_plugins** — C++ plugin SDK base classes, plugin discovery, host-side loaders, config envelope + helpers, and dialog protocol primitives; four plugin families: DataSource, MessageParser, Dialog, Toolbox ### Dependency graph +- `pj_base` → `magic_enum` - `pj_datastore` → `pj_base` - `pj_plugins` → `pj_base` -- `pj_scene_protocol` → `pj_base` + +## Documentation Workflow + +Coding agents should use this context hierarchy: + +```text +CLAUDE.md -> relevant docs -> code +``` + +- Start with this file to identify the module and the relevant source-of-truth documents. +- Read the relevant docs before treating code as authoritative for intent. Code shows current implementation + details; docs define the intended architecture, public contracts, terminology, and module boundaries. +- If docs and code disagree, treat that as a documentation consistency problem. Do not silently let stale docs survive. +- Any change to behavior, public APIs, ABI structs, SDK types, module ownership, plugin workflows, + storage formats, or user-facing semantics must include a documentation check. +- Before any commit, verify that documentation is up to date for the change. If docs are stale and the user did + not explicitly ask for a docs update, ask whether to update the docs before committing. Do not commit + known-stale documentation. ## Key Documentation @@ -23,6 +42,8 @@ PlotJuggler Core — C++20 foundation libraries for PlotJuggler storage, plugin | Document | Content | |----------|---------| +| `docs/builtin_type.md` | Canonical builtin object types used as the shim between third-party schemas and PlotJuggler internals | +| `docs/image_annotations_format.md` | Canonical `foxglove.ImageAnnotations` wire format for builtin `ImageAnnotations` payloads | | `docs/cpp_design_recommendations.md` | C++ style, error handling, API design guidelines | | `docs/toolbox-porting-gap-analysis.md` | SDK gaps identified when porting PJ3 toolboxes (being addressed) | @@ -46,13 +67,6 @@ PlotJuggler Core — C++20 foundation libraries for PlotJuggler storage, plugin | `dialog-plugin-guide.md` | SDK tutorial: WidgetData, typed events, EmbedUi, requestAccept, onTick | | `toolbox-guide.md` | SDK tutorial: read+write access, catalog, notifyDataChanged | -**Scene protocol** (`pj_scene_protocol/docs/`): - -| Document | Content | -|----------|---------| -| `ARCHITECTURE.md` | Wire format spec (`foxglove.ImageAnnotations` Protobuf), type catalog, encoding rules, design rationale (single canonical decoder, loader-side conversion) | -| `USER_GUIDE.md` | Producer recipe (loader writing markers) and consumer recipe (sink/viewer decoding), common pitfalls, pointer to PJ4 reference adapters | - ## Build & Test ```bash @@ -65,6 +79,9 @@ Dependencies: Conan (`conanfile.txt`). ## Pre-commit Validation +Before committing, first check whether the code changes require documentation updates. If documentation is stale +and the requested task did not include updating it, ask the user whether to update the docs before committing. + Before committing, always run: ```bash @@ -73,10 +90,14 @@ Before committing, always run: ## Instructions Glossary -- **"Read all documentation"** means: find and read every `.md` file in the entire project tree (all subdirectories). Use `find . -name "*.md"` or equivalent. This includes docs in `pj_base/`, `pj_datastore/docs/`, `pj_plugins/docs/`, `pj_scene_protocol/docs/`, and any other location. +- **"Read all documentation"** means: find and read every `.md` file in the entire project tree (all subdirectories). Use `find . -name "*.md"` or equivalent. This includes docs in `docs/`, `pj_datastore/docs/`, `pj_plugins/docs/`, and any other location. - **"Update the documentation"** means: based on what you learned during this session, correct any documentation that is outdated or inaccurate, and clarify any ambiguity that caused confusion or errors. If a doc says one thing but the code does another, fix the doc to match reality. If missing information led to a bug, add it. +- **"Check documentation"** means: review the docs listed above that are related to the changed module or API. + Confirm they still describe the current intent and behavior. If not, update them or ask the user before + committing. + ## Coding Conventions - **Formatting:** Google style via `.clang-format` — 2-space indent, 120-char limit diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e7f1f2..f69d921 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,7 +158,6 @@ add_subdirectory(pj_base) if(PJ_BUILD_DATASTORE) add_subdirectory(pj_datastore) endif() -add_subdirectory(pj_scene_protocol) add_subdirectory(pj_plugins) if(PJ_BUILD_PORTED_PLUGINS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/pj_ported_plugins/CMakeLists.txt") diff --git a/cmake/PlotJugglerSDKConfig.cmake.in b/cmake/PlotJugglerSDKConfig.cmake.in index aedde36..253c6f2 100644 --- a/cmake/PlotJugglerSDKConfig.cmake.in +++ b/cmake/PlotJugglerSDKConfig.cmake.in @@ -7,13 +7,17 @@ if(NOT PlotJugglerSDK_FIND_COMPONENTS) set(PlotJugglerSDK_FIND_COMPONENTS DataSource) endif() +# pj_base carries the SDK builtin-object vocabulary and publicly depends on +# magic_enum for enum/string helpers, so the exported targets need it before +# they are loaded. +find_dependency(magic_enum) + # Include the exported targets (defines PlotJugglerSDK::DataSource, etc.). include("${CMAKE_CURRENT_LIST_DIR}/PlotJugglerSDKTargets.cmake") # Per-component validation. foreach(_comp ${PlotJugglerSDK_FIND_COMPONENTS}) if(_comp STREQUAL "DataSource") - # No additional dependencies — pj_base is self-contained. set(PlotJugglerSDK_DataSource_FOUND TRUE) elseif(_comp STREQUAL "Dialog") diff --git a/docs/builtin_type.md b/docs/builtin_type.md new file mode 100644 index 0000000..12f7d7f --- /dev/null +++ b/docs/builtin_type.md @@ -0,0 +1,214 @@ +# Builtin Types + +Builtin types are PlotJuggler's canonical SDK vocabulary for object-like data. +They are the shim between third-party message families and the data shapes that +PlotJuggler can classify, store, decode, and render consistently. + +A plugin that reads a ROS message, a Protobuf message, a JSON payload, or any +other source-specific format converts that input into one of these types. The +conversion removes source-specific naming and wire-layout details while keeping +the semantic value intact. For example, a ROS `sensor_msgs/Image` and a +Foxglove image message can both become `PJ::sdk::Image`; a ROS +`sensor_msgs/PointCloud2` can become `PJ::sdk::PointCloud`. + +The public headers live under: + +```cpp +#include +#include +#include +#include +#include +#include +#include +``` + +## Design Principles + +**Convert at the boundary.** DataSource and MessageParser plugins understand +third-party schemas. PlotJuggler internals consume builtin types. This keeps ROS, +Foxglove, dataset-specific, and vendor-specific details out of viewers and +storage policy. + +**Unify when only the encoding differs.** Raw `rgb8`, `jpeg`, and `png` are all +images. They share the same consumer semantics, so they are represented by +`Image` with an `encoding` string rather than separate raw/compressed types. + +**Split when the semantic value differs.** A `mono16` grayscale image and a +`16UC1` depth map can have similar byte layouts, but they do not mean the same +thing. Depth data is represented by `DepthImage` because consumers interpret it +as metric distance with camera intrinsics. + +**Keep large buffers zero-copy capable.** Byte-backed types carry a +`Span` plus a `BufferAnchor`. The span points at the payload; the +anchor keeps the underlying allocation alive while consumers use it. + +**Keep small annotations owned.** `ImageAnnotations` owns its vectors directly. +Overlay data is small enough that the zero-copy anchor pattern is unnecessary. + +## Type Erasure and Classification + +`BuiltinObjectKind` is the a-priori tag a parser reports for a schema. It lets +the host decide that a topic produces images, point clouds, depth images, image +annotations, or no builtin object. + +| Kind | Concrete type | Purpose | +|------|---------------|---------| +| `kNone` | none | Scalar-only schema or unknown object. | +| `kImage` | `PJ::sdk::Image` | Raw or compressed image data. | +| `kPointCloud` | `PJ::sdk::PointCloud` | Packed 3D point records. | +| `kDepthImage` | `PJ::sdk::DepthImage` | Depth pixels plus camera intrinsics. | +| `kImageAnnotations` | `PJ::sdk::ImageAnnotations` | Pixel-space overlay primitives. | + +`BuiltinObject` is `std::any`. Producers store a concrete builtin value in it; +consumers recover the concrete type with `std::any_cast(&object)` or ask +`kindOf(object)` for the kind supported by the current SDK build. + +## Image + +`Image` is a self-contained image payload. It covers raw pixel buffers and +single-frame compressed payloads. The `encoding` string tells consumers how to +interpret `data`. + +Use `Image` when the decoded value is a color or luminance image: camera frames, +screenshots, thumbnails, JPEG/PNG-compressed image messages, or raw image +messages. + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp_ns` | `Timestamp` | Timestamp associated with the image. | +| `width` | `uint32_t` | Image width in pixels. | +| `height` | `uint32_t` | Image height in pixels. | +| `encoding` | `std::string` | Raw pixel layout or compression codec. | +| `row_step` | `uint32_t` | Bytes per row for raw encodings; `0` for compressed payloads. | +| `is_bigendian` | `bool` | Meaningful for multi-byte raw encodings such as `mono16`. | +| `data` | `Span` | Raw pixel bytes or compressed payload bytes. | +| `anchor` | `BufferAnchor` | Keeps `data` alive when it references shared storage. | +| `compressed_depth_min` | `std::optional` | ROS compressed-depth quantization metadata, when present. | +| `compressed_depth_max` | `std::optional` | ROS compressed-depth quantization metadata, when present. | + +Common raw encodings are `rgb8`, `rgba8`, `bgr8`, `bgra8`, `mono8`, and +`mono16`. Common compressed encodings are `jpeg`, `png`, and `qoi`. +`compressedDepth` is supported for ROS-style compressed depth image payloads. + +`Image::encoding` is intentionally open-ended. `CommonImageEncoding` documents +the encodings known to the SDK and provides string conversion helpers, but +plugins may still emit conventional source-specific encodings when needed. + +## DepthImage + +`DepthImage` is a self-contained depth map. Pixels represent distance from the +camera, and the object carries the camera intrinsics needed to interpret pixels +geometrically. + +Use `DepthImage` when consumers should treat the payload as metric depth rather +than luminance. A ROS depth image such as `16UC1` or `32FC1` is a typical source +for this type. + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp_ns` | `Timestamp` | Timestamp associated with the depth frame. | +| `width` | `uint32_t` | Image width in pixels. | +| `height` | `uint32_t` | Image height in pixels. | +| `encoding` | `std::string` | Depth pixel representation, such as `16UC1` or `32FC1`. | +| `data` | `Span` | Depth pixel bytes. | +| `anchor` | `BufferAnchor` | Keeps `data` alive when it references shared storage. | +| `K` | `std::array` | 3x3 row-major camera intrinsic matrix. | +| `distortion_model` | `std::string` | Empty for rectified images; otherwise identifies the distortion model. | +| `D` | `std::vector` | Distortion coefficients for `distortion_model`. | + +`K` follows the usual camera matrix convention: + +```text +[ fx 0 cx ] +[ 0 fy cy ] +[ 0 0 1 ] +``` + +Helpers in `pj_base/builtin/depth_image_utils.h` derive common matrices such as +rectification rotation and projection matrix when a consumer wants them. + +## PointCloud + +`PointCloud` is a packed array of point records. Each point occupies +`point_step` bytes, and `fields` describes where each channel lives inside one +point record. + +Use `PointCloud` for converted point-cloud messages such as ROS +`sensor_msgs/PointCloud2`, LiDAR packets that have been assembled into points, +or any source that produces a packed point buffer. + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp_ns` | `Timestamp` | Timestamp associated with the cloud. | +| `width` | `uint32_t` | Number of points per row, or total points for unorganized clouds. | +| `height` | `uint32_t` | Number of rows; `1` for unorganized clouds. | +| `point_step` | `uint32_t` | Bytes per point. | +| `row_step` | `uint32_t` | Bytes per row. Usually `point_step * width` when tightly packed. | +| `is_bigendian` | `bool` | Whether packed field values are big-endian. | +| `is_dense` | `bool` | `false` when some points may be invalid, typically NaN-filled. | +| `fields` | `std::vector` | Channel layout for each point. | +| `data` | `Span` | Packed point bytes. | +| `anchor` | `BufferAnchor` | Keeps `data` alive when it references shared storage. | + +Each `PointField` describes one channel: + +| Field | Type | Notes | +|-------|------|-------| +| `name` | `std::string` | Channel name, such as `x`, `y`, `z`, `intensity`, `rgb`, `ring`, or `time`. | +| `offset` | `uint32_t` | Byte offset of this channel inside one point. | +| `datatype` | `PointField::Datatype` | One of signed/unsigned integer or floating-point scalar types. | +| `count` | `uint32_t` | Number of elements of `datatype`; usually `1`. | + +The point layout intentionally mirrors common robotics formats while avoiding a +ROS-specific enum in the SDK type. + +## ImageAnnotations + +`ImageAnnotations` contains vector overlays in image-pixel coordinates. It is +used for detections, labels, tracked points, masks expressed as outlines, and +other lightweight 2D overlays that are drawn on top of an image. + +Use `ImageAnnotations` when the coordinates are pixels in a specific image, not +world coordinates. The type references the base image topic through +`image_topic`, allowing a renderer to associate annotations with their image +stream. + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp` | `Timestamp` | Timestamp associated with the annotation set. | +| `image_topic` | `std::string` | Topic of the image these annotations overlay. | +| `points` | `std::vector` | Points, line lists, line strips, and line loops. | +| `circles` | `std::vector` | Filled or stroked circles in image-pixel space. | +| `texts` | `std::vector` | Text labels anchored at pixel positions. | + +`PointsAnnotation` supports four topologies: + +| Topology | Meaning | +|----------|---------| +| `kPoints` | Each point is independent. | +| `kLineList` | Consecutive pairs form independent line segments. | +| `kLineStrip` | Points form a connected polyline. | +| `kLineLoop` | Like a line strip, but the last point connects back to the first. | + +Colors are RGBA `uint8_t` values. If `PointsAnnotation::colors` is empty, the +uniform `color` applies to every vertex. If `colors.size() == points.size()`, +per-vertex colors are used. `fill_color` applies to closed loops and circles +when its alpha channel is non-zero. + +`pj_base/builtin/image_annotations_codec.h` serializes and deserializes this +type using the canonical `foxglove.ImageAnnotations` protobuf wire format. +See [image_annotations_format.md](image_annotations_format.md) for the field +mapping and compatibility rules. + +## Conversion Examples + +| Source type | Canonical builtin type | Conversion intent | +|-------------|------------------------|-------------------| +| ROS `sensor_msgs/Image` | `Image` or `DepthImage` | Choose `DepthImage` when the semantic value is metric depth; otherwise use `Image`. | +| ROS `sensor_msgs/CompressedImage` | `Image` | Preserve compressed bytes and set `encoding` to the codec. | +| ROS `sensor_msgs/PointCloud2` | `PointCloud` | Map point fields, strides, density, endianness, and packed bytes. | +| Detection or tracking message | `ImageAnnotations` | Convert boxes, points, circles, and labels into pixel-space primitives. | + +The builtin type is the boundary object. After conversion, consumers should not +need to know which third-party schema produced it. diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md new file mode 100644 index 0000000..d14d5c4 --- /dev/null +++ b/docs/image_annotations_format.md @@ -0,0 +1,129 @@ +# Image Annotations Format + +PlotJuggler uses a canonical ImageAnnotations wire format when annotation overlays need to be stored, transported, or replayed as bytes. Source-specific adapters convert third-party messages, such as ROS messages, into `PJ::sdk::ImageAnnotations`; the codec then serializes that canonical value using the Foxglove `ImageAnnotations` protobuf schema. + +This keeps PlotJuggler internals and renderers independent from the original message schema. Consumers should decode the bytes into `PJ::sdk::ImageAnnotations` and operate on the canonical values, not on the source message type that produced them. + +For the broader builtin type catalog, see [builtin_type.md](builtin_type.md). + +## Schema + +The schema identifier for this format is: + +```text +foxglove.ImageAnnotations +``` + +The public C++ helpers live in: + +```cpp +#include +``` + +`serializeImageAnnotations()` writes this payload. `deserializeImageAnnotations()` +reads it back into `PJ::sdk::ImageAnnotations`. + +The encoded payload is a protobuf message. The current codec writes only the fields listed below and skips unknown fields while reading. + +## Top-Level Message + +`foxglove.ImageAnnotations` + +| Field | Type | Meaning | +| --- | --- | --- | +| `1` | repeated `CircleAnnotation` | Circle overlays | +| `2` | repeated `PointsAnnotation` | Point and line overlays | +| `3` | repeated `TextAnnotation` | Text overlays | + +An annotation payload with no circles, points, or texts serializes to an empty byte buffer. Decoding an empty buffer is treated as invalid input by the current reader. + +## Shared Messages + +`foxglove.Point2` + +| Field | Type | Meaning | +| --- | --- | --- | +| `1` | double | X coordinate in image pixels | +| `2` | double | Y coordinate in image pixels | + +`foxglove.Color` + +| Field | Type | Meaning | +| --- | --- | --- | +| `1` | double | Red channel, normalized to `[0, 1]` | +| `2` | double | Green channel, normalized to `[0, 1]` | +| `3` | double | Blue channel, normalized to `[0, 1]` | +| `4` | double | Alpha channel, normalized to `[0, 1]` | + +Canonical colors use 8-bit channels. Encoding converts each channel to a normalized double; decoding converts the normalized value back to an 8-bit channel. Round trips may differ by plus or minus 1 because of numeric rounding. + +## Circle Annotations + +`foxglove.CircleAnnotation` + +| Field | Type | Canonical value | +| --- | --- | --- | +| `2` | `Point2` | `center` | +| `3` | double | `radius * 2.0` | +| `4` | double | `thickness` | +| `5` | `Color` | `fill_color` | +| `6` | `Color` | `outline_color` | + +The wire schema stores circle size as a diameter. The canonical type stores it as a radius, so the codec converts between the two. + +## Point Annotations + +`foxglove.PointsAnnotation` + +| Field | Type | Canonical value | +| --- | --- | --- | +| `2` | enum | `type` | +| `3` | repeated `Point2` | `points` | +| `4` | `Color` | `outline_color` | +| `5` | repeated `Color` | `outline_colors` | +| `6` | `Color` | `fill_color` | +| `7` | double | `thickness` | + +The point topology is encoded as: + +| Wire value | Canonical topology | +| --- | --- | +| `1` | `kPoints` | +| `2` | `kLineLoop` | +| `3` | `kLineStrip` | +| `4` | `kLineList` | + +The writer never emits `0`. The reader treats an unknown or zero topology as `kPoints` so old or incomplete payloads still decode to a renderable annotation. + +`outline_colors` is optional. If it is empty, field `5` is omitted and the annotation uses the uniform `outline_color`. + +## Text Annotations + +`foxglove.TextAnnotation` + +| Field | Type | Canonical value | +| --- | --- | --- | +| `2` | `Point2` | `position` | +| `3` | string | `text` | +| `4` | double | `font_size` | +| `5` | `Color` | `text_color` | +| `6` | `Color` | skipped by the current canonical type | + +`background_color` is present in the wire schema but is not represented by `PJ::sdk::ImageAnnotations`, so it is skipped during decoding and not emitted during encoding. + +## Unsupported Canonical Fields + +`PJ::sdk::ImageAnnotations` also carries metadata that is useful inside PlotJuggler but is not part of this wire payload: + +| Canonical field | Wire behavior | +| --- | --- | +| `timestamp` | Not serialized | +| `image_topic` | Not serialized | + +Adapters that need those fields must preserve them outside this payload. + +## Reader Behavior + +The reader accepts protobuf fields with wire types `VARINT`, `I64`, `LEN`, and `I32`. It decodes the fields listed in this document and skips unknown fields, including unknown nested fields, so the format can tolerate compatible schema additions. + +Malformed protobuf data, invalid length-delimited fields, or truncated nested messages fail decoding. diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 9390d20..c27d7c9 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -1,8 +1,12 @@ # --------------------------------------------------------------------------- -# pj_base — vocabulary types, zero external dependencies +# pj_base — SDK vocabulary types and ABI surface # --------------------------------------------------------------------------- +find_package(magic_enum CONFIG REQUIRED) + add_library(pj_base STATIC + src/builtin/image_annotations_codec.cpp + src/builtin/image_annotations_decoder.cpp src/type_tree.cpp ) target_include_directories(pj_base PUBLIC @@ -17,6 +21,7 @@ set_target_properties(pj_base PROPERTIES POSITION_INDEPENDENT_CODE ON EXPORT_NAME DataSource ) +target_link_libraries(pj_base PUBLIC magic_enum::magic_enum) if(PJ_ASSERT_THROWS) target_compile_definitions(pj_base PUBLIC PJ_ASSERT_THROWS) endif() @@ -55,6 +60,8 @@ if(PJ_BUILD_TESTS) tests/abi_layout_sentinels_test.cpp tests/platform_test.cpp tests/arrow_holders_test.cpp + tests/image_annotations_codec_test.cpp + tests/image_annotations_decoder_test.cpp tests/media_metadata_test.cpp tests/push_message_v2_test.cpp ) diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h b/pj_base/include/pj_base/builtin/BuiltinObject.h similarity index 87% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h rename to pj_base/include/pj_base/builtin/BuiltinObject.h index 264bc7f..69e8c52 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObject.h +++ b/pj_base/include/pj_base/builtin/BuiltinObject.h @@ -19,11 +19,11 @@ #include -#include "pj_scene_protocol/builtin/BuiltinObjectKind.h" -#include "pj_scene_protocol/builtin/DepthImage.h" -#include "pj_scene_protocol/builtin/Image.h" -#include "pj_scene_protocol/builtin/ImageAnnotations.h" -#include "pj_scene_protocol/builtin/PointCloud.h" +#include "pj_base/builtin/BuiltinObjectKind.h" +#include "pj_base/builtin/DepthImage.h" +#include "pj_base/builtin/Image.h" +#include "pj_base/builtin/ImageAnnotations.h" +#include "pj_base/builtin/PointCloud.h" namespace PJ { namespace sdk { diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObjectKind.h b/pj_base/include/pj_base/builtin/BuiltinObjectKind.h similarity index 100% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/BuiltinObjectKind.h rename to pj_base/include/pj_base/builtin/BuiltinObjectKind.h diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/CommonImageEncoding.h b/pj_base/include/pj_base/builtin/CommonImageEncoding.h similarity index 100% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/CommonImageEncoding.h rename to pj_base/include/pj_base/builtin/CommonImageEncoding.h diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h b/pj_base/include/pj_base/builtin/DepthImage.h similarity index 96% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h rename to pj_base/include/pj_base/builtin/DepthImage.h index 3238a31..3daf7ae 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/builtin/DepthImage.h +++ b/pj_base/include/pj_base/builtin/DepthImage.h @@ -41,7 +41,7 @@ namespace sdk { /// R: rectification rotation. Identity for rectified images. /// P: projection matrix [K | 0_3] (3×4) for rectified images. /// -/// Helpers in pj_scene_protocol/builtin/depth_image_utils.h produce R +/// Helpers in pj_base/builtin/depth_image_utils.h produce R /// and P when a consumer wants them precomputed. /// /// `anchor` keeps the underlying buffer alive — the producer may have diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h b/pj_base/include/pj_base/builtin/Image.h similarity index 96% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h rename to pj_base/include/pj_base/builtin/Image.h index b18111f..109e17d 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/builtin/Image.h +++ b/pj_base/include/pj_base/builtin/Image.h @@ -35,7 +35,7 @@ namespace sdk { /// `compressed_depth_max` carry the quantization range needed to map /// the grayscale back to depth values. /// -/// See pj_scene_protocol/builtin/CommonImageEncoding.h for the documented +/// See pj_base/builtin/CommonImageEncoding.h for the documented /// vocabulary of canonical encoding strings, with helpers to parse and /// emit them. /// diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h b/pj_base/include/pj_base/builtin/ImageAnnotations.h similarity index 100% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/ImageAnnotations.h rename to pj_base/include/pj_base/builtin/ImageAnnotations.h diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/PointCloud.h b/pj_base/include/pj_base/builtin/PointCloud.h similarity index 100% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/PointCloud.h rename to pj_base/include/pj_base/builtin/PointCloud.h diff --git a/pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h b/pj_base/include/pj_base/builtin/depth_image_utils.h similarity index 97% rename from pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h rename to pj_base/include/pj_base/builtin/depth_image_utils.h index 727f580..6d3eb0b 100644 --- a/pj_scene_protocol/include/pj_scene_protocol/builtin/depth_image_utils.h +++ b/pj_base/include/pj_base/builtin/depth_image_utils.h @@ -13,7 +13,7 @@ #include -#include "pj_scene_protocol/builtin/DepthImage.h" +#include "pj_base/builtin/DepthImage.h" namespace PJ { namespace sdk { diff --git a/pj_base/include/pj_base/builtin/image_annotations_codec.h b/pj_base/include/pj_base/builtin/image_annotations_codec.h new file mode 100644 index 0000000..b1f7f83 --- /dev/null +++ b/pj_base/include/pj_base/builtin/image_annotations_codec.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include + +#include "pj_base/builtin/ImageAnnotations.h" +#include "pj_base/expected.hpp" + +namespace PJ { + +/// Wire-format identifier for canonical image annotations. +inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; + +/// Serializes a sdk::ImageAnnotations to canonical foxglove.ImageAnnotations Protobuf bytes. +/// +/// `timestamp` and `image_topic` are not serialized; callers that need them +/// must preserve them outside this payload. +[[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); + +/// Decodes canonical foxglove.ImageAnnotations Protobuf bytes into sdk::ImageAnnotations. +/// +/// Returns an error for null, empty, truncated, or malformed payloads. +[[nodiscard]] Expected deserializeImageAnnotations(const uint8_t* data, size_t size); + +} // namespace PJ diff --git a/pj_scene_protocol/src/image_annotation_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp similarity index 93% rename from pj_scene_protocol/src/image_annotation_codec.cpp rename to pj_base/src/builtin/image_annotations_codec.cpp index e596fe0..f8d2b78 100644 --- a/pj_scene_protocol/src/image_annotation_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -1,4 +1,4 @@ -#include "pj_scene_protocol/image_annotation_codec.h" +#include "pj_base/builtin/image_annotations_codec.h" #include #include @@ -15,14 +15,14 @@ using sdk::Point2; using sdk::PointsAnnotation; using sdk::TextAnnotation; -// Hand-rolled Protobuf wire emission. Mirror of the reader at -// `src/scene_decoder_protobuf.cpp` (same module). +// Hand-rolled Protobuf wire emission. Mirror of the reader in +// image_annotations_decoder.cpp. // // Wire format spec: https://protobuf.dev/programming-guides/encoding/ // Wire types we emit: VARINT(0), I64(1), LEN(2). I32(5) is unused here. // // Sub-messages are length-delimited: write the body to a scratch buffer, then -// write the parent's tag + length + body. Bodies are bounded (≤ a few hundred +// write the parent's tag + length + body. Bodies are bounded (at most a few hundred // bytes for typical annotations), so the extra allocation is fine. void writeVarint(std::vector& out, uint64_t v) { @@ -115,7 +115,7 @@ std::vector buildPointsAnnotation(const PointsAnnotation& pa) { writeLenDelim(body, buildColor(pa.color)); // Per-vertex colors: emit one field-5 entry per element. An empty `colors` - // vector emits zero entries — critical, because the reader pushes_back on + // vector emits zero entries; the reader pushes_back on // every field-5 occurrence, so emitting an empty Color would smuggle a // default-constructed entry into out.colors. for (const auto& c : pa.colors) { @@ -160,7 +160,7 @@ std::vector buildCircleAnnotation(const CircleAnnotation& ca) { // foxglove.TextAnnotation // { 2: position (Point2), 3: text (string), 4: font_size (double), // 5: text_color } -// background_color (field 6) is intentionally NOT emitted — the C++ struct has +// background_color (field 6) is intentionally not emitted. The C++ struct has // no equivalent field. The reader skips it on read. std::vector buildTextAnnotation(const TextAnnotation& ta) { std::vector body; diff --git a/pj_scene_protocol/src/scene_decoder_protobuf.cpp b/pj_base/src/builtin/image_annotations_decoder.cpp similarity index 79% rename from pj_scene_protocol/src/scene_decoder_protobuf.cpp rename to pj_base/src/builtin/image_annotations_decoder.cpp index 0dfc7ed..bc53a8b 100644 --- a/pj_scene_protocol/src/scene_decoder_protobuf.cpp +++ b/pj_base/src/builtin/image_annotations_decoder.cpp @@ -1,11 +1,9 @@ #include #include -#include #include #include -#include -#include "pj_scene_protocol/scene_decoder.h" +#include "pj_base/builtin/image_annotations_codec.h" namespace PJ { namespace { @@ -20,8 +18,7 @@ using sdk::TextAnnotation; // Minimal Protobuf wire-format reader for foxglove.ImageAnnotations. Decodes // PointsAnnotation, CircleAnnotation, and TextAnnotation in full. Round-trips -// against the sibling writer at `src/image_annotation_codec.cpp` are covered -// by `tests/image_annotation_codec_test.cpp`. +// against the sibling writer are covered by image_annotations_codec_test. // // Spec reference: https://protobuf.dev/programming-guides/encoding/ // Wire types we need: VARINT(0), I64(1), LEN(2). I32(5) skipped if encountered. @@ -133,7 +130,7 @@ AnnotationTopology mapTopology(uint64_t type) { return AnnotationTopology::kLineList; case 0: default: - return AnnotationTopology::kPoints; // UNKNOWN → safe default + return AnnotationTopology::kPoints; // UNKNOWN maps to a safe default. } } @@ -307,7 +304,7 @@ bool decodeCircleAnnotation(Reader& r, size_t len, CircleAnnotation& out) { if (sub_end > r.end) { return false; } - // Defaults match pj_scene_protocol/builtin/ImageAnnotations.h. + // Defaults match pj_base/builtin/ImageAnnotations.h. out.color = {0, 255, 0, 255}; out.fill_color = {0, 0, 0, 0}; out.thickness = 2.0; @@ -364,8 +361,8 @@ bool decodeCircleAnnotation(Reader& r, size_t len, CircleAnnotation& out) { // Decode one foxglove.TextAnnotation sub-message: // timestamp(1)=Time, position(2)=Point2, text(3)=string, font_size(4)=double, -// text_color(5)=Color, background_color(6)=Color (background_color skipped — not -// present in pj_scene_protocol/builtin/ImageAnnotations.h::sdk::TextAnnotation). +// text_color(5)=Color, background_color(6)=Color (background_color skipped; not +// present in pj_base/builtin/ImageAnnotations.h::sdk::TextAnnotation). bool decodeTextAnnotation(Reader& r, size_t len, TextAnnotation& out) { const uint8_t* sub_end = r.p + len; if (sub_end > r.end) { @@ -419,73 +416,63 @@ bool decodeTextAnnotation(Reader& r, size_t len, TextAnnotation& out) { return true; } -// Decode the top-level ImageAnnotations message. -class ProtobufImageAnnotationsDecoder final : public ISceneDecoder { - public: - Expected decode(const uint8_t* data, size_t size) override { - if (data == nullptr || size == 0) { - return unexpected(std::string("Protobuf ImageAnnotations: empty buffer")); - } - Reader r{data, data + size}; +} // namespace - sdk::ImageAnnotations ia; - while (!r.eof()) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad tag")); - } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); +Expected deserializeImageAnnotations(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) { + return unexpected(std::string("Protobuf ImageAnnotations: empty buffer")); + } + Reader r{data, data + size}; - if (field == 2 && wire == 2) { - uint64_t pa_len = 0; - if (!r.readVarint(pa_len)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad PointsAnnotation length")); - } - PointsAnnotation pa; - pa.color = {0, 255, 0, 255}; - pa.thickness = 2.0; - if (!decodePointsAnnotation(r, pa_len, pa)) { - return unexpected(std::string("Protobuf ImageAnnotations: PointsAnnotation decode failed")); - } - ia.points.push_back(std::move(pa)); - } else if (field == 1 && wire == 2) { - uint64_t ca_len = 0; - if (!r.readVarint(ca_len)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad CircleAnnotation length")); - } - CircleAnnotation ca; - if (!decodeCircleAnnotation(r, ca_len, ca)) { - return unexpected(std::string("Protobuf ImageAnnotations: CircleAnnotation decode failed")); - } - ia.circles.push_back(std::move(ca)); - } else if (field == 3 && wire == 2) { - uint64_t ta_len = 0; - if (!r.readVarint(ta_len)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad TextAnnotation length")); - } - TextAnnotation ta; - if (!decodeTextAnnotation(r, ta_len, ta)) { - return unexpected(std::string("Protobuf ImageAnnotations: TextAnnotation decode failed")); - } - ia.texts.push_back(std::move(ta)); - } else { - if (!r.skipField(wire)) { - return unexpected(std::string("Protobuf ImageAnnotations: skip failed")); - } - } + sdk::ImageAnnotations ia; + while (!r.eof()) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad tag")); } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); - SceneFrame sf; - sf.annotations.push_back(std::move(ia)); - return sf; + if (field == 2 && wire == 2) { + uint64_t pa_len = 0; + if (!r.readVarint(pa_len)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad PointsAnnotation length")); + } + PointsAnnotation pa; + pa.color = {0, 255, 0, 255}; + pa.thickness = 2.0; + if (!decodePointsAnnotation(r, pa_len, pa)) { + return unexpected(std::string("Protobuf ImageAnnotations: PointsAnnotation decode failed")); + } + ia.points.push_back(std::move(pa)); + } else if (field == 1 && wire == 2) { + uint64_t ca_len = 0; + if (!r.readVarint(ca_len)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad CircleAnnotation length")); + } + CircleAnnotation ca; + if (!decodeCircleAnnotation(r, ca_len, ca)) { + return unexpected(std::string("Protobuf ImageAnnotations: CircleAnnotation decode failed")); + } + ia.circles.push_back(std::move(ca)); + } else if (field == 3 && wire == 2) { + uint64_t ta_len = 0; + if (!r.readVarint(ta_len)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad TextAnnotation length")); + } + TextAnnotation ta; + if (!decodeTextAnnotation(r, ta_len, ta)) { + return unexpected(std::string("Protobuf ImageAnnotations: TextAnnotation decode failed")); + } + ia.texts.push_back(std::move(ta)); + } else { + if (!r.skipField(wire)) { + return unexpected(std::string("Protobuf ImageAnnotations: skip failed")); + } + } } -}; - -} // namespace -std::unique_ptr makeSceneDecoderProtobufImageAnnotations() { - return std::make_unique(); + return ia; } } // namespace PJ diff --git a/pj_scene_protocol/tests/image_annotation_codec_test.cpp b/pj_base/tests/image_annotations_codec_test.cpp similarity index 91% rename from pj_scene_protocol/tests/image_annotation_codec_test.cpp rename to pj_base/tests/image_annotations_codec_test.cpp index 4f6061b..7c32180 100644 --- a/pj_scene_protocol/tests/image_annotation_codec_test.cpp +++ b/pj_base/tests/image_annotations_codec_test.cpp @@ -1,4 +1,4 @@ -#include "pj_scene_protocol/image_annotation_codec.h" +#include "pj_base/builtin/image_annotations_codec.h" #include @@ -7,9 +7,6 @@ #include #include -#include "pj_scene_protocol/scene_decoder.h" // existing reader, used for round-trips -#include "pj_scene_protocol/scene_frame.h" - namespace PJ { namespace { @@ -22,8 +19,7 @@ using sdk::PointsAnnotation; using sdk::TextAnnotation; // ----------------------------------------------------------------------------- -// Hand-rolled Protobuf helpers — same style as the sibling decoder test -// (`tests/scene_decoder_test.cpp`). Used to build expected byte sequences for +// Hand-rolled Protobuf helpers. Used to build expected byte sequences for // golden-byte tests. // ----------------------------------------------------------------------------- namespace pb { @@ -56,20 +52,16 @@ inline void appendLenDelim(std::vector& out, const std::vector } // namespace pb // Decode the bytes produced by serializeImageAnnotations back into an -// sdk::ImageAnnotations. Returns the inner annotation; assumes the SceneFrame wraps -// exactly one sdk::ImageAnnotations (the reader's contract). +// sdk::ImageAnnotations. sdk::ImageAnnotations roundTrip(const sdk::ImageAnnotations& input) { auto bytes = serializeImageAnnotations(input); - auto decoder = makeSceneDecoder(kSchemaImageAnnotations); - EXPECT_NE(decoder.get(), nullptr); - auto result = decoder->decode(bytes.data(), bytes.size()); + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); EXPECT_TRUE(result.has_value()); - EXPECT_EQ(result->annotations.size(), 1u); - return result->annotations[0]; + return *result; } // Compare two ColorRGBA values allowing 1-LSB drift on each channel from the -// double-quantization round-trip (uint8 → double in [0,1] → uint8). +// double-quantization round-trip (uint8 -> double in [0,1] -> uint8). ::testing::AssertionResult ColorEq(const ColorRGBA& a, const ColorRGBA& b) { auto near = [](uint8_t x, uint8_t y) { return x > y ? (x - y) <= 1 : (y - x) <= 1; }; if (near(a.r, b.r) && near(a.g, b.g) && near(a.b, b.b) && near(a.a, b.a)) { @@ -90,7 +82,7 @@ TEST(ImageAnnotationCodecTest, EmptyAnnotationProducesEmptyBytes) { } // ----------------------------------------------------------------------------- -// 2. Golden-byte test — pins the wire format itself, not just round-trip behavior +// 2. Golden-byte test: pins the wire format itself, not just round-trip behavior. // ----------------------------------------------------------------------------- TEST(ImageAnnotationCodecTest, GoldenBytes_SinglePointsAnnotation) { @@ -169,7 +161,7 @@ TEST(ImageAnnotationCodecTest, GoldenBytes_SinglePointsAnnotation) { } // ----------------------------------------------------------------------------- -// 3. Round-trip tests — build → serialize → existing reader → compare +// 3. Round-trip tests: build, serialize, read, compare. // ----------------------------------------------------------------------------- TEST(ImageAnnotationCodecTest, RoundTrip_LineLoopFourPoints) { @@ -236,7 +228,7 @@ TEST(ImageAnnotationCodecTest, RoundTrip_TextUtf8) { ta.position = {320.5, 240.25}; ta.font_size = 14.0; ta.color = {255, 255, 255, 255}; - ta.text = "person 0.95 — \xc3\xa1\xc3\xa9\xc3\xad"; // UTF-8: "áéí" + ta.text = "person 0.95 \xe2\x80\x94 \xc3\xa1\xc3\xa9\xc3\xad"; // UTF-8 text in.texts.push_back(std::move(ta)); auto out = roundTrip(in); diff --git a/pj_scene_protocol/tests/scene_decoder_test.cpp b/pj_base/tests/image_annotations_decoder_test.cpp similarity index 68% rename from pj_scene_protocol/tests/scene_decoder_test.cpp rename to pj_base/tests/image_annotations_decoder_test.cpp index e96849d..52d2d6d 100644 --- a/pj_scene_protocol/tests/scene_decoder_test.cpp +++ b/pj_base/tests/image_annotations_decoder_test.cpp @@ -1,37 +1,24 @@ -#include "pj_scene_protocol/scene_decoder.h" - #include #include #include +#include #include -#include "pj_scene_protocol/scene_frame.h" +#include "pj_base/builtin/image_annotations_codec.h" namespace PJ { namespace { using sdk::AnnotationTopology; -using sdk::CircleAnnotation; -using sdk::ColorRGBA; -using sdk::ImageAnnotations; -using sdk::Point2; -using sdk::PointsAnnotation; -using sdk::TextAnnotation; - -TEST(SceneDecoderTest, FactoryReturnsNullForUnknownSchema) { - auto dec = makeSceneDecoder("nonsense/Schema"); - EXPECT_EQ(dec.get(), nullptr); -} // --------------------------------------------------------------------------- -// Protobuf decoder tests (foxglove.ImageAnnotations) — the canonical wire -// format. Per-source-format conversion (CDR vision_msgs, yolo, …) is -// loader-side and tested elsewhere (see pj_media/demos/cdr_to_image_annotation_test). +// Protobuf decoder tests for the canonical foxglove.ImageAnnotations wire +// format. Source-specific conversion happens before this decoder sees bytes. // --------------------------------------------------------------------------- namespace pb { -// Tiny encoder helpers for tests — protobuf wire format. +// Tiny encoder helpers for tests: protobuf wire format. inline void appendVarint(std::vector& out, uint64_t v) { while (v >= 0x80) { @@ -128,33 +115,25 @@ std::vector encodeCircleAnnotation( return body; } -TEST(SceneDecoderProtobufTest, FactoryReturnsDecoderForImageAnnotations) { - auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); - ASSERT_NE(dec.get(), nullptr); +TEST(ImageAnnotationsDecoderTest, SchemaNameMatchesFoxgloveImageAnnotations) { + EXPECT_EQ(kSchemaImageAnnotations, "foxglove.ImageAnnotations"); } -TEST(SceneDecoderProtobufTest, EmptyMessageProducesEmptyAnnotation) { - auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); - ASSERT_NE(dec.get(), nullptr); - +TEST(ImageAnnotationsDecoderTest, EmptyMessageProducesError) { std::vector empty_body; - auto result = dec->decode(empty_body.data(), empty_body.size()); + auto result = deserializeImageAnnotations(empty_body.data(), empty_body.size()); // Empty buffer is treated as error per the decoder's contract. EXPECT_FALSE(result.has_value()); } -TEST(SceneDecoderProtobufTest, SingleLineLoopFourPoints) { - auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); - ASSERT_NE(dec.get(), nullptr); - +TEST(ImageAnnotationsDecoderTest, SingleLineLoopFourPoints) { // type=2 (LINE_LOOP), 4 corners, thickness=2.5 auto pa = encodePointsAnnotation(2, {{10.0, 20.0}, {110.0, 20.0}, {110.0, 80.0}, {10.0, 80.0}}, 2.5); auto bytes = encodeImageAnnotations({pa}); - auto result = dec->decode(bytes.data(), bytes.size()); + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->annotations.size(), 1u); - const auto& ia = result->annotations[0]; + const auto& ia = *result; ASSERT_EQ(ia.points.size(), 1u); const auto& pts = ia.points[0]; @@ -167,25 +146,19 @@ TEST(SceneDecoderProtobufTest, SingleLineLoopFourPoints) { EXPECT_DOUBLE_EQ(pts.thickness, 2.5); } -TEST(SceneDecoderProtobufTest, MultiplePointsAnnotations) { - auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); - ASSERT_NE(dec.get(), nullptr); - +TEST(ImageAnnotationsDecoderTest, MultiplePointsAnnotations) { auto pa1 = encodePointsAnnotation(2, {{0.0, 0.0}, {100.0, 0.0}, {100.0, 100.0}, {0.0, 100.0}}, 1.0); auto pa2 = encodePointsAnnotation(3, {{50.0, 50.0}, {150.0, 150.0}}, 3.0); // LINE_STRIP auto bytes = encodeImageAnnotations({pa1, pa2}); - auto result = dec->decode(bytes.data(), bytes.size()); + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->annotations[0].points.size(), 2u); - EXPECT_EQ(result->annotations[0].points[0].topology, AnnotationTopology::kLineLoop); - EXPECT_EQ(result->annotations[0].points[1].topology, AnnotationTopology::kLineStrip); + ASSERT_EQ(result->points.size(), 2u); + EXPECT_EQ(result->points[0].topology, AnnotationTopology::kLineLoop); + EXPECT_EQ(result->points[1].topology, AnnotationTopology::kLineStrip); } -TEST(SceneDecoderProtobufTest, CircleAnnotationDecodes) { - auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); - ASSERT_NE(dec.get(), nullptr); - +TEST(ImageAnnotationsDecoderTest, CircleAnnotationDecodes) { // CircleAnnotation centered at (50, 60) with diameter 20 (radius 10), thickness 1.5, // semi-transparent red fill, opaque white outline. auto fill = encodeColor(1.0, 0.0, 0.0, 0.5); @@ -196,26 +169,23 @@ TEST(SceneDecoderProtobufTest, CircleAnnotationDecodes) { pb::appendTag(bytes, 1, 2); pb::appendLenDelim(bytes, ca); - auto result = dec->decode(bytes.data(), bytes.size()); + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->annotations.size(), 1u); - ASSERT_EQ(result->annotations[0].circles.size(), 1u); - const auto& c = result->annotations[0].circles[0]; + ASSERT_EQ(result->circles.size(), 1u); + const auto& c = result->circles[0]; EXPECT_DOUBLE_EQ(c.center.x, 50.0); EXPECT_DOUBLE_EQ(c.center.y, 60.0); EXPECT_DOUBLE_EQ(c.radius, 10.0); EXPECT_DOUBLE_EQ(c.thickness, 1.5); EXPECT_EQ(c.color.r, 255u); // outline = white EXPECT_EQ(c.color.a, 255u); - EXPECT_EQ(c.fill_color.r, 255u); // fill = red, alpha 0.5 → 128 + EXPECT_EQ(c.fill_color.r, 255u); // fill = red, alpha 0.5 -> 128 EXPECT_EQ(c.fill_color.g, 0u); EXPECT_NEAR(c.fill_color.a, 128u, 1u); } -TEST(SceneDecoderProtobufTest, NullDataReturnsError) { - auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); - ASSERT_NE(dec.get(), nullptr); - auto result = dec->decode(nullptr, 0); +TEST(ImageAnnotationsDecoderTest, NullDataReturnsError) { + auto result = deserializeImageAnnotations(nullptr, 0); EXPECT_FALSE(result.has_value()); } diff --git a/pj_datastore/CMakeLists.txt b/pj_datastore/CMakeLists.txt index cc7e5ed..89b4843 100644 --- a/pj_datastore/CMakeLists.txt +++ b/pj_datastore/CMakeLists.txt @@ -71,9 +71,8 @@ if(PJ_BUILD_TESTS) add_test(NAME ${test_name} COMMAND ${test_name}) endforeach() - # plugin_parser_object_write_test uses MessageParserPluginBase → needs - # the plugin SDK (pj_plugin_sdk gives access to message_parser_plugin_base.hpp - # and transitively pulls pj_scene_protocol for the BuiltinObject types). + # plugin_parser_object_write_test uses MessageParserPluginBase, so it needs + # the plugin SDK for message_parser_plugin_base.hpp and builtin object types. add_executable(plugin_parser_object_write_test tests/plugin_parser_object_write_test.cpp) target_link_libraries(plugin_parser_object_write_test PRIVATE pj_datastore pj_plugin_sdk GTest::gtest_main diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index bd2d5b2..64055ca 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -3,14 +3,13 @@ add_subdirectory(dialog_protocol) # --------------------------------------------------------------------------- # pj_plugin_sdk — INTERFACE library that exposes the C++ SDK headers # (MessageParserPluginBase, ObjectIngestPolicy, parser trampolines, ...) -# and the dependencies they need (pj_base, pj_scene_protocol). Plugins link -# this to get access to the SDK without having to repeat the include paths -# and the transitive link to pj_scene_protocol's builtin object types. +# and the dependencies they need. Plugins link this to get access to the SDK +# without having to repeat the include paths. # --------------------------------------------------------------------------- add_library(pj_plugin_sdk INTERFACE) target_include_directories(pj_plugin_sdk INTERFACE include) -target_link_libraries(pj_plugin_sdk INTERFACE pj_base pj_scene_protocol) +target_link_libraries(pj_plugin_sdk INTERFACE pj_base) # --------------------------------------------------------------------------- # pj_plugin_catalog — host-side embedded-manifest discovery @@ -112,7 +111,6 @@ target_link_libraries(pj_message_parser_host PUBLIC pj_base pj_dialog_protocol - pj_scene_protocol PRIVATE ${CMAKE_DL_LIBS} ) @@ -239,7 +237,7 @@ add_test(NAME message_parser_library_test COMMAND message_parser_library_test) add_executable(object_ingest_policy_test tests/object_ingest_policy_test.cpp) target_compile_options(object_ingest_policy_test PRIVATE ${PJ_WARNING_FLAGS}) target_link_libraries(object_ingest_policy_test PRIVATE - pj_base pj_scene_protocol GTest::gtest_main + pj_base GTest::gtest_main ) target_include_directories(object_ingest_policy_test PRIVATE include) add_test(NAME object_ingest_policy_test COMMAND object_ingest_policy_test) diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index b5d5a4c..9f710d7 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -526,6 +526,6 @@ struct on the DataSource side, in-process variant on the parser side) is opaque-payload-by-default: `BuiltinObject` is `std::any`, so appending a new builtin kind does not change the public type and forward compatibility is automatic. Concrete builtins live under -`pj_scene_protocol/builtin/` (`Image`, `DepthImage`, `PointCloud`, -`ImageAnnotations`); see `pj_scene_protocol/docs/USER_GUIDE.md` for -producer/consumer recipes. +`pj_base/builtin/` (`Image`, `DepthImage`, `PointCloud`, +`ImageAnnotations`); see `docs/builtin_type.md` for the type catalog and +`docs/image_annotations_format.md` for the canonical annotation wire format. diff --git a/pj_plugins/docs/message-parser-guide.md b/pj_plugins/docs/message-parser-guide.md index 6ec1b59..f7488aa 100644 --- a/pj_plugins/docs/message-parser-guide.md +++ b/pj_plugins/docs/message-parser-guide.md @@ -504,7 +504,7 @@ parseObject(PJ::Timestamp ts, PJ::sdk::PayloadView payload) override; with `std::any_cast(&obj)` to dispatch to the matching viewer. -Builtin types live under `pj_scene_protocol/builtin/`, one header per +Builtin types live under `pj_base/builtin/`, one header per type. `sdk::Image` carries an open-ended `std::string encoding` (`"rgb8"`, `"bgr8"`, `"mono8"`, `"jpeg"`, `"png"`, `"compressedDepth"`, …) so raw and compressed images share a single type. New kinds are diff --git a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp index 46e26c0..2255b43 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp @@ -4,9 +4,9 @@ */ #pragma once +#include #include #include -#include #include #include diff --git a/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp b/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp index 6e5a834..2456c06 100644 --- a/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp +++ b/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp @@ -20,13 +20,13 @@ #include #include +#include "pj_base/builtin/BuiltinObject.h" #include "pj_base/expected.hpp" #include "pj_base/message_parser_protocol.h" #include "pj_base/plugin_abi_export.h" #include "pj_base/sdk/plugin_data_api.hpp" #include "pj_base/sdk/service_registry.hpp" #include "pj_base/sdk/service_traits.hpp" -#include "pj_scene_protocol/builtin/BuiltinObject.h" namespace PJ { namespace sdk { diff --git a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp index cc7354f..13a8aa3 100644 --- a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp +++ b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp @@ -17,7 +17,7 @@ #include #include -#include "pj_scene_protocol/builtin/BuiltinObject.h" +#include "pj_base/builtin/BuiltinObject.h" namespace PJ { namespace sdk { diff --git a/pj_scene_protocol/CMakeLists.txt b/pj_scene_protocol/CMakeLists.txt deleted file mode 100644 index df705d8..0000000 --- a/pj_scene_protocol/CMakeLists.txt +++ /dev/null @@ -1,54 +0,0 @@ -# --------------------------------------------------------------------------- -# pj_scene_protocol — schema + canonical wire codec (writer + reader) for -# foxglove.ImageAnnotations and forthcoming scene primitive types. SDK -# boundary for plugin authors that produce or consume markers / scene data. -# Depends on pj_base and magic_enum (header-only, for the builtin/ -# vocabulary reflection helpers). -# --------------------------------------------------------------------------- - -find_package(magic_enum CONFIG REQUIRED) - -add_library(pj_scene_protocol STATIC - src/image_annotation_codec.cpp - src/scene_decoder.cpp - src/scene_decoder_protobuf.cpp -) -target_include_directories(pj_scene_protocol PUBLIC - $ - $ -) -target_compile_options(pj_scene_protocol PRIVATE ${PJ_WARNING_FLAGS}) -target_link_libraries(pj_scene_protocol PUBLIC pj_base magic_enum::magic_enum) -set_target_properties(pj_scene_protocol PROPERTIES - POSITION_INDEPENDENT_CODE ON -) - -# --------------------------------------------------------------------------- -# Install (guarded by PJ_INSTALL_SDK in root CMakeLists.txt) -# --------------------------------------------------------------------------- - -if(PJ_INSTALL_SDK) - install(TARGETS pj_scene_protocol EXPORT PlotJugglerSDKTargets - ARCHIVE DESTINATION lib - LIBRARY DESTINATION lib - ) - install(DIRECTORY include/ DESTINATION include) -endif() - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - -if(PJ_BUILD_TESTS) - set(PJ_SCENE_PROTOCOL_TESTS - tests/image_annotation_codec_test.cpp - tests/scene_decoder_test.cpp - ) - - foreach(test_src ${PJ_SCENE_PROTOCOL_TESTS}) - get_filename_component(test_name ${test_src} NAME_WE) - add_executable(${test_name} ${test_src}) - target_link_libraries(${test_name} PRIVATE pj_scene_protocol GTest::gtest_main) - add_test(NAME ${test_name} COMMAND ${test_name}) - endforeach() -endif() diff --git a/pj_scene_protocol/docs/ARCHITECTURE.md b/pj_scene_protocol/docs/ARCHITECTURE.md deleted file mode 100644 index 9030ee7..0000000 --- a/pj_scene_protocol/docs/ARCHITECTURE.md +++ /dev/null @@ -1,123 +0,0 @@ -# pj_scene_protocol — Architecture - -## Purpose and scope - -`pj_scene_protocol` is the canonical wire format and codec for visual markers and scene primitives shared across PlotJuggler's data sources and viewers. It is the SDK boundary that lets a plugin author **produce** marker data (e.g. detection bounding boxes, labelled points) or **consume** it without dragging in any visualization stack. - -Today the module covers 2D image annotations (points, lines, polygons, circles, text). It is named for forthcoming scope: 3D scene primitives (arrows, cubes, lines, meshes, text) are documented as the next addition, and the type system is laid out to accommodate them next to the 2D types without breaking existing wire bytes. - -**In scope:** -- Schema (vocabulary types — `Point2`, `ColorRGBA`, `sdk::ImageAnnotations`, `SceneFrame`, …). -- A canonical wire format (`foxglove.ImageAnnotations` Protobuf) and a hand-rolled writer + reader for it. -- The schema-name string constant that producers stamp on stored topics. - -**Out of scope (deliberately):** -- Per-source-format conversion. Translating from CDR `vision_msgs/Detection2DArray`, YOLO message types, CSV, RLDS, etc. into `sdk::ImageAnnotations` happens **loader-side**, never inside this module. PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}` are reference adapters. -- Storage / time-anchoring of scene frames (lives in PJ4's `pj_media/core/ScenePipelineSource` + `ObjectStore` from `pj_datastore`). -- Rendering (lives in PJ4's `pj_media/qt/MediaViewerWidget`). - -This split keeps `pj_scene_protocol` linkable by a streaming-source plugin or a one-off ROS bag converter without pulling FFmpeg, QRhi shaders, or anything else PlotJuggler's host happens to need. - -## Type catalog - -All types are POD-shaped, default-constructible, and compare with `operator==`. They live in `pj_scene_protocol/scene_frame.h`. - -| Type | Purpose | -|---|---| -| `Point2 {x, y}` | 2D point in image-pixel coordinates (origin top-left), `double` precision. | -| `ColorRGBA {r,g,b,a: uint8}` | 8-bit-per-channel color. `a == 0` is transparent. | -| `AnnotationTopology` (enum) | Vertex topology for `PointsAnnotation`: `kPoints`, `kLineList` (segments 0-1, 2-3, …), `kLineStrip` (polyline), `kLineLoop` (closes back; 4-point loop = rectangle). | -| `PointsAnnotation` | Vertices + topology + uniform `color` + optional per-vertex `colors` + `fill_color` (for `kLineLoop`) + `thickness`. | -| `CircleAnnotation` | `center` + `radius` (the wire format carries diameter; see below) + `thickness` + outline `color` + `fill_color`. | -| `TextAnnotation` | Anchor `position`, `text`, `font_size`, `color`. | -| `sdk::ImageAnnotations` | Bag of `points` + `circles` + `texts` for one image at one timestamp; refers to its base image via `image_topic`. | -| `SceneFrame` | Top-level decoder output. Wraps `vector`; future expansion will add 3D primitives, grids, etc. as sibling fields. | - -## Wire format - -The canonical wire format is **Foxglove `ImageAnnotations` Protobuf**. Conforming to it gives free interop with Foxglove Studio and other tools that consume the same schema. - -The schema-name string is published as: - -```cpp -inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; -``` - -Producers stamp this in the topic's `metadata_json` under the key `encoding`: - -```json -{"encoding":"foxglove.ImageAnnotations"} -``` - -Consumers pass the same string to `makeSceneDecoder()` to obtain the matching decoder. A factory typo returns `nullptr` rather than misbehaving silently. - -### Field numbers - -The writer at `src/image_annotation_codec.cpp` and the reader at `src/scene_decoder_protobuf.cpp` agree on the following Protobuf field numbers (matching the published Foxglove schema): - -| Message | Fields | -|---|---| -| `foxglove.ImageAnnotations` | `1: repeated CircleAnnotation`, `2: repeated PointsAnnotation`, `3: repeated TextAnnotation` | -| `foxglove.PointsAnnotation` | `2: type (enum)`, `3: repeated Point2`, `4: outline_color`, `5: repeated outline_colors`, `6: fill_color`, `7: thickness (double)` | -| `foxglove.CircleAnnotation` | `2: position (Point2)`, `3: diameter (double)`, `4: thickness (double)`, `5: fill_color`, `6: outline_color` | -| `foxglove.TextAnnotation` | `2: position (Point2)`, `3: text (string)`, `4: font_size (double)`, `5: text_color`, *6: background_color (skipped on write, skipped on read)* | -| `foxglove.Point2` | `1: x (double)`, `2: y (double)` | -| `foxglove.Color` | `1: r (double)`, `2: g (double)`, `3: b (double)`, `4: a (double)` — components in `[0, 1]` | - -Topology enum mapping: `kPoints=1`, `kLineLoop=2`, `kLineStrip=3`, `kLineList=4`. The Foxglove enum reserves `0` for `UNKNOWN`; the writer never emits 0. - -The wire types used are `VARINT(0)`, `I64(1)`, and `LEN(2)`. `I32(5)` is unused on write and skipped if encountered on read. - -### Encoding rules / round-trip behavior - -- **Color quantization is lossy.** `ColorRGBA` stores `uint8 [0, 255]`; the wire stores `double [0, 1]`. The writer divides by 255.0; the reader multiplies. A round-trip can drift up to 1 LSB on each channel. Tests assert with 1-LSB tolerance. -- **`CircleAnnotation::radius` ↔ wire `diameter`.** The writer emits `radius * 2`; the reader halves on read. The C++ surface always exposes radius. -- **Empty `colors` is preserved.** A `PointsAnnotation` with `colors.empty()` emits zero field-5 entries. Emitting a default `Color` for an empty vector would smuggle a phantom entry into the reader, breaking per-vertex coloring semantics. There is a regression test (`EmptyColorsVectorDoesNotInjectDefaultEntry`). -- **`sdk::ImageAnnotations::timestamp` and `::image_topic` do not cross the wire.** Those fields belong to the surrounding transport (the timestamp arrives via `ObjectStore`'s push; the topic identity is the topic). They are populated on read by the consumer pipeline, not by the codec. -- **`TextAnnotation::background_color` is intentionally absent from the C++ struct.** The wire format defines field 6, but the schema struct has no equivalent. The writer never emits it; the reader skips it. - -## API surface - -```cpp -// Schema constant (wire-format identifier). -inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; - -// Producer side. -[[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); - -// Consumer side. -class ISceneDecoder { - public: - virtual ~ISceneDecoder() = default; - virtual Expected decode(const uint8_t* data, size_t size) = 0; -}; -std::unique_ptr makeSceneDecoder(std::string_view schema_name); -``` - -`Expected` is `pj_base/expected.hpp`. A decode failure returns an error string; `decode()` does not throw. - -`makeSceneDecoder` returns `nullptr` if the schema name does not match `kSchemaImageAnnotations`. It is the caller's signal that the topic's `metadata_json` is wrong. - -The decoder is stateless. The expected pattern is one decoder instance per scene/annotation layer for the lifetime of that layer. - -## Design rationale - -**Single canonical decoder.** There is exactly one decoder kind: Protobuf for `foxglove.ImageAnnotations`. Adding ROS-message decoders, CSV decoders, etc. inside this module was rejected — the consumer side must not grow N decoders for every robotics message dialect that exists. Per-source-format conversion is loader-side and writes canonical bytes into the store. - -**Hand-rolled wire codec.** No `protoc`, no generated code, no libprotobuf. The reader is ~300 lines; the writer is ~200 lines. At this size the dependency cost outweighs the codegen convenience, and the explicit code makes wire-level decisions (field numbers, default values, `radius`/`diameter`) reviewable. - -**Schema-name = version.** Following Foxglove's own convention, schemas are versioned by changing the type name. There is no in-band version field. If the wire shape ever needs to change incompatibly, a new constant (e.g. `kSchemaImageAnnotationsV2`) and a new decoder kind are added; old data keeps working with the old name. - -**Pure value types.** No virtual base, no PIMPL, no allocators. The schema header includes only ``, ``, ``, and `pj_base/types.hpp` (for `Timestamp`). A plugin author can include this header without a build-system thought. - -## What is not here, and where it lives - -| Concern | Lives in | -|---|---| -| Per-source-format conversion (CDR `vision_msgs/Detection2DArray`, `yolo_msgs/DetectionArray`, …) | PJ4 `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}` (reference adapters); plugin loaders for production use | -| Class-id → palette mapping (FNV-1a hash) | PJ4 `pj_media/demos/marker_palette.{h,cpp}` | -| `MediaSource` integration: pulling annotation bytes out of an `ObjectStore`, decoding at a timestamp | PJ4 `pj_media/core/scene_pipeline_source.{h,cpp}` | -| Compositing base image with overlays, layer fusion | PJ4 `pj_media/core/composite_media_source.{h,cpp}` | -| Rendering: 5-pipeline QRhi (image, points, 1 px lines, thick lines, text) | PJ4 `pj_media/qt/media_viewer_widget.{h,cpp}` | - -See `USER_GUIDE.md` for the producer and consumer code paths. diff --git a/pj_scene_protocol/docs/USER_GUIDE.md b/pj_scene_protocol/docs/USER_GUIDE.md deleted file mode 100644 index 18aa9c3..0000000 --- a/pj_scene_protocol/docs/USER_GUIDE.md +++ /dev/null @@ -1,128 +0,0 @@ -# pj_scene_protocol User Guide - -How to produce or consume marker / scene data over PlotJuggler's canonical wire format. This guide is for plugin developers (DataSource, MessageParser) and viewer authors who need to integrate visual overlays. - -The module exposes one schema header and one codec header: - -```cpp -#include "pj_scene_protocol/scene_frame.h" // value types -#include "pj_scene_protocol/image_annotation_codec.h" // writer + schema name -#include "pj_scene_protocol/scene_decoder.h" // reader (consumers only) -``` - -Linking: `target_link_libraries(your_target PRIVATE pj_scene_protocol)`. The transitive `pj_base` dep is the only thing it pulls in. - -For the wire format reference, type catalog, and design rationale, see `ARCHITECTURE.md` in this folder. - ---- - -## 1. Producer recipe (loader / data source) - -A loader fills an `sdk::ImageAnnotations` from its source format and serializes to canonical bytes before pushing into the host's data store. - -```cpp -#include "pj_scene_protocol/scene_frame.h" -#include "pj_scene_protocol/image_annotation_codec.h" - -PJ::sdk::ImageAnnotations buildAnnotation(const Detection& det) { - PJ::sdk::ImageAnnotations ia; - - // Bounding box as a 4-point line loop. - PJ::PointsAnnotation rect; - rect.topology = PJ::AnnotationTopology::kLineLoop; - rect.points = { - {det.x_min, det.y_min}, {det.x_max, det.y_min}, - {det.x_max, det.y_max}, {det.x_min, det.y_max}, - }; - rect.color = paletteColor(det.class_id); // see pj_media/demos/marker_palette as reference - rect.thickness = 2.0; - ia.points.push_back(std::move(rect)); - - // Class label above the box. - PJ::TextAnnotation label; - label.position = {det.x_min, det.y_min - 4.0}; - label.text = det.class_name + " " + std::to_string(det.score); - label.font_size = 14.0; - label.color = {255, 255, 255, 255}; - ia.texts.push_back(std::move(label)); - - return ia; -} - -// In your loader's per-message callback: -auto bytes = PJ::serializeImageAnnotations(buildAnnotation(detection)); -host.pushObject(topic_id, ts_ns, bytes.data(), bytes.size()); -``` - -When you register the topic, stamp the schema name in `metadata_json`: - -```cpp -TopicOptions opts; -opts.metadata_json = R"({"encoding":"foxglove.ImageAnnotations"})"; -auto topic_id = host.registerTopic("/detections", opts); -``` - -That `encoding` value is the only signal the consumer side uses to dispatch the right decoder. If it is missing or misspelled, the data still arrives in the store but no viewer will pick it up. - ---- - -## 2. Consumer recipe (viewer / sink) - -A consumer reads bytes out of the store and decodes them with the canonical decoder. - -```cpp -#include "pj_scene_protocol/scene_decoder.h" - -auto decoder = PJ::makeSceneDecoder(PJ::kSchemaImageAnnotations); -if (!decoder) { - // Topic's metadata_json said something other than "foxglove.ImageAnnotations". - return; -} - -auto result = decoder->decode(bytes.data(), bytes.size()); -if (!result.has_value()) { - // result.error() is a string with a wire-level reason. - return; -} - -const PJ::SceneFrame& sf = *result; -for (const PJ::sdk::ImageAnnotations& ia : sf.annotations) { - for (const auto& pa : ia.points) { renderPoints(pa); } - for (const auto& ca : ia.circles) { renderCircle(ca); } - for (const auto& ta : ia.texts) { renderText(ta); } -} -``` - -The decoder is stateless — keep one per layer for the layer's lifetime, or build a fresh one per call (allocation is cheap). `decode()` does not throw. - ---- - -## 3. Common pitfalls - -**Schema-name mismatch.** `makeSceneDecoder("foxglove.image_annotations")` (lowercase) returns `nullptr`. Use the constant `kSchemaImageAnnotations` rather than a literal string. Same on the producer side — match the literal `"foxglove.ImageAnnotations"` exactly in `metadata_json`. - -**Color drift.** `ColorRGBA{255, 0, 0, 255}` is the *most* a channel can drift; round-trip equality on individual `uint8` channels is not guaranteed exactly. If you compare in a test, allow ±1 LSB per channel — `image_annotation_codec_test.cpp::ColorEq` shows the pattern. - -**Per-vertex `colors` semantics.** A `PointsAnnotation` honors `colors` only when `colors.size() == points.size()`. If `colors` is empty, the uniform `color` is splatted across all vertices. Anything else is implementation-defined; renderers may splat-last or ignore. Don't rely on the in-between case. - -**`fill_color` only fires for `kLineLoop`.** Other topologies ignore `fill_color`. Setting an alpha-zero default fill is the convention for "no fill." - -**Non-serialized fields.** `sdk::ImageAnnotations::timestamp` and `::image_topic` are populated by the consumer pipeline (timestamp comes from the store push; topic identity from the topic id). The codec does not round-trip them — equality on a freshly decoded annotation will see those fields as zero / empty. This is intentional; see `ARCHITECTURE.md §Wire format / Encoding rules`. - -**`CircleAnnotation::radius`, not diameter.** The C++ surface is radius. The wire carries diameter. Don't double the value yourself when constructing. - -**Empty annotations.** `serializeImageAnnotations()` on an `sdk::ImageAnnotations` with no primitives produces zero bytes. Pushing zero bytes is a valid "no overlays at this timestamp" signal; the decoder handles a non-empty buffer or returns an empty `SceneFrame`. Sending an empty buffer through `decode()` returns an error — guard the producer side or skip the push. - ---- - -## 4. Translating from a custom message format - -Per-source-format conversion is intentionally outside this module. A loader that reads, say, ROS 2 `vision_msgs/msg/Detection2DArray` is responsible for translating into `sdk::ImageAnnotations` itself. - -For a working reference, see PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}`: - -- `cdr_detection2d_to_image_annotation` — `vision_msgs/msg/Detection2DArray` → `sdk::ImageAnnotations`. Maps the first hypothesis's `class_id` to a stable palette colour and emits a `" "` text label above each bbox. -- `cdr_yolo_to_image_annotation` — `yolo_msgs/msg/DetectionArray` → `sdk::ImageAnnotations`. Same pattern, uses `class_name` for the label. -- `marker_palette` — FNV-1a class-id → `ColorRGBA` palette and label-string formatter. Reuse-friendly. - -These adapters live in PJ4 because they consume PJ4-side fixtures (MCAP demo). The pattern transfers to any plugin: read your message, fill an `sdk::ImageAnnotations`, serialize with `serializeImageAnnotations()`. diff --git a/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h b/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h deleted file mode 100644 index 3293868..0000000 --- a/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "pj_scene_protocol/builtin/ImageAnnotations.h" - -namespace PJ { - -/// Wire-format identifier for image annotations on ObjectStore. Loaders stamp -/// this in the topic's `metadata_json` under the key "encoding"; pj_media -/// looks for it before decoding. The literal value is the published Foxglove -/// schema name; we conform to that wire format spec, which gives us free -/// interop with Foxglove Studio and other tools. -inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; - -/// Serializes a sdk::ImageAnnotations to canonical foxglove.ImageAnnotations -/// Protobuf bytes. -/// -/// `timestamp_ns` and `image_topic` on the input are NOT serialized — the -/// timestamp travels with ObjectStore's push, topic identity with the topic -/// registration. Round-trip equality holds when the caller leaves both at -/// default values. -[[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); - -} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h b/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h deleted file mode 100644 index 64aadc7..0000000 --- a/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "pj_base/expected.hpp" -#include "pj_scene_protocol/image_annotation_codec.h" // for kSchemaImageAnnotations -#include "pj_scene_protocol/scene_frame.h" - -namespace PJ { - -/// Decodes canonical wire-format bytes (foxglove.ImageAnnotations Protobuf, -/// serialized by `pj_scene_protocol::serializeImageAnnotations`) into a -/// `SceneFrame` of vector primitives. Stateless — one instance per -/// scene/annotation layer. See `docs/ARCHITECTURE.md` for the wire format spec. -/// -/// There is exactly ONE decoder kind. Per-source-format conversion (e.g. CDR -/// `vision_msgs/msg/Detection2DArray` → canonical bytes) lives loader-side and -/// is invisible to consumers of this module. -class ISceneDecoder { - public: - virtual ~ISceneDecoder() = default; - virtual Expected decode(const uint8_t* data, size_t size) = 0; -}; - -/// Factory: returns the canonical Protobuf decoder. The `schema_name` argument -/// is checked against `kSchemaImageAnnotations` so a typo on the loader side -/// surfaces as a nullptr at construction. -std::unique_ptr makeSceneDecoder(std::string_view schema_name); - -} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/scene_frame.h b/pj_scene_protocol/include/pj_scene_protocol/scene_frame.h deleted file mode 100644 index 43c352f..0000000 --- a/pj_scene_protocol/include/pj_scene_protocol/scene_frame.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include - -#include "pj_base/types.hpp" -#include "pj_scene_protocol/builtin/ImageAnnotations.h" - -namespace PJ { - -/// Top-level output of a SceneDecoder. Wraps a list of ImageAnnotations for -/// this iteration; future iterations will extend with 3D ScenePrimitive, -/// Grid, etc. -struct SceneFrame { - Timestamp timestamp = 0; - std::vector annotations; - bool operator==(const SceneFrame&) const = default; - - [[nodiscard]] bool empty() const noexcept { - return annotations.empty(); - } -}; - -} // namespace PJ diff --git a/pj_scene_protocol/src/scene_decoder.cpp b/pj_scene_protocol/src/scene_decoder.cpp deleted file mode 100644 index ffd1648..0000000 --- a/pj_scene_protocol/src/scene_decoder.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "pj_scene_protocol/scene_decoder.h" - -#include -#include - -namespace PJ { - -// Single decoder kind, defined in scene_decoder_protobuf.cpp. -std::unique_ptr makeSceneDecoderProtobufImageAnnotations(); - -std::unique_ptr makeSceneDecoder(std::string_view schema_name) { - if (schema_name == kSchemaImageAnnotations) { - return makeSceneDecoderProtobufImageAnnotations(); - } - return nullptr; -} - -} // namespace PJ From 97c1e71a58584e5c51e4b1434fbba48061797ec2 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sat, 16 May 2026 19:36:31 +0200 Subject: [PATCH 15/18] refactor: consolidate builtin object contracts --- CLAUDE.md | 2 +- docs/builtin_type.md | 64 +- docs/image_annotations_format.md | 155 ++--- .../include/pj_base/builtin/BuiltinObject.h | 58 +- .../pj_base/builtin/BuiltinObjectKind.h | 54 -- .../pj_base/builtin/CommonImageEncoding.h | 60 -- pj_base/include/pj_base/builtin/Image.h | 45 +- .../pj_base/builtin/image_annotations_codec.h | 6 +- pj_base/include/pj_base/builtin_object_abi.h | 34 +- .../include/pj_base/message_parser_protocol.h | 2 +- pj_base/proto/pj/CircleAnnotation.proto | 37 + pj_base/proto/pj/Color.proto | 22 + pj_base/proto/pj/FrameTransform.proto | 34 + pj_base/proto/pj/FrameTransforms.proto | 15 + pj_base/proto/pj/Geometry.proto | 64 ++ pj_base/proto/pj/ImageAnnotations.proto | 31 + pj_base/proto/pj/KeyValuePair.proto | 16 + pj_base/proto/pj/PointsAnnotation.proto | 58 ++ pj_base/proto/pj/TextAnnotation.proto | 37 + .../src/builtin/image_annotations_codec.cpp | 194 ++---- .../src/builtin/image_annotations_decoder.cpp | 639 ++++++++---------- pj_base/src/builtin/protobuf_wire.h | 218 ++++++ pj_base/tests/abi_layout_sentinels_test.cpp | 4 +- .../tests/image_annotations_decoder_test.cpp | 24 +- pj_base/tests/media_metadata_test.cpp | 5 +- pj_plugins/docs/ARCHITECTURE.md | 4 +- pj_plugins/docs/data-source-guide.md | 4 +- pj_plugins/docs/message-parser-guide.md | 4 +- .../pj_plugins/host/message_parser_handle.hpp | 8 +- .../sdk/detail/message_parser_trampolines.hpp | 2 +- .../sdk/message_parser_plugin_base.hpp | 4 +- .../pj_plugins/sdk/object_ingest_policy.hpp | 26 +- .../tests/object_ingest_policy_test.cpp | 54 +- 33 files changed, 1141 insertions(+), 843 deletions(-) delete mode 100644 pj_base/include/pj_base/builtin/BuiltinObjectKind.h delete mode 100644 pj_base/include/pj_base/builtin/CommonImageEncoding.h create mode 100644 pj_base/proto/pj/CircleAnnotation.proto create mode 100644 pj_base/proto/pj/Color.proto create mode 100644 pj_base/proto/pj/FrameTransform.proto create mode 100644 pj_base/proto/pj/FrameTransforms.proto create mode 100644 pj_base/proto/pj/Geometry.proto create mode 100644 pj_base/proto/pj/ImageAnnotations.proto create mode 100644 pj_base/proto/pj/KeyValuePair.proto create mode 100644 pj_base/proto/pj/PointsAnnotation.proto create mode 100644 pj_base/proto/pj/TextAnnotation.proto create mode 100644 pj_base/src/builtin/protobuf_wire.h diff --git a/CLAUDE.md b/CLAUDE.md index c075e63..5d2b2f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ CLAUDE.md -> relevant docs -> code | Document | Content | |----------|---------| | `docs/builtin_type.md` | Canonical builtin object types used as the shim between third-party schemas and PlotJuggler internals | -| `docs/image_annotations_format.md` | Canonical `foxglove.ImageAnnotations` wire format for builtin `ImageAnnotations` payloads | +| `docs/image_annotations_format.md` | Canonical `PJ.ImageAnnotations` wire format for builtin `ImageAnnotations` payloads | | `docs/cpp_design_recommendations.md` | C++ style, error handling, API design guidelines | | `docs/toolbox-porting-gap-analysis.md` | SDK gaps identified when porting PJ3 toolboxes (being addressed) | diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 12f7d7f..ff267f6 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -7,15 +7,14 @@ PlotJuggler can classify, store, decode, and render consistently. A plugin that reads a ROS message, a Protobuf message, a JSON payload, or any other source-specific format converts that input into one of these types. The conversion removes source-specific naming and wire-layout details while keeping -the semantic value intact. For example, a ROS `sensor_msgs/Image` and a -Foxglove image message can both become `PJ::sdk::Image`; a ROS +the semantic value intact. For example, a ROS `sensor_msgs/Image` and another +image message schema can both become `PJ::sdk::Image`; a ROS `sensor_msgs/PointCloud2` can become `PJ::sdk::PointCloud`. The public headers live under: ```cpp #include -#include #include #include #include @@ -26,9 +25,9 @@ The public headers live under: ## Design Principles **Convert at the boundary.** DataSource and MessageParser plugins understand -third-party schemas. PlotJuggler internals consume builtin types. This keeps ROS, -Foxglove, dataset-specific, and vendor-specific details out of viewers and -storage policy. +third-party schemas. PlotJuggler internals consume builtin types. This keeps +ROS, dataset-specific, and vendor-specific details out of viewers and storage +policy. **Unify when only the encoding differs.** Raw `rgb8`, `jpeg`, and `png` are all images. They share the same consumer semantics, so they are represented by @@ -39,20 +38,53 @@ images. They share the same consumer semantics, so they are represented by thing. Depth data is represented by `DepthImage` because consumers interpret it as metric distance with camera intrinsics. -**Keep large buffers zero-copy capable.** Byte-backed types carry a -`Span` plus a `BufferAnchor`. The span points at the payload; the -anchor keeps the underlying allocation alive while consumers use it. - -**Keep small annotations owned.** `ImageAnnotations` owns its vectors directly. -Overlay data is small enough that the zero-copy anchor pattern is unnecessary. +**Keep large buffers zero-copy capable.** Byte-backed types split metadata from +payload bytes. The SDK object stores the header fields PlotJuggler needs to +interpret the payload, such as image dimensions or point-cloud field layout, and +stores the payload itself as `Span` plus a `BufferAnchor`. The +span points at the bytes; the anchor keeps the underlying allocation alive while +consumers use it. + +**Keep small objects owned.** `ImageAnnotations` owns its vectors directly. +Future transform and marker types should follow the same pattern unless they +grow payload-sized byte arrays. These values are small enough that the zero-copy +anchor pattern is unnecessary. + +**Do not force one serialization path on every builtin.** Large byte-backed +types are views over source-native payload bytes whenever possible; they should +not be repacked just to produce a canonical blob. Small owned types may define +canonical codecs when storage or replay needs bytes. Those codecs serialize the +owned SDK value directly to the canonical protobuf-wire payload described by the +`.proto` contract. The schema and wire-format details stay private; public SDK +headers expose only SDK structs. + +## Serialization Families + +Builtin objects fall into two serialization families: + +| Family | Current types | Storage model | Codec policy | +|--------|---------------|---------------|--------------| +| Byte-backed views | `Image`, `DepthImage`, `PointCloud` | Header fields live in the SDK struct; payload bytes live behind `Span` plus `BufferAnchor`. | No mandatory canonical codec; preserve zero-copy views over ROS, MCAP, compressed image, point-cloud, or plugin-owned payloads. If conversion is unavoidable, allocate a new payload and anchor it. | +| Owned values | `ImageAnnotations`; future transform and marker types | SDK structs own their vectors/strings/scalars directly. | Add explicit codecs when canonical bytes are needed. Codecs serialize the owned value to the protobuf-wire payload described by the `.proto` contract, using shared private wire primitives. | + +Canonical `.proto` files live under `pj_base/proto/pj` and act as the wire +format contract. `PJ.ImageAnnotations` describes the current annotation codec +payload. `PJ.FrameTransforms` is the schema reserved for future transform +codecs. Shared geometry primitives are grouped in `Geometry.proto`: `Point2`, +`Point3`, `Vector2`, `Vector3`, and `Quaternion`. + +The codecs do not expose generated Protobuf types in public SDK headers. The +current implementation does not require generated Protobuf code or a Protobuf +runtime dependency; it uses private field-tagged wire primitives and maps only +between bytes and SDK structs. ## Type Erasure and Classification -`BuiltinObjectKind` is the a-priori tag a parser reports for a schema. It lets +`BuiltinObjectType` is the a-priori tag a parser reports for a schema. It lets the host decide that a topic produces images, point clouds, depth images, image annotations, or no builtin object. -| Kind | Concrete type | Purpose | +| Type | Concrete type | Purpose | |------|---------------|---------| | `kNone` | none | Scalar-only schema or unknown object. | | `kImage` | `PJ::sdk::Image` | Raw or compressed image data. | @@ -62,7 +94,7 @@ annotations, or no builtin object. `BuiltinObject` is `std::any`. Producers store a concrete builtin value in it; consumers recover the concrete type with `std::any_cast(&object)` or ask -`kindOf(object)` for the kind supported by the current SDK build. +`typeOf(object)` for the type supported by the current SDK build. ## Image @@ -197,7 +229,7 @@ per-vertex colors are used. `fill_color` applies to closed loops and circles when its alpha channel is non-zero. `pj_base/builtin/image_annotations_codec.h` serializes and deserializes this -type using the canonical `foxglove.ImageAnnotations` protobuf wire format. +type using the canonical `PJ.ImageAnnotations` protobuf wire format. See [image_annotations_format.md](image_annotations_format.md) for the field mapping and compatibility rules. diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md index d14d5c4..3157b65 100644 --- a/docs/image_annotations_format.md +++ b/docs/image_annotations_format.md @@ -1,17 +1,24 @@ # Image Annotations Format -PlotJuggler uses a canonical ImageAnnotations wire format when annotation overlays need to be stored, transported, or replayed as bytes. Source-specific adapters convert third-party messages, such as ROS messages, into `PJ::sdk::ImageAnnotations`; the codec then serializes that canonical value using the Foxglove `ImageAnnotations` protobuf schema. +PlotJuggler uses a canonical `PJ.ImageAnnotations` wire format when 2D +annotation overlays need to be stored, transported, or replayed as bytes. +Source-specific adapters convert third-party messages, such as ROS messages, +into the owned `PJ::sdk::ImageAnnotations` value; the codec then serializes +that value to the protobuf-wire payload described by the schema. -This keeps PlotJuggler internals and renderers independent from the original message schema. Consumers should decode the bytes into `PJ::sdk::ImageAnnotations` and operate on the canonical values, not on the source message type that produced them. +This keeps PlotJuggler internals and renderers independent from the original +message schema. Consumers should decode the bytes into +`PJ::sdk::ImageAnnotations` and operate on the canonical values, not on the +source message type that produced them. For the broader builtin type catalog, see [builtin_type.md](builtin_type.md). -## Schema +## Contract The schema identifier for this format is: ```text -foxglove.ImageAnnotations +PJ.ImageAnnotations ``` The public C++ helpers live in: @@ -23,107 +30,81 @@ The public C++ helpers live in: `serializeImageAnnotations()` writes this payload. `deserializeImageAnnotations()` reads it back into `PJ::sdk::ImageAnnotations`. -The encoded payload is a protobuf message. The current codec writes only the fields listed below and skips unknown fields while reading. +The field-level contract is `pj_base/proto/pj/ImageAnnotations.proto` and its +imported `pj_base/proto/pj/*.proto` files. The markdown here intentionally does +not duplicate those field tables; the `.proto` files are the source of truth for +field numbers, field names, and protobuf wire types. -## Top-Level Message +The current C++ implementation uses PlotJuggler's private wire primitives +rather than generated Protobuf code. Public SDK headers expose +`PJ::sdk::ImageAnnotations`, not generated Protobuf classes, and `pj_base` does +not require a Protobuf runtime dependency. -`foxglove.ImageAnnotations` +## Attribution -| Field | Type | Meaning | -| --- | --- | --- | -| `1` | repeated `CircleAnnotation` | Circle overlays | -| `2` | repeated `PointsAnnotation` | Point and line overlays | -| `3` | repeated `TextAnnotation` | Text overlays | +The initial schema layout is adapted from the Foxglove SDK schema catalog, +licensed under MIT by Foxglove Technologies Inc. PlotJuggler keeps the adopted +field numbers and protobuf scalar/message shapes where they are useful, but the +schemas are not a byte-for-byte or descriptor-identical copy. -An annotation payload with no circles, points, or texts serializes to an empty byte buffer. Decoding an empty buffer is treated as invalid input by the current reader. +PlotJuggler-specific differences include: -## Shared Messages +- the protobuf package is `PJ`, not `foxglove`; +- imports use `pj/...`; +- `Point2`, `Point3`, `Vector2`, `Vector3`, and `Quaternion` are grouped in + `Geometry.proto`; +- the C++ codec maps only the fields represented by + `PJ::sdk::ImageAnnotations`. -`foxglove.Point2` +## SDK Mapping -| Field | Type | Meaning | -| --- | --- | --- | -| `1` | double | X coordinate in image pixels | -| `2` | double | Y coordinate in image pixels | +The wire schema is slightly richer than the current SDK value. The codec maps +the renderable annotation fields and ignores the rest: -`foxglove.Color` +| Schema field | SDK behavior | +|--------------|--------------| +| `ImageAnnotations.circles` | Mapped to `ImageAnnotations::circles`. | +| `ImageAnnotations.points` | Mapped to `ImageAnnotations::points`. | +| `ImageAnnotations.texts` | Mapped to `ImageAnnotations::texts`. | +| Top-level `timestamp` | Not serialized or decoded today. | +| Top-level `metadata` | Not serialized or decoded today. | +| Per-annotation `timestamp` | Not serialized or decoded today. | +| Per-annotation `metadata` | Not serialized or decoded today. | +| `TextAnnotation.background_color` | Not represented by the SDK type; skipped on decode and not emitted on encode. | -| Field | Type | Meaning | -| --- | --- | --- | -| `1` | double | Red channel, normalized to `[0, 1]` | -| `2` | double | Green channel, normalized to `[0, 1]` | -| `3` | double | Blue channel, normalized to `[0, 1]` | -| `4` | double | Alpha channel, normalized to `[0, 1]` | +`PJ::sdk::ImageAnnotations::image_topic` is also not part of this payload. It is +runtime association metadata used by PlotJuggler to attach overlays to an image +stream. Adapters that need to preserve it across storage or transport must store +it outside the `PJ.ImageAnnotations` bytes. -Canonical colors use 8-bit channels. Encoding converts each channel to a normalized double; decoding converts the normalized value back to an 8-bit channel. Round trips may differ by plus or minus 1 because of numeric rounding. +## Codec Rules -## Circle Annotations +Circles are stored in the wire schema as `diameter`; the SDK stores `radius`. +The codec converts between `diameter` and `radius * 2.0`. -`foxglove.CircleAnnotation` +Colors are stored in the schema as normalized `double` channels in `[0, 1]`. +The SDK stores RGBA `uint8_t` channels. Decode clamps normalized values to +`[0, 1]` and rounds to the nearest byte, so a round trip may differ by one +channel value because of floating-point rounding. -| Field | Type | Canonical value | -| --- | --- | --- | -| `2` | `Point2` | `center` | -| `3` | double | `radius * 2.0` | -| `4` | double | `thickness` | -| `5` | `Color` | `fill_color` | -| `6` | `Color` | `outline_color` | +Point topology uses the schema enum values: -The wire schema stores circle size as a diameter. The canonical type stores it as a radius, so the codec converts between the two. - -## Point Annotations - -`foxglove.PointsAnnotation` - -| Field | Type | Canonical value | -| --- | --- | --- | -| `2` | enum | `type` | -| `3` | repeated `Point2` | `points` | -| `4` | `Color` | `outline_color` | -| `5` | repeated `Color` | `outline_colors` | -| `6` | `Color` | `fill_color` | -| `7` | double | `thickness` | - -The point topology is encoded as: - -| Wire value | Canonical topology | -| --- | --- | +| Wire value | SDK topology | +|------------|--------------| | `1` | `kPoints` | | `2` | `kLineLoop` | | `3` | `kLineStrip` | | `4` | `kLineList` | -The writer never emits `0`. The reader treats an unknown or zero topology as `kPoints` so old or incomplete payloads still decode to a renderable annotation. - -`outline_colors` is optional. If it is empty, field `5` is omitted and the annotation uses the uniform `outline_color`. - -## Text Annotations - -`foxglove.TextAnnotation` - -| Field | Type | Canonical value | -| --- | --- | --- | -| `2` | `Point2` | `position` | -| `3` | string | `text` | -| `4` | double | `font_size` | -| `5` | `Color` | `text_color` | -| `6` | `Color` | skipped by the current canonical type | - -`background_color` is present in the wire schema but is not represented by `PJ::sdk::ImageAnnotations`, so it is skipped during decoding and not emitted during encoding. - -## Unsupported Canonical Fields - -`PJ::sdk::ImageAnnotations` also carries metadata that is useful inside PlotJuggler but is not part of this wire payload: - -| Canonical field | Wire behavior | -| --- | --- | -| `timestamp` | Not serialized | -| `image_topic` | Not serialized | - -Adapters that need those fields must preserve them outside this payload. - -## Reader Behavior +The writer never emits `UNKNOWN`/`0`. The reader maps unknown or zero topology +values to `kPoints`, so incomplete or forward-compatible payloads still decode +to a renderable annotation. -The reader accepts protobuf fields with wire types `VARINT`, `I64`, `LEN`, and `I32`. It decodes the fields listed in this document and skips unknown fields, including unknown nested fields, so the format can tolerate compatible schema additions. +An annotation value with no circles, points, or texts serializes to an empty byte +buffer. Decoding an empty buffer is treated as invalid input by the current +reader. -Malformed protobuf data, invalid length-delimited fields, or truncated nested messages fail decoding. +The reader accepts protobuf wire types `VARINT`, `I64`, `LEN`, and `I32`. It +decodes the mapped fields and skips unknown fields, including unknown nested +fields, so compatible schema additions can be tolerated. Malformed protobuf data, +invalid length-delimited fields, or truncated nested messages fail decoding. diff --git a/pj_base/include/pj_base/builtin/BuiltinObject.h b/pj_base/include/pj_base/builtin/BuiltinObject.h index 69e8c52..11c6d33 100644 --- a/pj_base/include/pj_base/builtin/BuiltinObject.h +++ b/pj_base/include/pj_base/builtin/BuiltinObject.h @@ -5,21 +5,24 @@ * BuiltinObject is `std::any`. A producer constructs it by passing a * concrete builtin value (`sdk::Image`, `sdk::PointCloud`, `sdk::DepthImage`, * `sdk::ImageAnnotations`, …); a consumer recovers the concrete type via - * `std::any_cast(&obj)` and obtains the kind tag via `kindOf(obj)`. + * `std::any_cast(&obj)` and obtains the type tag via `typeOf(obj)`. * * The type erasure is deliberate: choosing `std::any` over `std::variant` * keeps the SDK forward-compatible. Plugins built against an older SDK can * keep producing the alternatives they know without any TU referencing the * (later-extended) full alternative list; hosts built against an older SDK - * that receive an unknown kind simply see `BuiltinObjectKind::kNone` from - * `kindOf` and reject the message. No protocol bump required when a new - * builtin kind is appended to BuiltinObjectKind and its header. + * that receive an unknown type simply see `BuiltinObjectType::kNone` from + * `typeOf` and reject the message. No protocol bump required when a new + * builtin type is appended to BuiltinObjectType. */ #pragma once #include +#include +#include +#include +#include -#include "pj_base/builtin/BuiltinObjectKind.h" #include "pj_base/builtin/DepthImage.h" #include "pj_base/builtin/Image.h" #include "pj_base/builtin/ImageAnnotations.h" @@ -28,29 +31,58 @@ namespace PJ { namespace sdk { +enum class BuiltinObjectType : uint16_t { + kNone = 0, + kImage = 1, ///< sdk::Image — raw or compressed, distinguished by encoding string. + kPointCloud = 3, ///< sdk::PointCloud — packed points + per-channel field layout. + kDepthImage = 4, ///< sdk::DepthImage — depth pixels + camera intrinsics. + kImageAnnotations = 5, ///< sdk::ImageAnnotations — 2D overlays (points, lines, text). + // Reserved for future types; keep numeric values stable across releases. + // kOccupancyGrid = 6, +}; + +/// A-priori classification of a schema. Currently carries only the type; +/// the struct leaves room to attach declarative metadata later without +/// breaking the API. +struct SchemaClassification { + BuiltinObjectType object_type = BuiltinObjectType::kNone; +}; + +/// Canonical string for a type value. Uses magic_enum for reflection. +/// e.g. name(kImage) == "kImage". +[[nodiscard]] inline constexpr std::string_view name(BuiltinObjectType type) noexcept { + return magic_enum::enum_name(type); +} + +/// Parse a type name into the enum. Accepts the same strings name() +/// emits (e.g. "kImage"). Returns nullopt for unknown names. +[[nodiscard]] inline constexpr std::optional parseBuiltinObjectType(std::string_view s) noexcept { + return magic_enum::enum_cast(s); +} + using BuiltinObject = std::any; -/// Get the kind tag for a BuiltinObject without copying it. +/// Get the type tag for a BuiltinObject without copying it. /// Returns kNone for an empty BuiltinObject or one that wraps a type /// unknown to this SDK build. -[[nodiscard]] inline BuiltinObjectKind kindOf(const BuiltinObject& obj) noexcept { +[[nodiscard]] inline BuiltinObjectType typeOf(const BuiltinObject& obj) noexcept { if (!obj.has_value()) { - return BuiltinObjectKind::kNone; + return BuiltinObjectType::kNone; } const auto& t = obj.type(); if (t == typeid(Image)) { - return BuiltinObjectKind::kImage; + return BuiltinObjectType::kImage; } if (t == typeid(PointCloud)) { - return BuiltinObjectKind::kPointCloud; + return BuiltinObjectType::kPointCloud; } if (t == typeid(DepthImage)) { - return BuiltinObjectKind::kDepthImage; + return BuiltinObjectType::kDepthImage; } if (t == typeid(ImageAnnotations)) { - return BuiltinObjectKind::kImageAnnotations; + return BuiltinObjectType::kImageAnnotations; } - return BuiltinObjectKind::kNone; + return BuiltinObjectType::kNone; } } // namespace sdk diff --git a/pj_base/include/pj_base/builtin/BuiltinObjectKind.h b/pj_base/include/pj_base/builtin/BuiltinObjectKind.h deleted file mode 100644 index bb32c4d..0000000 --- a/pj_base/include/pj_base/builtin/BuiltinObjectKind.h +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @file BuiltinObjectKind.h - * @brief A-priori tag identifying which builtin object a parser produces - * for a given schema. - * - * The kind is returned by MessageParser::classifySchema() without parsing - * any payload — the host uses it to decide whether to register an - * ObjectTopic and how to route the message. - * - * Numeric values are stable across releases; mirror of the C ABI enum - * PJ_builtin_object_kind_t in pj_base/builtin_object_abi.h. - */ -#pragma once - -#include -#include -#include -#include - -namespace PJ { -namespace sdk { - -enum class BuiltinObjectKind : uint16_t { - kNone = 0, - kImage = 1, ///< sdk::Image — raw or compressed, distinguished by encoding string. - kPointCloud = 3, ///< sdk::PointCloud — packed points + per-channel field layout. - kDepthImage = 4, ///< sdk::DepthImage — depth pixels + camera intrinsics. - kImageAnnotations = 5, ///< sdk::ImageAnnotations — 2D overlays (points, lines, text). - // Reserved for future kinds; keep numeric values stable across releases. - // kOccupancyGrid = 6, -}; - -/// A-priori classification of a schema. Currently carries only the kind; -/// the struct (instead of a raw enum) leaves room to attach declarative -/// metadata later without breaking the API. -struct SchemaClassification { - BuiltinObjectKind object_kind = BuiltinObjectKind::kNone; -}; - -/// Canonical string for a kind value (without the leading `k`). Uses -/// magic_enum for reflection. e.g. name(kImage) == "kImage" — callers -/// that want the bare token strip the prefix. -[[nodiscard]] inline constexpr std::string_view name(BuiltinObjectKind kind) noexcept { - return magic_enum::enum_name(kind); -} - -/// Parse a kind name into the enum. Accepts the same strings name() -/// emits (e.g. "kImage"). Returns nullopt for unknown names. -[[nodiscard]] inline constexpr std::optional parseBuiltinObjectKind(std::string_view s) noexcept { - return magic_enum::enum_cast(s); -} - -} // namespace sdk -} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/CommonImageEncoding.h b/pj_base/include/pj_base/builtin/CommonImageEncoding.h deleted file mode 100644 index 40134b3..0000000 --- a/pj_base/include/pj_base/builtin/CommonImageEncoding.h +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @file CommonImageEncoding.h - * @brief Optional vocabulary of canonical image encoding strings. - * - * sdk::Image::encoding is an open std::string — producers and consumers - * are not required to use the values listed here. This header documents - * the encodings the in-tree producers (parser_ros, future plugins) emit - * and the consumers (pj_scene2D) recognise, plus a magic_enum-backed - * round-trip helper. - * - * The enumerator NAMES are the canonical strings (so - * magic_enum::enum_name(rgb8) == "rgb8"). Use parse() to convert a - * string from the wire into the enum, and name() to produce a string - * to emit. - */ -#pragma once - -#include -#include -#include -#include - -namespace PJ { -namespace sdk { - -// NOLINTBEGIN(readability-identifier-naming) -// Enumerator names are deliberately the canonical encoding strings, not -// the project's kCamelCase constants. -enum class CommonImageEncoding : uint8_t { - // Raw pixel layouts - rgb8, ///< 3 bytes/pixel, R-G-B order. - rgba8, ///< 4 bytes/pixel, R-G-B-A order. - bgr8, ///< 3 bytes/pixel, B-G-R order. - bgra8, ///< 4 bytes/pixel, B-G-R-A order. - mono8, ///< 1 byte/pixel, grayscale. - mono16, ///< 2 bytes/pixel, grayscale. - // Compressed wire formats - jpeg, - png, - qoi, - // ROS-specific - compressedDepth, ///< PNG payload + depth quantization range in extras. -}; -// NOLINTEND(readability-identifier-naming) - -/// Canonical string for an encoding value. Same as -/// magic_enum::enum_name(e) but kept as a one-liner for callers that -/// don't want to know about magic_enum. -[[nodiscard]] inline constexpr std::string_view name(CommonImageEncoding e) noexcept { - return magic_enum::enum_name(e); -} - -/// Parse an encoding string into the enum. Returns nullopt if the -/// string isn't one of the documented vocabulary entries. -[[nodiscard]] inline constexpr std::optional parseImageEncoding(std::string_view s) noexcept { - return magic_enum::enum_cast(s); -} - -} // namespace sdk -} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/Image.h b/pj_base/include/pj_base/builtin/Image.h index 109e17d..75e426b 100644 --- a/pj_base/include/pj_base/builtin/Image.h +++ b/pj_base/include/pj_base/builtin/Image.h @@ -6,8 +6,10 @@ #pragma once #include +#include #include #include +#include #include "pj_base/buffer_anchor.hpp" #include "pj_base/span.hpp" @@ -16,6 +18,44 @@ namespace PJ { namespace sdk { +/// Optional vocabulary of canonical image encoding strings. +/// +/// `Image::encoding` is an open std::string; producers and consumers are not +/// required to use the values listed here. This enum documents the encodings +/// the SDK recognizes and provides magic_enum-backed round-trip helpers. +// NOLINTBEGIN(readability-identifier-naming) +// Enumerator names are deliberately the canonical encoding strings, not the +// project's kCamelCase constants. +enum class CommonImageEncoding : uint8_t { + // Raw pixel layouts + rgb8, ///< 3 bytes/pixel, R-G-B order. + rgba8, ///< 4 bytes/pixel, R-G-B-A order. + bgr8, ///< 3 bytes/pixel, B-G-R order. + bgra8, ///< 4 bytes/pixel, B-G-R-A order. + mono8, ///< 1 byte/pixel, grayscale. + mono16, ///< 2 bytes/pixel, grayscale. + // Compressed wire formats + jpeg, + png, + qoi, + // ROS-specific + compressedDepth, ///< PNG payload + depth quantization range in extras. +}; +// NOLINTEND(readability-identifier-naming) + +/// Canonical string for an encoding value. Same as +/// magic_enum::enum_name(e), but kept as a one-liner for callers that +/// don't want to know about magic_enum. +[[nodiscard]] inline constexpr std::string_view name(CommonImageEncoding e) noexcept { + return magic_enum::enum_name(e); +} + +/// Parse an encoding string into the enum. Returns nullopt if the string is +/// not one of the documented vocabulary entries. +[[nodiscard]] inline constexpr std::optional parseImageEncoding(std::string_view s) noexcept { + return magic_enum::enum_cast(s); +} + /// Image. The `encoding` string distinguishes raw pixel layouts from /// compressed wire formats; the producer decides which. /// @@ -35,9 +75,8 @@ namespace sdk { /// `compressed_depth_max` carry the quantization range needed to map /// the grayscale back to depth values. /// -/// See pj_base/builtin/CommonImageEncoding.h for the documented -/// vocabulary of canonical encoding strings, with helpers to parse and -/// emit them. +/// `CommonImageEncoding` above defines the documented vocabulary of canonical +/// encoding strings, with helpers to parse and emit them. /// /// `anchor` keeps the underlying buffer alive — the producer may have made /// `data` a view into the source payload (zero-copy) or into a freshly diff --git a/pj_base/include/pj_base/builtin/image_annotations_codec.h b/pj_base/include/pj_base/builtin/image_annotations_codec.h index b1f7f83..3d489ef 100644 --- a/pj_base/include/pj_base/builtin/image_annotations_codec.h +++ b/pj_base/include/pj_base/builtin/image_annotations_codec.h @@ -11,15 +11,15 @@ namespace PJ { /// Wire-format identifier for canonical image annotations. -inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; +inline constexpr std::string_view kSchemaImageAnnotations = "PJ.ImageAnnotations"; -/// Serializes a sdk::ImageAnnotations to canonical foxglove.ImageAnnotations Protobuf bytes. +/// Serializes a sdk::ImageAnnotations to canonical PJ.ImageAnnotations wire bytes. /// /// `timestamp` and `image_topic` are not serialized; callers that need them /// must preserve them outside this payload. [[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); -/// Decodes canonical foxglove.ImageAnnotations Protobuf bytes into sdk::ImageAnnotations. +/// Decodes canonical PJ.ImageAnnotations wire bytes into sdk::ImageAnnotations. /// /// Returns an error for null, empty, truncated, or malformed payloads. [[nodiscard]] Expected deserializeImageAnnotations(const uint8_t* data, size_t size); diff --git a/pj_base/include/pj_base/builtin_object_abi.h b/pj_base/include/pj_base/builtin_object_abi.h index 9f927fb..e649ce7 100644 --- a/pj_base/include/pj_base/builtin_object_abi.h +++ b/pj_base/include/pj_base/builtin_object_abi.h @@ -3,12 +3,12 @@ * @brief C ABI vocabulary for schema classification. * * The host invokes classify_schema (a slot in PJ_message_parser_vtable_t) - * after bind_schema to learn what kind of canonical object the parser will + * after bind_schema to learn what type of canonical object the parser will * produce for that schema. The parser returns a PJ_schema_classification_t - * carrying a PJ_builtin_object_kind_t. + * carrying a PJ_builtin_object_type_t. * - * Canonical-object production (sdk::Image / sdk::CompressedImage / - * sdk::PointCloud) and the pure-functional scalar production + * Canonical-object production (sdk::Image / sdk::DepthImage / + * sdk::PointCloud / sdk::ImageAnnotations) and the pure-functional scalar production * (Expected>) are C++ SDK contracts: plugins * inheriting from MessageParserPluginBase register handlers in * SchemaHandler, and the in-process host consumes them via @@ -30,23 +30,23 @@ extern "C" { #endif /** - * Canonical object kinds. Numeric values are stable across releases — never - * renumber. Returned by the classify_schema slot to advertise what kind of + * Canonical object types. Numeric values are stable across releases — never + * renumber. Returned by the classify_schema slot to advertise what type of * canonical object the parser will produce for this schema (or kNone if * the parser only produces scalars). */ -typedef enum PJ_builtin_object_kind_t { - PJ_BUILTIN_OBJECT_KIND_NONE = 0, - PJ_BUILTIN_OBJECT_KIND_IMAGE = 1, - PJ_BUILTIN_OBJECT_KIND_POINTCLOUD = 3, - PJ_BUILTIN_OBJECT_KIND_DEPTH_IMAGE = 4, - PJ_BUILTIN_OBJECT_KIND_IMAGE_ANNOTATIONS = 5, - /* Reserve future kinds; appended at the tail. */ - /* PJ_BUILTIN_OBJECT_KIND_OCCUPANCY_GRID = 6, */ -} PJ_builtin_object_kind_t; +typedef enum PJ_builtin_object_type_t { + PJ_BUILTIN_OBJECT_TYPE_NONE = 0, + PJ_BUILTIN_OBJECT_TYPE_IMAGE = 1, + PJ_BUILTIN_OBJECT_TYPE_POINTCLOUD = 3, + PJ_BUILTIN_OBJECT_TYPE_DEPTH_IMAGE = 4, + PJ_BUILTIN_OBJECT_TYPE_IMAGE_ANNOTATIONS = 5, + /* Reserve future types; appended at the tail. */ + /* PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID = 6, */ +} PJ_builtin_object_type_t; /** - * Schema classification — what kind a parser declares for a given schema. + * Schema classification — what type a parser declares for a given schema. * Returned a priori (without parsing payload) by the classify_schema slot. * * Single field plus reserved padding to keep the struct size stable across @@ -54,7 +54,7 @@ typedef enum PJ_builtin_object_kind_t { * accept any value (forward compat). */ typedef struct PJ_schema_classification_t { - uint16_t object_kind; /**< PJ_builtin_object_kind_t. */ + uint16_t object_type; /**< PJ_builtin_object_type_t. */ uint16_t reserved; } PJ_schema_classification_t; diff --git a/pj_base/include/pj_base/message_parser_protocol.h b/pj_base/include/pj_base/message_parser_protocol.h index d29cc9f..6e3e12b 100644 --- a/pj_base/include/pj_base/message_parser_protocol.h +++ b/pj_base/include/pj_base/message_parser_protocol.h @@ -125,7 +125,7 @@ typedef struct PJ_message_parser_vtable_t { * @p out_classification by value (POD). * * NULL or absent (struct_size too small) → host treats as - * PJ_BUILTIN_OBJECT_KIND_NONE. + * PJ_BUILTIN_OBJECT_TYPE_NONE. * * Pure-functional contract: no host side-effects. */ diff --git a/pj_base/proto/pj/CircleAnnotation.proto b/pj_base/proto/pj/CircleAnnotation.proto new file mode 100644 index 0000000..4b5d621 --- /dev/null +++ b/pj_base/proto/pj/CircleAnnotation.proto @@ -0,0 +1,37 @@ +// PlotJuggler canonical image annotation protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +import "pj/Color.proto"; +import "pj/Geometry.proto"; +import "pj/KeyValuePair.proto"; +import "google/protobuf/timestamp.proto"; + +package PJ; + +// A circle annotation on a 2D image +message CircleAnnotation { + // Timestamp of circle + google.protobuf.Timestamp timestamp = 1; + + // Center of the circle in 2D image coordinates (pixels). + // The coordinate uses the top-left corner of the top-left pixel of the image as the origin. + PJ.Point2 position = 2; + + // Circle diameter in pixels + double diameter = 3; + + // Line thickness in pixels + double thickness = 4; + + // Fill color + PJ.Color fill_color = 5; + + // Outline color + PJ.Color outline_color = 6; + + // Additional user-provided metadata associated with this annotation. Keys must be unique. + repeated PJ.KeyValuePair metadata = 7; +} diff --git a/pj_base/proto/pj/Color.proto b/pj_base/proto/pj/Color.proto new file mode 100644 index 0000000..75377c0 --- /dev/null +++ b/pj_base/proto/pj/Color.proto @@ -0,0 +1,22 @@ +// PlotJuggler canonical color protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +package PJ; + +// A color in RGBA format +message Color { + // Red value between 0 and 1 + double r = 1; + + // Green value between 0 and 1 + double g = 2; + + // Blue value between 0 and 1 + double b = 3; + + // Alpha value between 0 and 1 + double a = 4; +} diff --git a/pj_base/proto/pj/FrameTransform.proto b/pj_base/proto/pj/FrameTransform.proto new file mode 100644 index 0000000..c69fa81 --- /dev/null +++ b/pj_base/proto/pj/FrameTransform.proto @@ -0,0 +1,34 @@ +// PlotJuggler canonical frame transform protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +import "pj/Geometry.proto"; +import "google/protobuf/timestamp.proto"; + +package PJ; + +// A transform between two reference frames in 3D space. The transform defines the position and orientation of a child frame within a parent frame. Translation moves the origin of the child frame relative to the parent origin. The rotation changes the orientation of the child frame around its origin. +// +// Examples: +// +// - With translation (x=1, y=0, z=0) and identity rotation (x=0, y=0, z=0, w=1), a point at (x=0, y=0, z=0) in the child frame maps to (x=1, y=0, z=0) in the parent frame. +// +// - With translation (x=1, y=2, z=0) and a 90-degree rotation around the z-axis (x=0, y=0, z=0.707, w=0.707), a point at (x=1, y=0, z=0) in the child frame maps to (x=-1, y=3, z=0) in the parent frame. +message FrameTransform { + // Timestamp of transform + google.protobuf.Timestamp timestamp = 1; + + // Name of the parent frame + string parent_frame_id = 2; + + // Name of the child frame + string child_frame_id = 3; + + // Translation component of the transform, representing the position of the child frame's origin in the parent frame. + PJ.Vector3 translation = 4; + + // Rotation component of the transform, representing the orientation of the child frame in the parent frame + PJ.Quaternion rotation = 5; +} diff --git a/pj_base/proto/pj/FrameTransforms.proto b/pj_base/proto/pj/FrameTransforms.proto new file mode 100644 index 0000000..f6e612d --- /dev/null +++ b/pj_base/proto/pj/FrameTransforms.proto @@ -0,0 +1,15 @@ +// PlotJuggler canonical frame transforms protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +import "pj/FrameTransform.proto"; + +package PJ; + +// An array of FrameTransform messages +message FrameTransforms { + // Array of transforms + repeated PJ.FrameTransform transforms = 1; +} diff --git a/pj_base/proto/pj/Geometry.proto b/pj_base/proto/pj/Geometry.proto new file mode 100644 index 0000000..0a7a96d --- /dev/null +++ b/pj_base/proto/pj/Geometry.proto @@ -0,0 +1,64 @@ +// PlotJuggler canonical geometry protobuf schemas. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +package PJ; + +// A point representing a position in 2D space +message Point2 { + // x coordinate position + double x = 1; + + // y coordinate position + double y = 2; +} + +// A point representing a position in 3D space +message Point3 { + // x coordinate position + double x = 1; + + // y coordinate position + double y = 2; + + // z coordinate position + double z = 3; +} + +// A vector in 2D space that represents a direction only +message Vector2 { + // x component + double x = 1; + + // y component + double y = 2; +} + +// A vector in 3D space that represents a direction only +message Vector3 { + // x component + double x = 1; + + // y component + double y = 2; + + // z component + double z = 3; +} + +// A quaternion representing a rotation in 3D space +message Quaternion { + // x value + double x = 1; + + // y value + double y = 2; + + // z value + double z = 3; + + // w value + double w = 4; +} diff --git a/pj_base/proto/pj/ImageAnnotations.proto b/pj_base/proto/pj/ImageAnnotations.proto new file mode 100644 index 0000000..cc266d6 --- /dev/null +++ b/pj_base/proto/pj/ImageAnnotations.proto @@ -0,0 +1,31 @@ +// PlotJuggler canonical image annotations protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +import "pj/CircleAnnotation.proto"; +import "pj/KeyValuePair.proto"; +import "pj/PointsAnnotation.proto"; +import "pj/TextAnnotation.proto"; +import "google/protobuf/timestamp.proto"; + +package PJ; + +// Array of annotations for a 2D image +message ImageAnnotations { + // Timestamp of the image annotations. When set, individual annotation timestamps will be ignored. + optional google.protobuf.Timestamp timestamp = 5; + + // Circle annotations + repeated PJ.CircleAnnotation circles = 1; + + // Points annotations + repeated PJ.PointsAnnotation points = 2; + + // Text annotations + repeated PJ.TextAnnotation texts = 3; + + // Additional user-provided metadata associated with the image annotations. Keys must be unique within this object. Per-annotation metadata takes precedence over these values. + repeated PJ.KeyValuePair metadata = 4; +} diff --git a/pj_base/proto/pj/KeyValuePair.proto b/pj_base/proto/pj/KeyValuePair.proto new file mode 100644 index 0000000..e9c2f76 --- /dev/null +++ b/pj_base/proto/pj/KeyValuePair.proto @@ -0,0 +1,16 @@ +// PlotJuggler canonical metadata protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +package PJ; + +// A key with its associated value +message KeyValuePair { + // Key + string key = 1; + + // Value + string value = 2; +} diff --git a/pj_base/proto/pj/PointsAnnotation.proto b/pj_base/proto/pj/PointsAnnotation.proto new file mode 100644 index 0000000..66c6590 --- /dev/null +++ b/pj_base/proto/pj/PointsAnnotation.proto @@ -0,0 +1,58 @@ +// PlotJuggler canonical image annotation protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +import "pj/Color.proto"; +import "pj/Geometry.proto"; +import "pj/KeyValuePair.proto"; +import "google/protobuf/timestamp.proto"; + +package PJ; + +// An array of points on a 2D image +message PointsAnnotation { + // Type of points annotation + enum Type { + // Unknown points annotation type + UNKNOWN = 0; + + // Individual points: 0, 1, 2, ... + POINTS = 1; + + // Closed polygon: 0-1, 1-2, ..., (n-1)-n, n-0 + LINE_LOOP = 2; + + // Connected line segments: 0-1, 1-2, ..., (n-1)-n + LINE_STRIP = 3; + + // Individual line segments: 0-1, 2-3, 4-5, ... + LINE_LIST = 4; + } + + // Timestamp of annotation + google.protobuf.Timestamp timestamp = 1; + + // Type of points annotation to draw + Type type = 2; + + // Points in 2D image coordinates (pixels). + // These coordinates use the top-left corner of the top-left pixel of the image as the origin. + repeated PJ.Point2 points = 3; + + // Outline color + PJ.Color outline_color = 4; + + // Per-point colors, if `type` is `POINTS`, or per-segment stroke colors, if `type` is `LINE_LIST`, `LINE_STRIP` or `LINE_LOOP`. + repeated PJ.Color outline_colors = 5; + + // Fill color + PJ.Color fill_color = 6; + + // Stroke thickness in pixels + double thickness = 7; + + // Additional user-provided metadata associated with this annotation. Keys must be unique. + repeated PJ.KeyValuePair metadata = 8; +} diff --git a/pj_base/proto/pj/TextAnnotation.proto b/pj_base/proto/pj/TextAnnotation.proto new file mode 100644 index 0000000..5c17740 --- /dev/null +++ b/pj_base/proto/pj/TextAnnotation.proto @@ -0,0 +1,37 @@ +// PlotJuggler canonical image annotation protobuf schema. +// Field layout adapted from Foxglove SDK schemas (MIT License, +// Copyright (c) Foxglove Technologies Inc). + +syntax = "proto3"; + +import "pj/Color.proto"; +import "pj/Geometry.proto"; +import "pj/KeyValuePair.proto"; +import "google/protobuf/timestamp.proto"; + +package PJ; + +// A text label on a 2D image +message TextAnnotation { + // Timestamp of annotation + google.protobuf.Timestamp timestamp = 1; + + // Bottom-left origin of the text label in 2D image coordinates (pixels). + // The coordinate uses the top-left corner of the top-left pixel of the image as the origin. + PJ.Point2 position = 2; + + // Text to display + string text = 3; + + // Font size in pixels + double font_size = 4; + + // Text color + PJ.Color text_color = 5; + + // Background fill color + PJ.Color background_color = 6; + + // Additional user-provided metadata associated with this annotation. Keys must be unique. + repeated PJ.KeyValuePair metadata = 7; +} diff --git a/pj_base/src/builtin/image_annotations_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp index f8d2b78..09c28ca 100644 --- a/pj_base/src/builtin/image_annotations_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -1,12 +1,14 @@ #include "pj_base/builtin/image_annotations_codec.h" #include -#include #include +#include "protobuf_wire.h" + namespace PJ { namespace { +using builtin_wire::Writer; using sdk::AnnotationTopology; using sdk::CircleAnnotation; using sdk::ColorRGBA; @@ -15,76 +17,20 @@ using sdk::Point2; using sdk::PointsAnnotation; using sdk::TextAnnotation; -// Hand-rolled Protobuf wire emission. Mirror of the reader in -// image_annotations_decoder.cpp. -// -// Wire format spec: https://protobuf.dev/programming-guides/encoding/ -// Wire types we emit: VARINT(0), I64(1), LEN(2). I32(5) is unused here. -// -// Sub-messages are length-delimited: write the body to a scratch buffer, then -// write the parent's tag + length + body. Bodies are bounded (at most a few hundred -// bytes for typical annotations), so the extra allocation is fine. - -void writeVarint(std::vector& out, uint64_t v) { - while (v >= 0x80u) { - out.push_back(static_cast((v & 0x7Fu) | 0x80u)); - v >>= 7; - } - out.push_back(static_cast(v)); -} - -void writeTag(std::vector& out, uint32_t field, uint32_t wire) { - writeVarint(out, (static_cast(field) << 3) | (wire & 0x7u)); -} - -void writeDouble(std::vector& out, double v) { - uint64_t bits = 0; - std::memcpy(&bits, &v, 8); - for (int i = 0; i < 8; ++i) { - out.push_back(static_cast((bits >> (8 * i)) & 0xFFu)); - } -} - -void writeLenDelim(std::vector& out, const std::vector& body) { - writeVarint(out, body.size()); - out.insert(out.end(), body.begin(), body.end()); -} - -void writeString(std::vector& out, std::string_view s) { - writeVarint(out, s.size()); - out.insert(out.end(), s.begin(), s.end()); +void writePoint2(Writer& writer, const Point2& point) { + writer.doubleField(1, point.x); + writer.doubleField(2, point.y); } -// foxglove.Point2 { 1: double x, 2: double y } -std::vector buildPoint2(const Point2& p) { - std::vector body; - writeTag(body, 1, 1); - writeDouble(body, p.x); - writeTag(body, 2, 1); - writeDouble(body, p.y); - return body; +void writeColor(Writer& writer, const ColorRGBA& color) { + writer.doubleField(1, static_cast(color.r) / 255.0); + writer.doubleField(2, static_cast(color.g) / 255.0); + writer.doubleField(3, static_cast(color.b) / 255.0); + writer.doubleField(4, static_cast(color.a) / 255.0); } -// foxglove.Color { 1: double r, 2: double g, 3: double b, 4: double a } -// Components in [0, 1]. We hold uint8 [0, 255] and convert by v/255.0. -std::vector buildColor(const ColorRGBA& c) { - std::vector body; - writeTag(body, 1, 1); - writeDouble(body, static_cast(c.r) / 255.0); - writeTag(body, 2, 1); - writeDouble(body, static_cast(c.g) / 255.0); - writeTag(body, 3, 1); - writeDouble(body, static_cast(c.b) / 255.0); - writeTag(body, 4, 1); - writeDouble(body, static_cast(c.a) / 255.0); - return body; -} - -// AnnotationTopology -> Foxglove enum. Inverse of `mapTopology` in the reader. -// kPoints=1, kLineLoop=2, kLineStrip=3, kLineList=4. The Foxglove enum reserves -// 0 for UNKNOWN; we never emit 0. -uint32_t topologyToEnum(AnnotationTopology t) { - switch (t) { +uint32_t topologyToEnum(AnnotationTopology topology) { + switch (topology) { case AnnotationTopology::kPoints: return 1; case AnnotationTopology::kLineLoop: @@ -94,111 +40,55 @@ uint32_t topologyToEnum(AnnotationTopology t) { case AnnotationTopology::kLineList: return 4; } - return 1; // unreachable; defensive + return 1; } -// foxglove.PointsAnnotation -// { 2: type (varint enum), 3: repeated Point2, 4: outline_color, -// 5: repeated outline_colors, 6: fill_color, 7: thickness (double) } -std::vector buildPointsAnnotation(const PointsAnnotation& pa) { - std::vector body; - - writeTag(body, 2, 0); - writeVarint(body, topologyToEnum(pa.topology)); +void writePointsAnnotation(Writer& writer, const PointsAnnotation& points) { + writer.varint(2, topologyToEnum(points.topology)); - for (const auto& pt : pa.points) { - writeTag(body, 3, 2); - writeLenDelim(body, buildPoint2(pt)); + for (const auto& point : points.points) { + writer.message(3, [&](Writer& nested) { writePoint2(nested, point); }); } - writeTag(body, 4, 2); - writeLenDelim(body, buildColor(pa.color)); + writer.message(4, [&](Writer& nested) { writeColor(nested, points.color); }); - // Per-vertex colors: emit one field-5 entry per element. An empty `colors` - // vector emits zero entries; the reader pushes_back on - // every field-5 occurrence, so emitting an empty Color would smuggle a - // default-constructed entry into out.colors. - for (const auto& c : pa.colors) { - writeTag(body, 5, 2); - writeLenDelim(body, buildColor(c)); + for (const auto& color : points.colors) { + writer.message(5, [&](Writer& nested) { writeColor(nested, color); }); } - writeTag(body, 6, 2); - writeLenDelim(body, buildColor(pa.fill_color)); - - writeTag(body, 7, 1); - writeDouble(body, pa.thickness); - - return body; + writer.message(6, [&](Writer& nested) { writeColor(nested, points.fill_color); }); + writer.doubleField(7, points.thickness); } -// foxglove.CircleAnnotation -// { 2: position (Point2), 3: diameter (double), 4: thickness (double), -// 5: fill_color, 6: outline_color } -// Note: our struct holds `radius`; we emit `diameter = radius * 2`. -std::vector buildCircleAnnotation(const CircleAnnotation& ca) { - std::vector body; - - writeTag(body, 2, 2); - writeLenDelim(body, buildPoint2(ca.center)); - - writeTag(body, 3, 1); - writeDouble(body, ca.radius * 2.0); - - writeTag(body, 4, 1); - writeDouble(body, ca.thickness); - - writeTag(body, 5, 2); - writeLenDelim(body, buildColor(ca.fill_color)); - - writeTag(body, 6, 2); - writeLenDelim(body, buildColor(ca.color)); - - return body; +void writeCircleAnnotation(Writer& writer, const CircleAnnotation& circle) { + writer.message(2, [&](Writer& nested) { writePoint2(nested, circle.center); }); + writer.doubleField(3, circle.radius * 2.0); + writer.doubleField(4, circle.thickness); + writer.message(5, [&](Writer& nested) { writeColor(nested, circle.fill_color); }); + writer.message(6, [&](Writer& nested) { writeColor(nested, circle.color); }); } -// foxglove.TextAnnotation -// { 2: position (Point2), 3: text (string), 4: font_size (double), -// 5: text_color } -// background_color (field 6) is intentionally not emitted. The C++ struct has -// no equivalent field. The reader skips it on read. -std::vector buildTextAnnotation(const TextAnnotation& ta) { - std::vector body; - - writeTag(body, 2, 2); - writeLenDelim(body, buildPoint2(ta.position)); - - writeTag(body, 3, 2); - writeString(body, ta.text); - - writeTag(body, 4, 1); - writeDouble(body, ta.font_size); - - writeTag(body, 5, 2); - writeLenDelim(body, buildColor(ta.color)); - - return body; +void writeTextAnnotation(Writer& writer, const TextAnnotation& text) { + writer.message(2, [&](Writer& nested) { writePoint2(nested, text.position); }); + writer.string(3, text.text); + writer.doubleField(4, text.font_size); + writer.message(5, [&](Writer& nested) { writeColor(nested, text.color); }); } } // namespace -// foxglove.ImageAnnotations { 1: repeated CircleAnnotation, -// 2: repeated PointsAnnotation, -// 3: repeated TextAnnotation } -std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia) { +std::vector serializeImageAnnotations(const ImageAnnotations& annotations) { std::vector out; + Writer writer(out); - for (const auto& c : ia.circles) { - writeTag(out, 1, 2); - writeLenDelim(out, buildCircleAnnotation(c)); + for (const auto& circle : annotations.circles) { + writer.message(1, [&](Writer& nested) { writeCircleAnnotation(nested, circle); }); } - for (const auto& p : ia.points) { - writeTag(out, 2, 2); - writeLenDelim(out, buildPointsAnnotation(p)); + for (const auto& points : annotations.points) { + writer.message(2, [&](Writer& nested) { writePointsAnnotation(nested, points); }); } - for (const auto& t : ia.texts) { - writeTag(out, 3, 2); - writeLenDelim(out, buildTextAnnotation(t)); + for (const auto& text : annotations.texts) { + writer.message(3, [&](Writer& nested) { writeTextAnnotation(nested, text); }); } return out; diff --git a/pj_base/src/builtin/image_annotations_decoder.cpp b/pj_base/src/builtin/image_annotations_decoder.cpp index bc53a8b..4cb889d 100644 --- a/pj_base/src/builtin/image_annotations_decoder.cpp +++ b/pj_base/src/builtin/image_annotations_decoder.cpp @@ -1,123 +1,24 @@ +#include #include -#include #include #include #include "pj_base/builtin/image_annotations_codec.h" +#include "protobuf_wire.h" namespace PJ { namespace { +using builtin_wire::Reader; +using builtin_wire::Tag; +using builtin_wire::WireType; using sdk::AnnotationTopology; using sdk::CircleAnnotation; using sdk::ColorRGBA; -using sdk::ImageAnnotations; using sdk::Point2; using sdk::PointsAnnotation; using sdk::TextAnnotation; -// Minimal Protobuf wire-format reader for foxglove.ImageAnnotations. Decodes -// PointsAnnotation, CircleAnnotation, and TextAnnotation in full. Round-trips -// against the sibling writer are covered by image_annotations_codec_test. -// -// Spec reference: https://protobuf.dev/programming-guides/encoding/ -// Wire types we need: VARINT(0), I64(1), LEN(2). I32(5) skipped if encountered. -// -// Foxglove schemas (https://github.com/foxglove/schemas, foxglove/proto/): -// ImageAnnotations { circles=1, points=2, texts=3 } -// PointsAnnotation { timestamp=1, type=2 (enum: 0/1/2/3/4), -// points=3 (repeated Point2), -// outline_color=4, outline_colors=5, -// fill_color=6, thickness=7 } -// Point2 { x=1, y=2 } (both double) -// Time { sec=1 (int64), nanosec=2 (uint32) } - -struct Reader { - const uint8_t* p; - const uint8_t* end; - - bool eof() const noexcept { - return p >= end; - } - size_t remaining() const noexcept { - return static_cast(end - p); - } - - // Returns false on overflow / end-of-buffer. - bool readVarint(uint64_t& out) { - out = 0; - int shift = 0; - while (p < end) { - uint8_t b = *p++; - out |= static_cast(b & 0x7Fu) << shift; - if ((b & 0x80u) == 0) { - return true; - } - shift += 7; - if (shift > 63) { - return false; - } - } - return false; - } - - bool readFixed64(uint64_t& out) { - if (remaining() < 8) { - return false; - } - std::memcpy(&out, p, 8); // little-endian on x86_64; protobuf is also LE - p += 8; - return true; - } - - bool readDouble(double& out) { - uint64_t bits = 0; - if (!readFixed64(bits)) { - return false; - } - std::memcpy(&out, &bits, 8); - return true; - } - - // Skip a single field given its wire type. Advances p past the field body. - bool skipField(uint32_t wire_type) { - switch (wire_type) { - case 0: { // VARINT - uint64_t dummy = 0; - return readVarint(dummy); - } - case 1: { // I64 - if (remaining() < 8) { - return false; - } - p += 8; - return true; - } - case 2: { // LEN - uint64_t len = 0; - if (!readVarint(len)) { - return false; - } - if (len > remaining()) { - return false; - } - p += len; - return true; - } - case 5: { // I32 - if (remaining() < 4) { - return false; - } - p += 4; - return true; - } - default: - return false; // groups (3/4) deprecated, not expected - } - } -}; - -// Map Foxglove PointsAnnotation.Type enum values to our AnnotationTopology. AnnotationTopology mapTopology(uint64_t type) { switch (type) { case 1: @@ -130,287 +31,281 @@ AnnotationTopology mapTopology(uint64_t type) { return AnnotationTopology::kLineList; case 0: default: - return AnnotationTopology::kPoints; // UNKNOWN maps to a safe default. + return AnnotationTopology::kPoints; } } -// Decode a Point2 sub-message: {1: double x, 2: double y}. -bool decodePoint2(Reader& r, size_t len, Point2& out) { - const uint8_t* sub_end = r.p + len; - if (sub_end > r.end) { - return false; - } - while (r.p < sub_end) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { +uint8_t normalizedToByte(double value) { + value = std::clamp(value, 0.0, 1.0); + return static_cast(value * 255.0 + 0.5); +} + +bool decodePoint2(Reader& reader, Point2& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { return false; } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); - if (field == 1 && wire == 1) { - if (!r.readDouble(out.x)) { - return false; - } - } else if (field == 2 && wire == 1) { - if (!r.readDouble(out.y)) { + + if (tag.field == 1 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.x)) { return false; } - } else { - if (!r.skipField(wire)) { + } else if (tag.field == 2 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.y)) { return false; } + } else if (!reader.skip(tag.type)) { + return false; } } return true; } -// Decode a foxglove.Color sub-message: {1: double r, 2: double g, 3: double b, 4: double a} -// with components in [0, 1]. Output is uint8 RGBA in [0, 255]. -bool decodeColor(Reader& r, size_t len, ColorRGBA& out) { - const uint8_t* sub_end = r.p + len; - if (sub_end > r.end) { - return false; - } - double rd = 0.0; - double gd = 0.0; - double bd = 0.0; - double ad = 1.0; - while (r.p < sub_end) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { +bool decodeColor(Reader& reader, ColorRGBA& out) { + double r = 0.0; + double g = 0.0; + double b = 0.0; + double a = 1.0; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { return false; } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); - if (wire == 1 && field >= 1 && field <= 4) { - double v = 0.0; - if (!r.readDouble(v)) { + + if (tag.type == WireType::kFixed64 && tag.field >= 1 && tag.field <= 4) { + double value = 0.0; + if (!reader.readDouble(value)) { return false; } - switch (field) { + switch (tag.field) { case 1: - rd = v; + r = value; break; case 2: - gd = v; + g = value; break; case 3: - bd = v; + b = value; break; case 4: - ad = v; + a = value; break; default: break; } - } else { - if (!r.skipField(wire)) { - return false; - } + } else if (!reader.skip(tag.type)) { + return false; } } - auto to_byte = [](double v) { - if (v < 0.0) { - v = 0.0; - } - if (v > 1.0) { - v = 1.0; - } - return static_cast(v * 255.0 + 0.5); - }; - out.r = to_byte(rd); - out.g = to_byte(gd); - out.b = to_byte(bd); - out.a = to_byte(ad); + + out = {normalizedToByte(r), normalizedToByte(g), normalizedToByte(b), normalizedToByte(a)}; return true; } -// Decode one PointsAnnotation sub-message. -bool decodePointsAnnotation(Reader& r, size_t len, PointsAnnotation& out) { - const uint8_t* sub_end = r.p + len; - if (sub_end > r.end) { - return false; - } - while (r.p < sub_end) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { +bool readPoint2Message(Reader& reader, Point2& out) { + Reader nested; + return reader.readMessage(nested) && decodePoint2(nested, out); +} + +bool readColorMessage(Reader& reader, ColorRGBA& out) { + Reader nested; + return reader.readMessage(nested) && decodeColor(nested, out); +} + +bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { return false; } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); - if (field == 2 && wire == 0) { - uint64_t type_val = 0; - if (!r.readVarint(type_val)) { - return false; - } - out.topology = mapTopology(type_val); - } else if (field == 3 && wire == 2) { - uint64_t pt_len = 0; - if (!r.readVarint(pt_len)) { - return false; - } - Point2 pt; - if (!decodePoint2(r, pt_len, pt)) { - return false; - } - out.points.push_back(pt); - } else if (field == 4 && wire == 2) { - uint64_t c_len = 0; - if (!r.readVarint(c_len)) { - return false; - } - if (!decodeColor(r, c_len, out.color)) { - return false; - } - } else if (field == 5 && wire == 2) { - uint64_t c_len = 0; - if (!r.readVarint(c_len)) { - return false; - } - ColorRGBA c{}; - if (!decodeColor(r, c_len, c)) { - return false; - } - out.colors.push_back(c); - } else if (field == 6 && wire == 2) { - uint64_t c_len = 0; - if (!r.readVarint(c_len)) { - return false; - } - if (!decodeColor(r, c_len, out.fill_color)) { - return false; - } - } else if (field == 7 && wire == 1) { - if (!r.readDouble(out.thickness)) { - return false; - } - } else { - if (!r.skipField(wire)) { - return false; + switch (tag.field) { + case 2: { + if (tag.type != WireType::kVarint) { + break; + } + uint64_t value = 0; + if (!reader.readVarint(value)) { + return false; + } + out.topology = mapTopology(value); + continue; } + case 3: { + if (tag.type != WireType::kLengthDelimited) { + break; + } + Point2 point; + if (!readPoint2Message(reader, point)) { + return false; + } + out.points.push_back(point); + continue; + } + case 4: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.color)) { + return false; + } + continue; + } + break; + case 5: { + if (tag.type != WireType::kLengthDelimited) { + break; + } + ColorRGBA color; + if (!readColorMessage(reader, color)) { + return false; + } + out.colors.push_back(color); + continue; + } + case 6: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.fill_color)) { + return false; + } + continue; + } + break; + case 7: + if (tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.thickness)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; } } return true; } -// Decode one foxglove.CircleAnnotation sub-message: -// timestamp(1)=Time, position(2)=Point2, diameter(3)=double, thickness(4)=double, -// fill_color(5)=Color, outline_color(6)=Color -// We map diameter/2 -> radius and outline_color -> color (the C++ struct has no -// separate outline field; .color IS the outline). -bool decodeCircleAnnotation(Reader& r, size_t len, CircleAnnotation& out) { - const uint8_t* sub_end = r.p + len; - if (sub_end > r.end) { - return false; - } - // Defaults match pj_base/builtin/ImageAnnotations.h. +bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { out.color = {0, 255, 0, 255}; out.fill_color = {0, 0, 0, 0}; out.thickness = 2.0; out.radius = 1.0; - while (r.p < sub_end) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { return false; } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); - if (field == 2 && wire == 2) { - uint64_t p_len = 0; - if (!r.readVarint(p_len)) { - return false; - } - if (!decodePoint2(r, p_len, out.center)) { - return false; - } - } else if (field == 3 && wire == 1) { - double diameter = 0.0; - if (!r.readDouble(diameter)) { - return false; - } - out.radius = diameter * 0.5; - } else if (field == 4 && wire == 1) { - if (!r.readDouble(out.thickness)) { - return false; - } - } else if (field == 5 && wire == 2) { - uint64_t c_len = 0; - if (!r.readVarint(c_len)) { - return false; - } - if (!decodeColor(r, c_len, out.fill_color)) { - return false; - } - } else if (field == 6 && wire == 2) { - uint64_t c_len = 0; - if (!r.readVarint(c_len)) { - return false; - } - if (!decodeColor(r, c_len, out.color)) { - return false; - } - } else { - if (!r.skipField(wire)) { - return false; - } + + switch (tag.field) { + case 2: + if (tag.type == WireType::kLengthDelimited) { + if (!readPoint2Message(reader, out.center)) { + return false; + } + continue; + } + break; + case 3: { + if (tag.type != WireType::kFixed64) { + break; + } + double diameter = 0.0; + if (!reader.readDouble(diameter)) { + return false; + } + out.radius = diameter * 0.5; + continue; + } + case 4: + if (tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.thickness)) { + return false; + } + continue; + } + break; + case 5: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.fill_color)) { + return false; + } + continue; + } + break; + case 6: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.color)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; } } return true; } -// Decode one foxglove.TextAnnotation sub-message: -// timestamp(1)=Time, position(2)=Point2, text(3)=string, font_size(4)=double, -// text_color(5)=Color, background_color(6)=Color (background_color skipped; not -// present in pj_base/builtin/ImageAnnotations.h::sdk::TextAnnotation). -bool decodeTextAnnotation(Reader& r, size_t len, TextAnnotation& out) { - const uint8_t* sub_end = r.p + len; - if (sub_end > r.end) { - return false; - } +bool decodeTextAnnotation(Reader& reader, TextAnnotation& out) { out.color = {255, 255, 255, 255}; out.font_size = 14.0; - while (r.p < sub_end) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { return false; } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); - if (field == 2 && wire == 2) { - uint64_t p_len = 0; - if (!r.readVarint(p_len)) { - return false; - } - if (!decodePoint2(r, p_len, out.position)) { - return false; - } - } else if (field == 3 && wire == 2) { - uint64_t s_len = 0; - if (!r.readVarint(s_len)) { - return false; - } - if (s_len > r.remaining()) { - return false; - } - out.text.assign(reinterpret_cast(r.p), static_cast(s_len)); - r.p += s_len; - } else if (field == 4 && wire == 1) { - if (!r.readDouble(out.font_size)) { - return false; - } - } else if (field == 5 && wire == 2) { - uint64_t c_len = 0; - if (!r.readVarint(c_len)) { - return false; - } - if (!decodeColor(r, c_len, out.color)) { - return false; - } - } else { - if (!r.skipField(wire)) { - return false; - } + + switch (tag.field) { + case 2: + if (tag.type == WireType::kLengthDelimited) { + if (!readPoint2Message(reader, out.position)) { + return false; + } + continue; + } + break; + case 3: + if (tag.type == WireType::kLengthDelimited) { + if (!reader.readString(out.text)) { + return false; + } + continue; + } + break; + case 4: + if (tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.font_size)) { + return false; + } + continue; + } + break; + case 5: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.color)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; } } return true; @@ -420,59 +315,63 @@ bool decodeTextAnnotation(Reader& r, size_t len, TextAnnotation& out) { Expected deserializeImageAnnotations(const uint8_t* data, size_t size) { if (data == nullptr || size == 0) { - return unexpected(std::string("Protobuf ImageAnnotations: empty buffer")); + return unexpected(std::string("ImageAnnotations wire: empty buffer")); } - Reader r{data, data + size}; - sdk::ImageAnnotations ia; - while (!r.eof()) { - uint64_t tag = 0; - if (!r.readVarint(tag)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad tag")); + Reader reader(data, size); + sdk::ImageAnnotations annotations; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return unexpected(std::string("ImageAnnotations wire: bad tag")); } - uint32_t field = static_cast(tag >> 3); - uint32_t wire = static_cast(tag & 0x7u); - if (field == 2 && wire == 2) { - uint64_t pa_len = 0; - if (!r.readVarint(pa_len)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad PointsAnnotation length")); - } - PointsAnnotation pa; - pa.color = {0, 255, 0, 255}; - pa.thickness = 2.0; - if (!decodePointsAnnotation(r, pa_len, pa)) { - return unexpected(std::string("Protobuf ImageAnnotations: PointsAnnotation decode failed")); - } - ia.points.push_back(std::move(pa)); - } else if (field == 1 && wire == 2) { - uint64_t ca_len = 0; - if (!r.readVarint(ca_len)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad CircleAnnotation length")); + if (tag.type != WireType::kLengthDelimited) { + if (!reader.skip(tag.type)) { + return unexpected(std::string("ImageAnnotations wire: skip failed")); } - CircleAnnotation ca; - if (!decodeCircleAnnotation(r, ca_len, ca)) { - return unexpected(std::string("Protobuf ImageAnnotations: CircleAnnotation decode failed")); - } - ia.circles.push_back(std::move(ca)); - } else if (field == 3 && wire == 2) { - uint64_t ta_len = 0; - if (!r.readVarint(ta_len)) { - return unexpected(std::string("Protobuf ImageAnnotations: bad TextAnnotation length")); - } - TextAnnotation ta; - if (!decodeTextAnnotation(r, ta_len, ta)) { - return unexpected(std::string("Protobuf ImageAnnotations: TextAnnotation decode failed")); + continue; + } + + Reader nested; + if (!reader.readMessage(nested)) { + return unexpected(std::string("ImageAnnotations wire: bad nested message length")); + } + + switch (tag.field) { + case 1: { + CircleAnnotation circle; + if (!decodeCircleAnnotation(nested, circle)) { + return unexpected(std::string("ImageAnnotations wire: CircleAnnotation decode failed")); + } + annotations.circles.push_back(std::move(circle)); + break; + } + case 2: { + PointsAnnotation points; + points.color = {0, 255, 0, 255}; + points.thickness = 2.0; + if (!decodePointsAnnotation(nested, points)) { + return unexpected(std::string("ImageAnnotations wire: PointsAnnotation decode failed")); + } + annotations.points.push_back(std::move(points)); + break; } - ia.texts.push_back(std::move(ta)); - } else { - if (!r.skipField(wire)) { - return unexpected(std::string("Protobuf ImageAnnotations: skip failed")); + case 3: { + TextAnnotation text; + if (!decodeTextAnnotation(nested, text)) { + return unexpected(std::string("ImageAnnotations wire: TextAnnotation decode failed")); + } + annotations.texts.push_back(std::move(text)); + break; } + default: + break; } } - return ia; + return annotations; } } // namespace PJ diff --git a/pj_base/src/builtin/protobuf_wire.h b/pj_base/src/builtin/protobuf_wire.h new file mode 100644 index 0000000..2b9b326 --- /dev/null +++ b/pj_base/src/builtin/protobuf_wire.h @@ -0,0 +1,218 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace PJ::builtin_wire { + +enum class WireType : uint32_t { + kVarint = 0, + kFixed64 = 1, + kLengthDelimited = 2, + kFixed32 = 5, +}; + +struct Tag { + uint32_t field = 0; + WireType type = WireType::kVarint; +}; + +class Writer { + public: + explicit Writer(std::vector& out) : out_(out) {} + + void varint(uint32_t field, uint64_t value) { + tag(field, WireType::kVarint); + appendVarint(value); + } + + void fixed64(uint32_t field, uint64_t value) { + tag(field, WireType::kFixed64); + appendFixed64(value); + } + + void doubleField(uint32_t field, double value) { + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(value)); + fixed64(field, bits); + } + + void string(uint32_t field, std::string_view value) { + bytes(field, reinterpret_cast(value.data()), value.size()); + } + + void bytes(uint32_t field, const uint8_t* data, size_t size) { + tag(field, WireType::kLengthDelimited); + appendVarint(size); + if (size != 0) { + out_.insert(out_.end(), data, data + size); + } + } + + template + void message(uint32_t field, BuildMessage&& build_message) { + std::vector body; + Writer nested(body); + build_message(nested); + bytes(field, body.data(), body.size()); + } + + private: + void tag(uint32_t field, WireType type) { + appendVarint((static_cast(field) << 3) | static_cast(type)); + } + + void appendVarint(uint64_t value) { + while (value >= 0x80u) { + out_.push_back(static_cast((value & 0x7Fu) | 0x80u)); + value >>= 7; + } + out_.push_back(static_cast(value)); + } + + void appendFixed64(uint64_t value) { + for (int i = 0; i < 8; ++i) { + out_.push_back(static_cast((value >> (8 * i)) & 0xFFu)); + } + } + + std::vector& out_; +}; + +class Reader { + public: + Reader() = default; + Reader(const uint8_t* data, size_t size) : p_(data), end_(data == nullptr ? nullptr : data + size) {} + + bool eof() const noexcept { + return p_ == nullptr || p_ >= end_; + } + + size_t remaining() const noexcept { + if (p_ == nullptr) { + return 0; + } + return static_cast(end_ - p_); + } + + bool readTag(Tag& out) { + uint64_t raw = 0; + if (!readVarint(raw) || raw == 0) { + return false; + } + out.field = static_cast(raw >> 3); + out.type = static_cast(raw & 0x7u); + return out.field != 0; + } + + bool readVarint(uint64_t& out) { + out = 0; + if (p_ == nullptr) { + return false; + } + int shift = 0; + for (int byte_index = 0; p_ < end_ && byte_index < 10; ++byte_index) { + const uint8_t byte = *p_++; + const uint64_t payload = static_cast(byte & 0x7Fu); + if (shift == 63 && payload > 1) { + return false; + } + out |= payload << shift; + if ((byte & 0x80u) == 0) { + return true; + } + shift += 7; + } + return false; + } + + bool readFixed64(uint64_t& out) { + if (remaining() < 8) { + return false; + } + out = 0; + for (int i = 0; i < 8; ++i) { + out |= static_cast(p_[i]) << (8 * i); + } + p_ += 8; + return true; + } + + bool readDouble(double& out) { + uint64_t bits = 0; + if (!readFixed64(bits)) { + return false; + } + std::memcpy(&out, &bits, sizeof(out)); + return true; + } + + bool readString(std::string& out) { + const uint8_t* data = nullptr; + size_t size = 0; + if (!readBytes(data, size)) { + return false; + } + out.assign(reinterpret_cast(data), size); + return true; + } + + bool readMessage(Reader& out) { + const uint8_t* data = nullptr; + size_t size = 0; + if (!readBytes(data, size)) { + return false; + } + out = Reader(data, size); + return true; + } + + bool skip(WireType type) { + switch (type) { + case WireType::kVarint: { + uint64_t ignored = 0; + return readVarint(ignored); + } + case WireType::kFixed64: + return skipBytes(8); + case WireType::kLengthDelimited: { + const uint8_t* ignored = nullptr; + size_t ignored_size = 0; + return readBytes(ignored, ignored_size); + } + case WireType::kFixed32: + return skipBytes(4); + default: + return false; + } + } + + private: + bool readBytes(const uint8_t*& data, size_t& size) { + uint64_t len = 0; + if (!readVarint(len) || len > remaining()) { + return false; + } + data = p_; + size = static_cast(len); + p_ += size; + return true; + } + + bool skipBytes(size_t size) { + if (remaining() < size) { + return false; + } + p_ += size; + return true; + } + + const uint8_t* p_ = nullptr; + const uint8_t* end_ = nullptr; +}; + +} // namespace PJ::builtin_wire diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index a24e51b..26bb7ad 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -97,9 +97,9 @@ static_assert(PJ_TOOLBOX_MIN_VTABLE_SIZE <= sizeof(PJ_toolbox_vtable_t), "MIN mu // --- Canonical-object pipeline structs --------------------------------------- // Public ABI types crossing the boundary for the v4 builtin-object pipeline. // Sizes and offsets are pinned; any change is a deliberate ABI revision. -static_assert(sizeof(PJ_builtin_object_kind_t) == 4, "enum layout pinned"); +static_assert(sizeof(PJ_builtin_object_type_t) == 4, "enum layout pinned"); static_assert(sizeof(PJ_schema_classification_t) == 4, "PJ_schema_classification_t layout pinned"); -static_assert(offsetof(PJ_schema_classification_t, object_kind) == 0, "object_kind at offset 0"); +static_assert(offsetof(PJ_schema_classification_t, object_type) == 0, "object_type at offset 0"); static_assert(offsetof(PJ_schema_classification_t, reserved) == 2, "reserved at offset 2"); static_assert(sizeof(PJ_payload_anchor_t) == 16, "PJ_payload_anchor_t pinned (ctx + release fn ptr)"); diff --git a/pj_base/tests/image_annotations_decoder_test.cpp b/pj_base/tests/image_annotations_decoder_test.cpp index 52d2d6d..647210e 100644 --- a/pj_base/tests/image_annotations_decoder_test.cpp +++ b/pj_base/tests/image_annotations_decoder_test.cpp @@ -13,7 +13,7 @@ namespace { using sdk::AnnotationTopology; // --------------------------------------------------------------------------- -// Protobuf decoder tests for the canonical foxglove.ImageAnnotations wire +// Protobuf decoder tests for the canonical PJ.ImageAnnotations wire // format. Source-specific conversion happens before this decoder sees bytes. // --------------------------------------------------------------------------- @@ -47,7 +47,7 @@ inline void appendLenDelim(std::vector& out, const std::vector } // namespace pb -// Build a Foxglove Point2 sub-message: {1: double x, 2: double y} +// Build a PJ.Point2 sub-message: {1: double x, 2: double y} std::vector encodePoint2(double x, double y) { std::vector body; pb::appendTag(body, 1, 1); @@ -57,7 +57,7 @@ std::vector encodePoint2(double x, double y) { return body; } -// Build a Foxglove PointsAnnotation: {2: type, 3: repeated points, 7: thickness} +// Build a PJ.PointsAnnotation: {2: type, 3: repeated points, 7: thickness} std::vector encodePointsAnnotation( uint32_t type, const std::vector>& pts, double thickness) { std::vector body; @@ -72,7 +72,7 @@ std::vector encodePointsAnnotation( return body; } -// Build a Foxglove ImageAnnotations: {2: repeated PointsAnnotation} +// Build a PJ.ImageAnnotations: {2: repeated PointsAnnotation} std::vector encodeImageAnnotations(const std::vector>& point_annotations) { std::vector out; for (const auto& pa : point_annotations) { @@ -82,7 +82,7 @@ std::vector encodeImageAnnotations(const std::vector encodeColor(double r, double g, double b, double a) { std::vector body; pb::appendTag(body, 1, 1); @@ -96,7 +96,7 @@ std::vector encodeColor(double r, double g, double b, double a) { return body; } -// Build a Foxglove CircleAnnotation: {2: position, 3: diameter, 4: thickness, +// Build a PJ.CircleAnnotation: {2: position, 3: diameter, 4: thickness, // 5: fill_color, 6: outline_color} std::vector encodeCircleAnnotation( double x, double y, double diameter, double thickness, const std::vector& fill, @@ -115,8 +115,8 @@ std::vector encodeCircleAnnotation( return body; } -TEST(ImageAnnotationsDecoderTest, SchemaNameMatchesFoxgloveImageAnnotations) { - EXPECT_EQ(kSchemaImageAnnotations, "foxglove.ImageAnnotations"); +TEST(ImageAnnotationsDecoderTest, SchemaNameMatchesImageAnnotations) { + EXPECT_EQ(kSchemaImageAnnotations, "PJ.ImageAnnotations"); } TEST(ImageAnnotationsDecoderTest, EmptyMessageProducesError) { @@ -126,6 +126,14 @@ TEST(ImageAnnotationsDecoderTest, EmptyMessageProducesError) { EXPECT_FALSE(result.has_value()); } +TEST(ImageAnnotationsDecoderTest, OverflowingVarintProducesError) { + std::vector bytes(10, 0xFF); + bytes.back() = 0x02; + + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); + EXPECT_FALSE(result.has_value()); +} + TEST(ImageAnnotationsDecoderTest, SingleLineLoopFourPoints) { // type=2 (LINE_LOOP), 4 corners, thickness=2.5 auto pa = encodePointsAnnotation(2, {{10.0, 20.0}, {110.0, 20.0}, {110.0, 80.0}, {10.0, 80.0}}, 2.5); diff --git a/pj_base/tests/media_metadata_test.cpp b/pj_base/tests/media_metadata_test.cpp index dc8c450..b592933 100644 --- a/pj_base/tests/media_metadata_test.cpp +++ b/pj_base/tests/media_metadata_test.cpp @@ -20,9 +20,8 @@ TEST(MediaMetadataBuilderTest, SingleKeyRoundtrip) { } TEST(MediaMetadataBuilderTest, AllThreeKeysInCanonicalOrder) { - const auto json = - MediaMetadataBuilder().mediaClass("video").encoding("h264").schema("foxglove/CompressedVideo").build(); - EXPECT_EQ(json, R"({"media_class":"video","encoding":"h264","schema":"foxglove/CompressedVideo"})"); + const auto json = MediaMetadataBuilder().mediaClass("video").encoding("h264").schema("PJ.CompressedVideo").build(); + EXPECT_EQ(json, R"({"media_class":"video","encoding":"h264","schema":"PJ.CompressedVideo"})"); } TEST(MediaMetadataBuilderTest, EmptyKeysAreOmitted) { diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 9f710d7..97ccc2c 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -509,7 +509,7 @@ which wraps any callable returning `PayloadView` (preferred, zero-copy) or `std::vector` into the C ABI struct. The host orchestrates dispatch through an `ObjectIngestPolicyResolver` -that cascades `topic > source > kind > default`: +that cascades `topic > source > type > default`: - `kEager`: invoke `fetchMessageData` now, run `parseScalars` + `parseObject`, persist via `ObjectStore::pushOwned`. @@ -524,7 +524,7 @@ Parsers participate via three optional virtual entry points on `SchemaHandler` table. The shape that crosses both ABI boundaries (C struct on the DataSource side, in-process variant on the parser side) is opaque-payload-by-default: `BuiltinObject` is `std::any`, so -appending a new builtin kind does not change the public type and +appending a new builtin type does not change the public type and forward compatibility is automatic. Concrete builtins live under `pj_base/builtin/` (`Image`, `DepthImage`, `PointCloud`, `ImageAnnotations`); see `docs/builtin_type.md` for the type catalog and diff --git a/pj_plugins/docs/data-source-guide.md b/pj_plugins/docs/data-source-guide.md index 8b16d68..0d290ad 100644 --- a/pj_plugins/docs/data-source-guide.md +++ b/pj_plugins/docs/data-source-guide.md @@ -840,8 +840,8 @@ See `pj_plugins/docs/dialog-plugin-guide.md` for the dialog protocol itself. ## Builtin-object pipeline (PR #86) — `pushMessage` + FetchMessageData -For sources that fan out raw bytes to a `MessageParser` (MCAP, foxglove -bridge, future ROS-bag streamers), the runtime host exposes a v2 ingest +For sources that fan out raw bytes to a `MessageParser` (MCAP, topic +bridges, future ROS-bag streamers), the runtime host exposes a v2 ingest slot that takes a deferred callable instead of bytes. The plugin builds a closure that knows how to materialise the payload, hands it to the host, and stays policy-agnostic: diff --git a/pj_plugins/docs/message-parser-guide.md b/pj_plugins/docs/message-parser-guide.md index f7488aa..ae87ece 100644 --- a/pj_plugins/docs/message-parser-guide.md +++ b/pj_plugins/docs/message-parser-guide.md @@ -492,7 +492,7 @@ parseObject(PJ::Timestamp ts, PJ::sdk::PayloadView payload) override; ``` - `classifySchema` is the *a-priori* declaration — given a type name + - schema bytes, announce which `BuiltinObjectKind` (`kImage`, + schema bytes, announce which `BuiltinObjectType` (`kImage`, `kDepthImage`, `kPointCloud`, `kImageAnnotations`, `kNone`) this schema produces. The host consults the answer **before** it ever sees the payload, so it can pick the right `ObjectIngestPolicy` for the @@ -507,7 +507,7 @@ parseObject(PJ::Timestamp ts, PJ::sdk::PayloadView payload) override; Builtin types live under `pj_base/builtin/`, one header per type. `sdk::Image` carries an open-ended `std::string encoding` (`"rgb8"`, `"bgr8"`, `"mono8"`, `"jpeg"`, `"png"`, `"compressedDepth"`, -…) so raw and compressed images share a single type. New kinds are +…) so raw and compressed images share a single type. New types are appended without changing the `BuiltinObject` type (its `std::any` nature is forward-compatible by construction). diff --git a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp index 2255b43..da6580f 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp @@ -109,18 +109,18 @@ class MessageParserHandle { /// the plugin doesn't expose classify_schema (older protocol header) /// returns kNone, matching the host contract documented in /// message_parser_protocol.h. - [[nodiscard]] sdk::BuiltinObjectKind classifySchema(std::string_view type_name, Span schema) const { + [[nodiscard]] sdk::BuiltinObjectType classifySchema(std::string_view type_name, Span schema) const { if (!PJ_HAS_TAIL_SLOT(PJ_message_parser_vtable_t, vt_, classify_schema)) { - return sdk::BuiltinObjectKind::kNone; + return sdk::BuiltinObjectType::kNone; } PJ_string_view_t tn{type_name.data(), type_name.size()}; PJ_bytes_view_t sc{schema.data(), schema.size()}; PJ_schema_classification_t out{}; PJ_error_t err{}; if (!vt_->classify_schema(ctx_, tn, sc, &out, &err)) { - return sdk::BuiltinObjectKind::kNone; + return sdk::BuiltinObjectType::kNone; } - return static_cast(out.object_kind); + return static_cast(out.object_type); } /// Query a plugin-exposed extension by reverse-DNS id. Tail-slot gated. diff --git a/pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp b/pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp index fcabb28..33478c2 100644 --- a/pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp +++ b/pj_plugins/include/pj_plugins/sdk/detail/message_parser_trampolines.hpp @@ -143,7 +143,7 @@ inline bool MessageParserPluginBase::trampoline_classify_schema( auto name_sv = type_name.data == nullptr ? std::string_view{} : std::string_view(type_name.data, type_name.size); Span schema_span(schema.data, schema.size); const auto cls = self->classifySchema(name_sv, schema_span); - out_classification->object_kind = static_cast(cls.object_kind); + out_classification->object_type = static_cast(cls.object_type); out_classification->reserved = 0; return true; } catch (const std::exception& e) { diff --git a/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp b/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp index 2456c06..f1138f0 100644 --- a/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp +++ b/pj_plugins/include/pj_plugins/sdk/message_parser_plugin_base.hpp @@ -40,7 +40,7 @@ namespace sdk { /// schemas that produce only scalars, only objects, or that the plugin /// recognizes but routes through the legacy parse() path. struct SchemaHandler { - BuiltinObjectKind object_kind = BuiltinObjectKind::kNone; + BuiltinObjectType object_type = BuiltinObjectType::kNone; /// Scalar route: returns owned column data — no anchor needed because the /// returned vector and any string_views inside it are materialized by the @@ -218,7 +218,7 @@ class MessageParserPluginBase { virtual sdk::SchemaClassification classifySchema(std::string_view type_name, Span schema) const final { (void)schema; if (const auto* h = findSchemaHandler(type_name)) { - return {h->object_kind}; + return {h->object_type}; } return {}; } diff --git a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp index 13a8aa3..6eb48f4 100644 --- a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp +++ b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp @@ -6,7 +6,7 @@ * * The DataSource is policy-agnostic: it only fabricates a callable that * produces the raw payload bytes when invoked. The host decides — based on - * the policy resolved for (source_id, topic, kind) — whether to invoke the + * the policy resolved for (source_id, topic, type) — whether to invoke the * callable immediately (parse and store now), invoke it once for scalars * and again on each pull, or never invoke it during ingest and only on * consumer pulls. @@ -52,18 +52,18 @@ enum class ObjectIngestPolicy : uint8_t { /// Resolver with hierarchical overrides: /// -/// topic > data_source > kind > default +/// topic > data_source > type > default /// /// The application sets the levels it cares about during setup; the host -/// queries resolve(source_id, topic, kind) for each message. The resolver +/// queries resolve(source_id, topic, type) for each message. The resolver /// is intentionally an opaque carrier — its policy decisions are the /// host's concern, not the DataSource plugin's. /// /// Typical setup: /// /// resolver.setDefault(kLazyObjectsEagerScalars); -/// resolver.setForKind(BuiltinObjectKind::kCompressedImage, kPureLazy); -/// resolver.setForKind(BuiltinObjectKind::kPointCloud, kPureLazy); +/// resolver.setForType(BuiltinObjectType::kImage, kPureLazy); +/// resolver.setForType(BuiltinObjectType::kPointCloud, kPureLazy); /// // kImage stays at kLazyObjectsEagerScalars: width/height/encoding columns are useful /// class ObjectIngestPolicyResolver { @@ -73,10 +73,10 @@ class ObjectIngestPolicyResolver { default_ = policy; } - /// Override the default for a specific canonical object kind. Useful when + /// Override the default for a specific canonical object type. Useful when /// (e.g.) all PointCloud2 topics should be lazy regardless of source. - void setForKind(BuiltinObjectKind kind, ObjectIngestPolicy policy) { - by_kind_[kind] = policy; + void setForType(BuiltinObjectType type, ObjectIngestPolicy policy) { + by_type_[type] = policy; } /// Override the default for all topics of a specific DataSource, keyed by @@ -90,18 +90,18 @@ class ObjectIngestPolicyResolver { by_topic_[std::string(topic_name)] = policy; } - /// Resolve the policy for a given (source_id, topic_name, object_kind). - /// Precedence: topic > source > kind > default. The first match wins — + /// Resolve the policy for a given (source_id, topic_name, object_type). + /// Precedence: topic > source > type > default. The first match wins — /// no merging or composition between levels. [[nodiscard]] ObjectIngestPolicy resolve( - std::string_view source_id, std::string_view topic_name, BuiltinObjectKind object_kind) const { + std::string_view source_id, std::string_view topic_name, BuiltinObjectType object_type) const { if (auto it = by_topic_.find(std::string(topic_name)); it != by_topic_.end()) { return it->second; } if (auto it = by_source_.find(std::string(source_id)); it != by_source_.end()) { return it->second; } - if (auto it = by_kind_.find(object_kind); it != by_kind_.end()) { + if (auto it = by_type_.find(object_type); it != by_type_.end()) { return it->second; } return default_; @@ -109,7 +109,7 @@ class ObjectIngestPolicyResolver { private: ObjectIngestPolicy default_ = ObjectIngestPolicy::kLazyObjectsEagerScalars; - std::unordered_map by_kind_; + std::unordered_map by_type_; std::unordered_map by_source_; std::unordered_map by_topic_; }; diff --git a/pj_plugins/tests/object_ingest_policy_test.cpp b/pj_plugins/tests/object_ingest_policy_test.cpp index e396611..8515bd8 100644 --- a/pj_plugins/tests/object_ingest_policy_test.cpp +++ b/pj_plugins/tests/object_ingest_policy_test.cpp @@ -2,56 +2,56 @@ #include -using PJ::sdk::BuiltinObjectKind; +using PJ::sdk::BuiltinObjectType; using PJ::sdk::ObjectIngestPolicy; using PJ::sdk::ObjectIngestPolicyResolver; TEST(ObjectIngestPolicyResolverTest, DefaultPolicyIsLazyObjectsEagerScalars) { ObjectIngestPolicyResolver r; EXPECT_EQ( - r.resolve("any_source", "/any/topic", BuiltinObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); + r.resolve("any_source", "/any/topic", BuiltinObjectType::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); } TEST(ObjectIngestPolicyResolverTest, SetDefaultIsRespected) { ObjectIngestPolicyResolver r; r.setDefault(ObjectIngestPolicy::kEager); - EXPECT_EQ(r.resolve("any_source", "/any/topic", BuiltinObjectKind::kImage), ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("any_source", "/any/topic", BuiltinObjectType::kImage), ObjectIngestPolicy::kEager); } -TEST(ObjectIngestPolicyResolverTest, KindOverrideFiresOnMatch) { +TEST(ObjectIngestPolicyResolverTest, TypeOverrideFiresOnMatch) { ObjectIngestPolicyResolver r; r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForType(BuiltinObjectType::kPointCloud, ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("src", "/lidar/points", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); - // Different kind falls through to default. - EXPECT_EQ(r.resolve("src", "/cam/image", BuiltinObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("src", "/lidar/points", BuiltinObjectType::kPointCloud), ObjectIngestPolicy::kPureLazy); + // Different type falls through to default. + EXPECT_EQ(r.resolve("src", "/cam/image", BuiltinObjectType::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); } -TEST(ObjectIngestPolicyResolverTest, SourceOverridesKind) { +TEST(ObjectIngestPolicyResolverTest, SourceOverridesType) { ObjectIngestPolicyResolver r; r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForType(BuiltinObjectType::kPointCloud, ObjectIngestPolicy::kPureLazy); r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); - // Source matches → kEager beats the kPointCloud kind override. - EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kEager); - // Different source → kind override fires. - EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); + // Source matches → kEager beats the kPointCloud type override. + EXPECT_EQ(r.resolve("mcap_source", "/lidar/points", BuiltinObjectType::kPointCloud), ObjectIngestPolicy::kEager); + // Different source → type override fires. + EXPECT_EQ(r.resolve("ros2_stream", "/lidar/points", BuiltinObjectType::kPointCloud), ObjectIngestPolicy::kPureLazy); } TEST(ObjectIngestPolicyResolverTest, TopicOverridesEverything) { ObjectIngestPolicyResolver r; r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForType(BuiltinObjectType::kPointCloud, ObjectIngestPolicy::kPureLazy); r.setForDataSource("mcap_source", ObjectIngestPolicy::kEager); r.setForTopic("/diagnostics/lidar", ObjectIngestPolicy::kPureLazy); - // Topic match wins over source and kind. + // Topic match wins over source and type. EXPECT_EQ( - r.resolve("mcap_source", "/diagnostics/lidar", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); + r.resolve("mcap_source", "/diagnostics/lidar", BuiltinObjectType::kPointCloud), ObjectIngestPolicy::kPureLazy); // Different topic → source override fires. - EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("mcap_source", "/other/lidar", BuiltinObjectType::kPointCloud), ObjectIngestPolicy::kEager); } TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { @@ -61,23 +61,23 @@ TEST(ObjectIngestPolicyResolverTest, TypicalApplicationSetup) { // their scalars aren't worth materializing. ObjectIngestPolicyResolver r; r.setDefault(ObjectIngestPolicy::kLazyObjectsEagerScalars); - r.setForKind(BuiltinObjectKind::kPointCloud, ObjectIngestPolicy::kPureLazy); + r.setForType(BuiltinObjectType::kPointCloud, ObjectIngestPolicy::kPureLazy); r.setForTopic("/cam/jpeg", ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("mcap", "/cam/raw", BuiltinObjectKind::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); - EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", BuiltinObjectKind::kImage), ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("mcap", "/lidar", BuiltinObjectKind::kPointCloud), ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("mcap", "/cam/raw", BuiltinObjectType::kImage), ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("mcap", "/cam/jpeg", BuiltinObjectType::kImage), ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("mcap", "/lidar", BuiltinObjectType::kPointCloud), ObjectIngestPolicy::kPureLazy); // Scalar-only topic (no builtin object) takes the default. - EXPECT_EQ(r.resolve("mcap", "/diagnostics", BuiltinObjectKind::kNone), ObjectIngestPolicy::kLazyObjectsEagerScalars); + EXPECT_EQ(r.resolve("mcap", "/diagnostics", BuiltinObjectType::kNone), ObjectIngestPolicy::kLazyObjectsEagerScalars); } TEST(ObjectIngestPolicyResolverTest, LastWriteWinsForSameKey) { ObjectIngestPolicyResolver r; - r.setForKind(BuiltinObjectKind::kImage, ObjectIngestPolicy::kEager); - r.setForKind(BuiltinObjectKind::kImage, ObjectIngestPolicy::kPureLazy); - EXPECT_EQ(r.resolve("src", "/topic", BuiltinObjectKind::kImage), ObjectIngestPolicy::kPureLazy); + r.setForType(BuiltinObjectType::kImage, ObjectIngestPolicy::kEager); + r.setForType(BuiltinObjectType::kImage, ObjectIngestPolicy::kPureLazy); + EXPECT_EQ(r.resolve("src", "/topic", BuiltinObjectType::kImage), ObjectIngestPolicy::kPureLazy); r.setForTopic("/x", ObjectIngestPolicy::kLazyObjectsEagerScalars); r.setForTopic("/x", ObjectIngestPolicy::kEager); - EXPECT_EQ(r.resolve("src", "/x", BuiltinObjectKind::kImage), ObjectIngestPolicy::kEager); + EXPECT_EQ(r.resolve("src", "/x", BuiltinObjectType::kImage), ObjectIngestPolicy::kEager); } From b599edf74643415c82dd7d3f4e9b510fb5caf1ae Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sat, 16 May 2026 19:39:32 +0200 Subject: [PATCH 16/18] refactor: merge image annotations codec implementation --- pj_base/CMakeLists.txt | 1 - .../src/builtin/image_annotations_codec.cpp | 359 +++++++++++++++++ .../src/builtin/image_annotations_decoder.cpp | 377 ------------------ 3 files changed, 359 insertions(+), 378 deletions(-) delete mode 100644 pj_base/src/builtin/image_annotations_decoder.cpp diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index c27d7c9..1ae8082 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -6,7 +6,6 @@ find_package(magic_enum CONFIG REQUIRED) add_library(pj_base STATIC src/builtin/image_annotations_codec.cpp - src/builtin/image_annotations_decoder.cpp src/type_tree.cpp ) target_include_directories(pj_base PUBLIC diff --git a/pj_base/src/builtin/image_annotations_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp index 09c28ca..9476378 100644 --- a/pj_base/src/builtin/image_annotations_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -1,6 +1,9 @@ #include "pj_base/builtin/image_annotations_codec.h" +#include #include +#include +#include #include #include "protobuf_wire.h" @@ -8,6 +11,9 @@ namespace PJ { namespace { +using builtin_wire::Reader; +using builtin_wire::Tag; +using builtin_wire::WireType; using builtin_wire::Writer; using sdk::AnnotationTopology; using sdk::CircleAnnotation; @@ -75,6 +81,298 @@ void writeTextAnnotation(Writer& writer, const TextAnnotation& text) { writer.message(5, [&](Writer& nested) { writeColor(nested, text.color); }); } +AnnotationTopology mapTopology(uint64_t type) { + switch (type) { + case 1: + return AnnotationTopology::kPoints; + case 2: + return AnnotationTopology::kLineLoop; + case 3: + return AnnotationTopology::kLineStrip; + case 4: + return AnnotationTopology::kLineList; + case 0: + default: + return AnnotationTopology::kPoints; + } +} + +uint8_t normalizedToByte(double value) { + value = std::clamp(value, 0.0, 1.0); + return static_cast(value * 255.0 + 0.5); +} + +bool decodePoint2(Reader& reader, Point2& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + if (tag.field == 1 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.x)) { + return false; + } + } else if (tag.field == 2 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.y)) { + return false; + } + } else if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + +bool decodeColor(Reader& reader, ColorRGBA& out) { + double r = 0.0; + double g = 0.0; + double b = 0.0; + double a = 1.0; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + if (tag.type == WireType::kFixed64 && tag.field >= 1 && tag.field <= 4) { + double value = 0.0; + if (!reader.readDouble(value)) { + return false; + } + switch (tag.field) { + case 1: + r = value; + break; + case 2: + g = value; + break; + case 3: + b = value; + break; + case 4: + a = value; + break; + default: + break; + } + } else if (!reader.skip(tag.type)) { + return false; + } + } + + out = {normalizedToByte(r), normalizedToByte(g), normalizedToByte(b), normalizedToByte(a)}; + return true; +} + +bool readPoint2Message(Reader& reader, Point2& out) { + Reader nested; + return reader.readMessage(nested) && decodePoint2(nested, out); +} + +bool readColorMessage(Reader& reader, ColorRGBA& out) { + Reader nested; + return reader.readMessage(nested) && decodeColor(nested, out); +} + +bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + switch (tag.field) { + case 2: { + if (tag.type != WireType::kVarint) { + break; + } + uint64_t value = 0; + if (!reader.readVarint(value)) { + return false; + } + out.topology = mapTopology(value); + continue; + } + case 3: { + if (tag.type != WireType::kLengthDelimited) { + break; + } + Point2 point; + if (!readPoint2Message(reader, point)) { + return false; + } + out.points.push_back(point); + continue; + } + case 4: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.color)) { + return false; + } + continue; + } + break; + case 5: { + if (tag.type != WireType::kLengthDelimited) { + break; + } + ColorRGBA color; + if (!readColorMessage(reader, color)) { + return false; + } + out.colors.push_back(color); + continue; + } + case 6: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.fill_color)) { + return false; + } + continue; + } + break; + case 7: + if (tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.thickness)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + +bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { + out.color = {0, 255, 0, 255}; + out.fill_color = {0, 0, 0, 0}; + out.thickness = 2.0; + out.radius = 1.0; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + switch (tag.field) { + case 2: + if (tag.type == WireType::kLengthDelimited) { + if (!readPoint2Message(reader, out.center)) { + return false; + } + continue; + } + break; + case 3: { + if (tag.type != WireType::kFixed64) { + break; + } + double diameter = 0.0; + if (!reader.readDouble(diameter)) { + return false; + } + out.radius = diameter * 0.5; + continue; + } + case 4: + if (tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.thickness)) { + return false; + } + continue; + } + break; + case 5: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.fill_color)) { + return false; + } + continue; + } + break; + case 6: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.color)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + +bool decodeTextAnnotation(Reader& reader, TextAnnotation& out) { + out.color = {255, 255, 255, 255}; + out.font_size = 14.0; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + switch (tag.field) { + case 2: + if (tag.type == WireType::kLengthDelimited) { + if (!readPoint2Message(reader, out.position)) { + return false; + } + continue; + } + break; + case 3: + if (tag.type == WireType::kLengthDelimited) { + if (!reader.readString(out.text)) { + return false; + } + continue; + } + break; + case 4: + if (tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.font_size)) { + return false; + } + continue; + } + break; + case 5: + if (tag.type == WireType::kLengthDelimited) { + if (!readColorMessage(reader, out.color)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + } // namespace std::vector serializeImageAnnotations(const ImageAnnotations& annotations) { @@ -94,4 +392,65 @@ std::vector serializeImageAnnotations(const ImageAnnotations& annotatio return out; } +Expected deserializeImageAnnotations(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) { + return unexpected(std::string("ImageAnnotations wire: empty buffer")); + } + + Reader reader(data, size); + sdk::ImageAnnotations annotations; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return unexpected(std::string("ImageAnnotations wire: bad tag")); + } + + if (tag.type != WireType::kLengthDelimited) { + if (!reader.skip(tag.type)) { + return unexpected(std::string("ImageAnnotations wire: skip failed")); + } + continue; + } + + Reader nested; + if (!reader.readMessage(nested)) { + return unexpected(std::string("ImageAnnotations wire: bad nested message length")); + } + + switch (tag.field) { + case 1: { + CircleAnnotation circle; + if (!decodeCircleAnnotation(nested, circle)) { + return unexpected(std::string("ImageAnnotations wire: CircleAnnotation decode failed")); + } + annotations.circles.push_back(std::move(circle)); + break; + } + case 2: { + PointsAnnotation points; + points.color = {0, 255, 0, 255}; + points.thickness = 2.0; + if (!decodePointsAnnotation(nested, points)) { + return unexpected(std::string("ImageAnnotations wire: PointsAnnotation decode failed")); + } + annotations.points.push_back(std::move(points)); + break; + } + case 3: { + TextAnnotation text; + if (!decodeTextAnnotation(nested, text)) { + return unexpected(std::string("ImageAnnotations wire: TextAnnotation decode failed")); + } + annotations.texts.push_back(std::move(text)); + break; + } + default: + break; + } + } + + return annotations; +} + } // namespace PJ diff --git a/pj_base/src/builtin/image_annotations_decoder.cpp b/pj_base/src/builtin/image_annotations_decoder.cpp deleted file mode 100644 index 4cb889d..0000000 --- a/pj_base/src/builtin/image_annotations_decoder.cpp +++ /dev/null @@ -1,377 +0,0 @@ -#include -#include -#include -#include - -#include "pj_base/builtin/image_annotations_codec.h" -#include "protobuf_wire.h" - -namespace PJ { -namespace { - -using builtin_wire::Reader; -using builtin_wire::Tag; -using builtin_wire::WireType; -using sdk::AnnotationTopology; -using sdk::CircleAnnotation; -using sdk::ColorRGBA; -using sdk::Point2; -using sdk::PointsAnnotation; -using sdk::TextAnnotation; - -AnnotationTopology mapTopology(uint64_t type) { - switch (type) { - case 1: - return AnnotationTopology::kPoints; - case 2: - return AnnotationTopology::kLineLoop; - case 3: - return AnnotationTopology::kLineStrip; - case 4: - return AnnotationTopology::kLineList; - case 0: - default: - return AnnotationTopology::kPoints; - } -} - -uint8_t normalizedToByte(double value) { - value = std::clamp(value, 0.0, 1.0); - return static_cast(value * 255.0 + 0.5); -} - -bool decodePoint2(Reader& reader, Point2& out) { - while (!reader.eof()) { - Tag tag; - if (!reader.readTag(tag)) { - return false; - } - - if (tag.field == 1 && tag.type == WireType::kFixed64) { - if (!reader.readDouble(out.x)) { - return false; - } - } else if (tag.field == 2 && tag.type == WireType::kFixed64) { - if (!reader.readDouble(out.y)) { - return false; - } - } else if (!reader.skip(tag.type)) { - return false; - } - } - return true; -} - -bool decodeColor(Reader& reader, ColorRGBA& out) { - double r = 0.0; - double g = 0.0; - double b = 0.0; - double a = 1.0; - - while (!reader.eof()) { - Tag tag; - if (!reader.readTag(tag)) { - return false; - } - - if (tag.type == WireType::kFixed64 && tag.field >= 1 && tag.field <= 4) { - double value = 0.0; - if (!reader.readDouble(value)) { - return false; - } - switch (tag.field) { - case 1: - r = value; - break; - case 2: - g = value; - break; - case 3: - b = value; - break; - case 4: - a = value; - break; - default: - break; - } - } else if (!reader.skip(tag.type)) { - return false; - } - } - - out = {normalizedToByte(r), normalizedToByte(g), normalizedToByte(b), normalizedToByte(a)}; - return true; -} - -bool readPoint2Message(Reader& reader, Point2& out) { - Reader nested; - return reader.readMessage(nested) && decodePoint2(nested, out); -} - -bool readColorMessage(Reader& reader, ColorRGBA& out) { - Reader nested; - return reader.readMessage(nested) && decodeColor(nested, out); -} - -bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { - while (!reader.eof()) { - Tag tag; - if (!reader.readTag(tag)) { - return false; - } - - switch (tag.field) { - case 2: { - if (tag.type != WireType::kVarint) { - break; - } - uint64_t value = 0; - if (!reader.readVarint(value)) { - return false; - } - out.topology = mapTopology(value); - continue; - } - case 3: { - if (tag.type != WireType::kLengthDelimited) { - break; - } - Point2 point; - if (!readPoint2Message(reader, point)) { - return false; - } - out.points.push_back(point); - continue; - } - case 4: - if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { - return false; - } - continue; - } - break; - case 5: { - if (tag.type != WireType::kLengthDelimited) { - break; - } - ColorRGBA color; - if (!readColorMessage(reader, color)) { - return false; - } - out.colors.push_back(color); - continue; - } - case 6: - if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.fill_color)) { - return false; - } - continue; - } - break; - case 7: - if (tag.type == WireType::kFixed64) { - if (!reader.readDouble(out.thickness)) { - return false; - } - continue; - } - break; - default: - break; - } - - if (!reader.skip(tag.type)) { - return false; - } - } - return true; -} - -bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { - out.color = {0, 255, 0, 255}; - out.fill_color = {0, 0, 0, 0}; - out.thickness = 2.0; - out.radius = 1.0; - - while (!reader.eof()) { - Tag tag; - if (!reader.readTag(tag)) { - return false; - } - - switch (tag.field) { - case 2: - if (tag.type == WireType::kLengthDelimited) { - if (!readPoint2Message(reader, out.center)) { - return false; - } - continue; - } - break; - case 3: { - if (tag.type != WireType::kFixed64) { - break; - } - double diameter = 0.0; - if (!reader.readDouble(diameter)) { - return false; - } - out.radius = diameter * 0.5; - continue; - } - case 4: - if (tag.type == WireType::kFixed64) { - if (!reader.readDouble(out.thickness)) { - return false; - } - continue; - } - break; - case 5: - if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.fill_color)) { - return false; - } - continue; - } - break; - case 6: - if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { - return false; - } - continue; - } - break; - default: - break; - } - - if (!reader.skip(tag.type)) { - return false; - } - } - return true; -} - -bool decodeTextAnnotation(Reader& reader, TextAnnotation& out) { - out.color = {255, 255, 255, 255}; - out.font_size = 14.0; - - while (!reader.eof()) { - Tag tag; - if (!reader.readTag(tag)) { - return false; - } - - switch (tag.field) { - case 2: - if (tag.type == WireType::kLengthDelimited) { - if (!readPoint2Message(reader, out.position)) { - return false; - } - continue; - } - break; - case 3: - if (tag.type == WireType::kLengthDelimited) { - if (!reader.readString(out.text)) { - return false; - } - continue; - } - break; - case 4: - if (tag.type == WireType::kFixed64) { - if (!reader.readDouble(out.font_size)) { - return false; - } - continue; - } - break; - case 5: - if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { - return false; - } - continue; - } - break; - default: - break; - } - - if (!reader.skip(tag.type)) { - return false; - } - } - return true; -} - -} // namespace - -Expected deserializeImageAnnotations(const uint8_t* data, size_t size) { - if (data == nullptr || size == 0) { - return unexpected(std::string("ImageAnnotations wire: empty buffer")); - } - - Reader reader(data, size); - sdk::ImageAnnotations annotations; - - while (!reader.eof()) { - Tag tag; - if (!reader.readTag(tag)) { - return unexpected(std::string("ImageAnnotations wire: bad tag")); - } - - if (tag.type != WireType::kLengthDelimited) { - if (!reader.skip(tag.type)) { - return unexpected(std::string("ImageAnnotations wire: skip failed")); - } - continue; - } - - Reader nested; - if (!reader.readMessage(nested)) { - return unexpected(std::string("ImageAnnotations wire: bad nested message length")); - } - - switch (tag.field) { - case 1: { - CircleAnnotation circle; - if (!decodeCircleAnnotation(nested, circle)) { - return unexpected(std::string("ImageAnnotations wire: CircleAnnotation decode failed")); - } - annotations.circles.push_back(std::move(circle)); - break; - } - case 2: { - PointsAnnotation points; - points.color = {0, 255, 0, 255}; - points.thickness = 2.0; - if (!decodePointsAnnotation(nested, points)) { - return unexpected(std::string("ImageAnnotations wire: PointsAnnotation decode failed")); - } - annotations.points.push_back(std::move(points)); - break; - } - case 3: { - TextAnnotation text; - if (!decodeTextAnnotation(nested, text)) { - return unexpected(std::string("ImageAnnotations wire: TextAnnotation decode failed")); - } - annotations.texts.push_back(std::move(text)); - break; - } - default: - break; - } - } - - return annotations; -} - -} // namespace PJ From b73656aaf696de9ab1a91c5b1e4b5f36c852bc2d Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sat, 16 May 2026 19:44:12 +0200 Subject: [PATCH 17/18] feat: add frame transforms builtin --- docs/builtin_type.md | 51 ++- pj_base/CMakeLists.txt | 3 + .../include/pj_base/builtin/BuiltinObject.h | 12 +- .../include/pj_base/builtin/FrameTransforms.h | 56 ++++ .../pj_base/builtin/frame_transforms_codec.h | 25 ++ pj_base/include/pj_base/builtin_object_abi.h | 7 +- .../src/builtin/frame_transforms_codec.cpp | 294 ++++++++++++++++++ pj_base/tests/abi_layout_sentinels_test.cpp | 1 + pj_base/tests/builtin_object_test.cpp | 27 ++ pj_base/tests/frame_transforms_codec_test.cpp | 178 +++++++++++ pj_plugins/docs/ARCHITECTURE.md | 5 +- pj_plugins/docs/message-parser-guide.md | 10 +- 12 files changed, 646 insertions(+), 23 deletions(-) create mode 100644 pj_base/include/pj_base/builtin/FrameTransforms.h create mode 100644 pj_base/include/pj_base/builtin/frame_transforms_codec.h create mode 100644 pj_base/src/builtin/frame_transforms_codec.cpp create mode 100644 pj_base/tests/builtin_object_test.cpp create mode 100644 pj_base/tests/frame_transforms_codec_test.cpp diff --git a/docs/builtin_type.md b/docs/builtin_type.md index ff267f6..4e0ad51 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -19,7 +19,9 @@ The public headers live under: #include #include #include +#include #include +#include ``` ## Design Principles @@ -45,10 +47,10 @@ stores the payload itself as `Span` plus a `BufferAnchor`. The span points at the bytes; the anchor keeps the underlying allocation alive while consumers use it. -**Keep small objects owned.** `ImageAnnotations` owns its vectors directly. -Future transform and marker types should follow the same pattern unless they -grow payload-sized byte arrays. These values are small enough that the zero-copy -anchor pattern is unnecessary. +**Keep small objects owned.** `ImageAnnotations` and `FrameTransforms` own +their vectors, strings, and scalar fields directly. Future marker types should +follow the same pattern unless they grow payload-sized byte arrays. These values +are small enough that the zero-copy anchor pattern is unnecessary. **Do not force one serialization path on every builtin.** Large byte-backed types are views over source-native payload bytes whenever possible; they should @@ -65,13 +67,13 @@ Builtin objects fall into two serialization families: | Family | Current types | Storage model | Codec policy | |--------|---------------|---------------|--------------| | Byte-backed views | `Image`, `DepthImage`, `PointCloud` | Header fields live in the SDK struct; payload bytes live behind `Span` plus `BufferAnchor`. | No mandatory canonical codec; preserve zero-copy views over ROS, MCAP, compressed image, point-cloud, or plugin-owned payloads. If conversion is unavoidable, allocate a new payload and anchor it. | -| Owned values | `ImageAnnotations`; future transform and marker types | SDK structs own their vectors/strings/scalars directly. | Add explicit codecs when canonical bytes are needed. Codecs serialize the owned value to the protobuf-wire payload described by the `.proto` contract, using shared private wire primitives. | +| Owned values | `ImageAnnotations`, `FrameTransforms`; future marker types | SDK structs own their vectors/strings/scalars directly. | Add explicit codecs when canonical bytes are needed. Codecs serialize the owned value to the protobuf-wire payload described by the `.proto` contract, using shared private wire primitives. | Canonical `.proto` files live under `pj_base/proto/pj` and act as the wire -format contract. `PJ.ImageAnnotations` describes the current annotation codec -payload. `PJ.FrameTransforms` is the schema reserved for future transform -codecs. Shared geometry primitives are grouped in `Geometry.proto`: `Point2`, -`Point3`, `Vector2`, `Vector3`, and `Quaternion`. +format contract. `PJ.ImageAnnotations` describes the annotation codec payload. +`PJ.FrameTransforms` describes the transform codec payload. Shared geometry +primitives are grouped in `Geometry.proto`: `Point2`, `Point3`, `Vector2`, +`Vector3`, and `Quaternion`. The codecs do not expose generated Protobuf types in public SDK headers. The current implementation does not require generated Protobuf code or a Protobuf @@ -82,7 +84,7 @@ between bytes and SDK structs. `BuiltinObjectType` is the a-priori tag a parser reports for a schema. It lets the host decide that a topic produces images, point clouds, depth images, image -annotations, or no builtin object. +annotations, frame transforms, or no builtin object. | Type | Concrete type | Purpose | |------|---------------|---------| @@ -91,6 +93,7 @@ annotations, or no builtin object. | `kPointCloud` | `PJ::sdk::PointCloud` | Packed 3D point records. | | `kDepthImage` | `PJ::sdk::DepthImage` | Depth pixels plus camera intrinsics. | | `kImageAnnotations` | `PJ::sdk::ImageAnnotations` | Pixel-space overlay primitives. | +| `kFrameTransforms` | `PJ::sdk::FrameTransforms` | Named 3D frame relationships. | `BuiltinObject` is `std::any`. Producers store a concrete builtin value in it; consumers recover the concrete type with `std::any_cast(&object)` or ask @@ -233,6 +236,33 @@ type using the canonical `PJ.ImageAnnotations` protobuf wire format. See [image_annotations_format.md](image_annotations_format.md) for the field mapping and compatibility rules. +## FrameTransforms + +`FrameTransforms` contains a batch of time-stamped 3D transforms between named +reference frames. It is used for TF-style data where consumers need to place +objects, point clouds, camera frustums, or markers in a shared frame graph. + +Use `FrameTransforms` when the semantic value is a parent/child frame +relationship: a translation vector and quaternion rotation from +`parent_frame_id` to `child_frame_id` at a specific timestamp. + +| Field | Type | Notes | +|-------|------|-------| +| `transforms` | `std::vector` | Transform records carried by one source payload. | + +Each `FrameTransform` contains: + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp` | `Timestamp` | Timestamp associated with the transform. | +| `parent_frame_id` | `std::string` | Name of the parent reference frame. | +| `child_frame_id` | `std::string` | Name of the child reference frame. | +| `translation` | `Vector3` | Child-frame origin in parent-frame coordinates. | +| `rotation` | `Quaternion` | Child-frame orientation relative to the parent frame. | + +`pj_base/builtin/frame_transforms_codec.h` serializes and deserializes this type +using the canonical `PJ.FrameTransforms` protobuf wire format. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | @@ -241,6 +271,7 @@ mapping and compatibility rules. | ROS `sensor_msgs/CompressedImage` | `Image` | Preserve compressed bytes and set `encoding` to the codec. | | ROS `sensor_msgs/PointCloud2` | `PointCloud` | Map point fields, strides, density, endianness, and packed bytes. | | Detection or tracking message | `ImageAnnotations` | Convert boxes, points, circles, and labels into pixel-space primitives. | +| ROS `tf2_msgs/TFMessage` | `FrameTransforms` | Convert transform batches into named parent/child frame relationships. | The builtin type is the boundary object. After conversion, consumers should not need to know which third-party schema produced it. diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 1ae8082..1d5f258 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -5,6 +5,7 @@ find_package(magic_enum CONFIG REQUIRED) add_library(pj_base STATIC + src/builtin/frame_transforms_codec.cpp src/builtin/image_annotations_codec.cpp src/type_tree.cpp ) @@ -59,6 +60,8 @@ if(PJ_BUILD_TESTS) tests/abi_layout_sentinels_test.cpp tests/platform_test.cpp tests/arrow_holders_test.cpp + tests/builtin_object_test.cpp + tests/frame_transforms_codec_test.cpp tests/image_annotations_codec_test.cpp tests/image_annotations_decoder_test.cpp tests/media_metadata_test.cpp diff --git a/pj_base/include/pj_base/builtin/BuiltinObject.h b/pj_base/include/pj_base/builtin/BuiltinObject.h index 11c6d33..9a788bf 100644 --- a/pj_base/include/pj_base/builtin/BuiltinObject.h +++ b/pj_base/include/pj_base/builtin/BuiltinObject.h @@ -4,8 +4,9 @@ * * BuiltinObject is `std::any`. A producer constructs it by passing a * concrete builtin value (`sdk::Image`, `sdk::PointCloud`, `sdk::DepthImage`, - * `sdk::ImageAnnotations`, …); a consumer recovers the concrete type via - * `std::any_cast(&obj)` and obtains the type tag via `typeOf(obj)`. + * `sdk::ImageAnnotations`, `sdk::FrameTransforms`, ...); a consumer recovers + * the concrete type via `std::any_cast(&obj)` and obtains the type tag via + * `typeOf(obj)`. * * The type erasure is deliberate: choosing `std::any` over `std::variant` * keeps the SDK forward-compatible. Plugins built against an older SDK can @@ -24,6 +25,7 @@ #include #include "pj_base/builtin/DepthImage.h" +#include "pj_base/builtin/FrameTransforms.h" #include "pj_base/builtin/Image.h" #include "pj_base/builtin/ImageAnnotations.h" #include "pj_base/builtin/PointCloud.h" @@ -37,8 +39,9 @@ enum class BuiltinObjectType : uint16_t { kPointCloud = 3, ///< sdk::PointCloud — packed points + per-channel field layout. kDepthImage = 4, ///< sdk::DepthImage — depth pixels + camera intrinsics. kImageAnnotations = 5, ///< sdk::ImageAnnotations — 2D overlays (points, lines, text). + kFrameTransforms = 6, ///< sdk::FrameTransforms — named 3D frame relationships. // Reserved for future types; keep numeric values stable across releases. - // kOccupancyGrid = 6, + // kOccupancyGrid = 7, }; /// A-priori classification of a schema. Currently carries only the type; @@ -82,6 +85,9 @@ using BuiltinObject = std::any; if (t == typeid(ImageAnnotations)) { return BuiltinObjectType::kImageAnnotations; } + if (t == typeid(FrameTransforms)) { + return BuiltinObjectType::kFrameTransforms; + } return BuiltinObjectType::kNone; } diff --git a/pj_base/include/pj_base/builtin/FrameTransforms.h b/pj_base/include/pj_base/builtin/FrameTransforms.h new file mode 100644 index 0000000..cc4c281 --- /dev/null +++ b/pj_base/include/pj_base/builtin/FrameTransforms.h @@ -0,0 +1,56 @@ +/** + * @file FrameTransforms.h + * @brief Time-stamped 3D transforms between named reference frames. + * + * FrameTransforms is a small owned builtin for TF-style frame relationships. + * It stores strings and scalar geometry directly; no BufferAnchor is needed. + */ +#pragma once + +#include +#include + +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// Translation vector in 3D space. +struct Vector3 { + double x = 0.0; + double y = 0.0; + double z = 0.0; + bool operator==(const Vector3&) const = default; +}; + +/// Unit quaternion representing 3D orientation. +struct Quaternion { + double x = 0.0; + double y = 0.0; + double z = 0.0; + double w = 1.0; + bool operator==(const Quaternion&) const = default; +}; + +/// Transform from `parent_frame_id` to `child_frame_id`. +struct FrameTransform { + Timestamp timestamp = 0; + std::string parent_frame_id; + std::string child_frame_id; + Vector3 translation; + Quaternion rotation; + bool operator==(const FrameTransform&) const = default; +}; + +/// Batch of frame transforms carried by one source payload. +struct FrameTransforms { + std::vector transforms; + bool operator==(const FrameTransforms&) const = default; + + [[nodiscard]] bool empty() const noexcept { + return transforms.empty(); + } +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/frame_transforms_codec.h b/pj_base/include/pj_base/builtin/frame_transforms_codec.h new file mode 100644 index 0000000..2c69cea --- /dev/null +++ b/pj_base/include/pj_base/builtin/frame_transforms_codec.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +#include "pj_base/builtin/FrameTransforms.h" +#include "pj_base/expected.hpp" + +namespace PJ { + +inline constexpr std::string_view kSchemaFrameTransforms = "PJ.FrameTransforms"; + +/// Serializes sdk::FrameTransforms to canonical PJ.FrameTransforms wire bytes. +/// +/// The payload follows pj_base/proto/pj/FrameTransforms.proto, but the +/// implementation uses PlotJuggler's private protobuf-wire primitives rather +/// than generated Protobuf code. +[[nodiscard]] std::vector serializeFrameTransforms(const sdk::FrameTransforms& transforms); + +/// Decodes canonical PJ.FrameTransforms wire bytes into sdk::FrameTransforms. +[[nodiscard]] Expected deserializeFrameTransforms(const uint8_t* data, size_t size); + +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin_object_abi.h b/pj_base/include/pj_base/builtin_object_abi.h index e649ce7..e085f53 100644 --- a/pj_base/include/pj_base/builtin_object_abi.h +++ b/pj_base/include/pj_base/builtin_object_abi.h @@ -8,8 +8,8 @@ * carrying a PJ_builtin_object_type_t. * * Canonical-object production (sdk::Image / sdk::DepthImage / - * sdk::PointCloud / sdk::ImageAnnotations) and the pure-functional scalar production - * (Expected>) are C++ SDK contracts: plugins + * sdk::PointCloud / sdk::ImageAnnotations / sdk::FrameTransforms) and the + * pure-functional scalar production (Expected>) are C++ SDK contracts: plugins * inheriting from MessageParserPluginBase register handlers in * SchemaHandler, and the in-process host consumes them via * MessageParserPluginBase::parseObject() and parseScalars() called @@ -41,8 +41,9 @@ typedef enum PJ_builtin_object_type_t { PJ_BUILTIN_OBJECT_TYPE_POINTCLOUD = 3, PJ_BUILTIN_OBJECT_TYPE_DEPTH_IMAGE = 4, PJ_BUILTIN_OBJECT_TYPE_IMAGE_ANNOTATIONS = 5, + PJ_BUILTIN_OBJECT_TYPE_FRAME_TRANSFORMS = 6, /* Reserve future types; appended at the tail. */ - /* PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID = 6, */ + /* PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID = 7, */ } PJ_builtin_object_type_t; /** diff --git a/pj_base/src/builtin/frame_transforms_codec.cpp b/pj_base/src/builtin/frame_transforms_codec.cpp new file mode 100644 index 0000000..2392777 --- /dev/null +++ b/pj_base/src/builtin/frame_transforms_codec.cpp @@ -0,0 +1,294 @@ +#include "pj_base/builtin/frame_transforms_codec.h" + +#include +#include +#include +#include +#include + +#include "protobuf_wire.h" + +namespace PJ { +namespace { + +using builtin_wire::Reader; +using builtin_wire::Tag; +using builtin_wire::WireType; +using builtin_wire::Writer; +using sdk::FrameTransform; +using sdk::FrameTransforms; +using sdk::Quaternion; +using sdk::Vector3; + +constexpr int64_t kNanosecondsPerSecond = 1000LL * 1000LL * 1000LL; + +struct TimestampParts { + int64_t seconds = 0; + int32_t nanos = 0; +}; + +TimestampParts splitTimestamp(Timestamp timestamp_ns) { + TimestampParts out; + out.seconds = timestamp_ns / kNanosecondsPerSecond; + out.nanos = static_cast(timestamp_ns % kNanosecondsPerSecond); + if (out.nanos < 0) { + --out.seconds; + out.nanos += static_cast(kNanosecondsPerSecond); + } + return out; +} + +bool combineTimestamp(const TimestampParts& parts, Timestamp& out) { + if (parts.nanos < 0 || parts.nanos >= kNanosecondsPerSecond) { + return false; + } + if (parts.seconds > std::numeric_limits::max() / kNanosecondsPerSecond || + parts.seconds < std::numeric_limits::min() / kNanosecondsPerSecond) { + return false; + } + const Timestamp seconds_ns = parts.seconds * kNanosecondsPerSecond; + if (seconds_ns > std::numeric_limits::max() - parts.nanos) { + return false; + } + out = seconds_ns + parts.nanos; + return true; +} + +void writeTimestamp(Writer& writer, Timestamp timestamp_ns) { + const auto parts = splitTimestamp(timestamp_ns); + writer.varint(1, static_cast(parts.seconds)); + writer.varint(2, static_cast(parts.nanos)); +} + +void writeVector3(Writer& writer, const Vector3& vector) { + writer.doubleField(1, vector.x); + writer.doubleField(2, vector.y); + writer.doubleField(3, vector.z); +} + +void writeQuaternion(Writer& writer, const Quaternion& quaternion) { + writer.doubleField(1, quaternion.x); + writer.doubleField(2, quaternion.y); + writer.doubleField(3, quaternion.z); + writer.doubleField(4, quaternion.w); +} + +void writeFrameTransform(Writer& writer, const FrameTransform& transform) { + writer.message(1, [&](Writer& nested) { writeTimestamp(nested, transform.timestamp); }); + writer.string(2, transform.parent_frame_id); + writer.string(3, transform.child_frame_id); + writer.message(4, [&](Writer& nested) { writeVector3(nested, transform.translation); }); + writer.message(5, [&](Writer& nested) { writeQuaternion(nested, transform.rotation); }); +} + +bool decodeTimestamp(Reader& reader, Timestamp& out) { + TimestampParts parts; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + if (tag.field == 1 && tag.type == WireType::kVarint) { + uint64_t value = 0; + if (!reader.readVarint(value)) { + return false; + } + parts.seconds = static_cast(value); + } else if (tag.field == 2 && tag.type == WireType::kVarint) { + uint64_t value = 0; + if (!reader.readVarint(value)) { + return false; + } + parts.nanos = static_cast(value); + } else if (!reader.skip(tag.type)) { + return false; + } + } + + return combineTimestamp(parts, out); +} + +bool decodeVector3(Reader& reader, Vector3& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + if (tag.field == 1 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.x)) { + return false; + } + } else if (tag.field == 2 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.y)) { + return false; + } + } else if (tag.field == 3 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.z)) { + return false; + } + } else if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + +bool decodeQuaternion(Reader& reader, Quaternion& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + if (tag.field == 1 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.x)) { + return false; + } + } else if (tag.field == 2 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.y)) { + return false; + } + } else if (tag.field == 3 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.z)) { + return false; + } + } else if (tag.field == 4 && tag.type == WireType::kFixed64) { + if (!reader.readDouble(out.w)) { + return false; + } + } else if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + +bool readTimestampMessage(Reader& reader, Timestamp& out) { + Reader nested; + return reader.readMessage(nested) && decodeTimestamp(nested, out); +} + +bool readVector3Message(Reader& reader, Vector3& out) { + Reader nested; + return reader.readMessage(nested) && decodeVector3(nested, out); +} + +bool readQuaternionMessage(Reader& reader, Quaternion& out) { + Reader nested; + return reader.readMessage(nested) && decodeQuaternion(nested, out); +} + +bool decodeFrameTransform(Reader& reader, FrameTransform& out) { + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return false; + } + + switch (tag.field) { + case 1: + if (tag.type == WireType::kLengthDelimited) { + if (!readTimestampMessage(reader, out.timestamp)) { + return false; + } + continue; + } + break; + case 2: + if (tag.type == WireType::kLengthDelimited) { + if (!reader.readString(out.parent_frame_id)) { + return false; + } + continue; + } + break; + case 3: + if (tag.type == WireType::kLengthDelimited) { + if (!reader.readString(out.child_frame_id)) { + return false; + } + continue; + } + break; + case 4: + if (tag.type == WireType::kLengthDelimited) { + if (!readVector3Message(reader, out.translation)) { + return false; + } + continue; + } + break; + case 5: + if (tag.type == WireType::kLengthDelimited) { + if (!readQuaternionMessage(reader, out.rotation)) { + return false; + } + continue; + } + break; + default: + break; + } + + if (!reader.skip(tag.type)) { + return false; + } + } + return true; +} + +} // namespace + +std::vector serializeFrameTransforms(const FrameTransforms& transforms) { + std::vector out; + Writer writer(out); + + for (const auto& transform : transforms.transforms) { + writer.message(1, [&](Writer& nested) { writeFrameTransform(nested, transform); }); + } + + return out; +} + +Expected deserializeFrameTransforms(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) { + return unexpected(std::string("FrameTransforms wire: empty buffer")); + } + + Reader reader(data, size); + sdk::FrameTransforms transforms; + + while (!reader.eof()) { + Tag tag; + if (!reader.readTag(tag)) { + return unexpected(std::string("FrameTransforms wire: bad tag")); + } + + if (tag.type != WireType::kLengthDelimited) { + if (!reader.skip(tag.type)) { + return unexpected(std::string("FrameTransforms wire: skip failed")); + } + continue; + } + + Reader nested; + if (!reader.readMessage(nested)) { + return unexpected(std::string("FrameTransforms wire: bad nested message length")); + } + + if (tag.field == 1) { + FrameTransform transform; + if (!decodeFrameTransform(nested, transform)) { + return unexpected(std::string("FrameTransforms wire: FrameTransform decode failed")); + } + transforms.transforms.push_back(std::move(transform)); + } + } + + return transforms; +} + +} // namespace PJ diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 26bb7ad..0878c2d 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -98,6 +98,7 @@ static_assert(PJ_TOOLBOX_MIN_VTABLE_SIZE <= sizeof(PJ_toolbox_vtable_t), "MIN mu // Public ABI types crossing the boundary for the v4 builtin-object pipeline. // Sizes and offsets are pinned; any change is a deliberate ABI revision. static_assert(sizeof(PJ_builtin_object_type_t) == 4, "enum layout pinned"); +static_assert(PJ_BUILTIN_OBJECT_TYPE_FRAME_TRANSFORMS == 6, "FrameTransforms type id pinned"); static_assert(sizeof(PJ_schema_classification_t) == 4, "PJ_schema_classification_t layout pinned"); static_assert(offsetof(PJ_schema_classification_t, object_type) == 0, "object_type at offset 0"); static_assert(offsetof(PJ_schema_classification_t, reserved) == 2, "reserved at offset 2"); diff --git a/pj_base/tests/builtin_object_test.cpp b/pj_base/tests/builtin_object_test.cpp new file mode 100644 index 0000000..1823eb8 --- /dev/null +++ b/pj_base/tests/builtin_object_test.cpp @@ -0,0 +1,27 @@ +#include + +#include "pj_base/builtin/BuiltinObject.h" + +using PJ::sdk::BuiltinObject; +using PJ::sdk::BuiltinObjectType; +using PJ::sdk::DepthImage; +using PJ::sdk::FrameTransforms; +using PJ::sdk::Image; +using PJ::sdk::ImageAnnotations; +using PJ::sdk::parseBuiltinObjectType; +using PJ::sdk::PointCloud; +using PJ::sdk::typeOf; + +TEST(BuiltinObjectTest, TypeOfRecognizesKnownBuiltinTypes) { + EXPECT_EQ(typeOf(BuiltinObject{}), BuiltinObjectType::kNone); + EXPECT_EQ(typeOf(BuiltinObject{Image{}}), BuiltinObjectType::kImage); + EXPECT_EQ(typeOf(BuiltinObject{PointCloud{}}), BuiltinObjectType::kPointCloud); + EXPECT_EQ(typeOf(BuiltinObject{DepthImage{}}), BuiltinObjectType::kDepthImage); + EXPECT_EQ(typeOf(BuiltinObject{ImageAnnotations{}}), BuiltinObjectType::kImageAnnotations); + EXPECT_EQ(typeOf(BuiltinObject{FrameTransforms{}}), BuiltinObjectType::kFrameTransforms); +} + +TEST(BuiltinObjectTest, ParsesFrameTransformsTypeName) { + EXPECT_EQ(parseBuiltinObjectType("kFrameTransforms"), BuiltinObjectType::kFrameTransforms); + EXPECT_FALSE(parseBuiltinObjectType("FrameTransforms").has_value()); +} diff --git a/pj_base/tests/frame_transforms_codec_test.cpp b/pj_base/tests/frame_transforms_codec_test.cpp new file mode 100644 index 0000000..4c83444 --- /dev/null +++ b/pj_base/tests/frame_transforms_codec_test.cpp @@ -0,0 +1,178 @@ +#include "pj_base/builtin/frame_transforms_codec.h" + +#include + +#include +#include +#include +#include +#include + +namespace PJ { +namespace { + +using sdk::FrameTransform; +using sdk::FrameTransforms; + +namespace pb { + +inline void appendVarint(std::vector& out, uint64_t v) { + while (v >= 0x80u) { + out.push_back(static_cast((v & 0x7Fu) | 0x80u)); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +inline void appendTag(std::vector& out, uint32_t field, uint32_t wire) { + appendVarint(out, (static_cast(field) << 3) | wire); +} + +inline void appendDouble(std::vector& out, double v) { + uint64_t bits = 0; + std::memcpy(&bits, &v, sizeof(v)); + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((bits >> (8 * i)) & 0xFFu)); + } +} + +inline void appendLenDelim(std::vector& out, const std::vector& body) { + appendVarint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); +} + +inline void appendString(std::vector& out, const std::string& value) { + appendVarint(out, value.size()); + out.insert(out.end(), value.begin(), value.end()); +} + +} // namespace pb + +std::vector encodeTimestamp(Timestamp timestamp_ns) { + constexpr int64_t ns_per_second = 1000LL * 1000LL * 1000LL; + int64_t seconds = timestamp_ns / ns_per_second; + int32_t nanos = static_cast(timestamp_ns % ns_per_second); + if (nanos < 0) { + --seconds; + nanos += static_cast(ns_per_second); + } + + std::vector body; + pb::appendTag(body, 1, 0); + pb::appendVarint(body, static_cast(seconds)); + pb::appendTag(body, 2, 0); + pb::appendVarint(body, static_cast(nanos)); + return body; +} + +std::vector encodeVector3(double x, double y, double z) { + std::vector body; + pb::appendTag(body, 1, 1); + pb::appendDouble(body, x); + pb::appendTag(body, 2, 1); + pb::appendDouble(body, y); + pb::appendTag(body, 3, 1); + pb::appendDouble(body, z); + return body; +} + +std::vector encodeQuaternion(double x, double y, double z, double w) { + std::vector body; + pb::appendTag(body, 1, 1); + pb::appendDouble(body, x); + pb::appendTag(body, 2, 1); + pb::appendDouble(body, y); + pb::appendTag(body, 3, 1); + pb::appendDouble(body, z); + pb::appendTag(body, 4, 1); + pb::appendDouble(body, w); + return body; +} + +std::vector encodeFrameTransform(const FrameTransform& transform) { + std::vector body; + pb::appendTag(body, 1, 2); + pb::appendLenDelim(body, encodeTimestamp(transform.timestamp)); + pb::appendTag(body, 2, 2); + pb::appendString(body, transform.parent_frame_id); + pb::appendTag(body, 3, 2); + pb::appendString(body, transform.child_frame_id); + pb::appendTag(body, 4, 2); + pb::appendLenDelim(body, encodeVector3(transform.translation.x, transform.translation.y, transform.translation.z)); + pb::appendTag(body, 5, 2); + pb::appendLenDelim( + body, encodeQuaternion(transform.rotation.x, transform.rotation.y, transform.rotation.z, transform.rotation.w)); + return body; +} + +TEST(FrameTransformsCodecTest, SchemaNameMatchesFrameTransforms) { + EXPECT_EQ(kSchemaFrameTransforms, "PJ.FrameTransforms"); +} + +TEST(FrameTransformsCodecTest, EmptyMessageProducesEmptyBytes) { + FrameTransforms transforms; + EXPECT_TRUE(serializeFrameTransforms(transforms).empty()); +} + +TEST(FrameTransformsCodecTest, GoldenBytesSingleTransform) { + FrameTransforms transforms; + transforms.transforms.push_back( + FrameTransform{ + .timestamp = 1'234'567'890'123, + .parent_frame_id = "map", + .child_frame_id = "base_link", + .translation = {.x = 1.0, .y = 2.0, .z = 3.0}, + .rotation = {.x = 0.0, .y = 0.0, .z = 0.707, .w = 0.707}, + }); + + std::vector expected; + pb::appendTag(expected, 1, 2); + pb::appendLenDelim(expected, encodeFrameTransform(transforms.transforms.front())); + + EXPECT_EQ(serializeFrameTransforms(transforms), expected); +} + +TEST(FrameTransformsCodecTest, RoundTripMultipleTransforms) { + FrameTransforms input; + input.transforms.push_back( + FrameTransform{ + .timestamp = 42, + .parent_frame_id = "map", + .child_frame_id = "odom", + .translation = {.x = 1.0, .y = 0.0, .z = 0.0}, + .rotation = {.x = 0.0, .y = 0.0, .z = 0.0, .w = 1.0}, + }); + input.transforms.push_back( + FrameTransform{ + .timestamp = -1, + .parent_frame_id = "odom", + .child_frame_id = "base_link", + .translation = {.x = -1.5, .y = 2.5, .z = 3.5}, + .rotation = {.x = 0.1, .y = 0.2, .z = 0.3, .w = 0.9}, + }); + + const auto bytes = serializeFrameTransforms(input); + auto output = deserializeFrameTransforms(bytes.data(), bytes.size()); + + ASSERT_TRUE(output.has_value()); + EXPECT_EQ(*output, input); +} + +TEST(FrameTransformsCodecTest, EmptyBufferProducesError) { + const std::vector bytes; + auto output = deserializeFrameTransforms(bytes.data(), bytes.size()); + EXPECT_FALSE(output.has_value()); +} + +TEST(FrameTransformsCodecTest, InvalidNestedMessageProducesError) { + std::vector bytes; + pb::appendTag(bytes, 1, 2); + pb::appendVarint(bytes, 10); + bytes.push_back(0x08); + + auto output = deserializeFrameTransforms(bytes.data(), bytes.size()); + EXPECT_FALSE(output.has_value()); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 97ccc2c..6e8da3a 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -527,5 +527,6 @@ is opaque-payload-by-default: `BuiltinObject` is `std::any`, so appending a new builtin type does not change the public type and forward compatibility is automatic. Concrete builtins live under `pj_base/builtin/` (`Image`, `DepthImage`, `PointCloud`, -`ImageAnnotations`); see `docs/builtin_type.md` for the type catalog and -`docs/image_annotations_format.md` for the canonical annotation wire format. +`ImageAnnotations`, `FrameTransforms`); see `docs/builtin_type.md` for the type +catalog and `docs/image_annotations_format.md` for the canonical annotation +wire format. diff --git a/pj_plugins/docs/message-parser-guide.md b/pj_plugins/docs/message-parser-guide.md index ae87ece..ba01078 100644 --- a/pj_plugins/docs/message-parser-guide.md +++ b/pj_plugins/docs/message-parser-guide.md @@ -470,8 +470,8 @@ dispatch code. Beyond the scalar-column output that `parse()` and the `sdk::ParserWriteHostService` cover, the SDK adds a second, narrow output channel for media-like payloads: a *builtin object* (image, -depth image, point cloud, image annotations) returned by name from the -parser, decoded once, and visualised by widgets that never learn the +depth image, point cloud, image annotations, frame transforms) returned by name +from the parser, decoded once, and visualised by widgets that never learn the wire format. Three optional virtual entry points on `MessageParserPluginBase` @@ -493,9 +493,9 @@ parseObject(PJ::Timestamp ts, PJ::sdk::PayloadView payload) override; - `classifySchema` is the *a-priori* declaration — given a type name + schema bytes, announce which `BuiltinObjectType` (`kImage`, - `kDepthImage`, `kPointCloud`, `kImageAnnotations`, `kNone`) this - schema produces. The host consults the answer **before** it ever sees - the payload, so it can pick the right `ObjectIngestPolicy` for the + `kDepthImage`, `kPointCloud`, `kImageAnnotations`, `kFrameTransforms`, + `kNone`) this schema produces. The host consults the answer **before** it + ever sees the payload, so it can pick the right `ObjectIngestPolicy` for the topic. - `parseScalars` writes the small-metadata fields (`width`, `height`, `frame_id`, …) that should land in the curve tree as scalar columns. From 3a77e5e7ea9156bfc18bf600383728aa5d205c96 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 17 May 2026 08:39:29 +0200 Subject: [PATCH 18/18] docs: fix object ingest policy example --- pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp index 6eb48f4..71bc8b3 100644 --- a/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp +++ b/pj_plugins/include/pj_plugins/sdk/object_ingest_policy.hpp @@ -62,7 +62,6 @@ enum class ObjectIngestPolicy : uint8_t { /// Typical setup: /// /// resolver.setDefault(kLazyObjectsEagerScalars); -/// resolver.setForType(BuiltinObjectType::kImage, kPureLazy); /// resolver.setForType(BuiltinObjectType::kPointCloud, kPureLazy); /// // kImage stays at kLazyObjectsEagerScalars: width/height/encoding columns are useful ///