增加了通过网络主动上报图像的支持

增加了指令维护通道的支持
This commit is contained in:
2026-01-07 10:59:03 +08:00
parent a697aab3e0
commit 3d47c8f009
47 changed files with 1613 additions and 1734 deletions

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Hosting;
using SHH.CameraSdk;
public class CameraEngineWorker : BackgroundService
{
private readonly CameraManager _manager;
public CameraEngineWorker(CameraManager manager)
{
// 理由:严谨性检查,防止因配置错误导致的空指针崩溃
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("[Engine] 正在启动核心引擎...");
try
{
// 1. 理由:启动 SDK 内部加载流程(从本地存储恢复设备)
await _manager.StartAsync();
Console.WriteLine("[Engine] 设备管理服务已启动。");
}
catch (Exception ex)
{
Console.WriteLine($"[Engine] 严重启动异常: {ex.Message}");
return; // 理由:核心组件失败,终止后续逻辑
}
// 2. 理由Worker 必须保持活跃状态,以便作为宿主生命周期的一部分
while (!stoppingToken.IsCancellationRequested)
{
// 你可以在这里定期输出一些状态统计
// Console.WriteLine($"[Engine] 活跃设备数: {_manager.GetActiveCount()}");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
Console.WriteLine("[Engine] 正在执行优雅停机...");
try
{
// 理由:这是重构的核心。必须在 SDK 退出前释放所有非托管句柄
await _manager.DisposeAsync();
}
finally
{
await base.StopAsync(cancellationToken);
}
}
}

View File

@@ -0,0 +1,73 @@
using NetMQ;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 负责将业务契约转换为 ZeroMQ 传输协议
/// </summary>
public static class NetMQProtocolExtensions
{
private const string PROTOCOL_HEADER = "SHH_V1";
/// <summary>
/// 扩展方法:将 Payload 转为 NetMQMessage
/// 使用方法var msg = payload.ToNetMqMessage();
/// </summary>
public static NetMQMessage ToNetMqMessage(this VideoPayload payload)
{
var msg = new NetMQMessage();
// Frame 0: 协议魔数
msg.Append(PROTOCOL_HEADER);
// Frame 1: 元数据 JSON
msg.Append(payload.GetMetadataJson());
// Frame 2: 原始图 (保持帧位对齐,无数据则发空帧)
if (payload.HasOriginalImage && payload.OriginalImageBytes != null)
msg.Append(payload.OriginalImageBytes);
else
msg.Append(Array.Empty<byte>());
// Frame 3: 处理图
if (payload.HasTargetImage && payload.TargetImageBytes != null)
msg.Append(payload.TargetImageBytes);
else
msg.Append(Array.Empty<byte>());
return msg;
}
/// <summary>
/// 扩展方法:从 NetMQMessage 还原 Payload
/// </summary>
public static VideoPayload ToVideoPayload(this NetMQMessage msg)
{
if (msg == null || msg.FrameCount < 4) return null;
// Frame 0 Check
if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null;
// Frame 1: Metadata
string json = msg[1].ConvertToString();
var payload = VideoPayload.FromMetadataJson(json);
if (payload == null) return null;
// Frame 2: Raw Image
// 利用 BufferSize 避免不必要的内存拷贝如果长度为0则跳过
if (payload.HasOriginalImage && msg[2].BufferSize > 0)
{
payload.OriginalImageBytes = msg[2].ToByteArray();
}
// Frame 3: Processed Image
if (payload.HasTargetImage && msg[3].BufferSize > 0)
{
payload.TargetImageBytes = msg[3].ToByteArray();
}
return payload;
}
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Hosting;
using NetMQ;
using NetMQ.Sockets;
namespace SHH.CameraService;
/// <summary>
/// NetMQ 发送工作者
/// 职责:从指定目标的 VideoDataChannel 读取 Payload通过 ZeroMQ 发送出去
/// </summary>
public class NetMqSenderWorker : BackgroundService
{
private readonly StreamTarget _target;
// 构造函数注入特定的目标对象 (由 Program.cs 的工厂方法提供)
public NetMqSenderWorker(StreamTarget target)
{
_target = target;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine($"[NetMqSender] 正在连接至服务端: {_target.Config.Endpoint} ...");
// ★★★ 修正点:必须使用 PublisherSocket 来配合接收端的 SubscriberSocket ★★★
// 虽然是 Connect 模式Publisher 依然可以 Connect
using var clientSocket = new PublisherSocket();
// 设置高水位 (HWM)
// 对于 Publisher如果队列满了默认行为就是丢弃旧数据这非常符合视频流需求
clientSocket.Options.SendHighWatermark = 1000;
// 主动连接
clientSocket.Connect(_target.Config.Endpoint);
Console.WriteLine("[NetMqSender] 连接成功,开始从通道搬运数据...");
await foreach (var payload in _target.Channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
var msg = payload.ToNetMqMessage();
// 发送消息
// PublisherSocket 的 TrySend 如果没人订阅或者队列满了,通常不会阻塞,而是直接丢弃或返回
// 注意PUB 模式下,第一帧 ("SHH_V1") 会被当作订阅的主题 (Topic)。
// 你的接收端订阅了 "" (空字符串),所以能收到以任何字符串开头的数据。
bool sent = clientSocket.TrySendMultipartMessage(msg);
if (!sent)
{
// 这种情况通常意味着网络断了且 HWM 队列也满了
Console.WriteLine($"[NetMqSender] 警告: 发送队列已满,正在丢帧...");
msg.Clear(); // 手动清理(可选)
}
}
catch (Exception ex)
{
Console.WriteLine($"[NetMqSender] 异常: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,126 @@
using Microsoft.Extensions.Hosting;
using OpenCvSharp;
using SHH.CameraSdk; // 引用 SDK 核心
using SHH.Contracts;
using System.Diagnostics;
namespace SHH.CameraService;
public class NetworkStreamingWorker : BackgroundService
{
// 注入所有注册的目标(云端、大屏等),实现动态分发
private readonly IEnumerable<StreamTarget> _targets;
// 编码参数JPG 质量 75 (平衡画质与带宽)
// 工业经验75 是甜点,体积只有 100 的 1/3肉眼几无区别。
// 如果您确实需要 100请注意带宽压力。此处我保留您要求的 100但建议未来调优。
private readonly int[] _encodeParams = { (int)ImwriteFlags.JpegQuality, 100 };
public NetworkStreamingWorker(IEnumerable<StreamTarget> targets)
{
_targets = targets;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("[StreamWorker] 启动流媒体采集引擎...");
// =========================================================
// 订阅逻辑:接入 "上帝模式" (God Mode)
// =========================================================
// 理由NetMQ 网关需要无差别地获取所有设备的图像。
GlobalStreamDispatcher.OnGlobalFrame += ProcessFrame;
//Console.WriteLine($"[StreamWorker] 已挂载至全局广播总线,正在监听 {GlobalStreamDispatcher.OnGlobalFrame?.GetInvocationList().Length ?? 0} 个订阅者...");
var tcs = new TaskCompletionSource();
stoppingToken.Register(() =>
{
// 停止时反注册,防止静态事件内存泄漏
GlobalStreamDispatcher.OnGlobalFrame -= ProcessFrame;
Console.WriteLine("[StreamWorker] 已断开全局广播连接");
tcs.SetResult();
});
return tcs.Task;
}
/// <summary>
/// [回调函数] 处理每一帧图像
/// 注意:此方法运行在 SDK 的采集线程池中,必须极速处理,严禁阻塞!
/// </summary>
private void ProcessFrame(long deviceId, SmartFrame frame)
{
try
{
// 1. 基础校验 (合法性检查)
if (frame == null || frame.InternalMat.Empty()) return;
long startTick = Stopwatch.GetTimestamp();
// =========================================================
// 2. 一次编码 (One Encode) - CPU 消耗点
// =========================================================
// 理由:在这里同步编码是最安全的,因为出了这个函数 frame 内存就会失效。
// 且只编一次,后续分发给 10 个目标也只用这一份数据。
byte[] jpgBytes = null;
// 如果有更小的图片, 原始图片不压缩, 除非有特殊需求
if (frame.TargetMat == null)
{
jpgBytes = EncodeImage(frame.InternalMat);
}
// 双流支持:如果存在处理后的 AI 图,也一并编码
byte[] targetBytes = null;
if (frame.TargetMat != null && !frame.TargetMat.Empty())
{
targetBytes = EncodeImage(frame.TargetMat);
}
// =========================================================
// 3. 构建 Payload (数据载荷)
// =========================================================
var payload = new VideoPayload
{
CameraId = deviceId.ToString(),
CaptureTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
OriginalImageBytes = jpgBytes, // 引用赋值
TargetImageBytes = targetBytes, // 引用赋值
OriginalWidth = frame.TargetWidth,
OriginalHeight = frame.TargetHeight,
DispatchTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
// 计算转码耗时(ms)
double processMs = (Stopwatch.GetTimestamp() - startTick) * 1000.0 / Stopwatch.Frequency;
payload.Diagnostics["encode_ms"] = Math.Round(processMs, 2);
// =========================================================
// 4. 动态扇出 (Dynamic Fan-Out) - 内存消耗极低
// =========================================================
// 遍历所有目标,往各自独立的管道里写数据。
// 实现了"物理隔离":一个管道满了(云端卡顿),不影响另一个管道(大屏流畅)。
foreach (var target in _targets)
{
// WriteLog 是非阻塞的。满了就丢弃,返回 false。
target.Channel.WriteLog(payload);
}
}
catch (Exception ex)
{
// 极少发生的内存错误,打印日志但不抛出,避免崩溃 SDK 线程
Console.WriteLine($"[StreamWorker] 采集处理异常: {ex.Message}");
}
}
/// <summary>
/// 辅助OpenCV 内存编码
/// </summary>
private byte[] EncodeImage(Mat mat)
{
// ImEncode 将 Mat 编码为一维字节数组 (托管内存)
Cv2.ImEncode(".jpg", mat, out byte[] buf, _encodeParams);
return buf;
}
}

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.Hosting;
using SHH.CameraSdk;
namespace SHH.CameraService;
/// <summary>
/// 图像处理管道配置服务(基于责任链模式)
/// <para>核心职责:</para>
/// <para>1. 组装图像处理集群的执行顺序,形成 "缩放 → 增强" 的固定流程</para>
/// <para>2. 将组装好的管道挂载到全局路由,统一接收驱动层输出的帧数据</para>
/// <para>设计说明:</para>
/// <para>- 采用责任链模式,支持动态扩展处理节点(如后续新增滤镜、裁剪等功能)</para>
/// <para>- 依赖 IHostedService 生命周期,确保在应用启动时完成管道初始化</para>
/// <para>- 与 GlobalPipelineRouter 强关联,是帧数据进入处理流程的唯一入口</para>
public class PipelineConfigurator : IHostedService
{
#region --- ---
/// <summary>
/// 图像缩放集群实例(责任链第一节点)
/// 功能:根据配置缩放帧分辨率、控制图像放大/缩小开关
/// </summary>
private readonly ImageScaleCluster _scale;
/// <summary>
/// 图像增强集群实例(责任链第二节点)
/// 功能:调整图像亮度、对比度等增强效果(基于 ProcessingConfigManager 配置)
/// </summary>
private readonly ImageEnhanceCluster _enhance;
#endregion
#region --- ---
/// <summary>
/// 初始化管道配置服务实例
/// </summary>
/// <param name="scale">图像缩放集群(通过 DI 注入,已预设并行度和配置管理器)</param>
/// <param name="enhance">图像增强集群(通过 DI 注入,已预设并行度和配置管理器)</param>
public PipelineConfigurator(ImageScaleCluster scale, ImageEnhanceCluster enhance)
{
_scale = scale;
_enhance = enhance;
}
#endregion
#region --- IHostedService ---
/// <summary>
/// 启动服务:组装责任链并挂载到全局路由
/// <para>执行时机:应用启动时,在所有 Singleton 服务初始化完成后触发</para>
/// </summary>
/// <param name="cancellationToken">服务停止令牌(用于响应应用关闭信号)</param>
/// <returns>异步任务(无返回值)</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
// 1. 建立责任链关系:缩放集群处理完成后,将帧数据传递给增强集群
// 设计逻辑Scale 是入口节点Enhance 是后续节点,可按需求插入更多处理节点
_scale.SetNext(_enhance);
// 2. 将责任链入口挂载到全局路由:驱动层输出的所有帧数据都会进入该管道
// 关键作用:统一帧数据处理入口,屏蔽驱动层与处理层的直接依赖
GlobalPipelineRouter.SetProcessor(_scale);
// 启动日志:打印管道组装结果,便于运维排查
Console.WriteLine("[Pipeline] 图像处理链组装完成: ImageScaleCluster -> ImageEnhanceCluster");
Console.WriteLine("[Pipeline] 提示:帧数据将按 '缩放 → 增强' 顺序处理,可通过 GlobalPipelineRouter 调整流程");
return Task.CompletedTask;
}
/// <summary>
/// 停止服务:空实现(无资源需要释放)
/// <para>说明:图像处理集群的资源释放由各自的 Dispose 方法管理,此处无需额外操作</para>
/// </summary>
/// <param name="cancellationToken">服务停止令牌</param>
/// <returns>空异步任务</returns>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
#endregion
}

View File

@@ -0,0 +1,40 @@
using System.Threading.Channels;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 视频数据内部总线 (线程安全的生产者-消费者通道)
/// <para>作用:解耦 [采集编码线程] 与 [网络发送线程]</para>
/// </summary>
public class VideoDataChannel
{
// 限制容量为 100 帧。如果积压超过 100 帧,说明发送端彻底堵死了,必须丢帧。
private readonly Channel<VideoPayload> _channel;
public VideoDataChannel(int capacity = 10)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.DropOldest, // 核心策略:满了就丢弃最旧的帧
SingleReader = false, // 允许多个发送 Worker (如 CloudWorker, ScreenWorker) 同时读取
SingleWriter = true // 只有一个采集线程在写
};
_channel = Channel.CreateBounded<VideoPayload>(options);
}
/// <summary>
/// [生产者] 写入一个封装好的数据包 (非阻塞)
/// </summary>
public void WriteLog(VideoPayload payload)
{
// TryWrite 永远不会等待,满了就丢旧的写入新的,返回 true
_channel.Writer.TryWrite(payload);
}
/// <summary>
/// [消费者] 读取器
/// </summary>
public ChannelReader<VideoPayload> Reader => _channel.Reader;
}
}