☰
DirectShow USB相机实时预览抓图与参数调节实践
2026/11/5 2:10:48 网站建设 项目流程

简介:面向具备C++与MFC基础、希望掌握视频采集显示与抓图流程的开发者,这份在VS2013下构建的工程示例围绕实时预览、视频参数设置和静态截图展开,可用于学习滤镜图的搭建、渲染、事件回调与错误处理。压缩包为rar格式,共46个文件,主要包括3个cpp源文件、5个头文件、工程与资源配置文件,以及obj、pdb、tlog等调试中间产物,整体约66.86MB,便于直接打开对照编译和调试。已有1716人学习,说明该主题在视频采集与图像处理入门中具有一定参考价值。代码涉及视频渲染滤镜、静态图像滤镜、流格式配置、播放控制与截图接口,支持分辨率、帧率和编码类型调整,并提示线程同步、截图保存路径设置及用图形编辑工具排查滤镜图结构,适合作为实时显示、参数调整和抓图功能的实践参考。

1. 从一次“预览卡半秒、抓图慢两帧”的现场说起

某开发者接了个产线检测的小工具:USB 相机实时显示、点一下按钮抓图、顺手能拖亮度对比度。第一版用 OpenCV 的VideoCapture加imshow,预览延迟肉眼可见,抓下来的图和窗口里看到的差两帧,参数拖完画面没反应,被现场一句“这调了跟没调一样”打回。后来把链路换成 DirectShow:预览走 Video Renderer,抓图用 SampleGrabber 在滤镜图里截样本,视频参数用IAMVideoProcAmp直接下发到驱动,延迟和不同步才算压住。这套东西适合两类人:手里有 UVC 相机、要做实时显示加抓图的 C++ 桌面开发者;以及被高层封装坑过、想弄明白采集链路里每一帧到底经过谁的人。下面按工程结构、预览、抓图、参数、排坑的顺序拆开讲。

2. 架构拆解:一条滤镜链路上的四类角色与工程目录

DirectShow 的采集程序,说到底是在拼一张有向图。图的管理者是 Filter Graph Manager,负责协调各个 Filter 的状态切换和时钟;真正把“相机”和“窗口”连起来的是 Capture Graph Builder,它封装了RenderStream这类高层调用,省得你一个个 pin 去试连。这个项目里我把链路拆成四类角色:Source Filter 代表采集设备,SampleGrabber 是插在中间偷样本的转换器,Video Renderer 负责把画面画到窗口,Null Renderer 则用于“只抓不显示”的场景。理解这四个角色的位置,后面所有代码都是围绕它们做增删。

2.1 从设备到窗口:Source、Grabber、Renderer 的数据流

一条典型的预览链路是Source → SampleGrabber → Video Renderer。Source 的输出 pin 协商好媒体类型后,样本按帧往下推;SampleGrabber 拿到样本不修改内容,只是在回调里把 buffer 指针交给你;Renderer 收到后按显示时钟排队上屏。这里的关键点是 SampleGrabber 属于 in-place 转换器,它必须同时有输入和输出 pin,所以不能把它当终点直接挂在那里,后面得跟一个 Renderer 或者 Null Renderer 把链路闭合。只抓图不预览时,我一般接CLSID_NullRenderer,这样样本照样流动,但不会占用窗口和显示时钟。

为什么不用 OpenCV 的VideoCapture一路到底?不是它不好,而是它把采集、解码、色彩转换全包在内部,抓图只能拿“最新一帧”,参数设置也没有统一入口。DirectShow 的好处是链路透明:帧从哪来、什么格式、有没有被丢,你都能在 Filter 层面看到。代价是 COM 接口多、引用计数容易漏,后面排坑章节会专门讲。

这个项目的数据流还有一个细节:预览分支和抓图分支共用同一个 SampleGrabber。好处是实现简单,坏处是抓图回调受渲染队列影响,极端情况下拿到的样本比屏幕上的晚。要彻底解决,得用 Smart Tee 把 Source 的输出分成 Preview 和 Capture 两路,Preview 给 Renderer,Capture 给 Grabber 加 Null Renderer。这个改造放在最后一章讲。

