简介这份资源面向使用 MFC 与 OpenCV 进行图像处理的开发者尤其是仍在使用 VS2013 搭配 OpenCV2.4.9 环境、希望快速实现图片显示与基础处理功能的人群。它解决了网上多数示例依赖 VC6.0 或已被淘汰的 CvvImage 类、移植困难的问题无需额外添加 CvvImage 即可直接编译运行。压缩包共 39 个文件约 49.73MB包含 5 个 h 头文件、3 个 cpp 源文件以及 sln 解决方案、vcxproj 工程文件、rc 资源脚本、ico 图标和 exe 可执行文件等覆盖从工程配置到界面资源的完整结构。界面可显示所打开图片的路径并内置灰度直方图均衡化与中值滤波代码便于对照学习图像增强与去噪的实现思路。目前已有 1072 人学习下载适合作为 MFC 图像处理入门与课程设计的参考工程。1. 从一次“图片死活不显示”说起MFC 里塞 OpenCV Mat 到底难在哪如果你写过 MFC 对话框程序又恰好想用 OpenCV 读张图显示在 Picture Control 上大概率经历过这个场景imread返回的 Mat 明明empty()是 falseimshow单独跑也能弹窗可一旦塞进 MFC 的控件里要么一片灰要么花屏要么程序直接崩在OnPaint里。这不是玄学是两套图像内存模型在打架。MFC 的CStatic、CDC走的是 Windows GDI 那套 HBITMAP、DC、位图句柄OpenCV 的cv::Mat走的是自己的引用计数、步长step、通道连续内存。把 Mat 显示到 MFC 控件本质就是在这两套体系之间做一次像素搬运和格式对齐。这篇笔记拆的就是这个搬运过程从 Mat 的内存布局讲到CImage、StretchDIBits两条落地路线再到参数怎么设、坑在哪。适合手里有 MFC 老项目、又不想把 OpenCV 换成别的图像库的从业者新手能照着把第一张图显示出来熟手能看清 BGR 通道序、step 对齐、资源释放这些边界。2. 先搞懂 Mat 的内存布局为什么不能直接把 data 指针丢给 GDI2.1 Mat 不只是一块内存它带步长和引用计数很多人以为cv::Mat就是一个unsigned char*加宽高这是第一个翻车点。Mat 的成员里data指向首行首像素step是每一行占用的字节数cols、rows是逻辑宽高elemSize()是单像素字节数channels()是通道数。关键在于step不一定等于cols * elemSize()。OpenCV 在分配内存时出于 SIMD 对齐考虑会给每行末尾补 padding所以step cols * elemSize()。如果你按cols * channels去逐行读遇到有 padding 的 Mat 就会整张图斜掉这就是典型的“花屏”来源之一。另一个点是引用计数。Mat 拷贝构造是浅拷贝两个 Mat 共享同一块 data析构时引用计数减到零才真正释放。如果你把一个局部 Mat 的data指针存到全局函数返回后 Mat 析构指针就成了野指针后面在OnPaint里访问就是随机崩溃。血泪经验是跨函数传递图像数据要么深拷贝clone()要么把 Mat 作为成员变量持有别只存裸指针。2.2 GDI 要的是 BGR 还是 RGB位图头怎么填Windows 的 DIB设备无关位图在 24 位和 32 位格式下内存里的通道顺序是 BGR不是 RGB。这一点和 OpenCV 的imread默认输出恰好一致——OpenCV 读进来就是 BGR。所以如果你只是把 Mat 原样搬给 GDI颜色通常是对的。但如果你中间用cvtColor转成了 RGB再直接显示红蓝就会互换人脸变蓝脸。这个坑很隐蔽因为图像“能显示”只是颜色不对新手容易以为是显示器或驱动问题。DIB 还需要一个BITMAPINFOHEADER里面biWidth、biHeight、biBitCount、biSizeImage都要填对。biHeight为正表示自下而上存储为负表示自上而下。OpenCV 的 Mat 是自上而下的所以填biHeight -rows可以省掉一次翻转。biBitCount要和 Mat 的通道数匹配三通道填 24四通道填 32单通道灰度填 8 并配合调色板。biSizeImage一般填step * rows填 0 在某些驱动上也能工作但显式填更稳。2.3 两条落地路线CImage 中转 vs 直接 StretchDIBits常见做法有两类。第一类是用 ATL/MFC 自带的CImage做中转把 Mat 的数据逐行拷进CImage的缓冲区再用CImage::Draw或BitBlt画到控件 DC。CImage内部封装了 DIB 创建和格式转换代码短适合快速出效果代价是多一次内存拷贝大图或高频刷新时性能一般。第二类是用StretchDIBits或SetDIBitsToDevice直接把 Mat 的 data 当 DIB 像素喂给 DC省掉中转拷贝性能更好但需要自己填BITMAPINFO参数错一个就花屏或崩溃。我一般对静态显示用CImage对视频帧或需要缩放的场景用StretchDIBits。提示无论走哪条路线都要确认 Mat 是连续的。用isContinuous()判断不连续就先clone()否则按step逐行处理别假设内存是一整块。3. 用 CImage 把 Mat 显示到 Picture Control完整可抄代码3.1 控件准备与变量绑定先在对话框资源里放一个 Picture Control类型选 Frame 或 Rectangle 都行ID 比如IDC_STATIC_IMG。右键添加变量类别选 Control变量类型CStatic名字m_pic。这一步决定了后面往哪个 DC 上画。如果你用的是Picture Control的默认 IDIDC_STATIC多个静态控件会冲突建议改 ID。绑定完成后在OnInitDialog里可以先把控件尺寸调一下或者留着后面按图像比例缩放。3.2 Mat 转 CImage 的核心函数下面这个函数把cv::Mat拷进CImage支持 8 位三通道和四通道。注意CImage::Create的第四个参数是位图信息指针传 NULL 表示用默认的BITMAPINFOHEADER此时CImage按 32 位对齐分配GetPitch()返回实际行字节数可能大于width * 4所以必须按 pitch 逐行拷不能一次memcpy整块。// 将 cv::Mat 转换为 CImage支持 CV_8UC3 和 CV_8UC4 bool MatToCImage(const cv::Mat mat, CImage img) { if (mat.empty()) return false; if (mat.depth() ! CV_8U) return false; // 只处理 8 位 if (mat.channels() ! 3 mat.channels() ! 4) return false; // 确保内存连续不连续则深拷贝一份 cv::Mat src mat.isContinuous() ? mat : mat.clone(); int channels src.channels(); int width src.cols; int height src.rows; // 创建 CImage32 位色深负高度表示自上而下 if (!img.Create(width, -height, 32)) return false; // 逐行拷贝按 pitch 对齐 BYTE* pDst (BYTE*)img.GetBits(); int pitch img.GetPitch(); // 可能大于 width * 4 for (int y 0; y height; y) { const BYTE* pSrcRow src.ptrBYTE(y); BYTE* pDstRow pDst y * pitch; if (channels 3) { // BGR - BGRA补 alpha 为 255 for (int x 0; x width; x) { pDstRow[x * 4 0] pSrcRow[x * 3 0]; pDstRow[x * 4 1] pSrcRow[x * 3 1]; pDstRow[x * 4 2] pSrcRow[x * 3 2]; pDstRow[x * 4 3] 255; } } else // channels 4BGRA 直接拷 { memcpy(pDstRow, pSrcRow, width * 4); } } return true; }逻辑说明img.Create(width, -height, 32)里负高度让 DIB 自上而下和 Mat 行序一致省掉翻转。GetPitch()是 CImage 实际每行字节数32 位下通常是width * 4向上对齐到 4 字节边界但不要假设等于width * 4。三通道时逐像素补 alpha四通道直接整行memcpy。参数上如果你只显示不缩放Create的宽高就用 Mat 的宽高如果要固定控件大小可以在Create时用控件尺寸但那样会拉伸建议后面用Draw的缩放参数。3.3 在 OnPaint 里画到控件 DC有了 CImage显示就简单了。在对话框的OnPaint里拿到 Picture Control 的 DC用CImage::Draw画上去。注意Draw会自动处理缩放目标矩形用控件客户区大小。void CMyDlg::OnPaint() { CPaintDC dc(this); if (!m_img.IsNull()) // m_img 是成员 CImage { CRect rect; m_pic.GetClientRect(rect); // 控件客户区 m_img.Draw(dc.GetSafeHdc(), rect); // 自动缩放绘制 } CDialogEx::OnPaint(); }逻辑说明m_img必须是对话框类的成员变量不能是局部变量否则OnPaint时已经析构。m_pic.GetClientRect拿的是控件相对坐标Draw的目标 DC 是对话框的CPaintDC坐标系一致所以直接传 rect 即可。如果控件有边框GetClientRect已经排除了边框画出来不会盖住边框。参数上Draw的第二个参数是目标矩形传CRect(0,0,w,h)也可以但用控件客户区更省事。3.4 触发重绘与资源释放加载图像后要调用Invalidate()或InvalidateRect触发重绘否则OnPaint不会自动跑。CImage是成员变量对话框析构时自动释放但如果你在函数里反复Create每次Create前最好先Destroy()否则可能内存泄漏。Mat 这边如果只是临时读图函数结束自动释放如果要保留存成成员 Mat。void CMyDlg::OnBnClickedBtnLoad() { CFileDialog dlg(TRUE, _T(png), NULL, OFN_FILEMUSTEXIST, _T(Image Files|*.png;*.jpg;*.bmp||), this); if (dlg.DoModal() ! IDOK) return; CStringA path(dlg.GetPathName()); // 转窄字符给 imread cv::Mat mat cv::imread(path.GetString(), cv::IMREAD_COLOR); if (mat.empty()) { AfxMessageBox(_T(图片读取失败)); return; } m_img.Destroy(); // 释放旧图 if (!MatToCImage(mat, m_img)) { AfxMessageBox(_T(转换失败)); return; } Invalidate(); // 触发重绘 }逻辑说明CFileDialog拿到的路径是宽字符imread在 Windows 上对宽字符路径支持不稳定常见做法是转成CStringA再传。IMREAD_COLOR强制三通道避免灰度图进来通道数不对。m_img.Destroy()在Create前调用防止重复加载时旧资源没释放。Invalidate()标记整个客户区无效下一轮消息循环会走OnPaint。4. 直接 StretchDIBits省掉中转拷贝的写法与参数表4.1 填 BITMAPINFO 的四个关键字段StretchDIBits直接把像素喂给 DC不需要 CImage。核心是填BITMAPINFO其中bmiHeader的字段必须和 Mat 对齐。下面这张表是我调参时反复核对的几个字段错一个就花屏或全黑。字段取值说明biSizesizeof(BITMAPINFOHEADER)固定 40biWidthmat.cols逻辑宽度不是 stepbiHeight-mat.rows负值表示自上而下和 Mat 一致biPlanes1固定biBitCountchannels * 8三通道 24四通道 32灰度 8biCompressionBI_RGB不压缩biSizeImagestep * rows可填 0显式填更稳4.2 StretchDIBits 调用与缩放void CMyDlg::DrawMatDirect(CDC* pDC, const cv::Mat mat, const CRect rcTarget) { if (mat.empty() || mat.depth() ! CV_8U) return; cv::Mat src mat.isContinuous() ? mat : mat.clone(); BITMAPINFO bmi { 0 }; bmi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth src.cols; bmi.bmiHeader.biHeight -src.rows; // 自上而下 bmi.bmiHeader.biPlanes 1; bmi.bmiHeader.biBitCount src.channels() * 8; bmi.bmiHeader.biCompression BI_RGB; bmi.bmiHeader.biSizeImage (DWORD)(src.step * src.rows); int oldMode pDC-SetStretchBltMode(HALFTONE); // 缩放时用 HALFTONE 更平滑 pDC-StretchDIBits( rcTarget.left, rcTarget.top, rcTarget.Width(), rcTarget.Height(), 0, 0, src.cols, src.rows, src.data, bmi, DIB_RGB_COLORS, SRCCOPY); pDC-SetStretchBltMode(oldMode); }逻辑说明SetStretchBltMode(HALFTONE)在缩放时启用插值否则缩小会丢像素、放大有锯齿。StretchDIBits的前四个参数是目标矩形中间四个是源矩形src.data是像素首地址bmi是位图信息。参数上如果目标矩形和源尺寸一样可以换成SetDIBitsToDevice少一次缩放计算。biSizeImage用step * rows而不是cols * channels * rows因为 step 可能含 padding。4.3 灰度图和四通道的差异处理灰度图channels() 1biBitCount填 8但 8 位 DIB 需要调色板BITMAPINFO后面要跟 256 个RGBQUAD。如果只填 header 不填调色板显示出来是随机颜色。常见做法是灰度图先cvtColor成 BGR 再三通道显示省掉调色板。四通道 BGRA 填 32 位biCompression仍是BI_RGBalpha 通道会被忽略显示效果和三通道一致但内存多占 25%。如果 Mat 是CV_32F或CV_16U必须先convertTo到CV_8U否则biBitCount填不对显示必花。注意StretchDIBits不检查 Mat 是否连续如果isContinuous()为 false 而你没 clone它会按连续内存读结果就是错行。这个坑在 ROI 裁剪后的 Mat 上特别常见。5. 避坑与排查五条我实际踩过的记录5.1 现象图片显示但颜色红蓝互换原因中间某一步把 BGR 转成了 RGB比如用了cvtColor(mat, mat, COLOR_BGR2RGB)做预处理然后直接喂给 GDI。GDI 的 DIB 期望 BGR拿到 RGB 就反了。解决要么别转要么转完再转回来或者显示前统一cvtColor回 BGR。我一般约定Mat 在显示链路里始终保持 BGR需要 RGB 只在算法内部临时转。5.2 现象图像斜掉或下半部分错位原因Mat 有 paddingstep ! cols * elemSize()但代码按cols * channels逐行读或直接整块memcpy。解决逐行用mat.ptrBYTE(y)取行首或者先clone()让 OpenCV 重新分配连续内存。clone()后的 Mat 通常step cols * elemSize()但不绝对仍建议按 step 处理。5.3 现象程序在 OnPaint 里随机崩溃原因存了局部 Mat 的data裸指针Mat 析构后指针悬空或者 CImage 是局部变量OnPaint时已析构。解决Mat 和 CImage 都作为对话框成员变量持有跨函数传递用clone()或引用计数。裸指针只在当前函数栈内使用。5.4 现象反复加载图片后内存持续上涨原因每次CImage::Create前没Destroy()或者 Mat 成员变量被反复赋值但旧数据没释放。CImage::Create在已分配的情况下会先释放再分配但如果你用SetBitmap或手动GetBits写可能绕过释放。解决加载新图前显式m_img.Destroy()Mat 用mat.release()或直接赋值让引用计数归零。5.5 现象大图显示卡顿拖动窗口闪烁原因OnPaint里每次全量重绘且没有双缓冲。解决用内存 DC 做双缓冲先在兼容 DC 上画好再BitBlt到屏幕或者只在图像变化时Invalidate窗口遮挡恢复时用InvalidateRect只刷脏区。StretchDIBits本身比 CImage 快但缩放模式选COLORONCOLOR比HALFTONE快画质要求不高时可以换。6. 进阶把显示封装成可复用控件与性能取舍6.1 封装一个 CMatView 派生自 CStatic如果项目里多处要显示 Mat每次写OnPaint很烦。常见做法是派生一个CMatView : public CStatic内部持有cv::Mat m_mat和CImage m_img暴露SetMat(const cv::Mat)接口在OnPaint里自绘。这样对话框里只需要绑定控件变量调SetMat即可。派生时注意CStatic默认不接收WM_PAINT的自绘需要把控件属性设为SS_OWNERDRAW或者重写DrawItem但更简单的是在OnPaint里直接画因为CStatic的OnPaint可以被重写。class CMatView : public CStatic { public: void SetMat(const cv::Mat mat) { m_mat mat.clone(); // 深拷贝避免外部释放 m_img.Destroy(); if (!m_mat.empty()) MatToCImage(m_mat, m_img); Invalidate(); } protected: afx_msg void OnPaint() { CPaintDC dc(this); if (!m_img.IsNull()) { CRect rc; GetClientRect(rc); m_img.Draw(dc.GetSafeHdc(), rc); } } DECLARE_MESSAGE_MAP() cv::Mat m_mat; CImage m_img; };逻辑说明SetMat里clone()保证控件持有独立数据外部 Mat 释放不影响显示。Invalidate触发重绘。OnPaint里只画不做转换转换在SetMat里做一次避免每帧重复转换。参数上如果外部 Mat 很大且频繁更新clone()的开销要考虑可以改成移动语义或双缓冲交换。6.2 性能取舍CImage 中转 vs StretchDIBits 直绘维度CImage 中转StretchDIBits 直绘代码量少封装好多需填 BITMAPINFO内存拷贝多一次无缩放质量Draw 内部处理可调 StretchBltMode灰度图需转三通道需调色板或转三通道适用场景静态图、低频刷新视频帧、高频刷新我一般静态图用 CImage视频或实时预览用 StretchDIBits。如果帧率要求高还会把SetStretchBltMode设成COLORONCOLOR牺牲一点画质换速度。另外StretchDIBits每次调用都会做格式转换如果 Mat 格式固定可以预先建一个兼容 DC 和 DIB section用SetDIBits更新像素再BitBlt这样更快但代码复杂度上一个台阶。6.3 一个验证技巧用像素值反查显示是否正确显示对不对肉眼有时看不准。我习惯在加载后取 Mat 的(0,0)像素值再在OnPaint后用GetPixel取控件左上角颜色对比 BGR 三个分量。如果一致说明通道序和坐标都对如果红蓝反了就是通道序问题如果全黑可能是biHeight正负搞反或者biBitCount不对。这个笨办法帮我定位过好几次“看起来正常其实错了”的显示问题。从那以后我每次把 Mat 塞进 MFC 控件前都强制走一遍isContinuous检查、channels确认、step打印、biHeight符号核对。这四步走完基本不会再翻车。希望帮到你。本文还有配套的精品资源点击获取