资讯动态

MFC图像滤波实战:均值、中值、高斯与椒盐去噪

发布时间:2026/10/26 5:21:34 来源:尧图企业网站定制
简介面向具备C基础的Windows图像处理初学者与课程实践者这份工程围绕MFC框架给出了平均值滤波、中值滤波与惯性指数平均滤波三种去噪方法的完整实现可用于对比不同算法在椒盐噪声、高斯噪声与信号平滑场景下的效果差异。压缩包共49个文件、约3.5MB以8个h头文件和7个cpp源文件为主体涵盖主框架、视图、对话框与文档类等典型MFC结构同时附带ico、rc资源文件以及exe、obj、pdb等编译产物便于直接运行与二次调试。已有675人学习下载。读者可以获得可直接编译的VC6工程、用CDC绘图与位图显示实现界面交互的代码以及调整邻域窗口、衰减因子等参数的参考写法适合按模块对照学习并快速移植到自己的图像处理项目中。1. 一张椒盐噪点图的三次抢救MFC 滤波器资源里到底装了什么同一张被椒盐噪点糊满的灰度图丢进均值滤波会变成一片雾丢进中值滤波能干净利落地把黑点白点抠掉丢进高斯滤波则留下一层柔和的模糊。这份 MFC 滤波器资源做的事很朴素把均值、中值、高斯三种最常用的空域滤波塞进同一个单文档工程共用一套像素通路和一套参数面板切换算法只换一个类。它解决的是课程设计、小工具、算法验证里最烦的那段“算法我会写但怎么把 CBitmap 变成能算的数组、再变回去显示”的重复劳动。适合两类人正在做图像处理课设、需要一份能编译能改的骨架的人以及想把滤波结果和耗时、PSNR 摆在一起做对比的人。2. 像素通路先立住从 CBitmap 到 BYTE 数组的三步拆解2.1 三种方法的选型理由为什么偏偏是这三个三种滤波并不是随手凑数它们分别代表了空域里三种不同的思路放在一起才能看出各自边界。均值滤波是线性低通本质是一个全 1 的 3×3 卷积核除以 9它对高斯噪声有效但会把边缘一起抹平噪点是变淡了而不是消失中值滤波是非线性的排序统计取窗口内第九个元素的中位数只要噪点占比不到窗口的一半孤立的极值就会被完整剔除代价是细线和角点容易被吃掉高斯滤波是加权线性离中心越远权重越小它比均值更“讲道理”边缘保留略好而且因为核是可分离的5×5 的 25 次乘法能降到 10 次。算子类型擅长噪声主要副作用3×3 单点开销是否可分离均值滤波线性高斯噪声边缘整体模糊9 次加、1 次除是但收益小中值滤波非线性排序椒盐噪声细线、角点丢失9 元素排序否高斯滤波线性加权高斯噪声、轻微模糊sigma 过大同样糊边9 次乘加是行列各一遍选型上有个实用判断看到图上是一颗颗孤立黑白点先上中值看到的是均匀颗粒感先上高斯只是想快速验证通路通不通先用均值因为它最不容易写错。提示如果手里的工程实现的是频域那三种理想低通、巴特沃斯低通、高斯低通第 2 章的数据通路和界面层可以原样复用只需要替换Apply里的实现其余逻辑不需要重写。2.2 一次 GetDIBits 拿到稳定行序绕开 pitch 正负号MFC 里访问位图像素最麻烦的环节通常出在CImage::GetBits()和GetPitch()的配合上。pitch 可能为负表示这是一张自下而上的 DIB行指针要往上走每个像素的字节偏移还得考虑 4 字节对齐。与其在循环里到处判断符号不如一开始就把位图转成一份自顶向下、32 位、行宽固定为w*4的缓冲后面所有算法都只认这一种布局。// 把 CBitmap 统一快照成自顶向下的 32 位 BGRA 缓冲 // 关键点biHeight 取负值 行序固定从上到下stride 恒为 w*4对齐问题一次性消掉 bool SnapshotToBgra(CBitmap bmp, int w, int h, std::vectorBYTE buf) { BITMAP bm{}; if (!bmp.GetBitmap(bm)) return false; // 位图句柄无效时在这里就返回 w bm.bmWidth; h bm.bmHeight; if (w 0 || h 0) return false; BITMAPINFO bi{}; bi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth w; bi.bmiHeader.biHeight -h; // 负高度自顶向下 bi.bmiHeader.biPlanes 1; bi.bmiHeader.biBitCount 32; // 统一升到 32 位省掉 24 位的对齐补位 bi.bmiHeader.biCompression BI_RGB; buf.assign((size_t)w * h * 4, 0); CClientDC dc(nullptr); // 只借用 DC 做格式转换不参与绘制 int lines ::GetDIBits(dc.GetSafeHdc(), (HBITMAP)bmp.GetSafeHandle(), 0, h, buf.data(), bi, DIB_RGB_COLORS); return lines h; // 行数不足说明中间某步失败了 }参数上只有三处值得改biBitCount保持 32改 24 会引入每行补齐到 4 字节倍数的麻烦biHeight的负号必须和后面所有按y*w寻址的代码保持一致改了就要同步改显示起始扫描行传 0表示从逻辑第一行开始拷贝。返回值判断行数而不是布尔是因为GetDIBits失败时返回 0部分成功时会返回实际拷贝行数这个细节在调试时会帮上忙。拿到 BGRA 之后转灰度是为了后面三种滤波只处理单通道代码量和耗时都能减少。灰度公式用整数近似避免在内层循环里做浮点乘。// BGRA 转 8 位灰度0.299R 0.587G 0.114B 的整数近似 void BgraToGray(const std::vectorBYTE bgra, int w, int h, std::vectorBYTE gray) { gray.assign((size_t)w * h, 0); const size_t n (size_t)w * h; for (size_t i 0; i n; i) { const BYTE* p bgra[i * 4]; // 内存序是 B,G,R,A gray[i] (BYTE)((29 * p[2] 150 * p[1] 77 * p[0]) 8); // 2915077256 } }这里三个系数加起来正好 256右移 8 位等价于除以 256不会溢出也不会掉精度太多。如果原图已经是灰度图这一步可以直接跳过把bgra[i*40]拷进gray[i]就行。2.3 算法与界面解耦一个纯虚基类撑起三种实现界面代码最怕和算法纠缠。三种滤波的参数不一样均值只有半径中值只有窗口高斯还要 sigma如果每个算法都在视图类里写一遍“取缓冲—循环—刷新界面”改起来会很乱。做法是抽一个只有一个纯虚函数的基类所有界面操作只认这个接口。// 滤波统一接口输入输出都是 8 位灰度缓冲尺寸由 w/h 给出 class CFilterBase { public: virtual ~CFilterBase() {} virtual void Apply(const std::vectorBYTE src, std::vectorBYTE dst, int w, int h) 0; virtual CString Name() const 0; // 界面上显示用 }; // 视图里只有这一条执行路径换算法不动 UI void CImgFilterView::RunFilter(std::unique_ptrCFilterBase pFilter) { if (m_gray.empty()) return; CWaitCursor wait; // 大图不加这个用户会以为程序死了 std::vectorBYTE out; DWORD t0 ::GetTickCount(); pFilter-Apply(m_gray, out, m_w, m_h); m_lastCostMs ::GetTickCount() - t0; // 顺手记录耗时验证阶段直接读 GrayToBgra(out, m_w, m_h, m_bgra); // 回写成 BGRA供显示使用 Invalidate(FALSE); // FALSE 只重画不擦背景减少闪烁 }std::unique_ptr传参意味着每次点击按钮新建一个算法对象用完即销毁不用担心状态残留。Apply内部要保证dst先assign成src同样大小否则后面按下标写入会越界。耗时字段单独存成员变量而不是弹 MessageBox是因为批量对比三种算法时弹窗会打断流程。2.4 结果回写与重绘负高度必须和缓冲行序对齐算完的结果要显示出来最省事的办法是直接用SetDIBitsToDevice把缓冲画到视图 DC不用再创建 CBitmap 和内存 DC。这里唯一的坑是biHeight的符号缓冲是自顶向下的这里也必须传负数否则图会上下颠倒而且这种颠倒在小图上不容易一眼看出来。// 直接把 BGRA 缓冲贴到视图 DC省掉一次 CBitmap 创建 void CImgFilterView::OnDraw(CDC* pDC) { if (m_bgra.empty()) return; BITMAPINFO bi{}; bi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth m_w; bi.bmiHeader.biHeight -m_h; // 与缓冲行序一致正数会导致上下颠倒 bi.bmiHeader.biPlanes 1; bi.bmiHeader.biBitCount 32; bi.bmiHeader.biCompression BI_RGB; ::SetDIBitsToDevice(pDC-GetSafeHdc(), 0, 0, m_w, m_h, 0, 0, 0, m_h, m_bgra.data(), bi, DIB_RGB_COLORS); }SetDIBitsToDevice的第 2、3 个参数是目标左上角坐标第 4、5 个是宽高中间那两个 0 是源图的起始扫描行和起始扫描线最后一个m_h是本次拷贝的行数。如果只刷新局部区域把前两个 0 改成对应偏移就能做局部重绘滚动大图时很有用。3. 三种算子逐行落地3×3 均值、9 元素中值与可分离高斯3.1 均值滤波滑窗、边界复制与那半个灰阶3×3 均值的结构极其简单难点全在边界。图像第一行的上方、第一列的左侧都是不存在的像素常见处理有三种补零会形成暗边、镜像效果最好但代码稍多、复制最近有效行实现最省事效果也够用。这里选边界复制因为它在后续几种滤波里可以复用同一个钳位函数。// 3×3 均值滤波src/dst 均为 8 位单通道w*h 为图像尺寸 void Mean3x3(const std::vectorBYTE src, std::vectorBYTE dst, int w, int h) { dst.assign(src.size(), 0); for (int y 0; y h; y) { for (int x 0; x w; x) { int sum 0; for (int dy -1; dy 1; dy) { // 边界复制越界的行钳到最近有效行避免出现一圈黑边 const int yy std::min(std::max(y dy, 0), h - 1); const BYTE* row src[(size_t)yy * w]; for (int dx -1; dx 1; dx) { const int xx std::min(std::max(x dx, 0), w - 1); sum row[xx]; } } dst[(size_t)y * w x] (BYTE)((sum 4) / 9); // 4 是四舍五入不是玄学 } } }(sum 4) / 9里的 4 是除数的一半作用是四舍五入而不是截断。少了它整幅图会整体偏暗半个灰阶肉眼在连续调上能看出来尤其是把滤波结果和原图来回切换的时候。另一个不要做的优化是把std::min/std::max换成手写 if在 Release 下编译器会把它内联成条件传送手写分支反而可能打断向量化。如果图很大这个三层循环的耗时主要花在重复读同一批像素上把内层横向三格的和缓存下来、只做“进一列出一列”的增量更新能把每点 9 次加法降到 3 次。不过对课设级别的图直接写反而更好读先跑通再谈优化。3.2 中值滤波九个元素插入排序比你想的快中值滤波跑得慢原因通常是用通用排序去排九个元素。九个元素的数据量固定插入排序的常数极小实测比调用标准库的std::sort快出一截也不会有递归和内存分配。// 九个元素取中位数插入排序后取 a[4] inline BYTE Median9(const BYTE v[9]) { BYTE a[9]; memcpy(a, v, 9); for (int i 1; i 9; i) { const BYTE key a[i]; int j i - 1; while (j 0 a[j] key) { a[j 1] a[j]; --j; } a[j 1] key; } return a[4]; } // 3×3 中值滤波窗口内九个值交给 Median9 void Median3x3(const std::vectorBYTE src, std::vectorBYTE dst, int w, int h) { dst.assign(src.size(), 0); BYTE win[9]; for (int y 0; y h; y) { for (int x 0; x w; x) { int k 0; for (int dy -1; dy 1; dy) { const int yy std::min(std::max(y dy, 0), h - 1); for (int dx -1; dx 1; dx) { const int xx std::min(std::max(x dx, 0), w - 1); win[k] src[(size_t)yy * w xx]; } } dst[(size_t)y * w x] Median9(win); // 偶数窗口没有唯一中值3×3 最省心 } } }窗口大小只能取奇数5×5 就是 25 个元素取第 13 个插入排序的开销会明显上升这时才值得换成快选算法。3×3 对椒盐噪声的抑制效果通常已经够了噪点密度超过窗口面积一半时中值也会失效表现为噪点被“摊开”成小块而不是消失这是判断密度上限的直观方法。3.3 高斯滤波核归一化与可分离卷积高斯核最容易出错的地方是归一化。二维核按exp(-(x²y²)/2σ²)逐点算完所有元素加起来往往不等于 1直接拿去卷积会让整幅图偏亮或偏暗。正确做法是算完立刻除以总和保证权重和严格为 1。// 一维高斯核长度 2r1内部已归一化 std::vectordouble MakeGaussianKernel(int r, double sigma) { const int k 2 * r 1; std::vectordouble k1(k, 0.0); double sum 0.0; for (int i -r; i r; i) { const double v std::exp(-(i * i) / (2.0 * sigma * sigma)); k1[i r] v; sum v; } for (double v : k1) v / sum; // 归一化缺了这步整幅图会整体变亮 return k1; }可分离卷积是两趟一维卷积先横向做一遍结果存进浮点缓冲再纵向做一遍。5×5 的核原本每点 25 次乘法拆开后只要 10 次半径越大差距越明显。中间结果必须用浮点存如果横向做完就截断成 BYTE纵向再算就会累积两次量化误差边缘会出现明显的色阶断层。// 可分离高斯先横后竖tmp 用 double 避免两次截断叠加误差 void GaussianBlur(const std::vectorBYTE src, std::vectorBYTE dst, int w, int h, int r, double sigma) { if (r 0 || sigma 0.0) { dst src; return; } const std::vectordouble k1 MakeGaussianKernel(r, sigma); std::vectordouble tmp(src.size(), 0.0); for (int y 0; y h; y) // 横向 for (int x 0; x w; x) { double acc 0.0; for (int i -r; i r; i) { const int xx std::min(std::max(x i, 0), w - 1); acc src[(size_t)y * w xx] * k1[i r]; } tmp[(size_t)y * w x] acc; } dst.assign(src.size(), 0); for (int y 0; y h; y) // 纵向 for (int x 0; x w; x) { double acc 0.0; for (int i -r; i r; i) { const int yy std::min(std::max(y i, 0), h - 1); acc tmp[(size_t)yy * w x] * k1[i r]; } // 加 0.5 再截断比直接强转少半级灰度偏差 dst[(size_t)y * w x] (BYTE)std::min(255.0, acc 0.5); } }sigma 和半径的搭配有个经验值r取3σ向上取整就足够再大的半径权重已经小到可以忽略多算纯属浪费。比如 σ1.0 用 3×3r1勉强够σ1.5 就要 5×5r2σ2.0 得上 7×7。用 3×3 去配 σ2.0等于把高斯截断成了一个近似方形的核效果会明显偏向均值滤波。3.4 参数怎么映射到界面滑条、半径与预览界面上的三个控件——算法下拉、半径滑条、sigma 编辑框——直接映射到上面三个函数的入参但有两条约束要在界面上做死半径只能是 1、2、3、4 这类正整数中值滤波只能取奇数窗口sigma 必须大于 0等于 0 时除以零会让整幅图变成一片黑或一片白。控件范围映射到备注算法下拉均值/中值/高斯创建哪个CFilterBase派生类切换时重置半径默认值半径滑条15r中值模式下强制取奇数sigma 编辑框0.15.0sigma仅高斯有效其余置灰预览开关勾选是否对缩放后的副本滤波大图必开否则每次点都等好几秒一个很实际的做法预览时把图缩到长边 800 像素以内再跑滤波点“应用”时才用原尺寸算一遍。用户拖滑条时看到的是缩放预览感知延迟从几秒降到几十毫秒这个差别直接决定这个 demo 好不好用。4. 避坑清单MFC 图像滤波最容易翻车的五处现场4.1 位图与像素访问两条血泪经验现象一GetDIBits每次都返回 0缓冲里全是零画出来一张纯黑图。原因源位图当时正被选在某个内存 DC 里。GetDIBits要求源位图不能处于被选入 DC 的状态很多工程在 OnDraw 里创建了兼容位图就直接拿去读正好踩中。 解决读取前先::SelectObject(memDC, oldBmp)把位图换出来或者干脆在加载完成、还没往 DC 里选的时候就先做一次快照把 BGRA 缓冲缓存成成员变量后续所有滤波都从这个缓存出发不再碰 CBitmap。现象二图能显示但整体上下颠倒或者右边缘有一条彩色斜纹。原因颠倒是biHeight的符号和缓冲行序不一致造成的一边自顶向下一边自底向上斜纹是 24 位位图每行补齐到 4 字节倍数时按w*3直接寻址越过了行尾的填充字节。 解决固定用 32 位 负高度这一套组合两者绑死如果必须处理 24 位stride 要算成((w * 24 31) / 32) * 4而不是w * 3。这类问题在小图上不容易发现测试时务必用一张宽度不是 4 的倍数的图比如 333×200跑一遍。4.2 数值与边界溢出和灰边最容易被忽略现象三滤波后图像边缘出现一圈明显的暗边或亮边。原因越界像素被当成 0 处理等效于在图像四周补了一圈黑卷积时把黑掺进了边缘像素。 解决统一用边界复制也就是把越界坐标钳到[0, w-1]和[0, h-1]。三种滤波共用同一个钳位逻辑改一处即可。镜像填充效果更好但要多写几行对这个场景收益不大。现象四高斯滤波后整幅图偏亮或者出现台阶状色带。原因偏亮是核没有归一化权重和大于 1色带是可分离卷积的中间结果用了 BYTE 存储横向截断一次、纵向再截断一次两次量化误差叠加。 解决MakeGaussianKernel末尾强制除以总和中间缓冲一律用double只在最后一步转回 BYTE转换时加 0.5 做四舍五入。4.3 界面与性能卡死、重入和内存现象五加载一张几千万像素的图点一下滤波窗口直接白屏任务管理器显示“未响应”。原因滤波在 UI 线程同步执行几亿次乘加把消息循环堵死了Windows 判定无响应。 解决短期方案是加CWaitCursor并把预览缩放到 800 像素以内让用户至少知道程序在干活真正的解法是把Apply丢到工作线程用PostMessage把结果回传给 UI 线程注意工作线程里绝对不能直接碰控件。用成员变量保存结果缓冲时还要注意工作线程写、UI 线程读中间得有临界区或者干脆等线程结束后再更新。现象六反复切换算法几十次之后内存占用一路涨。原因每次新建的 GDI 对象内存 DC、兼容位图、画笔只创建没释放DeleteObject漏了或者std::vector缓冲在主循环里被反复assign到极大尺寸。 解决GDI 句柄用 RAII 小类包起来析构时自动DeleteObject像素缓冲做成成员变量只在大图加载时重新分配一次滤波过程中用dst.assign(src.size(), 0)复用已有容量避免反复申请释放。5. 验证收尾用加噪-滤波闭环和 PSNR 表判断结果对不对滤波代码写完肉眼看着“似乎变干净了”就收工是最容易埋雷的做法。可复现的验证方式是对一张干净图按固定参数加椒盐噪声再分别跑三种滤波把结果和原图做 PSNR 对比同时记录耗时。// 固定种子的椒盐加噪保证每次验证的输入完全一致 void AddSaltPepper(std::vectorBYTE img, int w, int h, double ratio, unsigned seed) { std::mt19937 rng(seed); // 固定种子复现实验的前提 std::uniform_real_distributiondouble pick(0.0, 1.0); for (size_t i 0, n (size_t)w * h; i n; i) { double r pick(rng); if (r ratio * 0.5) img[i] 0; // 黑点 else if (r ratio) img[i] 255; // 白点 } } // PSNR峰值信噪比单位 dB越大越接近参考图 double PSNR(const std::vectorBYTE a, const std::vectorBYTE b) { double mse 0.0; for (size_t i 0; i a.size(); i) { const double d (double)a[i] - (double)b[i]; mse d * d; } mse / (double)a.size(); if (mse 1e-12) return 99.0; // 完全相同给个上限值防止表格出现 inf return 10.0 * std::log10(255.0 * 255.0 / mse); }噪声密度取 0.05、种子固定成某个常数跑出来的 PSNR 表才有意义。正常情况下中值滤波对椒盐噪声的 PSNR 会明显高出均值和高斯一大截可能差 610 dB如果三种算法的结果接近大概率是中值窗口取错了、或者加噪之后又做了归一化把噪声抹掉了先查这两个地方。耗时这块建议按半径做一张表半径 1、2、3 分别跑三种算法看增长斜率。均值随半径平方增长高斯因为可分离只随半径线性增长中值介于中间还受排序常数影响。斜率和预期不符说明要么可分离没生效要么内层循环里有隐藏的内存拷贝。最后一个我常用的技巧把高斯核从double整数化。取 sigma 对应的一维核乘以 256 并四舍五入成整数卷积时全程整数乘加最后一次性右移 8 位。精度损失在肉眼层面看不出来速度能快两三倍而且在没有浮点优化的编译配置下差距更大。改动只有两行——k1[ir] (int)(v / sum * 256.0 0.5)收尾时 8但记得把中间缓冲也换成 32 位整型别一边整数核一边浮点缓冲那样只会更慢。从那以后我每次接手一个滤波 demo都先把行序和 stride 打印出来看一眼再动算法那部分——十次翻车有七次栽在这上面而不是算法本身。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