2.2 线程边界:样本在哪个线程被推过来

这是新手最容易忽略的地方。ISampleGrabberCB::BufferCB不是在 UI 线程被调用的,它在 DirectShow 的流线程上执行,每来一帧调一次。你在回调里直接SetWindowText、直接刷新控件,短时间看不出问题,相机帧率一高就开始卡死或者随机崩。血泪经验是:回调里只做一件事——把需要的数据拷贝出来,然后立刻返回。

我一般用互斥量加一个“最新帧”缓冲:回调拿到 buffer 后,在锁内assign到成员 vector,置一个hasFrame标志;UI 线程用定时器或者按钮消息去取,取的时候再锁一次。这样流线程永远不会被 UI 的绘制拖住,UI 也不会读到半帧数据。注意别在回调里做 BMP 编码或者图像处理,那些都是毫秒级以上的活,放在流线程上会直接拖低采集帧率。

COM 的初始化也要留意。流线程是 DirectShow 自己创建的,通常已经处在 MTA 里;如果你的 UI 线程用的是 STA,跨线程传接口指针时要走封送,最省事的做法是 UI 线程也用CoInitializeEx(nullptr, COINIT_MULTITHREADED),所有 Filter 接口只在主线程调用,回调线程只碰自己的 buffer。窗口消息泵不能省,Video Renderer 依赖窗口消息做重绘和状态通知,消息循环被阻塞时预览会定格。

2.3 目录结构与模块职责

工程不大,但我习惯按职责分文件,避免所有东西堆在main.cpp里。下面是我在这个项目里用的目录,类名做了简化,实际代码里每个类都配一个头文件:

CaptureDemo/ src/ main.cpp // WinMain、窗口过程、消息泵 CaptureCore.h/.cpp // 对外门面:Open/Close/Grab/SetParam DeviceEnum.h/.cpp // 枚举视频输入设备,返回 moniker GraphBuilder.h/.cpp // 建图、连 pin、状态切换 GrabberCallback.h/.cpp// ISampleGrabberCB 实现与帧缓存 VideoProc.h/.cpp // IAMVideoProcAmp / IAMCameraControl 封装 BmpWriter.h/.cpp // RGB24 到 BMP 的落盘 include/ dshow_common.h // CComPtr 别名、HRESULT 检查宏
模块主要接口职责易错点
DeviceEnumICreateDevEnum、IPropertyBag列出相机名称与 moniker无设备时返回S_FALSE不是失败
GraphBuilderIGraphBuilder、ICaptureGraphBuilder2建图、RenderStream、Run/Stop释放顺序反了会崩在退出
GrabberCallbackISampleGrabberCB回调里拷贝最新帧不能碰 UI,不能做重活
VideoProcIAMVideoProcAmp、IAMCameraControl读范围、设值、回读flags 带 Auto 时手动值无效
BmpWriter无按 stride 写文件头和数据行宽不对齐会花屏

CaptureCore是对外的门面类,内部持有 graph、builder、grabber 和 callback 的CComPtr。这样 UI 层只需要调Open(deviceIndex)、Grab(path)、SetBrightness(value)三个方法,不需要知道 COM 细节。门面类析构时按“先 Stop、再置空回调、再释放 Grabber、最后释放 Graph”的顺序收尾,这个顺序后面会单独讲。

3. 实时预览:设备枚举、能力协商与 Graph 构建

预览能不能起来,八成取决于建图阶段。设备枚举拿不到 moniker,后面全白搭;媒体类型没协商好,RenderStream会给你一个看起来成功、实际黑屏的图;窗口句柄传错,Renderer 直接把画面画到桌面角落。这一章按“找设备、连链路、调参数”三步走,每步都给可抄的代码。

3.1 枚举设备与 moniker 绑定

不要用设备名字符串去FindCaptureDevice,重名相机一多就抓错。正确做法是枚举CLSID_VideoInputDeviceCategory,把IMoniker存下来,建图时用AddSourceFilterForMoniker绑定。IPropertyBag里读FriendlyName只用于界面显示,读不到就退回DevicePath。

