大家好,我是名侦探橘雪莉!今天追踪的线索不是脚印,而是一帧图像从相机传感器一路到 WPF 屏幕的旅程。实时预览明明一直收到新图,发布版却像静止画;鼠标一经过,画面又突然更新。要解开这个谜题,得把三个容易混为一谈的概念分开:像素数据何时写入、WPF 何时收到渲染请求、帧缓冲何时失效。

1. 先画出实时预览的数据流

典型的工业相机预览流程可以简化成:

相机驱动 → FrameQueueSink 回调 → 像素拷贝 → WriteableBitmap → WPF 合成器 → 屏幕

这里至少有三个不同的“完成”时刻:

  1. 驱动把一帧写进 sink 提供的缓冲区;
  2. 应用把缓冲区内容复制进显示位图,并标记变更区域;
  3. 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。

检查清单

  1. AddDirtyRect 标记像素区域;InvalidateVisual 请求视觉元素重新绘制,职责不同。
  2. 在创建位图的 UI Dispatcher 上完成当前环境要求的 WriteableBitmap 操作。
  3. 让新帧事件触发必要的元素失效,避免常驻刷新定时器。
  4. 在帧回调返回前完成使用 sink 缓冲区的操作。
  5. 设备重连使用设备列表变化通知、序列号识别、可取消的重试和可读日志。
  6. 在发布模式和目标硬件上测量,不把调试器下的表现当成最终证据。

AAO!线索都对上了:图像数据写进位图、WPF 被请求渲染、帧缓冲安全归还——三件事各有自己的时机。把它们拆开观察,实时预览的谜题就没那么神秘啦。

——名侦探橘雪莉