算法与UI拆分中遇到的问题及解决方式

背景

之前一直都是算法集成到UI中以一个完整软件的形式发布,但算法业务几乎是定制型的,导致算法的内部实现一直在变化,虽然输入输出的结构保持稳定,但每次都必须重新部署整个程序,感觉上有点别扭,所以尝试做拆分。

方案

算法跟UI以RPC协议来通讯,其基于TCP,稳定可靠,且c#有成熟的实现库gRPC,无需手搓轮子,是比较好的方案。

实现

三个项目:

1、GrpcLearn.Shared类库项目安装Grpc.AspNetCore,创建Protos文件夹,放置yolo.proto文件,并修改csproj文件根据Protos文件夹下的所有proto文件自动生成对应的客户端跟服务端基类。

2、GrpcLearn.ServergRPC项目卸载自带的Grpc.AspNetCore,并引用共享类库GrpcLearn.Shared,为了注册后面成windows服务,需要安装Microsoft.Extensions.Hosting.WindowsServices,为了使用Onnx需要安装Microsoft.ML.OnnxRuntime【如需使用gpu的需安装对应的库】,为了使用Opencv安装OpenCvSharp4+OpenCvSharp4.runtime.win【因为我用的windows系统】

下载一个yolov26的onnx文件,我在[yolov26 onnx](https://huggingface.co/besit/yolo-onnx/tree/main "yolov26 onnx")下载了n模型【这个貌似需要外网,如果没有vpn可以到别的地方下载,或者自己用py把pt文件转成onnx也行,不一定到我推荐的链接里找,但一定要是yolov26的,不然后面的代码可能会报错,因为这个自带了end2end,结果无需再使用NMS过滤重合的判定框】,把yolov26n.onnx放到Onnxs文件夹下,改名为yolo26.onnx,并设置属性,在生成解决方案时始终复制到输出目录中。

创建Yolo检测类到Detectors文件夹中

cs 复制代码
using ComputerVisionAlgorithm;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;

namespace GrpcLearn.Server.Detectors
{
    public class Yolov26Detector : IDisposable
    {
        private readonly InferenceSession _session;
        private readonly string _inputName;
        private const int InputSize = 640;
        private const float ConfidenceThreshold = 0.25f;

        public Yolov26Detector(string onnxPath)
        {
            // 1. 初始化推理会话(启用全图优化)
            var options = new Microsoft.ML.OnnxRuntime.SessionOptions
            {
                GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL
            };
            _session = new InferenceSession(onnxPath, options);
            _inputName = _session.InputNames[0];
        }

        /// <summary>
        /// 对单张图像执行检测,返回 Protobuf 响应
        /// </summary>
        public DetectResponse Detect(Mat originalImage)
        {
            // 2. Letterbox 预处理
            var (inputTensor, ratio, padW, padH) = Preprocess(originalImage);

            // 3. 构建输入容器并推理
            var inputs = new List<NamedOnnxValue>
            {
                NamedOnnxValue.CreateFromTensor(_inputName, inputTensor)
            };

            using var results = _session.Run(inputs);

            // 4. 获取输出 [1, 300, 6]
            // 推荐直接通过 GetTensor<float> 获取 DenseTensor 以便多维索引
            var outputTensor = results[0].AsTensor<float>();
            // 或者使用 results[0].GetTensor<float>() 得到 DenseTensor<float>

            // 5. 解析并填充 Protobuf
            return Postprocess(outputTensor, ratio, padW, padH);
        }

        /// <summary>
        /// Letterbox 预处理:保持比例缩放 + 灰边填充(114),返回 NCHW 张量
        /// </summary>
        private (DenseTensor<float> tensor, float ratio, int padW, int padH) Preprocess(Mat src)
        {
            int srcW = src.Width, srcH = src.Height;
            float ratio = Math.Min((float)InputSize / srcW, (float)InputSize / srcH);
            int newW = (int)(srcW * ratio);
            int newH = (int)(srcH * ratio);
            int padW = (InputSize - newW) / 2;
            int padH = (InputSize - newH) / 2;

            // 缩放并填充到 640x640(灰色 114 填充,符合 YOLO 官方行为)
            using var resized = new Mat();
            Cv2.Resize(src, resized, new Size(newW, newH));
            using var canvas = new Mat(new Size(InputSize, InputSize), MatType.CV_8UC3, new Scalar(114, 114, 114));
            using var roi = new Mat(canvas, new Rect(padW, padH, newW, newH));
            resized.CopyTo(roi);

            // 转为 RGB 并归一化到 [0,1],构造 NCHW 张量
            using var rgb = new Mat();
            Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB);

            var tensor = new DenseTensor<float>([1, 3, InputSize, InputSize]);
            // 使用 Span 高效填充,避免多次数组拷贝
            var span = tensor.Buffer.Span;
            unsafe
            {
                byte* ptr = (byte*)rgb.DataPointer;
                int channelStride = InputSize * InputSize;
                for (int i = 0; i < channelStride; i++)
                {
                    span[i] = ptr[i * 3] / 255.0f;               // R
                    span[channelStride + i] = ptr[i * 3 + 1] / 255.0f; // G
                    span[2 * channelStride + i] = ptr[i * 3 + 2] / 255.0f; // B
                }
            }

            return (tensor, ratio, padW, padH);
        }

        /// <summary>
        /// 后处理:过滤置信度 + 坐标还原 + 填充 Protobuf
        /// </summary>
        private static DetectResponse Postprocess(Tensor<float> output, float ratio, int padW, int padH)
        {
            var response = new DetectResponse();
            // output 形状为 [1, 300, 6],索引 [0, i, c]
            int numBoxes = output.Dimensions[1]; // 300

            for (int i = 0; i < numBoxes; i++)
            {
                float conf = output[0, i, 4];
                if (conf < ConfidenceThreshold) continue;

                // 提取模型输出的 letterbox 坐标
                float x1 = output[0, i, 0];
                float y1 = output[0, i, 1];
                float x2 = output[0, i, 2];
                float y2 = output[0, i, 3];

                // 坐标还原:先减 padding,再除以缩放比例
                x1 = (x1 - padW) / ratio;
                y1 = (y1 - padH) / ratio;
                x2 = (x2 - padW) / ratio;
                y2 = (y2 - padH) / ratio;

                // 可选:将坐标裁剪到原图范围内,防止越界
                // x1 = Math.Clamp(x1, 0, originalWidth); ... 此处略,按需添加

                response.Detections.Add(new Detection
                {
                    X1 = x1,
                    Y1 = y1,
                    X2 = x2,
                    Y2 = y2,
                    Confidence = conf,
                    ClassId = (int)output[0, i, 5]
                });
            }
            return response;
        }

        public void Dispose()
        {
            _session?.Dispose(); // 关键:释放 ONNX Runtime 的非托管资源
            GC.SuppressFinalize(this);
        }
    }
}