// 枚举视频输入设备,names 用于界面,monikers 用于建图 HRESULT EnumCaptureDevices(std::vector<std::wstring>& names, std::vector<CComPtr<IMoniker>>& monikers) { CComPtr<ICreateDevEnum> devEnum; HRESULT hr = CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&devEnum)); if (FAILED(hr)) return hr; CComPtr<IEnumMoniker> enumMoniker; hr = devEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &enumMoniker, 0); // S_FALSE 表示这一类设备一个都没有,不是错误 if (hr != S_OK) return hr; CComPtr<IMoniker> moniker; while (enumMoniker->Next(1, &moniker, nullptr) == S_OK) { CComPtr<IPropertyBag> propBag; if (SUCCEEDED(moniker->BindToStorage(nullptr, nullptr, IID_PPV_ARGS(&propBag)))) { CComVariant varName; // FriendlyName 是给人看的,DevicePath 才是唯一标识 if (SUCCEEDED(propBag->Read(L"FriendlyName", &varName, nullptr))) { names.emplace_back(varName.bstrVal); } else { names.emplace_back(L"未命名设备"); } } monikers.push_back(moniker); moniker.Release(); // Next 返回的引用要自己放 } return S_OK; }

这段代码的参数说明就两个:names和monikers一一对应,界面选中第几项,建图就取第几个 moniker。CreateClassEnumerator返回S_FALSE时不要当失败处理,那只是“没插相机”。另外Next每次返回的 moniker 带一个引用计数,CComPtr接管后记得在循环末尾释放局部变量,否则枚举几次就泄漏几个 COM 对象。

设备枚举还有一个坑:某些虚拟相机在BindToStorage时会阻塞几百毫秒,界面线程枚举会导致窗口假死。我一般把枚举放到后台线程,枚举完把名字列表PostMessage回 UI,moniker 用CComPtr存成员里,跨线程只传索引。

3.2 构建预览链路与状态切换

建图的核心是ICaptureGraphBuilder2::RenderStream。它帮你找 pin、协商媒体类型、插入必要的转换滤镜,比手动ConnectDirect省事。但 SampleGrabber 必须在你调用RenderStream之前AddFilter进图,并通过SetMediaType限定成 RGB24,否则驱动可能给你 MJPG 或 YUY2,抓图时还得自己转。

// 建预览图:Source -> SampleGrabber -> Video Renderer bool BuildPreviewGraph(IMoniker* device, HWND previewWnd, ISampleGrabber** outGrabber, IGraphBuilder** outGraph) { CComPtr<IGraphBuilder> graph; CComPtr<ICaptureGraphBuilder2> builder; CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&graph)); CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&builder)); builder->SetFiltergraph(graph); CComPtr<IBaseFilter> source; HRESULT hr = graph->AddSourceFilterForMoniker(device, nullptr, L"capture", &source); if (FAILED(hr)) return false; CComPtr<IBaseFilter> grabberFilter; CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&grabberFilter)); graph->AddFilter(grabberFilter, L"grabber"); CComPtr<ISampleGrabber> grabber; grabberFilter->QueryInterface(IID_PPV_ARGS(&grabber)); AM_MEDIA_TYPE mt = {}; mt.majortype = MEDIATYPE_Video; mt.subtype = MEDIASUBTYPE_RGB24; // 抓图直接可用,省一次转换 mt.formattype = FORMAT_VideoInfo; grabber->SetMediaType(&mt); grabber->SetBufferSamples(FALSE); // 回调里自己拷,不额外缓存 grabber->SetOneShot(FALSE); // 中间过滤器放 Grabber,终点留空由 RenderStream 选 Renderer hr = builder->RenderStream(&PIN_CATEGORY_PREVIEW, &MEDIATYPE_Video, source, grabberFilter, nullptr); if (FAILED(hr)) return false; // 把窗口交给 Renderer,必须在 Run 之前 CComPtr<IVideoWindow> videoWindow; graph->QueryInterface(IID_PPV_ARGS(&videoWindow)); videoWindow->put_Owner((OAHWND)previewWnd); videoWindow->put_WindowStyle(WS_CHILD | WS_CLIPCHILDREN); videoWindow->SetWindowPosition(0, 0, 640, 480); CComPtr<IMediaControl> mediaControl; graph->QueryInterface(IID_PPV_ARGS(&mediaControl)); mediaControl->Run(); *outGrabber = grabber.Detach(); *outGraph = graph.Detach(); return true; }

