diff --git a/.claude/skills/plotjuggler-plugin/references/builtin-objects.md b/.claude/skills/plotjuggler-plugin/references/builtin-objects.md index 8c62eda4..21aced34 100644 --- a/.claude/skills/plotjuggler-plugin/references/builtin-objects.md +++ b/.claude/skills/plotjuggler-plugin/references/builtin-objects.md @@ -114,10 +114,8 @@ exactly once. Do not keep a raw pointer into anything the closure captured. - `ImageAnnotations.image_topic` explicitly names the base image topic; camera calibration correlates by **name convention** (`/camera_info` ↔ `/image_raw`), not by a reference. -- **Association fields ride *outside* the serialized bytes.** The annotations - codec deliberately does not encode `image_topic` (or a timestamp) into the - payload — the store keys entries by (topic, timestamp), and association is a - topic/metadata convention. Don't expect a codec round-trip to carry them. +- The annotations codec carries the top-level `timestamp` (only when non-zero) and + `image_topic` (only when non-empty); see `docs/image_annotations_format.md`. ## Codec round-trips are not bit-exact diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e97156e..dc00f511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [0.36.0] + +Host contract: unchanged (no floor impact) — client-side helpers and an additive codec +change, no new host surface. + +Builtin objects (client-side): + +- Add compile-time field tables (`pj_base/builtin/field_table.hpp`, one + `_fields.hpp` per tabled struct, `describe(BuiltinObjectType)` in + `field_table_registry.hpp`) for `FrameTransforms`, `ImageAnnotations`, `PointCloud`, + `SceneEntities`, `Image`, `DepthImage`, `CameraInfo` and `VideoFrame`: a generic + binder reads and writes any described field by name. Packed payloads are `kBuffer` + descriptors; nullable numbers are `kOptionalNumber`; `std::array` members are + fixed-size `kList`s written through `list_replace`. A `kBuffer` layout never promises + more records than its bytes hold (`record_count` is 0 otherwise), and `set_number` + saturates to the target integer type (NaN becomes 0) instead of an undefined + float-to-integer conversion. +- Add `sdk::PinholeIntrinsics` and `sdk::unprojectPixel` (`depth_image_utils.hpp`): + unproject a pixel with positive metric depth through K^-1, validating K once per + image. Skew and projective terms are honoured (a float32-rounded K is accepted); + only a non-finite or singular K is rejected. Distortion correction stays the + caller's job. +- Add the object-topic metadata keys `pj_snapshot` + (`ObjectTopicMetadataBuilder::snapshot`: every entry is a complete clear-and-replace + snapshot) and `pj_derived` (`kDerivedMetadataKey`; set by a host on topics produced + by a host data processor, the value names the processor kind, an open set: + `kDerivedOnDemandValue` is `"on_demand"`). +- The ImageAnnotations wire carries the top-level `timestamp` and `image_topic`; + additive, old readers skip them. +- Fix the shared Timestamp decoder rejecting `INT64_MIN` nanoseconds (and anything within + one second of it): a payload our own encoder wrote failed to decode. + ## [0.35.0] Host contract: unchanged (no floor impact) — a client-side codec helper, no new diff --git a/VERSION b/VERSION index 7b52f5e5..93d4c1ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.0 +0.36.0 diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 7c212713..78ea5510 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -673,6 +673,53 @@ splice form and decodes with an empty span; the host runs `validateGridMap()` right after attaching the spliced bytes, and consumers that index cells run it again defensively. +## Field tables + +`pj_base/builtin/field_table.hpp` describes a builtin struct's members at +compile time — name, shape (`FieldKind`), and type-erased get/set accessors — +so a generic consumer (e.g. a script binder) can read or write any described +field by name without hand-written per-type glue. A struct opts in with a +`FieldTable` specialization built from `field<&T::member>("member")` calls +(a `kBuffer` factory such as `pointCloudDataField()` for a packed record buffer +like `PointCloud::data`); nested +structs and lists of described structs link to their own table through +`FieldDescriptor::nested`, so a consumer walks an arbitrarily deep struct tree +with one generic recursive routine. `field_table_registry.hpp` exposes +`describe(BuiltinObjectType)` to look up a table from the runtime tag a +`BuiltinObject` carries. + +Each tabled builtin has a `_fields.hpp` header; `describe()` in +`field_table_registry.hpp` is the authoritative list. `Image::data`, +`DepthImage::data`, and `VideoFrame::data` each expose their packed pixel / +bitstream bytes through a `kBuffer` descriptor the same way `PointCloud::data` +does, with the per-record layout derived from the encoding/format string +rather than a channel list (see each header's doc comment for how a raw vs. +compressed encoding differs). `CameraInfo`'s `K`/`R`/`P` (and `DepthImage`'s +`K`) are `std::array` members, described as a fixed-size `kList` +written through `list_replace` rather than `list_emplace`/`list_clear` (the +element count never changes); `Image::compressed_depth_min`/ +`compressed_depth_max` are `kOptionalNumber` — a nullable number read/written +like `kNumber` but guarded by `has_value()`. A field table is hand-maintained +alongside its struct: adding, renaming, or retyping a member requires updating the matching +`FieldTable` specialization in the same change, or the table silently +drifts from the struct it claims to describe. `pj_base/tests/field_table_test.cpp` +enforces this — its generic copy-through-table round trip is compared against +the struct's own `operator==` and its canonical wire-codec bytes, so a member +the table forgot to list shows up as a round-trip mismatch rather than a +silent gap. + +An object topic carrying a field-tabled type can mark itself with the +`pj_snapshot` object-topic metadata key (`PJ::sdk::kSnapshotMetadataKey`, +`ObjectTopicMetadataBuilder::snapshot()`, SDK 0.36.0). Value `"true"` means +every entry on the topic is a complete clear-and-replace snapshot — a +generic field-table consumer may render each entry alone, without replaying +the topic's history to reconstruct state first. This matters for +SceneEntities/ImageAnnotations producers in particular, whose entries can +otherwise be incremental (add/update/delete deltas); a producer that instead +republishes its whole set each time sets this flag so a stateless consumer +(and `set_object_topic_retention(topic, 1)`, which keeps only the latest +snapshot) can treat each entry as the full picture. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md index 0fd54634..0e121f8a 100644 --- a/docs/image_annotations_format.md +++ b/docs/image_annotations_format.md @@ -66,16 +66,17 @@ the renderable annotation fields and ignores the rest: | `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 `timestamp` | Serialized when non-zero, decoded into `ImageAnnotations::timestamp`; a timestamp of 0 is indistinguishable from an absent one. | +| `image_topic` (field 6, string) | Serialized when non-empty, decoded into `ImageAnnotations::image_topic`. | | 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. | -`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. +`PJ::sdk::ImageAnnotations::image_topic` is part of the payload: it is written +as field 6 when non-empty, so runtime association between an annotation set +and its image stream can be carried on the wire. A reader predating this +addition ignores the unknown field. ## Codec Rules @@ -100,9 +101,10 @@ 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. -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. +An annotation value serializes to an empty byte buffer only when it has no circles, +points, or texts, a zero `timestamp`, and an empty `image_topic`; one carrying just a +non-zero `timestamp` and/or an `image_topic` produces a non-empty payload. Decoding an +empty buffer is treated as invalid input by the current reader. The reader accepts protobuf wire types `VARINT`, `I64`, `LEN`, and `I32`. It decodes the mapped fields and skips unknown fields, including unknown nested diff --git a/docs/sdk-utilities.md b/docs/sdk-utilities.md index 16b09913..0ab3413f 100644 --- a/docs/sdk-utilities.md +++ b/docs/sdk-utilities.md @@ -60,6 +60,8 @@ CMake helpers become available through `find_package(plotjuggler_sdk)`. | `PJ::sdk::MediaMetadataBuilder, ObjectTopicMetadataBuilder` | Build media hints and canonical renderer metadata | `pj_base/sdk/media_metadata.hpp; pj_base/sdk/object_topic_metadata.hpp` | | `PJ::sdk::ObjectBytes` | Own object-read bytes through move-only RAII | `pj_base/sdk/object_bytes.hpp` | | `PJ::sdk::ArrowSchemaHolder, ArrowArrayHolder, ArrowStreamHolder` | Own Arrow out-params; successful writes transfer ownership, failed writes retain it | `pj_base/sdk/arrow.hpp` | +| `PJ::sdk::FieldTable, field<&T::member>, describe` | Read/write builtin struct members by name in a generic binder; lists are element-addressable | `pj_base/builtin/field_table.hpp; pj_base/builtin/field_table_registry.hpp` | +| `PJ::sdk::PinholeIntrinsics, unprojectPixel` | Unproject rectified metric depth pixels; validate K once per image | `pj_base/builtin/depth_image_utils.hpp` | | `pj_embed_file, pj_configure_plugin` | Embed UI/manifest assets and configure plugin exports/build rules | `cmake/PjPlugin.cmake` | Outside this SDK, reuse pj-official-plugins common/: diff --git a/pj_base/CLAUDE.md b/pj_base/CLAUDE.md index 7957b0a0..ffe0bdde 100644 --- a/pj_base/CLAUDE.md +++ b/pj_base/CLAUDE.md @@ -18,7 +18,8 @@ build details. It must not depend on Qt, `pj_datastore` or `pj_plugins`. - `include/pj_base/builtin/` — 18 builtin struct headers (`*.hpp`) and all 18 wire codecs (`*_codec.hpp`). Numeric tags are stable; values 2 and 12 are permanently reserved. Also contains the tagged, type-erased `BuiltinObject` - holder and type-erased codec dispatcher. + holder and type-erased codec dispatcher, and the compile-time field tables + (`field_table.hpp`, one `_fields.hpp` per tabled struct). - `include/pj_base/sdk/` — C++ SDK over the ABI: DataSource + Toolbox `*_plugin_base.hpp`, `service_registry.hpp`/`service_traits.hpp`, host views, Arrow RAII holders, `testing/`. - `include/pj_base/*_protocol.h`, `plugin_data_api.h`, `builtin_object_abi.h`, `plugin_abi_export.hpp` — the stable C-ABI surface for DataSource/MessageParser/Toolbox (the Dialog protocol header lives in `pj_plugins/dialog_protocol/`). - `proto/pj/` — canonical `.proto` wire contracts for the builtin types (see its README). diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index e1b3075d..7a524252 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -186,6 +186,7 @@ if(PJ_BUILD_TESTS) tests/frame_transforms_codec_test.cpp tests/image_annotations_codec_test.cpp tests/image_annotations_decoder_test.cpp + tests/field_table_test.cpp tests/media_metadata_test.cpp tests/object_topic_metadata_test.cpp tests/push_message_test.cpp diff --git a/pj_base/include/pj_base/builtin/camera_info_fields.hpp b/pj_base/include/pj_base/builtin/camera_info_fields.hpp new file mode 100644 index 00000000..2ee36b63 --- /dev/null +++ b/pj_base/include/pj_base/builtin/camera_info_fields.hpp @@ -0,0 +1,39 @@ +/** + * @file camera_info_fields.hpp + * @brief `FieldTable` specialization for `camera_info.hpp`'s `CameraInfo`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/camera_info.hpp" +#include "pj_base/builtin/field_table.hpp" + +namespace PJ::sdk { + +/// Fields of `CameraInfo`. `K` and `R` are fixed-size lists of 9 numbers, +/// `P` a fixed-size list of 12 (`std::array` — see +/// `field_table.hpp`'s `kList` fixed-size case, written through +/// `list_replace` rather than `list_emplace`/`list_clear`); `D` is a +/// growable list of numbers (`std::vector`, size depends on +/// `distortion_model`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CameraInfo::timestamp_ns>("timestamp_ns"), + field<&CameraInfo::frame_id>("frame_id"), + field<&CameraInfo::width>("width"), + field<&CameraInfo::height>("height"), + field<&CameraInfo::distortion_model>("distortion_model"), + field<&CameraInfo::D>("D"), + field<&CameraInfo::K>("K"), + field<&CameraInfo::R>("R"), + field<&CameraInfo::P>("P"), + }; + static constexpr FieldTableView view{"CameraInfo", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/depth_image_fields.hpp b/pj_base/include/pj_base/builtin/depth_image_fields.hpp new file mode 100644 index 00000000..47a6ace9 --- /dev/null +++ b/pj_base/include/pj_base/builtin/depth_image_fields.hpp @@ -0,0 +1,95 @@ +/** + * @file depth_image_fields.hpp + * @brief `FieldTable` specialization for `depth_image.hpp`'s `DepthImage`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "pj_base/builtin/depth_image.hpp" +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/point_cloud.hpp" + +namespace PJ::sdk { + +namespace detail { + +/// Pixel datatype of a `DepthImage::encoding` value, or `kUnknown` when the +/// string is not one of the two encodings `depth_image.hpp`'s doc comment +/// documents ("16UC1" — millimeters as uint16 — or "32FC1" — meters as +/// float32). `DepthImage::encoding` is an open string (like `Image::encoding`), +/// so an unrecognized value is expected, not an error. +[[nodiscard]] constexpr PointField::Datatype depthImageDatatype(std::string_view encoding) noexcept { + if (encoding == "16UC1") { + return PointField::Datatype::kUint16; + } + if (encoding == "32FC1") { + return PointField::Datatype::kFloat32; + } + return PointField::Datatype::kUnknown; +} + +} // namespace detail + +/// `kBuffer` descriptor for `DepthImage::data`. Unlike `Image`, `DepthImage` +/// has no `row_step` member, so `buffer()` derives it as `width * bpp` (no +/// row padding representable) whenever `bpp` (from +/// `bytesPerElement(detail::depthImageDatatype(encoding))`) is known; an unrecognized +/// encoding reports `record_step = row_step = 0` and `bytes` alone (its full +/// span) is the payload — same convention as `imageDataField()` +/// (`image_fields.hpp`); a declared extent larger than `data` also reports 0 +/// records. `channels` always carries exactly one entry naming +/// the encoding string. `buffer_assign()` is `detail::assignPayloadBytes`. +[[nodiscard]] constexpr FieldDescriptor depthImageDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& image = *static_cast(p); + const PointField::Datatype datatype = detail::depthImageDatatype(image.encoding); + const uint32_t bpp = bytesPerElement(datatype); + BufferLayout layout; + layout.bytes = image.data; + layout.record_step = bpp; + const bool fits = detail::rowsFitIn(image.height, static_cast(image.width) * bpp, 0, image.data.size()); + layout.record_count = bpp == 0 || !fits ? 0 : static_cast(image.width) * image.height; + layout.row_step = bpp == 0 ? 0 : image.width * bpp; + layout.is_bigendian = false; // DepthImage carries no endianness member; native in-memory doubles/ints. + layout.channels.push_back( + BufferLayout::Channel{ + .name = image.encoding, + .offset = 0, + .datatype = static_cast(datatype), + .count = bpp == 0 ? 0 : uint32_t{1}}); + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `DepthImage`: every member is an ordinary field except `data` +/// (`depthImageDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. `K` is a fixed-size list of 9 +/// numbers (`std::array` — see `field_table.hpp`'s `kList` +/// fixed-size case); `D` is a growable list of numbers +/// (`std::vector`, size depends on `distortion_model`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&DepthImage::width>("width"), + field<&DepthImage::height>("height"), + field<&DepthImage::encoding>("encoding"), + depthImageDataField("data"), + field<&DepthImage::K>("K"), + field<&DepthImage::distortion_model>("distortion_model"), + field<&DepthImage::D>("D"), + field<&DepthImage::timestamp_ns>("timestamp_ns"), + }; + static constexpr FieldTableView view{"DepthImage", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/depth_image_utils.hpp b/pj_base/include/pj_base/builtin/depth_image_utils.hpp index e103fe56..c51f3055 100644 --- a/pj_base/include/pj_base/builtin/depth_image_utils.hpp +++ b/pj_base/include/pj_base/builtin/depth_image_utils.hpp @@ -15,12 +15,81 @@ #pragma once #include +#include +#include #include "pj_base/builtin/depth_image.hpp" namespace PJ { namespace sdk { +/// Camera matrix K reduced to its inverse, built once per image so a per-pixel loop +/// checks only its own inputs. +class PinholeIntrinsics { + public: + /// Rejects a non-finite or singular K and nothing else: skew and projective terms + /// are honoured, so a K that went through float32 is accepted. Distortion + /// correction belongs to the caller. + [[nodiscard]] static std::optional fromK(const std::array& k) noexcept { + for (double value : k) { + if (!std::isfinite(value)) { + return std::nullopt; + } + } + const double cofactor0 = k[4] * k[8] - k[5] * k[7]; + const double cofactor1 = k[5] * k[6] - k[3] * k[8]; + const double cofactor2 = k[3] * k[7] - k[4] * k[6]; + const double det = k[0] * cofactor0 + k[1] * cofactor1 + k[2] * cofactor2; + if (!std::isfinite(det) || det == 0) { + return std::nullopt; + } + const std::array inverse{ + cofactor0 / det, (k[2] * k[7] - k[1] * k[8]) / det, (k[1] * k[5] - k[2] * k[4]) / det, + cofactor1 / det, (k[0] * k[8] - k[2] * k[6]) / det, (k[2] * k[3] - k[0] * k[5]) / det, + cofactor2 / det, (k[1] * k[6] - k[0] * k[7]) / det, (k[0] * k[4] - k[1] * k[3]) / det}; + for (double value : inverse) { + if (!std::isfinite(value)) { + return std::nullopt; + } + } + return PinholeIntrinsics(inverse); + } + + /// Unproject a rectified pixel with positive metric depth: the ray K^-1 [u, v, 1] + /// scaled so the returned point's z equals `depth_m`. Rejects a non-finite result + /// or a ray with z <= 0. + [[nodiscard]] std::optional> unproject(double u, double v, double depth_m) const noexcept { + if (!std::isfinite(u) || !std::isfinite(v) || !std::isfinite(depth_m) || depth_m <= 0) { + return std::nullopt; + } + const double ray_z = k_inverse_[6] * u + k_inverse_[7] * v + k_inverse_[8]; + if (!std::isfinite(ray_z) || ray_z <= 0) { + return std::nullopt; + } + const double scale = depth_m / ray_z; + const double point_x = (k_inverse_[0] * u + k_inverse_[1] * v + k_inverse_[2]) * scale; + const double point_y = (k_inverse_[3] * u + k_inverse_[4] * v + k_inverse_[5]) * scale; + if (!std::isfinite(point_x) || !std::isfinite(point_y)) { + return std::nullopt; + } + return std::array{point_x, point_y, depth_m}; + } + + private: + explicit PinholeIntrinsics(const std::array& k_inverse) noexcept : k_inverse_(k_inverse) {} + + /// Row-major K^-1; only `fromK` builds it, so every entry is finite. + std::array k_inverse_; +}; + +/// One-pixel form of `PinholeIntrinsics::fromK(k)->unproject(u, v, depth_m)`; a loop +/// over an image should call `fromK` once instead. +[[nodiscard]] inline std::optional> unprojectPixel( + const std::array& k, double u, double v, double depth_m) noexcept { + const auto intrinsics = PinholeIntrinsics::fromK(k); + return intrinsics ? intrinsics->unproject(u, v, depth_m) : std::nullopt; +} + /// 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 diff --git a/pj_base/include/pj_base/builtin/field_table.hpp b/pj_base/include/pj_base/builtin/field_table.hpp new file mode 100644 index 00000000..bf9ff02b --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table.hpp @@ -0,0 +1,496 @@ +/** + * @file field_table.hpp + * @brief Compile-time field tables describing the members of builtin object + * structs, so a generic binder (e.g. a script engine) can read/write + * any described field by name without per-type glue code. + * + * `FieldTable` is a template specialized once per describable struct + * (see `frame_transforms_fields.hpp`, `image_annotations_fields.hpp`). Each + * specialization lists its members as `FieldDescriptor`s built with + * `field<&T::member>("member")`, which deduces the member's `FieldKind` and + * generates non-capturing-lambda accessors from the member pointer alone — + * no per-field boilerplate at the call site. `field_table_registry.hpp` + * exposes `describe(BuiltinObjectType)` to look up a table from the runtime + * tag carried by a `BuiltinObject`. + * + * A table is a flat `Span`; nested structs and lists of + * described structs link to their own table through `FieldDescriptor::nested`, + * so a consumer walks an arbitrarily deep struct tree with one generic + * recursive routine (see `field_table_test.cpp` for the pattern). A packed + * record buffer (e.g. `PointCloud::data`) does not fit `field<>()`'s + * one-member-at-a-time shape — it is derived from several sibling members + * (bytes, stride, dimensions, channel layout) — so a buffer-bearing type + * instead provides its own `kBuffer` descriptor factory built directly + * against `FieldDescriptor` (e.g. `PointCloud`'s `pointCloudDataField()` in + * `point_cloud_fields.hpp`), resolving a `BufferLayout` on demand. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" + +namespace PJ::sdk { + +/// Runtime-visible shape of one field, driving which `FieldDescriptor` +/// accessors are populated. +enum class FieldKind : uint8_t { + kNumber, ///< Arithmetic member (not bool/int64/enum) — read/written as `double`. + kBool, ///< `bool` member — read/written as `double` (0.0/1.0). + kInt64, ///< `int64_t`/`uint64_t`/`Timestamp` member — read/written as `int64_t`, never `double` + ///< (a 53+ bit timestamp silently loses precision as a double). + kString, ///< `std::string` member. + kEnum, ///< `enum`/`enum class` member — read/written as its underlying integer via `double`. + kStruct, ///< Member whose type has its own `FieldTable` specialization; see `nested`. + kList, ///< `std::vector` (growable; see `list_emplace`/`list_clear`) or `std::array` + ///< (fixed-size; see `list_replace`) member; see `element_kind`/`nested` for the element shape. + kBuffer, ///< Raw record buffer (e.g. `PointCloud::data`), resolved through `BufferLayout`. A concrete + ///< describable type provides its own `kBuffer` descriptor factory (see `BufferLayout`'s doc comment). + kOptionalNumber, ///< `std::optional` member (e.g. `Image::compressed_depth_min`) — a + ///< nullable number. Presence is read through `has_value()`; the value itself, when + ///< present, is read/written as `double` through `get_number`/`set_number`, exactly + ///< like `kNumber`. Calling `get_number`/`set_number` when absent is only meaningful + ///< after a caller has checked `has_value()`; `set_number` also makes the field present. +}; + +/// Resolved view of a `kBuffer` field: a byte-packed record buffer plus a +/// PointField-style channel layout for interpreting each record. Built +/// on-demand by `FieldDescriptor::buffer()` — a fresh value each call, not a +/// pointer into a stored layout — so `channels` owns a small vector rather +/// than viewing one (`Channel`'s shape deliberately differs from the owner's +/// own channel-description type, e.g. `PointField`, so the two are not +/// safely alias-able through a reinterpreted span). Resolve once per object +/// and iterate its records from that one `BufferLayout`; `buffer()` +/// allocates the channel vector on every call, so calling it per-record is +/// wasteful. +struct BufferLayout { + /// Raw record bytes: `record_count` records of `record_step` bytes each + /// (or row-strided by `row_step` when `row_step != 0`). + Span bytes; + /// Byte length of one record. + uint32_t record_step = 0; + /// Number of records packed into `bytes`, clamped to what `bytes` holds: 0 when the + /// owner's declared size fields promise more than `bytes.size()` (channel `offset`/`count` + /// are declared values, so the consumer bounds-checks them against `record_step`). + uint64_t record_count = 0; + /// Byte length of one row when records are organized in rows; 0 when flat. + uint32_t row_step = 0; + /// True if multi-byte values inside `bytes` are big-endian. + bool is_bigendian = false; + + /// One named, typed channel packed at a fixed offset within every record. + struct Channel { + /// Channel name (e.g. "x", "intensity"). Views into the owner's own + /// channel-description member (e.g. `PointCloud::fields[i].name`), so it + /// is valid exactly as long as that owner is. + std::string_view name; + /// Byte offset of this channel within one record. + uint32_t offset = 0; + /// Wire datatype tag; codec-defined (mirrors PointField datatype values). + uint8_t datatype = 0; + /// Number of consecutive `datatype` values packed at `offset`. + uint32_t count = 1; + }; + /// Layout of every channel packed into one record of `bytes`, freshly + /// built by this call (owned, not a view — resolving it does one small + /// allocation, proportional to channel count, e.g. 4 for an XYZI cloud). + std::vector channels; +}; + +/// One field of a `FieldTable`: its name, shape, and type-erased accessors. +/// Every accessor not applicable to `kind` is `nullptr` — a consumer +/// switches on `kind` (or `element_kind` inside a `kList`) and calls only +/// the accessor group that kind documents. +struct FieldDescriptor { + /// Field name as declared in the owning struct (e.g. "translation"). + std::string_view name; + /// Shape of this field; selects which accessor group below is non-null. + FieldKind kind = FieldKind::kNumber; + /// For `kind == kList`: shape of one element. When `nested != nullptr` the + /// element is a struct and this is always `kStruct` — a consumer that + /// needs to know "struct element or scalar element" may branch on + /// `nested` first and only consult `element_kind` for the scalar case. + /// Unused (left at its default) for every other `kind`. + FieldKind element_kind = FieldKind::kNumber; + /// For `kind == kStruct`: the member type's own table. + /// For `kind == kList`: the element type's table, or `nullptr` for a scalar list. + const struct FieldTableView* nested = nullptr; + + /// Read/write a `kNumber`, `kBool`, `kEnum`, or `kOptionalNumber` field as + /// `double`. For `kOptionalNumber`, meaningful only when `has_value()` is + /// true; `set_number` also makes the field present. + double (*get_number)(const void*) = nullptr; + void (*set_number)(void*, double) = nullptr; + + /// For `kind == kOptionalNumber`: true iff the field currently holds a + /// value. `nullptr` for every other `kind`. + bool (*has_value)(const void*) = nullptr; + + /// Read/write a `kInt64` field. An underlying `uint64_t` round-trips through + /// `int64_t` via `std::bit_cast` (same 8 bytes, reinterpreted) rather than a + /// narrowing numeric conversion, so every bit pattern survives exactly. + int64_t (*get_int64)(const void*) = nullptr; + void (*set_int64)(void*, int64_t) = nullptr; + + /// Read/write a `kString` field. + std::string_view (*get_string)(const void*) = nullptr; + void (*set_string)(void*, std::string_view) = nullptr; + + /// For `kind == kStruct`: address the nested struct within the owner. + const void* (*struct_ptr)(const void*) = nullptr; + void* (*struct_ptr_mut)(void*) = nullptr; + + /// For `kind == kList`: element count and element address — populated for + /// both a growable list (`std::vector`) and a fixed-size one + /// (`std::array`). Elements are addressable one by one: a binder should + /// read `list_at(i)` lazily rather than materialize the whole list per access. + size_t (*list_size)(const void*) = nullptr; + const void* (*list_at)(const void*, size_t) = nullptr; + + /// For `kind == kList` backed by a growable `std::vector`: append-one + /// (returns the address of the newly appended element) and + /// truncate-to-empty. `nullptr` for a fixed-size list — use `list_replace` + /// instead. + void* (*list_emplace)(void*) = nullptr; + void (*list_clear)(void*) = nullptr; + + /// For `kind == kList` backed by a fixed-size `std::array` (e.g. + /// `CameraInfo::K`): overwrites element `i` in place and returns its + /// address, `i` in `[0, list_size)`. `nullptr` for a growable list — use + /// `list_emplace`/`list_clear` instead. The element count never changes + /// for such a field, so "append" and "truncate" don't apply; a generic + /// consumer branches on which of the two accessor pairs is non-null to + /// choose how to write the list (see `field_table_test.cpp`'s + /// `copyThroughTable` for the pattern). + void* (*list_replace)(void*, size_t) = nullptr; + + /// For `kind == kBuffer`: resolve the current buffer, or replace it + /// (taking ownership of the bytes and re-anchoring them). See + /// `BufferLayout`'s doc comment and a concrete factory such as + /// `pointCloudDataField()` (`point_cloud_fields.hpp`). + BufferLayout (*buffer)(const void*) = nullptr; + void (*buffer_assign)(void*, std::vector) = nullptr; +}; + +/// A described struct's field list plus its name, for diagnostics. +struct FieldTableView { + /// The described struct's type name (e.g. "FrameTransform"). + std::string_view type_name; + /// Every described member, in declaration order. + Span fields; +}; + +/// Per-type registry of field descriptors. Specialized once per describable +/// struct as: +/// ```cpp +/// template <> struct FieldTable { +/// static constexpr std::array fields{ +/// field<&Vector2::x>("x"), field<&Vector2::y>("y")}; +/// static constexpr FieldTableView view{"Vector2", Span(fields)}; +/// }; +/// ``` +/// The primary template is declared only, never defined: `HasFieldTable` +/// detects "no specialization exists for T" through that incompleteness. +template +struct FieldTable; + +namespace detail { + +/// Splits a data-member-pointer type `T C::*` into its owner (`C`) and +/// value (`T`) types, for deducing both from a `field<&C::member>` NTTP. +template +struct MemberPointerTraits; + +template +struct MemberPointerTraits { + using Owner = C; + using Value = T; +}; + +template +struct IsVector : std::false_type {}; +template +struct IsVector> : std::true_type {}; +template +inline constexpr bool kIsVector = IsVector::value; + +/// Detects `std::array` (any `E`, any `N`), for `field<>()`'s +/// fixed-size-list branch (e.g. `CameraInfo::K`). +template +struct IsArray : std::false_type {}; +template +struct IsArray> : std::true_type {}; +template +inline constexpr bool kIsArray = IsArray::value; + +/// Detects `std::optional`, for `field<>()`'s `kOptionalNumber` branch. +template +struct IsOptional : std::false_type {}; +template +struct IsOptional> : std::true_type {}; +template +inline constexpr bool kIsOptional = IsOptional::value; + +/// Always-false, but dependent on `T` — lets a `static_assert` inside the +/// final branch of an `if constexpr` chain fire only when instantiated, +/// instead of unconditionally. +template +inline constexpr bool kAlwaysFalse = false; + +/// True for every scalar type `setScalarAccessors` below knows how to +/// describe: `bool`/other arithmetic (covers `int64_t`/`uint64_t` too, +/// handled as a distinct `FieldKind` inside `setScalarAccessors`), +/// `enum`/`enum class`, and `std::string`. +template +inline constexpr bool kIsSupportedScalar = + std::is_arithmetic_v || std::is_enum_v || std::is_same_v; + +/// True iff `rows` rows of `row_bytes` data bytes, `row_stride` bytes apart (0 = tightly packed), +/// lie within `size` bytes; the last row needs no trailing padding. Never overflows, so a +/// hostile size field cannot make an oversized extent look small. +[[nodiscard]] constexpr bool rowsFitIn(uint64_t rows, uint64_t row_bytes, uint64_t row_stride, uint64_t size) { + if (rows == 0) { + return true; + } + if (row_bytes > size) { + return false; + } + const uint64_t stride = row_stride == 0 ? row_bytes : row_stride; + return stride == 0 || rows - 1 <= (size - row_bytes) / stride; +} + +/// `static_cast(value)` for a `double` fed in by a script: an integer `Target` +/// saturates at its limits and NaN becomes 0 (a plain cast is undefined out of range); +/// a floating `Target` keeps the plain cast. +template +[[nodiscard]] Target saturatingCast(double value) { + if constexpr (std::is_floating_point_v) { + return static_cast(value); + } else { + if (std::isnan(value)) { + return 0; + } + if (value <= static_cast(std::numeric_limits::min())) { + return std::numeric_limits::min(); + } + if (value >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(value); + } +} + +/// Locates a scalar value directly AT the given address — the adapter for a +/// `kList` element, where `list_at`/`list_emplace` already return a pointer +/// to the element itself. +template +struct DirectValueAccess { + static V* get(void* p) { + return static_cast(p); + } + static const V* get(const void* p) { + return static_cast(p); + } +}; + +/// Locates a scalar value at `owner->*Member` — the adapter for an ordinary +/// struct field, where the `(const) void*` a consumer passes in addresses +/// the OWNING struct, not the member. +template +struct MemberValueAccess { + using Owner = typename MemberPointerTraits::Owner; + static auto* get(void* p) { + return &(static_cast(p)->*Member); + } + static auto* get(const void* p) { + return &(static_cast(p)->*Member); + } +}; + +/// Populates `d`'s get/set accessor pair for scalar value type `V`, located +/// through `Access::get(p)`, and returns the matching `FieldKind`. Shared by +/// `field()`'s member branch (`Access = MemberValueAccess`, +/// caller assigns the result to `d.kind`) and its vector-element branch +/// (`Access = DirectValueAccess`, caller assigns the result to +/// `d.element_kind`) — the kind-deduction and lambda bodies are otherwise +/// identical between "a struct field" and "a scalar list element", so this +/// is the one place that logic is written. Only ever called for `V` with +/// `kIsSupportedScalar` true; the `field<>()` call sites gate on that +/// before calling in, so the `static_assert` below is unreachable through +/// them (kept as a safety net for a future direct caller). +template +constexpr FieldKind setScalarAccessors(FieldDescriptor& d) { + if constexpr (std::is_same_v) { + d.get_number = [](const void* p) { return static_cast(*Access::get(p)); }; + d.set_number = [](void* p, double v) { *Access::get(p) = (v != 0.0); }; + return FieldKind::kBool; + } else if constexpr (std::is_same_v) { + d.get_int64 = [](const void* p) { return static_cast(*Access::get(p)); }; + d.set_int64 = [](void* p, int64_t v) { *Access::get(p) = static_cast(v); }; + return FieldKind::kInt64; + } else if constexpr (std::is_same_v) { + // Round-trips through int64_t via bit_cast (same 8 bytes, reinterpreted) + // rather than a narrowing numeric conversion, so every bit pattern + // survives exactly — see FieldDescriptor::get_int64's doc comment. + d.get_int64 = [](const void* p) { return std::bit_cast(*Access::get(p)); }; + d.set_int64 = [](void* p, int64_t v) { *Access::get(p) = std::bit_cast(v); }; + return FieldKind::kInt64; + } else if constexpr (std::is_enum_v) { + using Underlying = std::underlying_type_t; + d.get_number = [](const void* p) { return static_cast(static_cast(*Access::get(p))); }; + d.set_number = [](void* p, double v) { *Access::get(p) = static_cast(saturatingCast(v)); }; + return FieldKind::kEnum; + } else if constexpr (std::is_arithmetic_v) { + d.get_number = [](const void* p) { return static_cast(*Access::get(p)); }; + d.set_number = [](void* p, double v) { *Access::get(p) = saturatingCast(v); }; + return FieldKind::kNumber; + } else if constexpr (std::is_same_v) { + d.get_string = [](const void* p) -> std::string_view { return *Access::get(p); }; + d.set_string = [](void* p, std::string_view v) { Access::get(p)->assign(v); }; + return FieldKind::kString; + } else { + static_assert(kAlwaysFalse, "setScalarAccessors: unsupported scalar type"); + } +} + +template +inline constexpr bool kHasFieldTableImpl = false; +/// SFINAE probe: substitution fails (leaving the primary `false` above) when +/// `FieldTable` has no specialization, since the primary template of +/// `FieldTable` is declared but never defined and so stays incomplete. +template +inline constexpr bool kHasFieldTableImpl::view)>> = true; + +/// `buffer_assign` of a builtin whose payload is a `data` view plus its `anchor`: +/// takes ownership of `bytes` (`makePayloadView`), `data` views them and `anchor` +/// keeps them alive. +template +void assignPayloadBytes(void* p, std::vector bytes) { + auto& object = *static_cast(p); + const PayloadView view = makePayloadView(std::move(bytes)); + object.data = view.bytes; + object.anchor = view.anchor; +} + +} // namespace detail + +/// True iff `FieldTable` has a specialization (i.e. `T` is describable), +/// detected without requiring the caller to define one. +template +concept HasFieldTable = detail::kHasFieldTableImpl; + +/// Builds the `FieldDescriptor` for data member `Member` (e.g. +/// `field<&Vector2::x>("x")`), deducing the owning struct and member type +/// from the member-pointer NTTP and generating non-capturing-lambda +/// accessors that cast `(const) void*` back to the owner type. `FieldKind` +/// is deduced from the member's type: +/// - `bool` -> kBool; `int64_t`/`uint64_t` (incl. `Timestamp`) -> kInt64; +/// other arithmetic -> kNumber; `enum`/`enum class` -> kEnum; +/// `std::string` -> kString. +/// - a class type `M` with `HasFieldTable` -> kStruct. +/// - `std::vector` -> kList (growable; see `list_emplace`/`list_clear`), +/// with `nested`/`element_kind` set from `E` the same way (kStruct + +/// nested table when `HasFieldTable`, otherwise the scalar +/// `FieldKind` of `E`). For a scalar `E`, the matching get_*/set_* pair +/// is ALSO populated, but operating on an ELEMENT address (as returned +/// by `list_at`/`list_emplace`), not on `owner->*Member` — the same +/// accessor a struct field of kind `E` would carry, repurposed for the +/// list's element type. +/// - `std::array` -> kList (fixed-size; see `list_replace`), `E` +/// restricted to a scalar (no struct-element fixed arrays today). Reads +/// the same way as a `std::vector` list (`list_size`/`list_at`); the +/// element count never changes, so writes go through `list_replace` +/// instead of `list_emplace`/`list_clear`. +/// - `std::optional` (`E` a non-bool arithmetic type) -> kOptionalNumber, +/// a nullable number: `has_value()` reports presence, `get_number`/ +/// `set_number` read/write the value like `kNumber` when present. +/// Any other member type fails to compile with a `static_assert`. +template +constexpr FieldDescriptor field(std::string_view name) { + using MemberPtr = decltype(Member); + using Owner = typename detail::MemberPointerTraits::Owner; + using Value = typename detail::MemberPointerTraits::Value; + + FieldDescriptor d{}; + d.name = name; + + if constexpr (detail::kIsSupportedScalar) { + d.kind = detail::setScalarAccessors>(d); + } else if constexpr (detail::kIsVector) { + using Element = typename Value::value_type; + d.kind = FieldKind::kList; + d.list_size = [](const void* p) -> size_t { return (static_cast(p)->*Member).size(); }; + d.list_at = [](const void* p, size_t i) -> const void* { return &(static_cast(p)->*Member)[i]; }; + d.list_emplace = [](void* p) -> void* { + auto& vec = static_cast(p)->*Member; + vec.emplace_back(); + return &vec.back(); + }; + d.list_clear = [](void* p) { (static_cast(p)->*Member).clear(); }; + + // A struct element links to its own table; a scalar element instead gets + // the matching get_*/set_* pair (see setScalarAccessors's doc comment), + // operating on an ELEMENT address (as returned by list_at/list_emplace) + // rather than on `owner->*Member`. Either way a generic consumer copies + // the list the same way: list_clear + list_emplace, then either recurse + // through `nested` or call the matching set_* on the element address. + if constexpr (HasFieldTable) { + d.element_kind = FieldKind::kStruct; + d.nested = &FieldTable::view; + } else if constexpr (detail::kIsSupportedScalar) { + d.element_kind = detail::setScalarAccessors>(d); + } else { + static_assert(detail::kAlwaysFalse, "field: unsupported std::vector element type"); + } + } else if constexpr (detail::kIsArray) { + using Element = typename Value::value_type; + static_assert(detail::kIsSupportedScalar, "field: unsupported std::array element type"); + constexpr size_t kSize = std::tuple_size_v; + d.kind = FieldKind::kList; + d.list_size = [](const void*) -> size_t { return kSize; }; + d.list_at = [](const void* p, size_t i) -> const void* { return &(static_cast(p)->*Member)[i]; }; + d.list_replace = [](void* p, size_t i) -> void* { return &(static_cast(p)->*Member)[i]; }; + // Same scalar accessor pair a std::vector list would carry + // (see field<>()'s std::vector branch), operating on an element address. + d.element_kind = detail::setScalarAccessors>(d); + } else if constexpr (detail::kIsOptional) { + using Inner = typename Value::value_type; + static_assert( + std::is_arithmetic_v && !std::is_same_v, + "field: unsupported std::optional value type (must be non-bool arithmetic)"); + d.kind = FieldKind::kOptionalNumber; + d.has_value = [](const void* p) -> bool { return (static_cast(p)->*Member).has_value(); }; + d.get_number = [](const void* p) -> double { + return static_cast(*(static_cast(p)->*Member)); + }; + d.set_number = [](void* p, double v) { (static_cast(p)->*Member) = detail::saturatingCast(v); }; + } else if constexpr (HasFieldTable) { + d.kind = FieldKind::kStruct; + d.nested = &FieldTable::view; + d.struct_ptr = [](const void* p) -> const void* { return &(static_cast(p)->*Member); }; + d.struct_ptr_mut = [](void* p) -> void* { return &(static_cast(p)->*Member); }; + } else { + static_assert( + detail::kAlwaysFalse, + "field: unsupported member type (no FieldTable and not a recognized scalar/vector/array/optional)"); + } + + return d; +} + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/field_table_registry.hpp b/pj_base/include/pj_base/builtin/field_table_registry.hpp new file mode 100644 index 00000000..81d034ec --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table_registry.hpp @@ -0,0 +1,66 @@ +/** + * @file field_table_registry.hpp + * @brief Looks up a builtin struct's `FieldTable` from its runtime + * `BuiltinObjectType` tag, so a generic binder can go straight from + * a `BuiltinObject` to its field descriptors without a `switch` of + * its own. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/builtin_object.hpp" +#include "pj_base/builtin/camera_info_fields.hpp" +#include "pj_base/builtin/depth_image_fields.hpp" +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/image_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_fields.hpp" +#include "pj_base/builtin/video_frame_fields.hpp" + +namespace PJ::sdk { + +/// Returns the field table for @p type, or `nullptr` for a type that has none +/// (buffer-only or not yet described) and for `kNone`. Every enumerator is +/// listed without a `default:` so `-Wswitch` flags a new `BuiltinObjectType` +/// until this function decides what it describes. +[[nodiscard]] inline const FieldTableView* describe(BuiltinObjectType type) noexcept { + switch (type) { + case BuiltinObjectType::kFrameTransforms: + return &FieldTable::view; + case BuiltinObjectType::kImageAnnotations: + return &FieldTable::view; + case BuiltinObjectType::kPointCloud: + return &FieldTable::view; + case BuiltinObjectType::kSceneEntities: + return &FieldTable::view; + case BuiltinObjectType::kImage: + return &FieldTable::view; + case BuiltinObjectType::kDepthImage: + return &FieldTable::view; + case BuiltinObjectType::kCameraInfo: + return &FieldTable::view; + case BuiltinObjectType::kVideoFrame: + return &FieldTable::view; + case BuiltinObjectType::kNone: + case BuiltinObjectType::kOccupancyGrid: + case BuiltinObjectType::kCompressedPointCloud: + case BuiltinObjectType::kMesh3D: + case BuiltinObjectType::kRobotDescription: + case BuiltinObjectType::kOccupancyGridUpdate: + case BuiltinObjectType::kLog: + case BuiltinObjectType::kPosesInFrame: + case BuiltinObjectType::kVoxelGrid: + case BuiltinObjectType::kPlotMarkers: + case BuiltinObjectType::kGridMap: + return nullptr; + } + return nullptr; +} + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp new file mode 100644 index 00000000..ba2876b5 --- /dev/null +++ b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp @@ -0,0 +1,84 @@ +/** + * @file frame_transforms_fields.hpp + * @brief `FieldTable` specializations for `frame_transforms.hpp`'s structs, + * so a generic script binder can expose their fields by name. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms.hpp" + +namespace PJ::sdk { + +/// Fields of `Vector2`: `x`, `y`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Vector2::x>("x"), + field<&Vector2::y>("y"), + }; + static constexpr FieldTableView view{"Vector2", Span(fields)}; +}; + +/// Fields of `Vector3`: `x`, `y`, `z`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Vector3::x>("x"), + field<&Vector3::y>("y"), + field<&Vector3::z>("z"), + }; + static constexpr FieldTableView view{"Vector3", Span(fields)}; +}; + +/// Fields of `Quaternion`: `x`, `y`, `z`, `w`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Quaternion::x>("x"), + field<&Quaternion::y>("y"), + field<&Quaternion::z>("z"), + field<&Quaternion::w>("w"), + }; + static constexpr FieldTableView view{"Quaternion", Span(fields)}; +}; + +/// Fields of `Pose`: `position` (Vector3), `orientation` (Quaternion). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Pose::position>("position"), + field<&Pose::orientation>("orientation"), + }; + static constexpr FieldTableView view{"Pose", Span(fields)}; +}; + +/// Fields of `FrameTransform`: `timestamp` (int64), `parent_frame_id`, +/// `child_frame_id` (strings), `translation` (Vector3), `rotation` (Quaternion). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&FrameTransform::timestamp>("timestamp"), + field<&FrameTransform::parent_frame_id>("parent_frame_id"), + field<&FrameTransform::child_frame_id>("child_frame_id"), + field<&FrameTransform::translation>("translation"), + field<&FrameTransform::rotation>("rotation"), + }; + static constexpr FieldTableView view{"FrameTransform", Span(fields)}; +}; + +/// Fields of `FrameTransforms`: `transforms` (list of `FrameTransform`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&FrameTransforms::transforms>("transforms"), + }; + static constexpr FieldTableView view{"FrameTransforms", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/image_annotations_codec.hpp b/pj_base/include/pj_base/builtin/image_annotations_codec.hpp index 4f04bc1e..c8e92baa 100644 --- a/pj_base/include/pj_base/builtin/image_annotations_codec.hpp +++ b/pj_base/include/pj_base/builtin/image_annotations_codec.hpp @@ -17,8 +17,9 @@ inline constexpr std::string_view kSchemaImageAnnotations = "PJ.ImageAnnotations /// 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. +/// The top-level `timestamp` (when non-zero) and `image_topic` (when non-empty) are +/// serialized; the payload is empty only when there are no circles, points or texts and +/// both are unset. [[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); /// Decodes canonical PJ.ImageAnnotations wire bytes into sdk::ImageAnnotations. diff --git a/pj_base/include/pj_base/builtin/image_annotations_fields.hpp b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp new file mode 100644 index 00000000..281c3e84 --- /dev/null +++ b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp @@ -0,0 +1,88 @@ +/** + * @file image_annotations_fields.hpp + * @brief `FieldTable` specializations for `image_annotations.hpp`'s structs, + * so a generic script binder can expose their fields by name. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/image_annotations.hpp" + +namespace PJ::sdk { + +/// Fields of `Point2`: `x`, `y`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Point2::x>("x"), + field<&Point2::y>("y"), + }; + static constexpr FieldTableView view{"Point2", Span(fields)}; +}; + +/// Fields of `ColorRGBA`: `r`, `g`, `b`, `a` (each `uint8_t`, read/written as `double`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ColorRGBA::r>("r"), + field<&ColorRGBA::g>("g"), + field<&ColorRGBA::b>("b"), + field<&ColorRGBA::a>("a"), + }; + static constexpr FieldTableView view{"ColorRGBA", Span(fields)}; +}; + +/// Fields of `PointsAnnotation`: `topology` (enum), `points` (list of +/// `Point2`), `thickness`, `color`, `colors` (list of `ColorRGBA`), `fill_color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&PointsAnnotation::topology>("topology"), field<&PointsAnnotation::points>("points"), + field<&PointsAnnotation::thickness>("thickness"), field<&PointsAnnotation::color>("color"), + field<&PointsAnnotation::colors>("colors"), field<&PointsAnnotation::fill_color>("fill_color"), + }; + static constexpr FieldTableView view{"PointsAnnotation", Span(fields)}; +}; + +/// Fields of `CircleAnnotation`: `center` (Point2), `radius`, `thickness`, +/// `color`, `fill_color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CircleAnnotation::center>("center"), field<&CircleAnnotation::radius>("radius"), + field<&CircleAnnotation::thickness>("thickness"), field<&CircleAnnotation::color>("color"), + field<&CircleAnnotation::fill_color>("fill_color"), + }; + static constexpr FieldTableView view{"CircleAnnotation", Span(fields)}; +}; + +/// Fields of `TextAnnotation`: `position` (Point2), `font_size`, `color`, `text`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&TextAnnotation::position>("position"), + field<&TextAnnotation::font_size>("font_size"), + field<&TextAnnotation::color>("color"), + field<&TextAnnotation::text>("text"), + }; + static constexpr FieldTableView view{"TextAnnotation", Span(fields)}; +}; + +/// Fields of `ImageAnnotations`: `timestamp` (int64), `image_topic` +/// (string), `points`, `circles`, `texts` (lists of the primitive structs above). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ImageAnnotations::timestamp>("timestamp"), field<&ImageAnnotations::image_topic>("image_topic"), + field<&ImageAnnotations::points>("points"), field<&ImageAnnotations::circles>("circles"), + field<&ImageAnnotations::texts>("texts"), + }; + static constexpr FieldTableView view{"ImageAnnotations", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/image_fields.hpp b/pj_base/include/pj_base/builtin/image_fields.hpp new file mode 100644 index 00000000..f710bf3f --- /dev/null +++ b/pj_base/include/pj_base/builtin/image_fields.hpp @@ -0,0 +1,129 @@ +/** + * @file image_fields.hpp + * @brief `FieldTable` specialization for `image.hpp`'s `Image`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/image.hpp" +#include "pj_base/builtin/point_cloud.hpp" + +namespace PJ::sdk { + +namespace detail { + +/// Per-pixel layout of a raw (uncompressed) `Image` encoding: the datatype +/// and how many consecutive values of it make up one pixel. `bytes()` is the +/// pixel size, i.e. what `imageDataField()`'s buffer descriptor reports as +/// `record_step`. A compressed or unrecognized encoding has no static +/// per-pixel size: `kUnknown`, count 0. +struct ImagePixelLayout { + PointField::Datatype datatype = PointField::Datatype::kUnknown; + uint32_t count = 0; + + [[nodiscard]] constexpr uint32_t bytes() const noexcept { + return count * bytesPerElement(datatype); + } +}; + +/// Resolves `ImagePixelLayout` for the documented raw encodings ("rgb8", +/// "rgba8", "bgr8", "bgra8", "mono8", "mono16"); returns a zeroed +/// `ImagePixelLayout` for a compressed encoding ("jpeg", "png", "qoi", +/// "compressedDepth") or any string outside `CommonImageEncoding`'s +/// vocabulary — `Image::encoding` is an open string, so an unrecognized +/// value is expected, not an error. +[[nodiscard]] constexpr ImagePixelLayout imagePixelLayout(std::string_view encoding) noexcept { + const auto parsed = parseImageEncoding(encoding); + if (!parsed.has_value()) { + return {}; + } + switch (*parsed) { + case CommonImageEncoding::rgb8: + case CommonImageEncoding::bgr8: + return {.datatype = PointField::Datatype::kUint8, .count = 3}; + case CommonImageEncoding::rgba8: + case CommonImageEncoding::bgra8: + return {.datatype = PointField::Datatype::kUint8, .count = 4}; + case CommonImageEncoding::mono8: + return {.datatype = PointField::Datatype::kUint8, .count = 1}; + case CommonImageEncoding::mono16: + return {.datatype = PointField::Datatype::kUint16, .count = 1}; + case CommonImageEncoding::jpeg: + case CommonImageEncoding::png: + case CommonImageEncoding::qoi: + case CommonImageEncoding::compressedDepth: + return {}; + } + return {}; +} + +} // namespace detail + +/// `kBuffer` descriptor for `Image::data`. `buffer()` resolves the packed +/// pixel bytes: `record_step`/`record_count` come from `detail::imagePixelLayout(encoding)` +/// (a raw encoding's static per-pixel size times `width * height`); a +/// compressed encoding — or any string outside `CommonImageEncoding`'s +/// documented vocabulary — has no static per-pixel size, so both are +/// reported as 0 and `bytes` alone (its full span) is the payload; a raw +/// encoding whose declared extent exceeds `data` also reports 0 records. `row_step` +/// and `is_bigendian` are read straight from the struct (meaningful for raw +/// encodings only, per `image.hpp`'s doc comment). `channels` always carries +/// exactly one entry naming the encoding string, so a consumer that gets a +/// zeroed `record_step` still learns which codec/layout produced the bytes. +/// `buffer_assign()` is `detail::assignPayloadBytes`. +[[nodiscard]] constexpr FieldDescriptor imageDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& image = *static_cast(p); + const detail::ImagePixelLayout pixel = detail::imagePixelLayout(image.encoding); + BufferLayout layout; + layout.bytes = image.data; + layout.record_step = pixel.bytes(); + const bool fits = detail::rowsFitIn( + image.height, static_cast(image.width) * pixel.bytes(), image.row_step, image.data.size()); + layout.record_count = pixel.bytes() == 0 || !fits ? 0 : static_cast(image.width) * image.height; + layout.row_step = image.row_step; + layout.is_bigendian = image.is_bigendian; + layout.channels.push_back( + BufferLayout::Channel{ + .name = image.encoding, + .offset = 0, + .datatype = static_cast(pixel.datatype), + .count = pixel.count}); + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `Image`: every member is an ordinary field except `data` +/// (`imageDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. `compressed_depth_min`/ +/// `compressed_depth_max` are `kOptionalNumber` (nullable — see +/// `field_table.hpp`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Image::width>("width"), + field<&Image::height>("height"), + field<&Image::encoding>("encoding"), + field<&Image::row_step>("row_step"), + field<&Image::is_bigendian>("is_bigendian"), + imageDataField("data"), + field<&Image::compressed_depth_min>("compressed_depth_min"), + field<&Image::compressed_depth_max>("compressed_depth_max"), + field<&Image::timestamp_ns>("timestamp_ns"), + field<&Image::frame_id>("frame_id"), + }; + static constexpr FieldTableView view{"Image", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/point_cloud_fields.hpp b/pj_base/include/pj_base/builtin/point_cloud_fields.hpp new file mode 100644 index 00000000..83b8a3b4 --- /dev/null +++ b/pj_base/include/pj_base/builtin/point_cloud_fields.hpp @@ -0,0 +1,82 @@ +/** + * @file point_cloud_fields.hpp + * @brief `FieldTable` specializations for `point_cloud.hpp`'s structs. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/point_cloud.hpp" + +namespace PJ::sdk { + +/// Fields of `PointField`: `name`, `offset`, `datatype` (enum), `count`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&PointField::name>("name"), + field<&PointField::offset>("offset"), + field<&PointField::datatype>("datatype"), + field<&PointField::count>("count"), + }; + static constexpr FieldTableView view{"PointField", Span(fields)}; +}; + +/// `kBuffer` descriptor for `PointCloud::data`. `buffer()` resolves the packed +/// record bytes plus a per-channel layout read from `fields` / `point_step` / +/// `row_step` / `width` / `height` / `is_bigendian` (record count is +/// `width * height`, with `height == 0` read as one row; a zero `point_step`, or +/// a declared extent larger than `data`, yields zero records). `buffer_assign()` is `detail::assignPayloadBytes`. +/// `anchor` itself is not a table field. +[[nodiscard]] constexpr FieldDescriptor pointCloudDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& cloud = *static_cast(p); + BufferLayout layout; + layout.bytes = cloud.data; + layout.record_step = cloud.point_step; + const uint64_t rows = cloud.height == 0 ? 1 : cloud.height; + const bool fits = detail::rowsFitIn( + rows, static_cast(cloud.width) * cloud.point_step, cloud.row_step, cloud.data.size()); + layout.record_count = cloud.point_step == 0 || !fits ? 0 : static_cast(cloud.width) * rows; + layout.row_step = cloud.row_step; + layout.is_bigendian = cloud.is_bigendian; + layout.channels.reserve(cloud.fields.size()); + for (const PointField& f : cloud.fields) { + layout.channels.push_back( + BufferLayout::Channel{ + .name = f.name, .offset = f.offset, .datatype = static_cast(f.datatype), .count = f.count}); + } + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `PointCloud`. Every member is an ordinary field except `data` +/// (`pointCloudDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&PointCloud::width>("width"), + field<&PointCloud::height>("height"), + field<&PointCloud::point_step>("point_step"), + field<&PointCloud::row_step>("row_step"), + field<&PointCloud::is_bigendian>("is_bigendian"), + field<&PointCloud::is_dense>("is_dense"), + field<&PointCloud::frame_id>("frame_id"), + field<&PointCloud::fields>("fields"), + pointCloudDataField("data"), + field<&PointCloud::timestamp_ns>("timestamp_ns"), + }; + static constexpr FieldTableView view{"PointCloud", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/scene_entities_fields.hpp b/pj_base/include/pj_base/builtin/scene_entities_fields.hpp new file mode 100644 index 00000000..4ffbdccf --- /dev/null +++ b/pj_base/include/pj_base/builtin/scene_entities_fields.hpp @@ -0,0 +1,212 @@ +/** + * @file scene_entities_fields.hpp + * @brief `FieldTable` specializations for `scene_entities.hpp`'s structs. + * + * `Pose`/`Vector3` are tabled by `frame_transforms_fields.hpp` and + * `ColorRGBA` by `image_annotations_fields.hpp` — `scene_entities.hpp` + * reuses those exact types (see its own file comment), so this header + * includes both rather than re-specializing `FieldTable` for them, which + * would be an ODR violation (two definitions of the same specialization). + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" // Pose, Vector3 +#include "pj_base/builtin/image_annotations_fields.hpp" // ColorRGBA +#include "pj_base/builtin/scene_entities.hpp" + +namespace PJ::sdk { + +/// Fields of `Point3`: `x`, `y`, `z`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Point3::x>("x"), + field<&Point3::y>("y"), + field<&Point3::z>("z"), + }; + static constexpr FieldTableView view{"Point3", Span(fields)}; +}; + +/// Fields of `KeyValuePair`: `key`, `value`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&KeyValuePair::key>("key"), + field<&KeyValuePair::value>("value"), + }; + static constexpr FieldTableView view{"KeyValuePair", Span(fields)}; +}; + +/// Fields of `ArrowPrimitive`: `pose`, `shaft_length`, `shaft_diameter`, +/// `head_length`, `head_diameter`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ArrowPrimitive::pose>("pose"), + field<&ArrowPrimitive::shaft_length>("shaft_length"), + field<&ArrowPrimitive::shaft_diameter>("shaft_diameter"), + field<&ArrowPrimitive::head_length>("head_length"), + field<&ArrowPrimitive::head_diameter>("head_diameter"), + field<&ArrowPrimitive::color>("color"), + }; + static constexpr FieldTableView view{"ArrowPrimitive", Span(fields)}; +}; + +/// Fields of `CubePrimitive`: `pose`, `size`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CubePrimitive::pose>("pose"), + field<&CubePrimitive::size>("size"), + field<&CubePrimitive::color>("color"), + }; + static constexpr FieldTableView view{"CubePrimitive", Span(fields)}; +}; + +/// Fields of `SpherePrimitive`: `pose`, `size`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SpherePrimitive::pose>("pose"), + field<&SpherePrimitive::size>("size"), + field<&SpherePrimitive::color>("color"), + }; + static constexpr FieldTableView view{"SpherePrimitive", Span(fields)}; +}; + +/// Fields of `CylinderPrimitive`: `pose`, `size`, `bottom_scale`, +/// `top_scale`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CylinderPrimitive::pose>("pose"), + field<&CylinderPrimitive::size>("size"), + field<&CylinderPrimitive::bottom_scale>("bottom_scale"), + field<&CylinderPrimitive::top_scale>("top_scale"), + field<&CylinderPrimitive::color>("color"), + }; + static constexpr FieldTableView view{"CylinderPrimitive", Span(fields)}; +}; + +/// Fields of `LinePrimitive`: `type` (enum), `pose`, `thickness`, +/// `scale_invariant`, `points` (list of `Point3`), `color`, `colors` (list +/// of `ColorRGBA`), `indices` (scalar list of `uint32_t` -> kNumber). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&LinePrimitive::type>("type"), field<&LinePrimitive::pose>("pose"), + field<&LinePrimitive::thickness>("thickness"), field<&LinePrimitive::scale_invariant>("scale_invariant"), + field<&LinePrimitive::points>("points"), field<&LinePrimitive::color>("color"), + field<&LinePrimitive::colors>("colors"), field<&LinePrimitive::indices>("indices"), + }; + static constexpr FieldTableView view{"LinePrimitive", Span(fields)}; +}; + +/// Fields of `TrianglePrimitive`: `pose`, `points` (list of `Point3`), +/// `color`, `colors` (list of `ColorRGBA`), `indices` (scalar list of +/// `uint32_t` -> kNumber). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&TrianglePrimitive::pose>("pose"), field<&TrianglePrimitive::points>("points"), + field<&TrianglePrimitive::color>("color"), field<&TrianglePrimitive::colors>("colors"), + field<&TrianglePrimitive::indices>("indices"), + }; + static constexpr FieldTableView view{"TrianglePrimitive", Span(fields)}; +}; + +/// Fields of `TextPrimitive`: `pose`, `billboard`, `font_size`, +/// `scale_invariant`, `color`, `text`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&TextPrimitive::pose>("pose"), field<&TextPrimitive::billboard>("billboard"), + field<&TextPrimitive::font_size>("font_size"), field<&TextPrimitive::scale_invariant>("scale_invariant"), + field<&TextPrimitive::color>("color"), field<&TextPrimitive::text>("text"), + }; + static constexpr FieldTableView view{"TextPrimitive", Span(fields)}; +}; + +/// Fields of `AxesPrimitive`: `pose`, `length`, `thickness`, `scale_invariant`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&AxesPrimitive::pose>("pose"), + field<&AxesPrimitive::length>("length"), + field<&AxesPrimitive::thickness>("thickness"), + field<&AxesPrimitive::scale_invariant>("scale_invariant"), + }; + static constexpr FieldTableView view{"AxesPrimitive", Span(fields)}; +}; + +/// Fields of `ModelPrimitive`: `pose`, `scale`, `color`, `override_color`, +/// `url`, `media_type`, `data` (scalar list of `uint8_t` -> kNumber; a plain +/// `std::vector` member here, unlike `PointCloud::data`, which is a +/// `Span`+`BufferAnchor` pair described through a `kBuffer` descriptor such as `pointCloudDataField()` instead). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ModelPrimitive::pose>("pose"), field<&ModelPrimitive::scale>("scale"), + field<&ModelPrimitive::color>("color"), field<&ModelPrimitive::override_color>("override_color"), + field<&ModelPrimitive::url>("url"), field<&ModelPrimitive::media_type>("media_type"), + field<&ModelPrimitive::data>("data"), + }; + static constexpr FieldTableView view{"ModelPrimitive", Span(fields)}; +}; + +/// Fields of `SceneEntity`: `timestamp` (int64), `frame_id`, `id`, +/// `lifetime_ns` (int64), `frame_locked`, `metadata` (list of +/// `KeyValuePair`), and the nine primitive lists in `SceneEntity`'s +/// declaration order (`arrows`, `cubes`, `spheres`, `cylinders`, `lines`, +/// `triangles`, `texts`, `models`, `axes`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SceneEntity::timestamp>("timestamp"), + field<&SceneEntity::frame_id>("frame_id"), + field<&SceneEntity::id>("id"), + field<&SceneEntity::lifetime_ns>("lifetime_ns"), + field<&SceneEntity::frame_locked>("frame_locked"), + field<&SceneEntity::metadata>("metadata"), + field<&SceneEntity::arrows>("arrows"), + field<&SceneEntity::cubes>("cubes"), + field<&SceneEntity::spheres>("spheres"), + field<&SceneEntity::cylinders>("cylinders"), + field<&SceneEntity::lines>("lines"), + field<&SceneEntity::triangles>("triangles"), + field<&SceneEntity::texts>("texts"), + field<&SceneEntity::models>("models"), + field<&SceneEntity::axes>("axes"), + }; + static constexpr FieldTableView view{"SceneEntity", Span(fields)}; +}; + +/// Fields of `SceneEntityDeletion`: `type` (enum), `timestamp` (int64), `id`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SceneEntityDeletion::type>("type"), + field<&SceneEntityDeletion::timestamp>("timestamp"), + field<&SceneEntityDeletion::id>("id"), + }; + static constexpr FieldTableView view{"SceneEntityDeletion", Span(fields)}; +}; + +/// Fields of `SceneEntities`: `entities` (list of `SceneEntity`), +/// `deletions` (list of `SceneEntityDeletion`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SceneEntities::entities>("entities"), + field<&SceneEntities::deletions>("deletions"), + }; + static constexpr FieldTableView view{"SceneEntities", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/video_frame_fields.hpp b/pj_base/include/pj_base/builtin/video_frame_fields.hpp new file mode 100644 index 00000000..fecd3d13 --- /dev/null +++ b/pj_base/include/pj_base/builtin/video_frame_fields.hpp @@ -0,0 +1,55 @@ +/** + * @file video_frame_fields.hpp + * @brief `FieldTable` specialization for `video_frame.hpp`'s `VideoFrame`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/video_frame.hpp" + +namespace PJ::sdk { + +/// `kBuffer` descriptor for `VideoFrame::data`. Every `VideoFrame::format` +/// ("h264", "h265", "vp9", "av1") is a compressed bitstream with +/// inter-frame dependencies — there is no static per-record size at all, so +/// `record_step`/`record_count`/`row_step` are always 0 and `bytes` alone +/// (its full span) is the payload; `channels` carries exactly one entry +/// naming the codec (`format`), so a consumer that gets a zeroed +/// `record_step` still learns which codec produced the bytes — same +/// convention as `imageDataField()` (`image_fields.hpp`) for a compressed +/// `Image`. `buffer_assign()` is `detail::assignPayloadBytes`. +[[nodiscard]] constexpr FieldDescriptor videoFrameDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& frame = *static_cast(p); + BufferLayout layout; + layout.bytes = frame.data; + layout.channels.push_back(BufferLayout::Channel{.name = frame.format, .offset = 0, .datatype = 0, .count = 0}); + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `VideoFrame`: every member is an ordinary field except `data` +/// (`videoFrameDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&VideoFrame::timestamp_ns>("timestamp_ns"), + field<&VideoFrame::frame_id>("frame_id"), + field<&VideoFrame::format>("format"), + videoFrameDataField("data"), + }; + static constexpr FieldTableView view{"VideoFrame", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/sdk/object_topic_metadata.hpp b/pj_base/include/pj_base/sdk/object_topic_metadata.hpp index c6ee473e..3b04a7a4 100644 --- a/pj_base/include/pj_base/sdk/object_topic_metadata.hpp +++ b/pj_base/include/pj_base/sdk/object_topic_metadata.hpp @@ -18,6 +18,30 @@ namespace PJ::sdk { /// @since 0.21.0 inline constexpr std::string_view kBuiltinObjectTypeMetadataKey = "builtin_object_type"; +/// Canonical metadata key marking a snapshot stream. Value `"true"` on a +/// SceneEntities/ImageAnnotations topic means EVERY entry is a complete +/// clear-and-replace snapshot, so a stateless consumer may render each entry +/// alone without accumulating state across prior entries. Absent (or any +/// other value) means entries may be incremental and a consumer must replay +/// the topic's history to reconstruct the current state. +/// @since 0.36.0 +inline constexpr std::string_view kSnapshotMetadataKey = "pj_snapshot"; + +/// Canonical metadata key marking a DERIVED object topic: one produced by a host +/// data processor rather than ingested from a data source. The value names the +/// kind of processor that produced it and is an open set: `kDerivedOnDemandValue` +/// ("on_demand") today, and a host may stamp other kinds (e.g. "markers"). A +/// consumer (a viewer, the assistant, a script author) checks the key's presence +/// for "is derived" and the value for one specific kind; it is set by the HOST, +/// never by the plugin that created the processor. Match the key by parsing the +/// metadata JSON, never by substring. +/// @since 0.36.0 +inline constexpr std::string_view kDerivedMetadataKey = "pj_derived"; + +/// The `kDerivedMetadataKey` value for a topic produced by an "on_demand" processor. +/// @since 0.36.0 +inline constexpr std::string_view kDerivedOnDemandValue = "on_demand"; + /// Builds deterministic metadata JSON for an object topic. /// /// `builtinObjectType()` accepts only the SDK enum and serializes its canonical @@ -64,6 +88,19 @@ class ObjectTopicMetadataBuilder { return *this; } + /// Mark this topic as a snapshot stream (see kSnapshotMetadataKey): every + /// entry is a complete clear-and-replace snapshot. `false` removes the key + /// again (incremental, the default). + /// @since 0.36.0 + ObjectTopicMetadataBuilder& snapshot(bool value = true) { + if (value) { + strings_.insert_or_assign(std::string(kSnapshotMetadataKey), std::string("true")); + } else { + strings_.erase(std::string(kSnapshotMetadataKey)); + } + return *this; + } + /// Serialize the accumulated metadata as a deterministic JSON object, or /// return the first contract error recorded by a setter. /// @since 0.21.0 diff --git a/pj_base/proto/pj/ImageAnnotations.proto b/pj_base/proto/pj/ImageAnnotations.proto index 83a84770..da87ad8c 100644 --- a/pj_base/proto/pj/ImageAnnotations.proto +++ b/pj_base/proto/pj/ImageAnnotations.proto @@ -32,4 +32,8 @@ message ImageAnnotations { // 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; + + // Topic of the image these annotations were computed for. Runtime association carried on the wire so a derived + // or recorded annotations topic can be paired with its image. + string image_topic = 6; } diff --git a/pj_base/src/builtin/geometry_codec.hpp b/pj_base/src/builtin/geometry_codec.hpp index eeeae1f4..1cecd0f2 100644 --- a/pj_base/src/builtin/geometry_codec.hpp +++ b/pj_base/src/builtin/geometry_codec.hpp @@ -12,11 +12,11 @@ #include #include -#include #include "pj_base/builtin/frame_transforms.hpp" // Vector2, Vector3, Quaternion, Pose #include "pj_base/builtin/image_annotations.hpp" // ColorRGBA #include "pj_base/builtin/scene_entities.hpp" // Point3 +#include "pj_base/time_math.hpp" #include "pj_base/types.hpp" #include "protobuf_wire.hpp" @@ -43,18 +43,11 @@ inline TimestampParts splitTimestamp(Timestamp timestamp_ns) { } inline bool combineTimestamp(const TimestampParts& parts, Timestamp& out) { - if (parts.nanos < 0 || parts.nanos >= kNanosecondsPerSecond) { + const auto combined = combineSecondsAndNanos(parts.seconds, parts.nanos); + if (!combined) { 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; + out = *combined; return true; } diff --git a/pj_base/src/builtin/image_annotations_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp index 8481bfa0..5cf94848 100644 --- a/pj_base/src/builtin/image_annotations_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -3,20 +3,22 @@ #include "pj_base/builtin/image_annotations_codec.hpp" -#include #include #include #include #include +#include "geometry_codec.hpp" #include "protobuf_wire.hpp" namespace PJ { namespace { +using builtin_wire::normalizedToByte; using builtin_wire::Reader; using builtin_wire::Tag; using builtin_wire::WireType; +using builtin_wire::writeColor; using builtin_wire::Writer; using sdk::AnnotationTopology; using sdk::CircleAnnotation; @@ -31,13 +33,6 @@ void writePoint2(Writer& writer, const Point2& point) { writer.doubleField(2, point.y); } -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); -} - uint32_t topologyToEnum(AnnotationTopology topology) { switch (topology) { case AnnotationTopology::kPoints: @@ -100,11 +95,6 @@ AnnotationTopology mapTopology(uint64_t type) { } } -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; @@ -127,7 +117,8 @@ bool decodePoint2(Reader& reader, Point2& out) { return true; } -bool decodeColor(Reader& reader, ColorRGBA& out) { +// Not builtin_wire::decodeColor: annotation colours default alpha to 1 and skip unknown fields. +bool decodeAnnotationColor(Reader& reader, ColorRGBA& out) { double r = 0.0; double g = 0.0; double b = 0.0; @@ -174,9 +165,9 @@ bool readPoint2Message(Reader& reader, Point2& out) { return reader.readMessage(nested) && decodePoint2(nested, out); } -bool readColorMessage(Reader& reader, ColorRGBA& out) { +bool readAnnotationColorMessage(Reader& reader, ColorRGBA& out) { Reader nested; - return reader.readMessage(nested) && decodeColor(nested, out); + return reader.readMessage(nested) && decodeAnnotationColor(nested, out); } bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { @@ -211,7 +202,7 @@ bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { } case 4: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { + if (!readAnnotationColorMessage(reader, out.color)) { return false; } continue; @@ -222,7 +213,7 @@ bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { break; } ColorRGBA color; - if (!readColorMessage(reader, color)) { + if (!readAnnotationColorMessage(reader, color)) { return false; } out.colors.push_back(color); @@ -230,7 +221,7 @@ bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { } case 6: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.fill_color)) { + if (!readAnnotationColorMessage(reader, out.fill_color)) { return false; } continue; @@ -297,7 +288,7 @@ bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { break; case 5: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.fill_color)) { + if (!readAnnotationColorMessage(reader, out.fill_color)) { return false; } continue; @@ -305,7 +296,7 @@ bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { break; case 6: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { + if (!readAnnotationColorMessage(reader, out.color)) { return false; } continue; @@ -359,7 +350,7 @@ bool decodeTextAnnotation(Reader& reader, TextAnnotation& out) { break; case 5: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { + if (!readAnnotationColorMessage(reader, out.color)) { return false; } continue; @@ -392,6 +383,13 @@ std::vector serializeImageAnnotations(const ImageAnnotations& annotatio writer.message(3, [&](Writer& nested) { writeTextAnnotation(nested, text); }); } + if (annotations.timestamp != 0) { + writer.message(5, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, annotations.timestamp); }); + } + if (!annotations.image_topic.empty()) { + writer.string(6, annotations.image_topic); + } + return out; } @@ -416,6 +414,17 @@ Expected deserializeImageAnnotations(const uint8_t* data, continue; } + // image_topic (field 6) is a plain string, not a nested message: read it + // directly off `reader` rather than through the generic readMessage() + // path below, which wraps the length-delimited payload as a sub-Reader + // for per-message field dispatch. + if (tag.field == 6) { + if (!reader.readString(annotations.image_topic)) { + return unexpected(std::string("ImageAnnotations wire: image_topic decode failed")); + } + continue; + } + Reader nested; if (!reader.readMessage(nested)) { return unexpected(std::string("ImageAnnotations wire: bad nested message length")); @@ -448,6 +457,12 @@ Expected deserializeImageAnnotations(const uint8_t* data, annotations.texts.push_back(std::move(text)); break; } + case 5: { + if (!builtin_wire::decodeTimestamp(nested, annotations.timestamp)) { + return unexpected(std::string("ImageAnnotations wire: Timestamp decode failed")); + } + break; + } default: break; } diff --git a/pj_base/tests/depth_image_codec_test.cpp b/pj_base/tests/depth_image_codec_test.cpp index f01830bb..c3f2fc7d 100644 --- a/pj_base/tests/depth_image_codec_test.cpp +++ b/pj_base/tests/depth_image_codec_test.cpp @@ -5,10 +5,15 @@ #include +#include +#include +#include #include #include +#include #include +#include "pj_base/builtin/depth_image_utils.hpp" #include "protobuf_wire_test_helpers.hpp" namespace PJ { @@ -68,5 +73,92 @@ TEST(DepthImageCodecTest, RoundTripPlumbBobDistortion) { } } +// K * p / p.z, the forward pinhole projection: the oracle for unprojection. +std::array project(const std::array& k, const std::array& p) { + const double x = k[0] * p[0] + k[1] * p[1] + k[2] * p[2]; + const double y = k[3] * p[0] + k[4] * p[1] + k[5] * p[2]; + const double z = k[6] * p[0] + k[7] * p[1] + k[8] * p[2]; + return {x / z, y / z}; +} + +// Unprojects (u, v, depth) through `k` and checks the point projects back to (u, v). +void expectReprojects(const std::array& k, double u, double v, double depth) { + const auto point = sdk::unprojectPixel(k, u, v, depth); + ASSERT_TRUE(point); + const auto reprojected = project(k, *point); + EXPECT_NEAR(reprojected[0], u, 1e-9); + EXPECT_NEAR(reprojected[1], v, 1e-9); +} + +TEST(DepthImageUtilsTest, PurePinholeMatchesTheShortFormula) { + const std::array k{525.3, 0, 319.5, 0, 524.7, 239.5, 0, 0, 1}; + const double u = 123.4; + const double v = 456.7; + const double depth = 3.21; + const auto p = sdk::unprojectPixel(k, u, v, depth); + ASSERT_TRUE(p); + const std::array expected{(u - k[2]) / k[0] * depth, (v - k[5]) / k[4] * depth, depth}; + for (size_t i = 0; i < 3; ++i) { + EXPECT_NEAR((*p)[i], expected[i], 1e-12 * std::abs(expected[i])) << "component " << i; + } +} + +TEST(DepthImageUtilsTest, AcceptsFloat32RoundedK) { + // k[8] = 1.0000001f and a float32 skew: neither is exactly 1/0 in double. + std::array k{525, static_cast(1e-4f), 319.5, 0, 525, 239.5, 0, 0, static_cast(1.0000001f)}; + ASSERT_NE(k[8], 1.0); + expectReprojects(k, 100, 50, 2); +} + +TEST(DepthImageUtilsTest, SkewedKReprojectsToThePixel) { + const std::array skewed{500, 2.5, 320, 0, 510, 240, 0, 0, 1}; + const std::array projective{500, 2.5, 320, 0.001, 510, 240, 1e-4, 2e-4, 1}; + expectReprojects(skewed, 37.5, 411.25, 1.75); + expectReprojects(projective, 37.5, 411.25, 1.75); + // A skewed K is not the pure-pinhole one: the short formula would be wrong here. + const auto p = sdk::unprojectPixel(skewed, 37.5, 411.25, 1.75); + ASSERT_TRUE(p); + EXPECT_GT(std::abs((*p)[0] - (37.5 - 320) / 500 * 1.75), 1e-3); +} + +TEST(DepthImageUtilsTest, RejectsSingularK) { + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({})); + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({0, 0, 1, 0, 4, 1, 0, 0, 1})); // fx == 0 + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({1, 2, 3, 2, 4, 6, 0, 0, 1})); // proportional rows +} + +TEST(DepthImageUtilsTest, RejectsNonFiniteK) { + const double bad_values[] = {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}; + for (double bad : bad_values) { + for (size_t i = 0; i < 9; ++i) { + std::array k{2, 0, 1, 0, 4, 1, 0, 0, 1}; + k[i] = bad; + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK(k)) << "entry " << i; + } + } + // Finite entries whose 2x2 minor overflows (1e300 * 1e300): the inverse is not finite. + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({1e300, 0, 0, 0, 1e300, 0, 0, 0, 1e-300})); + // Finite entries whose determinant overflows (1e110^3): the inverse would collapse to zeros. + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({1e110, 0, 0, 0, 1e110, 0, 0, 0, 1e110})); +} + +TEST(DepthImageUtilsTest, RejectsRaysBehindTheCamera) { + // K^-1 [u, v, 1] has z == -1 for every pixel. + const std::array k{1, 0, 0, 0, 1, 0, 0, 0, -1}; + const auto intrinsics = sdk::PinholeIntrinsics::fromK(k); + ASSERT_TRUE(intrinsics); + EXPECT_FALSE(intrinsics->unproject(3, 5, 2)); +} + +TEST(DepthImageUtilsTest, RejectsInvalidPixelOrDepth) { + const std::array k{2, 0, 1, 0, 4, 1, 0, 0, 1}; + ASSERT_TRUE(sdk::unprojectPixel(k, 3, 5, 2)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, 5, 0)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, 5, -1)); + EXPECT_FALSE(sdk::unprojectPixel(k, std::numeric_limits::quiet_NaN(), 5, 2)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, std::numeric_limits::infinity(), 2)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, 5, std::numeric_limits::quiet_NaN())); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/field_table_test.cpp b/pj_base/tests/field_table_test.cpp new file mode 100644 index 00000000..d8340388 --- /dev/null +++ b/pj_base/tests/field_table_test.cpp @@ -0,0 +1,1017 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/field_table.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/builtin/builtin_object_codec.hpp" +#include "pj_base/builtin/camera_info_fields.hpp" +#include "pj_base/builtin/depth_image_fields.hpp" +#include "pj_base/builtin/field_table_registry.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/image_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_fields.hpp" +#include "pj_base/builtin/video_frame_fields.hpp" + +namespace { + +using PJ::sdk::AnnotationTopology; +using PJ::sdk::ArrowPrimitive; +using PJ::sdk::AxesPrimitive; +using PJ::sdk::BufferLayout; +using PJ::sdk::BuiltinObjectType; +using PJ::sdk::CameraInfo; +using PJ::sdk::CircleAnnotation; +using PJ::sdk::ColorRGBA; +using PJ::sdk::CubePrimitive; +using PJ::sdk::CylinderPrimitive; +using PJ::sdk::DepthImage; +using PJ::sdk::FieldDescriptor; +using PJ::sdk::FieldKind; +using PJ::sdk::FieldTable; +using PJ::sdk::FieldTableView; +using PJ::sdk::FrameTransform; +using PJ::sdk::FrameTransforms; +using PJ::sdk::Image; +using PJ::sdk::ImageAnnotations; +using PJ::sdk::KeyValuePair; +using PJ::sdk::LinePrimitive; +using PJ::sdk::LineType; +using PJ::sdk::ModelPrimitive; +using PJ::sdk::PointCloud; +using PJ::sdk::PointField; +using PJ::sdk::PointsAnnotation; +using PJ::sdk::Pose; +using PJ::sdk::SceneEntities; +using PJ::sdk::SceneEntity; +using PJ::sdk::SceneEntityDeletion; +using PJ::sdk::SpherePrimitive; +using PJ::sdk::TextAnnotation; +using PJ::sdk::TextPrimitive; +using PJ::sdk::TrianglePrimitive; +using PJ::sdk::Vector2; +using PJ::sdk::VideoFrame; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const FieldDescriptor* findField(const FieldTableView& table, std::string_view name) { + for (const auto& f : table.fields) { + if (f.name == name) { + return &f; + } + } + return nullptr; +} + +// Walks `table` and every table reachable through `nested`, depth-first, +// visiting each distinct type name once (guards against re-visiting a type +// reachable through multiple paths, e.g. Vector3 via both Pose and FrameTransform). +void collectTables( + const FieldTableView& table, std::set& visited, std::vector& out) { + if (!visited.insert(table.type_name).second) { + return; + } + out.push_back(&table); + for (const auto& f : table.fields) { + if (f.nested != nullptr) { + collectTables(*f.nested, visited, out); + } + } +} + +// Recursively copies every described field from `src` to `dst` using only +// FieldDescriptor accessors — the same access pattern a generic script +// binder would use. A kList field either holds structs (nested != nullptr, +// recurse into the nested table) or scalars (nested == nullptr; copy via the +// get_*/set_* pair matching element_kind, applied to the element address +// returned by list_at/list_emplace for a growable list, or list_replace for +// a fixed-size one — see field_table.hpp's kList doc comment). A kBuffer +// field resolves its current bytes and re-assigns them onto dst, taking a +// fresh copy/anchor. A kOptionalNumber field copies the value only when +// present, leaving a freshly-constructed dst's absent default untouched +// otherwise. +void copyThroughTable(const FieldTableView& table, const void* src, void* dst) { + for (const auto& f : table.fields) { + switch (f.kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + f.set_number(dst, f.get_number(src)); + break; + case FieldKind::kInt64: + f.set_int64(dst, f.get_int64(src)); + break; + case FieldKind::kString: + f.set_string(dst, f.get_string(src)); + break; + case FieldKind::kOptionalNumber: + if (f.has_value(src)) { + f.set_number(dst, f.get_number(src)); + } + break; + case FieldKind::kStruct: + ASSERT_NE(f.nested, nullptr); + copyThroughTable(*f.nested, f.struct_ptr(src), f.struct_ptr_mut(dst)); + break; + case FieldKind::kList: { + const size_t n = f.list_size(src); + const bool fixed_size = f.list_replace != nullptr; + if (!fixed_size) { + f.list_clear(dst); + } else { + ASSERT_EQ(n, f.list_size(dst)) << "fixed-size list: src/dst element counts must match"; + } + for (size_t i = 0; i < n; ++i) { + void* dst_elem = fixed_size ? f.list_replace(dst, i) : f.list_emplace(dst); + const void* src_elem = f.list_at(src, i); + if (f.nested != nullptr) { + copyThroughTable(*f.nested, src_elem, dst_elem); + continue; + } + switch (f.element_kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + f.set_number(dst_elem, f.get_number(src_elem)); + break; + case FieldKind::kInt64: + f.set_int64(dst_elem, f.get_int64(src_elem)); + break; + case FieldKind::kString: + f.set_string(dst_elem, f.get_string(src_elem)); + break; + default: + FAIL() << "unsupported scalar element_kind in copyThroughTable"; + } + } + break; + } + case FieldKind::kBuffer: { + const BufferLayout layout = f.buffer(src); + std::vector bytes(layout.bytes.begin(), layout.bytes.end()); + f.buffer_assign(dst, std::move(bytes)); + break; + } + } + } +} + +// Serializes both objects through the canonical wire codec and expects identical +// bytes: the comparison for builtins whose payload has no meaningful operator==. +template +void expectSameWireBytes(const T& src, const T& dst) { + auto src_bytes = PJ::serializeBuiltinObject(PJ::sdk::BuiltinObject(src)); + auto dst_bytes = PJ::serializeBuiltinObject(PJ::sdk::BuiltinObject(dst)); + ASSERT_TRUE(src_bytes) << src_bytes.error(); + ASSERT_TRUE(dst_bytes) << dst_bytes.error(); + EXPECT_EQ(*src_bytes, *dst_bytes); +} + +// --------------------------------------------------------------------------- +// Sample data — populated, so every accessor path is exercised. +// --------------------------------------------------------------------------- + +FrameTransforms makeSampleFrameTransforms() { + FrameTransforms ft; + ft.transforms.push_back( + FrameTransform{ + .timestamp = 1'234'567'890'123'456, + .parent_frame_id = "map", + .child_frame_id = "base_link", + .translation = {.x = 1.0, .y = 2.0, .z = 3.0}, + .rotation = {.x = 0.1, .y = 0.2, .z = 0.3, .w = 0.9}, + }); + ft.transforms.push_back( + FrameTransform{ + .timestamp = -42, + .parent_frame_id = "odom", + .child_frame_id = "map", + .translation = {.x = -1.5, .y = 2.5, .z = -3.5}, + .rotation = {.x = 0.0, .y = 0.0, .z = 0.707, .w = 0.707}, + }); + return ft; +} + +ImageAnnotations makeSampleImageAnnotations() { + ImageAnnotations ia; + ia.timestamp = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + ia.image_topic = "/camera/image"; + + PointsAnnotation pa; + pa.topology = AnnotationTopology::kLineStrip; // non-default (kPoints == 0) + pa.points = {{.x = 1.0, .y = 2.0}, {.x = 3.0, .y = 4.0}, {.x = 5.0, .y = 6.0}}; + pa.thickness = 3.5; + pa.color = {.r = 10, .g = 20, .b = 30, .a = 40}; + pa.colors = {{.r = 1, .g = 2, .b = 3, .a = 4}, {.r = 5, .g = 6, .b = 7, .a = 8}}; + pa.fill_color = {.r = 50, .g = 60, .b = 70, .a = 80}; + ia.points.push_back(pa); + + CircleAnnotation ca; + ca.center = {.x = 7.0, .y = 8.0}; + ca.radius = 4.0; + ca.thickness = 1.5; + ca.color = {.r = 100, .g = 110, .b = 120, .a = 130}; + ca.fill_color = {.r = 140, .g = 150, .b = 160, .a = 170}; + ia.circles.push_back(ca); + + TextAnnotation ta; + ta.position = {.x = 9.0, .y = 10.0}; + ta.font_size = 18.0; + ta.color = {.r = 200, .g = 210, .b = 220, .a = 230}; + ta.text = "hello"; + ia.texts.push_back(ta); + + return ia; +} + +// One entity carrying at least one of every SceneEntity primitive kind, plus +// metadata; the batch also carries one deletion. +SceneEntities makeSampleSceneEntities() { + SceneEntity e; + e.timestamp = 1'000'000'000'000'123; // > 2^53. + e.frame_id = "map"; + e.id = "entity-1"; + e.lifetime_ns = 5'000'000'000; + e.frame_locked = true; + e.metadata = { + KeyValuePair{.key = "k1", .value = "v1"}, + KeyValuePair{.key = "k2", .value = "v2"}, + }; + + e.arrows.push_back( + ArrowPrimitive{ + .pose = {.position = {.x = 1, .y = 2, .z = 3}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .shaft_length = 1.0, + .shaft_diameter = 0.1, + .head_length = 0.3, + .head_diameter = 0.2, + .color = {.r = 255, .g = 0, .b = 0, .a = 255}, + }); + + e.cubes.push_back( + CubePrimitive{ + .pose = {.position = {.x = 4, .y = 5, .z = 6}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 1, .y = 1, .z = 1}, + .color = {.r = 0, .g = 255, .b = 0, .a = 255}, + }); + + e.spheres.push_back( + SpherePrimitive{ + .pose = {.position = {.x = 7, .y = 8, .z = 9}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 2, .y = 2, .z = 2}, + .color = {.r = 0, .g = 0, .b = 255, .a = 255}, + }); + + e.cylinders.push_back( + CylinderPrimitive{ + .pose = {.position = {.x = 1, .y = 1, .z = 1}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 1, .y = 1, .z = 2}, + .bottom_scale = 0.5, + .top_scale = 0.8, + .color = {.r = 10, .g = 20, .b = 30, .a = 255}, + }); + + e.lines.push_back( + LinePrimitive{ + .type = LineType::kLineLoop, // non-default (kLineStrip == 0) + .pose = {.position = {.x = 0, .y = 0, .z = 0}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .thickness = 0.05, + .scale_invariant = true, + .points = {{.x = 0, .y = 0, .z = 0}, {.x = 1, .y = 0, .z = 0}, {.x = 1, .y = 1, .z = 0}}, + .color = {.r = 1, .g = 2, .b = 3, .a = 4}, + .colors = + {{.r = 5, .g = 6, .b = 7, .a = 8}, + {.r = 9, .g = 10, .b = 11, .a = 12}, + {.r = 13, .g = 14, .b = 15, .a = 16}}, + .indices = {0, 1, 2}, + }); + + e.triangles.push_back( + TrianglePrimitive{ + .pose = {.position = {.x = 2, .y = 2, .z = 2}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .points = {{.x = 0, .y = 0, .z = 0}, {.x = 1, .y = 0, .z = 0}, {.x = 0, .y = 1, .z = 0}}, + .color = {.r = 20, .g = 21, .b = 22, .a = 23}, + .colors = + {{.r = 24, .g = 25, .b = 26, .a = 27}, + {.r = 28, .g = 29, .b = 30, .a = 31}, + {.r = 32, .g = 33, .b = 34, .a = 35}}, + .indices = {0, 1, 2}, + }); + + e.texts.push_back( + TextPrimitive{ + .pose = {.position = {.x = 3, .y = 3, .z = 3}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .billboard = true, + .font_size = 12.0, + .scale_invariant = true, + .color = {.r = 40, .g = 41, .b = 42, .a = 43}, + .text = "hello scene", + }); + + e.models.push_back( + ModelPrimitive{ + .pose = {.position = {.x = 4, .y = 4, .z = 4}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .scale = {.x = 1, .y = 1, .z = 1}, + .color = {.r = 50, .g = 51, .b = 52, .a = 53}, + .override_color = true, + .url = "", + .media_type = "model/gltf-binary", + .data = {0x01, 0x02, 0x03, 0x04}, + }); + + e.axes.push_back( + AxesPrimitive{ + .pose = {.position = {.x = 5, .y = 5, .z = 5}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .length = 0.5, + .thickness = 0.02, + .scale_invariant = false, + }); + + SceneEntities entities; + entities.entities.push_back(e); + entities.deletions.push_back( + SceneEntityDeletion{ + .type = SceneEntityDeletion::Type::kAll, // non-default (kMatchingId == 0) + .timestamp = 999, + .id = "", + }); + return entities; +} + +// Packs `points` (each {x, y, z, intensity} float32) at `point_step`-byte +// stride into a fresh owned buffer via `buffer_assign()`. +PointCloud makeSamplePointCloudUnorganized() { + PointCloud cloud; + cloud.width = 3; + cloud.height = 1; + cloud.point_step = 16; + cloud.row_step = 48; // 3 * 16, no padding. + cloud.is_bigendian = false; + cloud.is_dense = true; + cloud.frame_id = "lidar"; + cloud.fields = { + PointField{.name = "x", .offset = 0, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "y", .offset = 4, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "z", .offset = 8, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "intensity", .offset = 12, .datatype = PointField::Datatype::kFloat32, .count = 1}, + }; + cloud.timestamp_ns = 1'234'567'890'123'456; + + std::vector bytes(static_cast(cloud.point_step) * cloud.width); + const float points[3][4] = { + {1.0f, 2.0f, 3.0f, 0.5f}, + {4.0f, 5.0f, 6.0f, 0.6f}, + {7.0f, 8.0f, 9.0f, 0.7f}, + }; + for (size_t i = 0; i < 3; ++i) { + std::memcpy(bytes.data() + i * cloud.point_step, points[i], sizeof(points[i])); + } + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&cloud, std::move(bytes)); + return cloud; +} + +// A 4x3 rgb8 image (36 bytes, no row padding), both optionals set even +// though they are only semantically meaningful for "compressedDepth" — this +// exercises the kOptionalNumber accessors on every populated fixture. +Image makeSampleImage() { + Image image; + image.width = 4; + image.height = 3; + image.encoding = "rgb8"; + image.row_step = 12; // 4 * 3, no padding. + image.is_bigendian = false; + image.compressed_depth_min = 0.1f; + image.compressed_depth_max = 6.4f; + image.timestamp_ns = 1'234'567'890'123'456; + image.frame_id = "camera_optical_frame"; + + std::vector bytes(static_cast(image.row_step) * image.height); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i + 1); + } + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&image, std::move(bytes)); + return image; +} + +// A 2x2 32FC1 depth image (16 bytes) with a populated K and a non-empty D. +DepthImage makeSampleDepthImage() { + DepthImage image; + image.width = 2; + image.height = 2; + image.encoding = "32FC1"; + image.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + image.distortion_model = "plumb_bob"; + image.D = {0.1, -0.2, 0.001, -0.002, 0.05}; + image.timestamp_ns = -42; + + std::vector bytes(static_cast(image.width) * image.height * 4); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i + 1); + } + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&image, std::move(bytes)); + return image; +} + +CameraInfo makeSampleCameraInfo() { + CameraInfo info; + info.timestamp_ns = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + info.frame_id = "camera_optical_frame"; + info.width = 640; + info.height = 480; + info.distortion_model = "plumb_bob"; + info.D = {0.1, -0.2, 0.001, -0.002, 0.05}; + info.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + info.R = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; + info.P = {525.0, 0.0, 319.5, 0.0, 0.0, 525.0, 239.5, 0.0, 0.0, 0.0, 1.0, 0.0}; + return info; +} + +VideoFrame makeSampleVideoFrame() { + VideoFrame frame; + frame.timestamp_ns = 1'000'000'000'000'123; // > 2^53. + frame.frame_id = "cam0"; + frame.format = "h264"; + + std::vector bytes = {0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xC0, 0x1E}; + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&frame, std::move(bytes)); + return frame; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +TEST(FieldTableTest, NamesUniqueAndKindsConsistent) { + // Explicit roots so every specialization is exercised even when a struct + // (Vector2, Pose) is not reachable from any other root. + const std::array roots{ + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + }; + + std::set visited; + std::vector tables; + for (const auto* root : roots) { + collectTables(*root, visited, tables); + } + ASSERT_EQ(tables.size(), 32u) << "expected all 32 field_table specializations added through SDK block 5.1"; + + for (const auto* table : tables) { + SCOPED_TRACE(table->type_name); + std::set names; + for (const auto& f : table->fields) { + EXPECT_FALSE(f.name.empty()); + EXPECT_TRUE(names.insert(f.name).second) << "duplicate field name: " << f.name; + + switch (f.kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.set_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.set_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.buffer, nullptr); + break; + case FieldKind::kInt64: + EXPECT_NE(f.get_int64, nullptr); + EXPECT_NE(f.set_int64, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.set_number, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kString: + EXPECT_NE(f.get_string, nullptr); + EXPECT_NE(f.set_string, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kStruct: + EXPECT_NE(f.struct_ptr, nullptr); + EXPECT_NE(f.struct_ptr_mut, nullptr); + EXPECT_NE(f.nested, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kOptionalNumber: + EXPECT_NE(f.has_value, nullptr); + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.buffer, nullptr); + break; + case FieldKind::kList: + EXPECT_NE(f.list_size, nullptr); + EXPECT_NE(f.list_at, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.buffer, nullptr); + // Exactly one of the two write-accessor pairs is set: growable + // (list_emplace/list_clear, e.g. std::vector) or fixed-size + // (list_replace, e.g. std::array) — see field_table.hpp's kList + // doc comment. + if (f.list_replace != nullptr) { + EXPECT_EQ(f.list_emplace, nullptr); + EXPECT_EQ(f.list_clear, nullptr); + } else { + EXPECT_NE(f.list_emplace, nullptr); + EXPECT_NE(f.list_clear, nullptr); + } + if (f.nested != nullptr) { + // Struct-element list: nested table set, element_kind == kStruct, + // no scalar accessor (the elements are read via `nested`, not get_*/set_*). + EXPECT_EQ(f.element_kind, FieldKind::kStruct); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + } else { + // Scalar-element list: exactly one get_*/set_* pair set, matching element_kind. + EXPECT_NE(f.element_kind, FieldKind::kStruct); + switch (f.element_kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + break; + case FieldKind::kInt64: + EXPECT_NE(f.get_int64, nullptr); + EXPECT_NE(f.set_int64, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_string, nullptr); + break; + case FieldKind::kString: + EXPECT_NE(f.get_string, nullptr); + EXPECT_NE(f.set_string, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + break; + default: + ADD_FAILURE() << "unexpected scalar element_kind"; + } + } + break; + case FieldKind::kBuffer: + EXPECT_NE(f.buffer, nullptr); + EXPECT_NE(f.buffer_assign, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.nested, nullptr); + break; + } + } + } +} + +TEST(FieldTableTest, GenericCopyMatchesCodecRoundTrip) { + { + const FrameTransforms src = makeSampleFrameTransforms(); + ASSERT_FALSE(src.transforms.empty()); + FrameTransforms dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); + } + { + const ImageAnnotations src = makeSampleImageAnnotations(); + ASSERT_FALSE(src.points.empty()); + ASSERT_FALSE(src.circles.empty()); + ASSERT_FALSE(src.texts.empty()); + ImageAnnotations dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); + } + { + const SceneEntities src = makeSampleSceneEntities(); + ASSERT_FALSE(src.entities.empty()); + ASSERT_FALSE(src.deletions.empty()); + SceneEntities dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); + } +} + +// Image has no operator== (its payload bytes only compare meaningfully +// through the canonical wire codec, same as PointCloud), so compare +// serialized bytes instead. Exercises kBuffer (data) and kOptionalNumber +// (compressed_depth_min/max, both set on the fixture). +TEST(FieldTableTest, ImageBufferAndOptionalRoundTrip) { + const Image src = makeSampleImage(); + ASSERT_FALSE(src.data.empty()); + ASSERT_TRUE(src.compressed_depth_min.has_value()); + ASSERT_TRUE(src.compressed_depth_max.has_value()); + + Image dst; + copyThroughTable(FieldTable::view, &src, &dst); + + const FieldDescriptor* min_field = findField(FieldTable::view, "compressed_depth_min"); + ASSERT_NE(min_field, nullptr); + EXPECT_EQ(min_field->kind, FieldKind::kOptionalNumber); + EXPECT_TRUE(min_field->has_value(&dst)); + EXPECT_FLOAT_EQ(static_cast(min_field->get_number(&dst)), *src.compressed_depth_min); + + expectSameWireBytes(src, dst); +} + +// An Image with both optionals absent (the common case: only +// "compressedDepth" images carry them) must round-trip as absent too — a +// freshly-constructed dst starts absent and copyThroughTable's +// kOptionalNumber case only writes when has_value(src) is true. +TEST(FieldTableTest, ImageOptionalAbsentRoundTrip) { + Image src = makeSampleImage(); + src.compressed_depth_min.reset(); + src.compressed_depth_max.reset(); + + Image dst; + copyThroughTable(FieldTable::view, &src, &dst); + + const FieldDescriptor* min_field = findField(FieldTable::view, "compressed_depth_min"); + ASSERT_NE(min_field, nullptr); + EXPECT_FALSE(min_field->has_value(&dst)); + + expectSameWireBytes(src, dst); +} + +// DepthImage has no operator== either; exercises kBuffer (data, derived +// row_step/record_step) and the fixed-size kList (K, via list_replace). +TEST(FieldTableTest, DepthImageFixedArrayAndBufferRoundTrip) { + const DepthImage src = makeSampleDepthImage(); + ASSERT_FALSE(src.data.empty()); + ASSERT_FALSE(src.D.empty()); + + const FieldDescriptor* k_field = findField(FieldTable::view, "K"); + ASSERT_NE(k_field, nullptr); + EXPECT_EQ(k_field->kind, FieldKind::kList); + EXPECT_NE(k_field->list_replace, nullptr); + EXPECT_EQ(k_field->list_emplace, nullptr); + EXPECT_EQ(k_field->list_size(&src), 9u); + + DepthImage dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst.K, src.K); + EXPECT_EQ(dst.D, src.D); + + expectSameWireBytes(src, dst); +} + +// CameraInfo has operator== (no byte blob), so both the value comparison and +// the serialized-bytes comparison apply. Exercises the fixed-size kList +// (K, R, P) alongside the ordinary growable list (D). +TEST(FieldTableTest, CameraInfoFixedArrayRoundTrip) { + const CameraInfo src = makeSampleCameraInfo(); + ASSERT_FALSE(src.D.empty()); + + const FieldDescriptor* r_field = findField(FieldTable::view, "R"); + ASSERT_NE(r_field, nullptr); + EXPECT_EQ(r_field->list_size(&src), 9u); + const FieldDescriptor* p_field = findField(FieldTable::view, "P"); + ASSERT_NE(p_field, nullptr); + EXPECT_EQ(p_field->list_size(&src), 12u); + const FieldDescriptor* d_field = findField(FieldTable::view, "D"); + ASSERT_NE(d_field, nullptr); + EXPECT_NE(d_field->list_emplace, nullptr); // D is growable (std::vector), unlike K/R/P. + EXPECT_EQ(d_field->list_replace, nullptr); + + CameraInfo dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); +} + +// VideoFrame has no operator== either; exercises kBuffer for a format with +// no static per-record size (record_step/record_count/row_step all 0). +TEST(FieldTableTest, VideoFrameBufferRoundTrip) { + const VideoFrame src = makeSampleVideoFrame(); + ASSERT_FALSE(src.data.empty()); + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + const BufferLayout layout = data_field->buffer(&src); + EXPECT_EQ(layout.record_step, 0u); + EXPECT_EQ(layout.record_count, 0u); + EXPECT_EQ(layout.row_step, 0u); + ASSERT_EQ(layout.channels.size(), 1u); + EXPECT_EQ(layout.channels[0].name, "h264"); + + VideoFrame dst; + copyThroughTable(FieldTable::view, &src, &dst); + + expectSameWireBytes(src, dst); +} + +// The buffer descriptor's layout for a raw, uncompressed encoding: a 4x3 +// rgb8 image (3 bytes/pixel, no row padding). +TEST(FieldTableTest, ImageRgb8BufferLayout) { + const Image image = makeSampleImage(); + ASSERT_EQ(image.width, 4u); + ASSERT_EQ(image.height, 3u); + ASSERT_EQ(image.encoding, "rgb8"); + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + const BufferLayout layout = data_field->buffer(&image); + EXPECT_EQ(layout.record_step, 3u); // 3 bytes/pixel. + EXPECT_EQ(layout.record_count, 12u); // 4 * 3 pixels. + EXPECT_EQ(layout.row_step, 12u); // 4 pixels * 3 bytes, no padding. + EXPECT_FALSE(layout.is_bigendian); + ASSERT_EQ(layout.channels.size(), 1u); + EXPECT_EQ(layout.channels[0].name, "rgb8"); + EXPECT_EQ(layout.channels[0].count, 3u); +} + +// A compressed encoding has no static per-pixel size: record_step and +// record_count are 0, but the encoding string still comes through the sole +// channel's name, and the full compressed payload is still in `bytes`. +TEST(FieldTableTest, ImageCompressedBufferLayoutHasNoStaticRecordSize) { + Image image = makeSampleImage(); + image.encoding = "jpeg"; + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + const BufferLayout layout = data_field->buffer(&image); + EXPECT_EQ(layout.record_step, 0u); + EXPECT_EQ(layout.record_count, 0u); + EXPECT_FALSE(layout.bytes.empty()); + ASSERT_EQ(layout.channels.size(), 1u); + EXPECT_EQ(layout.channels[0].name, "jpeg"); +} + +TEST(FieldTableTest, Int64FieldsAreNotNumbers) { + constexpr int64_t kBig = (int64_t{1} << 55) + 12345; // > 2^53: exact only as int64. + + const FieldDescriptor* ft_timestamp = findField(FieldTable::view, "timestamp"); + ASSERT_NE(ft_timestamp, nullptr); + EXPECT_EQ(ft_timestamp->kind, FieldKind::kInt64); + EXPECT_EQ(ft_timestamp->get_number, nullptr); + EXPECT_EQ(ft_timestamp->set_number, nullptr); + ASSERT_NE(ft_timestamp->get_int64, nullptr); + ASSERT_NE(ft_timestamp->set_int64, nullptr); + + FrameTransform ft; + ft_timestamp->set_int64(&ft, kBig); + EXPECT_EQ(ft.timestamp, kBig); + EXPECT_EQ(ft_timestamp->get_int64(&ft), kBig); + + const FieldDescriptor* ia_timestamp = findField(FieldTable::view, "timestamp"); + ASSERT_NE(ia_timestamp, nullptr); + EXPECT_EQ(ia_timestamp->kind, FieldKind::kInt64); + EXPECT_EQ(ia_timestamp->get_number, nullptr); + EXPECT_EQ(ia_timestamp->set_number, nullptr); + + ImageAnnotations ia; + ia_timestamp->set_int64(&ia, kBig); + EXPECT_EQ(ia.timestamp, kBig); + EXPECT_EQ(ia_timestamp->get_int64(&ia), kBig); +} + +TEST(FieldTableTest, PointCloudBufferRoundTrip) { + const PointCloud src = makeSamplePointCloudUnorganized(); + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + EXPECT_EQ(data_field->kind, FieldKind::kBuffer); + ASSERT_NE(data_field->buffer, nullptr); + ASSERT_NE(data_field->buffer_assign, nullptr); + + const BufferLayout layout = data_field->buffer(&src); + EXPECT_EQ(layout.record_step, 16u); + EXPECT_EQ(layout.record_count, 3u); + EXPECT_EQ(layout.row_step, 48u); + EXPECT_FALSE(layout.is_bigendian); + ASSERT_EQ(layout.channels.size(), 4u); + EXPECT_EQ(layout.channels[0].name, "x"); + EXPECT_EQ(layout.channels[0].offset, 0u); + EXPECT_EQ(layout.channels[0].datatype, static_cast(PointField::Datatype::kFloat32)); + EXPECT_EQ(layout.channels[0].count, 1u); + EXPECT_EQ(layout.channels[1].name, "y"); + EXPECT_EQ(layout.channels[1].offset, 4u); + EXPECT_EQ(layout.channels[2].name, "z"); + EXPECT_EQ(layout.channels[2].offset, 8u); + EXPECT_EQ(layout.channels[3].name, "intensity"); + EXPECT_EQ(layout.channels[3].offset, 12u); + + // PointCloud has no operator== (payload bytes only compare meaningfully + // through the canonical wire codec), so compare serialized bytes instead. + PointCloud dst; + copyThroughTable(FieldTable::view, &src, &dst); + + expectSameWireBytes(src, dst); +} + +TEST(FieldTableTest, PointCloudOrganizedBufferLayout) { + PointCloud cloud; + cloud.width = 2; + cloud.height = 2; + cloud.point_step = 16; + cloud.row_step = 40; // 2 * 16 + 8 bytes padding. + cloud.frame_id = "camera_depth"; + cloud.fields = { + PointField{.name = "x", .offset = 0, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "y", .offset = 4, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "z", .offset = 8, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "intensity", .offset = 12, .datatype = PointField::Datatype::kFloat32, .count = 1}, + }; + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + std::vector bytes(static_cast(cloud.row_step) * cloud.height, 0); + data_field->buffer_assign(&cloud, std::move(bytes)); + + const BufferLayout layout = data_field->buffer(&cloud); + EXPECT_EQ(layout.record_step, 16u); + EXPECT_EQ(layout.record_count, 4u); // width * height, not row-count via row_step. + EXPECT_EQ(layout.row_step, 40u); +} + +// Size fields that declare more records than `bytes` holds must not be +// promised: record_count falls back to 0 so a consumer never reads past the +// span. +TEST(FieldTableTest, PointCloudBufferLayoutClampsToBytes) { + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + + PointCloud wider = makeSamplePointCloudUnorganized(); // 3 points, 48 bytes. + wider.width = 4; + EXPECT_EQ(data_field->buffer(&wider).record_count, 0u); + + PointCloud taller = makeSamplePointCloudUnorganized(); + taller.height = 2; + EXPECT_EQ(data_field->buffer(&taller).record_count, 0u); + + // The last row needs no trailing padding: 40 + 2 * 16 = 72 bytes hold a 2x2 cloud with row_step 40. + PointCloud padded; + padded.width = 2; + padded.height = 2; + padded.point_step = 16; + padded.row_step = 40; + data_field->buffer_assign(&padded, std::vector(72, 0)); + EXPECT_EQ(data_field->buffer(&padded).record_count, 4u); + data_field->buffer_assign(&padded, std::vector(71, 0)); + EXPECT_EQ(data_field->buffer(&padded).record_count, 0u); + + // A declared extent that overflows 64 bits is not satisfiable either. + PointCloud huge = makeSamplePointCloudUnorganized(); + huge.width = UINT32_MAX; + huge.height = UINT32_MAX; + huge.point_step = UINT32_MAX; + huge.row_step = UINT32_MAX; + EXPECT_EQ(data_field->buffer(&huge).record_count, 0u); +} + +TEST(FieldTableTest, ImageBufferLayoutClampsToBytes) { + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + + Image taller = makeSampleImage(); // 4x3 rgb8, 36 bytes. + taller.height = 4; + EXPECT_EQ(data_field->buffer(&taller).record_count, 0u); + + Image padded = makeSampleImage(); + padded.row_step = 16; // 2 * 16 + 12 = 44 bytes needed. + EXPECT_EQ(data_field->buffer(&padded).record_count, 0u); + + const Image consistent = makeSampleImage(); + EXPECT_EQ(data_field->buffer(&consistent).record_count, 12u); +} + +TEST(FieldTableTest, DepthImageBufferLayoutClampsToBytes) { + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + + DepthImage wider = makeSampleDepthImage(); // 2x2 32FC1, 16 bytes. + wider.width = 3; + EXPECT_EQ(data_field->buffer(&wider).record_count, 0u); + + const DepthImage consistent = makeSampleDepthImage(); + EXPECT_EQ(data_field->buffer(&consistent).record_count, 4u); +} + +// set_number never performs an undefined float-to-integer conversion: NaN +// reads back 0 and out-of-range values clamp to the target type's limits. +TEST(FieldTableTest, SetNumberSaturatesIntegerTargets) { + const FieldDescriptor* red = findField(FieldTable::view, "r"); + ASSERT_NE(red, nullptr); + ColorRGBA color; + red->set_number(&color, 300.0); + EXPECT_EQ(color.r, 255); + red->set_number(&color, -5.0); + EXPECT_EQ(color.r, 0); + red->set_number(&color, 200.7); + EXPECT_EQ(color.r, 200); + color.r = 9; + red->set_number(&color, std::numeric_limits::quiet_NaN()); + EXPECT_EQ(color.r, 0); + + const FieldDescriptor* width = findField(FieldTable::view, "width"); + ASSERT_NE(width, nullptr); + Image image; + width->set_number(&image, -1.0); + EXPECT_EQ(image.width, 0u); + width->set_number(&image, 1e20); + EXPECT_EQ(image.width, UINT32_MAX); + width->set_number(&image, -std::numeric_limits::infinity()); + EXPECT_EQ(image.width, 0u); +} + +TEST(FieldTableTest, SetNumberSaturatesEnumUnderlyingRange) { + const FieldDescriptor* datatype = findField(FieldTable::view, "datatype"); + ASSERT_NE(datatype, nullptr); + ASSERT_EQ(datatype->kind, FieldKind::kEnum); + PointField field; + datatype->set_number(&field, 300.0); + EXPECT_EQ(static_cast(field.datatype), 255); + datatype->set_number(&field, -1.0); + EXPECT_EQ(static_cast(field.datatype), 0); +} + +// A float target keeps its plain conversion: no clamping to an integer range. +TEST(FieldTableTest, SetNumberKeepsFloatTargets) { + const FieldDescriptor* depth_min = findField(FieldTable::view, "compressed_depth_min"); + ASSERT_NE(depth_min, nullptr); + Image image; + depth_min->set_number(&image, 1e20); + ASSERT_TRUE(image.compressed_depth_min.has_value()); + EXPECT_FLOAT_EQ(*image.compressed_depth_min, 1e20f); +} + +TEST(FieldTableTest, DescribeCoversExactlyTheTabledTypes) { + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kFrameTransforms), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kImageAnnotations), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kPointCloud), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kSceneEntities), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kImage), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kDepthImage), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kCameraInfo), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kVideoFrame), nullptr); + + // Every other stable BuiltinObjectType value (mirrors the array in + // builtin_object_codec_test.cpp) must resolve to nullptr. + const std::array other_types{ + BuiltinObjectType::kOccupancyGrid, + BuiltinObjectType::kCompressedPointCloud, + BuiltinObjectType::kMesh3D, + BuiltinObjectType::kRobotDescription, + BuiltinObjectType::kOccupancyGridUpdate, + BuiltinObjectType::kLog, + BuiltinObjectType::kPosesInFrame, + BuiltinObjectType::kVoxelGrid, + BuiltinObjectType::kGridMap, + BuiltinObjectType::kPlotMarkers, + }; + for (auto type : other_types) { + EXPECT_EQ(PJ::sdk::describe(type), nullptr) << PJ::sdk::name(type); + } + EXPECT_EQ(PJ::sdk::describe(BuiltinObjectType::kNone), nullptr); +} + +} // namespace diff --git a/pj_base/tests/image_annotations_codec_test.cpp b/pj_base/tests/image_annotations_codec_test.cpp index 88d68b6c..76d7f345 100644 --- a/pj_base/tests/image_annotations_codec_test.cpp +++ b/pj_base/tests/image_annotations_codec_test.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include "protobuf_wire_test_helpers.hpp" @@ -29,7 +30,7 @@ sdk::ImageAnnotations roundTrip(const sdk::ImageAnnotations& input) { auto bytes = serializeImageAnnotations(input); auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); EXPECT_TRUE(result.has_value()); - return *result; + return result.value(); // throws (a clean test failure) instead of reading an empty result } // Compare two ColorRGBA values allowing 1-LSB drift on each channel from the @@ -296,5 +297,120 @@ TEST(ImageAnnotationCodecTest, RoundTrip_PerVertexColors) { EXPECT_TRUE(colorEq(in.points[0].colors[2], out.points[0].colors[2])); } +// ----------------------------------------------------------------------------- +// 4. Top-level timestamp / image_topic (wire fields 5, 6). +// ----------------------------------------------------------------------------- + +TEST(ImageAnnotationCodecTest, RoundTrip_TimestampAndImageTopic) { + sdk::ImageAnnotations in; + in.timestamp = 5'250'000'000; // 5s, 250000000ns + in.image_topic = "/camera/image_raw"; + + CircleAnnotation ca; + ca.center = {1.0, 2.0}; + ca.radius = 3.0; + ca.thickness = 1.0; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {0, 0, 0, 0}; + in.circles.push_back(std::move(ca)); + + auto out = roundTrip(in); + EXPECT_EQ(out.timestamp, in.timestamp); + EXPECT_EQ(out.image_topic, in.image_topic); + ASSERT_EQ(out.circles.size(), 1u); + EXPECT_DOUBLE_EQ(out.circles[0].radius, 3.0); +} + +TEST(ImageAnnotationCodecTest, TimestampZeroAndEmptyTopicEmitNothing) { + // A default-constructed timestamp (0) and image_topic ("") must not emit + // fields 5/6, even when other annotation content is present. + sdk::ImageAnnotations with_defaults; + CircleAnnotation ca; + ca.center = {5.0, 6.0}; + ca.radius = 1.0; + ca.thickness = 1.0; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {0, 0, 0, 0}; + with_defaults.circles.push_back(ca); + + sdk::ImageAnnotations explicit_zero = with_defaults; + explicit_zero.timestamp = 0; + explicit_zero.image_topic.clear(); + + EXPECT_EQ(serializeImageAnnotations(with_defaults), serializeImageAnnotations(explicit_zero)); +} + +TEST(ImageAnnotationCodecTest, OldPayloadWithoutTheFieldsDecodesToDefaults) { + // A payload with only a circle annotation (as produced before fields 5/6 + // existed, and as still produced today when timestamp/image_topic are + // unset) must decode to Timestamp{0} and an empty image_topic. + sdk::ImageAnnotations in; + CircleAnnotation ca; + ca.center = {7.0, 8.0}; + ca.radius = 2.0; + ca.thickness = 1.0; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {0, 0, 0, 0}; + in.circles.push_back(std::move(ca)); + + auto bytes = serializeImageAnnotations(in); + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->timestamp, 0); + EXPECT_TRUE(result->image_topic.empty()); +} + +TEST(ImageAnnotationCodecTest, GoldenBytes_TimestampAndImageTopic) { + sdk::ImageAnnotations ia; + ia.timestamp = 5'250'000'000; // 5s, 250000000ns + ia.image_topic = "/camera/image_raw"; + + // Field 5: Timestamp submessage (seconds=5, nanos=250000000). + std::vector expected; + pb::appendTag(expected, 5, 2); + pb::appendLenDelim(expected, pb::encodeTimestamp(ia.timestamp)); + + // Field 6: image_topic string. + pb::appendTag(expected, 6, 2); + pb::appendString(expected, "/camera/image_raw"); + + auto actual = serializeImageAnnotations(ia); + EXPECT_EQ(actual, expected) << "wire format mismatch"; +} + +TEST(ImageAnnotationCodecTest, RoundTrip_TimestampExtremes) { + // INT64_MIN is not a whole number of seconds: the encoder floors it to seconds + // -9223372037 with positive nanos, which the decoder must accept again. + for (const Timestamp timestamp : + {std::numeric_limits::min(), std::numeric_limits::min() + 1, + std::numeric_limits::max(), std::numeric_limits::max() - 1, Timestamp{-1}, + Timestamp{-1'000'000'000}}) { + sdk::ImageAnnotations in; + in.timestamp = timestamp; + EXPECT_EQ(roundTrip(in).timestamp, timestamp) << timestamp; + } +} + +TEST(ImageAnnotationCodecTest, TimestampOutsideInt64NanosIsRejected) { + // Hand-built field 5 (Timestamp{seconds, nanos}); INT64_MIN is (-9223372037, 145224192). + const auto decodes = [](int64_t seconds, uint32_t nanos) { + std::vector body; + pb::appendTag(body, 1, 0); + pb::appendVarint(body, static_cast(seconds)); + pb::appendTag(body, 2, 0); + pb::appendVarint(body, nanos); + std::vector bytes; + pb::appendTag(bytes, 5, 2); + pb::appendLenDelim(bytes, body); + return deserializeImageAnnotations(bytes.data(), bytes.size()).has_value(); + }; + EXPECT_TRUE(decodes(-9223372037, 145224192)); // INT64_MIN + EXPECT_FALSE(decodes(-9223372037, 145224191)); // INT64_MIN - 1 + EXPECT_FALSE(decodes(-9223372038, 999999999)); // whole second below the range + EXPECT_TRUE(decodes(9223372036, 854775807)); // INT64_MAX + EXPECT_FALSE(decodes(9223372036, 854775808)); // INT64_MAX + 1 + EXPECT_FALSE(decodes(9223372037, 0)); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/object_topic_metadata_test.cpp b/pj_base/tests/object_topic_metadata_test.cpp index 92ce68a2..0c0bbda1 100644 --- a/pj_base/tests/object_topic_metadata_test.cpp +++ b/pj_base/tests/object_topic_metadata_test.cpp @@ -202,6 +202,45 @@ TEST(ObjectTopicMetadataBuilderTest, CanonicalKeyCannotBeInsertedAsCustomMetadat #endif } +TEST(ObjectTopicMetadataBuilderTest, SnapshotFlagRoundTrips) { + // snapshot(true) sets the canonical key. + const auto snapshot_true = + ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kSceneEntities).snapshot().build(); + ASSERT_TRUE(snapshot_true); + EXPECT_EQ(*snapshot_true, R"({"builtin_object_type":"kSceneEntities","pj_snapshot":"true"})"); + + // snapshot(false) leaves the key unset (incremental, the default), also after a snapshot(true). + const auto snapshot_false = + ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kImageAnnotations).snapshot(false).build(); + ASSERT_TRUE(snapshot_false); + EXPECT_EQ(*snapshot_false, R"({"builtin_object_type":"kImageAnnotations"})"); + const auto snapshot_reset = ObjectTopicMetadataBuilder() + .builtinObjectType(BuiltinObjectType::kImageAnnotations) + .snapshot() + .snapshot(false) + .build(); + ASSERT_TRUE(snapshot_reset); + EXPECT_EQ(*snapshot_reset, R"({"builtin_object_type":"kImageAnnotations"})"); + + // Omitting snapshot() entirely leaves the key unset too. + const auto no_snapshot = ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kImageAnnotations).build(); + ASSERT_TRUE(no_snapshot); + EXPECT_EQ(*no_snapshot, *snapshot_false); + + EXPECT_EQ(kSnapshotMetadataKey, "pj_snapshot"); +} + +TEST(ObjectTopicMetadataBuilderTest, DerivedKeyIsStableAndRoundTripsAsACustomString) { + EXPECT_EQ(kDerivedMetadataKey, "pj_derived"); + EXPECT_EQ(kDerivedOnDemandValue, "on_demand"); + const auto built = ObjectTopicMetadataBuilder() + .builtinObjectType(BuiltinObjectType::kPointCloud) + .string(kDerivedMetadataKey, kDerivedOnDemandValue) + .build(); + ASSERT_TRUE(built); + EXPECT_EQ(*built, R"({"builtin_object_type":"kPointCloud","pj_derived":"on_demand"})"); +} + TEST(ObjectTopicMetadataRegistrationTest, SourceTypedOverloadForwardsBuiltJson) { RegistrationRecorder recorder; const PJ_object_write_host_vtable_t vtable = {