ARTICLE DETAIL

资讯详情

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

基于OpenCV4与MFC的摄像头预览实战:从VideoCapture到图像绘制

基于OpenCV4与MFC的摄像头预览实战:从VideoCapture到图像绘制 简介面向需要在Windows桌面端集成OpenCV视觉功能的MFC开发者这份基于VS2019与OpenCV4.5.3的对话框程序源码提供了启动、停止摄像头及载入图像的完整实现。工程仅使用Mat、VideoCapture、imshow等核心类与函数代码量精简逻辑直接适合入门级读者快速掌握OpenCV在MFC界面下的调用方式同时特别处理了程序退出后摄像头LED灯仍常亮的常见遗留问题具备实际工程参考价值。压缩包共47个文件大小62.43MB主要包含cpp/h源文件、vcxproj工程配置、pdb调试信息以及exe可运行程序附带sln解决方案与资源文件打开即可编译运行。目前已有255人学习下载适合希望结合界面操作学习OpenCV图像采集的初学者或需要快速搭建摄像头测试工具的开发者。 做摄像头画面的桌面预览是很多工业调试软件和视觉项目绕不开的基础需求。我这次整理的是一个基于OpenCV4 VS2019的MFC对话框程序实现了摄像头的启动、停止以及本地图像的载入显示所有源码都能直接抄走用。项目本身不大但把视频流采集、画面绘制、图像格式转换、控件刷新时机这些桌面视觉开发的硬骨头都串了一遍适合刚接触MFC和OpenCV整合的开发者当第一个练手项目来研究。在动手之前先聊清楚方案选型。MFC虽然年头久但Windows平台下的工业软件、设备厂商SDK示例里到处都是它的影子很多相机和板卡厂商提供的Demo就是MFC工程。花点时间把MFC和OpenCV的配合姿势练熟再去对接那些存量代码时能省下大量折腾成本这也是我把这个例子整理出来的原因。1. 项目全貌与方案选型1.1 核心需求拆解标题里的信息可以拆成四个要点OpenCV4负责图像采集和编解码VS2019负责工程构建与调试MFC提供窗口和控件框架启动、停止摄像头和载入图像则是三个核心功能点。这三个功能在代码层面分别对应VideoCapture的设备开关、定时器驱动的帧刷新循环以及CFileDialog加imread/imdecode的图片加载。项目运行起来后用户能看到一个带预览区域的对话框点击启动按钮画面开始实时刷新点击停止按钮画面冻结再点载入图像则可以选一张本地图片显示在同一个预览区域。整体逻辑并不复杂难度主要集中在对齐、格式转换和资源释放这些细节上。1.2 为什么选MFC而不是Qt或WinForms如果纯粹从零开发新工具Qt的跨平台能力和现代C体验确实更好WinForms的开发效率也更高。但MFC有一个不可忽视的优势存量系统多工业现场的设备调试工具里大量存在MFC界面的身影而且很多老项目的维护和二次开发工作至今仍在持续。跨团队协作时接手一个MFC工程远比劝客户换框架来得实际。从学习角度讲MFC对话框程序的消息映射机制能帮你把Windows消息循环理解得很透彻这和OpenCV的图像处理部分结合起来恰好覆盖了桌面视觉应用开发的两条主线界面事件响应和图像数据流转。1.3 VS2019工程环境准备本项目需要使用VS2019安装时确保包含“使用C的桌面开发”工作负载并在组件里勾选“适用于v142生成工具的C MFCx86和x64”。MFC组件默认不会安装漏掉的话新建工程时会找不到MFC模板。OpenCV部分去官网下载Windows版安装包解压后需要做三件事把opencv\build\include目录添加到项目的“VC目录-包含目录”。把opencv\build\x64\vc15\lib目录添加到“VC目录-库目录”。这里的vc15文件虽然名字带vc15但VS2019完全兼容。在“链接器-输入-附加依赖项”里写入opencv_world450.libDebug配置下用带d后缀的opencv_world450d.lib。版本号要和下载的OpenCV版本对应比如4.5.1版本对应world451。另外强烈建议把opencv_world.dll放到exe所在目录或者加入系统PATH不然编译通过后运行时会直接提示找不到dll。2. 界面搭建与控件绑定2.1 对话框界面布局新建基于对话框的MFC工程后打开资源视图里的对话框资源拖入以下控件Picture ControlIDC_PIC_SHOW画面显示区域建议把Type设为RectangleColor设为Gray这样画面刷新时的背景闪烁会小一些。三个按钮IDC_BTN_START启动摄像头、IDC_BTN_STOP停止摄像头、IDC_BTN_LOAD载入图像。一个静态文本IDC_STATIC_STATUS显示当前状态比如“摄像头已启动”或“图像载入完成”。IDC_STATIC默认不支持消息响应如果要动态更新内容或后续扩展点击事件记得修改它的ID。布局上把Picture Control拉大一些作为主显示区域三个按钮放在下方或右侧。自定义按钮外观属于锦上添花工程重点先把功能跑通外观美化可以后续再做。2.2 成员变量与控件关联在对话框类头文件里加入OpenCV头文件和链接指令并声明关键成员#include opencv2/opencv.hpp #pragma comment(lib, opencv_world450.lib) #define CAMERA_TIMER_ID 1 class CCameraDlg : public CDialogEx { private: cv::VideoCapture m_cap; BOOL m_bCameraRunning; };在DoDataExchange函数里完成控件绑定void CCameraDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_PIC_SHOW, m_picCtrl); DDX_Control(pDX, IDC_BTN_START, m_btnStart); DDX_Control(pDX, IDC_BTN_STOP, m_btnStop); DDX_Control(pDX, IDC_BTN_LOAD, m_btnLoad); }绑定之后代码里就可以直接通过m_picCtrl操作Picture Control通过m_btnStart等对象控制按钮的启用和禁用状态了。3. 摄像头启动、停止与定时器刷新3.1 启动按钮与VideoCapture打开启动按钮的点击响应函数里第一步是打开摄像头设备。绝大多数电脑只有内置或外接的一个摄像头索引0即可。但某些场景下索引可能是1比如独立摄像头被主板驱动占用后。稳妥起见可以写一个循环尝试一下void CCameraDlg::OnBnClickedBtnStart() { if (m_bCameraRunning) return; for (int i 0; i 3; i) { if (m_cap.open(i)) { SetDlgItemText(IDC_STATIC_STATUS, _T(摄像头已打开索引: ) std::to_wstring(i).c_str()); break; } } if (!m_cap.isOpened()) { AfxMessageBox(_T(无法打开摄像头请检查设备是否被占用或驱动是否正常)); return; } m_bCameraRunning TRUE; m_btnStart.EnableWindow(FALSE); m_btnStop.EnableWindow(TRUE); m_btnLoad.EnableWindow(FALSE); SetTimer(CAMERA_TIMER_ID, 33, NULL); }这段代码里有两个细节值得说明。第一个是摄像头未成功打开时一定要给用户明确提示因为摄像头被其他软件占用、驱动异常都会导致open失败。第二个是打开成功后才开始定时器不要把SetTimer放在open之前否则定时器触发时如果摄像头还没就绪会多出一堆空帧逻辑。3.2 OnTimer定时取帧与画面绘制SetTimer(1, 33, NULL)表示每33毫秒触发一次OnTimer换算下来约30帧/秒。对实时预览来说足够流畅如果后续接入了耗时较长的图像处理算法再把间隔调大到50毫秒甚至100毫秒即可。定时器触发后的核心逻辑是取帧、转格式、绘制三步void CCameraDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent CAMERA_TIMER_ID) { cv::Mat frame; if (m_cap.read(frame) !frame.empty()) { CImage image; if (MatToCImage(frame, image)) { DrawImageToControl(image, m_picCtrl); image.Destroy(); } } } CDialogEx::OnTimer(nIDEvent); }这里要重点说明的是MatToCImage函数这个函数负责把OpenCV的Mat对象转换成MFC能显示的CImage对象。OpenCV默认的图像通道顺序是BGR而CImage在24位色深下期待RGB顺序直接转换会导致画面红色和蓝色互换。所以转换前必须先做通道调整。绘制函数DrawImageToControl的核心是StretchBlt它可以实现图片缩放并绘制到目标控件区域void CCameraDlg::DrawImageToControl(CImage* pImage, CStatic* pControl) { CRect rc; pControl-GetClientRect(rc); if (rc.IsRectEmpty()) return; CDC* pDC pControl-GetDC(); if (pDC) { pImage-StretchBlt(pDC-GetSafeHdc(), rc, SRCCOPY); pControl-ReleaseDC(pDC); } }注意获取控件客户区矩形后一定要检查是否为空窗口最小化或尚未完全创建时客户区尺寸可能为0如果直接StretchBlt到0宽高的区域可能会触发GDI异常。3.3 停止按钮与资源释放停止按钮的逻辑和启动正好相反但顺序必须严格遵守void CCameraDlg::OnBnClickedBtnStop() { KillTimer(CAMERA_TIMER_ID); m_bCameraRunning FALSE; if (m_cap.isOpened()) { m_cap.release(); } m_btnStart.EnableWindow(TRUE); m_btnStop.EnableWindow(FALSE); m_btnLoad.EnableWindow(TRUE); SetDlgItemText(IDC_STATIC_STATUS, _T(摄像头已停止)); }必须先KillTimer再release。如果顺序反了定时器在release之后仍然触发代码会试图从一个已经关闭的VideoCapture里读帧轻则返回空Mat重则触发访问冲突。这类问题在单步调试里很难复现因为每次读写之间间隔极短但实际运行一久必定暴露。除此之外在对话框的OnDestroy回调里也要做同样的清理防止用户直接点右上角关闭按钮退出时摄像头资源没有被释放。个人习惯是把KillTimer和release封装成一个StopCamera函数供按钮和OnDestroy共同调用。4. 载入图像与Mat/CImage转换4.1 CFileDialog选图与中文路径处理载入图像的第一步是让用户选择本地图片文件这用MFC的CFileDialog即可实现void CCameraDlg::OnBnClickedBtnLoad() { CFileDialog dlg(TRUE, _T(*.jpg), NULL, OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST, _T(图片文件(*.jpg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|所有文件(*.*)|*.*||), this); if (dlg.DoModal() ! IDOK) return; // 先停止摄像头再载入图像 OnBnClickedBtnStop(); CString strPath dlg.GetPathName(); ... }这里有一个非常隐蔽的大坑OpenCV4的imread函数在Windows平台下对中文路径支持并不好。如果选择的图片路径包含中文imread会静默返回空Mat没有任何报错信息。MFC工程在VS2019默认的Unicode字符集下CString是宽字符直接转成std::string再传给imread路径是ANSI编码中文基本会乱码。稳妥的做法是绕过imread改用ifstream读取文件内容再通过imdecode解码std::string UTF8ToLocal(const CString str) { ... } cv::Mat LoadImageFromPath(const CString strPath) { std::ifstream ifs(strPath, std::ios::binary); if (!ifs) return cv::Mat(); std::vectorchar buf((std::istreambuf_iteratorchar(ifs)), std::istreambuf_iteratorchar()); return cv::imdecode(cv::Mat(buf), cv::IMREAD_COLOR); }ifstream的宽字符路径重载在VS2019下可以正确处理中文路径配合imdecode绕开了imread对编码的限制。这个方法在需要处理中文路径的场景下非常实用比如从文件对话框选择路径或者从配置文件中读取路径。4.2 Mat转CImage的逐行拷贝Mat转CImage这个转换函数是整个项目的核心。很多人第一次写这类代码时直接整块memcpy结果画面要么歪斜要么色彩错乱。根本原因在于CImage在内存中每行字节数按4字节对齐而Mat的行字节数等于宽度乘以通道数并不一定满足对齐要求。正确的做法是逐行拷贝BOOL CCameraDlg::MatToCImage(const cv::Mat mat, CImage image) { if (mat.empty()) return FALSE; cv::Mat tmp; if (mat.type() CV_8UC3) { cv::cvtColor(mat, tmp, cv::COLOR_BGR2RGB); } else { tmp mat.clone(); } int nChannels tmp.channels(); int nBitCount nChannels 3 ? 24 : 8; int width tmp.cols; int height tmp.rows; image.Destroy(); image.Create(width, height, nBitCount); for (int y 0; y height; y) { memcpy(image.GetPixelAddress(0, y), tmp.ptruchar(y), width * nChannels); } return TRUE; }GetPixelAddress是CImage提供的按行取地址方法内部已经计算了4字节对齐偏移用它逐行复制可以保证每一行数据都落在正确的位置。CImage使用完毕后要调用Destroy释放内部资源我在OnTimer里用完就立即销毁了避免GDI句柄泄漏。这个细节在长时间运行的程序里尤其重要跑十分钟就出现画面卡顿多半就是GDI对象没有释放。5. 踩坑记录与排查清单5.1 摄像头打不开的检查顺序摄像头打不开是高频问题而且原因五花八门。按照出现概率排查顺序可以这样来表现可能原因快速验证方法open返回false摄像头被其他软件占用关闭QQ、浏览器、其他相机App后重试open返回true但画面黑屏摄像头隐私权限被系统禁用检查Windows设置中的相机隐私权限插上USB摄像头后索引对不上内置和外接摄像头并存循环尝试索引0、1、2并打印结果笔记本摄像头灯亮但无画面驱动异常或摄像头故障打开系统自带的Camera应用验证硬件是否正常我自己在调试时踩过最典型的一个坑设备管理器里明明看得到摄像头open却一直失败折腾半天发现是其他软件常驻后台占用了摄像头权限微软商店的某些UWP应用会自动常驻。所以建议代码里在open失败时弹窗提示用户检查设备占用而不是只返回一个笼统的错误码。5.2 画面花屏、颜色错乱与闪烁处理花屏或者画面出现横纹错位十有八九是行字节对齐问题。遇到这类现象先去检查MatToCImage的逐行拷贝是否正确不要用整块memcpy。我之前从网上抄过一段整块拷贝的代码换上后画面全是斜线后来才发现是CImage的宽度匹配加了填充字节。颜色错乱最典型的表现是红色变成蓝色。这个场景下99%是因为BGR和RGB通道顺序没有转换。记住一条规律OpenCV默认BGRCImage在24位色深下期待RGB中间一定隔着一次cvtColor。画面闪烁的问题分两种。一种是控件重绘时背景和图像交替闪烁这种可以通过在对话框的OnEraseBkgnd里返回TRUE避免背景擦除来缓解另一种是整个窗口都在闪那就需要考虑双缓冲绘制先在一个内存DC上完成绘制再一次性贴到控件上。实时视频预览对闪烁容忍度比较低追求品质时可以上双缓冲代码量会增加一点但效果明显。5.3 编译链接与中文字符集问题编译阶段最容易遇到的几个问题我整理成一张速查表报错信息原因解决方案LNK1112: 模块计算机类型x86与目标计算机类型x64冲突平台位数和库位数不匹配把工程平台切换到x64LNK1104: 无法打开文件opencv_world450.lib附加依赖项没写或者库目录没配检查链接器-输入和VC目录运行时找不到opencv_world450d.dlldll不在exe目录或PATH中把dll复制到exe同目录C2001: 常量中有换行符源文件编码问题导致中文注释被误解析把文件另存为UTF-8 with BOMC4819: 该文件包含不能在当前代码页中表示的字符源文件编码不是UTF-8 with BOM全工程改用UTF-8 with BOM保存关于中文注释报错的问题网络上反馈非常多。VS2019默认源文件保存格式可能是UTF-8无BOM编译器在处理中文字符时可能按照本地代码页解析偶尔一个特殊中文字符的最后一个字节和后面的引号组合在一起就会让编译器误认为字符串没有闭合。我早期的处理办法是给每个cpp文件开头加上#pragma execution_character_set(utf-8)但这个预处理指令只对字符串字面量生效对注释无效。后来在工程层面把文件统一另存为UTF-8 with BOM格式问题彻底解决建议在工程属性里配置好文件保存格式后一劳永逸。还有一个Debug和Release库混用的问题。如果Debug配置链接了opencv_world450.lib而不是opencv_world450d.lib运行时大概率会出现堆损坏表现是程序在关闭时崩溃或者变量内容诡异变化。原因是Debug版库和Release版库使用了不同的CRT混用会导致内存管理不一致。链接库的选择必须严格跟着配置走。最后说一个我自己的习惯每次写这类带摄像头的MFC小工具我都会先把停止按钮和OnDestroy的清理逻辑写完整再回头写启动逻辑。摄像头是独占式的硬件资源一旦release没有及时执行后面所有调试都会变成玄学。先想好退出再想好进入省下来的时间远比想象中多。这个项目的源码量不大但界面响应、图像转换、资源管理这些知识点全部跑通一遍再去接触更复杂的视觉应用基础就扎实了。本文还有配套的精品资源点击获取
返回列表