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.
class CpuStereoMatcher : public IStereoMatcher {
public:
/// @brief True when this build can apply the WLS disparity filter.
[[nodiscard]] static bool wls_available() noexcept;
/// @brief SGBM tuning parameters.
struct Params {
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 disp12_max_diff; ///< Max left-right disparity mismatch (px).
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
// default-member-initialisers used as default function arguments.
Params() noexcept
: 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,
@ -39,6 +58,11 @@ class CpuStereoMatcher : public IStereoMatcher {
private:
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

View File

@ -1,5 +1,6 @@
#pragma once
#include "cloud_point/cpu_stereo_matcher.hpp"
#include "cloud_point/stereo_matcher.hpp"
#include <memory>
@ -12,12 +13,16 @@ class StereoMatcherFactory {
public:
/// @brief Create a stereo matcher of the requested type.
/// 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.
/// @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
/// multiple of 16.
[[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

View File

@ -1,12 +1,35 @@
#include "cloud_point/cpu_stereo_matcher.hpp"
#include <algorithm>
#include <glog/logging.h>
#include <opencv2/imgproc.hpp>
#include <stdexcept>
#include <string>
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
#include <opencv2/ximgproc/disparity_filter.hpp>
#endif
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,
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) {
throw std::invalid_argument(
"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.speckle_window_size, params.speckle_range,
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 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;
}

View File

@ -31,6 +31,19 @@ if opencv_cuda_available
cpp_args += '-DHAVE_OPENCV_CUDA'
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',
sources: cloud_point_sources,
include_directories: inc,

View File

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

View File

@ -55,6 +55,41 @@ TEST(StereoMatcherTest, CpuMatcherRejectsEvenBlockSize) {
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) {
// 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.