Skip to content

Commit 681f651

Browse files
facontidavideclaude
andcommitted
Add depthz_image_transport: fast depth compression (32FC1 and 16UC1)
image_transport plugin for depth images, vendoring the depth codec from facontidavide/depth_image_compression. By default 32FC1 is quantized to a configurable uniform grid (<base_topic>.depthz.quantization, default 0.1 mm): every decoded pixel is within +/- half a step of the input, invalid pixels (NaN/inf/<= 0) are preserved and decode as NaN per REP 118, and the "qpred" payload (MED prediction on the quantization codes, zigzag + LEB128 varint residuals, zstd) has no 16-bit level limit, so arbitrarily fine steps and long ranges are supported. Setting quantization to 0 selects the bit-exact lossless mode: a value dictionary + 2D prediction ("dpred") for images with up to 65536 distinct values, and a dictionary-free float-total-order predictor ("fpred") beyond that. 16UC1 input is always compressed losslessly ("dpred16"). On real full-precision stereo depth the default mode compresses better than compressedDepth (PNG) while encoding and decoding several times faster, with a much finer, explicitly bounded quantization error. Implementation notes: - Blobs are fully self-describing: method, dimensions, pixel format and quantization step are readable from the header without decompressing. - Hot paths use runtime-dispatched AVX2 kernels next to scalar paths that emit bit-identical blobs (and are independent of the FPU rounding mode); decoding reconstructs along a wavefront. - Steady-state encode/decode performs no heap allocation (thread-local scratch, bounded-by-header zstd decompression, per-thread contexts). - Hostile/corrupt blobs are rejected before any allocation sized from untrusted fields; degenerate image sizes are handled; the test suite covers round trips, error bounds, and adversarial payloads under ASan/UBSan. - benchmark/ contains an optional black-box perf tool (off by default, -DDEPTHZ_BUILD_BENCHMARK=ON) that drives any image_transport plugin end to end over frames extracted from MCAP bags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e458fa5 commit 681f651

19 files changed

Lines changed: 3547 additions & 0 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Currently provided are:
5757

