From 73c98f87a807dd48d3f7d67ff18d77b4c69ac56c Mon Sep 17 00:00:00 2001 From: matlabbe Date: Sun, 13 Sep 2026 12:20:17 -0700 Subject: [PATCH] rtabmap_python tests and doc (#1455) --- .github/workflows/coverage.yml | 41 +++- README.md | 2 +- codecov.yml | 9 +- rtabmap_python/README.md | 49 +++++ rtabmap_python/package.xml | 3 + rtabmap_python/rosdoc2.yaml | 29 +++ .../rtabmap_python/cv_compression.py | 53 ++++++ rtabmap_python/test/test_cv_compression.py | 178 ++++++++++++++++++ 8 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 rtabmap_python/README.md create mode 100644 rtabmap_python/rosdoc2.yaml create mode 100644 rtabmap_python/test/test_cv_compression.py diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2ade89c0..39b6a390 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -60,7 +60,7 @@ jobs: # tested package depends on them (--packages-up-to), just not measured. - uses: ros-tooling/action-ros-ci@v0.4 with: - package-name: rtabmap_conversions rtabmap_util rtabmap_sync + package-name: rtabmap_conversions rtabmap_util rtabmap_sync rtabmap_python target-ros2-distro: humble # RTAB-Map is installed in the image, not as an apt package, so rosdep # cannot resolve the key and must not try. @@ -75,6 +75,9 @@ jobs: { "build": { "mixin": ["coverage-gcc"] + }, + "test": { + "pytest-with-coverage": true } } # Pinned so a change in the mixin repository cannot break this job. @@ -115,6 +118,8 @@ jobs: # `source` does not exist. GitHub picks sh whenever it cannot find # bash in the image's PATH, and says so in the log ("shell: sh -e"). . /opt/ros/humble/setup.sh + # C++ packages only -- rtabmap_python emits no .gcno for lcov to read, + # and is measured by the coveragepy step below instead. PKGS="rtabmap_conversions rtabmap_util rtabmap_sync" # Baseline from the .gcno files. Without it a source file that no test # ever loaded is missing from the report altogether rather than @@ -129,10 +134,34 @@ jobs: '*CompilerId*' '*/CMakeFiles/*' || true test -s lcov/total_coverage.info - # Fails the job if the step above produced nothing -- the failure mode - # this workflow has hit twice already is a green run that measured zero. - - name: Coverage summary - run: lcov --summary ros_ws/lcov/total_coverage.info + # Python coverage is a separate mechanism: the coverage-gcc mixin only adds + # --coverage to the compiler, which does nothing for an ament_python package. + # colcon test --pytest-with-coverage (set above) writes a Cobertura report into + # the package's own build directory instead. + # + # Its paths are relative to the package rather than the repository, so + # cv_compression.py arrives as "rtabmap_python/cv_compression.py" -- one level + # short of where it really lives. Rewrite them here rather than leave Codecov to + # guess, which it does by suffix and can get wrong. + - name: Python coverage report + working-directory: ros_ws + run: | + python3 - <<'EOF' + import pathlib + import xml.etree.ElementTree as ET + p = pathlib.Path('build/rtabmap_python/coverage.xml') + if not p.is_file(): + print('::warning::no python coverage produced for rtabmap_python') + raise SystemExit(0) + tree = ET.parse(p) + root = tree.getroot() + for source in root.iter('source'): + source.text = '.' + for cls in root.iter('class'): + cls.set('filename', 'rtabmap_python/' + cls.get('filename')) + tree.write(p, xml_declaration=True, encoding='utf-8') + print('rewrote', p, 'to repository-relative paths') + EOF # colcon lcov-result runs genhtml itself, into the same lcov/ directory. - name: Upload HTML coverage artifact @@ -146,7 +175,7 @@ jobs: if: ${{ env.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v5 with: - files: ros_ws/lcov/total_coverage.info + files: ros_ws/lcov/total_coverage.info,ros_ws/build/rtabmap_python/coverage.xml # Upload ONLY the aggregated lcov file. By default the CLI also walks # the tree and runs gcov over every .gcno it finds, which re-adds the # test sources the --filter above just dropped. diff --git a/README.md b/README.md index 0be9987b..51bb9c3c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The stack is split into small packages so a pipeline only pulls in what it uses. |---|---| | `rtabmap_msgs` | Message, service and action definitions used across the stack. | | [`rtabmap_conversions`](rtabmap_conversions/README.md) | C++ library converting between RTAB-Map library types and ROS 2 messages. | -| `rtabmap_python` | Python helpers, currently image compression matching RTAB-Map's own format. | +| [`rtabmap_python`](rtabmap_python/README.md) | Python helpers for RTAB-Map's own binary formats, currently the compressed matrices carried in `rtabmap_msgs` fields and database blobs. | ### Visualization diff --git a/codecov.yml b/codecov.yml index a826350c..2e315d02 100644 --- a/codecov.yml +++ b/codecov.yml @@ -56,13 +56,18 @@ component_management: name: rtabmap_sync paths: - rtabmap_sync/** + - component_id: rtabmap_python + name: rtabmap_python + paths: + - rtabmap_python/** comment: layout: "condensed_header, diff, components, files" behavior: default require_changes: true # stay quiet when coverage doesn't move -# Only rtabmap_conversions, rtabmap_util and rtabmap_sync have tests today, so +# Only rtabmap_conversions, rtabmap_util, rtabmap_sync and rtabmap_python have tests +# today, so # everything else would report as 0% and drag the total down to a number that # says nothing. As a package gains tests, drop its line here and add it to # individual_components above. @@ -73,8 +78,8 @@ ignore: - "rtabmap_launch/**" - "rtabmap_msgs/**" - "rtabmap_odom/**" - - "rtabmap_python/**" - "rtabmap_rviz_plugins/**" - "rtabmap_slam/**" - "rtabmap_viz/**" - "**/test/**" + - "**/setup.py" # packaging scaffolding, not code under test diff --git a/rtabmap_python/README.md b/rtabmap_python/README.md new file mode 100644 index 00000000..a57dbf58 --- /dev/null +++ b/rtabmap_python/README.md @@ -0,0 +1,49 @@ +# rtabmap_python + +Python helpers for reading and writing the binary formats [RTAB-Map](https://github.com/introlab/rtabmap) uses. + +RTAB-Map is a C++ library, and the data it hands to ROS is not always plain ROS types. Several `rtabmap_msgs` fields — and every blob in an `.db` database — carry a matrix in RTAB-Map's own compressed encoding rather than as a `sensor_msgs/Image` or an array. This package is the Python side of that encoding, for scripts that read those fields without going through the C++ library. + +There are no nodes here. It is an `ament_python` package that installs one importable module. + +## Module + +`rtabmap_python.cv_compression` — a single-channel `cv::Mat` to and from bytes. + +| Function | Description | +|---|---| +| `compress(data)` | 1-D or 2-D numpy array → `bytearray`. | +| `uncompress(data)` | those bytes → 2-D numpy array. | + +```python +import numpy as np +from rtabmap_python.cv_compression import compress, uncompress + +scan = np.zeros((360, 2), dtype=np.float32) +blob = compress(scan) # what the message field carries +restored = uncompress(blob) # (360, 2) float32 +``` + +The encoding is a zlib stream followed by a 12-byte trailer holding rows, cols and the element type as three `int32`. It matches `compressData()` and `uncompressData()` in RTAB-Map's `corelib/src/Compression.cpp` byte for byte, so either side can read what the other wrote. The [module docstring](rtabmap_python/cv_compression.py) has the exact layout, and the generated [Python API reference](https://docs.ros.org/en/jazzy/p/rtabmap_python/) renders it alongside the two functions. + +## Things worth knowing + +**The result is read-only.** `uncompress` views the decompressed buffer instead of copying it, so the array it returns has `writeable=False` and assigning into it raises. Call `.copy()` if you need to modify it. + +**Single-channel only.** The C++ encoder packs the channel count into the type code; the tables here cover the single-channel depths `CV_8U` through `CV_64F`. A multi-channel matrix written by the C++ side raises `KeyError` rather than decoding wrongly. So does an unsupported dtype on the way in — `int64` and `float16` have no encoding. + +**The trailer is host-endian**, because the C++ side writes raw `int`s. A blob is not portable between machines of opposite endianness. + +## Building and testing + +```bash +colcon build --packages-select rtabmap_python +colcon test --packages-select rtabmap_python +colcon test-result --verbose +``` + +The tests cover the round trip for every supported depth, the exact trailer bytes against the codes RTAB-Map's `serializeMatType()` produces, and each of the behaviours above. Alongside them, the standard `ament_copyright`, `ament_flake8` and `ament_pep257` linters run over the package. + +## License + +BSD-3-Clause. See the [repository root](https://github.com/introlab/rtabmap_ros#license). diff --git a/rtabmap_python/package.xml b/rtabmap_python/package.xml index 4a5d7e84..c8054481 100644 --- a/rtabmap_python/package.xml +++ b/rtabmap_python/package.xml @@ -10,6 +10,8 @@ https://github.com/introlab/rtabmap_ros/issues https://github.com/introlab/rtabmap_ros + python3-numpy + ament_copyright ament_flake8 ament_pep257 @@ -17,5 +19,6 @@ ament_python + rosdoc2.yaml diff --git a/rtabmap_python/rosdoc2.yaml b/rtabmap_python/rosdoc2.yaml new file mode 100644 index 00000000..ae1e28c9 --- /dev/null +++ b/rtabmap_python/rosdoc2.yaml @@ -0,0 +1,29 @@ +## Configuration for rosdoc2, the documentation generator used by docs.ros.org. +## Regenerate the annotated default with: +## rosdoc2 default_config --package-path rtabmap_python +## Build the docs locally with: +## rosdoc2 build --package-path rtabmap_python --output-directory doc_output + +## This 'attic section' self-documents this file's type and version. +type: 'rosdoc2 config' +version: 1 + +--- + +settings: + ## Generate the standard index page from package.xml (description, maintainer, + ## license, links) and a table of contents for the builders below. + generate_package_index: true + + ## This is an ament_python package: there are no C/C++ headers to parse, and the + ## API reference comes from the module docstrings via sphinx-apidoc. + always_run_doxygen: false + always_run_sphinx_apidoc: true + +builders: + ## Sphinx renders the landing page and the autodoc pages sphinx-apidoc produces + ## from rtabmap_python/. + - sphinx: { + name: 'rtabmap_python', + output_dir: '' + } diff --git a/rtabmap_python/rtabmap_python/cv_compression.py b/rtabmap_python/rtabmap_python/cv_compression.py index 0b8e1604..82769d89 100644 --- a/rtabmap_python/rtabmap_python/cv_compression.py +++ b/rtabmap_python/rtabmap_python/cv_compression.py @@ -27,6 +27,35 @@ # POSSIBILITY OF SUCH DAMAGE. +""" +Compress numpy arrays into RTAB-Map's ``cv::Mat`` wire format. + +RTAB-Map stores and transmits matrices -- images, laser scans, descriptors -- as a zlib +payload followed by a 12-byte trailer recording the shape and the element type. Database +blobs and the compressed fields of ``rtabmap_msgs`` messages both use it. + +The layout is:: + + [ zlib stream of the elements in C order ][ rows ][ cols ][ type ] + int32 int32 int32 + +The three trailer fields are written with ``struct`` format ``'iii'`` -- native byte order +and size, matching the C++ side's raw ``int`` writes. That makes the encoding +**host-endian**, so a blob does not travel between machines of opposite endianness. + +``type`` is the OpenCV depth of the elements: 0 ``CV_8U``, 1 ``CV_8S``, 2 ``CV_16U``, +3 ``CV_16S``, 4 ``CV_32S``, 5 ``CV_32F``, 6 ``CV_64F``. + +These two functions are the Python side of that format. They match ``compressData()`` and +``uncompressData()`` in RTAB-Map's ``corelib/src/Compression.cpp`` byte for byte, so a +matrix written by either side can be read by the other. + +Single-channel matrices only. The C++ encoder packs the channel count into the type code +alongside the depth; the tables here cover the single-channel depths ``CV_8U`` through +``CV_64F``, which is what the codes 0 to 6 mean. +""" + + import struct import zlib @@ -34,6 +63,20 @@ import numpy as np def compress(data): + """ + Compress a 1-D or 2-D array into RTAB-Map's format. + + :param data: a single-channel array whose dtype is one of ``uint8``, ``int8``, + ``uint16``, ``int16``, ``int32``, ``float32`` or ``float64``. A 1-D array of + length ``n`` is recorded as a 1-by-``n`` matrix, which is the shape + :func:`uncompress` gives back. Any memory layout is accepted; the bytes are + always written in C order. + :returns: a ``bytearray`` holding the zlib payload followed by the trailer described + in the module docstring. + :raises AssertionError: if ``data`` has more than two dimensions. + :raises KeyError: if its dtype is not one of the seven above -- ``int64`` and + ``float16`` have no encoding in this format. + """ assert data.ndim == 1 or data.ndim == 2 dim1 = 1 @@ -63,6 +106,16 @@ def compress(data): def uncompress(data): + """ + Restore an array written by :func:`compress` or by RTAB-Map's C++ side. + + :param data: a bytes-like object laid out as :func:`compress` returns. + :returns: a 2-D array of the recorded shape and dtype. It is 2-D even when + :func:`compress` was handed a 1-D array, and it is **read-only**: it views the + decompressed buffer instead of copying it, so call ``.copy()`` before writing. + :raises KeyError: if the trailer's type code is not a single-channel depth 0 to 6, + which is what a multi-channel matrix from the C++ side encodes to. + """ cvtype_to_numpy_type = { 0: 'uint8', 1: 'int8', diff --git a/rtabmap_python/test/test_cv_compression.py b/rtabmap_python/test/test_cv_compression.py new file mode 100644 index 00000000..55d46c47 --- /dev/null +++ b/rtabmap_python/test/test_cv_compression.py @@ -0,0 +1,178 @@ +# Copyright 2025 matlabbe +# +# 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 matlabbe nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +"""Tests for :mod:`rtabmap_python.cv_compression`.""" + +import struct +import zlib + +import numpy as np +import pytest + +from rtabmap_python.cv_compression import compress, uncompress + + +# The single-channel depths the format encodes, with the codes RTAB-Map's +# serializeMatType() gives them: CV_8U, CV_8S, CV_16U, CV_16S, CV_32S, CV_32F, CV_64F. +SUPPORTED_TYPES = [ + ('uint8', 0), + ('int8', 1), + ('uint16', 2), + ('int16', 3), + ('int32', 4), + ('float32', 5), + ('float64', 6), +] + +# Three int32: rows, cols, type code. +TRAILER_SIZE = 3 * 4 + + +@pytest.mark.parametrize('dtype,code', SUPPORTED_TYPES) +def test_roundtrip_preserves_shape_dtype_and_values(dtype, code): + """Every supported depth survives a compress/uncompress cycle unchanged.""" + data = np.arange(12, dtype=dtype).reshape(3, 4) + + result = uncompress(compress(data)) + + assert result.shape == (3, 4) + assert result.dtype == np.dtype(dtype) + assert np.array_equal(result, data) + + +@pytest.mark.parametrize('dtype,code', SUPPORTED_TYPES) +def test_trailer_records_rows_cols_and_type_code(dtype, code): + """The last 12 bytes are rows, cols and the type code, as the C++ side writes them.""" + data = np.zeros((3, 4), dtype=dtype) + + trailer = bytes(compress(data)[-TRAILER_SIZE:]) + + assert trailer == struct.pack('iii', 3, 4, code) + + +def test_payload_is_plain_zlib_of_the_c_order_bytes(): + """Everything before the trailer is a zlib stream, so the C++ side can inflate it.""" + data = np.arange(6, dtype=np.uint8) + + payload = bytes(compress(data)[:-TRAILER_SIZE]) + + assert zlib.decompress(payload) == data.tobytes() + + +def test_compress_returns_a_bytearray(): + """The return type is a bytearray, which is what the message fields expect.""" + assert isinstance(compress(np.zeros(4, dtype=np.uint8)), bytearray) + + +def test_one_dimensional_input_comes_back_as_a_single_row(): + """A 1-D array is recorded as 1-by-n, so the roundtrip is not shape-preserving.""" + data = np.arange(5, dtype=np.float32) + + result = uncompress(compress(data)) + + assert result.shape == (1, 5) + assert np.array_equal(result.ravel(), data) + + +@pytest.mark.parametrize('shape', [(1, 5), (5, 1), (2, 3)]) +def test_two_dimensional_shapes_are_preserved_exactly(shape): + """Rows and cols are recorded separately, so no 2-D shape is transposed or flattened.""" + data = np.arange(5 if 1 in shape else 6, dtype=np.uint8).reshape(shape) + + assert uncompress(compress(data)).shape == shape + + +def test_non_contiguous_input_roundtrips(): + """A transposed view is written in C order, so it reads back as the same matrix.""" + data = np.arange(12, dtype=np.int16).reshape(3, 4).T + assert not data.flags.c_contiguous + + result = uncompress(compress(data)) + + assert result.shape == (4, 3) + assert np.array_equal(result, data) + + +def test_empty_array_roundtrips_as_an_empty_row(): + """An empty array is not a special case; it comes back as a 1-by-0 matrix.""" + result = uncompress(compress(np.array([], dtype=np.uint8))) + + assert result.shape == (1, 0) + assert result.dtype == np.uint8 + + +def test_uncompressed_array_is_read_only(): + """uncompress() views the decompressed buffer rather than copying it.""" + result = uncompress(compress(np.arange(4, dtype=np.uint8))) + + assert not result.flags.writeable + with pytest.raises(ValueError): + result[0, 0] = 1 + # .copy() is the way out, as the docstring says. + assert result.copy().flags.writeable + + +def test_accepts_bytes_as_well_as_bytearray(): + """uncompress() reads whatever compress() produced, converted or not.""" + data = np.arange(8, dtype=np.uint16).reshape(2, 4) + + result = uncompress(bytes(compress(data))) + + assert np.array_equal(result, data) + + +def test_larger_matrix_roundtrips(): + """A matrix big enough to actually exercise zlib, with non-trivial content.""" + rng = np.random.default_rng(42) + data = rng.integers(0, 255, size=(120, 160), dtype=np.uint8) + + assert np.array_equal(uncompress(compress(data)), data) + + +def test_rejects_more_than_two_dimensions(): + """The format has no encoding for a third dimension, so compress() refuses one.""" + with pytest.raises(AssertionError): + compress(np.zeros((2, 2, 2), dtype=np.uint8)) + + +@pytest.mark.parametrize('dtype', ['int64', 'uint64', 'float16']) +def test_rejects_unsupported_dtype(dtype): + """Depths outside CV_8U..CV_64F have no type code and raise rather than truncate.""" + with pytest.raises(KeyError): + compress(np.zeros(4, dtype=dtype)) + + +def test_rejects_unknown_type_code(): + """A trailer from a multi-channel C++ matrix encodes a code this table lacks.""" + payload = bytes(compress(np.zeros((2, 2), dtype=np.uint8))[:-TRAILER_SIZE]) + # What serializeMatType() returns for a 3-channel CV_8U matrix: depth + ((cn - 1) << 3). + forged = bytearray(payload) + struct.pack('iii', 2, 2, 0 + ((3 - 1) << 3)) + + with pytest.raises(KeyError): + uncompress(forged)