创建Yolo服务类到Services文件夹中

cs 复制代码
using ComputerVisionAlgorithm;
using Grpc.Core;
using GrpcLearn.Server.Detectors;
using OpenCvSharp;
using Microsoft.Extensions.Logging; // 引入日志命名空间
using static ComputerVisionAlgorithm.YoloService;

namespace GrpcLearn.Server.Services
{
    // 通过构造函数注入 Detector 和 Logger
    public class Yolov26Service(Yolov26Detector detector, ILogger<Yolov26Service> logger) : YoloServiceBase
    {
        private readonly Yolov26Detector _detector = detector;
        private readonly ILogger<Yolov26Service> _logger = logger;

        public override async Task<DetectResponse> Detect(DetectRequest request, ServerCallContext context)
        {
            //_logger.LogWarning("收到图像检测请求: {ImagePath}", request.ImagePath);

            // 1. 参数校验:路径不能为空
            if (string.IsNullOrWhiteSpace(request.ImagePath))
            {
                throw new RpcException(new Status(StatusCode.InvalidArgument, "ImagePath 不能为空"));
            }

            // 2. 文件校验:文件必须存在
            if (!System.IO.File.Exists(request.ImagePath))
            {
                //_logger.LogWarning("文件不存在: {ImagePath}", request.ImagePath);
                throw new RpcException(new Status(StatusCode.NotFound, $"找不到目标图片: {request.ImagePath}"));
            }

            try
            {
                // 使用 using 确保 Mat 对象被及时释放,防止内存泄漏
                using Mat mat = Cv2.ImRead(request.ImagePath);

                // 3. 图像校验:确保图片被正确解析
                if (mat.Empty())
                {
                    throw new RpcException(new Status(StatusCode.InvalidArgument, "图片读取失败,可能是不支持的格式或已损坏"));
                }

                // 4. 执行推理 (Task.Run 将 CPU 密集型推理分配到线程池,避免阻塞 gRPC 的 IO 线程)
                var response = await Task.Run(() => _detector.Detect(mat));

                //_logger.LogWarning("检测成功,识别到 {Count} 个目标", response.Detections.Count);
                return response;
            }
            catch (RpcException)
            {
                // 拦截之前主动抛出的 RpcException,直接向上抛给客户端
                throw;
            }
            catch (Exception ex)
            {
                // 5. 兜底捕获:捕获 ONNXRuntime 等未预期的异常
                //_logger.LogError(ex, "图像检测过程中发生未预期的异常");
                // 向客户端隐藏具体的堆栈细节,仅返回泛化的内部错误
                throw new RpcException(new Status(StatusCode.Internal, "算法服务内部错误,请稍后重试:" + ex.Message));
            }
        }
    }
}

编写服务端Program.cs

cs 复制代码
using GrpcLearn.Server.Detectors;
using GrpcLearn.Server.Services;
using System.Diagnostics;

Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.AboveNormal;

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService();

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(5000, listenOptions =>
    {
        listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
    });
});

builder.Logging.ClearProviders();

// Add services to the container.
builder.Services.AddGrpc();