5858
- [compressed_image_transport](https://github.com/ros-perception/image_transport_plugins/tree/rolling/compressed_image_transport)
5959

60+
- [depthz_image_transport](https://github.com/ros-perception/image_transport_plugins/tree/rolling/depthz_image_transport) - A library compressing 32FC1 and 16UC1 depth images (2D prediction + zstd). By default 32FC1 is quantized to a configurable 0.1 mm grid; a bit-exact lossless mode is available (`quantization: 0.0`), and 16UC1 is always lossless.
61+
6062
- [zstd_image_transport](https://github.com/ros-perception/image_transport_plugins/tree/rolling/zstd_image_transport) - A libraory using ZSTD to compress the pointclouds.
6163

6264
- [theora_image_transport](https://github.com/ros-perception/image_transport_plugins/tree/master/theora_image_transport) - A library using theora to compress the pointclouds.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
cmake_minimum_required(VERSION 3.20)
2+
3+
project(depthz_image_transport)
4+
5+
# The codec is performance-critical: never build it unoptimized by default.
6+
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
7+
set(CMAKE_BUILD_TYPE RelWithDebInfo)
8+
endif()
9+
10+
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
11+
add_compile_options(-Wall -Wextra -Wpedantic)
12+
endif()
13+
14+
find_package(ament_cmake REQUIRED)
15+
find_package(image_transport REQUIRED)
16+
find_package(pluginlib REQUIRED)
17+
find_package(rclcpp REQUIRED)
18+
find_package(sensor_msgs REQUIRED)
19+
20+
# zstd: use the CMake config package if the distro ships one, otherwise the
21+
# plain library. Prefer the shared target: distro static libzstd.a is not
22+
# built with -fPIC and cannot be linked into this shared plugin library.
23+
find_package(zstd CONFIG QUIET)
24+
if(TARGET zstd::libzstd)
25+
set(ZSTD_DEPENDENCY zstd::libzstd)
26+
elseif(TARGET zstd::libzstd_shared)
27+
set(ZSTD_DEPENDENCY zstd::libzstd_shared)
28+
elseif(TARGET zstd::libzstd_static)
29+
set(ZSTD_DEPENDENCY zstd::libzstd_static)
30+
else()
31+
find_path(ZSTD_INCLUDE_DIR zstd.h REQUIRED)
32+
find_library(ZSTD_LIBRARY NAMES zstd REQUIRED)
33+
set(ZSTD_DEPENDENCY ${ZSTD_LIBRARY})
34+
include_directories(${ZSTD_INCLUDE_DIR})
35+
endif()
36+
37+
include_directories(include)
38+
39+
add_library(
40+
${PROJECT_NAME} SHARED
41+
src/depth_codec.cpp
42+
src/depthz_publisher.cpp
43+
src/depthz_subscriber.cpp
44+
src/manifest.cpp
45+
)
46+
47+
# The vendored depth codec uses C++20 (std::countr_zero, std::endian).
48+
target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_20)
49+
target_link_libraries(${PROJECT_NAME}
50+
${ZSTD_DEPENDENCY}
51+
image_transport::image_transport
52+
rclcpp::rclcpp
53+
pluginlib::pluginlib
54+
${sensor_msgs_TARGETS}
55+
)
56+
57+
install(TARGETS ${PROJECT_NAME}
58+
ARCHIVE DESTINATION lib
59+
LIBRARY DESTINATION lib
60+
RUNTIME DESTINATION bin
61+
)
62+
63+
# This is a pluginlib-only package: DepthzPublisher/DepthzSubscriber are
64+
# loaded dynamically by class name (see manifest.cpp), never included or
65+
# linked against directly by downstream packages. include/ is therefore not
66+
# installed -- consistent with the sibling compressed_image_transport and
67+
# zstd_image_transport plugin packages, which likewise export nothing. (The
68+
# headers stay in-source for this package's own build/tests, which include
69+
# them via the include_directories(include) above.)
70+
pluginlib_export_plugin_description_file(image_transport depthz_plugins.xml)
71+
72+
if(BUILD_TESTING)
73+
find_package(ament_lint_auto REQUIRED)
74+
ament_lint_auto_find_test_dependencies()
75+
76+
find_package(ament_cmake_gtest REQUIRED)
77+
ament_add_gtest(test_depth_codec test/test_depth_codec.cpp)
78+
target_compile_features(test_depth_codec PRIVATE cxx_std_20)
79+
target_include_directories(test_depth_codec PRIVATE src)
80+
target_link_libraries(test_depth_codec ${PROJECT_NAME})
81+
endif()
82+
83+
# Local perf-profiling tool, not part of the package's public surface or its
84+
# CI: exercises DepthzPublisher/DepthzSubscriber end to end through the
85+
# public image_transport API. Off by default; see benchmark/README.md.
86+
option(DEPTHZ_BUILD_BENCHMARK "Build the depthz_image_transport perf benchmark tool" OFF)
87+
if(DEPTHZ_BUILD_BENCHMARK)
88+
add_executable(benchmark_depthz benchmark/benchmark_depthz.cpp)
89+
target_compile_features(benchmark_depthz PRIVATE cxx_std_20)
90+
target_link_libraries(benchmark_depthz
91+
image_transport::image_transport
92+
rclcpp::rclcpp
93+
${sensor_msgs_TARGETS}
94+
)
95+
install(TARGETS benchmark_depthz
96+
RUNTIME DESTINATION lib/${PROJECT_NAME}
97+
)
98+
endif()
99+
100+
ament_package()

depthz_image_transport/README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# depthz_image_transport
2+
3+
`image_transport` plugin for depth images (`32FC1` and `16UC1`).
4+
5+
**By default the 32FC1 transport is LOSSY**: depth is quantized to a uniform
6+
0.1 mm grid before compression, so every decoded pixel is within ±0.05 mm of
7+
the input — far below the noise floor of any real depth camera. Set the
8+
`quantization` parameter to `0.0` for bit-exact lossless mode (NaN payloads
9+
included), or to a larger step for more compression. `16UC1` input (already
10+
integer millimeters) is always compressed losslessly.
11+
12+
The codec is vendored from
13+
[facontidavide/depth_image_compression](https://github.com/facontidavide/depth_image_compression),
14+
where the algorithm is documented. Method by input and configuration:
15+
16+
| input | `quantization` | blob method | guarantee |
17+
|---|---|---|---|
18+
| 32FC1 | `> 0` (default 0.1 mm) | `qpred` | ± step/2 per valid pixel, invalid (NaN/inf/≤0) → NaN |
19+
| 32FC1 | `0.0`, ≤ 65536 distinct values | `dpred` | bit-exact |
20+
| 32FC1 | `0.0`, > 65536 distinct values | `fpred` | bit-exact |
21+
| 16UC1 | (ignored) | `dpred16` | bit-exact |
22+
23+
Lossless `dpred`/`fpred`/`dpred16` blobs are interchangeable with the
24+
standalone library in both directions; `qpred` originates in this plugin
25+
(standalone releases predating it reject it, as this plugin rejects the
26+
standalone library's other methods). Every blob is self-describing:
27+
dimensions, pixel format and quantization step are readable from its header
28+
without decompressing anything.
29+
30+
## Performance
31+
32+
On real full-precision stereo depth (the hardest input: nearly every pixel
33+
carries a unique float bit pattern), the default quantized mode compresses
34+
substantially better than `compressedDepth` while encoding and decoding
35+
several times faster — and with a much finer, explicitly bounded
36+
quantization error than `compressedDepth`'s 16-bit inverse-depth stage.
37+
Coarser steps trade precision for ratio; the lossless mode compresses the
38+
least, since it must reproduce the sensor's mantissa noise bit-exactly.
39+
Compression is data-dependent, so measure on your own streams: the
40+
`benchmark/` directory contains a tool that runs any recorded MCAP depth
41+
topic through the real publisher/subscriber plugins and reports ratio,
42+
throughput, and error-bound verification (see `benchmark/README.md`).
43+
44+
## Usage
45+
46+
Subscribers select the transport with the standard `image_transport`
47+
parameter (in rviz2: the *Transport Hint* dropdown of the Image/Camera
48+
display):
49+
50+
```bash
51+
ros2 run my_pkg my_depth_consumer --ros-args -p image_transport:=depthz
52+
```
53+
54+
Publishers advertise all installed transports, so `<base_topic>/depthz`
55+
appears automatically. To publish only selected transports (saving encoder
56+
CPU), use the publishing node's `enable_pub_plugins` parameter:
57+
58+
```yaml
59+
/camera_node:
60+
ros__parameters:
61+
depth.image_rect.enable_pub_plugins:
62+
- image_transport/raw
63+
- image_transport/depthz
64+
```
65+
66+
An existing stream can be converted with
67+
`ros2 run image_transport republish` (`out_transport:=depthz`), e.g. for
68+
bag recording: record `<base_topic>/depthz` instead of
69+
`<base_topic>/compressedDepth`.
70+
71+
## Parameters
72+
73+
| parameter | default | meaning |
74+
|---|---|---|
75+
| `<base_topic>.depthz.quantization` | `0.1` | 32FC1 quantization step in **millimeters**; decoded depth is within ± half this step. `0.0` = bit-exact lossless. Ignored for 16UC1. |
76+
| `<base_topic>.depthz.zstd_level` | `1` | zstd level of the entropy stage (1–3); higher is slower with slightly better ratio. |
77+
78+
The published `CompressedImage.format` string advertises the lossiness
79+
(e.g. `32FC1; depthz; lossy 0.100mm`), so bag consumers can tell without
80+
decoding.
81+
82+
Only `32FC1` and `16UC1` encodings are accepted; other encodings are
83+
declined with an error log (use `compressed` or `zstd` for color images).
84+
85+
See `benchmark/README.md` for the tooling that produced the numbers above.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
data/
2+
__pycache__/
3+
colcon_ws/
4+
results/
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# depthz_image_transport perf benchmark
2+
3+
Local tooling for profiling `DepthzPublisher`/`DepthzSubscriber` with `perf`,
4+
using real depth data. Not part of the package's public surface or CI --
5+
opt in with `-DDEPTHZ_BUILD_BENCHMARK=ON`.
6+
7+
`benchmark_depthz` talks to the plugin exclusively through the public
8+
`image_transport`/`rclcpp` API (`ImageTransport::advertise`,
9+
`image_transport::create_subscription(..., "depthz", ...)`), the same way
10+
any real node would. Profiling this binary profiles exactly what a deployed
11+
publisher or subscriber node spends its time on -- pluginlib loading
12+
included -- not just the vendored codec's hot loop in isolation.
13+
14+
## 1. Get real depth frames
15+
16+
`extract_frames.py` pulls raw `sensor_msgs/Image` frames off an MCAP topic
17+
into a compact `.dzbm` file (see the docstring for the format):
18+
19+
```sh
20+
python3 extract_frames.py \
21+
--mcap ~/ws_eternal/src/Harvesting/harvest_bringup/test/test_data/rosbags/van_noord_tomato_20250919_181407/20250919_181407_0.mcap \
22+
--topic /zed_wrist/zed_node/depth/depth_registered \
23+
--out data/wrist_1920x1200.dzbm
24+
```
25+
26+
Needs `pip install mcap mcap-ros2-support`. That bag has two raw 32FC1
27+
streams from the ZED stereo cameras worth benchmarking:
28+
29+
| topic | resolution | frames |
30+
|---|---|---|
31+
| `/zed_base/zed_node/depth/depth_registered` | 960x600 | 10 |
32+
| `/zed_wrist/zed_node/depth/depth_registered` | 1920x1200 | 10 |
33+
34+
Point `--mcap`/`--topic` at any other bag with a raw (uncompressed) 32FC1 or
35+
16UC1 `sensor_msgs/Image` topic -- it doesn't have to be ZED/depth-camera
36+
specific.
37+
38+
## 2. Build
39+
40+
```sh
41+
colcon build --packages-select depthz_image_transport \
42+
--cmake-args -DDEPTHZ_BUILD_BENCHMARK=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo
43+
source install/setup.bash
44+
```
45+
46+
`RelWithDebInfo` keeps optimizations on (required -- see the package's own
47+
`CMakeLists.txt` comment) while still emitting frame pointers/debug info for
48+
`perf record -g` to unwind.
49+
50+
## 3. Run
51+
52+
```sh
53+
benchmark_depthz --frames data/wrist_1920x1200.dzbm --mode roundtrip --iterations 300
54+
```
55+
56+
`--mode`:
57+
- `roundtrip` (default): a real `DepthzPublisher` encodes each frame, a real
58+
`DepthzSubscriber` decodes it. End-to-end throughput and compression
59+
ratio.
60+
- `encode`: only the publisher plugin's encode path runs (the "subscriber"
61+
is a bare passthrough callback, kept alive only because
62+
`image_transport::Publisher` publishes on demand and needs to see a real
63+
subscriber count -- see its class doc).
64+
- `decode`: all frames are pre-encoded once (untimed) by a real
65+
`DepthzPublisher`, then the timed loop republishes those captured blobs
66+
directly onto the internal `<topic>/depthz` topic with a plain
67+
`rclcpp::Publisher`, bypassing the publisher plugin entirely, while a real
68+
`DepthzSubscriber` decodes them. This isolates decode cost from encode
69+
cost.
70+
71+
Other flags: `--transport <name>` (benchmark any installed image_transport
72+
plugin against the same frames, e.g. `compressedDepth`), `--iterations N`
73+
(loop the dataset N times), `--warmup N` (untimed iterations first, so
74+
`thread_local` scratch buffers reach their steady-state size before the
75+
clock starts), `--zstd-level 1-3`, `--quantization MM` (depthz quantization
76+
step in millimeters; the benchmark defaults to `0` = lossless, deliberately
77+
overriding the plugin's own lossy 0.1 mm default so the bit-exact verify
78+
stays meaningful — pass `--quantization 0.1` to measure the plugin's actual
79+
default behavior), `--no-verify` (skip verification entirely; use before
80+
profiling so the comparison doesn't show up as noise in the flamegraph),
81+
`--qos-depth N`.
82+
83+
Output reports wall time, fps, MB/s (raw), compression ratio, and (unless
84+
`--no-verify`) a verification against the source frames: bit-exact for
85+
lossless depthz, the documented ± step/2 error bound plus NaN preservation
86+
for quantized depthz, and informational-only for other transports.
87+
88+
## 4. Profile with perf
89+
90+
```sh
91+
perf stat -d -- benchmark_depthz --frames data/wrist_1920x1200.dzbm --mode encode --no-verify
92+
perf record -g --call-graph dwarf -o encode.perf.data -- \
93+
benchmark_depthz --frames data/wrist_1920x1200.dzbm --mode encode --no-verify
94+
perf report -i encode.perf.data
95+
```
96+
97+
`run_perf.sh` automates all of the above (build, extract both ZED streams,
98+
run `perf stat` + `perf record` for all 3 modes x 2 resolutions) and drops
99+
results under `results/`:
100+
101+
```sh
102+
./run_perf.sh
103+
```
104+
105+
Because the benchmark drives a `SingleThreadedExecutor` by hand (publish,
106+
then `spin_some` until the callback fires, repeat) rather than a background
107+
spin thread, encode and decode samples land on the same call stack you'd
108+
expect from the source -- `dpred_encode`/`build_value_dict`/`zstd_append`
109+
for encode, `dpred_decode`/`predict_unpack`/`ZSTD_decompressDCtx` for
110+
decode -- with no cross-thread noise to untangle.

0 commit comments

Comments
 (0)