5x5的浮点中值滤波,听起来只是把3x3窗口放大一圈,实际一改才发现完全不是那么回事。我这边有个传感器数据后处理模块,原来用的3x3中值滤波,处理速度一直在几毫秒量级,改成5x5之后直接飙到几十毫秒,模块将近九成执行时间都耗在这一步上。
麻烦出在两个地方:数据是浮点数组,图像处理里那套按灰度直方图做滑动窗口的经典加速办法直接失效;窗口里25个点其实只需要第13个最小值,却每次都得做全排序。这篇文章把我优化5x5浮点数据中值滤波的过程完整记录下来,包括排序网络怎么替换std::sort、IEEE 754位变换怎么把浮点比较变成整型比较、滑窗增量维护为什么在这个窗口尺寸下不划算,以及一路踩到的NaN和边界处理的坑。如果你的项目也在做固定窗口的分位数滤波,这些结论和代码可以直接拿去参考。
1. 朴素实现的计算瓶颈:25点排序与浮点直方图的死路
1.1 25个点找第13小,计算量不是你想的那样增长
中值滤波的原理很简单:取目标点周围邻域内的所有采样值,排序后取中间值输出。5x5窗口意味着总共25个点,中位数是排序后第13个值(下标12,从0开始)。
很多人第一反应是“25个点排序也没多少”,但放大看计算量是这样变化的:
| 窗口 | 数据点 | 中位数位置 | 全排序理想比较次数(约) |
|---|---|---|---|
| 3x3 | 9 | 第5个 | 9 × log2(9) ≈ 30 |
| 5x5 | 25 | 第13个 | 25 × log2(25) ≈ 120 |
3x3到5x5,数据点从9涨到25,是2.8倍;但全排序的比较次数从约30次涨到约120次,是4倍。如果处理100万个点,光比较操作就是1.2亿次级别。这还没算浮点比较本身的开销和排序过程中的循环分支成本。
但更关键的问题在于:我们只需要第25个元素里排名第13的那一个,并不需要另外12个元素的顺序。这是一个典型的选择问题(selection problem),不是排序问题。信息论上从25个元素里确定第13小的值,比较下界大约是84次,而完全排序需要约120次。也就是说,朴素的全排序方案本身就带了大约40%的冗余比较。
1.2 浮点数据为什么要堵死直方图这条路
图像处理领域做中值滤波,有一个绕不开的经典加速方案:Huang等人提出的基于直方图的滑动窗口算法。这个算法维护一个窗口内的灰度直方图,窗口移动时只更新离开和进入的像素点,然后通过直方图累计计数找到中位数。对于8位灰度图,直方图只有256个桶,每个窗口的中值查找复杂度可以做到与灰度级相关而非与窗口面积相关,非常快。
但这个方案有个隐含前提:数据是离散的、值域有限的整数灰度级。
浮点数据把这个前提直接拆了。理论上的浮点值域是连续的,你没法开一个无限大的桶数组。有人会说“那把浮点数据量化到有限桶数不就行了”,比如分4096个桶,但这样会引入量化误差:两个原本只差0.001的值可能被分到相邻桶里,导致中位数偏移。传感器数据往往对精度有硬性要求,这种近似在工程上不可接受。更麻烦的是,浮点数据还可能包含NaN、±Inf,直方图方案对这些非有限值的处理更是灾难。
所以这个问题的边界很清晰:没有直方图可用,必须老老实实在25个浮点数里做选择运算。优化的方向只剩下两个维度:一是让每次窗口的计算更快,二是想办法把窗口之间的重复计算利用起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序网络替换std::sort:无分支比较交换的收益
2.1 先做一个能跑的基线
我先把最直接的朴素实现写出来当基线,确保所有后续优化正确性都有对拍参照。
cpp复制float median5x5_naive(const float* img, int W, int H, int x, int y) {
float buf[25];
int n = 0;
for (int dy = -2; dy <= 2; ++dy) {
for (int dx = -2; dx <= 2; ++dx) {
buf[n++] = img[(y + dy) * W + (x + dx)];
}
}
std::sort(buf, buf + 25);
return buf[12];
}
朴素实现直接调用std::sort。std::sort用的是内省排序(introsort),平均性能已经很能打,但对25个元素的固定小数组,它的问题是身上背了太多通用性包袱:
- 每次递归调用都有函数调用和迭代器操作的开销,编译器想完全内联展开会很吃力;
- 分区过程中大量依赖数据的分支跳转,现代CPU的分支预测器面对随机浮点数据时命中率并不理想;
- 25个元素太小,快速排序的递归框架对这个规模来说是典型的“杀鸡用牛刀”。
2.2 Batcher奇偶归并网络:固定25输入的极端方案
排序网络(sorting network)的思路和通用排序完全不同:它不依赖比较结果动态决定下一步,而是把一组固定的“比较-交换”操作(compare-and-swap)按固定顺序排列,无论输入数据长什么样都执行完全相同的指令序列。对于元素个数固定、且窗口会反复执行同尺寸排序的场景,这是天然契合的方案。
25个输入用双调排序(bitonic sort)需要先补齐到32个输入,比较器数量大约240个,太浪费。我采用的是Batcher奇偶归并排序网络(odd-even mergesort network),对25输入不需要补齐,本地生成的比较器序列大约是141个比较器。
生成思路是典型的递归分治:
- 把25个下标按奇偶位置分成两组(13个和12个),分别递归排序;
- 对两组已排序序列做“奇偶归并”,归并的核心是用相邻位比较和跨位比较交替,把两组交错后的序列逐渐整理成完全有序。
生成的代码形态是这样:
cpp复制#define CMP_SWAP(arr, i, j) \
do { \
if (arr[i] > arr[j]) { \
float t = arr[i]; \
arr[i] = arr[j]; \
arr[j] = t; \
} \
} while (0)
inline void sort25_network(float a[25]) {
// 以下为生成器输出的序列,按顺序执行
CMP_SWAP(a, 0, 1);
CMP_SWAP(a, 2, 3);
CMP_SWAP(a, 4, 5);
// ... 总共约141个CMP_SWAP
CMP_SWAP(a, 11, 12);
}
141次比较-交换,比std::sort理论平均的约120次比较要多出约20%,但实际运行反而更快。
2.3 实测对比:为什么比较次数更多却更快
我在x86-64平台,-O3 -march=native编译,对一张1024×1024的浮点矩阵(约100万个点)做完整的5x5中值滤波,结果如下:
| 实现方式 | 整体耗时 | 单点耗时(约) |
|---|---|---|
| std::sort朴素实现 | 约42 ms | 约42 ns |
| Batcher排序网络 | 约26 ms | 约26 ns |
排序网络在比较次数更多的情况下,耗时反而降了接近40%。原因很直接:
- 完全无分支:不存在分支预测失败惩罚;
- 完全展开:没有循环计数器、没有递归调用栈;
- 数据基本停在寄存器/栈顶:25个float共100字节,完全落在L1 cache和栈上;
- 比较器序列固定,CPU流水线可以预取和乱序执行。
这就是排序网络的核心价值:它把“算法复杂度”换成“固定指令序列的确定性”,对需要处理海量小窗口的场景极其合适。
3. IEEE 754位模式映射:把浮点比较换成整型比较的实测
3.1 位变换的原理:浮点数位模式如何保持全序
排序网络虽然无分支,但比较的还是浮点数。现代x86上浮点比较指令(ucomiss)和整数比较指令(cmp)的延迟差别已经不大,但在很多嵌入式平台和MCU上,浮点比较往往涉及浮点寄存器到通用寄存器的搬运,代价明显更高。这让我想到一个预处理手段:把浮点位模式映射成无符号整数,并且保持大小顺序不变,之后所有比较都用整数比较。
32位IEEE 754浮点数的位结构是:1位符号位 + 8位指数位 + 23位尾
