ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

OpenCV Mat图像显示到MFC CDC:StretchDIBits转换与性能优化

OpenCV Mat图像显示到MFC CDC:StretchDIBits转换与性能优化

1. 从内存到屏幕:为什么Mat图不能直接扔给CDC

做过图像处理的朋友大概率都经历过这个场景:用OpenCV吭哧吭哧算完一张图,cv::Mat里数据整整齐齐,结果一到界面上就犯难——怎么把它显示到Windows窗口里?尤其是用MFC或者Win32 API写界面的项目,手里握着的是CDC(设备上下文),而OpenCV给的是Mat,两者之间隔着一道看不见的墙。

这道墙的本质,是两套完全不同的图像内存组织方式。Mat是OpenCV自己的矩阵容器,它的数据布局、通道顺序、行对齐规则都是OpenCV说了算;而CDC背后是Windows GDI那一套,它期望的是DIB(设备无关位图)格式的数据。你直接把Mat的data指针丢给GDI函数,轻则花屏,重则程序直接崩掉。这不是危言耸听,我见过太多人在这一步栽跟头。

这篇文章就是要把这道墙拆掉。我会从Mat的内存布局讲起,说清楚它和Windows位图之间的差异到底在哪,然后给出几种从简单到实用的转换方案,重点讲StretchDIBits这条最常用的路子,也会聊聊性能优化和那些文档里不会写的坑。不管你是刚接触OpenCV和MFC的新手,还是已经写过几版显示代码但总觉得不够稳的老手,应该都能从里面找到点有用的东西。

先说清楚适用场景:你用的是Windows平台,界面层是MFC的CDC或者Win32的HDC,图像数据来自OpenCV的cv::Mat,目标是把Mat的内容正确、高效地画到窗口上。如果你用的是Qt或者其他跨平台框架,思路类似但API不同,本文不展开。

2. Mat的内存布局与GDI位图的格式差异

2.1 Mat不只是一块连续内存那么简单

很多人以为Mat就是一块连续的像素缓冲区,mat.data指向首地址,mat.total() * mat.elemSize()就是总字节数。这个理解在大多数情况下没错,但它掩盖了几个关键细节,而这些细节恰恰是显示出问题的根源。

第一个细节是行步长(step)。Mat的每一行在内存里不一定是紧挨着的。OpenCV出于SIMD对齐的考虑,会给每行末尾补一些填充字节,让行宽对齐到某个边界(通常是4字节或更多)。也就是说,第0行结束到第1行开始之间可能有几个字节的间隙。mat.step表示的是每行占用的实际字节数(含填充),而mat.cols * mat.elemSize()只是有效像素的字节数。这两个值在图像宽度不是对齐边界整数倍的时候会不一样。

第二个细节是通道顺序。OpenCV默认用BGR顺序存储彩色图像,而Windows GDI的24位或32位DIB用的是BGR(对于24位)或者BGRA(对于32位)。这一点上两者其实是一致的,所以颜色不会反。但如果你用的是RGB顺序的Mat(比如从某些相机SDK直接拿到的数据),直接显示就会红蓝颠倒。

第三个细节是数据类型。Mat可以是CV_8U、CV_16U、CV_32F等等。GDI能直接显示的只有8位无符号整型。如果你手里是浮点Mat或者16位Mat,必须先转换到8位,否则显示出来必然是一团糟。

2.2 GDI期望的DIB长什么样

Windows GDI显示图像的核心函数是StretchDIBits(或者SetDIBitsToDevice),它们都要求一个BITMAPINFO结构加一个像素数据指针。BITMAPINFO里最关键的是bmiHeader,它描述了图像的宽、高、位深、压缩方式等信息。

这里有几个容易踩的点。第一,biHeight可以是正数也可以是负数。正数表示图像是自下而上存储的(bottom-up),也就是第一行数据是图像的最下面一行;负数表示自上而下(top-down)。OpenCV的Mat是自上而下的,所以你应该把biHeight设成负数,这样GDI就知道第一行是顶部。如果你设成正数,图像会上下颠倒。

第二,biBitCount要和你提供的数据匹配。24表示每像素3字节,32表示每像素4字节。用32位有个好处:行对齐天然满足,因为每行字节数永远是4的倍数,不需要额外处理填充。用24位的话,如果图像宽度不是4的倍数,每行末尾需要补0到4字节边界,这个填充你得自己加。

第三,biSizeImage可以设成0(对于BI_RGB未压缩格式),GDI会自动计算。但设成实际大小更稳妥。

