generated from tier4/ros2-project-template
-
Notifications
You must be signed in to change notification settings - Fork 64
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: mask-based pruning filter (vendor independent) (#250)
* chore(nebula_decoders): add utility types for angle pairs, ranges, and FoVs Signed-off-by: Max SCHMELLER <[email protected]> * chore(downsample_mask): add downsample_mask filter class The filter takes a path to a PNG image (will be converted to grayscale) and expected resolution/channel count of the mask. The grayscale mask is then dithered into a black/white mask of the same dimensions, and an `excluded(NebulaPoint const&)` function is provided to test whether a point shall be excluded according to the mask. For debug purposes, the dithered mask is written to the same directory as the input mask, with the file ending changed to `_dithered.png`. If this fails, the filter will not throw but log a warning. Signed-off-by: Max SCHMELLER <[email protected]> * chore(nebula_ros): add schema for point filters, and the downsample mask Signed-off-by: Max SCHMELLER <[email protected]> * chore(downsample_mask): add dependencies to nebula_decoders/package.xml Signed-off-by: Max SCHMELLER <[email protected]> * chore(downsample_mask): make debug mask output optional, clean up code Signed-off-by: Max SCHMELLER <[email protected]> * test(downsample_mask): add unit tests for dithering and filtering Signed-off-by: Max SCHMELLER <[email protected]> * docs(downsample_filter): add downsample filter docs Signed-off-by: Max SCHMELLER <[email protected]> * chore(cspell): add milli-degrees (`mdeg`) to dictionary Signed-off-by: Max SCHMELLER <[email protected]> * chore(downsample_mask): remove `.` before the exported mask's suffix Signed-off-by: Max SCHMELLER <[email protected]> * chore(downsample_mask): explicitly cast instead of implicit conversions Signed-off-by: Max SCHMELLER <[email protected]> * fix(downsample_mask): make `excluded()` function resilient to rounding errors Signed-off-by: Max SCHMELLER <[email protected]> * fix(downsample_mask): add input validation for constructor parameters Add input validation checks for azimuth peak resolution, azimuth range extent, and number of channels to prevent divide-by-zeros and other issues Signed-off-by: Max SCHMELLER <[email protected]> * chore(downsample_mask): fix imports Signed-off-by: Max SCHMELLER <[email protected]> * chore(angles): introduce `AngleUnit`s, make angle ranges support `end < start`angle. Signed-off-by: Max SCHMELLER <[email protected]> --------- Signed-off-by: Max SCHMELLER <[email protected]>
- Loading branch information
Showing
19 changed files
with
481 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,7 @@ | |
"Idat", | ||
"ipaddr", | ||
"manc", | ||
"mdeg", | ||
"memcpy", | ||
"mkdoxy", | ||
"Msop", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
# Point Filters | ||
|
||
Point filters run for every decoded point, reducing pointcloud size right at the decode stage. | ||
This can speed up the later parts of pointcloud processing pipelines by reducing the number of points copied between and processed by modules. | ||
|
||
## Configuration | ||
|
||
Filters are configured via the ROS parameter namespace `point_filters`. | ||
|
||
```yaml | ||
{ | ||
point_filters: | ||
filter_type_a: | ||
parameter_1: 5 | ||
parameter_2: "abc" | ||
filter_type_b: | ||
# ... | ||
} | ||
``` | ||
|
||
Where each `filter_type` can be specified at most once. | ||
The configuration options available depend on the respective filter type. | ||
|
||
Filters can also be set during runtime, e.g. via: | ||
|
||
```shell | ||
# replace <vendor> with the name of a supported vendor | ||
ros2 param set /<vendor>_ros_wrapper_node point_filters.filter_type_a ...' | ||
``` | ||
## Supported Filters | ||
The following filter types are supported: | ||
| Filter Name | Filter Type | Hesai | Robosense | Velodyne | | ||
| ---------------------- | ----------------- | :---: | :-------: | :------: | | ||
| Downsample Mask Filter | `downsample_mask` | ❌ | ❌ | ❌ | | ||
Compatibility: | ||
✅: compatible | ||
❌: incompatible | ||
Below, each filter type is documented in detail. | ||
### Downsample Mask Filter | ||
This filter takes a greyscale PNG image that represents polar coordinates (x=azimuth, y=elevation) | ||
and downsamples the pointcloud according to the lightness values of the image's pixels. | ||
|
||
<!-- prettier-ignore-start --> | ||
!!! note | ||
For ring-based sensors, `y` represents the `channel` as a proxy for `elevation`. | ||
The image height has to be equal to the sensor's number of channels. | ||
<!-- prettier-ignore-end --> | ||
The input image is dithered to a boolean mask: | ||
| Stage | Image | | ||
| :----------------------------------------- | :---------------------------------------------------: | | ||
| Input greyscale mask |  | | ||
| Internal dithered mask generated by Nebula |  | | ||
The decoded points are then kept/discarded based on that mask: | ||
|  |  | | ||
| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | ||
| Pointcloud output. The original pointcloud had uniform density (all white). 2D azimuth-elevation view, points are blurred to better visualize density | Close-up view of a region with multiple different downsampling levels (bottom left of the pointcloud) | | ||
#### Configuration Options | ||
Configuration is done in the following format: | ||
```yaml | ||
downsample_mask: | ||
path: /path/to/mask.png | ||
``` | ||
Or, during runtime, by setting: | ||
```shell | ||
ros2 param set /<vendor>_ros_wrapper_node point_filters.downsample_mask.path /path/to/mask.png' | ||
``` | ||
|
||
The filter can be disabled by omitting the `downsample_mask` config item, or by setting `path` to an empty string: | ||
|
||
```shell | ||
ros2 param set /<vendor>_ros_wrapper_node point_filters.downsample_mask.path ""' | ||
``` | ||
#### Behavior | ||
- Greyscale values are quantized to the nearest 10th (yielding 11 quantization levels in total) | ||
- Mask resolution is dictated by the sensor's maximum FoV, the number of channels (for rotational LiDARs) and the peak angular resolution: | ||
- For a 40-channel LiDAR with `360 deg` FoV and `0.1 deg` peak azimuth resolution, the mask has to be `(360 / 0.1, 40) = (3600, 40)` pixels | ||
- Currently, non-rotational LiDARs are not yet supported | ||
- Image editors like GIMP use perceptual color profiles, which can lead to unexpected results (more/less downsampling than expected). Check the generated `_dithered.png` mask to see if you are affected. | ||
- Dithering performed by Nebula is spatial only, meaning that it stays constant over time. Decoded points are checked against the nearest pixel in the dithered mask |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
153 changes: 153 additions & 0 deletions
153
...decoders/include/nebula_decoders/nebula_decoders_common/point_filters/downsample_mask.hpp
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
// Copyright 2024 TIER IV, Inc. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
#pragma once | ||
|
||
#include "nebula_decoders/nebula_decoders_common/angles.hpp" | ||
|
||
#include <nebula_common/loggers/logger.hpp> | ||
#include <nebula_common/nebula_common.hpp> | ||
#include <nebula_common/point_types.hpp> | ||
#include <nebula_common/util/string_conversions.hpp> | ||
#include <png++/error.hpp> | ||
#include <png++/gray_pixel.hpp> | ||
#include <png++/image.hpp> | ||
|
||
#include <Eigen/src/Core/Matrix.h> | ||
#include <sys/types.h> | ||
|
||
#include <cmath> | ||
#include <cstddef> | ||
#include <cstdint> | ||
#include <filesystem> | ||
#include <memory> | ||
#include <sstream> | ||
#include <stdexcept> | ||
#include <string> | ||
|
||
namespace nebula::drivers::point_filters | ||
{ | ||
|
||
namespace impl | ||
{ | ||
|
||
inline void dither( | ||
const png::image<png::gray_pixel> & in, png::image<png::gray_pixel> & out, | ||
uint8_t quantization_levels) | ||
{ | ||
if (in.get_width() != out.get_width() || in.get_height() != out.get_height()) { | ||
std::stringstream ss; | ||
ss << "Expected downsample mask of size " | ||
<< "(" << out.get_width() << ", " << out.get_height() << ")"; | ||
ss << ", got " | ||
<< "(" << in.get_width() << ", " << in.get_height() << ")"; | ||
|
||
throw std::runtime_error(ss.str()); | ||
} | ||
|
||
uint32_t denominator = quantization_levels; | ||
|
||
auto should_keep = [denominator](uint32_t numerator, uint32_t pos) { | ||
for (uint32_t i = 0; i < numerator; ++i) { | ||
auto dithered_pos = | ||
static_cast<size_t>(std::round(denominator / static_cast<double>(numerator) * i)); | ||
if (dithered_pos == pos) return true; | ||
} | ||
return false; | ||
}; | ||
|
||
for (size_t y = 0; y < out.get_height(); ++y) { | ||
for (size_t x = 0; x < out.get_width(); ++x) { | ||
const auto & pixel = in.get_pixel(x, y); | ||
uint32_t numerator = static_cast<uint32_t>(pixel) * denominator / 255; | ||
size_t pos = (x + y) % denominator; | ||
bool keep = should_keep(numerator, pos); | ||
out.set_pixel(x, y, keep * 255); | ||
} | ||
} | ||
} | ||
|
||
} // namespace impl | ||
|
||
class DownsampleMaskFilter | ||
{ | ||
static const uint8_t g_quantization_levels = 10; | ||
|
||
public: | ||
DownsampleMaskFilter( | ||
const std::string & filename, AngleRange<int32_t, MilliDegrees> azimuth_range_mdeg, | ||
uint32_t azimuth_peak_resolution_mdeg, size_t n_channels, | ||
const std::shared_ptr<loggers::Logger> & logger, bool export_dithered_mask = false) | ||
: azimuth_range_{ | ||
deg2rad(azimuth_range_mdeg.start / 1000.), deg2rad(azimuth_range_mdeg.end / 1000.)} | ||
{ | ||
if (azimuth_peak_resolution_mdeg == 0) { | ||
throw std::invalid_argument("azimuth_peak_resolution_mdeg must be positive"); | ||
} | ||
if (azimuth_range_.extent() <= 0) { | ||
throw std::invalid_argument("azimuth range extent must be positive"); | ||
} | ||
if (n_channels == 0) { | ||
throw std::invalid_argument("n_channels must be positive"); | ||
} | ||
|
||
png::image<png::gray_pixel> factors(filename); | ||
|
||
size_t mask_cols = azimuth_range_mdeg.extent() / azimuth_peak_resolution_mdeg; | ||
size_t mask_rows = n_channels; | ||
|
||
png::image<png::gray_pixel> dithered(mask_cols, mask_rows); | ||
impl::dither(factors, dithered, g_quantization_levels); | ||
|
||
mask_ = Eigen::MatrixX<uint8_t>(mask_rows, mask_cols); | ||
|
||
for (size_t y = 0; y < dithered.get_height(); ++y) { | ||
for (size_t x = 0; x < dithered.get_width(); ++x) { | ||
mask_.coeffRef(static_cast<int32_t>(y), static_cast<int32_t>(x)) = dithered.get_pixel(x, y); | ||
} | ||
} | ||
|
||
if (export_dithered_mask) { | ||
std::filesystem::path out_path{filename}; | ||
out_path = out_path.replace_filename( | ||
out_path.stem().string() + "_dithered" + out_path.extension().string()); | ||
|
||
try { | ||
dithered.write(out_path); | ||
logger->info("Wrote dithered mask to " + out_path.native()); | ||
} catch (const png::std_error & e) { | ||
logger->warn("Could not write " + out_path.native() + ": " + e.what()); | ||
} | ||
} | ||
} | ||
|
||
bool excluded(const NebulaPoint & point) | ||
{ | ||
double azi_normalized = (point.azimuth - azimuth_range_.start) / azimuth_range_.extent(); | ||
|
||
auto x = static_cast<ssize_t>(std::round(azi_normalized * static_cast<double>(mask_.cols()))); | ||
auto y = point.channel; | ||
|
||
bool x_out_of_bounds = x < 0 || x >= mask_.cols(); | ||
bool y_out_of_bounds = y >= mask_.rows(); | ||
|
||
return x_out_of_bounds || y_out_of_bounds || !mask_.coeff(y, x); | ||
} | ||
|
||
private: | ||
AngleRange<double, Radians> azimuth_range_; | ||
Eigen::MatrixX<uint8_t> mask_; | ||
}; | ||
|
||
} // namespace nebula::drivers::point_filters |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Oops, something went wrong.