参数说明集中在RenderStream的五个入参:类别用PIN_CATEGORY_PREVIEW,媒体类型用MEDIATYPE_Video,源过滤器是相机,中间过滤器是 Grabber,终点传nullptr让系统自己挑 Renderer。如果相机不支持 RGB24,这一步会失败,解决办法是先RenderStream到 Null Renderer,再在 Grabber 后面手动插一个CLSID_ColorConverter转到 RGB24,或者退一步接受 YUY2、在自己代码里做转换。

put_Owner要在Run之前调用,窗口样式必须带WS_CHILD,否则 Renderer 会自己弹一个顶层窗口。预览窗口大小通过SetWindowPosition设,但分辨率真正由媒体类型决定,窗口只是显示区域。状态切换靠IMediaControl,Run之后不要立刻Stop再Run,频繁切换会让某些 UVC 相机重新枚举,耗时两秒以上。

3.3 视频参数:IAMVideoProcAmp 与 IAMCameraControl 的正确读法

亮度、对比度、饱和度、增益这些走IAMVideoProcAmp,曝光、焦距、白平衡走IAMCameraControl。两个接口都从 Source Filter 上QueryInterface,不是从 Graph 上。设置之前先GetRange拿范围和 flags,这一步比Set本身重要:flags 里带VideoProcAmp_Flags_Auto表示驱动自己在调,你设手动值会被立刻覆盖,回读还是原值,看起来就像“设了没反应”。

// 设置亮度:先查范围,再强制手动模式,最后回读确认 bool SetBrightness(IBaseFilter* source, long value) { CComPtr<IAMVideoProcAmp> procAmp; if (FAILED(source->QueryInterface(IID_PPV_ARGS(&procAmp)))) return false; long minVal = 0, maxVal = 0, step = 0, defVal = 0, flags = 0; HRESULT hr = procAmp->GetRange(VideoProcAmp_Brightness, &minVal, &maxVal, &step, &defVal, &flags); if (FAILED(hr)) return false; // 超出范围的值有的驱动会截断,有的直接返回错误,先夹紧 if (value < minVal) value = minVal; if (value > maxVal) value = maxVal; // flags 带 Auto 时,必须显式切 Manual,否则设了也白设 hr = procAmp->Set(VideoProcAmp_Brightness, value, VideoProcAmp_Flags_Manual); if (FAILED(hr)) return false; // 回读一次,确认驱动真的接受了这个值 long current = 0, currentFlags = 0; procAmp->Get(VideoProcAmp_Brightness, &current, &currentFlags); return current == value; }

GetRange的五个输出参数分别是下限、上限、步长、默认值和能力标志。步长很重要,有些相机亮度只支持 1、2、3 这种整数档,你传 15 它可能取最近的档位,回读值和你设的不一样,不是失败,是量化。Set的第三个参数用VideoProcAmp_Flags_Manual,如果设备不支持手动模式会返回E_INVALIDARG或者S_FALSE,这时候只能接受自动曝光。

曝光要特别注意:IAMCameraControl::Set之前通常需要先Stop图,设完Sleep(100)再Run,部分 UVC 驱动在流运行中拒绝改曝光。不是所有相机都这样,但加上这段等待不会错。参数持久化我一般存注册表或者 JSON,键用FriendlyName + DevicePath的哈希,换相机不会串值;界面提供“恢复默认”,直接拿GetRange返回的defVal再Set一遍。

4. 抓图落地:SampleGrabber 回调、RGB24 对齐与 BMP 落盘

抓图这块最容易出两种问题:颜色错位和花屏。前者是像素格式没锁死,后者是行宽没对齐。SampleGrabber 给你的 buffer 是原始字节流,宽度、高度、stride 都要从协商后的媒体类型里读,不能想当然按width * 3算。这一章从回调注册讲到 BMP 文件头,最后说抓图节流。