2.3 两者对齐规则的冲突是花屏的元凶

把上面两点合起来看,问题就清楚了。Mat的行步长是OpenCV按自己的规则对齐的,DIB的行步长是GDI按4字节边界对齐的。这两个值在图像宽度满足某些条件时恰好相等,在另一些条件下就不等。如果你直接把mat.data交给StretchDIBits,并且告诉它图像宽度是mat.cols,那么当两个步长不一致时,GDI会按它自己算的步长去读数据,读到的位置就错位了,表现出来就是图像斜切、花屏或者颜色错乱。

我刚开始写这类代码的时候,就遇到过一张宽度为奇数的灰度图显示出来是斜的,查了半天才发现是步长问题。后来养成了一个习惯:只要涉及Mat到GDI的转换,第一件事就是检查mat.step和mat.cols * mat.elemSize()是否相等,不等就得特殊处理。

3. 用StretchDIBits打通Mat到CDC的显示链路

3.1 为什么选StretchDIBits而不是SetDIBitsToDevice

GDI里能画DIB的函数不止一个。SetDIBitsToDevice只能按1:1的比例画,不支持缩放;StretchDIBits支持源矩形和目标矩形的独立指定,可以缩放、可以只画一部分。在实际项目里,图像显示区域的大小往往和图像本身不一样,需要缩放适配,所以StretchDIBits是更通用的选择。而且它的参数设计也更灵活,源矩形和目标矩形分开指定,做局部放大、平移查看都很方便。

StretchDIBits的函数签名是这样的:

int StretchDIBits( HDC hdc, int xDest, int yDest, int DestWidth, int DestHeight, int xSrc, int ySrc, int SrcWidth, int SrcHeight, const VOID *lpBits, const BITMAPINFO *lpbmi, UINT iUsage, DWORD dwRop );

关键参数是lpBits(像素数据指针)、lpbmi(位图信息)、iUsage(通常用DIB_RGB_COLORS)、dwRop(光栅操作码,通常用SRCCOPY)。源矩形和目标矩形分别用xSrc/ySrc/SrcWidth/SrcHeight和xDest/yDest/DestWidth/DestHeight指定。

3.2 构造BITMAPINFO的正确姿势

构造BITMAPINFO看着简单,但细节不少。对于24位BGR图像,代码大概是这样:

BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = mat.cols; bmi.bmiHeader.biHeight = -mat.rows; // 负数表示自上而下 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 24; bmi.bmiHeader.biCompression = BI_RGB; bmi.bmiHeader.biSizeImage = 0; bmi.bmiHeader.biXPelsPerMeter = 0; bmi.bmiHeader.biYPelsPerMeter = 0; bmi.bmiHeader.biClrUsed = 0; bmi.bmiHeader.biClrImportant = 0;

biHeight设成负数这一点再强调一次,因为它是上下颠倒问题的直接原因。biSizeImage设0让GDI自己算,对于BI_RGB格式是安全的。

对于32位BGRA图像,把biBitCount改成32即可。32位的好处是每像素4字节,行步长天然是4的倍数,不需要处理填充,而且可以带Alpha通道(虽然StretchDIBits本身不做Alpha混合,但数据格式是兼容的)。

3.3 处理行步长不匹配的两种策略

如果mat.step不等于mat.cols * mat.elemSize(),直接传mat.data就会出问题。有两种处理方式。

第一种是逐行拷贝到临时缓冲区。分配一块大小为mat.rows * mat.cols * mat.elemSize()的连续内存,然后一行一行地memcpy过去,每行拷贝mat.cols * mat.elemSize()字节。这样得到的缓冲区行步长就是紧凑的,可以直接交给GDI。缺点是每次显示都要拷贝一遍,大图会有性能开销。

int rowBytes = mat.cols * mat.elemSize(); int compactStep = rowBytes; std::vector<uchar> buffer(mat.rows * compactStep); for (int i = 0; i < mat.rows; ++i) { memcpy(buffer.data() + i * compactStep, mat.ptr(i), rowBytes); }

第二种是利用GDI的步长参数。StretchDIBits本身没有步长参数,但BITMAPINFOHEADER的biWidth决定了GDI计算步长的方式。如果你把biWidth设成mat.step / mat.elemSize()(也就是把填充也算作像素),然后源矩形只取前mat.cols列,理论上可以让GDI按Mat的步长去读。但这样做有个前提:mat.step必须是mat.elemSize()的整数倍,而且GDI对biWidth的处理在不同位深下行为不完全一致,实测下来不够可靠。所以我个人更推荐第一种方案,简单直接,不容易出幺蛾子。

