Merge pull request #29638 from aarochu:feature-medianblur-arbitrary-channels

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).
This commit is contained in:
Aaron
2026-08-13 01:35:16 -07:00
committed by GitHub
parent 4afa329a78
commit bfa07fc52a
2 changed files with 60 additions and 0 deletions

View File

@@ -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<Mat> srcChannels;
cv::split(_src0, srcChannels);
std::vector<Mat> 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))

View File

@@ -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<Mat> 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<Mat> 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_<short>(8, 16) << 127, 138, 130, 102, 118, 97, 76, 84, 124, 90, 146, 63, 130, 87, 212, 85,