4.1 回调注册与媒体类型协商

SetCallback有两个模式:第二个参数传 1 走BufferCB,直接给 buffer 指针和长度;传 0 走SampleCB,给的是IMediaSample,需要自己GetPointer。抓图场景我用BufferCB,代码短,少一层接口调用。注册必须在RenderStream之前完成,链路连好之后再设回调,前面几帧就漏了。

// 回调对象由 CaptureCore 持有,生命周期要覆盖整个图 class GrabberCallback : public ISampleGrabberCB { public: STDMETHODIMP BufferCB(double sampleTime, BYTE* buffer, long len) override { // 流线程:只拷贝,不做编码、不碰 UI std::lock_guard<std::mutex> lock(m_mutex); if (m_pending && buffer && len > 0) { m_frame.assign(buffer, buffer + len); // 只保留最新一帧 m_width = m_connectedWidth; m_height = m_connectedHeight; m_hasFrame = true; m_pending = false; // 一次性请求,抓完即清 } return S_OK; } STDMETHODIMP SampleCB(double, IMediaSample*) override { return S_OK; } // 简化写法,实际还要实现 QueryInterface/AddRef/Release private: std::mutex m_mutex; std::vector<BYTE> m_frame; bool m_pending = false; bool m_hasFrame = false; int m_width = 0, m_height = 0; int m_connectedWidth = 0, m_connectedHeight = 0; };

注册代码就三行,但顺序不能反:

// 先设媒体类型,再设回调,最后才 RenderStream grabber->SetMediaType(&mt); grabber->SetCallback(callback, 1); // 1 = BufferCB builder->RenderStream(...);

回调里的len是这一帧的总字节数,不一定等于width * height * 3,可能带行尾 padding。真正可靠的做法是在RenderStream成功之后调GetConnectedMediaType,从VIDEOINFOHEADER里读biWidth、biHeight和biSizeImage,把宽高存到回调对象里,拷贝时按biSizeImage校验长度。相机中途改分辨率时媒体类型会重协商,这两个值要跟着更新,否则抓出来的图会错位。

4.2 从 buffer 到 BMP:stride、上下翻转与文件头

BMP 的行宽按 4 字节对齐,RGB24 每像素 3 字节,所以一行实际占(width * 3 + 3) & ~3字节。SampleGrabber 给的 buffer 通常已经是这个对齐后的布局,但高度方向可能是自上而下,而 BMP 默认自下而上,写文件时得倒着写行。文件头两个结构加起来 54 字节,字段别填错,bfType是0x4D42,也就是字符BM。

// rgb 是回调拷出来的原始帧,按 stride 对齐 bool SaveBmp(const std::vector<BYTE>& rgb, int width, int height, const wchar_t* path) { const int stride = (width * 3 + 3) & ~3; // 4 字节对齐 const int imageSize = stride * height; const int fileSize = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER) + imageSize; BITMAPFILEHEADER fh = {}; fh.bfType = 0x4D42; // 'BM' fh.bfOffBits = sizeof(fh) + sizeof(BITMAPINFOHEADER); fh.bfSize = fileSize; BITMAPINFOHEADER ih = {}; ih.biSize = sizeof(ih); ih.biWidth = width; ih.biHeight = height; // 正数:自下而上 ih.biPlanes = 1; ih.biBitCount = 24; ih.biCompression = BI_RGB; ih.biSizeImage = imageSize; FILE* fp = _wfopen(path, L"wb"); if (!fp) return false; fwrite(&fh, sizeof(fh), 1, fp); fwrite(&ih, sizeof(ih), 1, fp); // 源数据自上而下,BMP 默认自下而上,倒序写行 for (int y = height - 1; y >= 0; --y) { fwrite(rgb.data() + y * stride, 1, stride, fp); } fclose(fp); return true; }

biHeight如果填负数,表示图像自上而下,这时候就不用倒序写,但有些看图软件对负高度支持不好,我一般老老实实倒序。bfOffBits是像素数据起始偏移,必须等于两个头的大小之和,填错文件直接打不开。写文件前用rgb.size() >= stride * height校验一下,长度不够说明媒体类型和缓存对不上,宁可返回失败也别写出半张图。

