Cv2.ImShow 显示,增加线程安全处置逻辑,支持点击画面暂停、继续显示,关闭窗体注销动态流控的订阅
This commit is contained in:
@@ -1,63 +1,396 @@
|
||||
namespace SHH.CameraSdk;
|
||||
using OpenCvSharp;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// 动态窗口管理器
|
||||
/// 职责:根据业务指令动态创建/销毁 OpenCV 播放窗口,并管理流订阅
|
||||
/// </summary>
|
||||
public class DisplayWindowManager
|
||||
namespace SHH.CameraSdk
|
||||
{
|
||||
// 存储活跃的渲染器实例:Key = AppId (如 "UI_Preview_Main")
|
||||
private readonly ConcurrentDictionary<string, FrameConsumer> _activeWindows = new();
|
||||
|
||||
/// <summary>
|
||||
/// 开启一个本地播放窗口
|
||||
/// [终极版] 动态窗口管理器
|
||||
/// 核心修复:
|
||||
/// 1. 防止手动关闭窗口后,下一帧自动创建不可缩放的僵尸窗口
|
||||
/// 2. 确保关闭时流控策略 (FPS=0) 正确下发,释放帧调度资源
|
||||
/// 3. 新增鼠标点击暂停/恢复功能,支持单窗口帧流控制
|
||||
/// 4. 【关键修复】在 UILoop 中增加独立轮询机制,**暂停状态下也能检测窗口关闭**
|
||||
/// 核心特性:
|
||||
/// - 单线程UI渲染:规避 OpenCV GUI 多线程操作风险
|
||||
/// - 策略强联动:窗口生命周期与帧控制器订阅状态深度绑定
|
||||
/// - 资源闭环:自动清理订阅、销毁窗口、释放 Mat 内存,无内存泄漏
|
||||
/// </summary>
|
||||
/// <param name="appId">业务标识 (将作为窗口标题)</param>
|
||||
/// <param name="deviceId">要观看的设备ID</param>
|
||||
public void StartDisplay(string appId, int deviceId)
|
||||
public class DisplayWindowManager : IDisposable
|
||||
{
|
||||
// 如果窗口已存在,直接返回(防止重复创建)
|
||||
if (_activeWindows.ContainsKey(appId)) return;
|
||||
|
||||
Console.WriteLine($"[DisplayManager] 正在创建窗口: {appId} -> Device {deviceId}...");
|
||||
|
||||
// 1. 动态创建渲染器
|
||||
var renderer = new FrameConsumer(appId);
|
||||
|
||||
// 2. 启动渲染循环 (由于我们之前加了懒加载逻辑,此时不会立即弹窗,直到有帧数据过来)
|
||||
renderer.Start();
|
||||
|
||||
// 3. 存入字典管理
|
||||
if (_activeWindows.TryAdd(appId, renderer))
|
||||
#region --- 内部窗口上下文类 ---
|
||||
/// <summary>
|
||||
/// 单个窗口的上下文信息载体
|
||||
/// 存储设备关联、运行状态、回调函数等核心数据
|
||||
/// </summary>
|
||||
private class WindowContext
|
||||
{
|
||||
// 4. 【关键】建立数据订阅:将设备流导向这个渲染器
|
||||
GlobalStreamDispatcher.Subscribe(appId, deviceId, frame =>
|
||||
/// <summary> 关联的设备ID </summary>
|
||||
public int DeviceId { get; set; }
|
||||
/// <summary> 窗口暂停状态(volatile 保证多线程可见性) </summary>
|
||||
public volatile bool IsPaused;
|
||||
/// <summary> 鼠标事件回调函数 </summary>
|
||||
public MouseCallback MouseHandler;
|
||||
/// <summary> 窗口是否已实际创建(防止重复初始化) </summary>
|
||||
public bool IsWindowCreated;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region --- 私有成员变量 ---
|
||||
/// <summary> 活跃窗口注册表(线程安全) </summary>
|
||||
/// <remarks> Key = 窗口标识 AppId, Value = 窗口上下文 </remarks>
|
||||
private readonly ConcurrentDictionary<string, WindowContext> _activeWindows = new();
|
||||
|
||||
/// <summary> UI操作指令队列 </summary>
|
||||
/// <remarks> 容量限制100,防止渲染阻塞导致内存溢出 </remarks>
|
||||
private readonly BlockingCollection<Action> _uiActionQueue = new BlockingCollection<Action>(100);
|
||||
|
||||
/// <summary> UI渲染专属线程(长驻后台) </summary>
|
||||
private readonly Task _uiThread;
|
||||
|
||||
/// <summary> 全局取消令牌源:用于优雅终止渲染循环 </summary>
|
||||
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
||||
|
||||
/// <summary> 相机管理器实例:用于联动帧控制器的流控策略 </summary>
|
||||
private readonly CameraManager _cameraManager;
|
||||
#endregion
|
||||
|
||||
#region --- 构造与析构函数 ---
|
||||
/// <summary>
|
||||
/// 初始化动态窗口管理器
|
||||
/// </summary>
|
||||
/// <param name="cameraManager">相机管理器实例,用于帧策略联动</param>
|
||||
public DisplayWindowManager(CameraManager cameraManager)
|
||||
{
|
||||
_cameraManager = cameraManager;
|
||||
// 启动长驻UI线程,设置 LongRunning 提升调度优先级
|
||||
_uiThread = Task.Factory.StartNew(UILoop, TaskCreationOptions.LongRunning);
|
||||
Console.WriteLine("[DisplayManager] 渲染引擎就绪 (防僵尸窗口终极版)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放所有资源,优雅终止窗口管理器
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// 1. 发送取消信号,终止 UI 循环
|
||||
_cts.Cancel();
|
||||
|
||||
// 2. 等待 UI 线程退出(最多等待1秒,防止卡死)
|
||||
try { _uiThread.Wait(1000); }
|
||||
catch (Exception ex) { Console.WriteLine($"[DisplayManager] 线程退出异常: {ex.Message}"); }
|
||||
|
||||
// 3. 强制清理所有活跃窗口
|
||||
foreach (var appId in _activeWindows.Keys)
|
||||
{
|
||||
// 引用计数 +1,防止在渲染前被回收
|
||||
frame.AddRef();
|
||||
renderer.Enqueue(frame);
|
||||
});
|
||||
StopDisplay(appId);
|
||||
}
|
||||
|
||||
// 4. 清空 UI 指令队列,释放残留资源
|
||||
while (_uiActionQueue.TryTake(out var _)) { }
|
||||
|
||||
// 5. 销毁所有 OpenCV 残留窗口
|
||||
Cv2.DestroyAllWindows();
|
||||
|
||||
// 6. 释放核心组件资源
|
||||
_uiActionQueue.Dispose();
|
||||
_cts.Dispose();
|
||||
|
||||
Console.WriteLine("[DisplayManager] 渲染引擎已安全销毁");
|
||||
}
|
||||
else
|
||||
#endregion
|
||||
|
||||
#region --- 窗口生命周期管理 ---
|
||||
/// <summary>
|
||||
/// 启动指定设备的本地显示窗口
|
||||
/// </summary>
|
||||
/// <param name="appId">窗口唯一标识</param>
|
||||
/// <param name="deviceId">关联的设备ID</param>
|
||||
public void StartDisplay(string appId, int deviceId)
|
||||
{
|
||||
renderer.Dispose(); // 并发冲突处理
|
||||
}
|
||||
}
|
||||
// 防重入:已存在该窗口则直接返回
|
||||
if (_activeWindows.ContainsKey(appId)) return;
|
||||
|
||||
/// <summary>
|
||||
/// 关闭并销毁窗口
|
||||
/// </summary>
|
||||
public void StopDisplay(string appId)
|
||||
{
|
||||
if (_activeWindows.TryRemove(appId, out var renderer))
|
||||
Console.WriteLine($"[DisplayManager] 正在启动窗口: {appId} -> Device {deviceId}");
|
||||
|
||||
// 初始化窗口上下文
|
||||
var context = new WindowContext
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
IsPaused = false,
|
||||
IsWindowCreated = false
|
||||
};
|
||||
|
||||
// 注册鼠标左键回调:切换暂停/恢复状态
|
||||
context.MouseHandler = (mouseEvent, x, y, flags, userData) =>
|
||||
{
|
||||
if (mouseEvent == MouseEventTypes.LButtonDown)
|
||||
{
|
||||
context.IsPaused = !context.IsPaused;
|
||||
Console.WriteLine($"[DisplayManager] 窗口 {appId} 状态切换: {(context.IsPaused ? "暂停" : "恢复")}");
|
||||
}
|
||||
};
|
||||
|
||||
// 线程安全地添加窗口上下文
|
||||
if (_activeWindows.TryAdd(appId, context))
|
||||
{
|
||||
// 步骤1:投递窗口初始化指令到 UI 线程
|
||||
_uiActionQueue.TryAdd(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 创建可缩放窗口,并设置初始尺寸 640x360
|
||||
Cv2.NamedWindow(appId, WindowFlags.Normal | WindowFlags.GuiExpanded);
|
||||
Cv2.ResizeWindow(appId, 640, 360);
|
||||
// 绑定鼠标事件回调
|
||||
Cv2.SetMouseCallback(appId, context.MouseHandler);
|
||||
// 标记窗口已实际创建
|
||||
context.IsWindowCreated = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DisplayManager] 窗口 {appId} 初始化失败: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// 步骤2:订阅设备帧数据流
|
||||
GlobalStreamDispatcher.Subscribe(appId, deviceId, frame =>
|
||||
{
|
||||
// 快速判断:取消/暂停/窗口已移除,直接跳过帧处理
|
||||
if (_cts.IsCancellationRequested || context.IsPaused || !_activeWindows.ContainsKey(appId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Mat frameClone = null;
|
||||
try
|
||||
{
|
||||
// 深拷贝帧数据:原帧属于解码线程,必须克隆后移交 UI 线程
|
||||
frameClone = frame.InternalMat.Clone();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DisplayManager] 帧克隆失败: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 投递帧显示指令到 UI 线程
|
||||
bool enqueued = _uiActionQueue.TryAdd(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 双重检查:窗口上下文是否存在 + 窗口是否已创建
|
||||
if (_activeWindows.TryGetValue(appId, out var ctx) && ctx.IsWindowCreated)
|
||||
{
|
||||
// 最后一道防线:检测窗口是否被手动关闭
|
||||
if (Cv2.GetWindowProperty(appId, WindowPropertyFlags.Visible) < 1.0)
|
||||
{
|
||||
Task.Run(() => StopDisplay(appId));
|
||||
return;
|
||||
}
|
||||
// 执行帧显示
|
||||
Cv2.ImShow(appId, frameClone);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 强制释放克隆的 Mat 内存,杜绝内存泄漏
|
||||
frameClone?.Dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// 队列满时的兜底处理:手动释放克隆帧
|
||||
if (!enqueued) frameClone?.Dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止并销毁指定的显示窗口
|
||||
/// </summary>
|
||||
/// <param name="appId">窗口唯一标识</param>
|
||||
public void StopDisplay(string appId)
|
||||
{
|
||||
Console.WriteLine($"[DisplayManager] 正在关闭窗口: {appId}");
|
||||
// 从注册表中移除窗口上下文
|
||||
if (_activeWindows.TryRemove(appId, out var context))
|
||||
{
|
||||
Console.WriteLine($"[DisplayManager] 正在清理窗口资源: {appId}");
|
||||
|
||||
// 1. 取消订阅 (停止接收数据)
|
||||
GlobalStreamDispatcher.Unsubscribe(appId);
|
||||
// 步骤1:立即取消帧数据流订阅
|
||||
GlobalStreamDispatcher.Unsubscribe(appId);
|
||||
|
||||
// 2. 销毁渲染器 (OpenCV DestroyWindow 会在 FrameConsumer 内部触发)
|
||||
renderer.Dispose();
|
||||
// 步骤2:投递窗口销毁指令到 UI 线程
|
||||
_uiActionQueue.TryAdd(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Cv2.DestroyWindow(appId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DisplayManager] 窗口 {appId} 销毁失败: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// 步骤3:关键操作 - 下发流控策略(FPS=0),释放帧控制器资源
|
||||
UpdateDevicePolicy(context.DeviceId, appId, 0);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region --- 窗口状态控制 ---
|
||||
/// <summary>
|
||||
/// 暂停指定窗口的帧显示
|
||||
/// </summary>
|
||||
/// <param name="appId">窗口唯一标识</param>
|
||||
public void PauseDisplay(string appId)
|
||||
{
|
||||
if (_activeWindows.TryGetValue(appId, out var ctx))
|
||||
{
|
||||
ctx.IsPaused = true;
|
||||
Console.WriteLine($"[DisplayManager] 窗口 {appId} 已暂停");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 恢复指定窗口的帧显示
|
||||
/// </summary>
|
||||
/// <param name="appId">窗口唯一标识</param>
|
||||
public void ResumeDisplay(string appId)
|
||||
{
|
||||
if (_activeWindows.TryGetValue(appId, out var ctx))
|
||||
{
|
||||
ctx.IsPaused = false;
|
||||
Console.WriteLine($"[DisplayManager] 窗口 {appId} 已恢复");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region --- 流控策略联动 ---
|
||||
/// <summary>
|
||||
/// 更新设备的帧订阅策略,与帧控制器强联动
|
||||
/// </summary>
|
||||
/// <param name="deviceId">设备ID</param>
|
||||
/// <param name="appId">订阅标识(窗口ID)</param>
|
||||
/// <param name="fps">目标帧率(0表示取消订阅)</param>
|
||||
private void UpdateDevicePolicy(int deviceId, string appId, int fps)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取设备实例
|
||||
var device = _cameraManager.GetDevice(deviceId);
|
||||
if (device == null)
|
||||
{
|
||||
Console.WriteLine($"[策略联动] 设备 {deviceId} 不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取设备内置的帧控制器
|
||||
var frameController = device.Controller;
|
||||
if (frameController == null)
|
||||
{
|
||||
Console.WriteLine($"[策略联动] 设备 {deviceId} 未配置帧控制器");
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据帧率更新订阅状态
|
||||
if (fps > 0)
|
||||
{
|
||||
frameController.Register(appId, fps);
|
||||
Console.WriteLine($"[策略联动] ✅ 已注册流控: {appId} -> {fps} FPS");
|
||||
}
|
||||
else
|
||||
{
|
||||
frameController.Unregister(appId);
|
||||
Console.WriteLine($"[策略联动] 🗑️ 已注销流控: {appId}");
|
||||
}
|
||||
|
||||
// 记录审计日志,用于前端排查问题
|
||||
device.AddAuditLog($"UI播放窗口状态变更: {appId} -> {(fps > 0 ? $"{fps}fps" : "停止")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[策略联动] ❌ 联动失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region --- 核心渲染循环 ---
|
||||
/// <summary>
|
||||
/// 单线程UI渲染循环,所有 OpenCV GUI 操作的唯一入口
|
||||
/// </summary>
|
||||
private void UILoop()
|
||||
{
|
||||
// 轮询计数器:控制窗口状态检查频率
|
||||
int checkCounter = 0;
|
||||
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 阶段1:处理 UI 指令队列
|
||||
int processedCount = 0;
|
||||
// 单次循环最多处理10条指令,避免长时间占用线程
|
||||
while (processedCount < 10 && _uiActionQueue.TryTake(out var action, 5))
|
||||
{
|
||||
action.Invoke();
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
// 阶段2:执行 OpenCV 消息泵,防止窗口无响应
|
||||
Cv2.WaitKey(1);
|
||||
|
||||
// 阶段3:【核心修复】独立轮询检测窗口状态
|
||||
// 每20次循环(约200ms)检查一次,不受帧渲染暂停影响
|
||||
checkCounter++;
|
||||
if (checkCounter >= 20)
|
||||
{
|
||||
checkCounter = 0;
|
||||
CheckClosedWindows();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[UI] 渲染循环异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 循环退出时,销毁所有残留窗口
|
||||
Cv2.DestroyAllWindows();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查所有活跃窗口的状态,自动清理被手动关闭的窗口
|
||||
/// 【必须在 UI 线程调用】
|
||||
/// </summary>
|
||||
private void CheckClosedWindows()
|
||||
{
|
||||
// 获取当前活跃窗口ID的快照,避免遍历过程中集合被修改
|
||||
var activeIds = _activeWindows.Keys.ToArray();
|
||||
|
||||
foreach (var appId in activeIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
// GetWindowProperty 必须在 UI 线程执行
|
||||
// Visible < 1.0 表示窗口已被用户手动关闭
|
||||
if (Cv2.GetWindowProperty(appId, WindowPropertyFlags.Visible) < 1.0)
|
||||
{
|
||||
Console.WriteLine($"[UI] 检测到窗口 {appId} 已被手动关闭,触发清理...");
|
||||
// 异步清理:避免 StopDisplay 内部的队列操作阻塞 UI 线程
|
||||
Task.Run(() => StopDisplay(appId));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 捕获异常:通常是窗口已不存在,直接触发清理
|
||||
Task.Run(() => StopDisplay(appId));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user