From 699fa6f15d3dd5bfedfe8e03ea84ac5a8e35ed28 Mon Sep 17 00:00:00 2001 From: Alejandro Hernandez Cordero Date: Thu, 9 Jul 2026 17:25:03 +0200 Subject: [PATCH 1/2] round trip tests Signed-off-by: Alejandro Hernandez Cordero --- compressed_image_transport/CMakeLists.txt | 9 ++ compressed_image_transport/package.xml | 1 + .../test/test_compressed_roundtrip.cpp | 122 ++++++++++++++++++ theora_image_transport/CMakeLists.txt | 9 ++ theora_image_transport/package.xml | 1 + .../test/test_theora_roundtrip.cpp | 116 +++++++++++++++++ zstd_image_transport/CMakeLists.txt | 9 ++ zstd_image_transport/package.xml | 1 + .../test/test_zstd_roundtrip.cpp | 117 +++++++++++++++++ 9 files changed, 385 insertions(+) create mode 100644 compressed_image_transport/test/test_compressed_roundtrip.cpp create mode 100644 theora_image_transport/test/test_theora_roundtrip.cpp create mode 100644 zstd_image_transport/test/test_zstd_roundtrip.cpp diff --git a/compressed_image_transport/CMakeLists.txt b/compressed_image_transport/CMakeLists.txt index 25778a7..d16ed96 100644 --- a/compressed_image_transport/CMakeLists.txt +++ b/compressed_image_transport/CMakeLists.txt @@ -55,6 +55,15 @@ pluginlib_export_plugin_description_file(image_transport compressed_plugins.xml) if(BUILD_TESTING) find_package(ament_lint_auto REQUIRED) ament_lint_auto_find_test_dependencies() + + find_package(ament_cmake_gtest REQUIRED) + # Round-trips an image through the "compressed" transport end to end + # (CompressedPublisher encode -> CompressedSubscriber decode) via a real node. + ament_add_gtest(test_compressed_roundtrip test/test_compressed_roundtrip.cpp) + target_link_libraries(test_compressed_roundtrip + image_transport::image_transport + rclcpp::rclcpp + ${sensor_msgs_TARGETS}) endif() ament_package() diff --git a/compressed_image_transport/package.xml b/compressed_image_transport/package.xml index 03a1d06..917b093 100644 --- a/compressed_image_transport/package.xml +++ b/compressed_image_transport/package.xml @@ -21,6 +21,7 @@ ament_lint_auto ament_lint_common + ament_cmake_gtest ament_cmake diff --git a/compressed_image_transport/test/test_compressed_roundtrip.cpp b/compressed_image_transport/test/test_compressed_roundtrip.cpp new file mode 100644 index 0000000..b0a0c04 --- /dev/null +++ b/compressed_image_transport/test/test_compressed_roundtrip.cpp @@ -0,0 +1,122 @@ +// Copyright (c) 2026, Open Source Robotics Foundation, Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + +// Build a constant-colour bgr8 image (constant colour survives JPEG cleanly). +sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, uint8_t r) +{ + sensor_msgs::msg::Image img; + img.header.frame_id = "camera"; + img.height = h; + img.width = w; + img.encoding = "bgr8"; + img.is_bigendian = 0; + img.step = w * 3; + img.data.resize(static_cast(img.step) * h); + for (size_t i = 0; i < img.data.size(); i += 3) { + img.data[i] = b; + img.data[i + 1] = g; + img.data[i + 2] = r; + } + return img; +} + +} // namespace + +// Publish a raw image and receive it back through the "compressed" transport, +// exercising CompressedPublisher (encode) and CompressedSubscriber (decode) +// end to end. The default codec is JPEG (lossy): structure must match exactly, +// pixel values within a tolerance. +TEST(CompressedRoundTrip, PublishSubscribePreservesImage) +{ + auto node = rclcpp::Node::make_shared("test_compressed_roundtrip"); + const auto qos = rclcpp::QoS(rclcpp::KeepLast(10)); + + sensor_msgs::msg::Image::ConstSharedPtr received; + auto sub = image_transport::create_subscription( + *node, "camera/image", + [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;}, + "compressed", qos); + auto pub = image_transport::create_publisher(*node, "camera/image", qos); + + const auto original = makeBgr8(32, 24, 10, 120, 200); + + rclcpp::executors::SingleThreadedExecutor executor; + auto base = node->get_node_base_interface(); + + const size_t max_retries = 5; + const size_t max_loops = 200; + for (size_t retry = 0; retry < max_retries && !received; ++retry) { + pub.publish(original); + executor.spin_node_some(base); + for (size_t loop = 0; !received && loop < max_loops; ++loop) { + std::this_thread::sleep_for(10ms); + executor.spin_node_some(base); + } + } + + ASSERT_TRUE(received) << "no image received through the compressed transport"; + EXPECT_EQ(received->width, original.width); + EXPECT_EQ(received->height, original.height); + EXPECT_EQ(received->encoding, original.encoding); + ASSERT_EQ(received->data.size(), original.data.size()); + + int max_err = 0; + for (size_t i = 0; i < original.data.size(); ++i) { + max_err = std::max(max_err, std::abs(static_cast(received->data[i]) - + static_cast(original.data[i]))); + } + EXPECT_LE(max_err, 12) << "JPEG round-trip drifted more than expected"; +} + +int main(int argc, char ** argv) +{ + rclcpp::init(argc, argv); + testing::InitGoogleTest(&argc, argv); + const int ret = RUN_ALL_TESTS(); + rclcpp::shutdown(); + return ret; +} diff --git a/theora_image_transport/CMakeLists.txt b/theora_image_transport/CMakeLists.txt index dec4be8..a7d577b 100644 --- a/theora_image_transport/CMakeLists.txt +++ b/theora_image_transport/CMakeLists.txt @@ -123,6 +123,15 @@ if(BUILD_TESTING) find_package(ament_lint_auto REQUIRED) set(ament_cmake_cppcheck_LANGUAGE "c++") ament_lint_auto_find_test_dependencies() + + find_package(ament_cmake_gtest REQUIRED) + # Round-trips an image through the "theora" transport end to end + # (TheoraPublisher encode -> TheoraSubscriber decode) via a real node. + ament_add_gtest(test_theora_roundtrip test/test_theora_roundtrip.cpp) + target_link_libraries(test_theora_roundtrip + image_transport::image_transport + rclcpp::rclcpp + ${sensor_msgs_TARGETS}) endif() pluginlib_export_plugin_description_file(image_transport theora_plugins.xml) diff --git a/theora_image_transport/package.xml b/theora_image_transport/package.xml index 7c5b21b..e27dd5f 100644 --- a/theora_image_transport/package.xml +++ b/theora_image_transport/package.xml @@ -36,6 +36,7 @@ ament_lint_auto ament_lint_common + ament_cmake_gtest rosidl_interface_packages diff --git a/theora_image_transport/test/test_theora_roundtrip.cpp b/theora_image_transport/test/test_theora_roundtrip.cpp new file mode 100644 index 0000000..bbb3f3b --- /dev/null +++ b/theora_image_transport/test/test_theora_roundtrip.cpp @@ -0,0 +1,116 @@ +// Copyright (c) 2026, Open Source Robotics Foundation, Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include + +#include +#include +#include +#include + +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + +// Build a constant-colour bgr8 image with dimensions that are multiples of 16 +// (theora encodes in 16x16 macroblocks). +sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, uint8_t r) +{ + sensor_msgs::msg::Image img; + img.header.frame_id = "camera"; + img.height = h; + img.width = w; + img.encoding = "bgr8"; + img.is_bigendian = 0; + img.step = w * 3; + img.data.resize(static_cast(img.step) * h); + for (size_t i = 0; i < img.data.size(); i += 3) { + img.data[i] = b; + img.data[i + 1] = g; + img.data[i + 2] = r; + } + return img; +} + +} // namespace + +// Round-trip through the "theora" transport (TheoraPublisher encode -> +// TheoraSubscriber decode). Theora is a stateful streaming codec: header +// packets must arrive before a keyframe can be decoded, so several frames are +// published. Theora is lossy, so only the cloud structure is checked exactly. +TEST(TheoraRoundTrip, PublishSubscribeDecodesFrame) +{ + auto node = rclcpp::Node::make_shared("test_theora_roundtrip"); + const auto qos = rclcpp::QoS(rclcpp::KeepLast(10)); + + sensor_msgs::msg::Image::ConstSharedPtr received; + auto sub = image_transport::create_subscription( + *node, "camera/image", + [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;}, + "theora", qos); + auto pub = image_transport::create_publisher(*node, "camera/image", qos); + + const auto original = makeBgr8(64, 48, 40, 90, 160); + + rclcpp::executors::SingleThreadedExecutor executor; + auto base = node->get_node_base_interface(); + + // Publish a stream of frames; the decoder needs the header packets plus a + // keyframe before it can emit a decoded image. + const size_t max_frames = 30; + const size_t max_loops = 50; + for (size_t frame = 0; frame < max_frames && !received; ++frame) { + pub.publish(original); + executor.spin_node_some(base); + for (size_t loop = 0; !received && loop < max_loops; ++loop) { + std::this_thread::sleep_for(10ms); + executor.spin_node_some(base); + } + } + + ASSERT_TRUE(received) << "no image decoded through the theora transport"; + EXPECT_EQ(received->width, original.width); + EXPECT_EQ(received->height, original.height); + EXPECT_FALSE(received->data.empty()); + EXPECT_FALSE(received->encoding.empty()); +} + +int main(int argc, char ** argv) +{ + rclcpp::init(argc, argv); + testing::InitGoogleTest(&argc, argv); + const int ret = RUN_ALL_TESTS(); + rclcpp::shutdown(); + return ret; +} diff --git a/zstd_image_transport/CMakeLists.txt b/zstd_image_transport/CMakeLists.txt index 82676bc..945d08f 100644 --- a/zstd_image_transport/CMakeLists.txt +++ b/zstd_image_transport/CMakeLists.txt @@ -52,6 +52,15 @@ pluginlib_export_plugin_description_file(image_transport zstd_plugins.xml) if(BUILD_TESTING) find_package(ament_lint_auto REQUIRED) ament_lint_auto_find_test_dependencies() + + find_package(ament_cmake_gtest REQUIRED) + # Compile the codec sources directly into the test so it does not depend on + # image_transport (and its transitive links) to exercise the compression core. + ament_add_gtest(test_zstd_roundtrip test/test_zstd_roundtrip.cpp src/zlib_cpp.cpp) + target_include_directories(test_zstd_roundtrip PRIVATE src) + target_link_libraries(test_zstd_roundtrip + ament_cmake_ros_core::ament_ros_defaults + ZLIB::ZLIB) endif() ament_package() diff --git a/zstd_image_transport/package.xml b/zstd_image_transport/package.xml index 3924816..d265b59 100644 --- a/zstd_image_transport/package.xml +++ b/zstd_image_transport/package.xml @@ -19,6 +19,7 @@ ament_lint_auto ament_lint_common + ament_cmake_gtest ament_cmake diff --git a/zstd_image_transport/test/test_zstd_roundtrip.cpp b/zstd_image_transport/test/test_zstd_roundtrip.cpp new file mode 100644 index 0000000..1932ff5 --- /dev/null +++ b/zstd_image_transport/test/test_zstd_roundtrip.cpp @@ -0,0 +1,117 @@ +// Copyright (c) 2026, Open Source Robotics Foundation, Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include + +#include +#include +#include + +#include // NOLINT(build/include_order) cpplint misclassifies + +#include "byte_order.hpp" +#include "zlib_cpp.hpp" + +namespace +{ + +// Build a deterministic byte buffer of `n` bytes. +std::vector makeBuffer(std::size_t n, std::uint8_t fill_pattern = 0) +{ + std::vector b(n); + for (std::size_t i = 0; i < n; ++i) { + b[i] = fill_pattern ? fill_pattern : static_cast((i * 31u + 7u) & 0xFFu); + } + return b; +} + +std::vector roundtrip(const std::vector & input) +{ + zlib::Comp comp(zlib::Comp::Level::Level_6, true); + EXPECT_TRUE(comp.IsSucc()); + const std::vector compressed = + comp.Process(std::span(input.data(), input.size()), true); + + zlib::Decomp decomp; + return decomp.Process(std::span(compressed.data(), compressed.size())); +} + +} // namespace + +// zlib is lossless: compress -> decompress must reproduce the input exactly, +// across a range of sizes (incl. the empty payload). +TEST(ZstdCodecRoundTrip, LosslessAcrossSizes) +{ + for (std::size_t n : {std::size_t{0}, std::size_t{1}, std::size_t{17}, std::size_t{1024}, + std::size_t{100000}}) + { + const auto original = makeBuffer(n); + const auto restored = roundtrip(original); + EXPECT_EQ(restored, original) << "roundtrip mismatch for size " << n; + } +} + +// A highly repetitive payload must actually shrink (proves compression runs). +TEST(ZstdCodecRoundTrip, ShrinksCompressibleData) +{ + const auto original = makeBuffer(65536, 0xAB); + zlib::Comp comp(zlib::Comp::Level::Level_6, true); + const auto compressed = + comp.Process(std::span(original.data(), original.size()), true); + EXPECT_GT(compressed.size(), 0u); + EXPECT_LT(compressed.size(), original.size()); + EXPECT_EQ(roundtrip(original), original); +} + +// Invalid input must be rejected gracefully (no crash, empty output). +TEST(ZstdCodecRoundTrip, RejectsGarbage) +{ + const auto garbage = makeBuffer(64, 0x5A); + zlib::Decomp decomp; + const auto out = decomp.Process(std::span(garbage.data(), garbage.size())); + EXPECT_TRUE(out.empty()); +} + +// The little-endian header helpers used by the (de)serializer must round-trip +// exactly, including values with the high bit set (the old shift code was UB). +TEST(ZstdByteOrder, StoreLoadRoundTrip) +{ + using zstd_image_transport::load_le; + using zstd_image_transport::store_le; + for (std::uint32_t v : {0u, 1u, 255u, 256u, 0x80000000u, 0xFFFFFFFFu, 0xDEADBEEFu}) { + std::array b{}; + store_le(std::span(b), v); + EXPECT_EQ(load_le(std::span(b)), v); + } + // Little-endian byte order: least-significant byte first. + std::array b{}; + store_le(std::span(b), 0x11223344u); + EXPECT_EQ(b[0], 0x44); + EXPECT_EQ(b[3], 0x11); +} From 4bac2aacf88f4ed580b4ab9aea118fd63357db86 Mon Sep 17 00:00:00 2001 From: Alejandro Hernandez Cordero Date: Thu, 9 Jul 2026 17:45:10 +0200 Subject: [PATCH 2/2] Node transport too Signed-off-by: Alejandro Hernandez Cordero --- compressed_image_transport/CMakeLists.txt | 12 +- .../test/test_compressed_roundtrip.cpp | 132 ++++++++--------- .../test/test_compressed_transport.cpp | 139 ++++++++++++++++++ theora_image_transport/CMakeLists.txt | 10 +- ...oundtrip.cpp => test_theora_transport.cpp} | 79 +++++++--- zstd_image_transport/CMakeLists.txt | 8 + .../test/test_zstd_transport.cpp | 129 ++++++++++++++++ 7 files changed, 409 insertions(+), 100 deletions(-) create mode 100644 compressed_image_transport/test/test_compressed_transport.cpp rename theora_image_transport/test/{test_theora_roundtrip.cpp => test_theora_transport.cpp} (62%) create mode 100644 zstd_image_transport/test/test_zstd_transport.cpp diff --git a/compressed_image_transport/CMakeLists.txt b/compressed_image_transport/CMakeLists.txt index d16ed96..cd1a263 100644 --- a/compressed_image_transport/CMakeLists.txt +++ b/compressed_image_transport/CMakeLists.txt @@ -57,10 +57,18 @@ if(BUILD_TESTING) ament_lint_auto_find_test_dependencies() find_package(ament_cmake_gtest REQUIRED) - # Round-trips an image through the "compressed" transport end to end - # (CompressedPublisher encode -> CompressedSubscriber decode) via a real node. + # Codec-level round-trip: exercises the JPEG/PNG codecs directly (OpenCV), + # with no ROS node or transport. ament_add_gtest(test_compressed_roundtrip test/test_compressed_roundtrip.cpp) + target_include_directories(test_compressed_roundtrip PRIVATE ${OpenCV_INCLUDE_DIRS}) target_link_libraries(test_compressed_roundtrip + ament_cmake_ros_core::ament_ros_defaults + ${OpenCV_LIBRARIES}) + + # End-to-end integration over the "compressed" image transport through a real + # node, plus a pluginlib-discovery check. + ament_add_gtest(test_compressed_transport test/test_compressed_transport.cpp) + target_link_libraries(test_compressed_transport image_transport::image_transport rclcpp::rclcpp ${sensor_msgs_TARGETS}) diff --git a/compressed_image_transport/test/test_compressed_roundtrip.cpp b/compressed_image_transport/test/test_compressed_roundtrip.cpp index b0a0c04..9f83bda 100644 --- a/compressed_image_transport/test/test_compressed_roundtrip.cpp +++ b/compressed_image_transport/test/test_compressed_roundtrip.cpp @@ -27,96 +27,88 @@ // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. -#include +// Codec-level round-trip tests: they exercise the OpenCV image codecs the +// compressed transport relies on (JPEG / PNG), with no ROS node or transport. -#include -#include -#include -#include -#include -#include +#include -#include -#include -#include +#include -using namespace std::chrono_literals; +#include +#include namespace { -// Build a constant-colour bgr8 image (constant colour survives JPEG cleanly). -sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, uint8_t r) +// Build a bgr8 image with a deterministic per-pixel pattern. +cv::Mat makeBgr8(int w, int h) { - sensor_msgs::msg::Image img; - img.header.frame_id = "camera"; - img.height = h; - img.width = w; - img.encoding = "bgr8"; - img.is_bigendian = 0; - img.step = w * 3; - img.data.resize(static_cast(img.step) * h); - for (size_t i = 0; i < img.data.size(); i += 3) { - img.data[i] = b; - img.data[i + 1] = g; - img.data[i + 2] = r; + cv::Mat img(h, w, CV_8UC3); + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + img.at(y, x) = cv::Vec3b( + static_cast((x * 7) & 0xFF), + static_cast((y * 5) & 0xFF), + static_cast((x + y) & 0xFF)); + } } return img; } +int maxAbsDiff(const cv::Mat & a, const cv::Mat & b) +{ + cv::Mat diff; + cv::absdiff(a, b, diff); + double maxval = 0.0; + cv::minMaxLoc(diff.reshape(1), nullptr, &maxval); + return static_cast(maxval); +} + } // namespace -// Publish a raw image and receive it back through the "compressed" transport, -// exercising CompressedPublisher (encode) and CompressedSubscriber (decode) -// end to end. The default codec is JPEG (lossy): structure must match exactly, -// pixel values within a tolerance. -TEST(CompressedRoundTrip, PublishSubscribePreservesImage) +// PNG is lossless: encode -> decode must reproduce the image exactly. +TEST(CompressedCodecRoundTrip, PngIsLossless) { - auto node = rclcpp::Node::make_shared("test_compressed_roundtrip"); - const auto qos = rclcpp::QoS(rclcpp::KeepLast(10)); + const cv::Mat original = makeBgr8(32, 24); + std::vector buffer; + const std::vector params = {cv::IMWRITE_PNG_COMPRESSION, 3}; + ASSERT_TRUE(cv::imencode(".png", original, buffer, params)); - sensor_msgs::msg::Image::ConstSharedPtr received; - auto sub = image_transport::create_subscription( - *node, "camera/image", - [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;}, - "compressed", qos); - auto pub = image_transport::create_publisher(*node, "camera/image", qos); + const cv::Mat decoded = cv::imdecode(buffer, cv::IMREAD_UNCHANGED); + ASSERT_FALSE(decoded.empty()); + EXPECT_EQ(decoded.size(), original.size()); + EXPECT_EQ(decoded.type(), original.type()); + EXPECT_EQ(maxAbsDiff(original, decoded), 0); +} - const auto original = makeBgr8(32, 24, 10, 120, 200); +// JPEG is lossy: structure exact, pixel values within a tolerance. +TEST(CompressedCodecRoundTrip, JpegApproximate) +{ + const cv::Mat original = makeBgr8(32, 24); + std::vector buffer; + const std::vector params = {cv::IMWRITE_JPEG_QUALITY, 95}; + ASSERT_TRUE(cv::imencode(".jpg", original, buffer, params)); - rclcpp::executors::SingleThreadedExecutor executor; - auto base = node->get_node_base_interface(); + const cv::Mat decoded = cv::imdecode(buffer, cv::IMREAD_COLOR); + ASSERT_FALSE(decoded.empty()); + EXPECT_EQ(decoded.size(), original.size()); + EXPECT_LE(maxAbsDiff(original, decoded), 20) << "JPEG round-trip drifted more than expected"; +} - const size_t max_retries = 5; - const size_t max_loops = 200; - for (size_t retry = 0; retry < max_retries && !received; ++retry) { - pub.publish(original); - executor.spin_node_some(base); - for (size_t loop = 0; !received && loop < max_loops; ++loop) { - std::this_thread::sleep_for(10ms); - executor.spin_node_some(base); +// A single-channel 16-bit image (depth-like) must survive PNG losslessly. +TEST(CompressedCodecRoundTrip, Png16BitIsLossless) +{ + cv::Mat original(24, 32, CV_16UC1); + for (int y = 0; y < original.rows; ++y) { + for (int x = 0; x < original.cols; ++x) { + original.at(y, x) = static_cast((x * 337 + y * 71) & 0xFFFF); } } + std::vector buffer; + ASSERT_TRUE(cv::imencode(".png", original, buffer)); - ASSERT_TRUE(received) << "no image received through the compressed transport"; - EXPECT_EQ(received->width, original.width); - EXPECT_EQ(received->height, original.height); - EXPECT_EQ(received->encoding, original.encoding); - ASSERT_EQ(received->data.size(), original.data.size()); - - int max_err = 0; - for (size_t i = 0; i < original.data.size(); ++i) { - max_err = std::max(max_err, std::abs(static_cast(received->data[i]) - - static_cast(original.data[i]))); - } - EXPECT_LE(max_err, 12) << "JPEG round-trip drifted more than expected"; -} - -int main(int argc, char ** argv) -{ - rclcpp::init(argc, argv); - testing::InitGoogleTest(&argc, argv); - const int ret = RUN_ALL_TESTS(); - rclcpp::shutdown(); - return ret; + const cv::Mat decoded = cv::imdecode(buffer, cv::IMREAD_UNCHANGED); + ASSERT_FALSE(decoded.empty()); + EXPECT_EQ(decoded.type(), original.type()); + EXPECT_EQ(maxAbsDiff(original, decoded), 0); } diff --git a/compressed_image_transport/test/test_compressed_transport.cpp b/compressed_image_transport/test/test_compressed_transport.cpp new file mode 100644 index 0000000..af5c4af --- /dev/null +++ b/compressed_image_transport/test/test_compressed_transport.cpp @@ -0,0 +1,139 @@ +// Copyright (c) 2026, Open Source Robotics Foundation, Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + +// Build a constant-colour bgr8 image (constant colour survives JPEG cleanly). +sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, uint8_t r) +{ + sensor_msgs::msg::Image img; + img.header.frame_id = "camera"; + img.height = h; + img.width = w; + img.encoding = "bgr8"; + img.is_bigendian = 0; + img.step = w * 3; + img.data.resize(static_cast(img.step) * h); + for (size_t i = 0; i < img.data.size(); i += 3) { + img.data[i] = b; + img.data[i + 1] = g; + img.data[i + 2] = r; + } + return img; +} + +} // namespace + +class CompressedTransportTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + if (!rclcpp::ok()) { + rclcpp::init(0, nullptr); + } + } + static void TearDownTestSuite() + { + rclcpp::shutdown(); + } +}; + +// The compressed transport plugin must be discoverable through pluginlib. +TEST_F(CompressedTransportTest, TransportIsLoadable) +{ + bool found = false; + for (const auto & name : image_transport::getLoadableTransports()) { + if (name.find("compressed") != std::string::npos) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "compressed transport not found among loadable transports"; +} + +// Full integration over the compressed transport (default JPEG codec, lossy): +// structure must match exactly, pixel values within a tolerance. +TEST_F(CompressedTransportTest, PublishSubscribeRoundTrip) +{ + auto node = std::make_shared("compressed_transport_test"); + + sensor_msgs::msg::Image::ConstSharedPtr received; + auto sub = image_transport::create_subscription( + *node, "test_image", + [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;}, + "compressed", rclcpp::SystemDefaultsQoS()); + auto pub = image_transport::create_publisher(*node, "test_image", rclcpp::SystemDefaultsQoS()); + + const auto original = makeBgr8(32, 24, 10, 120, 200); + + rclcpp::executors::SingleThreadedExecutor exec; + const auto deadline = std::chrono::steady_clock::now() + 15s; + while (!received && std::chrono::steady_clock::now() < deadline && rclcpp::ok()) { + pub.publish(original); + exec.spin_node_some(node); + std::this_thread::sleep_for(50ms); + } + + ASSERT_TRUE(received) << "no image received through the compressed transport within the timeout"; + EXPECT_EQ(received->width, original.width); + EXPECT_EQ(received->height, original.height); + EXPECT_EQ(received->encoding, original.encoding); + ASSERT_EQ(received->data.size(), original.data.size()); + + int max_err = 0; + for (size_t i = 0; i < original.data.size(); ++i) { + max_err = std::max(max_err, std::abs(static_cast(received->data[i]) - + static_cast(original.data[i]))); + } + EXPECT_LE(max_err, 12) << "JPEG round-trip drifted more than expected"; +} + +int main(int argc, char ** argv) +{ + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/theora_image_transport/CMakeLists.txt b/theora_image_transport/CMakeLists.txt index a7d577b..2346a19 100644 --- a/theora_image_transport/CMakeLists.txt +++ b/theora_image_transport/CMakeLists.txt @@ -125,10 +125,12 @@ if(BUILD_TESTING) ament_lint_auto_find_test_dependencies() find_package(ament_cmake_gtest REQUIRED) - # Round-trips an image through the "theora" transport end to end - # (TheoraPublisher encode -> TheoraSubscriber decode) via a real node. - ament_add_gtest(test_theora_roundtrip test/test_theora_roundtrip.cpp) - target_link_libraries(test_theora_roundtrip + # theora exposes no standalone codec API (encode/decode live in the stateful + # plugin), so it is covered only by the end-to-end transport test below, which + # exercises TheoraPublisher (encode) and TheoraSubscriber (decode) through a + # real node, plus a pluginlib-discovery check. + ament_add_gtest(test_theora_transport test/test_theora_transport.cpp) + target_link_libraries(test_theora_transport image_transport::image_transport rclcpp::rclcpp ${sensor_msgs_TARGETS}) diff --git a/theora_image_transport/test/test_theora_roundtrip.cpp b/theora_image_transport/test/test_theora_transport.cpp similarity index 62% rename from theora_image_transport/test/test_theora_roundtrip.cpp rename to theora_image_transport/test/test_theora_transport.cpp index bbb3f3b..ec21b77 100644 --- a/theora_image_transport/test/test_theora_roundtrip.cpp +++ b/theora_image_transport/test/test_theora_transport.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include @@ -65,41 +66,74 @@ sensor_msgs::msg::Image makeBgr8(uint32_t w, uint32_t h, uint8_t b, uint8_t g, u } // namespace -// Round-trip through the "theora" transport (TheoraPublisher encode -> -// TheoraSubscriber decode). Theora is a stateful streaming codec: header -// packets must arrive before a keyframe can be decoded, so several frames are -// published. Theora is lossy, so only the cloud structure is checked exactly. -TEST(TheoraRoundTrip, PublishSubscribeDecodesFrame) +class TheoraTransportTest : public ::testing::Test { - auto node = rclcpp::Node::make_shared("test_theora_roundtrip"); - const auto qos = rclcpp::QoS(rclcpp::KeepLast(10)); +protected: + static void SetUpTestSuite() + { + if (!rclcpp::ok()) { + rclcpp::init(0, nullptr); + } + } + static void TearDownTestSuite() + { + rclcpp::shutdown(); + } +}; + +// The theora transport plugin must be discoverable through pluginlib. +TEST_F(TheoraTransportTest, TransportIsLoadable) +{ + bool found = false; + for (const auto & name : image_transport::getLoadableTransports()) { + if (name.find("theora") != std::string::npos) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "theora transport not found among loadable transports"; +} + +// Full integration over the theora transport. Theora is a stateful streaming +// codec (header packets + a keyframe must arrive before a frame decodes), so a +// stream of frames is published. Theora is lossy: only structure is checked. +TEST_F(TheoraTransportTest, PublishSubscribeRoundTrip) +{ + auto node = std::make_shared("theora_transport_test"); + const auto qos = rclcpp::QoS(rclcpp::KeepLast(10)); sensor_msgs::msg::Image::ConstSharedPtr received; auto sub = image_transport::create_subscription( - *node, "camera/image", + *node, "test_image", [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;}, "theora", qos); - auto pub = image_transport::create_publisher(*node, "camera/image", qos); + auto pub = image_transport::create_publisher(*node, "test_image", qos); const auto original = makeBgr8(64, 48, 40, 90, 160); - rclcpp::executors::SingleThreadedExecutor executor; - auto base = node->get_node_base_interface(); + rclcpp::executors::SingleThreadedExecutor exec; + + // Wait for the subscription to be matched before publishing: theora sends its + // stream headers with the first frame, so a late-matched subscriber would miss + // them and never be able to decode. + const auto match_deadline = std::chrono::steady_clock::now() + 10s; + while (pub.getNumSubscribers() == 0 && + std::chrono::steady_clock::now() < match_deadline && rclcpp::ok()) + { + exec.spin_node_some(node); + std::this_thread::sleep_for(10ms); + } - // Publish a stream of frames; the decoder needs the header packets plus a - // keyframe before it can emit a decoded image. - const size_t max_frames = 30; - const size_t max_loops = 50; - for (size_t frame = 0; frame < max_frames && !received; ++frame) { + const auto deadline = std::chrono::steady_clock::now() + 20s; + while (!received && std::chrono::steady_clock::now() < deadline && rclcpp::ok()) { pub.publish(original); - executor.spin_node_some(base); - for (size_t loop = 0; !received && loop < max_loops; ++loop) { + for (int i = 0; i < 20 && !received; ++i) { + exec.spin_node_some(node); std::this_thread::sleep_for(10ms); - executor.spin_node_some(base); } } - ASSERT_TRUE(received) << "no image decoded through the theora transport"; + ASSERT_TRUE(received) << "no image decoded through the theora transport within the timeout"; EXPECT_EQ(received->width, original.width); EXPECT_EQ(received->height, original.height); EXPECT_FALSE(received->data.empty()); @@ -108,9 +142,6 @@ TEST(TheoraRoundTrip, PublishSubscribeDecodesFrame) int main(int argc, char ** argv) { - rclcpp::init(argc, argv); testing::InitGoogleTest(&argc, argv); - const int ret = RUN_ALL_TESTS(); - rclcpp::shutdown(); - return ret; + return RUN_ALL_TESTS(); } diff --git a/zstd_image_transport/CMakeLists.txt b/zstd_image_transport/CMakeLists.txt index 945d08f..3a2f5c2 100644 --- a/zstd_image_transport/CMakeLists.txt +++ b/zstd_image_transport/CMakeLists.txt @@ -61,6 +61,14 @@ if(BUILD_TESTING) target_link_libraries(test_zstd_roundtrip ament_cmake_ros_core::ament_ros_defaults ZLIB::ZLIB) + + # End-to-end integration over the "zstd" image transport through a real node + # (requires the installed plugin, discovered via pluginlib at runtime). + ament_add_gtest(test_zstd_transport test/test_zstd_transport.cpp) + target_link_libraries(test_zstd_transport + image_transport::image_transport + rclcpp::rclcpp + ${sensor_msgs_TARGETS}) endif() ament_package() diff --git a/zstd_image_transport/test/test_zstd_transport.cpp b/zstd_image_transport/test/test_zstd_transport.cpp new file mode 100644 index 0000000..0413118 --- /dev/null +++ b/zstd_image_transport/test/test_zstd_transport.cpp @@ -0,0 +1,129 @@ +// Copyright (c) 2026, Open Source Robotics Foundation, Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + +// Build a bgr8 image with a varying (non-constant) pattern to exercise the codec. +sensor_msgs::msg::Image makeImage(uint32_t w, uint32_t h) +{ + sensor_msgs::msg::Image img; + img.header.frame_id = "camera"; + img.height = h; + img.width = w; + img.encoding = "bgr8"; + img.is_bigendian = 0; + img.step = w * 3; + img.data.resize(static_cast(img.step) * h); + for (size_t i = 0; i < img.data.size(); ++i) { + img.data[i] = static_cast((i * 131u + 17u) & 0xFFu); + } + return img; +} + +} // namespace + +class ZstdTransportTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + if (!rclcpp::ok()) { + rclcpp::init(0, nullptr); + } + } + static void TearDownTestSuite() + { + rclcpp::shutdown(); + } +}; + +// The zstd transport plugin must be discoverable through pluginlib. +TEST_F(ZstdTransportTest, TransportIsLoadable) +{ + bool found = false; + for (const auto & name : image_transport::getLoadableTransports()) { + if (name.find("zstd") != std::string::npos) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "zstd transport not found among loadable transports"; +} + +// Full integration over the zstd transport. zstd is lossless, so the received +// image must equal the original exactly (data and layout metadata). +TEST_F(ZstdTransportTest, PublishSubscribeRoundTrip) +{ + auto node = std::make_shared("zstd_transport_test"); + + sensor_msgs::msg::Image::ConstSharedPtr received; + auto sub = image_transport::create_subscription( + *node, "test_image", + [&received](const sensor_msgs::msg::Image::ConstSharedPtr & msg) {received = msg;}, + "zstd", rclcpp::SystemDefaultsQoS()); + auto pub = image_transport::create_publisher(*node, "test_image", rclcpp::SystemDefaultsQoS()); + + const auto original = makeImage(64, 48); + + rclcpp::executors::SingleThreadedExecutor exec; + const auto deadline = std::chrono::steady_clock::now() + 15s; + while (!received && std::chrono::steady_clock::now() < deadline && rclcpp::ok()) { + pub.publish(original); + exec.spin_node_some(node); + std::this_thread::sleep_for(50ms); + } + + ASSERT_TRUE(received) << "no image received through the zstd transport within the timeout"; + EXPECT_EQ(received->width, original.width); + EXPECT_EQ(received->height, original.height); + EXPECT_EQ(received->step, original.step); + EXPECT_EQ(received->encoding, original.encoding); + EXPECT_EQ(received->data, original.data); // lossless +} + +int main(int argc, char ** argv) +{ + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}