还有一个隐蔽问题:SampleGrabber输出 RGB24 时,三通道顺序是 B、G、R,不是 R、G、B。如果你在回调里又用 OpenCV 的Mat包一层做处理,记得cvtColor成 BGR,否则保存出来人脸是蓝的。这个坑我踩过一次,现场调试了半天以为是相机白平衡坏了。

4.3 抓图节流:一次性请求与最新帧覆盖

按下抓图按钮时,不要在 UI 线程里直接调GetCurrentBuffer,那个调用会锁住 Grabber,相机帧率越高丢帧越明显。正确姿势是 UI 只置一个m_pending = true,回调在下一帧到达时判断这个标志,拷贝完立刻清零。这样抓到的永远是“按下按钮之后的第一帧”,不会重复抓到同一张。

// UI 线程:只发请求,不碰数据 void RequestSnapshot() { std::lock_guard<std::mutex> lock(m_mutex); m_pending = true; } // 定时器里检查是否已经抓到,抓到再落盘 void PollSnapshot(const wchar_t* path) { std::vector<BYTE> frame; int w = 0, h = 0; { std::lock_guard<std::mutex> lock(m_mutex); if (!m_hasFrame) return; frame.swap(m_frame); // 把数据换出来,缩短锁持有时间 w = m_width; h = m_height; m_hasFrame = false; } SaveBmp(frame, w, h, path); // 编码和 IO 放在锁外 }

如果要做连拍,m_pending就不能是布尔值,改成计数器或者环形队列,回调每次减一,队列满时丢最旧的一帧。缓冲区别每帧assign新 vector,复用同一块内存,避免高频分配导致堆碎片。抓图频率超过相机帧率没有意义,UI 上做一下按钮防抖,200 毫秒内的重复点击直接忽略。

5. 排查清单:预览黑屏、抓图花屏、参数不生效的 5 个现场

这类项目跑不起来,报错信息往往只有一句HRESULT,真正的原因藏在调用顺序和引用计数里。下面五条是我在调试和帮人看代码时反复遇到的现场,每条按“现象、原因、解决”写,照着对照基本能定位。

5.1 现象:Run返回S_OK,预览窗口全黑

原因通常是RenderStream静默失败了,或者 Renderer 的 owner 窗口设错。IMediaControl::Run只负责切状态,不管图连没连对;put_Owner传了一个无效 HWND,或者窗口还没创建就建图,画面会画到看不见的地方。还有一种情况是相机被别的程序占用,Source Filter 加进去了但输出 pin 没数据。

解决:每一步都检查HRESULT,RenderStream失败时用builder->FindPin打印源过滤器的输出 pin 数量,确认相机真的暴露了 capture pin。窗口句柄在WM_CREATE之后再传,put_Owner之后补一句put_Visible(OATRUE)。用Null Renderer替换 Video Renderer 做对照,如果 Null 能跑通,问题就在窗口这一侧。

5.2 现象:抓图颜色错位,右边多一条花边

原因是 stride 算错。相机实际输出的行宽是(width * 3 + 3) & ~3,你按width * 3去读,每一行都错开几个字节,累积到右边就是彩色条纹。另一种情况是媒体类型没锁成 RGB24,驱动给了 YUY2,你按 RGB24 解析,颜色整个是乱的。

解决:RenderStream之后调GetConnectedMediaType,把VIDEOINFOHEADER里的biWidth、biHeight、biBitCount打日志,按实际值算 stride。如果biBitCount不是 24,检查SetMediaType是不是在RenderStream之前调的,或者相机根本不支持 RGB24,需要加 Color Converter。

5.3 现象:抓到的图和预览窗口差好几帧

原因是 Grabber 和 Renderer 串在同一条链路上,Renderer 有显示时钟和缓冲队列,Grabber 回调拿到的样本可能还在队列里排队。相机帧率低、渲染慢的时候,这个延迟会累积到几帧以上。

