
简介针对MFC与OpenCV结合显示图片的常见需求这份资源提供一套在VS2013与OpenCV 2.4.9环境下调试通过的MFC示例程序采用Mat类型完成图像加载与显示避免引入已被淘汰的CvvImage类。相比网上老式的VC6.0工程或依赖辅助类的方案其接口方式更贴近现代OpenCV习惯适合维护旧版工程、课程设计或刚接触MFC图像界面的开发者学习参考。程序界面会显示当前打开图片的路径并附带灰度直方图均衡化与中值滤波的实现代码前者增强对比度后者去除椒盐噪声便于理解算法与MFC界面的调用关系也方便抽取复用。资源包共39个文件以C源码.cpp/.h/.rc、Visual Studio工程配置.vcxproj/.sln为主另含obj/pdb/tlog等编译调试文件压缩包约49.73MBDebug目录下带可运行exe便于快速验证和二次开发。目前已有1071人学习过该资源无论对于需要完成图像处理演示还是搭建MFCOpenCV项目框架都是可参考的实现模板。 做MFC界面又需要实时显示摄像头帧的老哥大概率都经历过这样一个瞬间OpenCV那边辛苦算出来的Mat图像到了MFC窗口这边不知道怎么给它塞进Picture Control里。直接画不认。转成CImage又老出花屏、颜色不对、内存还蹭蹭涨。我最早碰到这个问题是在VS2013上加一个OpenCV图像预览窗口断断续续折腾了一个下午后来把Mat转CImage的链路理清楚之后才发现这里面的大部分坑其实都集中在三个地方色彩通道顺序、内存布局对齐、还有DC的获取与释放。这篇就把这套流程从配置到代码一次性说透重点覆盖图片类型为Mat的显示场景保证你照着写完就能跑。这类问题不只是新手遇到很多做工业视觉上位机的同学也会被卡住。MFC负责窗口和交互OpenCV负责图像处理和分析两者本身是不同体系的东西中间必须有一层“翻译官”把OpenCV的Mat数据翻译成MFC能理解的GDI位图格式然后再交给Picture Control去显示。搞清楚这条数据搬运链路你以后不管想显示普通图片、摄像头实时帧还是findContours之后画出的轮廓图原理都是一模一样的。1. 为什么这题值得单独写一篇MFC里显示Mat的常见痛点1.1 MFC与OpenCV的典型协作模式MFC是微软提供的一套C界面框架它的显示能力基于GDI和GDI原生认识的是HBITMAP、CImage这类位图资源。OpenCV是独立的图像算法库它内部所有图像都保存在Mat结构体里Mat的本质是一块内存记录着宽、高、通道数、数据类型和像素指针。这两个东西要协同工作你必须在Mat和GDI位图之间做一次数据转换。很多初学者会试图直接把Mat的数据指针塞给某个控件或者干脆重载OnPaint去自己画结果不是编译报错就是显示出一堆乱码。正确思路是先把Mat转换成CImage再把CImage绘制到控件的DC上。这里面还涉及一个细节OpenCV默认的颜色通道排列是BGR而GDI在24位位图里要求的排列是RGB。如果直接拷贝像素不处理通道顺序你会发现图片里原本是红色的物体显示成了蓝色经典的“红蓝互换”问题。这个坑在网上一搜一大片但真正理解为什么的人不多实际上就是通道顺序没调整。1.2 各位搜索的热词已经暴露了问题集中区从大家高频搜索的词条看这个领域的问题非常集中环境配置类的有“opencv安装教程vs2013”、“opencv安装教程”代码实现类的有“c opencv findcontours”、“opencv棋盘格标定c代码”界面类的有“mfc列表框控件的使用”、“mfc控件自适应屏幕分辨率”。把这些搜索词放在一起看基本可以画出一张完整的需求画像一个MFC老项目接上了OpenCV做图像处理需要把Mat结果显示在界面上同时还要处理摄像头实时帧、轮廓绘制、标定可视化等衍生需求。也就是说Mat显示是地基地基不稳上层一切皆塌。2. 环境准备VS2013下把OpenCV装进MFC项目2.1 官方库的版本选择与目录结构很多搜索词里带有“vs2013”和“opencv安装教程”说明这还是个现实需求。VS2013对应的编译器是VC12而OpenCV从3.x开始官方预编译库主要提供vc14和vc15版本的文件夹如果你非要装OpenCV 3.4.x在VS2013里链接的时候会报一堆无法解析的外部符号。我的经验是两种方案二选一要么用OpenCV 2.4.x系列它发布时官方正好提供了vc12目录和VS2013是天作之合要么用OpenCV 3.4版本源码自己用CMake编译一遍能拿到vc12能用的库代价是编译时间较长。如果你只是做显示和基础图像处理OpenCV 2.4.13.6其实绰绰有余坑也少。下载并解压OpenCV后你会得到一个包含sources和build两个顶层目录的文件夹。build目录里面又有x86和x64两个子目录分别对应32位和64位编译模式。注意MFC项目如果用了x86那库链接路径就必须指向x86目录这个细节经常有人搞混。提示VS2013搭配OpenCV 2.4.x时Debug模式要链接opencv_world2413d.libRelease模式要链接opencv_world2413.lib少了那个d后缀就会在运行时出现莫名其妙的崩溃。2.2 项目配置三步走与属性表复用配置OpenCV其实就三步不复杂但每次新建项目都要重新走一遍所以我建议你把配置保存成属性表一劳永逸。第一步配置包含目录。打开项目属性找到VC目录在“包含目录”一栏添加OpenCV的include路径通常是D:\opencv\build\include。第二步配置库目录。在“库目录”一栏添加D:\opencv\build\x86\vc12\lib如果你用的是x64那就换成对应的x64路径。第三步配置附加依赖项。切到链接器→输入→附加依赖项写上对应的lib文件名。具体来说在VC目录里设置是为了让编译器找到头文件和库文件在链接器输入里写上lib是为了让链接器知道要链接哪个库。三者缺一不可。配置完之后一定要检查一下当前活动解决方案平台是不是和目标一致我自己就曾因为x86和x64没对应卡了将近一个小时。为了避免每次新建项目都重复配置我强烈建议你配置好一个能正常显示Mat图片的项目后导出属性表下次新建项目直接“属性管理器→添加现有属性表”就没有这么繁琐了。这个技巧适用于所有VS版本不局限于VS2013。2.3 运行时DLL缺失这些坑编译链接通过不等于程序能跑起来。OpenCV的库是动态链接的你还需要保证程序运行时能找到对应的DLL文件。最省事的办法是把DLL路径添加到系统PATH环境变量或者直接把opencv_world2413.dll复制到exe所在的目录。有时候界面一运行就弹“找不到opencv_world2413.dll”但是你把DLL放在exe目录下问题就消失了。我不建议为了省事把DLL丢到C:\Windows\System32那样会造成多个版本之间的污染当你同时有多个OpenCV项目时就等着头疼吧。3. 从Mat到屏幕一张图片显示的完整链路3.1 为什么不能直接把Mat交给MFCMat在内存中的像素排列是连续或不连续的它的每一行字节数由step字段决定不一定等于cols乘通道数。因为OpenCV做ROI时Mat只是指向原图的一块子区域起始地址和步长都可能和完整图像不一样。CImage内部则是一个DIB位图它在内存里有自己的排列规则也会因为对齐问题导致每行字节数和简单宽乘高不一样。如果你直接拿memcpy把Mat的data指针拷贝到CImage的Bits指针遇到不连续内存或者步长不一致的情况图像就会变得歪歪斜斜每一行错位。正确做法是逐行拷贝以每行的实际字节数为单位复制数据。这样不管Mat是连续的还是不连续的CImage都能得到一份完整且排列正确的像素数据。这也是整个转换函数里最关键的部分。3.2 Mat转CImage的正确姿势手写转换函数既然CvvImage在新版OpenCV中早已被移除我们干脆自己写一个转换函数不依赖任何旧版接口逻辑也完全可控。核心步骤如下先判断Mat的通道数和深度统一转到8位彩色或8位灰度如果是3通道把BGR顺序转成RGB创建相同大小的CImage逐行拷贝像素数据。#include opencv2/opencv.hpp #include atlimage.h #pragma once // Mat转CImage支持8位灰度、8位彩色BGR CImage MatToCImage(const cv::Mat src) { CV_Assert(!src.empty()); cv::Mat img; if (src.type() CV_8UC3) { // OpenCV默认BGRCImage的24位色是RGB这里必须转换 cv::cvtColor(src, img, cv::COLOR_BGR2RGB); } else if (src.type() CV_8UC1) { img src.clone(); // 灰度图直接拷一份 } else { // 其他类型如16位深度、4通道等这里直接报错提示 CV_Error(cv::Error::StsUnsupportedFormat, 仅支持CV_8UC1和CV_8UC3); } CImage cimg; cimg.Create(img.cols, img.rows, 24); // 创建24位DIB int pitch cimg.GetPitch(); // CImage每行字节数注意可能为负数 BYTE* base (BYTE*)cimg.GetBits(); // CImage存储如果是从下往上GetPitch返回负数做一次修正 if (pitch 0) { base base pitch * (cimg.GetHeight() - 1); pitch -pitch; } // 连续且行对齐一致时一次性拷贝性能最好 if (img.isContinuous() pitch img.cols * img.elemSize()) { memcpy(base, img.data, img.total() * img.elemSize()); } else { // 不连续或对齐不一致时逐行拷贝防止图像错位 for (int y 0; y img.rows; y) { memcpy(base y * pitch, img.ptruchar(y), img.cols * img.elemSize()); } } return cimg; }这里面有几个细节我要特别解释。第一GetPitch返回的是每一行像素在内存里占据的字节数因为DIB有4字节对齐的要求所以它可能比“宽乘3”大一些。第二为什么逐行拷贝比整体拷贝更可靠因为Mat做ROI的时候每一行的起点可能不连续只有逐行处理才能保证每一行都精确对齐。第三颜色通道转换一定放在创建CImage之前否则后面拷进去的还是BGR显示出来红蓝互换。3.3 把CImage显示到Picture Control上拿到了CImage之后显示环节就简单了。你需要先通过GetDlgItem获取Picture Control的窗口指针然后GetDC拿到它的设备上下文再用StretchBlt把CImage绘制上去。这里有一个容易忽略的地方图片尺寸和控件尺寸往往不一致如果不做缩放图片过大时只能看到左上角一小块。void ShowMatInPicture(const cv::Mat image, CWnd* pWnd) { if (image.empty() || !pWnd) return; CImage cimg MatToCImage(image); CRect rc; pWnd-GetClientRect(rc); CDC* pDC pWnd-GetDC(); if (pDC) { // 用HALFTONE模式拉伸缩放后边缘过渡更平滑 int oldMode pDC-SetStretchBltMode(HALFTONE); cimg.StretchBlt(pDC-m_hDC, rc, SRCCOPY); pDC-SetStretchBltMode(oldMode); pWnd-ReleaseDC(pDC); } cimg.Destroy(); // CImage是GDI资源用完后需要释放 }为什么这里一定要用StretchBlt而不用BitBltBitBlt只是单纯复制像素不做任何缩放如果图片尺寸大于控件显示出来的就永远是左上角那一块很容易让人误以为程序只读到了部分图像。StretchBlt则可以根据目标矩形自动缩放指定HALFTONE模式之后缩小时锯齿也没那么明显。这里还有一个极其重要的资源释放细节GetDC拿到的DC一定要用ReleaseDC释放CImage用完之后一定要调用Destroy。很多程序运行几分钟内内存暴涨就是因为这两个资源没有释放。GDI对象不释放任务管理器里句柄数会不断上涨最终界面直接卡死。4. 动态刷新摄像头实时帧显示的两种做法4.1 定时器方案简单稳定不折腾如果你只是周期性地更新画面比如加载一张视频帧或者隔一段时间刷新一次显示MFC定时器是最简单的方式。在对话框初始化里SetTimer(1, 33, NULL)33毫秒约等于30帧每秒每帧获取新的Mat再调用显示函数一切搞定。void CMyDialog::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { cv::Mat frame; // 这里从VideoCapture或者图像处理流程中获取frame // 示例cap frame; if (!frame.empty()) { ShowMatInPicture(frame, GetDlgItem(IDC_PIC)); } } CDialogEx::OnTimer(nIDEvent); }定时器方案的优点是代码量少、不用考虑线程同步适合显示任务较轻的场景。缺点是你如果在一个循环里同时做图像算法处理图像滤波、轮廓查找这类操作耗时较长主界面会变得卡顿操作按钮都没反应。所以定时器方案适合处理逻辑简单、耗时短的图像任务。4.2 工作线程方案不阻塞UI的关键当图像处理耗时长或者帧率要求高时我建议把获取图像和处理图像放到工作线程里主线程只负责把处理结果绘制到界面。工作线程里做完Mat处理后用PostMessage给主窗口发送一个自定义消息把Mat的指针传过去主窗口在消息处理函数里完成显示。// 工作线程 UINT ThreadProc(LPVOID pParam) { CMyDialog* pDlg (CMyDialog*)pParam; cv::VideoCapture cap(0); cv::Mat frame; while (cap.read(frame)) { cv::Mat result SomeProcess(frame); // 发送消息result指针作为参数传递 ::PostMessage(pDlg-GetSafeHwnd(), WM_SHOW_MAT, (WPARAM)new cv::Mat(result), 0); cv::waitKey(1); } return 0; }这里有个注意点不能在工作线程里直接去操作MFC控件MFC的控件方法基本上都要在主线程调用否则会崩。所以标准的做法就是工作线程把Mat准备好然后通过消息通知主线程来画图。消息里传指针时记得用new创建一份堆上的Mat主线程处理完毕后delete释放防止访问已经被释放的内存。4.3 刷新闪屏和DC泄漏排查思路用定时器刷新图像时画面闪烁是一个很常见的现象。闪烁的本质是绘制频率低于屏幕刷新频率或者绘制过程中先擦了背景再绘制中间露出了空白。减轻闪烁的思路一般是取消背景擦除在对话框里处理WM_ERASEBKGND消息并返回TRUE让系统不擦背景。DC泄漏则多表现为程序运行一段时间后画面突然不刷新了或者整个对话框按钮都变成白板。你可以打开任务管理器查看进程的“句柄数”和“GDI对象”是否持续增长。如果是逐个检查自己写的显示函数确保每一次GetDC都对应一次ReleaseDC每一个CImage都对应一次Destroy。5. 高频问题排查与实操心得5.1 问题速查表从花屏到内存泄漏下面这张表我整理了自己在实际开发中遇到最多的问题和对应的排查方向如果你在显示Mat时遇到异常可以对照着快速定位现象可能原因解决办法图像花屏、有斜向条纹Mat和CImage内存布局不一致直接用了memcpy整体拷贝改成逐行拷贝按各自Pitch对齐红色和蓝色颠倒没有做BGR到RGB的转换调用cvtColor(src, img, COLOR_BGR2RGB)只显示图片左上角控件比图片小没有做缩放处理使用StretchBlt替代BitBlt图像上下颠倒CImage的GetPitch为负时没有修正起始地址检测Pitch负值定位到图像最后一行再逐行拷贝Debug模式一闪而过lib文件选错用了Release版或没有加d后缀链接opencv_world2413d.lib编译链接报一堆LNK2019OpenCV版本与VS编译器不匹配VS2013用OpenCV 2.4.x或自己编译3.x运行时找不到DLLDLL没有在exe目录或PATH中把OpenCV的bin目录加入PATH或复制DLL到exe目录内存/句柄持续上涨每次刷新创建的CImage和DC没有释放确保Destroy()和ReleaseDC()一一对应这几类问题基本覆盖了90%的“MFCOpenCV显示Mat”报错场景。尤其是花屏和颜色颠倒这两个都是初学者最高频踩的坑学会了自己排查比到处复制代码高效得多。5.2 我自己的几条实操习惯在做Mat显示这件事上我慢慢养成了几个习惯分享出来供你参考。第一任何Mat要显示前先打印一下它的尺寸、类型和是否连续确认数据源没问题再接显示层。很多时候问题根本不在显示而是之前某一步把Mat内容搞坏了。第二把MatToCImage和显示函数分开封装不要都堆在按钮事件里。因为图像处理后续往往还有画轮廓、画标定棋盘格这类需求到时候你只需要在那张Mat上画完再调用同一个显示函数就行。封装好了代码可以一直复用。第三多考虑一下显示控件的自适应缩放。如果你的界面要适配不同分辨率的屏幕 Picture Control的大小会动态变化StretchBlt每次都会根据当前GetClientRect重新计算目标尺寸天然自适应这比你手动去算像素级坐标要可靠得多。最后一件事如果你在调试时遇到比较诡异的显示问题我的建议是不要太早怀疑OpenCV的库出了问题。大多数时候先检查颜色通道再检查内存拷贝方式最后检查DC和CImage资源释放这三板斧能解决绝大部分问题。另外如果你处理完轮廓检测之后想在原图上叠加显示结果直接在Mat上绘制完再走一遍转换显示就可以了原理完全一样。老老实实把这条数据链路理清楚MFC里显示Mat这关就算彻底过了。本文还有配套的精品资源点击获取