ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC显示OpenCV Mat:从内存布局到StretchDIBits实战

MFC显示OpenCV Mat:从内存布局到StretchDIBits实战

简介:这份资源面向使用 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()是 false,imshow单独跑也能弹窗,可一旦塞进 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 指针丢给 GDI

2.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; // 创建 CImage,32 位色深,负高度表示自上而下 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.ptr<BYTE>(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 == 4,BGRA 直接拷 { 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)固定 40
biWidthmat.cols逻辑宽度,不是 step
biHeight-mat.rows负值表示自上而下,和 Mat 一致
biPlanes1固定
biBitCountchannels * 8三通道 24,四通道 32,灰度 8
biCompressionBI_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() == 1,biBitCount填 8,但 8 位 DIB 需要调色板,BITMAPINFO后面要跟 256 个RGBQUAD。如果只填 header 不填调色板,显示出来是随机颜色。常见做法是灰度图先cvtColor成 BGR 再三通道显示,省掉调色板。四通道 BGRA 填 32 位,biCompression仍是BI_RGB,alpha 通道会被忽略,显示效果和三通道一致,但内存多占 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 有 padding,step != cols * elemSize(),但代码按cols * channels逐行读或直接整块memcpy。解决:逐行用mat.ptr<BYTE>(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符号核对。这四步走完,基本不会再翻车。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进