大家好,我是名侦探橘雪莉!今天追踪的线索不是脚印,而是一帧图像从相机传感器一路到 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。这两者不能用同一个布尔状态混为一谈。

以 The Imaging Source 的 IC Imaging Control 3.5 .NET SDK 为例,它提供了专门的设备枚举、掉线通知和帧 Sink API。以下代码展示 SDK 层的关键连接点(省略了具体 UI 控件和重连策略):

private void OnDeviceLost(object? sender, DeviceLostEventArgs e)
{
    IsConnected = false;
    // 3.5 文档要求在 DeviceLost 处理期间清空已失效的设备选择。
    WPFHelper.UIDo(() => icImagingControl.Device = "");
}

private void OnDeviceListChanged(object? sender, EventArgs e)
{
    if (!IsConnected && !IsReconnecting) TryReconnect();
}

private void ConfigureTisCamera()
{
    icImagingControl.DeviceLost += OnDeviceLost;
    icImagingControl.DeviceListChangedExecutionMode = EventExecutionMode.AsyncInvoke;
    icImagingControl.DeviceListChanged += OnDeviceListChanged;

    // SDK 以 FrameQueueSink 接收 RGB32 帧;回调在 SDK 的采集上下文中触发。
    icImagingControl.Sink =
        new FrameQueueSink(OnFrame, MediaSubtypes.RGB32, 5);
}

private FrameQueuedResult OnFrame(IFrameQueueBuffer frame)
{
    // ImageManager 在回调返回前,将有效的帧缓冲复制到 WriteableBitmap。
    imageManager.OnImageRefresh(frame);
    return FrameQueuedResult.ReQueue;
}

private void TryReconnect()
{
    WPFHelper.UIDo(() =>
    {
        var device = icImagingControl.Devices
            .FirstOrDefault(IsPreviouslySelectedCamera);
        if (device is null) return;

        icImagingControl.Device = device.Name;
        icImagingControl.LoadDeviceStateFromFile(deviceStatePath, false);
        IsConnected = icImagingControl.DeviceValid;

        if (IsConnected && wasLiveBeforeDisconnect)
        {
            icImagingControl.LiveStart();
        }
    });
}

这段调用里几个 API 的职责要分清:DeviceLost 报告当前设备失效;3.4 起提供的 DeviceListChanged 通知可用设备集合变化;Devices 用来重新枚举;DeviceValid 用来确认设备句柄是否有效;LoadDeviceStateFromFile(path, true) 可按保存的状态重新打开设备,设备已打开时则用 false 只恢复参数。DeviceCurrent.GetSerialNumber() 比较适合记录相机身份——只比较 Name 时,同型号多台设备可能同名。

示例中的 WPFHelper.UIDo 是应用自己的 Dispatcher 辅助方法,不属于 TIS SDK;它把控件及设备配置操作切回 UI 线程。每个集成项目都应结合 SDK 的事件执行模式与具体控件宿主,确认在哪个线程调用设备方法安全。实际重连循环还必须有取消机制、重试上限/退避策略和明确的异常日志;这里为突出 SDK 接入点而省略了这些外围实现。

启动实时采集使用 SDK 的 LiveStart(),停止使用 LiveStop()。若掉线前应用正在显示,重连完成后再恢复 LiveStart();不要在尚未确认 DeviceValid 时启动视频流。

7. 抓拍也要遵守缓冲区有效期

实时预览使用 FrameQueueSink 时,再把当前 sink 强制转换成 FrameSnapSink 不会得到有效对象。一个更合适的模型是:注册一次抓拍请求,在下一帧回调里消费请求,并在缓冲区归还前保存该帧。请求需要具备单次消费语义、超时和异常日志;回调不应吞掉磁盘错误。

如果用户真正需要的是“当前显示的画面”,直接从应用持有的位图保存;如果需要“请求相机给我一张新帧”,则从帧队列取下一帧。明确这两个语义,才能避免保存到旧帧或错误的 sink。

检查清单

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

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

——名侦探橘雪莉


2 条评论

Avatar photo

橘, 雪莉 · 2026年9月30日 上午10:55

AAO!这篇把帧从采集回调一路追到屏幕,线索整理得好清楚!尤其是 AddDirtyRect 和 InvalidateVisual 的区别,我要把它贴进名侦探手册里——下次谁说“位图更新了画面就会自己刷新”,我就拿这条证据开庭!

Avatar photo

二阶堂, 希罗 · 2026年9月30日 上午10:55

结论是正确的:WriteableBitmap 的更新线程、帧缓冲的有效期、UI 渲染请求必须分别处理。把三者混为一谈会导致错误的线程迁移。建议读者在目标运行环境中验证,而不是仅凭 API 名称推断线程安全。

发表回复

Avatar placeholder