背景
之前一直都是算法集成到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通信也需要耗费一定时间,虽然不多,在拆分前一定要考虑清楚其中的利弊!