提示:如果图像不大或者显示频率不高,逐行拷贝的开销可以忽略。如果追求极致性能,可以在Mat创建时就保证它是连续的(mat.isContinuous()为true),这样mat.step == mat.cols * mat.elemSize(),就不需要拷贝了。

3.4 完整的显示函数实现

把上面的东西串起来,一个完整的显示函数大概长这样:

void ShowMatOnCDC(CDC* pDC, const cv::Mat& mat, const CRect& rcDest) { if (mat.empty() || !pDC) return; // 确保是8位3通道或4通道 cv::Mat displayMat; if (mat.type() == CV_8UC3) { displayMat = mat; } else if (mat.type() == CV_8UC4) { displayMat = mat; } else if (mat.type() == CV_8UC1) { cv::cvtColor(mat, displayMat, cv::COLOR_GRAY2BGR); } else { // 其他类型先转8位 cv::Mat tmp; mat.convertTo(tmp, CV_8U); if (tmp.channels() == 1) cv::cvtColor(tmp, displayMat, cv::COLOR_GRAY2BGR); else displayMat = tmp; } int channels = displayMat.channels(); int bitCount = channels * 8; int rowBytes = displayMat.cols * channels; // 处理行步长 const uchar* pBits = displayMat.data; std::vector<uchar> compactBuf; if (displayMat.step != (size_t)rowBytes) { compactBuf.resize(displayMat.rows * rowBytes); for (int i = 0; i < displayMat.rows; ++i) { memcpy(compactBuf.data() + i * rowBytes, displayMat.ptr(i), rowBytes); } pBits = compactBuf.data(); } BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = displayMat.cols; bmi.bmiHeader.biHeight = -displayMat.rows; bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = bitCount; bmi.bmiHeader.biCompression = BI_RGB; pDC->StretchDIBits( rcDest.left, rcDest.top, rcDest.Width(), rcDest.Height(), 0, 0, displayMat.cols, displayMat.rows, pBits, &bmi, DIB_RGB_COLORS, SRCCOPY); }

这个函数处理了类型转换、行步长、位深设置,基本能覆盖大部分场景。调用的时候传一个CDC指针、一个Mat和一个目标矩形就行。

4. 显示效果调优与性能取舍

4.1 缩放质量:StretchDIBits的拉伸模式

StretchDIBits在缩放时默认用的是COLORONCOLOR模式,这个模式速度快但质量一般,缩小图像时会有明显的锯齿和细节丢失。如果你需要更好的缩放质量,可以在调用之前设置拉伸模式:

int oldMode = pDC->SetStretchBltMode(HALFTONE); pDC->SetBrushOrg(0, 0); // HALFTONE模式需要设置画刷原点 pDC->StretchDIBits(...); pDC->SetStretchBltMode(oldMode);

HALFTONE模式会做一定的插值,缩小图像时效果明显好于COLORONCOLOR,但速度会慢一些。SetBrushOrg那行在HALFTONE模式下是必须的,否则可能出现对齐问题。实测下来,对于几百KB的普通图像,HALFTONE的开销完全可以接受;如果是每秒几十帧的实时视频流,可能就得权衡一下了。

4.2 大图显示的卡顿从哪来

显示大图卡顿通常有三个来源。第一是前面说的行步长拷贝,如果每帧都要拷贝几MB的数据,累积起来很可观。第二是缩放本身的计算量,尤其是放大倍数较大的时候。第三是GDI的绘制是在UI线程做的,如果图像很大,会阻塞消息循环,导致界面无响应。

针对第一点,尽量让Mat保持连续,避免拷贝。cv::Mat::create创建的Mat通常是连续的,但通过ROI(感兴趣区域)得到的Mat往往不连续。如果你要反复显示同一个ROI,可以先clone()一份连续的出来。

针对第二点,如果缩放比例固定,可以考虑先把图像缩放到目标尺寸再显示,这样StretchDIBits做的就是1:1拷贝,快很多。OpenCV的cv::resize用的是优化过的算法,质量也比GDI的拉伸好。

针对第三点,如果确实需要显示很大的图像,可以考虑用双缓冲:先在内存DC里画好,再一次性BitBlt到屏幕DC。这样能减少闪烁,但不会减少总计算量。真正的解决办法是把图像处理放到工作线程,UI线程只负责最后的绘制。

4.3 内存DC与双缓冲的配合

在MFC里做双缓冲很常见,配合Mat显示也不复杂。核心思路是创建一个和客户区等大的兼容DC和兼容位图,所有绘制操作先在内存DC上做,最后统一拷贝到屏幕DC。

void CMyView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(&rcClient); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap memBmp; memBmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&memBmp); // 先在内存DC上画背景 memDC.FillSolidRect(&rcClient, RGB(240, 240, 240)); // 再画Mat ShowMatOnCDC(&memDC, m_mat, rcClient); // 最后一次性拷贝到屏幕 pDC->BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