解决:用 Smart Tee 把 Source 的输出分成两路,Preview 接 Renderer,Capture 接 Grabber 加 Null Renderer,抓图分支不经过显示队列。Smart Tee 的PIN_CATEGORY_CAPTURE和PIN_CATEGORY_PREVIEW要分别RenderStream,Grabber 只挂在 Capture 分支上。改完之后抓图和屏幕基本同帧,代价是多一个过滤器,建图代码复杂一点。

5.4 现象:Set参数返回S_OK,画面纹丝不动

原因有三种:flags 带Auto,驱动把手动值覆盖了;参数设在 Preview pin 上而不是 Source Filter 上;相机在流运行中拒绝改参数。第一种最常见,GetRange出来的 flags 如果包含VideoProcAmp_Flags_Auto,必须先Set成Manual再设值。第二种是QueryInterface拿错了对象,IAMVideoProcAmp要从 Source Filter 拿,不是从 Grabber 或 Graph 拿。第三种在曝光上特别明显,部分 UVC 驱动要求先Stop。

解决:Set之后立刻Get回读,值和预期不一致就打印 flags。曝光类参数按“Stop→Set→Sleep(100)→Run”的顺序试一遍。如果回读一直是默认值,用IAMCameraControl换一个属性试,确认是单个属性不支持还是整个接口不支持。

5.5 现象:关窗口时崩溃,调用栈停在Release

原因是释放顺序错了。回调线程还在跑的时候把 callback 对象释放了,下一帧进来就是野指针;或者先释放了 Graph,再释放 Grabber,Grabber 内部的 pin 还在引用 Graph 的资源。这类崩溃在 Debug 下不一定复现,Release 下必现。

解决:退出顺序固定为IMediaControl::Stop→SetCallback(nullptr, 0)→ 释放 Grabber → 释放 Graph →CoUninitialize。Stop之后回调可能还有一帧在飞,等一个SetEvent或者Sleep(50)再置空回调。所有 COM 接口用CComPtr管理,别手动Release。窗口销毁放在WM_DESTROY里做,别在WM_CLOSE里就释放图,消息泵还在跑。

6. 二次开发:把回调改成图像处理管线,再加一路录像

这套代码跑通之后,最常见的改造需求是“抓图前先处理”和“边预览边录像”。抓图处理不用动链路,在BufferCB拷贝完之后、SaveBmp之前插一个处理函数就行,输入输出都是 RGB24 缓冲。我一般定义一个IFrameProcessor接口,Process(BYTE* data, int w, int h, int stride),灰度、边缘、二维码识别都实现这个接口,回调里按配置调用。注意处理耗时必须远小于帧间隔,1080p 的逐像素运算在流线程上跑会把帧率拖到个位数,重活要么降分辨率,要么把帧丢到工作线程异步做。

录像的改造点在链路:用 Smart Tee 分出 Capture 分支,接CLSID_AviDest(AVI Mux)再加CLSID_FileWriter,IFileSinkFilter::SetFileName指定输出路径,IMediaControl::Run之后 Mux 会自动写文件。抓图分支和录像分支可以共用同一个 Tee,互不影响。下面是我常用的改造对照:

需求改动点注意
抓图前做灰度/边缘回调内调用IFrameProcessor处理时间超过帧间隔就拆到工作线程
边预览边录像Smart Tee + AVI Mux + File Writer录像分支不要接 Renderer
多相机同时预览每个相机一套 Graph共用一个消息泵,窗口分开
参数预设JSON 存FriendlyName + DevicePath哈希换设备不串值,启动时回读校验

验证改造是否成功,我习惯加两个日志点:回调里记sampleTime和len,落盘时记文件大小和耗时。抓图花屏先看len和stride * height对不对得上;录像文件打不开先看 Mux 的输入 pin 有没有连上,RenderStream的返回值别丢。多相机场景下每个 Graph 的CoInitialize都在主线程做,回调线程只碰自己的缓冲,别共享全局变量。

从那以后我每次接采集类需求,都先把“Stop→ 置空回调 → 等一帧 →Release”这条退出顺序写成一个 RAII 小类,再动别的功能。抓图链路也一样,先把媒体类型和 stride 打日志确认,再谈图像处理。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询