ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC中显示OpenCV Mat图像的完整实践指南

MFC中显示OpenCV Mat图像的完整实践指南 简介面向MFC与OpenCV开发者的实操示例工程旨在解决VS2013环境下直接显示Mat类型图片的问题无需引入已被淘汰的CvvImage类同时覆盖灰度直方图均衡化和中值滤波等常见图像预处理需求适用于图像处理学习、课堂演示或作为项目基础模块。压缩包共39个文件以源代码和工程配置为主包括5个h头文件、3个cpp源文件、sln与vcxproj工程文件以及调试生成的exe、pdb、obj等中间文件整体约49.73MB目录结构清晰便于按需查看代码与运行结果。目前已有1071人学习/下载压缩包内附可执行exe程序便于对照运行效果。工程基于VS2013与OpenCV2.4.9编写兼容其他相近版本界面能显示打开图片的路径并提供灰度直方图均衡化和中值滤波的完整实现较之网上基于VC6.0或依赖CvvImage的旧方案更贴近现代开发环境适合初学者快速上手也便于开发者直接修改复用例如自行替换图片路径后即可观察算法变化。 最近在做一个桌面端图像处理小工具选了 MFC 作为界面框架因为老项目都是这技术栈跟现有代码好衔接。算法部分用 OpenCV 处理核心数据结构自然就是 Mat。但真正上手时才发现最麻烦的居然不是算法逻辑而是怎么把处理完的 Mat 图像干净利落地显示到 MFC 界面上。网上关于这个需求的中文资料不少但大多数比较零散要么只讲了某个片段要么版本太旧还是用的 IplImage。我这次把环境配置、Mat 与 MFC 显示机制的关系、核心转换代码、常见坑和优化手段完整梳理了一遍整理成这篇记录给做桌面图像工具的朋友们参考。1. 开发环境配置与前提1.1 工具版本与选型我使用的是 Visual Studio 2019 配合 OpenCV 4.5.5MFC 项目类型选择的是“基于对话框”这样界面搭建最快不需要处理复杂的文档/视图结构。OpenCV 版本选择 4.x 系列因为 4.x 对 Mat 的支持更干净C 接口的坑比 3.x 少一些而且官方预编译包更新及时不需要自己用 CMake 折腾源码编译。选择 VS2019 而不是更高版本主要考虑到 OpenCV 官方包里的 DLL 是用 MSVC 编译的版本匹配度很重要。如果 VS 版本和预编译库的编译器版本跨度过大链接阶段经常报一些奇怪的符号错误。当然VS2022 也可以只是我手上的项目仍停留在 2019没必要为了一个展示功能去升级整套工具链。注意一定要确认 OpenCV 的预编译包位数x64 还是 Win32和你的 MFC 项目平台配置保持一致。我见过不少人在 Debug x86 下折腾 OpenCV x64 的包链接错误排半天最后才发现是位数不匹配。1.2 OpenCV 环境变量与项目属性配置OpenCV 安装完解压后我这里解压到D:\opencv需要做两件事。第一件是配置系统环境变量把D:\opencv\build\x64\vc15\bin加进Path否则运行时找不到opencv_world455.dll。第二件是在 VS 里给项目配置C/C 常规 - 附加包含目录D:\opencv\build\include链接器 - 常规 - 附加库目录D:\opencv\build\x64\vc15\lib根据 VS 版本选 vc14/vc15 目录链接器 - 输入 - 附加依赖项Debug 下填opencv_world455d.libRelease 下填opencv_world455.lib这些配置看似基础却非常关键。很多新手往往卡在这里但我的经验是属性表Property Sheet一定要建方便以后复制到其他项目。我还发现如果项目从别的电脑拷贝过来环境变量和属性表是两套体系环境变量在部分机器上可能不生效最好用完整的绝对路径写进属性表避免换机器后踩坑。2. Mat 图像与 MFC 显示机制2.1 Mat 数据结构的底层逻辑OpenCV 里的 Mat 本质上是一个矩阵存储形式是连续或非连续的内存块。你可以把它想象成一个三维数组宽对应列数高对应行数每个像素有多个通道如 BGR 彩色图是 3 通道灰度图是 1 通道。里面有个data指针直接指向像素内存还有step记录每一行占用的实际字节数可能和cols * channels * pixelSize不完全一致因为要对齐。理解这个底层逻辑非常关键因为要把 Mat 显示出来最终绕不开把这块内存数据拷贝给图形系统。使用 Mat 时要注意Mat::clone()才会深拷贝直接使用Mat B A是浅拷贝共享底层数据。我在做代码维护时发现如果显示线程和处理线程共享同一个 Mat务必做好同步避免数据正在被修改时被绘制。2.2 为什么 Mat 不能直接显示在 MFC 控件上MFC 的CStatic控件默认显示的是HBITMAP句柄也就是 Windows 位图资源。而 Mat 只是内存里的像素矩阵没有句柄不是 GDI 对象。所以核心工作就是把 Mat 中的像素数据按 Windows 位图格式DIB设备无关位图组织起来交给 GDI 绘制到控件上。这一步等价于“翻译”将 OpenCV 的 Mat 数据结构翻译成 Windows 的 BITMAPINFO 像素数据再通过StretchDIBits绘制或封装成CBitmap/CImage对象呈现。2.3 BGR 与 RGB 通道顺序的陷阱OpenCV 加载彩色图时默认通道顺序是 BGR蓝、绿、红而 Windows 的 GDI 绘制时同样期望 BGR 顺序这一点很多人不知道所以直接显示有时颜色看起来是正常的。但如果你通过CImage或者转成 QImageQt 场景时通道顺序可能需要反过来否则图像偏红偏蓝。这个坑的经典表现是用imread读一张图直接转成 HBITMAP 后颜色不对皮肤会偏蓝草坪会偏红。解决方式是在转换时检查并交换 R 和 B 通道或者不做 swap 而是调整 BITMAPINFO 的biBitCount和位图掩码强制解释为 BGR。我在做显示模块的时候因为目标控件就是 MFC 下的 GDIBGR 顺序可以直接用省了一步通道交换的操作。3. 核心实现Mat 转 HBITMAP 并显示到控件3.1 最直接的贴图方案使用 StretchDIBits在 MFC 里显示 Mat我个人最常用、也最推荐的是StretchDIBits方案。它直接从内存中的 DIB设备无关位图绘制到目标 DC不需要创建 DDB设备相关位图也不需要中间的CImage速度足够快。这里最关键的是正确填充BITMAPINFO结构体然后用Mat::data作为像素源直接绘制。核心函数实现如下bool ShowMatInCtrl(CWnd* pWnd, const cv::Mat matSrc) { if (pWnd nullptr || matSrc.empty()) return false; CRect rcCtrl; pWnd-GetClientRect(rcCtrl); // 只支持 8 位灰度图和 24 位 BGR 彩色图 CvSize sizeImg { matSrc.cols, matSrc.rows }; int nBpp matSrc.channels() 1 ? 8 : 24; BITMAPINFO bmi { 0 }; bmi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth sizeImg.width; bmi.bmiHeader.biHeight -sizeImg.height; // 负值表示自顶向下 的行序避免图像倒置 bmi.bmiHeader.biPlanes 1; bmi.bmiHeader.biBitCount nBpp; bmi.bmiHeader.biCompression BI_RGB; bmi.bmiHeader.biSizeImage 0; CDC* pDC pWnd-GetDC(); if (!pDC) return false; int nRet StretchDIBits( pDC-GetSafeHdc(), rcCtrl.left, rcCtrl.top, rcCtrl.Width(), rcCtrl.Height(), 0, 0, sizeImg.width, sizeImg.height, matSrc.data, bmi, DIB_RGB_COLORS, SRCCOPY ); pWnd-ReleaseDC(pDC); return nRet ! GDI_ERROR; }几个细节值得留意bmiHeader.biHeight设置为负数表示图像数据自顶向下存储。OpenCV 的 Mat 第 0 行是图像顶部而 Windows 默认的 DIB 是自底向上的如果这里不处理成负值显示出来的图像会上下颠倒。biBitCount要与 Mat 的通道数匹配灰度图是 8BGR 彩图是 24。如果 Mat 是 4 通道BGRA直接转 32 位也可以但原理上需要额外保证通道顺序与 Alpha 通道的要求。StretchDIBits会自动缩放图像到目标控件大小因此拖动窗口改变控件大小时显示的图像也会跟着缩放。这个行为在图像预览场景下很自然不需要额外写缩放逻辑。调用方式的示例代码如下// 在某个按钮响应或定时器里更新预览 cv::Mat matFrame cv::imread(D:\\test.jpg); ShowMatInCtrl(m_staticPreview, matFrame);注意在OnPaint里面直接调用也能用但更推荐把绘制统一封装成函数在OnPaint、OnSize、数据更新时都能复用同一个入口避免各处代码不一致。3.2 没用到 GDI 资源泄漏的方案优化版上面这个版本已经可以工作了但它有一个隐患如果控件区域很小而图像很大每次都直接拉伸绘制在快速连续刷新场景比如实时视频下会存在效率问题。这时候可以在绘制前先把图像缩放到目标尺寸再做一次性拷贝。优化思路是先通过cv::resize将 Mat 缩放到控件像素尺寸再调用转换函数。这样StretchDIBits的源矩形和目标矩形大小几乎一致绘制开销最小而且不会因缩放造成额外模糊闪烁。cv::Mat matScaled; CRect rcCtrl; m_staticPreview.GetClientRect(rcCtrl); if (matSrc.cols ! rcCtrl.Width() || matSrc.rows ! rcCtrl.Height()) { cv::resize(matSrc, matScaled, cv::Size(rcCtrl.Width(), rcCtrl.Height())); } else { matScaled matSrc; } ShowMatInCtrl(m_staticPreview, matScaled);我在实际项目里做视频预览时就是先缩放到控件大小再展示CPU 占用比直接StretchDIBits全图缩放整整降了一个档次。3.3 CImage 方案与 GDI 的备选路线除了StretchDIBits还有一个常见做法是通过CImage来包装 Mat 数据。CImage内部可以用Create创建 DIB 区段然后直接把数据拷贝进去最后Draw到控件 DC 上。bool ShowMatUsingCImage(CWnd* pWnd, const cv::Mat matSrc) { if (pWnd nullptr || matSrc.empty()) return false; CRect rcCtrl; pWnd-GetClientRect(rcCtrl); CImage img; int nBpp matSrc.channels() 1 ? 8 : 24; img.Create(matSrc.cols, matSrc.rows, nBpp); // 把 Mat 数据拷贝到 CImage 的像素缓冲区 uchar* pDst (uchar*)img.GetBits(); int nDstStep img.GetPitch(); for (int y 0; y matSrc.rows; y) { memcpy(pDst y * nDstStep, matSrc.data y * matSrc.step, matSrc.cols * matSrc.channels()); } CDC* pDC pWnd-GetDC(); img.Draw(pDC-GetSafeHdc(), rcCtrl); pWnd-ReleaseDC(pDC); return true; }CImage 方案的优点是可读性更好代码量不多适合快速实现。缺点是需要多一步像素拷贝而且在快速刷新时略慢于StretchDIBits直接绘制。它的包装逻辑也更接近“生成一张位图然后贴上去”的思维调试起来很直观。如果使用 GDI也可以把 Mat 转换成 Bitmap核心是通过 Bitmap 的构造函数或 LockBits 方式写入像素。这套方式适合需要叠加文字、图形等 GDI 特效的场景不过整体上比前两种方案重一些在纯显示场景里有些小题大做。4. 常见问题与调试经验实录4.1 图像倒置、颜色异常、闪烁问题的排查我在开发过程中踩了四类坑做成一个速查表问题现象可能原因解决办法图像上下颠倒BITMAPINFO 的 biHeight 为正数设置为负数表示自顶向下颜色偏蓝/偏红Mat 是 BGR但目标解释成了 RGB确认 BITMAPINFO 的 biBitCount 和通道顺序必要时用 cvtColor 转换控件不刷新切换图像后残留旧图未触发 Invalidate 或者绘制代码不在 OnPaint绘制后调用Invalidate(FALSE)或UpdateWindow()频繁刷新闪烁明显直接在控件上反复绘制触发背景擦除使用内存 DC 先绘制到临时位图再一次贴到控件上或重写OnEraseBkgnd返回TRUE只显示黑白/灰蒙蒙灰度图被当成 24 位显示或 24 位被当成 8 位检查 matSrc.channels()确保 biBitCount 与通道数匹配GDI 对象泄漏、程序越来越慢每次创建 HBITMAP 或 CImage 后用后未释放CImage 在栈上析构会自动释放但CBitmap要注意DeleteObject尽量用 RAII这些坑里最“经典”的是图像倒置。我第一次对接时图像总上下颠倒查了半天以为是坐标映射问题后来才发现是 BITMAPINFO 的 biHeight 正负号搞错了。所以做图像显示第一件事就是确认行序方向。另一个容易忽略的是“残留旧图”。单纯在按钮函数里调用ShowMatInCtrl后界面不一定立刻刷新。MFC 的消息循环在繁忙时可能延迟处理WM_PAINT所以我一般在绘制函数末尾加上一句pWnd-Invalidate(FALSE);强制通知系统刷新保证切换图片时界面立刻响应。4.2 通过内存 DC 减少闪烁的实践经验刷新频率超过 10 帧/秒时直接往控件 DC 上StretchDIBits会闪得厉害。解决办法是先用内存 DC 绘制到一张兼容位图上再把整张位图一次性贴到控件。这一步在静态图片显示时没太大意义但如果你做的是视频预览或相机实时流就非常关键。这里给出一个简化示例void DrawMatToWindow(CDC* pCtrlDC, CRect rcCtrl, const cv::Mat matSrc) { CDC memDC; memDC.CreateCompatibleDC(pCtrlDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pCtrlDC, rcCtrl.Width(), rcCtrl.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 在 memDC 上执行 StretchDIBits 绘制 // ... 同 ShowMatInCtrl 内部逻辑只是把目标 DC 换成 memDC ... pCtrlDC-BitBlt(rcCtrl.left, rcCtrl.top, rcCtrl.Width(), rcCtrl.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); bmp.DeleteObject(); }双缓冲的本质是把所有绘制工作放到离屏内存里完成最终一次性提交到屏幕视觉上就消除了闪烁。代价是额外一次内存拷贝和位图创建但对于 Windows GDI 来说这点开销远远小于刷新时的撕裂和闪烁带来的负面体验。4.3 调试技巧用图像调试断言检查 Mat 状态显示问题定位困难时我习惯在显示前加一段调试代码把关键的 Mat 属性打印出来TRACE(_T(Mat cols%d, rows%d, channels%d, type%d, step%d\n), matSrc.cols, matSrc.rows, matSrc.channels(), matSrc.type(), (int)matSrc.step);这样一旦显示异常先确认 Mat 是否符合预期比如是否为空、通道数是不是 3、step 是否大于 cols*3 等。如果 channels 为 1而代码里按 24 位显示那出来的画面必然是灰蒙蒙的如果 type 是CV_8UC4那就要考虑 alpha 通道的问题。这些信息通过TRACE快速拿到省去反复打断点的时间。5. 扩展思考与应用场景5.1 显示动态视频流时的注意事项实时视频流显示时每帧都是新的 Mat而且尺寸固定。这时候就不需要每次重新imread而是直接从采集回调或解码队列中取帧处理完调用ShowMatInCtrl。注意线程同步OpenCV 的VideoCapture::read在循环里读取帧如果你在 UI 线程里直接显示会导致界面卡顿。稳妥做法是后台线程读帧塞队列UI 定时器或消息触发取队首帧刷新显示。我实际做过一个项目后台线程 25fps 读视频UI 线程 30fps 刷新中间用一个带互斥锁的循环队列做缓冲。因为显示模块只处理队列中的最新帧所以即便后台帧率波动大界面显示依然流畅不会掉帧。5.2 叠加文字和检测框的显示技巧如果你只需要在 Mat 上画一些标记人脸框、目标位置完全不需要直接绘制到控件上。先在 Mat 上用cv::rectangle、cv::putText等算法画好最终结果再一次性显示出来这样效率和代码可维护性都更好。这个做法的核心优势是所有绘制逻辑都在 OpenCV 这边不会因为 GDI 坐标系转换而出错。我之前试过直接在控件 DC 上画框结果当控件大小变化时框的位置和尺寸都要跟着算维护起来头疼。后来全部改到 Mat 上画显示只管贴图问题迎刃而解。5.3 把显示模块封装成可复用类我从这个项目里提炼出的经验是把 Mat 显示功能封装成一个独立的小类比零散地维护函数更利于复用。比如CMatImageCtrl内部封装SetImage(const cv::Mat)和OnPaint的联动控件自绘时自动调用绘制函数外部只需要调一句ctrl.SetImage(mat)。类的封装还解决了一个隐藏问题MFC 控件自绘时需要处理WM_PAINT、WM_SIZE等多处逻辑如果全部塞在对话框代码里对话框会越来越臃肿。子类化之后对话框代码只需要维护业务逻辑显示细节都由控件类自己管理。这一点对长期维护的项目价值尤其明显。6. 写在最后的操作心得回头来看MFC 显示 OpenCV Mat 图像这件事技术难度并不高真正的难点在于对两套图像体系的底层理解。一个是 OpenCV 的矩阵思维一个是 Windows GDI 的位图思维二者之间看似只是一个转换函数的事但细节差距很大行序方向、通道顺序、内存对齐、GDI 对象生命周期每个小点处理不到位都会在运行时以各种诡异现象暴露出来。我最开始做这个功能时只关注“如何把 Mat 转成 HBITMAP”却忽略了控件的刷新时机和双缓冲处理。后来在视频预览场景中被闪现折磨之后才真正理解“显示”不止是贴图还涉及帧同步、绘制效率和资源管理。这些坑踩过以后再回头看感觉就像把一条满是暗礁的航线探了一遍现在每次写 Mat 显示相关的代码心里都特别有底。如果你在做类似的需求我建议按这个次序来先配置好环境打通最基础的imreadStretchDIBits流程然后加颜色和倒置的处理最后再考虑性能优化和刷新策略。一口气把全部方案都做出来遇到问题时反而难排查。最后分享一个小技巧我在调试图像显示时会在项目里准备一张固定测试图比如一张深浅色渐变的图专门用来检查颜色和倒置问题。原因很简单普通照片颜色一旦偏了肉眼不容易判断是通道问题还是显示亮度问题但渐变图只要有一点点通道或行序错误一眼就能察觉。这张测试图我到现在还在用每次换环境或换电脑都会用它快速验证 OpenCV 和 MFC 的对接是否正常。本文还有配套的精品资源点击获取
返回列表