这样做的另一个好处是,当窗口被其他窗口遮挡后重新露出时,不会出现残影。不过要注意,每次OnDraw都创建兼容位图开销不小,更好的做法是把内存DC和位图作为成员变量,只在窗口大小变化时重建。

5. 那些文档里不会写的坑与排查经验

5.1 图像上下颠倒:biHeight符号的陷阱

这个问题我提过好几次,但还是要单独拿出来说,因为它太常见了。BITMAPINFOHEADER的biHeight为正时表示bottom-up,为负时表示top-down。OpenCV的Mat是top-down的,所以必须用负数。但有些示例代码里用的是正数,然后显示出来是颠倒的,作者可能又加了一个翻转操作来"修复",结果代码里多了一次无谓的cv::flip。正确的做法就是直接用负数,不需要翻转。

如果你拿到一段别人的代码,发现里面有cv::flip配合显示,先检查一下biHeight的符号,很可能那个flip是多余的。

5.2 颜色通道错乱:BGR与RGB的识别

OpenCV默认BGR,GDI的24位DIB也是BGR,所以正常情况下颜色是对的。但如果你从某些来源拿到的是RGB数据(比如某些网络摄像头的SDK、某些图像解码库),直接构造Mat显示就会红蓝互换。判断方法很简单:显示一张有明显红色和蓝色的图,看颜色对不对。如果不对,用cv::cvtColor(mat, mat, cv::COLOR_RGB2BGR)转一下。

还有一种情况是4通道图像。OpenCV的CV_8UC4默认是BGRA,GDI的32位DIB也是BGRA,所以直接显示颜色是对的。但如果你把4通道当3通道处理,就会出问题。

5.3 内存越界:步长计算错误导致的崩溃

前面说的步长问题,如果处理不当,轻则花屏,重则内存越界访问导致崩溃。特别是当mat.step小于mat.cols * mat.elemSize()的时候(这种情况理论上不应该出现,但如果你手动构造过Mat或者用了某些非标准的数据源,是有可能的),GDI按biWidth算出来的步长会比实际数据大,读到后面就越界了。

排查这类问题,我通常会在显示之前加一段断言检查:

ASSERT(mat.step >= (size_t)(mat.cols * mat.elemSize())); ASSERT(mat.depth() == CV_8U);

如果断言触发,说明数据有问题,先解决数据问题再谈显示。

5.4 频繁显示时的资源泄漏排查

StretchDIBits本身不涉及GDI对象的创建和销毁,所以一般不会泄漏。但如果你在显示函数里创建了兼容DC、位图、画刷等GDI对象,忘记释放就会泄漏。Windows的GDI对象数量是有限制的(默认每个进程10000个),泄漏到一定程度程序就会出问题。

排查GDI泄漏可以用任务管理器看"GDI对象"列,如果数字持续增长不下降,就有泄漏。也可以用专门的工具。我自己的习惯是,任何CreateXXX都配一个DeleteXXX,用RAII封装起来,避免忘记。

5.5 多线程环境下CDC使用的注意事项

CDC和HDC不是线程安全的。如果你在工作线程里处理完图像,想直接在工作线程里调用StretchDIBits画到窗口上,这是不行的。正确的做法是把处理结果传回UI线程,在UI线程的OnDraw或者消息处理函数里绘制。

如果一定要在工作线程里准备绘制数据(比如构造BITMAPINFO、拷贝像素缓冲区),这些是可以的,只要不碰CDC就行。把准备好的数据打包,通过PostMessage发给UI线程,UI线程收到后再调用StretchDIBits。

6. 从显示到交互:Mat在CDC场景下的延伸用法

6.1 鼠标坐标到图像坐标的映射

