feat(stereo): add optional disparity post-filters

- add configurable median and edge-aware WLS filtering to the CPU SGBM matcher

- preserve SGBM rejection holes when WLS smooths valid disparities

- detect and link OpenCV ximgproc when available, with a graceful fallback otherwise

- forward CPU tuning through the matcher factory and cover validation and hole preservation
This commit is contained in:
Artur Mukhamadiev 2026-09-12 15:50:12 +03:00
parent 4f21e2ea38
commit 96bc82f0fb
6 changed files with 156 additions and 7 deletions

View File

@ -15,6 +15,9 @@ namespace score {
/// away from the true surface. /// away from the true surface.
class CpuStereoMatcher : public IStereoMatcher { class CpuStereoMatcher : public IStereoMatcher {
public: public:
/// @brief True when this build can apply the WLS disparity filter.
[[nodiscard]] static bool wls_available() noexcept;
/// @brief SGBM tuning parameters. /// @brief SGBM tuning parameters.
struct Params { struct Params {
int block_size; ///< Odd matching block size (SADWindowSize). int block_size; ///< Odd matching block size (SADWindowSize).
@ -23,11 +26,27 @@ class CpuStereoMatcher : public IStereoMatcher {
int speckle_range; ///< Max disparity variation inside a blob. int speckle_range; ///< Max disparity variation inside a blob.
int disp12_max_diff; ///< Max left-right disparity mismatch (px). int disp12_max_diff; ///< Max left-right disparity mismatch (px).
int pre_filter_cap; ///< x-derivative clipping value. int pre_filter_cap; ///< x-derivative clipping value.
/// Median filter applied to the disparity map (0 disables, else odd
/// 3 or 5). Only trims isolated spikes; SGBM's sub-pixel noise is
/// correlated over several pixels, so prefer the WLS filter.
int median_kernel;
/// Edge-aware weighted-least-squares smoothing of the disparity
/// (cv::ximgproc::DisparityWLSFilter). Off by default: on SCARED it
/// halves the fine-scale surface roughness (mesh normals 45 -> 23
/// deg off-axis) but costs accuracy (MAE 0.84 -> 0.92 mm, within
/// 2 mm 80 -> 78 %) and a second SGBM pass (~2x matching time).
/// Enable for visualisation, keep off for measurement. Ignored,
/// with a warning, when OpenCV was built without ximgproc.
bool wls_filter;
double wls_lambda; ///< Smoothness weight (2000; 8000 over-smooths).
double wls_sigma; ///< Edge sensitivity (typical 0.8-2.0).
// Explicit constructor avoids a GCC limitation with nested-struct // Explicit constructor avoids a GCC limitation with nested-struct
// default-member-initialisers used as default function arguments. // default-member-initialisers used as default function arguments.
Params() noexcept Params() noexcept
: block_size(5), uniqueness_ratio(10), speckle_window_size(100), : block_size(5), uniqueness_ratio(10), speckle_window_size(100),
speckle_range(2), disp12_max_diff(1), pre_filter_cap(31) {} speckle_range(2), disp12_max_diff(1), pre_filter_cap(31),
median_kernel(0), wls_filter(false), wls_lambda(2000.0),
wls_sigma(1.5) {}
}; };
CpuStereoMatcher(int min_disparity = 0, int num_disparities = 128, CpuStereoMatcher(int min_disparity = 0, int num_disparities = 128,
@ -39,6 +58,11 @@ class CpuStereoMatcher : public IStereoMatcher {
private: private:
cv::Ptr<cv::StereoSGBM> sgbm_; cv::Ptr<cv::StereoSGBM> sgbm_;
cv::Ptr<cv::StereoMatcher> right_matcher_; ///< Only with WLS.
cv::Ptr<cv::Algorithm> wls_; ///< DisparityWLSFilter.
int median_kernel_;
double wls_lambda_;
double wls_sigma_;
}; };
} // namespace score } // namespace score

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include "cloud_point/cpu_stereo_matcher.hpp"
#include "cloud_point/stereo_matcher.hpp" #include "cloud_point/stereo_matcher.hpp"
#include <memory> #include <memory>
@ -12,12 +13,16 @@ class StereoMatcherFactory {
public: public:
/// @brief Create a stereo matcher of the requested type. /// @brief Create a stereo matcher of the requested type.
/// If GPU is requested but unavailable, falls back to CPU. /// If GPU is requested but unavailable, falls back to CPU.
/// @param num_disparities Number of disparity levels for SGBM (default 128). /// @param num_disparities Number of disparity levels for SGBM (default
/// 128).
/// Must be a positive multiple of 16. /// Must be a positive multiple of 16.
/// @param cpu_params SGBM tuning used for the CPU matcher (and the
/// CPU fallback of the GPU matcher).
/// @throws std::invalid_argument if @p num_disparities is not a positive /// @throws std::invalid_argument if @p num_disparities is not a positive
/// multiple of 16. /// multiple of 16.
[[nodiscard]] static std::unique_ptr<IStereoMatcher> [[nodiscard]] static std::unique_ptr<IStereoMatcher>
create(StereoAlgorithmType type, int num_disparities = 128); create(StereoAlgorithmType type, int num_disparities = 128,
CpuStereoMatcher::Params cpu_params = CpuStereoMatcher::Params{});
}; };
} // namespace score } // namespace score

View File

@ -1,12 +1,35 @@
#include "cloud_point/cpu_stereo_matcher.hpp" #include "cloud_point/cpu_stereo_matcher.hpp"
#include <algorithm>
#include <glog/logging.h>
#include <opencv2/imgproc.hpp>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
#include <opencv2/ximgproc/disparity_filter.hpp>
#endif
namespace score { namespace score {
bool CpuStereoMatcher::wls_available() noexcept {
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
return true;
#else
return false;
#endif
}
CpuStereoMatcher::CpuStereoMatcher(int min_disparity, int num_disparities, CpuStereoMatcher::CpuStereoMatcher(int min_disparity, int num_disparities,
Params params) { Params params)
: median_kernel_(params.median_kernel), wls_lambda_(params.wls_lambda),
wls_sigma_(params.wls_sigma) {
if (params.median_kernel != 0 && params.median_kernel != 3 &&
params.median_kernel != 5) {
throw std::invalid_argument(
"CpuStereoMatcher: median_kernel must be 0, 3 or 5, got " +
std::to_string(params.median_kernel));
}
if (params.block_size < 1 || params.block_size % 2 == 0) { if (params.block_size < 1 || params.block_size % 2 == 0) {
throw std::invalid_argument( throw std::invalid_argument(
"CpuStereoMatcher: block_size must be a positive odd number, got " + "CpuStereoMatcher: block_size must be a positive odd number, got " +
@ -23,11 +46,57 @@ CpuStereoMatcher::CpuStereoMatcher(int min_disparity, int num_disparities,
params.disp12_max_diff, params.pre_filter_cap, params.uniqueness_ratio, params.disp12_max_diff, params.pre_filter_cap, params.uniqueness_ratio,
params.speckle_window_size, params.speckle_range, params.speckle_window_size, params.speckle_range,
cv::StereoSGBM::MODE_SGBM); cv::StereoSGBM::MODE_SGBM);
if (params.wls_filter) {
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
// NB: createDisparityWLSFilter(sgbm_) would silently disable the
// left matcher's uniqueness/speckle/LRC post-filters to obtain a
// dense map for its own confidence estimate. The generic factory
// leaves our tuned matcher alone; we then keep SGBM's holes invalid.
right_matcher_ = cv::ximgproc::createRightMatcher(sgbm_);
auto wls = cv::ximgproc::createDisparityWLSFilterGeneric(
/*use_confidence=*/true);
wls->setLambda(params.wls_lambda);
wls->setSigmaColor(params.wls_sigma);
wls->setLRCthresh(24); // 1.5 px in SGBM's 16x fixed point
wls->setDepthDiscontinuityRadius(
std::max(1, params.block_size / 2 + 1));
wls_ = wls;
#else
LOG(WARNING) << "CpuStereoMatcher: WLS filter requested but OpenCV "
"was built without ximgproc; disparity is unfiltered";
#endif
}
} }
cv::Mat CpuStereoMatcher::compute(const cv::Mat &left, const cv::Mat &right) { cv::Mat CpuStereoMatcher::compute(const cv::Mat &left, const cv::Mat &right) {
cv::Mat disparity; cv::Mat disparity;
sgbm_->compute(left, right, disparity); sgbm_->compute(left, right, disparity);
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
// The WLS filter needs a valid ROI to the right of the disparity search
// range; skip it (raw SGBM output) for images narrower than that.
const int roi_width = sgbm_->getMinDisparity() +
sgbm_->getNumDisparities() + sgbm_->getBlockSize();
if (wls_ && left.cols > roi_width) {
// Snapshot SGBM's holes first: the filter may modify its inputs.
const cv::Mat holes = disparity <= 0;
cv::Mat right_disparity, filtered;
right_matcher_->compute(right, left, right_disparity);
auto wls = wls_.dynamicCast<cv::ximgproc::DisparityWLSFilter>();
wls->filter(disparity, left, filtered, right_disparity);
// WLS extrapolates into pixels SGBM rejected; keep those invalid so
// holes stay holes and coverage is not inflated with guesses.
filtered.setTo(cv::Scalar(sgbm_->getMinDisparity() * 16 - 16), holes);
disparity = filtered;
}
#endif
if (median_kernel_ > 0) {
cv::Mat filtered;
cv::medianBlur(disparity, filtered, median_kernel_);
disparity = filtered;
}
return disparity; return disparity;
} }

View File

@ -31,6 +31,19 @@ if opencv_cuda_available
cpp_args += '-DHAVE_OPENCV_CUDA' cpp_args += '-DHAVE_OPENCV_CUDA'
endif endif
# Optional ximgproc (opencv_contrib) for the WLS disparity post-filter.
opencv_ximgproc_lib = disabler()
if cxx.has_header('opencv2/ximgproc/disparity_filter.hpp', dependencies: opencv_dep)
opencv_ximgproc_lib = cxx.find_library('opencv_ximgproc', required: false)
endif
if opencv_ximgproc_lib.found()
cpc_deps += opencv_ximgproc_lib
cpp_args += '-DCLOUD_POINT_HAVE_OPENCV_XIMGPROC'
message('opencv_ximgproc found: WLS disparity filter enabled')
else
message('opencv_ximgproc not found: WLS disparity filter disabled')
endif
cloud_point_compute_lib = shared_library('cloud_point_compute', cloud_point_compute_lib = shared_library('cloud_point_compute',
sources: cloud_point_sources, sources: cloud_point_sources,
include_directories: inc, include_directories: inc,

View File

@ -8,7 +8,8 @@
namespace score { namespace score {
std::unique_ptr<IStereoMatcher> std::unique_ptr<IStereoMatcher>
StereoMatcherFactory::create(StereoAlgorithmType type, int num_disparities) { StereoMatcherFactory::create(StereoAlgorithmType type, int num_disparities,
CpuStereoMatcher::Params cpu_params) {
if (num_disparities <= 0 || num_disparities % 16 != 0) { if (num_disparities <= 0 || num_disparities % 16 != 0) {
throw std::invalid_argument( throw std::invalid_argument(
"StereoMatcherFactory: num_disparities must be a positive " "StereoMatcherFactory: num_disparities must be a positive "
@ -17,14 +18,16 @@ StereoMatcherFactory::create(StereoAlgorithmType type, int num_disparities) {
} }
switch (type) { switch (type) {
case StereoAlgorithmType::CPU: case StereoAlgorithmType::CPU:
return std::make_unique<CpuStereoMatcher>(0, num_disparities); return std::make_unique<CpuStereoMatcher>(0, num_disparities,
cpu_params);
case StereoAlgorithmType::GPU: case StereoAlgorithmType::GPU:
try { try {
return std::make_unique<GpuStereoMatcher>(0, num_disparities); return std::make_unique<GpuStereoMatcher>(0, num_disparities);
} catch (const std::exception &e) { } catch (const std::exception &e) {
LOG(WARNING) << "GPU stereo matcher unavailable: " << e.what() LOG(WARNING) << "GPU stereo matcher unavailable: " << e.what()
<< ". Falling back to CPU."; << ". Falling back to CPU.";
return std::make_unique<CpuStereoMatcher>(0, num_disparities); return std::make_unique<CpuStereoMatcher>(0, num_disparities,
cpu_params);
} }
} }
return nullptr; return nullptr;

View File

@ -55,6 +55,41 @@ TEST(StereoMatcherTest, CpuMatcherRejectsEvenBlockSize) {
EXPECT_THROW(CpuStereoMatcher(0, 64, params), std::invalid_argument); EXPECT_THROW(CpuStereoMatcher(0, 64, params), std::invalid_argument);
} }
TEST(StereoMatcherTest, CpuMatcherRejectsBadMedianKernel) {
CpuStereoMatcher::Params params;
params.median_kernel = 4;
EXPECT_THROW(CpuStereoMatcher(0, 64, params), std::invalid_argument);
}
TEST(StereoMatcherTest, WlsKeepsSgbmHolesInvalid) {
if (!CpuStereoMatcher::wls_available())
GTEST_SKIP() << "OpenCV built without ximgproc";
// Left 64 px of the right image have no counterpart -> SGBM leaves the
// left border invalid; WLS must not fill it with extrapolated values.
cv::Mat left(120, 200, CV_8UC1);
cv::randu(left, 0, 255);
cv::blur(left, left, cv::Size(3, 3));
cv::Mat right = cv::Mat::zeros(left.size(), CV_8UC1);
left(cv::Rect(8, 0, left.cols - 8, left.rows))
.copyTo(right(cv::Rect(0, 0, left.cols - 8, left.rows)));
CpuStereoMatcher::Params raw;
raw.wls_filter = false;
CpuStereoMatcher::Params wls;
wls.wls_filter = true;
cv::Mat d_raw = CpuStereoMatcher(0, 64, raw).compute(left, right);
cv::Mat d_wls = CpuStereoMatcher(0, 64, wls).compute(left, right);
ASSERT_EQ(d_wls.type(), CV_16S);
ASSERT_EQ(d_wls.size(), d_raw.size());
const int raw_invalid = cv::countNonZero(d_raw <= 0);
EXPECT_GT(raw_invalid, 0);
// No SGBM hole may be filled with an extrapolated disparity.
const int filled = cv::countNonZero((d_raw <= 0) & (d_wls > 0));
EXPECT_EQ(filled, 0);
// The filter must still produce a valid map elsewhere.
EXPECT_GT(cv::countNonZero(d_wls > 0), 0);
}
TEST(StereoMatcherTest, CpuMatcherRecoversKnownShift) { TEST(StereoMatcherTest, CpuMatcherRecoversKnownShift) {
// Textured synthetic pair: right image is the left shifted by 8 px, so // Textured synthetic pair: right image is the left shifted by 8 px, so
// the (16x fixed-point) disparity in the interior should be ~8 px. // the (16x fixed-point) disparity in the interior should be ~8 px.