// 单例化 Yolov26Detector 并注册到依赖注入容器中
builder.Services.AddSingleton<Yolov26Detector>(sp =>
{
    var onnxPath = Path.Combine(AppContext.BaseDirectory, "Onnxs", "yolo26.onnx");
    return new Yolov26Detector(onnxPath);
});

var app = builder.Build();

// 将服务类注册到 gRPC 管道
app.MapGrpcService<Yolov26Service>();

app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");

app.Run();

需要注意的就是第一个语句,把本服务程序的等级设计为高于正常【至少如此】,这样才能保证在它注册成windows服务并运行时,不会变慢!

3、GrpcLearn.Client控制台应用项目,引用GrpcLearn.Shared共享库,并编写Program.cs

cs 复制代码
using ComputerVisionAlgorithm;
using Grpc.Net.Client;

using var channel = GrpcChannel.ForAddress("http://192.168.3.103:5000");
var client = new YoloService.YoloServiceClient(channel);

while (true)
{
    DateTime start = DateTime.Now;
    try
    {
        var response = await client.DetectAsync(new DetectRequest
        {
            ImagePath = @"C:\Users\17359\Desktop\test.png"
        });

        if (response.Detections.Count == 0)
        {
            Console.WriteLine("未检测到任何目标");
        }
        else
        {
            Console.WriteLine($"检测到 {response.Detections.Count} 个目标:");
            foreach (var detection in response.Detections)
            {
                Console.WriteLine($"ClassId: {detection.ClassId}, Confidence: {detection.Confidence:F2}, " +
                                  $"Box: ({detection.X1:F2}, {detection.Y1:F2}) - ({detection.X2:F2}, {detection.Y2:F2})");
            }
        }
    }
    catch (Grpc.Core.RpcException ex)
    {
        Console.WriteLine($"gRPC 调用失败: {ex.Status.Detail}");
    }

    DateTime end = DateTime.Now;

    TimeSpan duration = end - start;
    Console.WriteLine($"检测耗时: {duration.TotalMilliseconds:F2} ms");

    await Task.Delay(1000); // 每秒检测一次
}

以Release形式生成解决方案,可以在Server跟Client俩项目中分别得到exe程序

Server

使用sc命令将Server的exe程序注册成windows服务

cs 复制代码
sc create "YoloGrpcService" binPath= "D:\DonetProject\Learn\GrpcLearn\GrpcLearn.Server\bin\Release\net10.0\GrpcLearn.Server.exe" start= auto

注册成功后启动

cs 复制代码
sc start "YoloGrpcService"

使用命令让系统不对此程序进行任何节能操作,跟前面代码里设置程序优先级至少高于正常,缺一不可!

cs 复制代码
powercfg /powerthrottling disable /path "D:\DonetProject\Learn\GrpcLearn\GrpcLearn.Server\bin\Release\net10.0\GrpcLearn.Server.exe"

服务端部署完毕后,运行客户端

推理速度跟结果都比较正常

问题

如果服务端Program.cs不加入

复制代码
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.AboveNormal;

或者没有使用命令

powercfg /powerthrottling disable /path "D:\DonetProject\Learn\GrpcLearn\GrpcLearn.Server\bin\Release\net10.0\GrpcLearn.Server.exe"

推理速度会变慢,猜测可能是系统不知道我们这个程序需要高性能计算,自动在它作为后台服务时,出于节约能源的考虑,给它降级了,使其不能以正常的竞争力抢夺CPU控制权。

应用

只要yolov26系列的都可以快速修改对应代码复用,改动成本不高,可以给后续的软件集成省事,若是需要算法实时调用的场景,不建议如此拆分,因为RPC通信也需要耗费一定时间,虽然不多,在拆分前一定要考虑清楚其中的利弊!

相关推荐
1-1=02 小时前
C# 泛型
c#·.net·.netcore
智码看视界17 小时前
模型压缩与部署——量化、剪枝与 ONNX 导出
深度学习·剪枝·模型压缩·tensorrt·模型部署·onnx·量化
椒颜皮皮虾྅1 天前
让文档不止于文字:DeploySharp + PP-Structure,打通版面分析与表格重建
开发语言·学习·c#·.net·cuda
椒颜皮皮虾྅1 天前
一套 C# 接口,解锁四类视觉任务:DeploySharp 分类、分割、关键点与旋转框实战
开发语言·分类·c#
罗迪尼亚的熔岩1 天前
联合kuka机器人, 海康visonmaster 的手眼9点标定方法
机器人·c#·工业相机·标定
她说彩礼65万1 天前
C# TaskCompletionSource
java·开发语言·c#
泛凡(Linyongui)2 天前
基于 CW32L010 的智能厨卫调温旋钮方案--低成本 EC21 编码旋钮全解析
单片机·嵌入式硬件·c#
暴走约伯2 天前
【VFX工具链源码拆解 02】告别机械劳动(上):一键统改百个子粒子颜色与批量重命名
3d·unity·c#·材质
信息安全失业大专人员2 天前
每日了解一个智能体-WorkBuddy
开发语言·ai·c#·智能体