ARTICLE DETAIL

资讯详情

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

MFC+OpenCV视频播放器实现:从VideoCapture到CImage显示与逐帧控制

MFC+OpenCV视频播放器实现:从VideoCapture到CImage显示与逐帧控制 简介基于OpenCV与VC2015MFC实现的视频播放示例程序适合初学OpenCV图像处理或MFC界面集成的开发者帮助解决视频打开、播放、暂停、快进、拖动进度条、前一帧后一帧等交互功能的实现问题。整个压缩包共661个文件容量约163.2MB其中包含OpenCV编译好的库文件228个lib与222个dll用于程序链接和运行111个hpp和49个h头文件提供接口声明另有24个exe、24个cmake及txt/7z文件辅助项目构建与说明。将库文件放在E盘即可直接编译通过省去自行配置OpenCV环境的繁琐步骤程序内还保留录制摄像头视频的参考代码虽然相关菜单被移除但函数逻辑完整方便读者恢复功能或学习扩展。界面按钮响应函数清晰便于从各交互入口逐模块理解视频播放与摄像头采集的实现流程。目前已有640人学习下载适合希望快速上手OpenCVMFC视频应用的初学者借鉴和二次开发。1. 拿到这个 MFC 播放器先理解 VideoCapture 和界面刷新的关系如果你手头正好有一个把 OpenCV 编好的库放在 E 盘、用 VC2015 打开就能编译的 MFC 工程第一件事不应该急着点绿色三角运行而是先看它是怎么把cv::Mat显示到对话框上的。因为视频播放最核心的工作其实拆成两半一半是 OpenCV 负责解码另一半是 MFC 负责把解码后的帧画到控件上。很多初学者卡在“视频能读画面不出来”或者“画面出来但红蓝颠倒”就是因为这两半没有接好。这个示例程序的价值也在这里打开视频、播放、暂停、快进、拖动进度条、前一帧、后一帧都做成了独立按钮接口清晰适合拿来做图像处理项目时的界面底座。对于只做过控制台 OpenCV 程序、想迁移到 Windows 桌面应用的人来说这套框架比用 Qt 重写一遍要省事得多。2. 读取视频帧并从 cv::Mat 绘制到 MFC 控件绕不开的三种方案2.1 先分清 VideoCapture 和旧版 CvCapture如果你在网上搜 OpenCV 播放视频会看到两种代码。老教程常用CvCapture *capture cvCaptureFromFile(...)再配合cvQueryFrame这是 OpenCV 1.x 时代的 C 接口。新工程里不应该再这么写因为内存管理和错误处理都很原始而且很多新版本里这些接口的兼容层已不再是重点维护对象。这个项目使用的cv::VideoCapture是 C 接口构造和析构自动管理资源打开文件的方式也更直观cv::VideoCapture cap; if (!cap.open(filePath)) { AfxMessageBox(_T(打开视频失败)); return; } double fps cap.get(cv::CAP_PROP_FPS); int totalFrames static_castint(cap.get(cv::CAP_PROP_FRAME_COUNT));VideoCapture::open接受文件路径或摄像头索引返回值是 bool不要写成if (cap.open(...))之后再继续往下走因为一旦失败后续读帧会全部落空。CAP_PROP_FPS和CAP_PROP_FRAME_COUNT都是 double 类型取整时要留意部分视频文件本身就拿不到这两个参数返回值会是 0 或负数。实际项目中我会在totalFrames小于等于 0 时禁用进度条和逐帧按钮避免用户点出无效操作。2.2 显示帧的核心CImage 的 pitch 和拷贝把cv::Mat画到 MFC 控件上常见有三种做法。第一种是直接用CvvImage这是很多老工程里的做法代码很短但它是非官方文件依赖旧版库结构不推荐新项目继续使用。第二种是用SetDIBitsToDevice自己填BITMAPINFO自由度最高但写起来啰嗦还得自己处理颜色表和行对齐。第三种也是我比较推荐的一种用CImage创建一个 DIB 位图拿到像素指针后把cv::Mat的数据拷进去再用BitBlt或StretchBlt显示。关键代码如下bool ShowFrameToCtrl(cv::Mat frame, CWnd* pWnd) { if (frame.empty()) return false; CRect rc; pWnd-GetClientRect(rc); DWORD w rc.Width() 0 ? rc.Width() : 1; DWORD h rc.Height() 0 ? rc.Height() : 1; CImage img; img.Create((int)w, (int)h, 24); cv::Mat resized; cv::resize(frame, resized, cv::Size((int)w, (int)h), 0, 0, cv::INTER_LINEAR); cv::Mat rgb; cv::cvtColor(resized, rgb, cv::COLOR_BGR2RGB); int pitch img.GetPitch(); BYTE* dstPtr static_castBYTE*(img.GetBits()); for (DWORD row 0; row h; row) { memcpy(dstPtr row * pitch, rgb.ptrBYTE(row), rgb.step); } CClientDC dc(pWnd); img.BitBlt(dc.GetSafeHdc(), rc.left, rc.top, SRCCOPY); img.Destroy(); return true; }CImage::GetPitch返回的是每行字节数包含行对齐填充不能直接用w * 3代替。rgb.step是 OpenCV 中一行的实际字节数通常也是按四字节对齐的所以复制行数据时要按step来取。img.Destroy必须在用完位图后调用否则句柄会一直占用长时间播放视频后 GDI 资源会耗尽界面会逐渐发白。第三种方案的优点是代码可控颜色转换、缩放、控件自适应都能自己决定。后续如果想做双缓冲只需要再加一个内存 DC把图像先画到内存位图上再一次BitBlt到窗口就能消除闪烁。2.3 Timer 刷新和工作线程刷新的取舍示例程序的播放逻辑通常放在OnTimer里这也符合初学者最容易理解的模型定时器到点读一帧刷界面。它的缺陷是读帧和绘制都在 UI 线程里跑遇到解码压力大的视频拖动窗口时会明显卡顿。如果只是两三分钟的小视频或测试素材这个方案完全够用。刷新方案是否阻塞 UI 线程实现成本适用场景SetTimer OnTimer是低演示、本地小文件、原型验证std::thread PostMessage否中高分辨率视频、长时间播放、界面需要保持流畅工作线程方案里帧数据必须深拷贝之后跨线程传递否则主线程显示时工作线程可能正在覆盖同一块 Mat 数据。常见做法是 new 一份cv::Mat通过PostMessage把指针送到 UI 线程处理完再 deletestd::thread([this]() { while (m_playing) { cv::Mat frame; if (!m_cap.read(frame)) break; cv::Mat* pFrame new cv::Mat(frame); ::PostMessage(m_hWnd, WM_VIDEO_FRAME, 0, reinterpret_castLPARAM(pFrame)); std::this_thread::sleep_for(std::chrono::milliseconds(33)); } }).detach();消息处理函数里再把这个指针转换回来显示之后释放内存。这里要注意m_playing是跨线程访问的共享变量严谨一点应该加std::atomicbool。另外工作线程里不要调用任何 GDI 绘图函数MFC 的控件刷新必须在 UI 线程完成。3. 播放、暂停、快进、进度条把 MFC 消息映射到 OpenCV 控制接口3.1 按钮消息映射和播放状态管理MFC 对话框程序里按钮点击事件通过消息映射表绑定到成员函数。这个工程的按钮功能是标准的我在类似项目里通常会维护下面这些成员变量cv::VideoCapture m_cap; bool m_isPlaying false; bool m_bDragging false; int m_currentFrame 0; int m_totalFrames 0;消息映射写成这样BEGIN_MESSAGE_MAP(CVideoPlayerDlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_PLAY, CVideoPlayerDlg::OnBnClickedBtnPlay) ON_BN_CLICKED(IDC_BTN_PAUSE, CVideoPlayerDlg::OnBnClickedBtnPause) ON_BN_CLICKED(IDC_BTN_FAST_FORWARD, CVideoPlayerDlg::OnBnClickedBtnFastForward) ON_BN_CLICKED(IDC_BTN_PREV_FRAME, CVideoPlayerDlg::OnBnClickedBtnPrevFrame) ON_BN_CLICKED(IDC_BTN_NEXT_FRAME, CVideoPlayerDlg::OnBnClickedBtnNextFrame) ON_WM_TIMER() ON_WM_HSCROLL() END_MESSAGE_MAP()播放和暂停按钮的逻辑很简单一个启动定时器一个停止定时器。定时器 ID 用常量定义避免魔法数字void CVideoPlayerDlg::OnBnClickedBtnPlay() { if (!m_cap.isOpened() || m_isPlaying) return; m_isPlaying true; SetTimer(PLAY_TIMER_ID, 33, nullptr); } void CVideoPlayerDlg::OnBnClickedBtnPause() { m_isPlaying false; KillTimer(PLAY_TIMER_ID); }33 毫秒大约对应 30FPS。如果视频源本身的 FPS 是 25这个刷新率也够用。但需要注意SetTimer的精度并不高它只保证不早于设定时间触发实际间隔会受系统时钟影响。对帧率要求高的场合应该读取CAP_PROP_FPS后倒推间隔而不是固定写死。3.2 OnTimer 里读取帧并刷新界面播放的核心循环在OnTimer里逻辑顺序是先read一帧再取当前位置刷新画面更新进度条和文本标签。void CVideoPlayerDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent PLAY_TIMER_ID) { cv::Mat frame; if (!m_cap.read(frame)) { KillTimer(PLAY_TIMER_ID); m_isPlaying false; m_cap.set(cv::CAP_PROP_POS_FRAMES, 0); m_currentFrame 0; return; } m_currentFrame static_castint(m_cap.get(cv::CAP_PROP_POS_FRAMES)); ShowFrameToCtrl(frame, GetDlgItem(IDC_STATIC_VIDEO)); m_slider.SetPos(m_currentFrame); CString str; str.Format(_T(第 %d 帧 / 共 %d 帧), m_currentFrame, m_totalFrames); SetDlgItemText(IDC_STATIC_FRAME_INFO, str); } CDialogEx::OnTimer(nIDEvent); }这里的CAP_PROP_POS_FRAMES要在read返回后立刻获取。因为部分后端会在下一次读取时更新内部位置晚一步拿到的值已经不是当前帧了。另外视频播放到末尾时read返回 false常见处理是停止并回到第一帧。你也可以在末尾处重新set(CAP_PROP_POS_FRAMES, 0)继续循环播放取决于业务需求。3.3 进度条拖动和 Set 状态复位进度条用的是CSliderCtrl范围在打开视频时设置m_slider.SetRange(0, m_totalFrames 0 ? m_totalFrames - 1 : 0); m_slider.SetPos(0);拖动过程中会持续收到OnHScroll消息如果每一条TB_THUMBTRACK都执行一次set很容易卡顿因为 Seek 操作会触发解码器内部刷新。正确做法是拖动期间只记录位置松手后再跳转void CVideoPlayerDlg::OnHScroll(UINT nSBCode, UINT nPos, CScrollBar* pScrollBar) { if (pScrollBar-GetDlgCtrlID() IDC_SLIDER_POS) { if (nSBCode TB_THUMBTRACK) { m_bDragging true; } else if (nSBCode TB_ENDTRACK) { int pos m_slider.GetPos(); m_cap.set(cv::CAP_PROP_POS_FRAMES, pos); m_currentFrame pos; cv::Mat frame; if (m_cap.read(frame)) { ShowFrameToCtrl(frame, GetDlgItem(IDC_STATIC_VIDEO)); m_slider.SetPos(pos); } m_bDragging false; } return; } CDialogEx::OnHScroll(nSBCode, nPos, pScrollBar); }拖动期间还要暂停播放否则OnTimer和OnHScroll同时操作VideoCapture内部状态会乱。可以在SetTimer之前检查m_bDragging处于拖动状态时就跳过这一轮读帧。3.4 快进跳秒还是倍速快进可以做成两种交互一种是点击一次跳十秒适合快速浏览另一种是按住快进持续加速播放。前者实现最简单void CVideoPlayerDlg::OnBnClickedBtnFastForward() { if (!m_cap.isOpened()) return; double ms m_cap.get(cv::CAP_PROP_POS_MSEC); m_cap.set(cv::CAP_PROP_POS_MSEC, ms 10000.0); cv::Mat frame; if (m_cap.read(frame)) { ShowFrameToCtrl(frame, GetDlgItem(IDC_STATIC_VIDEO)); m_currentFrame static_castint(m_cap.get(cv::CAP_PROP_POS_FRAMES)); m_slider.SetPos(m_currentFrame); } }这里直接用CAP_PROP_POS_MSEC而不是帧号是因为按时间跳转更贴近用户认知也不受视频真实帧率波动影响。倍速播放则要改OnTimer里的读帧数量一次循环调用多次grab()只取最后一帧显示。grab()负责解码但不生成 Mat比调用read()轻适合跳帧场景。4. 逐帧播放的精度问题CAP_PROP_POS_FRAMES 并不是万能的4.1 下一帧实现set 之后要立刻 grab逐帧播放是图像分析类项目的刚需常用于视频标注、缺陷检测和逐帧比对。下一帧按钮的处理方式是先获取当前帧号加一然后调用set定位void CVideoPlayerDlg::OnBnClickedBtnNextFrame() { if (!m_cap.isOpened()) return; KillTimer(PLAY_TIMER_ID); int pos static_castint(m_cap.get(cv::CAP_PROP_POS_FRAMES)); int target min(pos 1, m_totalFrames - 1); m_cap.set(cv::CAP_PROP_POS_FRAMES, target); cv::Mat frame; if (m_cap.grab() m_cap.retrieve(frame)) { m_currentFrame target; ShowFrameToCtrl(frame, GetDlgItem(IDC_STATIC_VIDEO)); m_slider.SetPos(m_currentFrame); UpdateFrameLabel(); } }调用set后最好先用grab()让后端完成内部的解码定位再retrieve()拿到实际像素。如果直接read()某些后端会把set目标位置和read的自动前进叠加导致画面比预期多走一帧。4.2 编码格式对关键帧依赖的影响这里有一个比较隐蔽的坑CAP_PROP_POS_FRAMES的定位精度取决于视频编码。H.264、H.265 这类压缩格式存在 GOP 和 B 帧OpenCV 底层通过 FFmpeg 跳转时解码器必须先定位到目标帧所在的关键帧再解出目标帧。如果关键帧间隔太大跳转后可能需要一小段时间才出画面。上一帧比下一帧更麻烦。set(POS_FRAMES, pos - 1)再retrieve()得到的可能是向前几帧的画面因为解码器重新定位后不一定能精确落在你要的那个 P 帧或 B 帧上。我在实际项目中把这种逐帧定位叫“软定位”用来人工浏览可以用来做像素级对比不够严谨。如果你真的需要完全精确的逐帧序列更可靠的方式有两个播放前先用 OpenCV 把视频全部拆成图片序列。使用带精确定位的媒体框架比如 FFmpeg 命令行直接抽帧。4.3 三种定位参数对比参数单位优点典型坑CAP_PROP_POS_FRAMES帧号直观适合逐帧逻辑非关键帧起点B 帧场景可能不准CAP_PROP_POS_MSEC毫秒适合按时间跳转帧率波动时换算出的帧号有误差CAP_PROP_POS_AVI_RATIO0.0 ~ 1.0相对位置适合进度条部分格式不支持返回值不稳定最理想的做法是维护一个m_currentFrame变量由你自己的代码保证它和画面一致。set之后再把m_currentFrame更新为实际显示的帧号而不是完全信任get的返回值。4.4 快捷键逐帧浏览在做视频标注工具时鼠标点按钮太慢。可以在对话框里重写PreTranslateMessage把左右方向键映射到上一帧和下一帧BOOL CVideoPlayerDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN) { if (pMsg-wParam VK_RIGHT) { OnBnClickedBtnNextFrame(); return TRUE; } if (pMsg-wParam VK_LEFT) { OnBnClickedBtnPrevFrame(); return TRUE; } if (pMsg-wParam VK_SPACE) { if (m_isPlaying) OnBnClickedBtnPause(); else OnBnClickedBtnPlay(); return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); }这个入口是对话框还没把按键消息分发给子控件之前拦截所以焦点在哪都能响应。要注意空格键默认会触发按钮点击如果你在界面上放置了多个按钮焦点落在其中一个按钮上时空格事件可能被按钮消耗掉。这时候可以在OnBnClickedBtn里先调用一次GetFocus()判断焦点控件。4.5 用帧号标签验证定位是否正确逐帧功能做完后我会先在窗口上放一个静态文本显示当前帧号然后连续点击下一帧观察帧号跳变规律。如果每一帧差值是 1说明定位链路在测试视频上是正确的。如果某一段连续几帧没有变化或者突然跳到某个关键帧附近基本可以确认是编码格式导致的问题而不是你的代码逻辑有误。这个验证方法也能帮你判断一个视频适不适合做逐帧分析。5. OpenCV 库的工程属性与 OpenCVModules-*.cmake 的来由5.1 为什么“放到 E 盘就能编译”下载包里出现OpenCVModules-release.cmake和OpenCVModules-debug.cmake说明工程引用的 OpenCV 是通过 CMake 包配置方式生成的。OpenCV 官方或源码构建在配置完成后会生成这些模块文件里面记录了 include 路径、库路径、依赖的 DLL 等信息。VC2015 工程里如果直接把 include 和 lib 写死成E:\opencv\build\include、E:\opencv\build\x64\vc14\lib那么只要库文件还在同样的位置换一台机器也能直接打开编译。这种方式的优点是省事缺点是没有可移植性。库文件一换盘符就需要手动改工程属性。更好的做法是建一个OpenCV.props属性页把所有路径集中管理Project ToolsVersion4.0 xmlnshttp://schemas.microsoft.com/developer/msbuild/2003 PropertyGroup IncludePathE:\opencv\build\include;$(IncludePath)/IncludePath LibraryPathE:\opencv\build\x64\vc14\lib;$(LibraryPath)/LibraryPath /PropertyGroup ItemDefinitionGroup Link AdditionalDependenciesopencv_world450.lib;%(AdditionalDependencies)/AdditionalDependencies /Link /ItemDefinitionGroup /Project这个 props 文件可以在属性管理器里一次性添加给 Debug 和 Release 配置。如果要换目录只改一处。Debug 版本链接的库通常带d后缀也就是opencv_world450d.lib。5.2 VC2015 对应的库版本和运行环境OpenCV 预编译库按 VC 版本分目录比如vc14对应 Visual Studio 2015vc15对应 2017vc16对应 2019。你拿到的是 VC2015 工程就应该选vc14目录下的库混用vc15或vc16会导致链接错误或运行时崩溃。OpenCVModules-release.cmake文件里记录的库目标名也可能和 VS 工程里的AdditionalDependencies不一致。如果改成 CMake 方式引用应该使用set(OpenCV_DIR E:/opencv/build) find_package(OpenCV REQUIRED) include_directories(${OpenCV_INCLUDE_DIRS}) target_link_libraries(your_target ${OpenCV_LIBS})这样 CMake 会自动读取OpenCVModules-release.cmake、OpenCVModules-debug.cmake中的库信息不需要手动写库名也能根据 Debug/Release 自动选择带d的版本。5.3 能编译但运行时提示找不到 DLL这种情况通常出现在你只配置了 lib 路径没有把 OpenCV 的 bin 目录加入系统 PATH也没把 DLL 复制到 exe 输出目录。程序启动时加载opencv_world450.dll失败弹窗立刻出现。解决办法是到E:\opencv\build\x64\vc14\bin下找到对应 DLL复制到工程输出目录。如果你的程序是 Debug 编译应复制带d的 DLL否则会提示 “Debug 库与运行时不一致” 或直接初始化失败。5.4 播放器常见问题排查表现象可能原因处理方式打开视频没反应路径含中文CString 转 std::string 编码错误使用CT2A转换当前代码页或先改成英文路径测试画面颜色红蓝互换Mat 是 BGR 顺序CImage 期望 RGB显示前调用cvtColor(COLOR_BGR2RGB)画面闪烁严重OnTimer 中每次都重绘整个客户区使用内存 DC 双缓冲先画到位图再 BitBlt拖动进度条卡住Seek 次数太多解码器频繁重建只在TB_ENDTRACK时跳转拖动过程只标记位置播放 MP4 只有声音没有画面OpenCV 的 FFmpeg 后端对部分编码支持不完整换用 OpenCV 4.x 最新版本或检查视频是否为 H.2645.5 关于 OpenCV 4.5.2 和图像处理项目扩展如果你打算在这个播放器基础上做图像处理建议优先使用 OpenCV 4.x。4.5.2 之后的版本对 QR 码、Code 128 等条码识别有原生支持做视觉检测项目时不用额外挂第三方库。图像坐标系的约定也统一Mat.cols对应宽度Mat.rows对应高度和画图时的CImage概念要区分开否则绘制时容易宽高颠倒。6. 用摄像头录像代码做一次全链路自检帧率数据和颜色还原6.1 摄像头数据共用显示通道项目里保留了摄像头录像代码但删掉了菜单这段代码的价值不只是拍照录像而是可以作为播放器显示链路的自检工具。摄像头输入没有关键帧、没有 GOP每一帧都是实时生成的如果ShowFrameToCtrl在视频文件播放中不够快在摄像头模式会更快暴露出来。在工程里手动加一个测试按钮事件代码里打开摄像头然后把定时器切到摄像头数据源。播放器主体不用改共用同一个ShowFrameToCtrl函数void CVideoPlayerDlg::OnBnClickedBtnCameraTest() { if (!m_cam.open(0)) { AfxMessageBox(_T(摄像头打开失败)); return; } KillTimer(PLAY_TIMER_ID); m_isPlaying true; SetTimer(CAMERA_TIMER_ID, 33, nullptr); }6.2 在 OnTimer 里做帧率自检摄像头模式下OnTimer的代码和视频播放几乎一样只多了一个耗时统计。用GetTickCount64记录读帧时间和显示时间void CVideoPlayerDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent CAMERA_TIMER_ID) { ULONGLONG t1 ::GetTickCount64(); cv::Mat frame; if (!m_cam.read(frame)) { KillTimer(CAMERA_TIMER_ID); return; } ULONGLONG t2 ::GetTickCount64(); ShowFrameToCtrl(frame, GetDlgItem(IDC_STATIC_VIDEO)); ULONGLONG t3 ::GetTickCount64(); CString str; str.Format(_T(读取: %llu ms 绘制: %llu ms), t2 - t1, t3 - t2); SetDlgItemText(IDC_STATIC_FRAME_INFO, str); } CDialogEx::OnTimer(nIDEvent); }如果读取耗时长期超过 30 毫秒说明摄像头分辨率太高或者采集格式需要调整可以尝试调用CAP_PROP_FRAME_WIDTH和CAP_PROP_FRAME_HEIGHT把分辨率降到 640x480。绘制耗时偏大时优先优化ShowFrameToCtrl里的resize和拷贝可以先缩小图像再做颜色转换。6.3 摄像头录像的写入与收尾录像功能需要cv::VideoWriter关键参数是编码格式、帧率和画面尺寸。MJPEG 在兼容性上比 H.264 更省心写入速度也快void StartRecord(cv::VideoWriter writer, int width, int height, double fps) { int fourcc cv::VideoWriter::fourcc(M, J, P, G); writer.open(record.avi, fourcc, fps, cv::Size(width, height), true); if (!writer.isOpened()) { AfxMessageBox(_T(录像文件创建失败)); } }关闭程序时必须在OnClose里释放摄像头并关闭写入器否则视频文件头部信息会缺失录出来的文件打不开void CVideoPlayerDlg::OnClose() { KillTimer(PLAY_TIMER_ID); KillTimer(CAMERA_TIMER_ID); if (m_writer.isOpened()) { m_writer.release(); } if (m_cam.isOpened()) { m_cam.release(); } CDialogEx::OnClose(); }这套摄像头自检在业务上会验证三件事MFC 控件绘制通路是否顺畅OpenCV 读取实时流是否稳定以及 VideoWriter 写文件是否正常。如果摄像头每一帧都能稳定显示并且录像文件可以播放说明播放器的渲染链路没问题接下来只要把图像处理算法放到OnTimer的预留位置继续叠加即可。本文还有配套的精品资源点击获取
返回列表