diff --git a/CLAUDE.md b/CLAUDE.md index df2653b..09a459d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,13 @@ PlotJuggler Core — C++20 foundation libraries for PlotJuggler storage, plugin - **pj_base** — vocabulary types, plugin ABI headers, plugin SDK headers (zero external deps) - **pj_datastore** — columnar storage engine + `ObjectStore` (for media blobs) + `DerivedEngine` (fmt, tsl::robin_map, nanoarrow) - **pj_plugins** — C-ABI plugin protocol, C++ SDK base classes, plugin discovery, host-side loaders, config envelope helpers, and dialog protocol primitives; four plugin families: DataSource, MessageParser, Dialog, Toolbox +- **pj_scene_protocol** — canonical schema + Foxglove `ImageAnnotations` Protobuf codec (writer + reader); SDK boundary for plugin authors producing or consuming 2D markers / scene primitives. `pj_base`-only deps. ### Dependency graph - `pj_datastore` → `pj_base` - `pj_plugins` → `pj_base` +- `pj_scene_protocol` → `pj_base` ## Key Documentation @@ -44,6 +46,13 @@ PlotJuggler Core — C++20 foundation libraries for PlotJuggler storage, plugin | `dialog-plugin-guide.md` | SDK tutorial: WidgetData, typed events, EmbedUi, requestAccept, onTick | | `toolbox-guide.md` | SDK tutorial: read+write access, catalog, notifyDataChanged | +**Scene protocol** (`pj_scene_protocol/docs/`): + +| Document | Content | +|----------|---------| +| `ARCHITECTURE.md` | Wire format spec (`foxglove.ImageAnnotations` Protobuf), type catalog, encoding rules, design rationale (single canonical decoder, loader-side conversion) | +| `USER_GUIDE.md` | Producer recipe (loader writing markers) and consumer recipe (sink/viewer decoding), common pitfalls, pointer to PJ4 reference adapters | + ## Build & Test ```bash @@ -64,7 +73,7 @@ Before committing, always run: ## Instructions Glossary -- **"Read all documentation"** means: find and read every `.md` file in the entire project tree (all subdirectories). Use `find . -name "*.md"` or equivalent. This includes docs in `pj_base/`, `pj_datastore/docs/`, `pj_plugins/docs/`, and any other location. +- **"Read all documentation"** means: find and read every `.md` file in the entire project tree (all subdirectories). Use `find . -name "*.md"` or equivalent. This includes docs in `pj_base/`, `pj_datastore/docs/`, `pj_plugins/docs/`, `pj_scene_protocol/docs/`, and any other location. - **"Update the documentation"** means: based on what you learned during this session, correct any documentation that is outdated or inaccurate, and clarify any ambiguity that caused confusion or errors. If a doc says one thing but the code does another, fix the doc to match reality. If missing information led to a bug, add it. diff --git a/CMakeLists.txt b/CMakeLists.txt index f69d921..61c4015 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,6 +159,7 @@ if(PJ_BUILD_DATASTORE) add_subdirectory(pj_datastore) endif() add_subdirectory(pj_plugins) +add_subdirectory(pj_scene_protocol) if(PJ_BUILD_PORTED_PLUGINS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/pj_ported_plugins/CMakeLists.txt") set(PJ_HAS_PORTED_PLUGINS TRUE) diff --git a/pj_base/include/pj_base/plugin_abi_export.h b/pj_base/include/pj_base/plugin_abi_export.h index 59fdfea..cbcccdd 100644 --- a/pj_base/include/pj_base/plugin_abi_export.h +++ b/pj_base/include/pj_base/plugin_abi_export.h @@ -31,8 +31,7 @@ #if defined(_MSC_VER) #define PJ_PLUGIN_ABI_LINK __declspec(dllexport) __declspec(selectany) #else -#define PJ_PLUGIN_ABI_LINK \ - __attribute__((visibility("default"))) __attribute__((weak)) __attribute__((used)) +#define PJ_PLUGIN_ABI_LINK __attribute__((visibility("default"))) __attribute__((weak)) __attribute__((used)) #endif extern "C" { diff --git a/pj_base/include/pj_base/sdk/platform.hpp b/pj_base/include/pj_base/sdk/platform.hpp index 7b48e88..48d28fb 100644 --- a/pj_base/include/pj_base/sdk/platform.hpp +++ b/pj_base/include/pj_base/sdk/platform.hpp @@ -19,15 +19,15 @@ #include #if defined(__linux__) || defined(__APPLE__) -# include +#include #elif defined(_WIN32) -# ifndef WIN32_LEAN_AND_MEAN -# define WIN32_LEAN_AND_MEAN -# endif -# ifndef NOMINMAX -# define NOMINMAX -# endif -# include +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include #endif namespace PJ::sdk { @@ -39,12 +39,12 @@ namespace PJ::sdk { /// without forcing _CRT_SECURE_NO_WARNINGS project-wide. inline std::optional getEnv(const char* name) { #if defined(_MSC_VER) -# pragma warning(push) -# pragma warning(disable : 4996) +#pragma warning(push) +#pragma warning(disable : 4996) #endif const char* value = std::getenv(name); #if defined(_MSC_VER) -# pragma warning(pop) +#pragma warning(pop) #endif if (value == nullptr || *value == '\0') { return std::nullopt; @@ -109,9 +109,9 @@ inline std::filesystem::path getSharedLibDir(const void* fn_addr) { #elif defined(_WIN32) wchar_t buf[MAX_PATH] = {}; HMODULE hm = nullptr; - if (::GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | - GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, - reinterpret_cast(fn_addr), &hm)) { + if (::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(fn_addr), &hm)) { ::GetModuleFileNameW(hm, buf, MAX_PATH); return fs::path(buf).parent_path(); } diff --git a/pj_base/tests/platform_test.cpp b/pj_base/tests/platform_test.cpp index b76a434..7e9f8d3 100644 --- a/pj_base/tests/platform_test.cpp +++ b/pj_base/tests/platform_test.cpp @@ -92,8 +92,7 @@ TEST(UserDataDirTest, PrefersLocalAppDataOnWindows) { TEST(UserDataDirTest, UsesApplicationSupportOnMac) { ScopedEnv guard("HOME", "/tmp/pj_test_home"); auto dir = userDataDir(); - EXPECT_EQ(dir, - std::filesystem::path("/tmp/pj_test_home") / "Library" / "Application Support" / "plotjuggler"); + EXPECT_EQ(dir, std::filesystem::path("/tmp/pj_test_home") / "Library" / "Application Support" / "plotjuggler"); } #else TEST(UserDataDirTest, PrefersXdgDataHomeOnLinux) { @@ -107,8 +106,7 @@ TEST(UserDataDirTest, FallsBackToHomeLocalShareOnLinux) { ::unsetenv("XDG_DATA_HOME"); ScopedEnv guard("HOME", "/tmp/pj_test_home"); auto dir = userDataDir(); - EXPECT_EQ(dir, - std::filesystem::path("/tmp/pj_test_home") / ".local" / "share" / "plotjuggler"); + EXPECT_EQ(dir, std::filesystem::path("/tmp/pj_test_home") / ".local" / "share" / "plotjuggler"); } #endif diff --git a/pj_datastore/include/pj_datastore/colormap_registry.hpp b/pj_datastore/include/pj_datastore/colormap_registry.hpp index ca24e3d..e9c4169 100644 --- a/pj_datastore/include/pj_datastore/colormap_registry.hpp +++ b/pj_datastore/include/pj_datastore/colormap_registry.hpp @@ -48,7 +48,9 @@ class ColorMapRegistry { [[nodiscard]] bool hasActive() const; /// Name of the currently active colormap, or empty string when none. - [[nodiscard]] const std::string& activeName() const { return active_; } + [[nodiscard]] const std::string& activeName() const { + return active_; + } private: struct Entry { diff --git a/pj_datastore/include/pj_datastore/query.hpp b/pj_datastore/include/pj_datastore/query.hpp index 82279e4..b775ea6 100644 --- a/pj_datastore/include/pj_datastore/query.hpp +++ b/pj_datastore/include/pj_datastore/query.hpp @@ -108,8 +108,7 @@ class RangeCursor { class SeriesCursor { public: /// Construct cursor over [time_range.min, time_range.max] from committed chunks. - SeriesCursor( - const std::deque& chunks, std::size_t column_index, PJ::Range time_range); + SeriesCursor(const std::deque& chunks, std::size_t column_index, PJ::Range time_range); [[nodiscard]] bool valid() const noexcept; diff --git a/pj_datastore/src/query.cpp b/pj_datastore/src/query.cpp index 77e7eba..4e99eaf 100644 --- a/pj_datastore/src/query.cpp +++ b/pj_datastore/src/query.cpp @@ -21,7 +21,8 @@ namespace { chunk.columns[column_index].descriptor->logical_type == PrimitiveType::kBool; } -[[nodiscard]] std::optional readSeriesValue(const TopicChunk& chunk, std::size_t column_index, std::size_t row) { +[[nodiscard]] std::optional readSeriesValue( + const TopicChunk& chunk, std::size_t column_index, std::size_t row) { if (column_index >= chunk.columns.size() || row >= chunk.stats.row_count || chunk.isNull(column_index, row)) { return std::nullopt; } @@ -205,8 +206,7 @@ RangeCursor rangeQuery(const std::deque& chunks, Timestamp t_min, Ti // SeriesCursor // =========================================================================== -SeriesCursor::SeriesCursor( - const std::deque& chunks, std::size_t column_index, Range time_range) +SeriesCursor::SeriesCursor(const std::deque& chunks, std::size_t column_index, Range time_range) : chunks_(&chunks), column_index_(column_index), time_range_(normalized(time_range)) { skipToSample(); } @@ -237,8 +237,7 @@ void SeriesCursor::skipToSample() { while (chunk_index_ < chunks_->size()) { const auto& chunk = (*chunks_)[chunk_index_]; - if (chunk.stats.row_count == 0 || chunk.stats.t_max < time_range_.min || - column_index_ >= chunk.columns.size()) { + if (chunk.stats.row_count == 0 || chunk.stats.t_max < time_range_.min || column_index_ >= chunk.columns.size()) { ++chunk_index_; row_index_ = 0; continue; diff --git a/pj_datastore/tests/series_reader_test.cpp b/pj_datastore/tests/series_reader_test.cpp index d3e385b..15b5346 100644 --- a/pj_datastore/tests/series_reader_test.cpp +++ b/pj_datastore/tests/series_reader_test.cpp @@ -1,5 +1,3 @@ -#include "pj_datastore/reader.hpp" - #include #include @@ -8,6 +6,7 @@ #include "pj_base/type_tree.hpp" #include "pj_datastore/engine.hpp" +#include "pj_datastore/reader.hpp" #include "pj_datastore/writer.hpp" namespace PJ { @@ -22,16 +21,14 @@ class SeriesReaderTest : public ::testing::Test { DataWriter writer = engine_.createWriter(); auto schema_or = writer.registerSchema( - "row", - makeStruct( - "row", - { - makePrimitive("dense", PrimitiveType::kFloat64), - makePrimitive("sparse", PrimitiveType::kFloat64), - makePrimitive("text", PrimitiveType::kString), - makePrimitive("flag", PrimitiveType::kBool), - makePrimitive("all_null", PrimitiveType::kFloat64), - })); + "row", makeStruct( + "row", { + makePrimitive("dense", PrimitiveType::kFloat64), + makePrimitive("sparse", PrimitiveType::kFloat64), + makePrimitive("text", PrimitiveType::kString), + makePrimitive("flag", PrimitiveType::kBool), + makePrimitive("all_null", PrimitiveType::kFloat64), + })); ASSERT_TRUE(schema_or.has_value()) << schema_or.error(); TopicDescriptor descriptor; diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp index dbe1f11..c0841cc 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp @@ -143,7 +143,7 @@ class WidgetDataView { struct ChartSeriesView { std::string label; std::vector> points; // {x, y} - std::string color; // optional hex "#rrggbb"; empty means use chart theme default + std::string color; // optional hex "#rrggbb"; empty means use chart theme default }; [[nodiscard]] std::optional> chartSeries(std::string_view name) const { @@ -269,7 +269,9 @@ class WidgetDataView { /// Return all widget names that declare drop_target: true. [[nodiscard]] std::vector dropTargets() const { std::vector result; - if (!data_.is_object()) return result; + if (!data_.is_object()) { + return result; + } for (const auto& [key, val] : data_.items()) { if (val.is_object()) { auto it = val.find("drop_target"); diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp index 509405c..658c745 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp @@ -69,8 +69,8 @@ class DialogPluginTyped : public DialogPluginBase { /// ChartPreviewWidget: zoom or pan changed the visible range. /// Only called when the plugin has declared setChartZoomEnabled for this widget. - virtual bool onChartViewChanged(std::string_view /*widget_name*/, double /*x_min*/, double /*x_max*/, - double /*y_min*/, double /*y_max*/) { + virtual bool onChartViewChanged( + std::string_view /*widget_name*/, double /*x_min*/, double /*x_max*/, double /*y_min*/, double /*y_max*/) { return false; } diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/encoding_utils.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/encoding_utils.hpp index b856172..ffef41f 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/encoding_utils.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/encoding_utils.hpp @@ -8,7 +8,6 @@ #pragma once #include - #include #include #include diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp index ce46870..1596aca 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp @@ -128,8 +128,8 @@ class WidgetEvent { auto xmax = data_.find("chart_x_max"); auto ymin = data_.find("chart_y_min"); auto ymax = data_.find("chart_y_max"); - if (xmin == data_.end() || !xmin->is_number() || xmax == data_.end() || !xmax->is_number() || - ymin == data_.end() || !ymin->is_number() || ymax == data_.end() || !ymax->is_number()) { + if (xmin == data_.end() || !xmin->is_number() || xmax == data_.end() || !xmax->is_number() || ymin == data_.end() || + !ymin->is_number() || ymax == data_.end() || !ymax->is_number()) { return std::nullopt; } return ChartViewState{xmin->get(), xmax->get(), ymin->get(), ymax->get()}; diff --git a/pj_plugins/examples/mock_json_parser.cpp b/pj_plugins/examples/mock_json_parser.cpp index 4fd2afb..84e0e60 100644 --- a/pj_plugins/examples/mock_json_parser.cpp +++ b/pj_plugins/examples/mock_json_parser.cpp @@ -23,5 +23,4 @@ class MockJsonParser : public PJ::MessageParserPluginBase { } // namespace PJ_MESSAGE_PARSER_PLUGIN( - MockJsonParser, - R"({"id":"mock-json-parser","name":"Mock JSON Parser","version":"1.0.0","encoding":["json"]})") + MockJsonParser, R"({"id":"mock-json-parser","name":"Mock JSON Parser","version":"1.0.0","encoding":["json"]})") diff --git a/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp index ca5d03f..4644dc3 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp @@ -68,13 +68,19 @@ class PluginRuntimeCatalog { [[nodiscard]] bool reload(); // Returns loaded DataSource plugins. - [[nodiscard]] const std::vector& dataSources() const { return data_sources_; } + [[nodiscard]] const std::vector& dataSources() const { + return data_sources_; + } // Returns loaded MessageParser plugins. - [[nodiscard]] const std::vector& messageParsers() const { return message_parsers_; } + [[nodiscard]] const std::vector& messageParsers() const { + return message_parsers_; + } // Returns loaded Toolbox plugins. - [[nodiscard]] const std::vector& toolboxes() const { return toolbox_plugins_; } + [[nodiscard]] const std::vector& toolboxes() const { + return toolbox_plugins_; + } // Returns file-import capable DataSource plugins. [[nodiscard]] std::vector fileImportSources(); diff --git a/pj_plugins/src/plugin_runtime_catalog.cpp b/pj_plugins/src/plugin_runtime_catalog.cpp index 03161c2..07d6a50 100644 --- a/pj_plugins/src/plugin_runtime_catalog.cpp +++ b/pj_plugins/src/plugin_runtime_catalog.cpp @@ -57,9 +57,7 @@ std::vector constPtrs(const std::vector& plugins, uint6 PluginRuntimeCatalog::PluginRuntimeCatalog( std::filesystem::path plugin_dir, DiagnosticSink sink, std::string diagnostic_source) - : plugin_dir_(std::move(plugin_dir)), - sink_(std::move(sink)), - diagnostic_source_(std::move(diagnostic_source)) {} + : plugin_dir_(std::move(plugin_dir)), sink_(std::move(sink)), diagnostic_source_(std::move(diagnostic_source)) {} void PluginRuntimeCatalog::setPluginDir(std::filesystem::path plugin_dir) { plugin_dir_ = std::move(plugin_dir); @@ -85,8 +83,7 @@ void PluginRuntimeCatalog::scanDirectory() { if (!loadAndRegister(descriptor)) { report( DiagnosticLevel::kError, descriptor.id, - descriptor.dso_path.string() + ": failed to load " + std::string(toString(descriptor.family)) + - " plugin"); + descriptor.dso_path.string() + ": failed to load " + std::string(toString(descriptor.family)) + " plugin"); } } } @@ -143,8 +140,7 @@ bool PluginRuntimeCatalog::reload() { } else { report( DiagnosticLevel::kError, descriptor.id, - descriptor.dso_path.string() + ": failed to load " + std::string(toString(descriptor.family)) + - " plugin"); + descriptor.dso_path.string() + ": failed to load " + std::string(toString(descriptor.family)) + " plugin"); } } @@ -357,8 +353,7 @@ std::string PluginRuntimeCatalog::buildFileFilter() const { std::string all_exts; std::string per_plugin; for (const auto& source : data_sources_) { - if ((source.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT) == 0 || - source.file_extensions.empty()) { + if ((source.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT) == 0 || source.file_extensions.empty()) { continue; } diff --git a/pj_scene_protocol/CMakeLists.txt b/pj_scene_protocol/CMakeLists.txt new file mode 100644 index 0000000..85a1275 --- /dev/null +++ b/pj_scene_protocol/CMakeLists.txt @@ -0,0 +1,51 @@ +# --------------------------------------------------------------------------- +# pj_scene_protocol — schema + canonical wire codec (writer + reader) for +# foxglove.ImageAnnotations and forthcoming scene primitive types. SDK +# boundary for plugin authors that produce or consume markers / scene data. +# Depends on pj_base only. +# --------------------------------------------------------------------------- + +add_library(pj_scene_protocol STATIC + src/image_annotation_codec.cpp + src/scene_decoder.cpp + src/scene_decoder_protobuf.cpp +) +target_include_directories(pj_scene_protocol PUBLIC + $ + $ +) +target_compile_options(pj_scene_protocol PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(pj_scene_protocol PUBLIC pj_base) +set_target_properties(pj_scene_protocol PROPERTIES + POSITION_INDEPENDENT_CODE ON +) + +# --------------------------------------------------------------------------- +# Install (guarded by PJ_INSTALL_SDK in root CMakeLists.txt) +# --------------------------------------------------------------------------- + +if(PJ_INSTALL_SDK) + install(TARGETS pj_scene_protocol EXPORT PlotJugglerSDKTargets + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + ) + install(DIRECTORY include/ DESTINATION include) +endif() + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +if(PJ_BUILD_TESTS) + set(PJ_SCENE_PROTOCOL_TESTS + tests/image_annotation_codec_test.cpp + tests/scene_decoder_test.cpp + ) + + foreach(test_src ${PJ_SCENE_PROTOCOL_TESTS}) + get_filename_component(test_name ${test_src} NAME_WE) + add_executable(${test_name} ${test_src}) + target_link_libraries(${test_name} PRIVATE pj_scene_protocol GTest::gtest_main) + add_test(NAME ${test_name} COMMAND ${test_name}) + endforeach() +endif() diff --git a/pj_scene_protocol/docs/ARCHITECTURE.md b/pj_scene_protocol/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5a7fc4b --- /dev/null +++ b/pj_scene_protocol/docs/ARCHITECTURE.md @@ -0,0 +1,123 @@ +# pj_scene_protocol — Architecture + +## Purpose and scope + +`pj_scene_protocol` is the canonical wire format and codec for visual markers and scene primitives shared across PlotJuggler's data sources and viewers. It is the SDK boundary that lets a plugin author **produce** marker data (e.g. detection bounding boxes, labelled points) or **consume** it without dragging in any visualization stack. + +Today the module covers 2D image annotations (points, lines, polygons, circles, text). It is named for forthcoming scope: 3D scene primitives (arrows, cubes, lines, meshes, text) are documented as the next addition, and the type system is laid out to accommodate them next to the 2D types without breaking existing wire bytes. + +**In scope:** +- Schema (vocabulary types — `Point2`, `ColorRGBA`, `ImageAnnotation`, `SceneFrame`, …). +- A canonical wire format (`foxglove.ImageAnnotations` Protobuf) and a hand-rolled writer + reader for it. +- The schema-name string constant that producers stamp on stored topics. + +**Out of scope (deliberately):** +- Per-source-format conversion. Translating from CDR `vision_msgs/Detection2DArray`, YOLO message types, CSV, RLDS, etc. into `ImageAnnotation` happens **loader-side**, never inside this module. PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}` are reference adapters. +- Storage / time-anchoring of scene frames (lives in PJ4's `pj_media/core/ScenePipelineSource` + `ObjectStore` from `pj_datastore`). +- Rendering (lives in PJ4's `pj_media/qt/MediaViewerWidget`). + +This split keeps `pj_scene_protocol` linkable by a streaming-source plugin or a one-off ROS bag converter without pulling FFmpeg, QRhi shaders, or anything else PlotJuggler's host happens to need. + +## Type catalog + +All types are POD-shaped, default-constructible, and compare with `operator==`. They live in `pj_scene_protocol/image_annotation.h`. + +| Type | Purpose | +|---|---| +| `Point2 {x, y}` | 2D point in image-pixel coordinates (origin top-left), `double` precision. | +| `ColorRGBA {r,g,b,a: uint8}` | 8-bit-per-channel color. `a == 0` is transparent. | +| `AnnotationTopology` (enum) | Vertex topology for `PointsAnnotation`: `kPoints`, `kLineList` (segments 0-1, 2-3, …), `kLineStrip` (polyline), `kLineLoop` (closes back; 4-point loop = rectangle). | +| `PointsAnnotation` | Vertices + topology + uniform `color` + optional per-vertex `colors` + `fill_color` (for `kLineLoop`) + `thickness`. | +| `CircleAnnotation` | `center` + `radius` (the wire format carries diameter; see below) + `thickness` + outline `color` + `fill_color`. | +| `TextAnnotation` | Anchor `position`, `text`, `font_size`, `color`. | +| `ImageAnnotation` | Bag of `points` + `circles` + `texts` for one image at one timestamp; refers to its base image via `image_topic`. | +| `SceneFrame` | Top-level decoder output. Wraps `vector`; future expansion will add 3D primitives, grids, etc. as sibling fields. | + +## Wire format + +The canonical wire format is **Foxglove `ImageAnnotations` Protobuf**. Conforming to it gives free interop with Foxglove Studio and other tools that consume the same schema. + +The schema-name string is published as: + +```cpp +inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; +``` + +Producers stamp this in the topic's `metadata_json` under the key `encoding`: + +```json +{"encoding":"foxglove.ImageAnnotations"} +``` + +Consumers pass the same string to `makeSceneDecoder()` to obtain the matching decoder. A factory typo returns `nullptr` rather than misbehaving silently. + +### Field numbers + +The writer at `src/image_annotation_codec.cpp` and the reader at `src/scene_decoder_protobuf.cpp` agree on the following Protobuf field numbers (matching the published Foxglove schema): + +| Message | Fields | +|---|---| +| `foxglove.ImageAnnotations` | `1: repeated CircleAnnotation`, `2: repeated PointsAnnotation`, `3: repeated TextAnnotation` | +| `foxglove.PointsAnnotation` | `2: type (enum)`, `3: repeated Point2`, `4: outline_color`, `5: repeated outline_colors`, `6: fill_color`, `7: thickness (double)` | +| `foxglove.CircleAnnotation` | `2: position (Point2)`, `3: diameter (double)`, `4: thickness (double)`, `5: fill_color`, `6: outline_color` | +| `foxglove.TextAnnotation` | `2: position (Point2)`, `3: text (string)`, `4: font_size (double)`, `5: text_color`, *6: background_color (skipped on write, skipped on read)* | +| `foxglove.Point2` | `1: x (double)`, `2: y (double)` | +| `foxglove.Color` | `1: r (double)`, `2: g (double)`, `3: b (double)`, `4: a (double)` — components in `[0, 1]` | + +Topology enum mapping: `kPoints=1`, `kLineLoop=2`, `kLineStrip=3`, `kLineList=4`. The Foxglove enum reserves `0` for `UNKNOWN`; the writer never emits 0. + +The wire types used are `VARINT(0)`, `I64(1)`, and `LEN(2)`. `I32(5)` is unused on write and skipped if encountered on read. + +### Encoding rules / round-trip behavior + +- **Color quantization is lossy.** `ColorRGBA` stores `uint8 [0, 255]`; the wire stores `double [0, 1]`. The writer divides by 255.0; the reader multiplies. A round-trip can drift up to 1 LSB on each channel. Tests assert with 1-LSB tolerance. +- **`CircleAnnotation::radius` ↔ wire `diameter`.** The writer emits `radius * 2`; the reader halves on read. The C++ surface always exposes radius. +- **Empty `colors` is preserved.** A `PointsAnnotation` with `colors.empty()` emits zero field-5 entries. Emitting a default `Color` for an empty vector would smuggle a phantom entry into the reader, breaking per-vertex coloring semantics. There is a regression test (`EmptyColorsVectorDoesNotInjectDefaultEntry`). +- **`ImageAnnotation::timestamp` and `::image_topic` do not cross the wire.** Those fields belong to the surrounding transport (the timestamp arrives via `ObjectStore`'s push; the topic identity is the topic). They are populated on read by the consumer pipeline, not by the codec. +- **`TextAnnotation::background_color` is intentionally absent from the C++ struct.** The wire format defines field 6, but the schema struct has no equivalent. The writer never emits it; the reader skips it. + +## API surface + +```cpp +// Schema constant (wire-format identifier). +inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; + +// Producer side. +[[nodiscard]] std::vector serializeImageAnnotation(const ImageAnnotation& ia); + +// Consumer side. +class ISceneDecoder { + public: + virtual ~ISceneDecoder() = default; + virtual Expected decode(const uint8_t* data, size_t size) = 0; +}; +std::unique_ptr makeSceneDecoder(std::string_view schema_name); +``` + +`Expected` is `pj_base/expected.hpp`. A decode failure returns an error string; `decode()` does not throw. + +`makeSceneDecoder` returns `nullptr` if the schema name does not match `kSchemaImageAnnotations`. It is the caller's signal that the topic's `metadata_json` is wrong. + +The decoder is stateless. The expected pattern is one decoder instance per scene/annotation layer for the lifetime of that layer. + +## Design rationale + +**Single canonical decoder.** There is exactly one decoder kind: Protobuf for `foxglove.ImageAnnotations`. Adding ROS-message decoders, CSV decoders, etc. inside this module was rejected — the consumer side must not grow N decoders for every robotics message dialect that exists. Per-source-format conversion is loader-side and writes canonical bytes into the store. + +**Hand-rolled wire codec.** No `protoc`, no generated code, no libprotobuf. The reader is ~300 lines; the writer is ~200 lines. At this size the dependency cost outweighs the codegen convenience, and the explicit code makes wire-level decisions (field numbers, default values, `radius`/`diameter`) reviewable. + +**Schema-name = version.** Following Foxglove's own convention, schemas are versioned by changing the type name. There is no in-band version field. If the wire shape ever needs to change incompatibly, a new constant (e.g. `kSchemaImageAnnotationsV2`) and a new decoder kind are added; old data keeps working with the old name. + +**Pure value types.** No virtual base, no PIMPL, no allocators. The schema header includes only ``, ``, ``, and `pj_base/types.hpp` (for `Timestamp`). A plugin author can include this header without a build-system thought. + +## What is not here, and where it lives + +| Concern | Lives in | +|---|---| +| Per-source-format conversion (CDR `vision_msgs/Detection2DArray`, `yolo_msgs/DetectionArray`, …) | PJ4 `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}` (reference adapters); plugin loaders for production use | +| Class-id → palette mapping (FNV-1a hash) | PJ4 `pj_media/demos/marker_palette.{h,cpp}` | +| `MediaSource` integration: pulling annotation bytes out of an `ObjectStore`, decoding at a timestamp | PJ4 `pj_media/core/scene_pipeline_source.{h,cpp}` | +| Compositing base image with overlays, layer fusion | PJ4 `pj_media/core/composite_media_source.{h,cpp}` | +| Rendering: 5-pipeline QRhi (image, points, 1 px lines, thick lines, text) | PJ4 `pj_media/qt/media_viewer_widget.{h,cpp}` | + +See `USER_GUIDE.md` for the producer and consumer code paths. diff --git a/pj_scene_protocol/docs/USER_GUIDE.md b/pj_scene_protocol/docs/USER_GUIDE.md new file mode 100644 index 0000000..760f6b2 --- /dev/null +++ b/pj_scene_protocol/docs/USER_GUIDE.md @@ -0,0 +1,128 @@ +# pj_scene_protocol User Guide + +How to produce or consume marker / scene data over PlotJuggler's canonical wire format. This guide is for plugin developers (DataSource, MessageParser) and viewer authors who need to integrate visual overlays. + +The module exposes one schema header and one codec header: + +```cpp +#include "pj_scene_protocol/image_annotation.h" // value types +#include "pj_scene_protocol/image_annotation_codec.h" // writer + schema name +#include "pj_scene_protocol/scene_decoder.h" // reader (consumers only) +``` + +Linking: `target_link_libraries(your_target PRIVATE pj_scene_protocol)`. The transitive `pj_base` dep is the only thing it pulls in. + +For the wire format reference, type catalog, and design rationale, see `ARCHITECTURE.md` in this folder. + +--- + +## 1. Producer recipe (loader / data source) + +A loader fills an `ImageAnnotation` from its source format and serializes to canonical bytes before pushing into the host's data store. + +```cpp +#include "pj_scene_protocol/image_annotation.h" +#include "pj_scene_protocol/image_annotation_codec.h" + +PJ::ImageAnnotation buildAnnotation(const Detection& det) { + PJ::ImageAnnotation ia; + + // Bounding box as a 4-point line loop. + PJ::PointsAnnotation rect; + rect.topology = PJ::AnnotationTopology::kLineLoop; + rect.points = { + {det.x_min, det.y_min}, {det.x_max, det.y_min}, + {det.x_max, det.y_max}, {det.x_min, det.y_max}, + }; + rect.color = paletteColor(det.class_id); // see pj_media/demos/marker_palette as reference + rect.thickness = 2.0; + ia.points.push_back(std::move(rect)); + + // Class label above the box. + PJ::TextAnnotation label; + label.position = {det.x_min, det.y_min - 4.0}; + label.text = det.class_name + " " + std::to_string(det.score); + label.font_size = 14.0; + label.color = {255, 255, 255, 255}; + ia.texts.push_back(std::move(label)); + + return ia; +} + +// In your loader's per-message callback: +auto bytes = PJ::serializeImageAnnotation(buildAnnotation(detection)); +host.pushObject(topic_id, ts_ns, bytes.data(), bytes.size()); +``` + +When you register the topic, stamp the schema name in `metadata_json`: + +```cpp +TopicOptions opts; +opts.metadata_json = R"({"encoding":"foxglove.ImageAnnotations"})"; +auto topic_id = host.registerTopic("/detections", opts); +``` + +That `encoding` value is the only signal the consumer side uses to dispatch the right decoder. If it is missing or misspelled, the data still arrives in the store but no viewer will pick it up. + +--- + +## 2. Consumer recipe (viewer / sink) + +A consumer reads bytes out of the store and decodes them with the canonical decoder. + +```cpp +#include "pj_scene_protocol/scene_decoder.h" + +auto decoder = PJ::makeSceneDecoder(PJ::kSchemaImageAnnotations); +if (!decoder) { + // Topic's metadata_json said something other than "foxglove.ImageAnnotations". + return; +} + +auto result = decoder->decode(bytes.data(), bytes.size()); +if (!result.has_value()) { + // result.error() is a string with a wire-level reason. + return; +} + +const PJ::SceneFrame& sf = *result; +for (const PJ::ImageAnnotation& ia : sf.annotations) { + for (const auto& pa : ia.points) { renderPoints(pa); } + for (const auto& ca : ia.circles) { renderCircle(ca); } + for (const auto& ta : ia.texts) { renderText(ta); } +} +``` + +The decoder is stateless — keep one per layer for the layer's lifetime, or build a fresh one per call (allocation is cheap). `decode()` does not throw. + +--- + +## 3. Common pitfalls + +**Schema-name mismatch.** `makeSceneDecoder("foxglove.image_annotations")` (lowercase) returns `nullptr`. Use the constant `kSchemaImageAnnotations` rather than a literal string. Same on the producer side — match the literal `"foxglove.ImageAnnotations"` exactly in `metadata_json`. + +**Color drift.** `ColorRGBA{255, 0, 0, 255}` is the *most* a channel can drift; round-trip equality on individual `uint8` channels is not guaranteed exactly. If you compare in a test, allow ±1 LSB per channel — `image_annotation_codec_test.cpp::ColorEq` shows the pattern. + +**Per-vertex `colors` semantics.** A `PointsAnnotation` honors `colors` only when `colors.size() == points.size()`. If `colors` is empty, the uniform `color` is splatted across all vertices. Anything else is implementation-defined; renderers may splat-last or ignore. Don't rely on the in-between case. + +**`fill_color` only fires for `kLineLoop`.** Other topologies ignore `fill_color`. Setting an alpha-zero default fill is the convention for "no fill." + +**Non-serialized fields.** `ImageAnnotation::timestamp` and `::image_topic` are populated by the consumer pipeline (timestamp comes from the store push; topic identity from the topic id). The codec does not round-trip them — equality on a freshly decoded annotation will see those fields as zero / empty. This is intentional; see `ARCHITECTURE.md §Wire format / Encoding rules`. + +**`CircleAnnotation::radius`, not diameter.** The C++ surface is radius. The wire carries diameter. Don't double the value yourself when constructing. + +**Empty annotations.** `serializeImageAnnotation()` on an `ImageAnnotation` with no primitives produces zero bytes. Pushing zero bytes is a valid "no overlays at this timestamp" signal; the decoder handles a non-empty buffer or returns an empty `SceneFrame`. Sending an empty buffer through `decode()` returns an error — guard the producer side or skip the push. + +--- + +## 4. Translating from a custom message format + +Per-source-format conversion is intentionally outside this module. A loader that reads, say, ROS 2 `vision_msgs/msg/Detection2DArray` is responsible for translating into `ImageAnnotation` itself. + +For a working reference, see PJ4's `pj_media/demos/cdr_*_to_image_annotation.{h,cpp}`: + +- `cdr_detection2d_to_image_annotation` — `vision_msgs/msg/Detection2DArray` → `ImageAnnotation`. Maps the first hypothesis's `class_id` to a stable palette colour and emits a `" "` text label above each bbox. +- `cdr_yolo_to_image_annotation` — `yolo_msgs/msg/DetectionArray` → `ImageAnnotation`. Same pattern, uses `class_name` for the label. +- `marker_palette` — FNV-1a class-id → `ColorRGBA` palette and label-string formatter. Reuse-friendly. + +These adapters live in PJ4 because they consume PJ4-side fixtures (MCAP demo). The pattern transfers to any plugin: read your message, fill an `ImageAnnotation`, serialize with `serializeImageAnnotation()`. diff --git a/pj_scene_protocol/include/pj_scene_protocol/image_annotation.h b/pj_scene_protocol/include/pj_scene_protocol/image_annotation.h new file mode 100644 index 0000000..991a768 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/image_annotation.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +#include "pj_base/types.hpp" + +namespace PJ { + +/// Vertex topology for vector annotations. Mirrors the canonical primitive set +/// from `pj_media/docs/datatypes_2D.md` §8. +enum class AnnotationTopology : uint8_t { + kPoints, ///< Each point is independent. + kLineList, ///< Consecutive pairs form segments (0-1, 2-3, ...). + kLineStrip, ///< Connected polyline 0-1, 1-2, ..., n-1-n. + kLineLoop, ///< Like LineStrip but closes back to the first point. 4-point loop = rectangle. +}; + +/// 2D point in image-pixel coordinates (origin top-left). +struct Point2 { + double x = 0.0; + double y = 0.0; + bool operator==(const Point2&) const = default; +}; + +/// 8-bit per-channel RGBA color. a=0 means transparent / disabled. +struct ColorRGBA { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 255; + bool operator==(const ColorRGBA&) const = default; +}; + +/// Vector primitive (points, lines, polygons) over an image's pixel space. +/// +/// Color semantics: if `colors` is empty, the uniform `color` applies to all +/// vertices. If `colors.size() == points.size()`, per-vertex coloring is used. +/// Anything in between is implementation-defined (renderers may splat-last). +struct PointsAnnotation { + AnnotationTopology topology = AnnotationTopology::kPoints; + std::vector points; + double thickness = 2.0; + ColorRGBA color = {0, 255, 0, 255}; + std::vector colors; + ColorRGBA fill_color = {0, 0, 0, 0}; ///< a=0 means no fill (LineLoop only). + bool operator==(const PointsAnnotation&) const = default; +}; + +/// Filled or stroked circle in image-pixel space. +struct CircleAnnotation { + Point2 center; + double radius = 1.0; + double thickness = 2.0; + ColorRGBA color = {0, 255, 0, 255}; + ColorRGBA fill_color = {0, 0, 0, 0}; + bool operator==(const CircleAnnotation&) const = default; +}; + +/// Text label anchored at a pixel position. +struct TextAnnotation { + Point2 position; + double font_size = 14.0; + ColorRGBA color = {255, 255, 255, 255}; + std::string text; + bool operator==(const TextAnnotation&) const = default; +}; + +/// All annotations for one image at one timestamp. References its base image +/// explicitly via `image_topic` so the renderer knows which frame to overlay. +struct ImageAnnotation { + Timestamp timestamp = 0; + std::string image_topic; + std::vector points; + std::vector circles; + std::vector texts; + bool operator==(const ImageAnnotation&) const = default; + + /// True if no primitives are present. + [[nodiscard]] bool empty() const noexcept { + return points.empty() && circles.empty() && texts.empty(); + } +}; + +/// Top-level output of a SceneDecoder. Wraps a list of ImageAnnotations for +/// this iteration; future iterations will extend with 3D ScenePrimitive (§7), +/// Grid (§9), etc. +struct SceneFrame { + Timestamp timestamp = 0; + std::vector annotations; + bool operator==(const SceneFrame&) const = default; + + [[nodiscard]] bool empty() const noexcept { + return annotations.empty(); + } +}; + +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h b/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h new file mode 100644 index 0000000..3fac8c9 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/image_annotation_codec.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include "pj_scene_protocol/image_annotation.h" + +namespace PJ { + +/// Wire-format identifier for image annotations on ObjectStore. Loaders stamp +/// this in the topic's `metadata_json` under the key "encoding"; pj_media +/// looks for it before decoding. The literal value is the published Foxglove +/// schema name; we conform to that wire format spec, which gives us free +/// interop with Foxglove Studio and other tools. +inline constexpr std::string_view kSchemaImageAnnotations = "foxglove.ImageAnnotations"; + +/// Serializes an ImageAnnotation to canonical foxglove.ImageAnnotations +/// Protobuf bytes. +/// +/// `timestamp` and `image_topic` on the input are NOT serialized — the +/// timestamp travels with ObjectStore's push, topic identity with the topic +/// registration. Round-trip equality holds when the caller leaves both at +/// default values. +[[nodiscard]] std::vector serializeImageAnnotation(const ImageAnnotation& ia); + +} // namespace PJ diff --git a/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h b/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h new file mode 100644 index 0000000..02f3a91 --- /dev/null +++ b/pj_scene_protocol/include/pj_scene_protocol/scene_decoder.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include "pj_base/expected.hpp" +#include "pj_scene_protocol/image_annotation.h" +#include "pj_scene_protocol/image_annotation_codec.h" // for kSchemaImageAnnotations + +namespace PJ { + +/// Decodes canonical wire-format bytes (foxglove.ImageAnnotations Protobuf — +/// the schema documented in `pj_media/docs/datatypes_2D.md §8` and serialized +/// by `pj_scene_protocol::serializeImageAnnotation`) into a `SceneFrame` of +/// vector primitives. Stateless — one instance per scene/annotation layer. +/// +/// There is exactly ONE decoder kind. Per-source-format conversion (e.g. CDR +/// `vision_msgs/msg/Detection2DArray` → canonical bytes) lives loader-side and +/// is invisible to pj_media. +class ISceneDecoder { + public: + virtual ~ISceneDecoder() = default; + virtual Expected decode(const uint8_t* data, size_t size) = 0; +}; + +/// Factory: returns the canonical Protobuf decoder. The `schema_name` argument +/// is checked against `kSchemaImageAnnotations` so a typo on the loader side +/// surfaces as a nullptr at construction. +std::unique_ptr makeSceneDecoder(std::string_view schema_name); + +} // namespace PJ diff --git a/pj_scene_protocol/src/image_annotation_codec.cpp b/pj_scene_protocol/src/image_annotation_codec.cpp new file mode 100644 index 0000000..12914f8 --- /dev/null +++ b/pj_scene_protocol/src/image_annotation_codec.cpp @@ -0,0 +1,199 @@ +#include "pj_scene_protocol/image_annotation_codec.h" + +#include +#include +#include + +namespace PJ { +namespace { + +// Hand-rolled Protobuf wire emission. Mirror of the reader at +// `src/scene_decoder_protobuf.cpp` (same module). +// +// Wire format spec: https://protobuf.dev/programming-guides/encoding/ +// Wire types we emit: VARINT(0), I64(1), LEN(2). I32(5) is unused here. +// +// Sub-messages are length-delimited: write the body to a scratch buffer, then +// write the parent's tag + length + body. Bodies are bounded (≤ a few hundred +// bytes for typical annotations), so the extra allocation is fine. + +void writeVarint(std::vector& out, uint64_t v) { + while (v >= 0x80u) { + out.push_back(static_cast((v & 0x7Fu) | 0x80u)); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +void writeTag(std::vector& out, uint32_t field, uint32_t wire) { + writeVarint(out, (static_cast(field) << 3) | (wire & 0x7u)); +} + +void writeDouble(std::vector& out, double v) { + uint64_t bits = 0; + std::memcpy(&bits, &v, 8); + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((bits >> (8 * i)) & 0xFFu)); + } +} + +void writeLenDelim(std::vector& out, const std::vector& body) { + writeVarint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); +} + +void writeString(std::vector& out, std::string_view s) { + writeVarint(out, s.size()); + out.insert(out.end(), s.begin(), s.end()); +} + +// foxglove.Point2 { 1: double x, 2: double y } +std::vector buildPoint2(const Point2& p) { + std::vector body; + writeTag(body, 1, 1); + writeDouble(body, p.x); + writeTag(body, 2, 1); + writeDouble(body, p.y); + return body; +} + +// foxglove.Color { 1: double r, 2: double g, 3: double b, 4: double a } +// Components in [0, 1]. We hold uint8 [0, 255] and convert by v/255.0. +std::vector buildColor(const ColorRGBA& c) { + std::vector body; + writeTag(body, 1, 1); + writeDouble(body, static_cast(c.r) / 255.0); + writeTag(body, 2, 1); + writeDouble(body, static_cast(c.g) / 255.0); + writeTag(body, 3, 1); + writeDouble(body, static_cast(c.b) / 255.0); + writeTag(body, 4, 1); + writeDouble(body, static_cast(c.a) / 255.0); + return body; +} + +// AnnotationTopology -> Foxglove enum. Inverse of `mapTopology` in the reader. +// kPoints=1, kLineLoop=2, kLineStrip=3, kLineList=4. The Foxglove enum reserves +// 0 for UNKNOWN; we never emit 0. +uint32_t topologyToEnum(AnnotationTopology t) { + switch (t) { + case AnnotationTopology::kPoints: + return 1; + case AnnotationTopology::kLineLoop: + return 2; + case AnnotationTopology::kLineStrip: + return 3; + case AnnotationTopology::kLineList: + return 4; + } + return 1; // unreachable; defensive +} + +// foxglove.PointsAnnotation +// { 2: type (varint enum), 3: repeated Point2, 4: outline_color, +// 5: repeated outline_colors, 6: fill_color, 7: thickness (double) } +std::vector buildPointsAnnotation(const PointsAnnotation& pa) { + std::vector body; + + writeTag(body, 2, 0); + writeVarint(body, topologyToEnum(pa.topology)); + + for (const auto& pt : pa.points) { + writeTag(body, 3, 2); + writeLenDelim(body, buildPoint2(pt)); + } + + writeTag(body, 4, 2); + writeLenDelim(body, buildColor(pa.color)); + + // Per-vertex colors: emit one field-5 entry per element. An empty `colors` + // vector emits zero entries — critical, because the reader pushes_back on + // every field-5 occurrence, so emitting an empty Color would smuggle a + // default-constructed entry into out.colors. + for (const auto& c : pa.colors) { + writeTag(body, 5, 2); + writeLenDelim(body, buildColor(c)); + } + + writeTag(body, 6, 2); + writeLenDelim(body, buildColor(pa.fill_color)); + + writeTag(body, 7, 1); + writeDouble(body, pa.thickness); + + return body; +} + +// foxglove.CircleAnnotation +// { 2: position (Point2), 3: diameter (double), 4: thickness (double), +// 5: fill_color, 6: outline_color } +// Note: our struct holds `radius`; we emit `diameter = radius * 2`. +std::vector buildCircleAnnotation(const CircleAnnotation& ca) { + std::vector body; + + writeTag(body, 2, 2); + writeLenDelim(body, buildPoint2(ca.center)); + + writeTag(body, 3, 1); + writeDouble(body, ca.radius * 2.0); + + writeTag(body, 4, 1); + writeDouble(body, ca.thickness); + + writeTag(body, 5, 2); + writeLenDelim(body, buildColor(ca.fill_color)); + + writeTag(body, 6, 2); + writeLenDelim(body, buildColor(ca.color)); + + return body; +} + +// foxglove.TextAnnotation +// { 2: position (Point2), 3: text (string), 4: font_size (double), +// 5: text_color } +// background_color (field 6) is intentionally NOT emitted — the C++ struct has +// no equivalent field. The reader skips it on read. +std::vector buildTextAnnotation(const TextAnnotation& ta) { + std::vector body; + + writeTag(body, 2, 2); + writeLenDelim(body, buildPoint2(ta.position)); + + writeTag(body, 3, 2); + writeString(body, ta.text); + + writeTag(body, 4, 1); + writeDouble(body, ta.font_size); + + writeTag(body, 5, 2); + writeLenDelim(body, buildColor(ta.color)); + + return body; +} + +} // namespace + +// foxglove.ImageAnnotations { 1: repeated CircleAnnotation, +// 2: repeated PointsAnnotation, +// 3: repeated TextAnnotation } +std::vector serializeImageAnnotation(const ImageAnnotation& ia) { + std::vector out; + + for (const auto& c : ia.circles) { + writeTag(out, 1, 2); + writeLenDelim(out, buildCircleAnnotation(c)); + } + for (const auto& p : ia.points) { + writeTag(out, 2, 2); + writeLenDelim(out, buildPointsAnnotation(p)); + } + for (const auto& t : ia.texts) { + writeTag(out, 3, 2); + writeLenDelim(out, buildTextAnnotation(t)); + } + + return out; +} + +} // namespace PJ diff --git a/pj_scene_protocol/src/scene_decoder.cpp b/pj_scene_protocol/src/scene_decoder.cpp new file mode 100644 index 0000000..ffd1648 --- /dev/null +++ b/pj_scene_protocol/src/scene_decoder.cpp @@ -0,0 +1,18 @@ +#include "pj_scene_protocol/scene_decoder.h" + +#include +#include + +namespace PJ { + +// Single decoder kind, defined in scene_decoder_protobuf.cpp. +std::unique_ptr makeSceneDecoderProtobufImageAnnotations(); + +std::unique_ptr makeSceneDecoder(std::string_view schema_name) { + if (schema_name == kSchemaImageAnnotations) { + return makeSceneDecoderProtobufImageAnnotations(); + } + return nullptr; +} + +} // namespace PJ diff --git a/pj_scene_protocol/src/scene_decoder_protobuf.cpp b/pj_scene_protocol/src/scene_decoder_protobuf.cpp new file mode 100644 index 0000000..d03f262 --- /dev/null +++ b/pj_scene_protocol/src/scene_decoder_protobuf.cpp @@ -0,0 +1,483 @@ +#include +#include +#include +#include +#include +#include + +#include "pj_scene_protocol/scene_decoder.h" + +namespace PJ { +namespace { + +// Minimal Protobuf wire-format reader for foxglove.ImageAnnotations. Decodes +// PointsAnnotation, CircleAnnotation, and TextAnnotation in full. Round-trips +// against the sibling writer at `src/image_annotation_codec.cpp` are covered +// by `tests/image_annotation_codec_test.cpp`. +// +// Spec reference: https://protobuf.dev/programming-guides/encoding/ +// Wire types we need: VARINT(0), I64(1), LEN(2). I32(5) skipped if encountered. +// +// Foxglove schemas (https://github.com/foxglove/schemas, foxglove/proto/): +// ImageAnnotations { circles=1, points=2, texts=3 } +// PointsAnnotation { timestamp=1, type=2 (enum: 0/1/2/3/4), +// points=3 (repeated Point2), +// outline_color=4, outline_colors=5, +// fill_color=6, thickness=7 } +// Point2 { x=1, y=2 } (both double) +// Time { sec=1 (int64), nanosec=2 (uint32) } + +struct Reader { + const uint8_t* p; + const uint8_t* end; + + bool eof() const noexcept { + return p >= end; + } + size_t remaining() const noexcept { + return static_cast(end - p); + } + + // Returns false on overflow / end-of-buffer. + bool readVarint(uint64_t& out) { + out = 0; + int shift = 0; + while (p < end) { + uint8_t b = *p++; + out |= static_cast(b & 0x7Fu) << shift; + if ((b & 0x80u) == 0) { + return true; + } + shift += 7; + if (shift > 63) { + return false; + } + } + return false; + } + + bool readFixed64(uint64_t& out) { + if (remaining() < 8) { + return false; + } + std::memcpy(&out, p, 8); // little-endian on x86_64; protobuf is also LE + p += 8; + return true; + } + + bool readDouble(double& out) { + uint64_t bits = 0; + if (!readFixed64(bits)) { + return false; + } + std::memcpy(&out, &bits, 8); + return true; + } + + // Skip a single field given its wire type. Advances p past the field body. + bool skipField(uint32_t wire_type) { + switch (wire_type) { + case 0: { // VARINT + uint64_t dummy = 0; + return readVarint(dummy); + } + case 1: { // I64 + if (remaining() < 8) { + return false; + } + p += 8; + return true; + } + case 2: { // LEN + uint64_t len = 0; + if (!readVarint(len)) { + return false; + } + if (len > remaining()) { + return false; + } + p += len; + return true; + } + case 5: { // I32 + if (remaining() < 4) { + return false; + } + p += 4; + return true; + } + default: + return false; // groups (3/4) deprecated, not expected + } + } +}; + +// Map Foxglove PointsAnnotation.Type enum values to our AnnotationTopology. +AnnotationTopology mapTopology(uint64_t type) { + switch (type) { + case 1: + return AnnotationTopology::kPoints; + case 2: + return AnnotationTopology::kLineLoop; + case 3: + return AnnotationTopology::kLineStrip; + case 4: + return AnnotationTopology::kLineList; + case 0: + default: + return AnnotationTopology::kPoints; // UNKNOWN → safe default + } +} + +// Decode a Point2 sub-message: {1: double x, 2: double y}. +bool decodePoint2(Reader& r, size_t len, Point2& out) { + const uint8_t* sub_end = r.p + len; + if (sub_end > r.end) { + return false; + } + while (r.p < sub_end) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return false; + } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); + if (field == 1 && wire == 1) { + if (!r.readDouble(out.x)) { + return false; + } + } else if (field == 2 && wire == 1) { + if (!r.readDouble(out.y)) { + return false; + } + } else { + if (!r.skipField(wire)) { + return false; + } + } + } + return true; +} + +// Decode a foxglove.Color sub-message: {1: double r, 2: double g, 3: double b, 4: double a} +// with components in [0, 1]. Output is uint8 RGBA in [0, 255]. +bool decodeColor(Reader& r, size_t len, ColorRGBA& out) { + const uint8_t* sub_end = r.p + len; + if (sub_end > r.end) { + return false; + } + double rd = 0.0; + double gd = 0.0; + double bd = 0.0; + double ad = 1.0; + while (r.p < sub_end) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return false; + } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); + if (wire == 1 && field >= 1 && field <= 4) { + double v = 0.0; + if (!r.readDouble(v)) { + return false; + } + switch (field) { + case 1: + rd = v; + break; + case 2: + gd = v; + break; + case 3: + bd = v; + break; + case 4: + ad = v; + break; + default: + break; + } + } else { + if (!r.skipField(wire)) { + return false; + } + } + } + auto to_byte = [](double v) { + if (v < 0.0) { + v = 0.0; + } + if (v > 1.0) { + v = 1.0; + } + return static_cast(v * 255.0 + 0.5); + }; + out.r = to_byte(rd); + out.g = to_byte(gd); + out.b = to_byte(bd); + out.a = to_byte(ad); + return true; +} + +// Decode one PointsAnnotation sub-message. +bool decodePointsAnnotation(Reader& r, size_t len, PointsAnnotation& out) { + const uint8_t* sub_end = r.p + len; + if (sub_end > r.end) { + return false; + } + while (r.p < sub_end) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return false; + } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); + + if (field == 2 && wire == 0) { + uint64_t type_val = 0; + if (!r.readVarint(type_val)) { + return false; + } + out.topology = mapTopology(type_val); + } else if (field == 3 && wire == 2) { + uint64_t pt_len = 0; + if (!r.readVarint(pt_len)) { + return false; + } + Point2 pt; + if (!decodePoint2(r, pt_len, pt)) { + return false; + } + out.points.push_back(pt); + } else if (field == 4 && wire == 2) { + uint64_t c_len = 0; + if (!r.readVarint(c_len)) { + return false; + } + if (!decodeColor(r, c_len, out.color)) { + return false; + } + } else if (field == 5 && wire == 2) { + uint64_t c_len = 0; + if (!r.readVarint(c_len)) { + return false; + } + ColorRGBA c{}; + if (!decodeColor(r, c_len, c)) { + return false; + } + out.colors.push_back(c); + } else if (field == 6 && wire == 2) { + uint64_t c_len = 0; + if (!r.readVarint(c_len)) { + return false; + } + if (!decodeColor(r, c_len, out.fill_color)) { + return false; + } + } else if (field == 7 && wire == 1) { + if (!r.readDouble(out.thickness)) { + return false; + } + } else { + if (!r.skipField(wire)) { + return false; + } + } + } + return true; +} + +// Decode one foxglove.CircleAnnotation sub-message: +// timestamp(1)=Time, position(2)=Point2, diameter(3)=double, thickness(4)=double, +// fill_color(5)=Color, outline_color(6)=Color +// We map diameter/2 -> radius and outline_color -> color (the C++ struct has no +// separate outline field; .color IS the outline). +bool decodeCircleAnnotation(Reader& r, size_t len, CircleAnnotation& out) { + const uint8_t* sub_end = r.p + len; + if (sub_end > r.end) { + return false; + } + // Defaults match pj_scene_protocol/image_annotation.h. + out.color = {0, 255, 0, 255}; + out.fill_color = {0, 0, 0, 0}; + out.thickness = 2.0; + out.radius = 1.0; + while (r.p < sub_end) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return false; + } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); + if (field == 2 && wire == 2) { + uint64_t p_len = 0; + if (!r.readVarint(p_len)) { + return false; + } + if (!decodePoint2(r, p_len, out.center)) { + return false; + } + } else if (field == 3 && wire == 1) { + double diameter = 0.0; + if (!r.readDouble(diameter)) { + return false; + } + out.radius = diameter * 0.5; + } else if (field == 4 && wire == 1) { + if (!r.readDouble(out.thickness)) { + return false; + } + } else if (field == 5 && wire == 2) { + uint64_t c_len = 0; + if (!r.readVarint(c_len)) { + return false; + } + if (!decodeColor(r, c_len, out.fill_color)) { + return false; + } + } else if (field == 6 && wire == 2) { + uint64_t c_len = 0; + if (!r.readVarint(c_len)) { + return false; + } + if (!decodeColor(r, c_len, out.color)) { + return false; + } + } else { + if (!r.skipField(wire)) { + return false; + } + } + } + return true; +} + +// Decode one foxglove.TextAnnotation sub-message: +// timestamp(1)=Time, position(2)=Point2, text(3)=string, font_size(4)=double, +// text_color(5)=Color, background_color(6)=Color (background_color skipped — not +// present in pj_scene_protocol/image_annotation.h::TextAnnotation). +bool decodeTextAnnotation(Reader& r, size_t len, TextAnnotation& out) { + const uint8_t* sub_end = r.p + len; + if (sub_end > r.end) { + return false; + } + out.color = {255, 255, 255, 255}; + out.font_size = 14.0; + while (r.p < sub_end) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return false; + } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); + if (field == 2 && wire == 2) { + uint64_t p_len = 0; + if (!r.readVarint(p_len)) { + return false; + } + if (!decodePoint2(r, p_len, out.position)) { + return false; + } + } else if (field == 3 && wire == 2) { + uint64_t s_len = 0; + if (!r.readVarint(s_len)) { + return false; + } + if (s_len > r.remaining()) { + return false; + } + out.text.assign(reinterpret_cast(r.p), static_cast(s_len)); + r.p += s_len; + } else if (field == 4 && wire == 1) { + if (!r.readDouble(out.font_size)) { + return false; + } + } else if (field == 5 && wire == 2) { + uint64_t c_len = 0; + if (!r.readVarint(c_len)) { + return false; + } + if (!decodeColor(r, c_len, out.color)) { + return false; + } + } else { + if (!r.skipField(wire)) { + return false; + } + } + } + return true; +} + +// Decode the top-level ImageAnnotations message. +class ProtobufImageAnnotationsDecoder final : public ISceneDecoder { + public: + Expected decode(const uint8_t* data, size_t size) override { + if (data == nullptr || size == 0) { + return unexpected(std::string("Protobuf ImageAnnotations: empty buffer")); + } + Reader r{data, data + size}; + + ImageAnnotation ia; + while (!r.eof()) { + uint64_t tag = 0; + if (!r.readVarint(tag)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad tag")); + } + uint32_t field = static_cast(tag >> 3); + uint32_t wire = static_cast(tag & 0x7u); + + if (field == 2 && wire == 2) { + uint64_t pa_len = 0; + if (!r.readVarint(pa_len)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad PointsAnnotation length")); + } + PointsAnnotation pa; + pa.color = {0, 255, 0, 255}; + pa.thickness = 2.0; + if (!decodePointsAnnotation(r, pa_len, pa)) { + return unexpected(std::string("Protobuf ImageAnnotations: PointsAnnotation decode failed")); + } + ia.points.push_back(std::move(pa)); + } else if (field == 1 && wire == 2) { + uint64_t ca_len = 0; + if (!r.readVarint(ca_len)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad CircleAnnotation length")); + } + CircleAnnotation ca; + if (!decodeCircleAnnotation(r, ca_len, ca)) { + return unexpected(std::string("Protobuf ImageAnnotations: CircleAnnotation decode failed")); + } + ia.circles.push_back(std::move(ca)); + } else if (field == 3 && wire == 2) { + uint64_t ta_len = 0; + if (!r.readVarint(ta_len)) { + return unexpected(std::string("Protobuf ImageAnnotations: bad TextAnnotation length")); + } + TextAnnotation ta; + if (!decodeTextAnnotation(r, ta_len, ta)) { + return unexpected(std::string("Protobuf ImageAnnotations: TextAnnotation decode failed")); + } + ia.texts.push_back(std::move(ta)); + } else { + if (!r.skipField(wire)) { + return unexpected(std::string("Protobuf ImageAnnotations: skip failed")); + } + } + } + + SceneFrame sf; + sf.annotations.push_back(std::move(ia)); + return sf; + } +}; + +} // namespace + +std::unique_ptr makeSceneDecoderProtobufImageAnnotations() { + return std::make_unique(); +} + +} // namespace PJ diff --git a/pj_scene_protocol/tests/image_annotation_codec_test.cpp b/pj_scene_protocol/tests/image_annotation_codec_test.cpp new file mode 100644 index 0000000..f520610 --- /dev/null +++ b/pj_scene_protocol/tests/image_annotation_codec_test.cpp @@ -0,0 +1,328 @@ +#include "pj_scene_protocol/image_annotation_codec.h" + +#include + +#include +#include +#include +#include + +#include "pj_scene_protocol/image_annotation.h" +#include "pj_scene_protocol/scene_decoder.h" // existing reader, used for round-trips + +namespace PJ { +namespace { + +// ----------------------------------------------------------------------------- +// Hand-rolled Protobuf helpers — same style as the sibling decoder test +// (`tests/scene_decoder_test.cpp`). Used to build expected byte sequences for +// golden-byte tests. +// ----------------------------------------------------------------------------- +namespace pb { + +inline void appendVarint(std::vector& out, uint64_t v) { + while (v >= 0x80u) { + out.push_back(static_cast((v & 0x7Fu) | 0x80u)); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +inline void appendTag(std::vector& out, uint32_t field, uint32_t wire) { + appendVarint(out, (static_cast(field) << 3) | wire); +} + +inline void appendDouble(std::vector& out, double v) { + uint64_t bits = 0; + std::memcpy(&bits, &v, 8); + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((bits >> (8 * i)) & 0xFFu)); + } +} + +inline void appendLenDelim(std::vector& out, const std::vector& body) { + appendVarint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); +} + +} // namespace pb + +// Decode the bytes produced by serializeImageAnnotation back into an +// ImageAnnotation. Returns the inner annotation; assumes the SceneFrame wraps +// exactly one ImageAnnotation (the reader's contract). +ImageAnnotation roundTrip(const ImageAnnotation& input) { + auto bytes = serializeImageAnnotation(input); + auto decoder = makeSceneDecoder(kSchemaImageAnnotations); + EXPECT_NE(decoder.get(), nullptr); + auto result = decoder->decode(bytes.data(), bytes.size()); + EXPECT_TRUE(result.has_value()); + EXPECT_EQ(result->annotations.size(), 1u); + return result->annotations[0]; +} + +// Compare two ColorRGBA values allowing 1-LSB drift on each channel from the +// double-quantization round-trip (uint8 → double in [0,1] → uint8). +::testing::AssertionResult ColorEq(const ColorRGBA& a, const ColorRGBA& b) { + auto near = [](uint8_t x, uint8_t y) { return x > y ? (x - y) <= 1 : (y - x) <= 1; }; + if (near(a.r, b.r) && near(a.g, b.g) && near(a.b, b.b) && near(a.a, b.a)) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() << "Color mismatch: got {" << +b.r << "," << +b.g << "," << +b.b << "," << +b.a + << "}, expected {" << +a.r << "," << +a.g << "," << +a.b << "," << +a.a << "}"; +} + +// ----------------------------------------------------------------------------- +// 1. Empty input produces empty bytes +// ----------------------------------------------------------------------------- + +TEST(ImageAnnotationCodecTest, EmptyAnnotationProducesEmptyBytes) { + ImageAnnotation ia; + auto bytes = serializeImageAnnotation(ia); + EXPECT_TRUE(bytes.empty()); +} + +// ----------------------------------------------------------------------------- +// 2. Golden-byte test — pins the wire format itself, not just round-trip behavior +// ----------------------------------------------------------------------------- + +TEST(ImageAnnotationCodecTest, GoldenBytes_SinglePointsAnnotation) { + // Build the canonical input: one PointsAnnotation, kLineLoop, two points, + // outline color = pure red (255, 0, 0, 255), fill = transparent default, + // thickness = 2.0. + ImageAnnotation ia; + PointsAnnotation pa; + pa.topology = AnnotationTopology::kLineLoop; + pa.points = {{1.0, 2.0}, {3.0, 4.0}}; + pa.color = {255, 0, 0, 255}; + pa.fill_color = {0, 0, 0, 0}; + pa.thickness = 2.0; + ia.points.push_back(std::move(pa)); + + // Hand-build the expected byte sequence using the standalone pb helpers. + // This is the inverse encoding the reader expects, so getting it byte-for- + // byte right is the strongest available proof. + + // Point2 sub-messages. + std::vector p1; + pb::appendTag(p1, 1, 1); + pb::appendDouble(p1, 1.0); + pb::appendTag(p1, 2, 1); + pb::appendDouble(p1, 2.0); + + std::vector p2; + pb::appendTag(p2, 1, 1); + pb::appendDouble(p2, 3.0); + pb::appendTag(p2, 2, 1); + pb::appendDouble(p2, 4.0); + + // Color sub-messages. + std::vector outline_color; + pb::appendTag(outline_color, 1, 1); + pb::appendDouble(outline_color, 1.0); // r = 255/255 + pb::appendTag(outline_color, 2, 1); + pb::appendDouble(outline_color, 0.0); + pb::appendTag(outline_color, 3, 1); + pb::appendDouble(outline_color, 0.0); + pb::appendTag(outline_color, 4, 1); + pb::appendDouble(outline_color, 1.0); // a = 255/255 + + std::vector fill_color; + pb::appendTag(fill_color, 1, 1); + pb::appendDouble(fill_color, 0.0); + pb::appendTag(fill_color, 2, 1); + pb::appendDouble(fill_color, 0.0); + pb::appendTag(fill_color, 3, 1); + pb::appendDouble(fill_color, 0.0); + pb::appendTag(fill_color, 4, 1); + pb::appendDouble(fill_color, 0.0); + + // PointsAnnotation body. + std::vector pa_body; + pb::appendTag(pa_body, 2, 0); + pb::appendVarint(pa_body, 2); // kLineLoop = 2 + pb::appendTag(pa_body, 3, 2); + pb::appendLenDelim(pa_body, p1); + pb::appendTag(pa_body, 3, 2); + pb::appendLenDelim(pa_body, p2); + pb::appendTag(pa_body, 4, 2); + pb::appendLenDelim(pa_body, outline_color); + pb::appendTag(pa_body, 6, 2); + pb::appendLenDelim(pa_body, fill_color); + pb::appendTag(pa_body, 7, 1); + pb::appendDouble(pa_body, 2.0); + + // Top-level ImageAnnotations: one PointsAnnotation at field 2. + std::vector expected; + pb::appendTag(expected, 2, 2); + pb::appendLenDelim(expected, pa_body); + + auto actual = serializeImageAnnotation(ia); + EXPECT_EQ(actual, expected) << "wire format mismatch"; +} + +// ----------------------------------------------------------------------------- +// 3. Round-trip tests — build → serialize → existing reader → compare +// ----------------------------------------------------------------------------- + +TEST(ImageAnnotationCodecTest, RoundTrip_LineLoopFourPoints) { + ImageAnnotation in; + PointsAnnotation pa; + pa.topology = AnnotationTopology::kLineLoop; + pa.points = {{75.0, 185.0}, {125.0, 185.0}, {125.0, 215.0}, {75.0, 215.0}}; + pa.color = {0, 255, 0, 255}; + pa.fill_color = {0, 0, 0, 0}; + pa.thickness = 2.5; + in.points.push_back(std::move(pa)); + + auto out = roundTrip(in); + ASSERT_EQ(out.points.size(), 1u); + EXPECT_EQ(out.points[0].topology, AnnotationTopology::kLineLoop); + EXPECT_EQ(out.points[0].points, in.points[0].points); + EXPECT_TRUE(ColorEq(in.points[0].color, out.points[0].color)); + EXPECT_TRUE(ColorEq(in.points[0].fill_color, out.points[0].fill_color)); + EXPECT_DOUBLE_EQ(out.points[0].thickness, 2.5); +} + +TEST(ImageAnnotationCodecTest, RoundTrip_AllTopologies) { + for (auto topology : + {AnnotationTopology::kPoints, AnnotationTopology::kLineList, AnnotationTopology::kLineStrip, + AnnotationTopology::kLineLoop}) { + ImageAnnotation in; + PointsAnnotation pa; + pa.topology = topology; + pa.points = {{0.0, 0.0}, {10.0, 10.0}}; + pa.color = {0, 255, 0, 255}; + pa.fill_color = {0, 0, 0, 0}; + pa.thickness = 2.0; + in.points.push_back(std::move(pa)); + + auto out = roundTrip(in); + ASSERT_EQ(out.points.size(), 1u) << "topology=" << static_cast(topology); + EXPECT_EQ(out.points[0].topology, topology); + } +} + +TEST(ImageAnnotationCodecTest, RoundTrip_CirclePreservesDiameterRadiusInverse) { + ImageAnnotation in; + CircleAnnotation ca; + ca.center = {50.0, 60.0}; + ca.radius = 10.0; // wire emits diameter = 20; reader halves back to 10. + ca.thickness = 1.5; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {255, 0, 0, 128}; // semi-transparent red + in.circles.push_back(std::move(ca)); + + auto out = roundTrip(in); + ASSERT_EQ(out.circles.size(), 1u); + EXPECT_DOUBLE_EQ(out.circles[0].center.x, 50.0); + EXPECT_DOUBLE_EQ(out.circles[0].center.y, 60.0); + EXPECT_DOUBLE_EQ(out.circles[0].radius, 10.0); + EXPECT_DOUBLE_EQ(out.circles[0].thickness, 1.5); + EXPECT_TRUE(ColorEq(in.circles[0].color, out.circles[0].color)); + EXPECT_TRUE(ColorEq(in.circles[0].fill_color, out.circles[0].fill_color)); +} + +TEST(ImageAnnotationCodecTest, RoundTrip_TextUtf8) { + ImageAnnotation in; + TextAnnotation ta; + ta.position = {320.5, 240.25}; + ta.font_size = 14.0; + ta.color = {255, 255, 255, 255}; + ta.text = "person 0.95 — \xc3\xa1\xc3\xa9\xc3\xad"; // UTF-8: "áéí" + in.texts.push_back(std::move(ta)); + + auto out = roundTrip(in); + ASSERT_EQ(out.texts.size(), 1u); + EXPECT_EQ(out.texts[0].text, in.texts[0].text); + EXPECT_DOUBLE_EQ(out.texts[0].position.x, 320.5); + EXPECT_DOUBLE_EQ(out.texts[0].position.y, 240.25); + EXPECT_DOUBLE_EQ(out.texts[0].font_size, 14.0); + EXPECT_TRUE(ColorEq(in.texts[0].color, out.texts[0].color)); +} + +TEST(ImageAnnotationCodecTest, RoundTrip_FullImageAnnotationAllThreeKinds) { + ImageAnnotation in; + + // Two points annotations. + PointsAnnotation pa1; + pa1.topology = AnnotationTopology::kLineLoop; + pa1.points = {{0.0, 0.0}, {100.0, 0.0}, {100.0, 100.0}, {0.0, 100.0}}; + pa1.color = {255, 128, 64, 255}; + pa1.thickness = 3.0; + in.points.push_back(pa1); + + PointsAnnotation pa2; + pa2.topology = AnnotationTopology::kLineStrip; + pa2.points = {{50.0, 50.0}, {150.0, 100.0}, {200.0, 200.0}}; + pa2.color = {64, 255, 128, 200}; + pa2.thickness = 1.0; + in.points.push_back(pa2); + + // One circle. + CircleAnnotation ca; + ca.center = {320.0, 240.0}; + ca.radius = 5.0; + ca.thickness = 2.0; + ca.color = {0, 0, 255, 255}; + ca.fill_color = {0, 0, 255, 64}; + in.circles.push_back(ca); + + // One text. + TextAnnotation ta; + ta.position = {10.0, 10.0}; + ta.font_size = 12.0; + ta.color = {255, 255, 0, 255}; + ta.text = "label"; + in.texts.push_back(ta); + + auto out = roundTrip(in); + ASSERT_EQ(out.points.size(), 2u); + ASSERT_EQ(out.circles.size(), 1u); + ASSERT_EQ(out.texts.size(), 1u); + + EXPECT_EQ(out.points[0].topology, AnnotationTopology::kLineLoop); + EXPECT_EQ(out.points[1].topology, AnnotationTopology::kLineStrip); + EXPECT_EQ(out.points[0].points.size(), 4u); + EXPECT_EQ(out.points[1].points.size(), 3u); + EXPECT_DOUBLE_EQ(out.circles[0].radius, 5.0); + EXPECT_EQ(out.texts[0].text, "label"); +} + +TEST(ImageAnnotationCodecTest, EmptyColorsVectorDoesNotInjectDefaultEntry) { + // A PointsAnnotation with empty `colors` must round-trip to empty `colors`. + // If the writer emitted a default Color for the empty vector, the reader + // would push a phantom entry, breaking per-vertex coloring semantics. + ImageAnnotation in; + PointsAnnotation pa; + pa.topology = AnnotationTopology::kPoints; + pa.points = {{1.0, 1.0}, {2.0, 2.0}}; + pa.colors = {}; // explicitly empty + pa.color = {0, 255, 0, 255}; + pa.thickness = 2.0; + in.points.push_back(std::move(pa)); + + auto out = roundTrip(in); + ASSERT_EQ(out.points.size(), 1u); + EXPECT_TRUE(out.points[0].colors.empty()) << "writer must not emit phantom field-5 entries"; +} + +TEST(ImageAnnotationCodecTest, RoundTrip_PerVertexColors) { + ImageAnnotation in; + PointsAnnotation pa; + pa.topology = AnnotationTopology::kLineStrip; + pa.points = {{0.0, 0.0}, {10.0, 10.0}, {20.0, 0.0}}; + pa.colors = {{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}}; + pa.color = {255, 255, 255, 255}; + pa.thickness = 2.0; + in.points.push_back(std::move(pa)); + + auto out = roundTrip(in); + ASSERT_EQ(out.points.size(), 1u); + ASSERT_EQ(out.points[0].colors.size(), 3u); + EXPECT_TRUE(ColorEq(in.points[0].colors[0], out.points[0].colors[0])); + EXPECT_TRUE(ColorEq(in.points[0].colors[1], out.points[0].colors[1])); + EXPECT_TRUE(ColorEq(in.points[0].colors[2], out.points[0].colors[2])); +} + +} // namespace +} // namespace PJ diff --git a/pj_scene_protocol/tests/scene_decoder_test.cpp b/pj_scene_protocol/tests/scene_decoder_test.cpp new file mode 100644 index 0000000..028ce2e --- /dev/null +++ b/pj_scene_protocol/tests/scene_decoder_test.cpp @@ -0,0 +1,215 @@ +#include "pj_scene_protocol/scene_decoder.h" + +#include + +#include +#include +#include + +#include "pj_scene_protocol/image_annotation.h" + +namespace PJ { +namespace { + +TEST(SceneDecoderTest, FactoryReturnsNullForUnknownSchema) { + auto dec = makeSceneDecoder("nonsense/Schema"); + EXPECT_EQ(dec.get(), nullptr); +} + +// --------------------------------------------------------------------------- +// Protobuf decoder tests (foxglove.ImageAnnotations) — the canonical wire +// format. Per-source-format conversion (CDR vision_msgs, yolo, …) is +// loader-side and tested elsewhere (see pj_media/demos/cdr_to_image_annotation_test). +// --------------------------------------------------------------------------- + +namespace pb { +// Tiny encoder helpers for tests — protobuf wire format. + +inline void appendVarint(std::vector& out, uint64_t v) { + while (v >= 0x80) { + out.push_back(static_cast((v & 0x7F) | 0x80)); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +inline void appendTag(std::vector& out, uint32_t field, uint32_t wire) { + appendVarint(out, (static_cast(field) << 3) | wire); +} + +inline void appendDouble(std::vector& out, double v) { + uint64_t bits = 0; + std::memcpy(&bits, &v, 8); + for (int i = 0; i < 8; ++i) { + out.push_back(static_cast((bits >> (8 * i)) & 0xFFu)); + } +} + +inline void appendLenDelim(std::vector& out, const std::vector& body) { + appendVarint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); +} + +} // namespace pb + +// Build a Foxglove Point2 sub-message: {1: double x, 2: double y} +std::vector encodePoint2(double x, double y) { + std::vector body; + pb::appendTag(body, 1, 1); + pb::appendDouble(body, x); // x = double + pb::appendTag(body, 2, 1); + pb::appendDouble(body, y); // y = double + return body; +} + +// Build a Foxglove PointsAnnotation: {2: type, 3: repeated points, 7: thickness} +std::vector encodePointsAnnotation( + uint32_t type, const std::vector>& pts, double thickness) { + std::vector body; + pb::appendTag(body, 2, 0); + pb::appendVarint(body, type); + for (const auto& [x, y] : pts) { + pb::appendTag(body, 3, 2); + pb::appendLenDelim(body, encodePoint2(x, y)); + } + pb::appendTag(body, 7, 1); + pb::appendDouble(body, thickness); + return body; +} + +// Build a Foxglove ImageAnnotations: {2: repeated PointsAnnotation} +std::vector encodeImageAnnotations(const std::vector>& point_annotations) { + std::vector out; + for (const auto& pa : point_annotations) { + pb::appendTag(out, 2, 2); + pb::appendLenDelim(out, pa); + } + return out; +} + +// Build a Foxglove Color sub-message: {1: r, 2: g, 3: b, 4: a} (all double in [0,1]). +std::vector encodeColor(double r, double g, double b, double a) { + std::vector body; + pb::appendTag(body, 1, 1); + pb::appendDouble(body, r); + pb::appendTag(body, 2, 1); + pb::appendDouble(body, g); + pb::appendTag(body, 3, 1); + pb::appendDouble(body, b); + pb::appendTag(body, 4, 1); + pb::appendDouble(body, a); + return body; +} + +// Build a Foxglove CircleAnnotation: {2: position, 3: diameter, 4: thickness, +// 5: fill_color, 6: outline_color} +std::vector encodeCircleAnnotation( + double x, double y, double diameter, double thickness, const std::vector& fill, + const std::vector& outline) { + std::vector body; + pb::appendTag(body, 2, 2); + pb::appendLenDelim(body, encodePoint2(x, y)); + pb::appendTag(body, 3, 1); + pb::appendDouble(body, diameter); + pb::appendTag(body, 4, 1); + pb::appendDouble(body, thickness); + pb::appendTag(body, 5, 2); + pb::appendLenDelim(body, fill); + pb::appendTag(body, 6, 2); + pb::appendLenDelim(body, outline); + return body; +} + +TEST(SceneDecoderProtobufTest, FactoryReturnsDecoderForImageAnnotations) { + auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); + ASSERT_NE(dec.get(), nullptr); +} + +TEST(SceneDecoderProtobufTest, EmptyMessageProducesEmptyAnnotation) { + auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); + ASSERT_NE(dec.get(), nullptr); + + std::vector empty_body; + auto result = dec->decode(empty_body.data(), empty_body.size()); + // Empty buffer is treated as error per the decoder's contract. + EXPECT_FALSE(result.has_value()); +} + +TEST(SceneDecoderProtobufTest, SingleLineLoopFourPoints) { + auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); + ASSERT_NE(dec.get(), nullptr); + + // type=2 (LINE_LOOP), 4 corners, thickness=2.5 + auto pa = encodePointsAnnotation(2, {{10.0, 20.0}, {110.0, 20.0}, {110.0, 80.0}, {10.0, 80.0}}, 2.5); + auto bytes = encodeImageAnnotations({pa}); + + auto result = dec->decode(bytes.data(), bytes.size()); + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->annotations.size(), 1u); + const auto& ia = result->annotations[0]; + ASSERT_EQ(ia.points.size(), 1u); + + const auto& pts = ia.points[0]; + EXPECT_EQ(pts.topology, AnnotationTopology::kLineLoop); + ASSERT_EQ(pts.points.size(), 4u); + EXPECT_DOUBLE_EQ(pts.points[0].x, 10.0); + EXPECT_DOUBLE_EQ(pts.points[0].y, 20.0); + EXPECT_DOUBLE_EQ(pts.points[2].x, 110.0); + EXPECT_DOUBLE_EQ(pts.points[2].y, 80.0); + EXPECT_DOUBLE_EQ(pts.thickness, 2.5); +} + +TEST(SceneDecoderProtobufTest, MultiplePointsAnnotations) { + auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); + ASSERT_NE(dec.get(), nullptr); + + auto pa1 = encodePointsAnnotation(2, {{0.0, 0.0}, {100.0, 0.0}, {100.0, 100.0}, {0.0, 100.0}}, 1.0); + auto pa2 = encodePointsAnnotation(3, {{50.0, 50.0}, {150.0, 150.0}}, 3.0); // LINE_STRIP + auto bytes = encodeImageAnnotations({pa1, pa2}); + + auto result = dec->decode(bytes.data(), bytes.size()); + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->annotations[0].points.size(), 2u); + EXPECT_EQ(result->annotations[0].points[0].topology, AnnotationTopology::kLineLoop); + EXPECT_EQ(result->annotations[0].points[1].topology, AnnotationTopology::kLineStrip); +} + +TEST(SceneDecoderProtobufTest, CircleAnnotationDecodes) { + auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); + ASSERT_NE(dec.get(), nullptr); + + // CircleAnnotation centered at (50, 60) with diameter 20 (radius 10), thickness 1.5, + // semi-transparent red fill, opaque white outline. + auto fill = encodeColor(1.0, 0.0, 0.0, 0.5); + auto outline = encodeColor(1.0, 1.0, 1.0, 1.0); + auto ca = encodeCircleAnnotation(50.0, 60.0, 20.0, 1.5, fill, outline); + + std::vector bytes; + pb::appendTag(bytes, 1, 2); + pb::appendLenDelim(bytes, ca); + + auto result = dec->decode(bytes.data(), bytes.size()); + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->annotations.size(), 1u); + ASSERT_EQ(result->annotations[0].circles.size(), 1u); + const auto& c = result->annotations[0].circles[0]; + EXPECT_DOUBLE_EQ(c.center.x, 50.0); + EXPECT_DOUBLE_EQ(c.center.y, 60.0); + EXPECT_DOUBLE_EQ(c.radius, 10.0); + EXPECT_DOUBLE_EQ(c.thickness, 1.5); + EXPECT_EQ(c.color.r, 255u); // outline = white + EXPECT_EQ(c.color.a, 255u); + EXPECT_EQ(c.fill_color.r, 255u); // fill = red, alpha 0.5 → 128 + EXPECT_EQ(c.fill_color.g, 0u); + EXPECT_NEAR(c.fill_color.a, 128u, 1u); +} + +TEST(SceneDecoderProtobufTest, NullDataReturnsError) { + auto dec = makeSceneDecoder("foxglove.ImageAnnotations"); + ASSERT_NE(dec.get(), nullptr); + auto result = dec->decode(nullptr, 0); + EXPECT_FALSE(result.has_value()); +} + +} // namespace +} // namespace PJ diff --git a/run_clang_tidy.sh b/run_clang_tidy.sh deleted file mode 100755 index 354fee9..0000000 --- a/run_clang_tidy.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -eu - -script_dir=${0%/*} -ws_dir=$(realpath "$script_dir") - -# Check if clangd-22 is available -if ! command -v clangd-22 &> /dev/null; then - echo "Error: clangd-22 is not installed or not in PATH." - echo "" - echo "To install on Ubuntu/Debian via https://apt.llvm.org/:" - echo " wget https://apt.llvm.org/llvm.sh" - echo " chmod +x llvm.sh" - echo " sudo ./llvm.sh 22" - echo " sudo apt install clangd-22 clang-tidy-22" - exit 1 -fi - -if [[ "${1:-}" == "--help" ]]; then - echo "Usage: $(basename "$0") [build_path]" - echo "Run clang-tidy on all C++ sources in pj_base, pj_datastore, and pj_plugins." - echo - echo "Arguments:" - echo " build_path Path to build directory containing compile_commands.json (default: build)" - exit 0 -fi - -cmake_build_path="$ws_dir/${1:-build}" - -if [ ! -f "$cmake_build_path/compile_commands.json" ]; then - echo "Error: compile_commands.json not found in $cmake_build_path" - echo "Please build the project first with CMake to generate compile_commands.json" - exit 1 -fi - -source_dirs=( - "$ws_dir/pj_base" - "$ws_dir/pj_datastore" - "$ws_dir/pj_plugins" -) - -echo "-----------------------------------------------------------" -echo "Running clang-tidy on:" -for dir in "${source_dirs[@]}"; do - echo " $dir" -done -echo "-----------------------------------------------------------" - -find "${source_dirs[@]}" -name '*.cpp' -print0 \ - | xargs -0 -n 1 -P $(nproc) bash -c ' - set -o pipefail - echo "$@" - cd "'"$ws_dir"'" && clangd-22 \ - --log=error \ - --clang-tidy \ - --compile-commands-dir="'"$cmake_build_path"'" \ - --check-locations=false \ - --check="$@" \ - 2>&1 | sed "s/^/${1//\//\\/}: /" - ' _ - -echo "-----------------------------------------------------------" -echo "Clang-tidy complete." -echo "-----------------------------------------------------------"