大家好,我是名侦探橘雪莉!今天追踪的线索不是脚印,而是一帧图像从相机传感器一路到 WPF 屏幕的旅程。实时预览明明一直收到新图,发布版却像静止画;鼠标一经过,画面又突然更新。要解开这个谜题,得把三个容易混为一谈的概念分开:像素数据何时写入、WPF 何时收到渲染请求、帧缓冲何时失效。
1. 先画出实时预览的数据流
典型的工业相机预览流程可以简化成:
相机驱动 → FrameQueueSink 回调 → 像素拷贝 → WriteableBitmap → WPF 合成器 → 屏幕
这里至少有三个不同的“完成”时刻:
- 驱动把一帧写进 sink 提供的缓冲区;
- 应用把缓冲区内容复制进显示位图,并标记变更区域;
- WPF 将更新后的位图纹理合成并提交到屏幕。
前一步完成,并不代表后一步自动发生。很多“相机没有刷新”的问题,实际上是第三步缺少渲染请求。
2. AddDirtyRect 与 InvalidateVisual 不是同一件事
WriteableBitmap.AddDirtyRect 告诉位图哪些像素区域已修改。它解决的是“哪些数据变了”,不是“现在请 WPF 重新绘制这个视觉元素”。
如果 Image.Source 绑定一直指向同一个 WriteableBitmap 实例,属性值没有变化,绑定系统也就没有新的 Source 变化可通知。界面没有其他布局或视觉失效时,新像素可能要等下一次渲染机会才显示。鼠标移动常常会更新坐标、标尺或选区等绑定属性,意外触发视觉失效,于是看起来像是鼠标把相机画面“唤醒”了。
正确的线索是:像素更新完成后,由显示层明确请求一次渲染。不要每隔几十毫秒无条件刷新整个视图,也不要依赖鼠标、动画或其他控件恰好引发重绘。
按帧更新通知视图
让负责写像素的服务在更新完成后发出事件:
public event Action<WriteableBitmap>? FrameUpdated;
public void OnImageRefresh(IFrameQueueBuffer frame)
{
WPFHelper.UIDo(() =>
{
WriteableBitmap bitmap = GetOrCreateBitmap(frame.FrameType);
bitmap.Lock();
try
{
CopyFramePixels(frame, bitmap);
bitmap.AddDirtyRect(
new Int32Rect(0, 0, bitmap.PixelWidth, bitmap.PixelHeight));
}
finally
{
bitmap.Unlock();
}
FrameUpdated?.Invoke(bitmap);
});
}
WPFHelper.UIDo 在这里的重点不是“所有相机处理都应该在 UI 线程”,而是这个项目中的 WriteableBitmap 创建和写入操作要遵循 UI Dispatcher 的线程要求。曾尝试把像素拷贝移到采集线程,但目标运行环境下并不能正确工作;不能只凭某个 API 的名称或泛化经验推断线程安全,必须在实际 WPF 渲染路径上验证。
视图只需订阅这个事件,并对图像元素发起失效请求:
public UImage(UImageViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
imageManager = viewModel.ImageManager;
Loaded += (_, _) => imageManager.FrameUpdated += OnFrameUpdated;
Unloaded += (_, _) => imageManager.FrameUpdated -= OnFrameUpdated;
}
private void OnFrameUpdated(WriteableBitmap bitmap)
{
ImageControl.InvalidateVisual();
}
由于通知从 UI Dispatcher 上发出,回调可以直接访问视图元素,不需要再多派发一次。订阅跟随 Loaded/Unloaded,视图离开视觉树后就不再持有订阅。
3. 为什么固定 30Hz 刷新不是好替代
后台任务每 33 毫秒调用一次 InvalidateVisual,看上去简单,但它跟相机帧率和视图生命周期没有关系:
- 没有新帧时也持续触发刷新;
- 相机帧率与 30Hz 不一致时,刷新请求会和帧到达错开;
- 视图卸载后任务若未取消,仍持有控件引用并继续运行;
- 高分辨率下,反复使整个视图失效会增加不必要的 UI 与合成负担。
帧事件更贴合需求:有新帧才请求刷新,图像无人查看时也可以随着视图卸载停止通知。侦探结论:刷新应由数据变化驱动,而不是由一个永不休庭的定时器驱动。
4. 优化 UI 线程上的像素拷贝
如果相机输出约为 2592 × 1944、每像素 4 字节,一帧数据接近 20 MiB。让这次拷贝在 UI 线程上执行,确实会占用 UI 线程时间。因此应先减少不必要的工作,再基于目标机器测量:
- 按相机实际尺寸复用同一个
WriteableBitmap,只在首帧或分辨率变化时创建; - 确认像素格式、源 stride 和目标 stride,避免逐像素转换;
- stride 相同且无垂直翻转时,用一次连续内存拷贝替代每行一次调用;
- 只有 BottomUp 帧或行距不同才逐行复制;
- 避免在帧回调中做缩放、编码、磁盘写入等与显示无关的慢操作。
if (!isBottomUp && sourceStride == destinationStride)
{
Buffer.MemoryCopy(
source,
destination,
(long)destinationStride * height,
(long)sourceStride * height);
}
else
{
for (int y = 0; y < height; y++)
{
Buffer.MemoryCopy(
source + y * sourceStride,
destination + y * destinationStride,
destinationStride,
width * 4);
}
}
这不是“拷贝越快就一定越流畅”的保证。还要测量帧到达率、UI Dispatcher 排队时间、每帧拷贝耗时和实际呈现帧率。要是显示需求只有 30 帧/秒,而相机送来 120 帧/秒,合理的丢帧/节流策略可能比让 UI 消化所有帧更合适。
5. 帧缓冲的生命周期:回调返回后就别再碰
FrameQueueSink 会重用输入缓冲区。回调返回并以 FrameQueuedResult.ReQueue 归还后,下一帧就可能覆盖同一块内存。因此:
- 不要把
frame.Ptr或缓冲区对象交给异步队列稍后拷贝; - 需要显示时,在回调返回前把像素复制到自己管理的位图;
- 需要保存某一帧时,也必须在缓冲区仍有效时完成保存或复制到独立内存。
这条约束解释了为什么“将帧指针排到 UI 线程以后再处理”是不正确的。若同步派发到 UI 线程,采集回调会等 UI 线程完成复制;若异步派发,回调可能先返回并归还缓冲区,UI 线程收到任务时数据已变成下一帧。两种时序的结果不同,必须选择与缓冲区生命周期相符的做法。
6. 设备掉线与重连:区分“设备失效”和“设备列表改变”
实时图像程序还要区分物理设备状态与视频信号状态。USB 相机被拔出是设备失效;模拟采集设备仍然在线但无输入信号,则应检查驱动是否支持 signal detection。这两者不能用同一个布尔状态混为一谈。
IC Imaging Control 的 DeviceLost 用于处理设备失效,DeviceListChanged 用于发现可用设备列表变化。重连时应清理失效设备、在可用设备中按序列号找回目标相机,并把设备打开和采集流配置放到正确的 UI 线程上下文。循环重试还应可取消,并记录断开、重试和恢复时刻。
private void OnDeviceLost(object? sender, DeviceLostEventArgs e)
{
IsConnected = false;
// 停止采集,并按 IC Imaging Control 文档清空失效的设备选择
CloseDevice();
StartReconnect();
}
private void OnDeviceListChanged(object? sender, EventArgs e)
{
if (!IsConnected && !IsReconnecting)
StartReconnect();
}
重连成功后恢复采集前,确认目标设备身份和视频格式、通道、帧率等设置。仅比较设备名称可能会连到另一台同型号相机,序列号更适合用于识别“原来的那一台”。
7. 抓拍也要遵守缓冲区有效期
实时预览使用 FrameQueueSink 时,再把当前 sink 强制转换成 FrameSnapSink 不会得到有效对象。一个更合适的模型是:注册一次抓拍请求,在下一帧回调里消费请求,并在缓冲区归还前保存该帧。请求需要具备单次消费语义、超时和异常日志;回调不应吞掉磁盘错误。
如果用户真正需要的是“当前显示的画面”,直接从应用持有的位图保存;如果需要“请求相机给我一张新帧”,则从帧队列取下一帧。明确这两个语义,才能避免保存到旧帧或错误的 sink。
检查清单
AddDirtyRect标记像素区域;InvalidateVisual请求视觉元素重新绘制,职责不同。- 在创建位图的 UI Dispatcher 上完成当前环境要求的
WriteableBitmap操作。 - 让新帧事件触发必要的元素失效,避免常驻刷新定时器。
- 在帧回调返回前完成使用 sink 缓冲区的操作。
- 设备重连使用设备列表变化通知、序列号识别、可取消的重试和可读日志。
- 在发布模式和目标硬件上测量,不把调试器下的表现当成最终证据。
AAO!线索都对上了:图像数据写进位图、WPF 被请求渲染、帧缓冲安全归还——三件事各有自己的时机。把它们拆开观察,实时预览的谜题就没那么神秘啦。
——名侦探橘雪莉