From bfa07fc52a1c7dc2de5819cd0dd6bb286c02a9e0 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 13 Aug 2026 01:35:16 -0700 Subject: [PATCH] Merge pull request #29638 from aarochu:feature-medianblur-arbitrary-channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imgproc: support arbitrary channel counts in medianBlur #29638 ## Summary Fixes #29592. `cv::medianBlur`/`cv2.medianBlur` rejects `CV_8U` images with channel counts other than 1, 3, or 4 whenever the optimized large-kernel path is needed — `ksize >= 7` always, and `ksize == 5` on SIMD-enabled builds (the `ksize == 3`/`ksize == 5`-without-SIMD "sort net" path already handles arbitrary channel counts generically, so this only affects the fast path): ``` src.depth() == CV_8U && (cn == 1 || cn == 3 || cn == 4) ``` Median filtering is channel-independent, so there's no correctness reason for this restriction — it's an implementation detail of the optimized SIMD kernels (which hard-code 1/3/4-channel-interleaved layouts), not a property of the algorithm. Users with 2, 5, 6, 9+ channel images (multispectral, feature maps, stacked masks) currently have to split, filter, and re-concatenate manually outside the library — exactly the workaround already implemented downstream in Albucore, per the issue. ## Fix In `cv::medianBlur` (`modules/imgproc/src/median_blur.dispatch.cpp`), before dispatching to HAL/OpenCL/the optimized SIMD path: if the channel count isn't 1, 3, or 4, split the image into individual channels, run `medianBlur` on each one independently (a single channel is always supported by every existing path, recursively), and merge the results back together. This touches none of the performance-critical kernels — it's a fallback that only runs for channel counts those kernels don't support, and the existing 1/3/4-channel fast paths are completely unaffected. ## Test plan - [x] Added `Imgproc_MedianBlur.arbitrary_channel_count_29592` in `modules/imgproc/test/test_filter.cpp`, covering channel counts `{1,2,3,4,5,6,9}` × kernel sizes `{3,5,7,9}`. For each combination it asserts `medianBlur` doesn't throw, preserves size/type, and — critically — produces output bit-identical to filtering each channel independently via `cv::split`/`cv::merge` (the reference/expected behavior). - [x] Built `opencv_core` + `opencv_imgproc` + `opencv_test_imgproc` locally (MSVC) and ran the new test — passes. - [x] Ran the full existing `Imgproc_MedianBlur.*`, `Imgproc_Filter2D.*`, `Imgproc_Blur.*`, `Imgproc_GaussianBlur.*` suites — all 14 pass, no regressions. - Note: `GaussianBlurVsBitexact`, `sepFilter2D_types`, and `StackBlur` tests in the same binary fail/crash in this local environment, but reproduce identically on unmodified `4.x` with no changes at all — confirmed pre-existing and unrelated to this change (not investigated further, out of scope here). --- modules/imgproc/src/median_blur.dispatch.cpp | 24 +++++++++++++ modules/imgproc/test/test_filter.cpp | 36 ++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/modules/imgproc/src/median_blur.dispatch.cpp b/modules/imgproc/src/median_blur.dispatch.cpp index 6e1b9c917d..8be50b4cdd 100644 --- a/modules/imgproc/src/median_blur.dispatch.cpp +++ b/modules/imgproc/src/median_blur.dispatch.cpp @@ -197,6 +197,30 @@ void medianBlur( InputArray _src0, OutputArray _dst, int ksize ) return; } + int cn = _src0.channels(); + if( ksize > 5 && cn != 1 && cn != 3 && cn != 4 ) + { + // ksize == 3 and ksize == 5 go through the generic "sort net" path, + // which already supports any channel count. Only the large-kernel + // path (ksize > 5) hard-codes support for 1, 3 or 4 channels. Median + // filtering is channel-independent, so any other channel count + // (2, 5, 6,...) is handled by filtering each channel on its own -- + // always supported, since cn == 1 -- and merging the results back + // together. + std::vector srcChannels; + cv::split(_src0, srcChannels); + + std::vector dstChannels(srcChannels.size()); + parallel_for_(Range(0, (int)srcChannels.size()), [&](const Range& range) + { + for( int i = range.start; i < range.end; i++ ) + medianBlur(srcChannels[i], dstChannels[i], ksize); + }); + + cv::merge(dstChannels, _dst); + return; + } + CV_OCL_RUN(_dst.isUMat(), ocl_medianFilter(_src0,_dst, ksize)) diff --git a/modules/imgproc/test/test_filter.cpp b/modules/imgproc/test/test_filter.cpp index 91baa66e6a..2d075d1c32 100644 --- a/modules/imgproc/test/test_filter.cpp +++ b/modules/imgproc/test/test_filter.cpp @@ -2277,6 +2277,42 @@ TEST(Imgproc_MedianBlur, regression_28385) ASSERT_EQ(out.size(), Size(100, 50000)); } +// Regression test for https://github.com/opencv/opencv/issues/29592 +// medianBlur used to reject CV_8U images with channel counts other than +// 1, 3 or 4 whenever the large-kernel path was needed (ksize >= 7, or +// ksize == 5 on SIMD-enabled builds). Any channel count should now work, +// and must match filtering each channel independently. +TEST(Imgproc_MedianBlur, arbitrary_channel_count_29592) +{ + const int channelCounts[] = { 1, 2, 3, 4, 5, 6, 9 }; + const int ksizes[] = { 3, 5, 7, 9 }; + + for (int cn : channelCounts) + { + Mat src(17, 19, CV_MAKETYPE(CV_8U, cn)); + randu(src, 0, 256); + + std::vector srcChannels; + cv::split(src, srcChannels); + + for (int ksize : ksizes) + { + Mat dst; + ASSERT_NO_THROW(medianBlur(src, dst, ksize)) << "cn=" << cn << " ksize=" << ksize; + ASSERT_EQ(dst.size(), src.size()); + ASSERT_EQ(dst.type(), src.type()); + + std::vector dstChannels(srcChannels.size()); + for (size_t i = 0; i < srcChannels.size(); i++) + medianBlur(srcChannels[i], dstChannels[i], ksize); + Mat expected; + cv::merge(dstChannels, expected); + + EXPECT_EQ(0.0, cvtest::norm(dst, expected, NORM_INF)) << "cn=" << cn << " ksize=" << ksize; + } + } +} + TEST(Imgproc_Sobel, s16_regression_13506) { Mat src = (Mat_(8, 16) << 127, 138, 130, 102, 118, 97, 76, 84, 124, 90, 146, 63, 130, 87, 212, 85,