显示出来只是第一步,接下来往往要处理鼠标交互——比如在图像上画框、取点、测量距离。鼠标事件给的是客户区坐标,你需要把它转换成图像坐标。转换的关键是知道图像在客户区里的显示矩形(也就是StretchDIBits的目标矩形)。

假设目标矩形是rcDest,图像尺寸是imgW x imgH,鼠标点是pt,那么图像坐标是:

int imgX = (pt.x - rcDest.left) * imgW / rcDest.Width(); int imgY = (pt.y - rcDest.top) * imgH / rcDest.Height();

反过来,图像坐标转客户区坐标:

int clientX = rcDest.left + imgX * rcDest.Width() / imgW; int clientY = rcDest.top + imgY * rcDest.Height() / imgH;

这两个转换在实现画框、取点等功能时是基础。注意整数除法的精度问题,必要时用浮点计算再取整。

6.2 在Mat上叠加绘制再显示

有时候你不想在CDC上画,而是想直接在Mat上画(比如用OpenCV的绘图函数画了框和文字),然后整体显示。这样做的好处是绘制逻辑和图像处理逻辑统一在OpenCV里,不用混用GDI和OpenCV两套绘图API。

cv::rectangle(mat, cv::Rect(100, 100, 200, 150), cv::Scalar(0, 0, 255), 2); cv::putText(mat, "Target", cv::Point(100, 90), cv::FONT_HERSHEY_SIMPLEX, 0.8, cv::Scalar(0, 255, 0), 2); ShowMatOnCDC(pDC, mat, rcDest);

这种方式在需要频繁更新叠加内容的场景下很方便,因为每次都是重新画一遍Mat再显示,不用担心GDI绘图状态的残留问题。

6.3 显示区域的裁剪与局部放大

StretchDIBits的源矩形参数支持只显示图像的一部分,这为实现局部放大提供了便利。比如你想显示图像中以(cx, cy)为中心、大小为w x h的区域,并放大到目标矩形:

int srcX = cx - w / 2; int srcY = cy - h / 2; // 边界检查... pDC->StretchDIBits( rcDest.left, rcDest.top, rcDest.Width(), rcDest.Height(), srcX, srcY, w, h, pBits, &bmi, DIB_RGB_COLORS, SRCCOPY);

配合鼠标滚轮事件调整w和h,就能实现缩放查看。配合鼠标拖动调整cx和cy,就能实现平移。这套逻辑在图像查看器类的工具里很常用。

6.4 保存显示结果为图片

有时候用户想把当前显示的内容(包括叠加的框和文字)保存下来。如果叠加是在Mat上做的,直接cv::imwrite就行。如果叠加是在CDC上做的,就需要先把CDC的内容抓成位图再保存,麻烦一些。所以如果保存是需求之一,建议尽量在Mat上做叠加。

cv::imwrite("output.png", mat);

简单直接,不依赖GDI。

7. 一些实测下来的经验参数与建议

关于位深选择,我个人的建议是:如果不需要Alpha通道,用24位;如果需要Alpha或者追求行对齐的便利,用32位。32位虽然多占25%的内存和带宽,但省去了行填充的处理,代码更简单,而且在现代机器上这点开销基本可以忽略。

关于缩放模式,静态图像显示用HALFTONE,实时视频用COLORONCOLOR。如果实时视频也想要好一点的质量,可以先把Mat用cv::resize缩放到接近目标尺寸,再用COLORONCOLOR做最后的微调,这样兼顾质量和速度。

关于双缓冲,只要窗口有频繁重绘的可能,就值得上双缓冲。MFC的OnEraseBkgnd记得返回TRUE,避免背景擦除导致的闪烁。

关于类型转换,显示函数里做类型检查和转换是必要的,但不要每次显示都做。如果Mat的类型在多次显示之间不变,可以在外面转好一次,显示函数只负责画。这样能省掉重复的转换开销。

最后说一个我踩过的坑:StretchDIBits的返回值是成功绘制的扫描行数,失败返回GDI_ERROR。有些情况下它会静默失败(比如内存不足),如果你不检查返回值,可能以为画成功了但屏幕上是空的。建议在调试版本里加一个返回值检查,至少打个日志。

int ret = pDC->StretchDIBits(...); if (ret == GDI_ERROR) { TRACE("StretchDIBits failed, error code: %d\n", GetLastError()); }

这个习惯帮我定位过好几次莫名其妙的白屏问题,根源都是GDI资源耗尽或者参数不合法。

RELATED READING

延伸阅读

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