/* Copyright (c) 2010-2026, Mathieu Labbe - IntRoLab - Universite de Sherbrooke 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 Universite de Sherbrooke 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 AUTHOR 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 rtabmap_conversions; namespace { // A transform with translation and rotation on all three axes, so that a // round-trip that drops or swaps a component cannot pass by accident. rtabmap::Transform sampleTransform() { return rtabmap::Transform(1.0f, -2.0f, 3.0f, 0.1f, -0.2f, 0.3f); } void expectTransformNear( const rtabmap::Transform & actual, const rtabmap::Transform & expected, float epsilon = 1e-5f) { ASSERT_FALSE(actual.isNull()) << "expected " << expected.prettyPrint(); for(int i=0; i<12; ++i) { EXPECT_NEAR(actual.data()[i], expected.data()[i], epsilon) << "at index " << i << "\n actual: " << actual.prettyPrint() << "\n expected: " << expected.prettyPrint(); } } } // namespace ///////////////////////// // Transform <-> geometry_msgs ///////////////////////// TEST(MsgConversion, transformGeometryMsgRoundTrip) { const rtabmap::Transform in = sampleTransform(); geometry_msgs::msg::Transform msg; transformToGeometryMsg(in, msg); expectTransformNear(transformFromGeometryMsg(msg), in); } TEST(MsgConversion, transformGeometryMsgQuaternionIsNormalized) { geometry_msgs::msg::Transform msg; transformToGeometryMsg(sampleTransform(), msg); const double norm = std::sqrt( msg.rotation.x * msg.rotation.x + msg.rotation.y * msg.rotation.y + msg.rotation.z * msg.rotation.z + msg.rotation.w * msg.rotation.w); EXPECT_NEAR(norm, 1.0, 1e-9); } TEST(MsgConversion, transformGeometryMsgNullRoundTrip) { geometry_msgs::msg::Transform msg; transformToGeometryMsg(rtabmap::Transform(), msg); // A null transform is encoded as an all-zero quaternion. EXPECT_EQ(msg.rotation.x, 0.0); EXPECT_EQ(msg.rotation.y, 0.0); EXPECT_EQ(msg.rotation.z, 0.0); EXPECT_EQ(msg.rotation.w, 0.0); EXPECT_TRUE(transformFromGeometryMsg(msg).isNull()); } TEST(MsgConversion, transformGeometryMsgIdentityIsNotNull) { geometry_msgs::msg::Transform msg; transformToGeometryMsg(rtabmap::Transform::getIdentity(), msg); const rtabmap::Transform out = transformFromGeometryMsg(msg); EXPECT_FALSE(out.isNull()); EXPECT_TRUE(out.isIdentity()); } ///////////////////////// // Transform <-> tf2 ///////////////////////// TEST(MsgConversion, transformTFRoundTrip) { const rtabmap::Transform in = sampleTransform(); tf2::Transform tf; EXPECT_TRUE(transformToTF(in, tf)); expectTransformNear(transformFromTF(tf), in); } TEST(MsgConversion, transformTFIdentityRoundTrip) { tf2::Transform tf; EXPECT_TRUE(transformToTF(rtabmap::Transform::getIdentity(), tf)) << "an identity transform is not a null transform"; const rtabmap::Transform out = transformFromTF(tf); EXPECT_FALSE(out.isNull()); EXPECT_TRUE(out.isIdentity()); } TEST(MsgConversion, transformToTFWritesTranslationAndRotation) { // Guards against the output being left untouched: seed it with a value that // differs from the expected result, then check it was actually overwritten. tf2::Transform tf(tf2::Quaternion(0, 0, 0, 1), tf2::Vector3(99, 99, 99)); EXPECT_TRUE(transformToTF(sampleTransform(), tf)); EXPECT_NEAR(tf.getOrigin().x(), 1.0, 1e-5); EXPECT_NEAR(tf.getOrigin().y(), -2.0, 1e-5); EXPECT_NEAR(tf.getOrigin().z(), 3.0, 1e-5); geometry_msgs::msg::Transform expected; transformToGeometryMsg(sampleTransform(), expected); EXPECT_NEAR(tf.getRotation().x(), expected.rotation.x, 1e-5); EXPECT_NEAR(tf.getRotation().y(), expected.rotation.y, 1e-5); EXPECT_NEAR(tf.getRotation().z(), expected.rotation.z, 1e-5); EXPECT_NEAR(tf.getRotation().w(), expected.rotation.w, 1e-5); } TEST(MsgConversion, transformToTFNullReturnsFalseAndNaN) { tf2::Transform tf(tf2::Quaternion(0, 0, 0, 1), tf2::Vector3(99, 99, 99)); EXPECT_FALSE(transformToTF(rtabmap::Transform(), tf)); // tf2::Transform cannot carry a null sentinel, so the output is poisoned with NaN // on purpose: a caller that ignores the return value must fail loudly rather than // silently proceed with a plausible-looking identity. for(int i=0; i<3; ++i) { EXPECT_TRUE(std::isnan(tf.getBasis()[i].x())) << "basis row " << i; EXPECT_TRUE(std::isnan(tf.getBasis()[i].y())) << "basis row " << i; EXPECT_TRUE(std::isnan(tf.getBasis()[i].z())) << "basis row " << i; } EXPECT_TRUE(std::isnan(tf.getOrigin().x())); EXPECT_TRUE(std::isnan(tf.getOrigin().y())); EXPECT_TRUE(std::isnan(tf.getOrigin().z())); const tf2::Quaternion q = tf.getRotation(); EXPECT_TRUE(std::isnan(q.x())); EXPECT_TRUE(std::isnan(q.y())); EXPECT_TRUE(std::isnan(q.z())); EXPECT_TRUE(std::isnan(q.w())); } TEST(MsgConversion, transformToTFNullPoisonsComposition) { // The point of the NaN: it propagates through downstream math instead of // quietly producing a wrong-but-finite answer. tf2::Transform tf; EXPECT_FALSE(transformToTF(rtabmap::Transform(), tf)); const tf2::Transform composed = tf * tf2::Transform(tf2::Quaternion(0, 0, 0, 1), tf2::Vector3(1, 2, 3)); EXPECT_TRUE(std::isnan(composed.getOrigin().x())); EXPECT_TRUE(std::isnan(composed.getOrigin().y())); EXPECT_TRUE(std::isnan(composed.getOrigin().z())); } TEST(MsgConversion, transformFromTFDetectsNaN) { const tf2Scalar nan = std::numeric_limits::quiet_NaN(); // NaN anywhere in the rotation basis... EXPECT_TRUE(transformFromTF(tf2::Transform( tf2::Matrix3x3(nan, nan, nan, nan, nan, nan, nan, nan, nan), tf2::Vector3(0, 0, 0))).isNull()) << "NaN basis"; // ...or in the translation alone must yield a null transform. EXPECT_TRUE(transformFromTF(tf2::Transform( tf2::Quaternion(0, 0, 0, 1), tf2::Vector3(nan, 0, 0))).isNull()) << "NaN origin"; } TEST(MsgConversion, transformTFNullRoundTrip) { // The pair round-trips a null transform: toTF poisons with NaN and reports // false, fromTF maps that back to null. tf2::Transform tf; EXPECT_FALSE(transformToTF(rtabmap::Transform(), tf)); EXPECT_TRUE(transformFromTF(tf).isNull()); } TEST(MsgConversion, transformFromTFAcceptsValidTransforms) { // The NaN guard must not reject legitimate values, including zeros. EXPECT_FALSE(transformFromTF(tf2::Transform( tf2::Quaternion(0, 0, 0, 1), tf2::Vector3(0, 0, 0))).isNull()); EXPECT_FALSE(transformFromTF(tf2::Transform( tf2::Quaternion(0, 0, 0, 1), tf2::Vector3(-1, 2, -3))).isNull()); } ///////////////////////// // Transform <-> Pose ///////////////////////// TEST(MsgConversion, transformPoseMsgRoundTrip) { const rtabmap::Transform in = sampleTransform(); geometry_msgs::msg::Pose msg; transformToPoseMsg(in, msg); expectTransformNear(transformFromPoseMsg(msg), in); } TEST(MsgConversion, transformPoseMsgNullIsNull) { geometry_msgs::msg::Pose msg; transformToPoseMsg(rtabmap::Transform(), msg); EXPECT_TRUE(transformFromPoseMsg(msg).isNull()); } TEST(MsgConversion, transformPoseMsgIgnoreRotationIfNotSet) { // Note: geometry_msgs::msg::Quaternion defaults to w=1.0, so an "unset" // orientation has to be zeroed explicitly to reach the branch under test. geometry_msgs::msg::Pose msg; msg.position.x = 1.0; msg.position.y = 2.0; msg.position.z = 3.0; msg.orientation.w = 0.0; // An all-zero orientation normally yields a null transform... EXPECT_TRUE(transformFromPoseMsg(msg, false).isNull()); // ...but with ignoreRotationIfNotSet the translation is kept with no rotation. expectTransformNear( transformFromPoseMsg(msg, true), rtabmap::Transform(1.0f, 2.0f, 3.0f, 0.0f, 0.0f, 0.0f)); } ///////////////////////// // Points and keypoints ///////////////////////// TEST(MsgConversion, point2fRoundTrip) { const cv::Point2f in(1.5f, -2.5f); rtabmap_msgs::msg::Point2f msg; point2fToROS(in, msg); const cv::Point2f out = point2fFromROS(msg); EXPECT_FLOAT_EQ(out.x, in.x); EXPECT_FLOAT_EQ(out.y, in.y); } TEST(MsgConversion, points2fVectorRoundTrip) { const std::vector in = {{1.0f, 2.0f}, {-3.0f, 4.5f}}; std::vector msg; points2fToROS(in, msg); const std::vector out = points2fFromROS(msg); ASSERT_EQ(out.size(), in.size()); for(size_t i=0; i in = {{1.0f, 2.0f, 3.0f}, {-4.0f, 5.0f, -6.0f}}; std::vector msg; points3fToROS(in, msg); const std::vector out = points3fFromROS(msg); ASSERT_EQ(out.size(), in.size()); for(size_t i=0; i in = {{1.0f, 2.0f, 3.0f}}; const rtabmap::Transform t = sampleTransform(); // Applying t on the way out and t.inverse() on the way in must cancel. std::vector msg; points3fToROS(in, msg, t); const std::vector out = points3fFromROS(msg, t.inverse()); ASSERT_EQ(out.size(), in.size()); EXPECT_NEAR(out[0].x, in[0].x, 1e-4); EXPECT_NEAR(out[0].y, in[0].y, 1e-4); EXPECT_NEAR(out[0].z, in[0].z, 1e-4); // ...and the intermediate message really is the transformed point. const cv::Point3f expected = rtabmap::util3d::transformPoint(in[0], t); EXPECT_NEAR(msg[0].x, expected.x, 1e-4); EXPECT_NEAR(msg[0].y, expected.y, 1e-4); EXPECT_NEAR(msg[0].z, expected.z, 1e-4); } TEST(MsgConversion, points3fFromROSAppendsToExistingVector) { std::vector msg(2); msg[0].x = 1.0f; msg[1].x = 2.0f; std::vector points = {{9.0f, 9.0f, 9.0f}}; points3fFromROS(msg, points); ASSERT_EQ(points.size(), 3u); EXPECT_FLOAT_EQ(points[0].x, 9.0f) << "existing content must be preserved"; EXPECT_FLOAT_EQ(points[1].x, 1.0f); EXPECT_FLOAT_EQ(points[2].x, 2.0f); } TEST(MsgConversion, keypointRoundTrip) { const cv::KeyPoint in(cv::Point2f(10.0f, 20.0f), 7.0f, 45.0f, 0.5f, 2, 3); rtabmap_msgs::msg::KeyPoint msg; keypointToROS(in, msg); const cv::KeyPoint out = keypointFromROS(msg); EXPECT_FLOAT_EQ(out.pt.x, in.pt.x); EXPECT_FLOAT_EQ(out.pt.y, in.pt.y); EXPECT_FLOAT_EQ(out.size, in.size); EXPECT_FLOAT_EQ(out.angle, in.angle); EXPECT_FLOAT_EQ(out.response, in.response); EXPECT_EQ(out.octave, in.octave); EXPECT_EQ(out.class_id, in.class_id); } TEST(MsgConversion, keypointsFromROSAppendsAndAppliesXShift) { const std::vector in = { cv::KeyPoint(cv::Point2f(10.0f, 20.0f), 7.0f), cv::KeyPoint(cv::Point2f(30.0f, 40.0f), 7.0f)}; std::vector msg; keypointsToROS(in, msg); ASSERT_EQ(msg.size(), in.size()); std::vector kpts = {cv::KeyPoint(cv::Point2f(1.0f, 1.0f), 1.0f)}; keypointsFromROS(msg, kpts, /*xShift=*/100); ASSERT_EQ(kpts.size(), 3u); EXPECT_FLOAT_EQ(kpts[0].pt.x, 1.0f) << "existing content must be preserved"; EXPECT_FLOAT_EQ(kpts[1].pt.x, 110.0f); EXPECT_FLOAT_EQ(kpts[2].pt.x, 130.0f); EXPECT_FLOAT_EQ(kpts[1].pt.y, 20.0f) << "xShift must not touch y"; } ///////////////////////// // Timestamps ///////////////////////// TEST(MsgConversion, timestampRoundTrip) { // Not exact on purpose: a double resolves to a few hundred nanoseconds at this // magnitude, so the round trip is only good to about a microsecond. const double in = 1234567890.123456; EXPECT_NEAR(timestampFromROS(timestampToROS(in)), in, 1e-6); } TEST(MsgConversion, timestampToROSUsesRosClock) { // Message header stamps convert to RCL_ROS_TIME, while rclcpp::Time(sec, nsec) // defaults to RCL_SYSTEM_TIME. Comparing two different clock types throws, so a // timestamp built here must be comparable with one taken from a message -- several // conversions do exactly that when syncing to an odometry stamp. const rclcpp::Time built = timestampToROS(1000.0); EXPECT_EQ(built.get_clock_type(), RCL_ROS_TIME); builtin_interfaces::msg::Time asMsg = timestampToROS(1000.5); const rclcpp::Time fromMsg(asMsg); EXPECT_EQ(fromMsg.get_clock_type(), RCL_ROS_TIME); EXPECT_NO_THROW({ volatile bool differ = (built != fromMsg); (void)differ; }) << "a built stamp must be comparable with a message-derived one"; } TEST(MsgConversion, timestampZeroRoundTrip) { EXPECT_EQ(timestampFromROS(timestampToROS(0.0)), 0.0); } ///////////////////////// // sizeOfPointField ///////////////////////// TEST(MsgConversion, sizeOfPointField) { EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::INT8), 1); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::UINT8), 1); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::INT16), 2); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::UINT16), 2); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::INT32), 4); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::UINT32), 4); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::FLOAT32), 4); EXPECT_EQ(sizeOfPointField(sensor_msgs::msg::PointField::FLOAT64), 8); } TEST(MsgConversion, sizeOfPointFieldThrowsOnUnknownType) { EXPECT_THROW(sizeOfPointField(42), std::runtime_error); } ///////////////////////// // getClosestIterator ///////////////////////// TEST(MsgConversion, getClosestIterator) { const std::map buffer = {{1.0, 10}, {2.0, 20}, {3.0, 30}}; EXPECT_EQ(getClosestIterator(buffer, 1.0)->second, 10) << "exact match"; EXPECT_EQ(getClosestIterator(buffer, 2.0)->second, 20) << "exact match"; EXPECT_EQ(getClosestIterator(buffer, 1.4)->second, 10) << "closer to lower"; EXPECT_EQ(getClosestIterator(buffer, 1.6)->second, 20) << "closer to upper"; EXPECT_EQ(getClosestIterator(buffer, 0.0)->second, 10) << "clamped below range"; EXPECT_EQ(getClosestIterator(buffer, 99.0)->second, 30) << "clamped above range"; } TEST(MsgConversion, getClosestIteratorSingleEntry) { const std::map buffer = {{5.0, 50}}; EXPECT_EQ(getClosestIterator(buffer, 0.0)->second, 50); EXPECT_EQ(getClosestIterator(buffer, 99.0)->second, 50); } ///////////////////////// // compressedMat <-> bytes ///////////////////////// TEST(MsgConversion, compressedMatRoundTrip) { const cv::Mat in = (cv::Mat_(1, 5) << 1, 2, 3, 250, 255); std::vector bytes; compressedMatToBytes(in, bytes); ASSERT_EQ(bytes.size(), 5u); const cv::Mat out = compressedMatFromBytes(bytes); ASSERT_EQ(out.type(), CV_8UC1); ASSERT_EQ(out.total(), in.total()); EXPECT_EQ(cv::countNonZero(out.reshape(1, 1) != in.reshape(1, 1)), 0); } TEST(MsgConversion, compressedMatEmptyRoundTrip) { std::vector bytes = {1, 2, 3}; compressedMatToBytes(cv::Mat(), bytes); EXPECT_TRUE(bytes.empty()) << "output must be cleared"; EXPECT_TRUE(compressedMatFromBytes(bytes).empty()); } TEST(MsgConversion, compressedMatFromBytesCopyFlag) { std::vector bytes = {1, 2, 3}; const cv::Mat shared = compressedMatFromBytes(bytes, /*copy=*/false); const cv::Mat copied = compressedMatFromBytes(bytes, /*copy=*/true); bytes[0] = 99; EXPECT_EQ(shared.at(0, 0), 99) << "copy=false must alias the input"; EXPECT_EQ(copied.at(0, 0), 1) << "copy=true must be independent"; } ///////////////////////// // EnvSensor ///////////////////////// TEST(MsgConversion, envSensorRoundTrip) { const rtabmap::EnvSensor in( rtabmap::EnvSensor::kAmbientTemperature, 21.5, 1234567890.5); rtabmap_msgs::msg::EnvSensor msg; envSensorToROS(in, msg); const rtabmap::EnvSensor out = envSensorFromROS(msg); EXPECT_EQ(out.type(), in.type()); EXPECT_DOUBLE_EQ(out.value(), in.value()); EXPECT_NEAR(out.stamp(), in.stamp(), 1e-6); } TEST(MsgConversion, envSensorsRoundTripKeyedByType) { rtabmap::EnvSensors in; in.insert(std::make_pair( rtabmap::EnvSensor::kAmbientTemperature, rtabmap::EnvSensor(rtabmap::EnvSensor::kAmbientTemperature, 21.5, 1.0))); in.insert(std::make_pair( rtabmap::EnvSensor::kAmbientLight, rtabmap::EnvSensor(rtabmap::EnvSensor::kAmbientLight, 300.0, 2.0))); std::vector msg; envSensorsToROS(in, msg); ASSERT_EQ(msg.size(), in.size()); const rtabmap::EnvSensors out = envSensorsFromROS(msg); ASSERT_EQ(out.size(), in.size()); for(rtabmap::EnvSensors::const_iterator iter=in.begin(); iter!=in.end(); ++iter) { rtabmap::EnvSensors::const_iterator found = out.find(iter->first); ASSERT_NE(found, out.end()) << "missing type " << iter->first; EXPECT_DOUBLE_EQ(found->second.value(), iter->second.value()); } } ///////////////////////// // Link ///////////////////////// TEST(MsgConversion, linkRoundTrip) { cv::Mat information = cv::Mat::eye(6, 6, CV_64FC1) * 3.0; const rtabmap::Link in( 1, 2, rtabmap::Link::kGlobalClosure, sampleTransform(), information); rtabmap_msgs::msg::Link msg; linkToROS(in, msg); const rtabmap::Link out = linkFromROS(msg); EXPECT_EQ(out.from(), in.from()); EXPECT_EQ(out.to(), in.to()); EXPECT_EQ(out.type(), in.type()); expectTransformNear(out.transform(), in.transform()); ASSERT_EQ(out.infMatrix().rows, 6); ASSERT_EQ(out.infMatrix().cols, 6); for(int i=0; i<6; ++i) { for(int j=0; j<6; ++j) { EXPECT_DOUBLE_EQ( out.infMatrix().at(i, j), in.infMatrix().at(i, j)) << "at " << i << "," << j; } } } ///////////////////////// // CameraModel ///////////////////////// TEST(MsgConversion, cameraModelFromROSReadsIntrinsics) { sensor_msgs::msg::CameraInfo in; in.width = 640; in.height = 480; in.distortion_model = "plumb_bob"; in.d = {0.1, 0.2, 0.3, 0.4, 0.5}; in.k = {525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0}; in.r = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; in.p = {525.0, 0.0, 320.0, 0.0, 0.0, 525.0, 240.0, 0.0, 0.0, 0.0, 1.0, 0.0}; const rtabmap::Transform localTransform(0.0f, 0.0f, 0.1f, 0.0f, 0.0f, 0.0f); const rtabmap::CameraModel model = cameraModelFromROS(in, localTransform); EXPECT_EQ(model.imageWidth(), 640); EXPECT_EQ(model.imageHeight(), 480); EXPECT_NEAR(model.fx(), 525.0, 1e-9); EXPECT_NEAR(model.fy(), 525.0, 1e-9); EXPECT_NEAR(model.cx(), 320.0, 1e-9); EXPECT_NEAR(model.cy(), 240.0, 1e-9); expectTransformNear(model.localTransform(), localTransform); // The raw distortion coefficients are kept verbatim. ASSERT_EQ(model.D_raw().cols, 5); for(size_t i=0; i(0, i), in.d[i], 1e-9) << "d at " << i; } } TEST(MsgConversion, cameraModelToROSRectifiedHasNoDistortion) { sensor_msgs::msg::CameraInfo in; in.width = 640; in.height = 480; in.distortion_model = "plumb_bob"; in.d = {0.1, 0.2, 0.3, 0.4, 0.5}; in.k = {525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0}; in.r = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; in.p = {525.0, 0.0, 320.0, 0.0, 0.0, 525.0, 240.0, 0.0, 0.0, 0.0, 1.0, 0.0}; sensor_msgs::msg::CameraInfo out; cameraModelToROS(cameraModelFromROS(in, rtabmap::Transform::getIdentity()), out); EXPECT_EQ(out.width, in.width); EXPECT_EQ(out.height, in.height); // A model carrying a projection matrix describes an already-rectified image, // so cameraModelToROS deliberately emits zero distortion rather than echoing // back the raw coefficients. K and P do round-trip unchanged. EXPECT_EQ(out.distortion_model, "plumb_bob"); ASSERT_EQ(out.d.size(), 5u); for(size_t i=0; i(3, 3) << 525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0); cv::Mat D = cv::Mat::zeros(1, 6, CV_64FC1); D.at(0, 0) = 0.1; D.at(0, 1) = 0.2; D.at(0, 4) = 0.3; D.at(0, 5) = 0.4; const rtabmap::CameraModel model( "fisheye", cv::Size(640, 480), K, D, cv::Mat(), cv::Mat(), rtabmap::Transform::getIdentity()); sensor_msgs::msg::CameraInfo out; cameraModelToROS(model, out); EXPECT_EQ(out.distortion_model, "equidistant"); ASSERT_EQ(out.d.size(), 4u); EXPECT_NEAR(out.d[0], 0.1, 1e-9); EXPECT_NEAR(out.d[1], 0.2, 1e-9); EXPECT_NEAR(out.d[2], 0.3, 1e-9); EXPECT_NEAR(out.d[3], 0.4, 1e-9); } TEST(MsgConversion, cameraModelToROSRationalPolynomialDistortion) { cv::Mat K = (cv::Mat_(3, 3) << 525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0); cv::Mat D = (cv::Mat_(1, 8) << 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8); const rtabmap::CameraModel model( "rational", cv::Size(640, 480), K, D, cv::Mat(), cv::Mat(), rtabmap::Transform::getIdentity()); sensor_msgs::msg::CameraInfo out; cameraModelToROS(model, out); EXPECT_EQ(out.distortion_model, "rational_polynomial"); ASSERT_EQ(out.d.size(), 8u); for(size_t i=0; i model -> message round trip. sensor_msgs::msg::CameraInfo in; in.width = 640; in.height = 480; in.distortion_model = "equidistant"; in.d = {0.1, 0.2, 0.3, 0.4}; in.k = {525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0}; sensor_msgs::msg::CameraInfo out; cameraModelToROS(cameraModelFromROS(in, rtabmap::Transform::getIdentity()), out); EXPECT_EQ(out.distortion_model, "equidistant"); ASSERT_EQ(out.d.size(), 4u); for(size_t i=0; i identity = {1., 0., 0., 0., 1., 0., 0., 0., 1.}; for(size_t i=0; i(3, 3) << 525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0); const rtabmap::CameraModel model( "raw", cv::Size(640, 480), K, cv::Mat(), cv::Mat(), cv::Mat(), rtabmap::Transform::getIdentity()); sensor_msgs::msg::CameraInfo out; cameraModelToROS(model, out); const std::array expected = { 525.0, 0.0, 320.0, 0.0, 0.0, 525.0, 240.0, 0.0, 0.0, 0.0, 1.0, 0.0}; for(size_t i=0; i(1, 4) << 1.0f, 2.0f, 3.0f, 4.0f); cv::Mat info = (cv::Mat_(1, 2) << 9.0f, 8.0f); const rtabmap::GlobalDescriptor in(7, data, info); rtabmap_msgs::msg::GlobalDescriptor msg; globalDescriptorToROS(in, msg); const rtabmap::GlobalDescriptor out = globalDescriptorFromROS(msg); EXPECT_EQ(out.type(), in.type()); ASSERT_EQ(out.data().total(), in.data().total()); for(size_t i=0; i(0, i), in.data().at(0, i)) << "data at " << i; } ASSERT_EQ(out.info().total(), in.info().total()); for(size_t i=0; i(0, i), in.info().at(0, i)) << "info at " << i; } } TEST(MsgConversion, globalDescriptorsVectorRoundTrip) { std::vector in; in.push_back(rtabmap::GlobalDescriptor(1, (cv::Mat_(1, 2) << 1.0f, 2.0f))); in.push_back(rtabmap::GlobalDescriptor(2, (cv::Mat_(1, 2) << 3.0f, 4.0f))); std::vector msg; globalDescriptorsToROS(in, msg); ASSERT_EQ(msg.size(), in.size()); const std::vector out = globalDescriptorsFromROS(msg); ASSERT_EQ(out.size(), in.size()); for(size_t i=0; i(0, 0), in[i].data().at(0, 0)) << "at " << i; } } TEST(MsgConversion, globalDescriptorsEmptyRoundTrip) { std::vector msg(3); globalDescriptorsToROS(std::vector(), msg); EXPECT_TRUE(msg.empty()) << "output must be cleared"; EXPECT_TRUE(globalDescriptorsFromROS(msg).empty()); } ///////////////////////// // UserData ///////////////////////// TEST(MsgConversion, userDataUncompressedRoundTrip) { const cv::Mat in = (cv::Mat_(2, 3) << 1, 2, 3, 4, 5, 6); rtabmap_msgs::msg::UserData msg; userDataToROS(in, msg, /*compress=*/false); EXPECT_EQ(msg.rows, in.rows); EXPECT_EQ(msg.cols, in.cols); EXPECT_EQ(msg.type, in.type()); const cv::Mat out = userDataFromROS(msg); ASSERT_EQ(out.rows, in.rows); ASSERT_EQ(out.cols, in.cols); ASSERT_EQ(out.type(), in.type()); EXPECT_EQ(cv::countNonZero(out != in), 0); } TEST(MsgConversion, userDataCompressedRoundTrip) { const cv::Mat in = (cv::Mat_(2, 3) << 1, 2, 3, 4, 5, 6); rtabmap_msgs::msg::UserData msg; userDataToROS(in, msg, /*compress=*/true); // Compressed payloads travel as a 1xN byte blob. EXPECT_EQ(msg.rows, 1); EXPECT_EQ(msg.type, CV_8UC1); EXPECT_EQ((size_t)msg.cols, msg.data.size()); // userDataFromROS hands back the still-compressed blob; the caller uncompresses. const cv::Mat blob = userDataFromROS(msg); ASSERT_FALSE(blob.empty()); const cv::Mat out = rtabmap::uncompressData(blob); ASSERT_EQ(out.rows, in.rows); ASSERT_EQ(out.cols, in.cols); ASSERT_EQ(out.type(), in.type()); EXPECT_EQ(cv::countNonZero(out != in), 0); } TEST(MsgConversion, userDataEmpty) { rtabmap_msgs::msg::UserData msg; userDataToROS(cv::Mat(), msg, /*compress=*/false); EXPECT_TRUE(msg.data.empty()); EXPECT_TRUE(userDataFromROS(msg).empty()); } ///////////////////////// // StereoCameraModel ///////////////////////// TEST(MsgConversion, stereoCameraModelFromROS) { const double fx = 525.0; const double baseline = 0.12; sensor_msgs::msg::CameraInfo left; left.width = 640; left.height = 480; left.k = {fx, 0.0, 320.0, 0.0, fx, 240.0, 0.0, 0.0, 1.0}; left.r = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; left.p = {fx, 0.0, 320.0, 0.0, 0.0, fx, 240.0, 0.0, 0.0, 0.0, 1.0, 0.0}; // The right camera carries the baseline in P(0,3) = -fx * baseline. sensor_msgs::msg::CameraInfo right = left; right.p[3] = -fx * baseline; const rtabmap::StereoCameraModel model = stereoCameraModelFromROS( left, right, rtabmap::Transform::getIdentity()); EXPECT_NEAR(model.left().fx(), fx, 1e-9); EXPECT_NEAR(model.right().fx(), fx, 1e-9); EXPECT_NEAR(model.baseline(), baseline, 1e-9); EXPECT_TRUE(model.isValidForProjection()); } ///////////////////////// // OdometryInfo ///////////////////////// TEST(MsgConversion, odomInfoRoundTrip) { rtabmap::OdometryInfo in; in.lost = false; in.features = 500; in.localMapSize = 1000; in.localScanMapSize = 2000; in.localKeyFrames = 5; in.keyFrameAdded = true; in.timeEstimation = 0.02f; in.interval = 0.033; in.distanceTravelled = 12.5f; in.reg.matches = 300; in.reg.inliers = 250; in.transform = sampleTransform(); rtabmap_msgs::msg::OdomInfo msg; odomInfoToROS(in, msg); const rtabmap::OdometryInfo out = odomInfoFromROS(msg); EXPECT_EQ(out.lost, in.lost); EXPECT_EQ(out.features, in.features); EXPECT_EQ(out.localMapSize, in.localMapSize); EXPECT_EQ(out.localScanMapSize, in.localScanMapSize); EXPECT_EQ(out.localKeyFrames, in.localKeyFrames); EXPECT_EQ(out.keyFrameAdded, in.keyFrameAdded); EXPECT_FLOAT_EQ(out.timeEstimation, in.timeEstimation); EXPECT_NEAR(out.interval, in.interval, 1e-6); EXPECT_FLOAT_EQ(out.distanceTravelled, in.distanceTravelled); EXPECT_EQ(out.reg.matches, in.reg.matches); EXPECT_EQ(out.reg.inliers, in.reg.inliers); expectTransformNear(out.transform, in.transform); } TEST(MsgConversion, odomInfoIgnoreDataDropsHeavyMembers) { rtabmap::OdometryInfo in; in.features = 500; in.reg.inliers = 250; in.words.insert(std::make_pair(1, cv::KeyPoint(cv::Point2f(1, 2), 3))); in.localMap.insert(std::make_pair(1, cv::Point3f(1, 2, 3))); rtabmap_msgs::msg::OdomInfo full; odomInfoToROS(in, full, /*ignoreData=*/false); EXPECT_FALSE(full.words_keys.empty()); rtabmap_msgs::msg::OdomInfo light; odomInfoToROS(in, light, /*ignoreData=*/true); EXPECT_TRUE(light.words_keys.empty()) << "heavy members must be dropped"; // The scalar statistics survive either way. EXPECT_EQ(odomInfoFromROS(light).features, in.features); EXPECT_EQ(odomInfoFromROS(light).reg.inliers, in.reg.inliers); } TEST(MsgConversion, odomInfoToStatistics) { rtabmap::OdometryInfo info; info.features = 400; info.reg.inliers = 100; info.reg.matches = 200; info.localMapSize = 1234; const std::map stats = odomInfoToStatistics(info); ASSERT_TRUE(stats.find("Odometry/Features/") != stats.end()); EXPECT_FLOAT_EQ(stats.at("Odometry/Features/"), 400.0f); EXPECT_FLOAT_EQ(stats.at("Odometry/Matches/"), 200.0f); EXPECT_FLOAT_EQ(stats.at("Odometry/Inliers/"), 100.0f); EXPECT_FLOAT_EQ(stats.at("Odometry/LocalMapSize/"), 1234.0f); // MatchesRatio is inliers/features, and must not divide by zero. EXPECT_FLOAT_EQ(stats.at("Odometry/MatchesRatio/"), 100.0f/400.0f); } TEST(MsgConversion, odomInfoToStatisticsEmptyCovariance) { // RegistrationInfo does not initialize covariance, so a plain OdometryInfo has // an empty matrix. Reading it must not be attempted. rtabmap::OdometryInfo info; ASSERT_TRUE(info.reg.covariance.empty()) << "precondition"; const std::map stats = odomInfoToStatistics(info); EXPECT_TRUE(stats.find("Odometry/StdDevLin/") == stats.end()) << "covariance-derived stats must be omitted, not read out of bounds"; EXPECT_TRUE(stats.find("Odometry/VarianceAng/") == stats.end()); // The rest of the statistics are still produced. EXPECT_TRUE(stats.find("Odometry/Features/") != stats.end()); } TEST(MsgConversion, odomInfoToStatisticsWithCovariance) { rtabmap::OdometryInfo info; info.reg.covariance = cv::Mat::eye(6, 6, CV_64FC1) * 4.0; const std::map stats = odomInfoToStatistics(info); ASSERT_TRUE(stats.find("Odometry/VarianceLin/") != stats.end()); EXPECT_FLOAT_EQ(stats.at("Odometry/VarianceLin/"), 4.0f); EXPECT_FLOAT_EQ(stats.at("Odometry/StdDevLin/"), 2.0f); EXPECT_FLOAT_EQ(stats.at("Odometry/VarianceAng/"), 4.0f); EXPECT_FLOAT_EQ(stats.at("Odometry/StdDevAng/"), 2.0f); } TEST(MsgConversion, odomInfoToStatisticsNoFeatures) { rtabmap::OdometryInfo info; info.features = 0; info.reg.inliers = 10; EXPECT_FLOAT_EQ(odomInfoToStatistics(info).at("Odometry/MatchesRatio/"), 0.0f) << "must not divide by zero"; } ///////////////////////// // MapGraph / MapData ///////////////////////// TEST(MsgConversion, mapGraphRoundTrip) { std::map poses; poses.insert(std::make_pair(1, rtabmap::Transform(1, 0, 0, 0, 0, 0))); poses.insert(std::make_pair(2, sampleTransform())); std::multimap links; links.insert(std::make_pair(1, rtabmap::Link( 1, 2, rtabmap::Link::kNeighbor, sampleTransform(), cv::Mat::eye(6, 6, CV_64FC1) * 2.0))); links.insert(std::make_pair(2, rtabmap::Link( 2, 1, rtabmap::Link::kGlobalClosure, rtabmap::Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1)))); const rtabmap::Transform mapToOdom(0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 0.1f); rtabmap_msgs::msg::MapGraph msg; mapGraphToROS(poses, links, mapToOdom, msg); ASSERT_EQ(msg.poses.size(), poses.size()); ASSERT_EQ(msg.poses_id.size(), poses.size()); ASSERT_EQ(msg.links.size(), links.size()); std::map outPoses; std::multimap outLinks; rtabmap::Transform outMapToOdom; mapGraphFromROS(msg, outPoses, outLinks, outMapToOdom); ASSERT_EQ(outPoses.size(), poses.size()); for(std::map::const_iterator iter=poses.begin(); iter!=poses.end(); ++iter) { ASSERT_TRUE(outPoses.find(iter->first) != outPoses.end()) << "missing pose " << iter->first; expectTransformNear(outPoses.at(iter->first), iter->second); } ASSERT_EQ(outLinks.size(), links.size()); for(std::multimap::const_iterator iter=links.begin(); iter!=links.end(); ++iter) { std::multimap::const_iterator found = outLinks.find(iter->first); ASSERT_TRUE(found != outLinks.end()) << "missing link from " << iter->first; EXPECT_EQ(found->second.from(), iter->second.from()); EXPECT_EQ(found->second.to(), iter->second.to()); EXPECT_EQ(found->second.type(), iter->second.type()); } expectTransformNear(outMapToOdom, mapToOdom); } TEST(MsgConversion, mapGraphEmptyRoundTrip) { rtabmap_msgs::msg::MapGraph msg; mapGraphToROS(std::map(), std::multimap(), rtabmap::Transform(), msg); EXPECT_TRUE(msg.poses.empty()); EXPECT_TRUE(msg.links.empty()); std::map poses; std::multimap links; rtabmap::Transform mapToOdom; mapGraphFromROS(msg, poses, links, mapToOdom); EXPECT_TRUE(poses.empty()); EXPECT_TRUE(links.empty()); EXPECT_TRUE(mapToOdom.isNull()) << "a null map_to_odom must survive as null"; } TEST(MsgConversion, mapDataRoundTrip) { std::map poses; poses.insert(std::make_pair(1, sampleTransform())); std::multimap links; links.insert(std::make_pair(1, rtabmap::Link( 1, 2, rtabmap::Link::kNeighbor, sampleTransform()))); std::map signatures; rtabmap::Signature sig(1, 0, 3, 1234.5, "my_label", sampleTransform()); signatures.insert(std::make_pair(1, sig)); const rtabmap::Transform mapToOdom = rtabmap::Transform::getIdentity(); rtabmap_msgs::msg::MapData msg; mapDataToROS(poses, links, signatures, mapToOdom, msg); ASSERT_EQ(msg.nodes.size(), signatures.size()); ASSERT_EQ(msg.graph.poses.size(), poses.size()); std::map outPoses; std::multimap outLinks; std::map outSignatures; rtabmap::Transform outMapToOdom; mapDataFromROS(msg, outPoses, outLinks, outSignatures, outMapToOdom); EXPECT_EQ(outPoses.size(), poses.size()); EXPECT_EQ(outLinks.size(), links.size()); ASSERT_EQ(outSignatures.size(), signatures.size()); ASSERT_TRUE(outSignatures.find(1) != outSignatures.end()); EXPECT_EQ(outSignatures.at(1).id(), sig.id()); EXPECT_EQ(outSignatures.at(1).getLabel(), sig.getLabel()); EXPECT_EQ(outSignatures.at(1).getWeight(), sig.getWeight()); EXPECT_NEAR(outSignatures.at(1).getStamp(), sig.getStamp(), 1e-6); } ///////////////////////// // Node / Signature ///////////////////////// namespace { rtabmap::Signature sampleSignature() { rtabmap::Signature s(7, 2, 3, 1234.5, "node_label", sampleTransform()); std::multimap words; std::vector kpts; std::vector pts3; cv::Mat descriptors(2, 4, CV_32FC1); for(int i=0; i<2; ++i) { words.insert(std::make_pair(100 + i, i)); kpts.push_back(cv::KeyPoint(cv::Point2f(10.0f * i, 20.0f * i), 7.0f)); pts3.push_back(cv::Point3f(1.0f * i, 2.0f * i, 3.0f * i)); for(int j=0; j<4; ++j) { descriptors.at(i, j) = float(i * 4 + j); } } s.setWords(words, kpts, pts3, descriptors); return s; } } // namespace TEST(MsgConversion, nodeRoundTrip) { const rtabmap::Signature in = sampleSignature(); rtabmap_msgs::msg::Node msg; nodeToROS(in, msg); const rtabmap::Signature out = nodeFromROS(msg); EXPECT_EQ(out.id(), in.id()); EXPECT_EQ(out.mapId(), in.mapId()); EXPECT_EQ(out.getWeight(), in.getWeight()); EXPECT_NEAR(out.getStamp(), in.getStamp(), 1e-6); EXPECT_EQ(out.getLabel(), in.getLabel()); expectTransformNear(out.getPose(), in.getPose()); // Visual words: ids, keypoints, 3D points and descriptors. ASSERT_EQ(out.getWords().size(), in.getWords().size()); EXPECT_TRUE(std::equal(out.getWords().begin(), out.getWords().end(), in.getWords().begin())); ASSERT_EQ(out.getWordsKpts().size(), in.getWordsKpts().size()); for(size_t i=0; i(3, 3) << 525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0); const rtabmap::CameraModel model( "cam", cv::Size(640, 480), K, cv::Mat(), cv::Mat(), cv::Mat(), rtabmap::Transform(0.0f, 0.0f, 0.1f, 0.0f, 0.0f, 0.0f)); rtabmap::SensorData in(cv::Mat(), cv::Mat(), model, 42, 1234.5); in.setGroundTruth(sampleTransform()); in.setGPS(rtabmap::GPS(1234.5, -71.9, 45.4, 100.0, 5.0, 90.0)); rtabmap_msgs::msg::SensorData msg; sensorDataToROS(in, msg, "base_link"); EXPECT_EQ(msg.header.frame_id, "base_link"); const rtabmap::SensorData out = sensorDataFromROS(msg); EXPECT_NEAR(out.stamp(), in.stamp(), 1e-6); // sensorDataToROS writes ground_truth_pose into the message, but sensorDataFromROS // deliberately does not read it back: the ground truth is owned by the enclosing // Node conversion (nodeFromROS feeds it to the Signature constructor). See // nodeGroundTruthRoundTrip for the round trip that does preserve it. EXPECT_FALSE(transformFromPoseMsg(msg.ground_truth_pose).isNull()) << "the message must still carry the ground truth for nodeFromROS"; EXPECT_TRUE(out.groundTruth().isNull()) << "sensorDataFromROS does not restore the ground truth"; ASSERT_EQ(out.cameraModels().size(), 1u); EXPECT_NEAR(out.cameraModels()[0].fx(), 525.0, 1e-9); EXPECT_NEAR(out.cameraModels()[0].cx(), 320.0, 1e-9); expectTransformNear( out.cameraModels()[0].localTransform(), model.localTransform()); EXPECT_NEAR(out.gps().longitude(), in.gps().longitude(), 1e-9); EXPECT_NEAR(out.gps().latitude(), in.gps().latitude(), 1e-9); EXPECT_NEAR(out.gps().altitude(), in.gps().altitude(), 1e-9); EXPECT_NEAR(out.gps().bearing(), in.gps().bearing(), 1e-9); } TEST(MsgConversion, sensorDataUserDataRoundTrip) { rtabmap::SensorData in; in.setStamp(10.0); in.setUserData((cv::Mat_(1, 3) << 7, 8, 9)); rtabmap_msgs::msg::SensorData msg; sensorDataToROS(in, msg); const rtabmap::SensorData out = sensorDataFromROS(msg); const cv::Mat data = out.userDataRaw().empty() ? rtabmap::uncompressData(out.userDataCompressed()) : out.userDataRaw(); ASSERT_FALSE(data.empty()); ASSERT_EQ(data.cols, 3); EXPECT_EQ(data.at(0, 0), 7); EXPECT_EQ(data.at(0, 2), 9); } ///////////////////////// // Statistics / Info ///////////////////////// TEST(MsgConversion, infoRoundTrip) { rtabmap::Statistics in; in.setExtended(true); in.setRefImageId(5); in.setLoopClosureId(9); in.setProximityDetectionId(11); in.setStamp(1234.5); in.setLoopClosureTransform(sampleTransform()); in.setWmState(std::vector{1, 2, 3}); std::map posterior; posterior.insert(std::make_pair(1, 0.25f)); posterior.insert(std::make_pair(2, 0.75f)); in.setPosterior(posterior); std::map weights; weights.insert(std::make_pair(1, 10)); in.setWeights(weights); std::map labels; labels.insert(std::make_pair(1, "kitchen")); in.setLabels(labels); in.addStatistic("Some/Stat/", 3.5f); rtabmap_msgs::msg::Info msg; infoToROS(in, msg); // An unstamped header is filled from the statistics, so infoFromROS recovers the // stamp without the caller doing anything. Only to double precision, though. EXPECT_NEAR(timestampFromROS(msg.header.stamp), in.stamp(), 1e-6); EXPECT_TRUE(msg.header.frame_id.empty()) << "the frame id is always the caller's job"; rtabmap::Statistics out; infoFromROS(msg, out); EXPECT_EQ(out.refImageId(), in.refImageId()); EXPECT_EQ(out.loopClosureId(), in.loopClosureId()); EXPECT_EQ(out.proximityDetectionId(), in.proximityDetectionId()); EXPECT_NEAR(out.stamp(), in.stamp(), 1e-6); expectTransformNear(out.loopClosureTransform(), in.loopClosureTransform()); EXPECT_EQ(out.wmState(), in.wmState()); ASSERT_EQ(out.posterior().size(), in.posterior().size()); EXPECT_FLOAT_EQ(out.posterior().at(1), 0.25f); EXPECT_FLOAT_EQ(out.posterior().at(2), 0.75f); ASSERT_EQ(out.weights().size(), in.weights().size()); EXPECT_EQ(out.weights().at(1), 10); ASSERT_EQ(out.labels().size(), in.labels().size()); EXPECT_EQ(out.labels().at(1), "kitchen"); ASSERT_TRUE(out.data().find("Some/Stat/") != out.data().end()); EXPECT_FLOAT_EQ(out.data().at("Some/Stat/"), 3.5f); } ///////////////////////// // PointCloud2 helpers ///////////////////////// namespace { /// Builds a dense, unorganized XYZ float cloud from the given points. sensor_msgs::msg::PointCloud2 makeXYZCloud(const std::vector & points) { sensor_msgs::msg::PointCloud2 cloud; cloud.height = 1; cloud.width = points.size(); cloud.is_bigendian = false; cloud.is_dense = true; cloud.fields.resize(3); const char * names[3] = {"x", "y", "z"}; for(int i=0; i<3; ++i) { cloud.fields[i].name = names[i]; cloud.fields[i].offset = 4 * i; cloud.fields[i].datatype = sensor_msgs::msg::PointField::FLOAT32; cloud.fields[i].count = 1; } cloud.point_step = 12; cloud.row_step = cloud.point_step * cloud.width; cloud.data.resize(cloud.row_step * cloud.height); for(size_t i=0; i(&cloud.data[i * cloud.point_step]); p[0] = points[i].x; p[1] = points[i].y; p[2] = points[i].z; } return cloud; } cv::Point3f readXYZ(const sensor_msgs::msg::PointCloud2 & cloud, size_t index) { const float * p = reinterpret_cast(&cloud.data[index * cloud.point_step]); return cv::Point3f(p[0], p[1], p[2]); } } // namespace TEST(MsgConversion, transformPointCloudTranslation) { const std::vector points = {{1.0f, 2.0f, 3.0f}, {-1.0f, 0.0f, 1.0f}}; const sensor_msgs::msg::PointCloud2 in = makeXYZCloud(points); Eigen::Matrix4f t = Eigen::Matrix4f::Identity(); t(0, 3) = 10.0f; t(1, 3) = 20.0f; t(2, 3) = 30.0f; sensor_msgs::msg::PointCloud2 out; transformPointCloud(t, in, out); ASSERT_EQ(out.width, in.width); ASSERT_EQ(out.point_step, in.point_step); for(size_t i=0; i points = {{1.0f, 0.0f, 0.0f}}; const sensor_msgs::msg::PointCloud2 in = makeXYZCloud(points); const Eigen::Matrix4f t = rtabmap::Transform(0, 0, 0, 0, 0, M_PI/2.0).toEigen4f(); sensor_msgs::msg::PointCloud2 out; transformPointCloud(t, in, out); const cv::Point3f p = readXYZ(out, 0); EXPECT_NEAR(p.x, 0.0f, 1e-5); EXPECT_NEAR(p.y, 1.0f, 1e-5); EXPECT_NEAR(p.z, 0.0f, 1e-5); } TEST(MsgConversion, transformPointCloudIdentityPreservesMetadata) { const sensor_msgs::msg::PointCloud2 in = makeXYZCloud({{1.0f, 2.0f, 3.0f}}); sensor_msgs::msg::PointCloud2 out; transformPointCloud(Eigen::Matrix4f::Identity(), in, out); EXPECT_EQ(out.height, in.height); EXPECT_EQ(out.width, in.width); EXPECT_EQ(out.point_step, in.point_step); EXPECT_EQ(out.row_step, in.row_step); EXPECT_EQ(out.is_dense, in.is_dense); ASSERT_EQ(out.fields.size(), in.fields.size()); for(size_t i=0; i last constexpr float kWallDistance = 5.0f; // m, distance to the wall at the first point constexpr float kSpeed = 1.0f; // m/s forward (+x) /// How the per-point time channel is encoded. deskew() accepts three datatypes, and /// FLOAT64 differs from the other two: it carries ABSOLUTE stamps (with an automatic /// ms/us/ns unit guess), while UINT32 and FLOAT32 carry offsets from the header stamp. enum TimeEncoding { kOffsetSecFloat32, ///< FLOAT32 seconds, relative to header.stamp kOffsetNsecUint32, ///< UINT32 nanoseconds, relative to header.stamp kAbsoluteSecFloat64, ///< FLOAT64 absolute seconds kAbsoluteMsecFloat64 ///< FLOAT64 absolute milliseconds (auto-scaled by deskew) }; /// Organized-cloud layout. deskew() picks its traversal from width>height, so the two /// orderings exercise different loops. enum ScanLayout { kTimeOnColumns, ///< Ouster style: width=time samples, height=rings kTimeOnRows ///< Velodyne style: height=time samples, width=rings }; /** * Builds the raw (skewed) scan of a flat wall captured while moving forward. * * Each time sample is taken 1 ms after the previous one, by which time the robot has * closed in on the wall by kSpeed * elapsed. Expressed in the sensor frame at capture * time, the wall therefore appears to slide towards the robot: a straight wall is * recorded as a slanted line. Deskewing must undo exactly that. * * @param headerStamp absolute stamp put in the message header * @param firstPointOffset time of the first sample relative to the header stamp * @param encoding how to write the time channel * @param layout whether time runs along columns or rows * @param rings number of rings (the non-time dimension) * @param fieldName name of the time channel * @param descendingTime emit the samples newest-first, which deskew has to detect * @param displacement distance travelled as a function of time since the first * sample; defaults to the constant-velocity kSpeed * elapsed */ sensor_msgs::msg::PointCloud2 makeSkewedWallScan( double headerStamp, double firstPointOffset, TimeEncoding encoding = kOffsetSecFloat32, ScanLayout layout = kTimeOnColumns, size_t rings = 1, const std::string & fieldName = "t", bool descendingTime = false, const std::function & displacement = nullptr) { const bool timeIs64Bit = encoding == kAbsoluteSecFloat64 || encoding == kAbsoluteMsecFloat64; // Keep the 8-byte time channel aligned: x,y,z then 4 bytes of padding. const uint32_t timeOffset = timeIs64Bit ? 16 : 12; const uint32_t pointStep = timeIs64Bit ? 24 : 16; sensor_msgs::msg::PointCloud2 cloud; cloud.header.stamp = timestampToROS(headerStamp); cloud.header.frame_id = "base_link"; cloud.is_bigendian = false; cloud.is_dense = true; if(layout == kTimeOnColumns) { cloud.width = kScanPoints; cloud.height = rings; } else { cloud.width = rings; cloud.height = kScanPoints; } cloud.fields.resize(4); const char * xyz[3] = {"x", "y", "z"}; for(int i=0; i<3; ++i) { cloud.fields[i].name = xyz[i]; cloud.fields[i].offset = 4 * i; cloud.fields[i].datatype = sensor_msgs::msg::PointField::FLOAT32; cloud.fields[i].count = 1; } cloud.fields[3].name = fieldName; cloud.fields[3].offset = timeOffset; cloud.fields[3].datatype = encoding == kOffsetNsecUint32 ? sensor_msgs::msg::PointField::UINT32 : timeIs64Bit ? sensor_msgs::msg::PointField::FLOAT64 : sensor_msgs::msg::PointField::FLOAT32; cloud.fields[3].count = 1; cloud.point_step = pointStep; cloud.row_step = cloud.point_step * cloud.width; cloud.data.resize(cloud.row_step * cloud.height); for(size_t i=0; i(base); // The robot has closed in on the wall by this much when the sample was taken. const double travelled = displacement ? displacement(elapsed) : kSpeed * elapsed; p[0] = kWallDistance - float(travelled); // the skew p[1] = -1.0f + 2.0f * float(sample) / float(kScanPoints - 1); p[2] = 0.1f * float(r); // one plane per ring switch(encoding) { case kOffsetSecFloat32: *reinterpret_cast(base + timeOffset) = float(offset); break; case kOffsetNsecUint32: *reinterpret_cast(base + timeOffset) = uint32_t(std::llround(offset * 1e9)); break; case kAbsoluteSecFloat64: *reinterpret_cast(base + timeOffset) = absolute; break; case kAbsoluteMsecFloat64: *reinterpret_cast(base + timeOffset) = absolute * 1e3; break; } } } return cloud; } /// Reads x of the point at (time sample, ring) for the given layout. float readWallX(const sensor_msgs::msg::PointCloud2 & cloud, size_t sample, size_t ring, ScanLayout layout) { const size_t row = (layout == kTimeOnColumns) ? ring : sample; const size_t col = (layout == kTimeOnColumns) ? sample : ring; return *reinterpret_cast( &cloud.data[row * cloud.row_step + col * cloud.point_step]); } float readField(const sensor_msgs::msg::PointCloud2 & cloud, size_t index, size_t field) { return *reinterpret_cast( &cloud.data[index * cloud.point_step + cloud.fields[field].offset]); } } // namespace TEST(MsgConversion, deskewConstantVelocityHeaderAtFirstPoint) { const double firstPointStamp = 1000.0; // Header stamped at the first point, so "t" runs 0 .. +0.100 s. const sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan(firstPointStamp, 0.0); ASSERT_NEAR(readField(in, 0, 0), kWallDistance, 1e-4) << "first point is unskewed"; ASSERT_NEAR(readField(in, kScanPoints-1, 0), kWallDistance - float(kScanSpan), 1e-4) << "last point is skewed by v*0.099s = 9.9 cm"; sensor_msgs::msg::PointCloud2 out; ASSERT_TRUE(deskew(in, out, rtabmap::Transform(kSpeed, 0, 0, 0, 0, 0))); // Everything collapses back onto the wall at its original distance. for(size_t i=0; i height=4 rings. expectDeskewRecoversWall(kOffsetSecFloat32, kTimeOnColumns, 4, 1000.0); } TEST(MsgConversion, deskewTimeOnRowsWithMultipleRings) { // Velodyne layout: height=101 samples > width=4 rings, which takes the other loop. expectDeskewRecoversWall(kOffsetSecFloat32, kTimeOnRows, 4, 1000.0); } TEST(MsgConversion, deskewLayoutsAgree) { // The same scan expressed in either layout must deskew to the same geometry. const double headerStamp = 1000.0; const size_t rings = 4; sensor_msgs::msg::PointCloud2 byColumns, byRows; ASSERT_TRUE(deskew(makeSkewedWallScan(headerStamp, 0.0, kOffsetSecFloat32, kTimeOnColumns, rings), byColumns, rtabmap::Transform(kSpeed, 0, 0, 0, 0, 0))); ASSERT_TRUE(deskew(makeSkewedWallScan(headerStamp, 0.0, kOffsetSecFloat32, kTimeOnRows, rings), byRows, rtabmap::Transform(kSpeed, 0, 0, 0, 0, 0))); for(size_t i=0; i(&c.data[i * c.point_step]); return std::make_pair(p[0], p[1]); }; for(size_t i=0; i a = xy(in, i); const std::pair b = xy(out, i); const double dt = double(i) * kScanStep; // sample 0 sits at the header stamp EXPECT_NEAR(std::hypot(b.first, b.second), std::hypot(a.first, a.second), 1e-4) << "a rotation must preserve the range of sample " << i; EXPECT_NEAR(std::atan2(b.second, b.first) - std::atan2(a.second, a.first), yawRate * dt, 1e-4) << "sample " << i << " must be rotated by yawRate*dt"; } // Spelling out the i=0 case: dt is zero there, so that sample is untouched. EXPECT_FLOAT_EQ(xy(out, 0).first, xy(in, 0).first); EXPECT_FLOAT_EQ(xy(out, 0).second, xy(in, 0).second); } TEST(MsgConversion, deskewPassesThroughWhenThereIsNoTimeSpread) { // A driver that leaves the time channel at zero gives a scan with no time spread. // There is nothing to correct, so the cloud must come back unchanged rather than // being reported as a failure -- callers abort the frame on false. sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan(1000.0, 0.0); for(size_t i=0; i(&in.data[i * in.point_step + in.fields[3].offset]) = 0.0f; } sensor_msgs::msg::PointCloud2 out; ASSERT_TRUE(deskew(in, out, rtabmap::Transform(kSpeed, 0, 0, 0, 0, 0))); EXPECT_EQ(out.data, in.data) << "the cloud must be returned untouched"; } TEST(MsgConversion, deskewIsIdempotent) { // Deskewing zeroes the time channel to mark the cloud as done, so running deskew a // second time (e.g. lidar_deskewing feeding icp_odometry) must be a silent no-op. const sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan(1000.0, 0.0); const rtabmap::Transform velocity(kSpeed, 0, 0, 0, 0, 0); sensor_msgs::msg::PointCloud2 once; ASSERT_TRUE(deskew(in, once, velocity)); for(size_t i=0; i( &once.data[i * once.point_step + once.fields[3].offset]), 0.0f) << "deskewing must zero the time channel, sample " << i; } sensor_msgs::msg::PointCloud2 twice; ASSERT_TRUE(deskew(once, twice, velocity)) << "a second pass must not fail"; EXPECT_EQ(twice.data, once.data) << "a second pass must change nothing"; } TEST(MsgConversion, deskewClampsSamplesOutsideTheSweep) { // The ordering check only inspects the first and last samples, so a corrupt stamp in // the middle is not detected. It must be clamped to the end of the sweep rather than // extrapolated, which would fling the point far past the wall. const double headerStamp = 1000.0; sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan(headerStamp, 0.0); const size_t corrupt = kScanPoints / 2; *reinterpret_cast( &in.data[corrupt * in.point_step + in.fields[3].offset]) = 0.5f; // 5x the sweep sensor_msgs::msg::PointCloud2 out; ASSERT_TRUE(deskew(in, out, rtabmap::Transform(kSpeed, 0, 0, 0, 0, 0))); // Clamped to the last sample's correction, so it lands within the sweep's own range // rather than meters away. Every other sample is unaffected. const float x = readWallX(out, corrupt, 0, kTimeOnColumns); EXPECT_GE(x, kWallDistance - 1e-3f); EXPECT_LE(x, kWallDistance + float(kSpeed * kScanSpan) + 1e-3f) << "an unclamped ratio of ~5 would put this point ~0.45 m past the wall"; for(size_t i=0; i(3, 3) << 525.0, 0.0, 320.0, 0.0, 525.0, 240.0, 0.0, 0.0, 1.0); const rtabmap::CameraModel model( "cam", cv::Size(4, 4), K, cv::Mat(), cv::Mat(), cv::Mat(), rtabmap::Transform(0.0f, 0.0f, 0.1f, 0.0f, 0.0f, 0.0f)); cv::Mat rgb(4, 4, CV_8UC3, cv::Scalar(10, 20, 30)); cv::Mat depth(4, 4, CV_16UC1, cv::Scalar(1000)); rtabmap::SensorData in(rgb, depth, model, 1, 1234.5); rtabmap_msgs::msg::RGBDImage msg; rgbdImageToROS(in, msg, "camera_link"); EXPECT_EQ(msg.rgb_camera_info.header.frame_id, "camera_link"); EXPECT_NEAR(timestampFromROS(msg.rgb_camera_info.header.stamp), 1234.5, 1e-6); // The top-level header is stamped too, so rgbdImageFromROS recovers the stamp // without the caller having to fill it in. EXPECT_EQ(msg.header.frame_id, "camera_link"); EXPECT_NEAR(timestampFromROS(msg.header.stamp), 1234.5, 1e-6); // The returned SensorData shallow-references the message buffers, so the message // must outlive it -- see rgbdImageFromROSAliasesTheMessage. const rtabmap_msgs::msg::RGBDImage::ConstSharedPtr held = std::make_shared(msg); const rtabmap::SensorData out = rgbdImageFromROS(held); EXPECT_NEAR(out.stamp(), in.stamp(), 1e-6); ASSERT_EQ(out.cameraModels().size(), 1u); EXPECT_NEAR(out.cameraModels()[0].fx(), 525.0, 1e-9); // The local transform is not carried by the message (CameraInfo has no such // field); callers resolve it from TF, so it comes back as the default identity. EXPECT_TRUE(out.cameraModels()[0].localTransform().isIdentity()) << out.cameraModels()[0].localTransform().prettyPrint(); ASSERT_FALSE(out.imageRaw().empty()); EXPECT_EQ(out.imageRaw().type(), CV_8UC3); EXPECT_EQ(cv::countNonZero(out.imageRaw().reshape(1) != rgb.reshape(1)), 0); ASSERT_FALSE(out.depthRaw().empty()); EXPECT_EQ(out.depthRaw().type(), CV_16UC1); EXPECT_EQ(cv::countNonZero(out.depthRaw() != depth), 0); } TEST(MsgConversion, rgbdImageFromROSAliasesTheMessage) { // rgbdImageFromROS deliberately avoids copying the pixels: the SensorData it returns // points into the message's own buffers. Mutating the message is visible through the // SensorData. Callers must therefore keep the message alive and unchanged for as long // as they use the result -- and must deep-copy before letting the SensorData outlive // the subscription callback, since the ROS queue recycles the message once it // returns. cv::Mat rgb(4, 4, CV_8UC3, cv::Scalar(10, 20, 30)); cv::Mat depth(4, 4, CV_16UC1, cv::Scalar(1000)); auto msg = std::make_shared(); msg->rgb_camera_info.width = 4; msg->rgb_camera_info.height = 4; msg->rgb_camera_info.k = {525.0, 0.0, 2.0, 0.0, 525.0, 2.0, 0.0, 0.0, 1.0}; cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", rgb).toImageMsg(msg->rgb); cv_bridge::CvImage(std_msgs::msg::Header(), "16UC1", depth).toImageMsg(msg->depth); const rtabmap::SensorData data = rgbdImageFromROS(msg); ASSERT_FALSE(data.imageRaw().empty()); ASSERT_EQ(data.imageRaw().at(0, 0), cv::Vec3b(10, 20, 30)); // Writing through the message is observable in the SensorData: no copy was made. msg->rgb.data[0] = 99; EXPECT_EQ(data.imageRaw().at(0, 0)[0], 99) << "SensorData is expected to alias the message buffer"; } TEST(MsgConversion, toCvCopyReadsRawImages) { cv::Mat rgb(4, 4, CV_8UC3, cv::Scalar(10, 20, 30)); cv::Mat depth(4, 4, CV_16UC1, cv::Scalar(1000)); rtabmap_msgs::msg::RGBDImage msg; cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", rgb).toImageMsg(msg.rgb); cv_bridge::CvImage(std_msgs::msg::Header(), "16UC1", depth).toImageMsg(msg.depth); cv_bridge::CvImagePtr rgbPtr, depthPtr; toCvCopy(msg, rgbPtr, depthPtr); ASSERT_TRUE(rgbPtr && depthPtr); EXPECT_EQ(cv::countNonZero(rgbPtr->image.reshape(1) != rgb.reshape(1)), 0); EXPECT_EQ(cv::countNonZero(depthPtr->image != depth), 0); // The copy must be independent of the message buffer. rgbPtr->image.at(0, 0) = cv::Vec3b(0, 0, 0); EXPECT_EQ(rgb.at(0, 0), cv::Vec3b(10, 20, 30)); } TEST(MsgConversion, toCvCopyEmptyImageYieldsEmptyPtr) { rtabmap_msgs::msg::RGBDImage msg; cv_bridge::CvImagePtr rgbPtr, depthPtr; toCvCopy(msg, rgbPtr, depthPtr); ASSERT_TRUE(rgbPtr && depthPtr) << "pointers must be valid even with no image"; EXPECT_TRUE(rgbPtr->image.empty()); EXPECT_TRUE(depthPtr->image.empty()); } TEST(MsgConversion, toCvShareAliasesRawImages) { cv::Mat rgb(4, 4, CV_8UC3, cv::Scalar(10, 20, 30)); cv::Mat depth(4, 4, CV_16UC1, cv::Scalar(1000)); rtabmap_msgs::msg::RGBDImage msg; cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", rgb).toImageMsg(msg.rgb); cv_bridge::CvImage(std_msgs::msg::Header(), "16UC1", depth).toImageMsg(msg.depth); cv_bridge::CvImageConstPtr rgbPtr, depthPtr; toCvShare(msg, std::shared_ptr(), rgbPtr, depthPtr); ASSERT_TRUE(rgbPtr && depthPtr); ASSERT_FALSE(rgbPtr->image.empty()); EXPECT_EQ(cv::countNonZero(rgbPtr->image.reshape(1) != rgb.reshape(1)), 0); EXPECT_EQ(cv::countNonZero(depthPtr->image != depth), 0); } ///////////////////////// // Compressed images ///////////////////////// namespace { /// Builds a depth image compressed the way rtabmap does it (not a jpg/png CompressedImage). sensor_msgs::msg::CompressedImage makeRtabmapCompressedDepth(const cv::Mat & depth) { sensor_msgs::msg::CompressedImage msg; msg.format = ""; // anything but "jpg" takes the rtabmap::uncompressImage path msg.data = rtabmap::compressImage(depth, ".png"); return msg; } } // namespace TEST(MsgConversion, toCvCopyReadsCompressedDepth) { const cv::Mat depth(4, 4, CV_16UC1, cv::Scalar(1234)); rtabmap_msgs::msg::RGBDImage msg; msg.depth_compressed = makeRtabmapCompressedDepth(depth); cv_bridge::CvImagePtr rgbPtr, depthPtr; toCvCopy(msg, rgbPtr, depthPtr); ASSERT_TRUE(depthPtr); ASSERT_FALSE(depthPtr->image.empty()); EXPECT_EQ(depthPtr->image.type(), CV_16UC1); EXPECT_EQ(depthPtr->encoding, sensor_msgs::image_encodings::TYPE_16UC1); EXPECT_EQ(cv::countNonZero(depthPtr->image != depth), 0); } TEST(MsgConversion, toCvShareReadsCompressedDepth) { const cv::Mat depth(4, 4, CV_32FC1, cv::Scalar(1.5f)); rtabmap_msgs::msg::RGBDImage msg; msg.depth_compressed = makeRtabmapCompressedDepth(depth); cv_bridge::CvImageConstPtr rgbPtr, depthPtr; toCvShare(msg, std::shared_ptr(), rgbPtr, depthPtr); ASSERT_TRUE(depthPtr); ASSERT_FALSE(depthPtr->image.empty()); EXPECT_EQ(depthPtr->image.type(), CV_32FC1); EXPECT_EQ(depthPtr->encoding, sensor_msgs::image_encodings::TYPE_32FC1); EXPECT_EQ(cv::countNonZero(depthPtr->image != depth), 0); } TEST(MsgConversion, toCvShareOnAnEmptyMessageGivesEmptyImages) { // Both pointers must be valid even when the message carries nothing: callers such as // rgbdImageFromROS() dereference them unconditionally. const rtabmap_msgs::msg::RGBDImage msg; cv_bridge::CvImageConstPtr rgbPtr, depthPtr; toCvShare(msg, std::shared_ptr(), rgbPtr, depthPtr); ASSERT_TRUE(rgbPtr); ASSERT_TRUE(depthPtr); EXPECT_TRUE(rgbPtr->image.empty()); EXPECT_TRUE(depthPtr->image.empty()); } TEST(MsgConversion, rgbdImageFromROSOnAnEmptyMessageIsInvalid) { rtabmap_msgs::msg::RGBDImage::SharedPtr msg = std::make_shared(); msg->header.frame_id = "camera_link"; msg->header.stamp = rclcpp::Time(1000, 0, RCL_ROS_TIME); const rtabmap::SensorData data = rgbdImageFromROS(msg); EXPECT_FALSE(data.isValid()) << "an empty message must give empty data, not a crash"; } TEST(MsgConversion, rgbdImageFromROSWithoutDepthKeepsTheColorImage) { // The depth image is optional: color plus camera info is a valid message, and the // resolution check must not divide by the zero depth width. rtabmap_msgs::msg::RGBDImage::SharedPtr msg = std::make_shared(); msg->header.frame_id = "camera_link"; msg->header.stamp = rclcpp::Time(1000, 0, RCL_ROS_TIME); cv::Mat rgb(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", rgb).toImageMsg(msg->rgb); msg->rgb_camera_info.width = 8; msg->rgb_camera_info.height = 8; msg->rgb_camera_info.k = {525.0, 0.0, 4.0, 0.0, 525.0, 4.0, 0.0, 0.0, 1.0}; const rtabmap::SensorData data = rgbdImageFromROS(msg); EXPECT_TRUE(data.isValid()); ASSERT_FALSE(data.imageRaw().empty()); EXPECT_EQ(data.imageRaw().at(0, 0), cv::Vec3b(10, 20, 30)); EXPECT_TRUE(data.depthRaw().empty()); ASSERT_EQ(data.cameraModels().size(), 1u); EXPECT_NEAR(data.cameraModels()[0].fx(), 525.0, 1e-9); } TEST(MsgConversion, toCvCopyReadsCompressedRgb) { const cv::Mat rgb(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); rtabmap_msgs::msg::RGBDImage msg; msg.rgb_compressed.format = "png"; msg.rgb_compressed.data = rtabmap::compressImage(rgb, ".png"); cv_bridge::CvImagePtr rgbPtr, depthPtr; toCvCopy(msg, rgbPtr, depthPtr); ASSERT_TRUE(rgbPtr); ASSERT_FALSE(rgbPtr->image.empty()); EXPECT_EQ(rgbPtr->image.type(), CV_8UC3); EXPECT_EQ(cv::countNonZero(rgbPtr->image.reshape(1) != rgb.reshape(1)), 0); } ///////////////////////// // SensorData: raw copies, laser scans, stereo ///////////////////////// TEST(MsgConversion, sensorDataToROSCopyRawDataCarriesImages) { cv::Mat K = (cv::Mat_(3, 3) << 525.0, 0.0, 4.0, 0.0, 525.0, 4.0, 0.0, 0.0, 1.0); const rtabmap::CameraModel model("cam", cv::Size(8, 8), K, cv::Mat(), cv::Mat(), cv::Mat()); const cv::Mat rgb(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat depth(8, 8, CV_16UC1, cv::Scalar(2000)); rtabmap::SensorData in(rgb, depth, model, 1, 1000.0); // Without copyRawData the raw images are not serialized... rtabmap_msgs::msg::SensorData without; sensorDataToROS(in, without, "base_link", /*copyRawData=*/false); EXPECT_TRUE(without.left.data.empty()); EXPECT_TRUE(without.right.data.empty()); // ...with it, they are. rtabmap_msgs::msg::SensorData with; sensorDataToROS(in, with, "base_link", /*copyRawData=*/true); ASSERT_FALSE(with.left.data.empty()); ASSERT_FALSE(with.right.data.empty()); EXPECT_EQ(with.left.encoding, sensor_msgs::image_encodings::BGR8); EXPECT_EQ(with.right.encoding, sensor_msgs::image_encodings::TYPE_16UC1); const rtabmap::SensorData out = sensorDataFromROS(with); ASSERT_FALSE(out.imageRaw().empty()); EXPECT_EQ(cv::countNonZero(out.imageRaw().reshape(1) != rgb.reshape(1)), 0); ASSERT_FALSE(out.depthRaw().empty()); EXPECT_EQ(cv::countNonZero(out.depthRaw() != depth), 0); } TEST(MsgConversion, sensorDataLaserScanRoundTrip) { cv::Mat points(1, 3, CV_32FC3); points.at(0, 0) = cv::Vec3f(1.0f, 0.0f, 0.0f); points.at(0, 1) = cv::Vec3f(0.0f, 2.0f, 0.0f); points.at(0, 2) = cv::Vec3f(0.0f, 0.0f, 3.0f); const rtabmap::Transform localTransform(0.0f, 0.0f, 0.3f, 0.0f, 0.0f, 0.0f); const rtabmap::LaserScan scan(points, /*maxPoints=*/100, /*maxRange=*/40.0f, rtabmap::LaserScan::kXYZ, localTransform); rtabmap::SensorData in; in.setStamp(1000.0); in.setLaserScan(scan); rtabmap_msgs::msg::SensorData msg; sensorDataToROS(in, msg, "base_link", /*copyRawData=*/true); EXPECT_EQ(msg.laser_scan_max_pts, 100); EXPECT_FLOAT_EQ(msg.laser_scan_max_range, 40.0f); EXPECT_EQ(msg.laser_scan_format, (int)rtabmap::LaserScan::kXYZ); expectTransformNear(transformFromGeometryMsg(msg.laser_scan_local_transform), localTransform, 1e-4f); const rtabmap::SensorData out = sensorDataFromROS(msg); const rtabmap::LaserScan & outScan = out.laserScanRaw().empty() ? out.laserScanCompressed() : out.laserScanRaw(); EXPECT_EQ(outScan.size(), scan.size()); EXPECT_EQ(outScan.maxPoints(), scan.maxPoints()); EXPECT_FLOAT_EQ(outScan.rangeMax(), scan.rangeMax()); expectTransformNear(outScan.localTransform(), localTransform, 1e-4f); } TEST(MsgConversion, sensorDataStereoModelRoundTrip) { const double fx = 525.0; const double baseline = 0.12; const rtabmap::Transform localTransform(0.0f, 0.0f, 0.1f, 0.0f, 0.0f, 0.0f); const rtabmap::StereoCameraModel stereo( fx, fx, 320.0, 240.0, baseline, localTransform, cv::Size(640, 480)); ASSERT_TRUE(stereo.isValidForProjection()) << "precondition"; rtabmap::SensorData in; in.setStamp(1000.0); in.setStereoImage(cv::Mat(), cv::Mat(), stereo); rtabmap_msgs::msg::SensorData msg; sensorDataToROS(in, msg, "base_link"); // The stereo branch fills BOTH camera infos, unlike the monocular one. ASSERT_EQ(msg.left_camera_info.size(), 1u); ASSERT_EQ(msg.right_camera_info.size(), 1u); const rtabmap::SensorData out = sensorDataFromROS(msg); ASSERT_EQ(out.stereoCameraModels().size(), 1u); EXPECT_TRUE(out.cameraModels().empty()) << "must not be read back as monocular"; EXPECT_NEAR(out.stereoCameraModels()[0].left().fx(), fx, 1e-9); EXPECT_NEAR(out.stereoCameraModels()[0].baseline(), baseline, 1e-6); expectTransformNear(out.stereoCameraModels()[0].localTransform(), localTransform, 1e-4f); } TEST(MsgConversion, nodeWithStereoModelRoundTrip) { const rtabmap::StereoCameraModel stereo( 525.0, 525.0, 320.0, 240.0, 0.12, rtabmap::Transform::getIdentity(), cv::Size(640, 480)); rtabmap::Signature in(3, 0, 1, 1000.0, "stereo_node", sampleTransform()); in.sensorData().setStereoImage(cv::Mat(), cv::Mat(), stereo); rtabmap_msgs::msg::Node msg; nodeToROS(in, msg); const rtabmap::Signature out = nodeFromROS(msg); EXPECT_EQ(out.id(), in.id()); ASSERT_EQ(out.sensorData().stereoCameraModels().size(), 1u); EXPECT_NEAR(out.sensorData().stereoCameraModels()[0].baseline(), 0.12, 1e-6); } TEST(MsgConversion, infoToROSKeepsACallerSuppliedStamp) { // CoreWrapper stamps the message before calling infoToROS, sometimes with a // publication time unrelated to the data. That must not be overwritten. rtabmap::Statistics in; in.setExtended(true); in.setStamp(1234.5); rtabmap_msgs::msg::Info msg; msg.header.stamp = timestampToROS(9999.0); msg.header.frame_id = "map"; infoToROS(in, msg); EXPECT_NEAR(timestampFromROS(msg.header.stamp), 9999.0, 1e-6) << "a caller-supplied stamp must win over the statistics stamp"; EXPECT_EQ(msg.header.frame_id, "map"); } TEST(MsgConversion, infoOdomCacheRoundTrip) { // Statistics carries a whole MapGraph for the odometry cache in localization mode. std::map poses; poses.insert(std::make_pair(1, sampleTransform())); poses.insert(std::make_pair(2, rtabmap::Transform(1, 2, 3, 0, 0, 0))); std::multimap links; links.insert(std::make_pair(1, rtabmap::Link( 1, 2, rtabmap::Link::kNeighbor, sampleTransform()))); rtabmap::Statistics in; in.setExtended(true); in.setOdomCachePoses(poses); in.setOdomCacheConstraints(links); rtabmap_msgs::msg::Info msg; infoToROS(in, msg); ASSERT_EQ(msg.odom_cache.poses.size(), poses.size()); ASSERT_EQ(msg.odom_cache.links.size(), links.size()); rtabmap::Statistics out; infoFromROS(msg, out); ASSERT_EQ(out.odomCachePoses().size(), poses.size()); expectTransformNear(out.odomCachePoses().at(1), poses.at(1)); expectTransformNear(out.odomCachePoses().at(2), poses.at(2)); EXPECT_EQ(out.odomCacheConstraints().size(), links.size()); } ///////////////////////// // TF-based conversions ///////////////////////// namespace { /// A tf2 buffer needs a clock, but neither a node nor a listener: transforms can be /// injected directly, which makes every TF-based conversion an ordinary unit test. std::shared_ptr makeTfBuffer() { std::shared_ptr buffer = std::make_shared(std::make_shared(RCL_ROS_TIME)); // Transforms are injected synchronously before the lookups, so tell tf2 not to warn // about waiting for a listener thread that will never exist. buffer->setUsingDedicatedThread(true); return buffer; } void addTf(tf2_ros::Buffer & buffer, const std::string & parent, const std::string & child, const rtabmap::Transform & t, double stamp, bool isStatic = true) { geometry_msgs::msg::TransformStamped msg; msg.header.stamp = timestampToROS(stamp); msg.header.frame_id = parent; msg.child_frame_id = child; transformToGeometryMsg(t, msg.transform); ASSERT_TRUE(buffer.setTransform(msg, "unit_test", isStatic)); } } // namespace TEST(MsgConversion, getTransformReadsTheBuffer) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.1f, 0.0f, 0.2f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); const rtabmap::Transform out = getTransform("base_link", "camera_link", timestampToROS(1000.0), *buffer, 0.0); expectTransformNear(out, baseToCamera); } TEST(MsgConversion, getTransformReturnsNullWhenUnknown) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform::getIdentity(), 1000.0); // An unrelated frame must not throw; it must come back as a null transform. EXPECT_TRUE(getTransform("base_link", "lidar_link", timestampToROS(1000.0), *buffer, 0.0) .isNull()); } TEST(MsgConversion, getTransformIsInvertedByFrameOrder) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.1f, 0.2f, 0.3f, 0.0f, 0.0f, 0.5f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); const rtabmap::Transform forward = getTransform("base_link", "camera_link", timestampToROS(1000.0), *buffer, 0.0); const rtabmap::Transform backward = getTransform("camera_link", "base_link", timestampToROS(1000.0), *buffer, 0.0); expectTransformNear(backward, forward.inverse(), 1e-4f); } TEST(MsgConversion, getMovingTransformMeasuresMotionBetweenStamps) { // base_link drives 1 m along x of odom between t=1000 and t=1001. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "odom", "base_link", rtabmap::Transform(0, 0, 0, 0, 0, 0), 1000.0, false); addTf(*buffer, "odom", "base_link", rtabmap::Transform(1, 0, 0, 0, 0, 0), 1001.0, false); // Motion of base_link from t=1000 to t=1001, seen in the fixed odom frame. const rtabmap::Transform motion = getMovingTransform( "base_link", "odom", timestampToROS(1000.0), timestampToROS(1001.0), *buffer, 0.0); ASSERT_FALSE(motion.isNull()); EXPECT_NEAR(motion.x(), 1.0, 1e-4); EXPECT_NEAR(motion.y(), 0.0, 1e-4); EXPECT_NEAR(motion.z(), 0.0, 1e-4); } TEST(MsgConversion, getMovingTransformInterpolatesBetweenStamps) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "odom", "base_link", rtabmap::Transform(0, 0, 0, 0, 0, 0), 1000.0, false); addTf(*buffer, "odom", "base_link", rtabmap::Transform(1, 0, 0, 0, 0, 0), 1001.0, false); // Halfway through, so half the motion. const rtabmap::Transform half = getMovingTransform( "base_link", "odom", timestampToROS(1000.0), timestampToROS(1000.5), *buffer, 0.0); ASSERT_FALSE(half.isNull()); EXPECT_NEAR(half.x(), 0.5, 1e-4); } TEST(MsgConversion, getMovingTransformIsNullWithoutAFixedFrame) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "odom", "base_link", rtabmap::Transform::getIdentity(), 1000.0, false); EXPECT_TRUE(getMovingTransform("base_link", "map", timestampToROS(1000.0), timestampToROS(1001.0), *buffer, 0.0).isNull()); } TEST(MsgConversion, convertScanMsgProducesALaserScan) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToLaser(0.2f, 0.0f, 0.1f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "laser", baseToLaser, 1000.0); sensor_msgs::msg::LaserScan msg; msg.header.stamp = timestampToROS(1000.0); msg.header.frame_id = "laser"; msg.angle_min = -1.0f; msg.angle_max = 1.0f; msg.angle_increment = 0.1f; msg.time_increment = 0.0f; msg.range_min = 0.1f; msg.range_max = 30.0f; msg.ranges.assign(21, 5.0f); rtabmap::LaserScan scan; ASSERT_TRUE(convertScanMsg(msg, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)); EXPECT_FALSE(scan.empty()); EXPECT_EQ(scan.size(), (int)msg.ranges.size()); expectTransformNear(scan.localTransform(), baseToLaser, 1e-4f); } TEST(MsgConversion, convertScanMsgRejectsMalformedScans) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "laser", rtabmap::Transform::getIdentity(), 1000.0); sensor_msgs::msg::LaserScan base; base.header.stamp = timestampToROS(1000.0); base.header.frame_id = "laser"; base.angle_min = -1.0f; base.angle_max = 1.0f; base.angle_increment = 0.1f; base.range_min = 0.1f; base.range_max = 30.0f; base.ranges.assign(21, 5.0f); rtabmap::LaserScan scan; sensor_msgs::msg::LaserScan zeroIncrement = base; zeroIncrement.angle_increment = 0.0f; EXPECT_FALSE(convertScanMsg(zeroIncrement, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)) << "angle_increment of 0 would divide by zero"; sensor_msgs::msg::LaserScan invertedRange = base; invertedRange.range_min = 40.0f; EXPECT_FALSE(convertScanMsg(invertedRange, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)) << "range_min > range_max"; sensor_msgs::msg::LaserScan invertedAngle = base; invertedAngle.angle_min = 1.0f; invertedAngle.angle_max = -1.0f; EXPECT_FALSE(convertScanMsg(invertedAngle, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)) << "positive increment with angle_max < angle_min"; } TEST(MsgConversion, convertScanMsgFailsWithoutTf) { const std::shared_ptr buffer = makeTfBuffer(); // empty sensor_msgs::msg::LaserScan msg; msg.header.stamp = timestampToROS(1000.0); msg.header.frame_id = "laser"; msg.angle_min = -1.0f; msg.angle_max = 1.0f; msg.angle_increment = 0.1f; msg.range_min = 0.1f; msg.range_max = 30.0f; msg.ranges.assign(21, 5.0f); rtabmap::LaserScan scan; EXPECT_FALSE(convertScanMsg(msg, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)); } TEST(MsgConversion, convertScan3dMsgKeepsLocalTransformAndLimits) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToLidar(0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "lidar", baseToLidar, 1000.0); sensor_msgs::msg::PointCloud2 msg = makeXYZCloud({{1.0f, 0.0f, 0.0f}, {2.0f, 0.0f, 0.0f}, {3.0f, 0.0f, 0.0f}}); msg.header.stamp = timestampToROS(1000.0); msg.header.frame_id = "lidar"; rtabmap::LaserScan scan; ASSERT_TRUE(convertScan3dMsg(msg, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)); EXPECT_EQ(scan.size(), 3); expectTransformNear(scan.localTransform(), baseToLidar, 1e-4f); EXPECT_EQ(scan.rangeMax(), 0.0f) << "no max range requested"; rtabmap::LaserScan limited; ASSERT_TRUE(convertScan3dMsg(msg, "base_link", "", timestampToROS(1000.0), limited, *buffer, 0.0, /*maxPoints=*/10, /*maxRange=*/2.5f)); EXPECT_EQ(limited.maxPoints(), 10); EXPECT_FLOAT_EQ(limited.rangeMax(), 2.5f); } TEST(MsgConversion, convertScan3dMsgFailsWithoutTf) { const std::shared_ptr buffer = makeTfBuffer(); // empty sensor_msgs::msg::PointCloud2 msg = makeXYZCloud({{1.0f, 0.0f, 0.0f}}); msg.header.stamp = timestampToROS(1000.0); msg.header.frame_id = "lidar"; rtabmap::LaserScan scan; EXPECT_FALSE(convertScan3dMsg(msg, "base_link", "", timestampToROS(1000.0), scan, *buffer, 0.0)); } TEST(MsgConversion, landmarksFromROSAppliesTfAndDefaultVariance) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); geometry_msgs::msg::PoseWithCovarianceStamped tag; tag.header.stamp = timestampToROS(1000.0); tag.header.frame_id = "camera_link"; tag.pose.pose.position.x = 2.0; // 2 m in front of the camera tag.pose.pose.orientation.w = 1.0; // covariance left at zero -> the defaults must be substituted std::map > tags; tags.insert(std::make_pair(7, std::make_pair(tag, 0.15f))); const rtabmap::Landmarks landmarks = landmarksFromROS( tags, "base_link", "", timestampToROS(1000.0), *buffer, 0.0, /*defaultLinVariance=*/0.01, /*defaultAngVariance=*/0.02); ASSERT_EQ(landmarks.size(), 1u); ASSERT_TRUE(landmarks.find(7) != landmarks.end()); // The tag pose must come back in base_link: 0.5 (base->camera) + 2.0 (camera->tag). EXPECT_NEAR(landmarks.at(7).pose().x(), 2.5, 1e-4); const cv::Mat cov = landmarks.at(7).covariance(); ASSERT_EQ(cov.rows, 6); EXPECT_NEAR(cov.at(0,0), 0.01, 1e-9) << "linear default"; EXPECT_NEAR(cov.at(3,3), 0.02, 1e-9) << "angular default"; } TEST(MsgConversion, landmarksFromROSCorrectsForOdometryMotion) { // The tag is seen 1 s after the odometry stamp, during which the robot drives 1 m. // landmarksFromROS must fold that motion in, otherwise the landmark is placed where // the robot would have seen it had it not moved. const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); addTf(*buffer, "odom", "base_link", rtabmap::Transform(0, 0, 0, 0, 0, 0), 1000.0, false); addTf(*buffer, "odom", "base_link", rtabmap::Transform(1, 0, 0, 0, 0, 0), 1001.0, false); geometry_msgs::msg::PoseWithCovarianceStamped tag; tag.header.stamp = timestampToROS(1001.0); // observed at t=1001 tag.header.frame_id = "camera_link"; tag.pose.pose.position.x = 2.0; tag.pose.pose.orientation.w = 1.0; std::map > tags; tags.insert(std::make_pair(7, std::make_pair(tag, 0.15f))); // odomStamp is 1000, one second BEFORE the observation. const rtabmap::Landmarks corrected = landmarksFromROS( tags, "base_link", "odom", timestampToROS(1000.0), *buffer, 0.0, 0.01, 0.02); ASSERT_EQ(corrected.size(), 1u); // 0.5 (base->camera) + 2.0 (camera->tag) + 1.0 (odometry motion since odomStamp). EXPECT_NEAR(corrected.at(7).pose().x(), 3.5, 1e-3); // Without an odom frame the correction cannot be looked up, and the landmark stays // in the frame at the observation stamp. const rtabmap::Landmarks uncorrected = landmarksFromROS( tags, "base_link", "", timestampToROS(1000.0), *buffer, 0.0, 0.01, 0.02); ASSERT_EQ(uncorrected.size(), 1u); EXPECT_NEAR(uncorrected.at(7).pose().x(), 2.5, 1e-3); } TEST(MsgConversion, landmarksFromROSKeepsProvidedCovariance) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform::getIdentity(), 1000.0); geometry_msgs::msg::PoseWithCovarianceStamped tag; tag.header.stamp = timestampToROS(1000.0); tag.header.frame_id = "camera_link"; tag.pose.pose.position.x = 1.0; tag.pose.pose.orientation.w = 1.0; for(size_t i=0; i<6; ++i) { tag.pose.covariance[i*6 + i] = 0.5; // a real, finite covariance } std::map > tags; tags.insert(std::make_pair(1, std::make_pair(tag, 0.1f))); const rtabmap::Landmarks landmarks = landmarksFromROS( tags, "base_link", "", timestampToROS(1000.0), *buffer, 0.0, /*defaultLinVariance=*/0.01, /*defaultAngVariance=*/0.02); ASSERT_EQ(landmarks.size(), 1u); EXPECT_NEAR(landmarks.at(1).covariance().at(0,0), 0.5, 1e-9) << "a provided covariance must not be replaced by the default"; EXPECT_NEAR(landmarks.at(1).covariance().at(3,3), 0.5, 1e-9); } TEST(MsgConversion, landmarksFromROSRejectsNonPositiveIds) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform::getIdentity(), 1000.0); geometry_msgs::msg::PoseWithCovarianceStamped tag; tag.header.stamp = timestampToROS(1000.0); tag.header.frame_id = "camera_link"; tag.pose.pose.orientation.w = 1.0; std::map > tags; tags.insert(std::make_pair(0, std::make_pair(tag, 0.1f))); tags.insert(std::make_pair(-3, std::make_pair(tag, 0.1f))); tags.insert(std::make_pair(5, std::make_pair(tag, 0.1f))); const rtabmap::Landmarks landmarks = landmarksFromROS( tags, "base_link", "", timestampToROS(1000.0), *buffer, 0.0, 0.01, 0.02); EXPECT_EQ(landmarks.size(), 1u) << "ids <= 0 must be dropped"; EXPECT_TRUE(landmarks.find(5) != landmarks.end()); } void expectTfDeskewRecoversWall(bool slerp) { SCOPED_TRACE(slerp ? "slerp=true" : "slerp=false"); // base_link advances 0.1 m along odom over the sweep -- the same motion the constant // velocity tests apply at 1 m/s. With slerp the correction is interpolated between // the two end poses; without it, every sample gets its own TF lookup. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "odom", "base_link", rtabmap::Transform(0, 0, 0, 0, 0, 0), 1000.0, false); addTf(*buffer, "odom", "base_link", rtabmap::Transform(float(kSpeed * kScanSpan), 0, 0, 0, 0, 0), 1000.0 + kScanSpan, false); sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan(1000.0, 0.0); in.header.frame_id = "base_link"; sensor_msgs::msg::PointCloud2 out; ASSERT_TRUE(deskew(in, out, "odom", *buffer, 0.0, slerp)); for(size_t i=0; i b = makeTfBuffer(); geometry_msgs::msg::TransformStamped m; m.header.frame_id = "odom"; m.child_frame_id = "base_link"; m.transform.rotation.w = 1.0; for(double elapsed : {0.0, kneeTime, kScanSpan}) { m.header.stamp = timestampToROS(1000.0 + elapsed); m.transform.translation.x = travelled(elapsed); b->setTransform(m, "unit_test", false); } return b; }; sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan( 1000.0, 0.0, kOffsetSecFloat32, kTimeOnColumns, 1, "t", false, travelled); in.header.frame_id = "base_link"; sensor_msgs::msg::PointCloud2 slerped, perPoint; const std::shared_ptr b1 = buildBuffer(); const std::shared_ptr b2 = buildBuffer(); ASSERT_TRUE(deskew(in, slerped, "odom", *b1, 0.0, /*slerp=*/true)); ASSERT_TRUE(deskew(in, perPoint, "odom", *b2, 0.0, /*slerp=*/false)); // Per-point lookups follow the real motion, so they reconstruct the wall exactly. for(size_t i=0; i buffer = makeTfBuffer(); // empty sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan(1000.0, 0.0); in.header.frame_id = "base_link"; sensor_msgs::msg::PointCloud2 out; EXPECT_FALSE(deskew(in, out, "odom", *buffer, 0.0, true)); } ///////////////////////// // convertRGBDMsgs / convertStereoMsg ///////////////////////// namespace { /// A rectified pinhole CameraInfo. tx is P(0,3): 0 for the left/depth camera, and /// -fx*baseline for the right camera of a stereo pair. sensor_msgs::msg::CameraInfo makeCameraInfo( const std::string & frameId, double stamp, int width, int height, double tx = 0.0, double fx = 100.0) { sensor_msgs::msg::CameraInfo info; info.header.stamp = timestampToROS(stamp); info.header.frame_id = frameId; info.width = width; info.height = height; info.distortion_model = "plumb_bob"; info.d = {0.0, 0.0, 0.0, 0.0, 0.0}; info.k = {fx, 0.0, width/2.0, 0.0, fx, height/2.0, 0.0, 0.0, 1.0}; info.r = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; info.p = {fx, 0.0, width/2.0, tx, 0.0, fx, height/2.0, 0.0, 0.0, 0.0, 1.0, 0.0}; return info; } cv_bridge::CvImageConstPtr makeImage( const std::string & frameId, double stamp, const cv::Mat & image, const std::string & encoding) { std_msgs::msg::Header header; header.stamp = timestampToROS(stamp); header.frame_id = frameId; return std::make_shared(header, encoding, image); } } // namespace TEST(MsgConversion, convertRGBDMsgsSingleCamera) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.1f, 0.0f, 0.2f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); const cv::Mat rgbImage(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat depthImage(8, 8, CV_16UC1, cv::Scalar(1500)); const std::vector images = {makeImage("camera_link", 1000.0, rgbImage, "bgr8")}; const std::vector depths = {makeImage("camera_link", 1000.0, depthImage, "16UC1")}; const std::vector infos = {makeCameraInfo("camera_link", 1000.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, /*alreadyRectifiedImages=*/true)); EXPECT_TRUE(stereoModels.empty()) << "a depth image must not produce a stereo model"; ASSERT_EQ(models.size(), 1u); EXPECT_NEAR(models[0].fx(), 100.0, 1e-9); expectTransformNear(models[0].localTransform(), baseToCamera, 1e-4f); ASSERT_EQ(rgb.cols, 8); ASSERT_EQ(rgb.rows, 8); EXPECT_EQ(depth.type(), CV_16UC1); EXPECT_EQ(depth.at(0, 0), 1500); } TEST(MsgConversion, convertRGBDMsgsMultiCameraSideBySide) { // Two cameras are concatenated horizontally into one wide image, one model each. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "cam0", rtabmap::Transform(0.1f, 0.1f, 0, 0, 0, 0), 1000.0); addTf(*buffer, "base_link", "cam1", rtabmap::Transform(0.1f, -0.1f, 0, 0, 0, 0), 1000.0); const cv::Mat rgb0(8, 8, CV_8UC3, cv::Scalar(10, 0, 0)); const cv::Mat rgb1(8, 8, CV_8UC3, cv::Scalar(0, 20, 0)); const cv::Mat depth0(8, 8, CV_16UC1, cv::Scalar(1000)); const cv::Mat depth1(8, 8, CV_16UC1, cv::Scalar(2000)); const std::vector images = { makeImage("cam0", 1000.0, rgb0, "bgr8"), makeImage("cam1", 1000.0, rgb1, "bgr8")}; const std::vector depths = { makeImage("cam0", 1000.0, depth0, "16UC1"), makeImage("cam1", 1000.0, depth1, "16UC1")}; const std::vector infos = { makeCameraInfo("cam0", 1000.0, 8, 8), makeCameraInfo("cam1", 1000.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)); ASSERT_EQ(models.size(), 2u); EXPECT_EQ(rgb.cols, 16) << "the two 8-wide images must be side by side"; EXPECT_EQ(rgb.rows, 8); EXPECT_EQ(depth.cols, 16); // Each half keeps its own camera's data. EXPECT_EQ(depth.at(0, 0), 1000); EXPECT_EQ(depth.at(0, 8), 2000); EXPECT_NEAR(models[0].localTransform().y(), 0.1, 1e-4); EXPECT_NEAR(models[1].localTransform().y(), -0.1, 1e-4); } namespace { /// base_link sits at odom origin at t=1000 and 1 m along x at t=1001. void addOdomMotion(tf2_ros::Buffer & buffer) { geometry_msgs::msg::TransformStamped m; m.header.frame_id = "odom"; m.child_frame_id = "base_link"; m.transform.rotation.w = 1.0; m.header.stamp = timestampToROS(1000.0); m.transform.translation.x = 0.0; ASSERT_TRUE(buffer.setTransform(m, "unit_test", false)); m.header.stamp = timestampToROS(1001.0); m.transform.translation.x = 1.0; ASSERT_TRUE(buffer.setTransform(m, "unit_test", false)); } } // namespace TEST(MsgConversion, convertRGBDMsgsSyncsToOdomStamp) { // The image is captured at t=1001 but must be expressed relative to the base frame // at odomStamp=1000, one meter back. const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.1f, 0.0f, 0.2f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); addOdomMotion(*buffer); const cv::Mat rgbImage(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat depthImage(8, 8, CV_16UC1, cv::Scalar(1500)); const std::vector images = {makeImage("camera_link", 1001.0, rgbImage, "bgr8")}; const std::vector depths = {makeImage("camera_link", 1001.0, depthImage, "16UC1")}; const std::vector infos = {makeCameraInfo("camera_link", 1001.0, 8, 8)}; cv::Mat rgb, depth; std::vector corrected; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "odom", timestampToROS(1000.0), rgb, depth, corrected, stereoModels, *buffer, 0.0, true)); ASSERT_EQ(corrected.size(), 1u); EXPECT_NEAR(corrected[0].localTransform().x(), 1.1, 1e-3) << "0.1 base->camera plus 1.0 of odometry motion"; // Without an odom frame the motion is not folded in. std::vector uncorrected; std::vector stereoModels2; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "", timestampToROS(1000.0), rgb, depth, uncorrected, stereoModels2, *buffer, 0.0, true)); ASSERT_EQ(uncorrected.size(), 1u); EXPECT_NEAR(uncorrected[0].localTransform().x(), 0.1, 1e-3); } TEST(MsgConversion, convertRGBDMsgsSyncsEachCameraAtItsOwnStamp) { // Two cameras captured 1 s apart, on a robot moving 1 m/s along x. Each must be // corrected by its OWN elapsed motion, not by a single shared one. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "cam0", rtabmap::Transform(0.1f, 0.1f, 0, 0, 0, 0), 1000.0); addTf(*buffer, "base_link", "cam1", rtabmap::Transform(0.1f, -0.1f, 0, 0, 0, 0), 1000.0); addOdomMotion(*buffer); // x=0 @1000, x=1 @1001 geometry_msgs::msg::TransformStamped m; // extend to x=2 @1002 m.header.frame_id = "odom"; m.child_frame_id = "base_link"; m.transform.rotation.w = 1.0; m.header.stamp = timestampToROS(1002.0); m.transform.translation.x = 2.0; ASSERT_TRUE(buffer->setTransform(m, "unit_test", false)); const cv::Mat rgb0(8, 8, CV_8UC3, cv::Scalar(10, 0, 0)); const cv::Mat rgb1(8, 8, CV_8UC3, cv::Scalar(0, 20, 0)); const cv::Mat depth0(8, 8, CV_16UC1, cv::Scalar(1000)); const cv::Mat depth1(8, 8, CV_16UC1, cv::Scalar(2000)); const std::vector images = { makeImage("cam0", 1001.0, rgb0, "bgr8"), makeImage("cam1", 1002.0, rgb1, "bgr8")}; const std::vector depths = { makeImage("cam0", 1001.0, depth0, "16UC1"), makeImage("cam1", 1002.0, depth1, "16UC1")}; const std::vector infos = { makeCameraInfo("cam0", 1001.0, 8, 8), makeCameraInfo("cam1", 1002.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "odom", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)); ASSERT_EQ(models.size(), 2u); // cam0 is 1 s after odomStamp, cam1 is 2 s after. EXPECT_NEAR(models[0].localTransform().x(), 1.1, 1e-3) << "0.1 + 1.0 of motion"; EXPECT_NEAR(models[1].localTransform().x(), 2.1, 1e-3) << "0.1 + 2.0 of motion"; // The corrections must differ, which is the whole point of per-camera stamps. EXPECT_GT(models[1].localTransform().x() - models[0].localTransform().x(), 0.5); // The lateral offsets are untouched by a purely forward motion. EXPECT_NEAR(models[0].localTransform().y(), 0.1, 1e-3); EXPECT_NEAR(models[1].localTransform().y(), -0.1, 1e-3); } TEST(MsgConversion, convertRGBDMsgsPrefersTheDepthStampWhenTheyDiffer) { // The RGB and depth stamps of a camera are assumed to be equal. This pins the // tie-break for when they are not: the depth stamp prevails, since it is the one the // geometry is synchronized to. Not a behavior to rely on -- a camera whose two // stamps disagree is already outside the contract. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform(0.1f, 0, 0, 0, 0, 0), 1000.0); addOdomMotion(*buffer); const cv::Mat rgbImage(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat depthImage(8, 8, CV_16UC1, cv::Scalar(1500)); // RGB stamped at odomStamp (no motion), depth stamped 1 s later (1 m of motion). const std::vector images = {makeImage("camera_link", 1000.0, rgbImage, "bgr8")}; const std::vector depths = {makeImage("camera_link", 1001.0, depthImage, "16UC1")}; const std::vector infos = {makeCameraInfo("camera_link", 1000.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "odom", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)); ASSERT_EQ(models.size(), 1u); EXPECT_NEAR(models[0].localTransform().x(), 1.1, 1e-3) << "the depth stamp (1001) prevails over the rgb stamp (1000)"; } TEST(MsgConversion, convertRGBDMsgsMultiStereoBuildsOneModelPerPair) { // mono8 "right" images make convertRGBDMsgs take the stereo branch and produce // StereoCameraModels instead of CameraModels. The odometry sync is not re-tested // here: it happens in the shared loop before the depth/stereo split, so // convertRGBDMsgsSyncsEachCameraAtItsOwnStamp already covers it for both. const double fx = 100.0; const double baseline0 = 0.15; const double baseline1 = 0.20; const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "left0", rtabmap::Transform(0.1f, 0.1f, 0, 0, 0, 0), 1000.0); addTf(*buffer, "base_link", "left1", rtabmap::Transform(0.1f, -0.1f, 0, 0, 0, 0), 1000.0); const cv::Mat left0(8, 8, CV_8UC1, cv::Scalar(40)); const cv::Mat left1(8, 8, CV_8UC1, cv::Scalar(60)); const cv::Mat right(8, 8, CV_8UC1, cv::Scalar(50)); const std::vector images = { makeImage("left0", 1000.0, left0, "mono8"), makeImage("left1", 1000.0, left1, "mono8")}; const std::vector rights = { makeImage("right0", 1000.0, right, "mono8"), makeImage("right1", 1000.0, right, "mono8")}; const std::vector leftInfos = { makeCameraInfo("left0", 1000.0, 8, 8, 0.0, fx), makeCameraInfo("left1", 1000.0, 8, 8, 0.0, fx)}; const std::vector rightInfos = { makeCameraInfo("right0", 1000.0, 8, 8, -fx*baseline0, fx), makeCameraInfo("right1", 1000.0, 8, 8, -fx*baseline1, fx)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, rights, leftInfos, rightInfos, "base_link", "", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)); EXPECT_TRUE(models.empty()) << "mono8 right images must give stereo models"; ASSERT_EQ(stereoModels.size(), 2u); // Each pair keeps its own baseline and its own local transform. EXPECT_NEAR(stereoModels[0].baseline(), baseline0, 1e-6); EXPECT_NEAR(stereoModels[1].baseline(), baseline1, 1e-6); EXPECT_NEAR(stereoModels[0].localTransform().y(), 0.1, 1e-3); EXPECT_NEAR(stereoModels[1].localTransform().y(), -0.1, 1e-3); // The two left images are laid out side by side, as in the RGB-D case. EXPECT_EQ(rgb.cols, 16); EXPECT_EQ(depth.cols, 16); } TEST(MsgConversion, convertRGBDMsgsSurvivesAFailedOdomLookup) { // A missing odom frame must only warn: the data is still converted, uncorrected. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform(0.1f, 0, 0.2f, 0, 0, 0), 1001.0); const cv::Mat rgbImage(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const std::vector images = {makeImage("camera_link", 1001.0, rgbImage, "bgr8")}; const std::vector infos = {makeCameraInfo("camera_link", 1001.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, {}, infos, {}, "base_link", "odom", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)) << "a failed odometry correction must not be fatal"; ASSERT_EQ(models.size(), 1u); EXPECT_NEAR(models[0].localTransform().x(), 0.1, 1e-3) << "left uncorrected"; } TEST(MsgConversion, convertStereoMsgSyncsToOdomStamp) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "left_link", rtabmap::Transform(0.1f, 0, 0.2f, 0, 0, 0), 1000.0); addOdomMotion(*buffer); const cv::Mat mono(8, 8, CV_8UC1, cv::Scalar(40)); cv::Mat left, right; rtabmap::StereoCameraModel model; ASSERT_TRUE(convertStereoMsg( makeImage("left_link", 1001.0, mono, "mono8"), makeImage("right_link", 1001.0, mono, "mono8"), makeCameraInfo("left_link", 1001.0, 8, 8, 0.0), makeCameraInfo("right_link", 1001.0, 8, 8, -15.0), "base_link", "odom", timestampToROS(1000.0), left, right, model, *buffer, 0.0, true)); EXPECT_NEAR(model.localTransform().x(), 1.1, 1e-3); } TEST(MsgConversion, convertScan3dMsgSyncsToOdomStamp) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "lidar", rtabmap::Transform(0.0f, 0, 0.5f, 0, 0, 0), 1000.0); addOdomMotion(*buffer); sensor_msgs::msg::PointCloud2 msg = makeXYZCloud({{1.0f, 0.0f, 0.0f}}); msg.header.stamp = timestampToROS(1001.0); msg.header.frame_id = "lidar"; rtabmap::LaserScan scan; ASSERT_TRUE(convertScan3dMsg(msg, "base_link", "odom", timestampToROS(1000.0), scan, *buffer, 0.0)); EXPECT_NEAR(scan.localTransform().x(), 1.0, 1e-3) << "0.0 base->lidar plus 1.0 motion"; EXPECT_NEAR(scan.localTransform().z(), 0.5, 1e-3); } TEST(MsgConversion, convertScanMsgSyncsToOdomStamp) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "laser", rtabmap::Transform(0.2f, 0, 0.1f, 0, 0, 0), 1000.0); addOdomMotion(*buffer); sensor_msgs::msg::LaserScan msg; msg.header.stamp = timestampToROS(1001.0); msg.header.frame_id = "laser"; msg.angle_min = -1.0f; msg.angle_max = 1.0f; msg.angle_increment = 0.1f; msg.time_increment = 0.0f; msg.range_min = 0.1f; msg.range_max = 30.0f; msg.ranges.assign(21, 5.0f); rtabmap::LaserScan scan; ASSERT_TRUE(convertScanMsg(msg, "base_link", "odom", timestampToROS(1000.0), scan, *buffer, 0.0)); EXPECT_NEAR(scan.localTransform().x(), 1.2, 1e-3) << "0.2 base->laser plus 1.0 motion"; } TEST(MsgConversion, convertRGBDMsgsRejectsBadEncoding) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform::getIdentity(), 1000.0); // 32FC1 is a valid depth encoding but not a valid rgb/left one. const cv::Mat bad(8, 8, CV_32FC1, cv::Scalar(1.0f)); const std::vector images = {makeImage("camera_link", 1000.0, bad, "32FC1")}; const std::vector infos = {makeCameraInfo("camera_link", 1000.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; EXPECT_FALSE(convertRGBDMsgs(images, {}, infos, {}, "base_link", "", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)); } TEST(MsgConversion, convertRGBDMsgsFailsWithoutTf) { const std::shared_ptr buffer = makeTfBuffer(); // empty const cv::Mat rgbImage(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const std::vector images = {makeImage("camera_link", 1000.0, rgbImage, "bgr8")}; const std::vector infos = {makeCameraInfo("camera_link", 1000.0, 8, 8)}; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; EXPECT_FALSE(convertRGBDMsgs(images, {}, infos, {}, "base_link", "", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true)); } TEST(MsgConversion, convertRGBDMsgsCarriesLocalFeatures) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform::getIdentity(), 1000.0); const cv::Mat rgbImage(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat depthImage(8, 8, CV_16UC1, cv::Scalar(1500)); const std::vector images = {makeImage("camera_link", 1000.0, rgbImage, "bgr8")}; const std::vector depths = {makeImage("camera_link", 1000.0, depthImage, "16UC1")}; const std::vector infos = {makeCameraInfo("camera_link", 1000.0, 8, 8)}; std::vector kptMsgs(2); kptMsgs[0].pt.x = 1.0f; kptMsgs[0].pt.y = 2.0f; kptMsgs[0].size = 7.0f; kptMsgs[1].pt.x = 3.0f; kptMsgs[1].pt.y = 4.0f; kptMsgs[1].size = 7.0f; std::vector ptMsgs(2); ptMsgs[0].x = 1.0f; ptMsgs[1].x = 2.0f; cv::Mat descriptors = cv::Mat::ones(2, 4, CV_32FC1); std::vector outKpts; std::vector outPts; cv::Mat outDescriptors; cv::Mat rgb, depth; std::vector models; std::vector stereoModels; ASSERT_TRUE(convertRGBDMsgs(images, depths, infos, {}, "base_link", "", timestampToROS(1000.0), rgb, depth, models, stereoModels, *buffer, 0.0, true, {kptMsgs}, {ptMsgs}, {descriptors}, &outKpts, &outPts, &outDescriptors)); ASSERT_EQ(outKpts.size(), 2u); EXPECT_FLOAT_EQ(outKpts[0].pt.x, 1.0f); EXPECT_FLOAT_EQ(outKpts[1].pt.x, 3.0f); ASSERT_EQ(outPts.size(), 2u); EXPECT_FLOAT_EQ(outPts[1].x, 2.0f); EXPECT_EQ(outDescriptors.rows, 2); } TEST(MsgConversion, convertStereoMsgProducesAStereoModel) { const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToLeft(0.1f, 0.0f, 0.2f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "left_link", baseToLeft, 1000.0); const double fx = 100.0; const double baseline = 0.15; const cv::Mat leftImage(8, 8, CV_8UC1, cv::Scalar(40)); const cv::Mat rightImage(8, 8, CV_8UC1, cv::Scalar(50)); cv::Mat left, right; rtabmap::StereoCameraModel model; ASSERT_TRUE(convertStereoMsg( makeImage("left_link", 1000.0, leftImage, "mono8"), makeImage("right_link", 1000.0, rightImage, "mono8"), makeCameraInfo("left_link", 1000.0, 8, 8, /*tx=*/0.0, fx), makeCameraInfo("right_link", 1000.0, 8, 8, /*tx=*/-fx*baseline, fx), "base_link", "", timestampToROS(1000.0), left, right, model, *buffer, 0.0, /*alreadyRectified=*/true)); EXPECT_NEAR(model.baseline(), baseline, 1e-6); EXPECT_NEAR(model.left().fx(), fx, 1e-9); expectTransformNear(model.localTransform(), baseToLeft, 1e-4f); ASSERT_EQ(left.type(), CV_8UC1); ASSERT_EQ(right.type(), CV_8UC1); EXPECT_EQ(left.at(0, 0), 40); EXPECT_EQ(right.at(0, 0), 50); } TEST(MsgConversion, convertStereoMsgConvertsColorToMono) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "left_link", rtabmap::Transform::getIdentity(), 1000.0); const cv::Mat color(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat mono(8, 8, CV_8UC1, cv::Scalar(50)); cv::Mat left, right; rtabmap::StereoCameraModel model; ASSERT_TRUE(convertStereoMsg( makeImage("left_link", 1000.0, color, "bgr8"), makeImage("right_link", 1000.0, mono, "mono8"), makeCameraInfo("left_link", 1000.0, 8, 8, 0.0), makeCameraInfo("right_link", 1000.0, 8, 8, -15.0), "base_link", "", timestampToROS(1000.0), left, right, model, *buffer, 0.0, true)); // The left image is kept in color; the right is always reduced to mono. EXPECT_EQ(left.type(), CV_8UC3); EXPECT_EQ(right.type(), CV_8UC1); } TEST(MsgConversion, convertStereoMsgRejectsBadEncoding) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "left_link", rtabmap::Transform::getIdentity(), 1000.0); const cv::Mat bad(8, 8, CV_32FC1, cv::Scalar(1.0f)); const cv::Mat mono(8, 8, CV_8UC1, cv::Scalar(50)); cv::Mat left, right; rtabmap::StereoCameraModel model; EXPECT_FALSE(convertStereoMsg( makeImage("left_link", 1000.0, bad, "32FC1"), makeImage("right_link", 1000.0, mono, "mono8"), makeCameraInfo("left_link", 1000.0, 8, 8, 0.0), makeCameraInfo("right_link", 1000.0, 8, 8, -15.0), "base_link", "", timestampToROS(1000.0), left, right, model, *buffer, 0.0, true)); } TEST(MsgConversion, convertStereoMsgFailsWithoutTf) { const std::shared_ptr buffer = makeTfBuffer(); // empty const cv::Mat mono(8, 8, CV_8UC1, cv::Scalar(50)); cv::Mat left, right; rtabmap::StereoCameraModel model; EXPECT_FALSE(convertStereoMsg( makeImage("left_link", 1000.0, mono, "mono8"), makeImage("right_link", 1000.0, mono, "mono8"), makeCameraInfo("left_link", 1000.0, 8, 8, 0.0), makeCameraInfo("right_link", 1000.0, 8, 8, -15.0), "base_link", "", timestampToROS(1000.0), left, right, model, *buffer, 0.0, true)); } ///////////////////////// // IMU ///////////////////////// TEST(MsgConversion, imuRoundTrip) { sensor_msgs::msg::Imu in; in.orientation.x = 0.0; in.orientation.y = 0.0; in.orientation.z = 0.0; in.orientation.w = 1.0; in.angular_velocity.x = 0.1; in.angular_velocity.y = 0.2; in.angular_velocity.z = 0.3; in.linear_acceleration.x = 1.0; in.linear_acceleration.y = 2.0; in.linear_acceleration.z = 9.81; for(size_t i=0; i<9; ++i) { in.orientation_covariance[i] = 0.01 * (i + 1); in.angular_velocity_covariance[i] = 0.02 * (i + 1); in.linear_acceleration_covariance[i] = 0.03 * (i + 1); } const rtabmap::IMU imu = imuFromROS(in, rtabmap::Transform::getIdentity()); sensor_msgs::msg::Imu out; imuToROS(imu, out); EXPECT_DOUBLE_EQ(out.orientation.w, in.orientation.w); EXPECT_DOUBLE_EQ(out.angular_velocity.x, in.angular_velocity.x); EXPECT_DOUBLE_EQ(out.angular_velocity.y, in.angular_velocity.y); EXPECT_DOUBLE_EQ(out.angular_velocity.z, in.angular_velocity.z); EXPECT_DOUBLE_EQ(out.linear_acceleration.x, in.linear_acceleration.x); EXPECT_DOUBLE_EQ(out.linear_acceleration.y, in.linear_acceleration.y); EXPECT_DOUBLE_EQ(out.linear_acceleration.z, in.linear_acceleration.z); for(size_t i=0; i<9; ++i) { EXPECT_DOUBLE_EQ(out.orientation_covariance[i], in.orientation_covariance[i]) << "orientation covariance at " << i; EXPECT_DOUBLE_EQ(out.angular_velocity_covariance[i], in.angular_velocity_covariance[i]) << "angular velocity covariance at " << i; EXPECT_DOUBLE_EQ(out.linear_acceleration_covariance[i], in.linear_acceleration_covariance[i]) << "linear acceleration covariance at " << i; } }