☰
.NET + Vue3 集成 YOLOv8 目标检测:ONNX Runtime 全流程实现
2026/10/17 2:09:56 网站建设 项目流程

前阵子接到一个需求,要在既有业务系统里加一个图像识别模块,上传图片后能自动标出画面里的目标物体并返回位置信息。这类功能放在今天并不稀奇,难点在于技术选型:业务后端是 .NET 体系,前端正准备往 Vue 3 迁移,AI 部分大家第一时间想到 Python,但要为一个功能单独维护一套 Python 推理服务,运维成本和链路复杂度都会上去。反复权衡之后,我选了 .NET + Vue 3 + YOLO 这套组合,把模型转成 ONNX 格式后直接在 .NET 进程里推理,前端用 Vue 3 完成上传、预览和 Canvas 绘制检测框。这篇文章会把从模型导出、后端 API、前端展示到性能优化和踩坑的完整过程写清楚,给正在纠结同类方案的人一个可复用的参考。

1. 先说结论:为什么最终选了 .NET + ONNX Runtime 而不是再起一个 Python 服务

1.1 需求场景与最初的纠结

需求本身不复杂:用户上传一张图片,系统调起目标检测模型,返回每个检测到的目标类别、置信度和坐标框,前端在图片上把这些框画出来。当时团队讨论时,第一个冒出来的方案是写一个 Python Flask/FastAPI 服务,用 YOLO 跑推理,.NET 端通过 HTTP 调用。

这个方案看起来最“正统”,因为 YOLO 的生态和示例代码几乎都在 Python 侧。但我越推演越犹豫:首先,运维层面要多维护一个 Python 环境,依赖、版本、Docker 镜像都得单独管;其次,调用链变长了,图片从 .NET 服务传到 Python 服务,Python 返回 JSON,任何一环出问题都不好排查;第三,如果图片量大,两个服务之间的 I/O 带宽和序列化开销会非常可观。更现实的问题是,团队里并不打算长期养一个 Python 方向的人来维护它。

于是我开始调研 .NET 侧直跑 ONNX 模型的可行性,结论是比想象中成熟得多。ONNX Runtime 官方提供 .NET 绑定,性能和 Python 侧没有本质差别,模型文件也是同一个,只要从 PyTorch 导出一次 ONNX,后面就跟 Python 没关系了。

1.2 两套方案的取舍对比

我把两条路线放在一起列了个对比表,最终取向其实一目了然。

对比项Python 独立推理服务.NET 进程内 ONNX Runtime
开发速度快,示例多中等,需要自己写预处理和后处理
部署复杂度需要独立容器或服务,多一套运维随主服务一起发布,一个进程跑完
调用链路图片要跨服务传输进程内调用,几乎没有网络开销
模型生态直接对接 YOLO 生态,自由度最高依赖 ONNX 导出结果,但官方维护很积极
团队维护成本需要有人长期维护 Python 侧全栈 .NET,学习成本一次性的
性能上限高,可独立扩容高,并发下可以开多个 Session

这个对比做完,结论很清晰:如果团队本身就是 .NET 体系,进程内推理是最符合现实的选择。ONNX 格式本身是开放的,YOLO 模型导出来后,C# 侧只要处理预处理、推理、后处理三个环节,代码量并没有想象中那么多。

1.3 前端选 Vue 3 的原因和整体数据流

前端选 Vue 3 没什么纠结。项目原本就在向 Vue 3 迁移,组合式 API 让逻辑复用舒服很多,TypeScript 支持也比 Vue 2 时代完善,配 Vite 开发体验很顺。另外检测结果要画在图片上,Canvas 操作和响应式状态管理配合得很好,Element Plus 的上传组件开箱即用。

整体数据流是这样:浏览器里用户选图并预览,点击识别后 Vue 3 通过 axios 把图片以 multipart/form-data 形式 POST 到 ASP.NET Core 接口;后端校验文件、落盘,把图片解码后缩放到模型输入尺寸,填入张量,交给 ONNX Runtime 推理;推理输出经过解析和 NMS 得到检测框,换算回原图坐标后转成 JSON 返回;前端拿到结果后使用 Canvas 2D 在原图上绘制矩形框、类别名称和置信度。整个过程用户感知不到后端是“怎么跑模型的”,只需要看到一张带框的图片。

这套架构的关键在于,所有 AI 相关代码全部收敛在后端服务内部,对外暴露的只是一个普通的上传识别接口,前端的接入成本和调用一个普通业务接口一样低。

2. YOLO 模型转 ONNX:把训练好的模型塞进 .NET 进程

2.1 导出模型时的几个关键参数

我用的是 YOLOv8 系列,因为它的导出工具链做得比较省心。导出命令在命令行里一行就够:

yolo export model=yolov8n.pt format=onnx opset=16 simplify=True

这里有两个参数值得说明。opset最好指定到 16 或以上,太低的算子集版本可能导致某些层导出异常,ONNX Runtime 在 .NET 侧推理时也会报不支持的算子错误。simplify=True会对计算图做冗余消除和常量折叠,导出的模型体积更小,推理时也少做一些无用计算。

导出完成后,建议用可视化工具打开看一下模型的输入输出节点名称和形状。输入节点通常是images,形状是[1, 3, 640, 640];输出节点是动态名称,YOLOv8 导出后一般是[1, 84, 8400]。这个形状很关键,后面写后处理全靠它。如果你导出后看到的是[1, 8400, 84]或其他排列,说明配置不一样,解析代码要对应调整,不要盲目抄。

模型放在项目的Models目录下,通过配置项指定路径。我在项目里放了yolov8n.onnx(约 6 MB)作为默认模型,后续如果想切换s、m版本,只要替换文件、确认输入输出形状一致即可。

2.2 C# 侧推理的完整代码骨架

NuGet 包用Microsoft.ML.OnnxRuntime,CPU 场景装这个就够了;如果有显卡想用 GPU 推理,再装Microsoft.ML.OnnxRuntime.Gpu。我先把推理 Session 封装成单例,模型加载一次,后面所有请求复用它。

using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class YoloDetector { private readonly InferenceSession _session; public YoloDetector(string modelPath) { var options = new SessionOptions(); // 限制并发线程数,避免和业务线程争抢 CPU options.AppendExecutionProvider_CPU(); options.IntraOpNumThreads = 4; _session = new InferenceSession(modelPath, options); } public float[] Run(float[] inputData, int inputSize) { var tensor = new DenseTensor<float>(inputData, new[] { 1, 3, inputSize, inputSize }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", tensor) }; using var results = _session.Run(inputs); var output = results.First().AsTensor<float>().ToArray(); return output; } }

这段代码的核心是构建输入张量。DenseTensor的维度必须和模型的输入匹配,也就是[1, 3, 640, 640],分别表示 batch、通道数、高、宽。session.Run一次调用就完成推理,返回结果中第一个元素就是输出张量。

这里有个容易被忽略的细节:InferenceSession是线程安全的,多个请求可以同时调用Run。真正要小心的是SessionOptions里的线程配置,默认情况下 ONNX Runtime 会尽量用完所有 CPU 核心,如果服务器还要跑其他业务,建议限制IntraOpNumThreads。

2.3 绝不能省掉的 letterbox 预处理

模型训练时输入是固定尺寸正方形,但实际图片几乎都是长方形。如果直接拉伸到 640×640,目标物体会变形,检测精度会明显下降。正确做法是保持原始长宽比缩放,然后对剩余区域填充灰色像素,这就是 letterbox。

我使用 ImageSharp 来做图像处理,因为它跨平台且不需要依赖系统原生库。缩放的代码:

using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; const int inputSize = 640; const float paddingValue = 114f; using var image = Image.Load<Rgba32>(stream); float ratio = Math.Min((float)inputSize / image.Width, (float)inputSize / image.Height); int newWidth = (int)Math.Round(image.Width * ratio); int newHeight = (int)Math.Round(image.Height * ratio); int padX = (inputSize - newWidth) / 2; int padY = (inputSize - newHeight) / 2; image.Mutate(ctx => ctx.Resize(new ResizeOptions { Size = new Size(newWidth, newHeight), Mode = ResizeMode.Stretch })); // 创建灰色画布并居中粘贴,模拟 letterbox using var canvas = new Image<Rgba32>(inputSize, inputSize, Color.FromRgb(114, 114, 114)); canvas.Mutate(ctx => ctx.DrawImage(image, new Point(padX, padY), 1f));

paddingValue取 114,是因为模型训练时就是用这个灰值填充的,用其他颜色会影响推理效果。一些现成图像库的 Pad 模式默认填充黑色,如果不手动改成 114,同样的模型、同样的图片,识别结果可能差一截。从最终效果看,letterbox 是预处理环节里对精度影响最大的一个细节,省掉它,图省事直接拉伸,检测框会明显变歪。

像素数据从画布提取并归一化时,格式也要对齐训练时的方式:模型期望的是 RGB 顺序 0~1 之间的浮点数,内存布局是 CHW 而不是 HWC。ImageSharp 默认是 Rgba32,转换时只取前三个通道。

var floats = new float[3 * inputSize * inputSize]; canvas.ProcessPixelRows(accessor => { for (int y = 0; y < inputSize; y++) { var row = accessor.GetRowSpan(y); for (int x = 0; x < inputSize; x++) { var pixel = row[x]; floats[0 * inputSize * inputSize + y * inputSize + x] = pixel.R / 255f; floats[1 * inputSize * inputSize + y * inputSize + x] = pixel.G / 255f; floats[2 * inputSize * inputSize + y * inputSize + x] = pixel.B / 255f; } } });

2.4 后处理:解析 1×84×8400 输出与 NMS

YOLOv8 的输出形状是[1, 84, 8400],其中 84 是 4 个坐标值 + 80 个类别分数(COCO 数据集约定的 80 类),8400 是模型在不同尺度特征图上生成的候选框数量。这个布局是“通道优先”,也就是说一个候选框的 84 个值不是紧密排在一起的,而是分散在 8400 个位置区间里。

解析时先把它看作一维数组,索引偏移按c * 8400 + i计算,其中i是候选框序号,c是通道序号。前 4 个通道分别对应中心点 x、y、宽度 w、高度 h。

const int channelCount = 84; const int anchorCount = 8400; const float confThreshold = 0.25f; const float iouThreshold = 0.45f; var boxes = new List<DetectionBox>(); for (int i = 0; i < anchorCount; i++) { float cx = output[i]; float cy = output[channelCount * 1 + i]; float w = output[channelCount * 2 + i]; float h = output[channelCount * 3 + i]; float maxScore = 0f; int bestClass = 0; for (int c = 4; c < channelCount; c++) { float score = output[channelCount * c + i]; if (score > maxScore) { maxScore = score; bestClass = c - 4; } } if (maxScore < confThreshold) continue; boxes.Add(new DetectionBox { ClassId = bestClass, Confidence = maxScore, Box = new float[] { cx - w / 2f, cy - h / 2f, cx + w / 2f, cy + h / 2f } }); }

cx - w / 2f这一步是把中心点坐标和宽高换算成左上角和右下角的坐标。8400 个候选框里绝大多数置信度都很低,过滤掉后通常只剩几十个。接下来做 NMS(非极大值抑制),目的是把多个重叠框合并成一个,保留置信度最高的那个。

boxes.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); var selected = new List<DetectionBox>(); while (boxes.Count > 0) { var best = boxes[0]; selected.Add(best); boxes.RemoveAt(0); boxes.RemoveAll(b => IoU(best.Box, b.Box) > iouThreshold); } float IoU(float[] a, float[] b) { float x1 = Math.Max(a[0], b[0]); float y1 = Math.Max(a[1], b[1]); float x2 = Math.Min(a[2], b[2]); float y2 = Math.Min(a[3], b[3]); float interArea = Math.Max(0, x2 - x1) * Math.Max(0, y2 - y1); float unionArea = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - interArea; return unionArea <= 0 ? 0 : interArea / unionArea; }

到这一步拿到的坐标还是 640×640 输入空间里的值,需要还原回原图坐标。还原公式用 letterbox 时记下的ratio、padX、padY:

float x1 = (box[0] - padX) / ratio; float y1 = (box[1] - padY) / ratio; float x2 = (box[2] - padX) / ratio; float y2 = (box[3] - padY) / ratio;

这个还原步骤看着简单,却是最容易埋坑的地方。我后面专门有一节讲排查过程,这里先标记一下。

3. 后端 API 设计:上传、校验、推理、返回一条龙

3.1 接口与 DTO 先想清楚

接口设计上我没搞太复杂,一个POST /api/detection接口就够用,接收文件字段,返回检测结果。但 DTO 还是要提前定义清楚,避免前端对接时到处问字段含义。

public class BoundingBoxDto { public float X1 { get; set; } public float Y1 { get; set; } public float X2 { get; set; } public float Y2 { get; set; } public float Confidence { get; set; } public string ClassName { get; set; } } public class DetectionResponseDto { public string FileName { get; set; } public int ImageWidth { get; set; } public int ImageHeight { get; set; } public long DurationMs { get; set; } public List<BoundingBoxDto> Detections { get; set; } }

ImageWidth和ImageHeight是原图的宽高,前端绘制检测框时要用。DurationMs返回推理耗时,虽然前端不一定展示,但排查问题时有这个数据会很方便。

如果哪天需求升级成视频流或批量图片,再把同步接口改成异步任务队列,返回一个任务 ID,前端轮询或通过 SignalR 拿结果。第一版做成同步就够用,上传单张图片的耗时通常在几百毫秒内,用户等待时间完全可接受。

3.2 文件校验与安全边界

文件上传接口是网络安全的重灾区,哪怕只是一个内部工具,该做的校验也不能省。我处理了这么几层:扩展名白名单、文件头魔数校验、文件大小限制。

图片文件不能只看扩展名,攻击者完全可以改后缀名绕过。在实践中,文件大小用IFormFile.Length判断,限制在 10 MB 以内,超过的直接返回 413。魔数校验就是读文件的前几个字节,JPEG 是FF D8 FF,PNG 是89 50 4E 47,WebP 是52 49 46 46。我封装了一个简单方法,在保存文件前先校验,通过再存。

var allowedExtensions = new HashSet<string> { ".jpg", ".jpeg", ".png", ".webp", ".bmp" }; var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(extension)) return BadRequest("不支持的图片格式"); using var stream = file.OpenReadStream(); var header = new byte[8]; await stream.ReadAsync(header, 0, 8); bool isValid = IsValidImageHeader(header); if (!isValid) return BadRequest("文件内容不是有效图片");

文件保存到临时目录,推理完成就删除。这样不给服务器留垃圾文件,也避免用户反复上传占满磁盘。

3.3 推理服务的生命周期管理

推理服务必须注册成单例,原因是模型加载一次就要几百毫秒甚至更久,如果每个请求都重新new InferenceSession,光模型加载时间就比推理时间还长,内存也会跟着暴涨。依赖注入里注册成单例就行:

builder.Services.AddSingleton(new YoloDetector("Models/yolov8n.onnx"));

控制器里直接注入这个单例:

[ApiController] [Route("api")] public class DetectionController : ControllerBase { private readonly YoloDetector _detector; public DetectionController(YoloDetector detector) { _detector = detector; } [HttpPost("detection")] public async Task<IActionResult> Detect(IFormFile file) { // 校验、预处理、推理、后处理、返回 } }

这里有一点要注意:YoloDetector内部持有的InferenceSession是线程安全的,可以直接被并发访问。但如果你在自定义类里放了非线程安全的缓存字段,比如List<DetectionBox>,那注册成单例后并发就会出现数据错乱。我当时把所有可变状态都收敛在方法内部,类只保留_session这个只读字段,就避开了这个问题。

3.4 返回结果的结构设计

坐标返回的是原始图片坐标系里的绝对像素值,这个选择很重要。前端拿到后只需按图片展示尺寸做一次等比换算即可,后端不用关心前端画布多大,前端也不用猜测后端返回的是不是归一化坐标。

类别名和置信度一起返回,前端可以直接展示。置信度我保留原始精度,不在后端四舍五入,展示时前端自己格式化。如果检测结果为空,Detections返回空数组而不是null,前端处理起来更自然。

返回的具体 JSON 大概是下面这个结构:

{ "fileName": "demo.jpg", "imageWidth": 1920, "imageHeight": 1080, "durationMs": 186, "detections": [ { "x1": 520.31, "y1": 298.17, "x2": 783.56, "y2": 600.42, "confidence": 0.92, "className": "person" } ] }

前端拿到这个结构,不需要做任何复杂解析,直接遍历就能画框。把接口稳定成这个形状之后,前端联调一次通过,后面几乎没再为字段问题来回沟通。

4. Vue 3 前端:把检测框画到 canvas 上

4.1 项目搭建与上传组件

前端用 Vite 初始化项目,TypeScript 模板,引入 Element Plus 作为基础组件库。上传部分直接用了el-upload,因为它自带拖拽、文件选择、状态管理,比手写 input 省不少事。

<template> <div class="detection-page"> <el-upload drag accept="image/*" :auto-upload="false" :limit="1" :on-change="handleFileChange" > <div>拖拽图片到此处,或点击选择</div> </el-upload> <el-button type="primary" :loading="loading" @click="handleDetect"> 开始识别 </el-button> <canvas ref="canvasRef" v-show="hasImage"></canvas> </div> </template>

选中的图片用URL.createObjectURL生成本地预览地址,画到 canvas 之前可以先显示在<img>标签里。on-change事件里拿到UploadFile对象,取出raw文件保存到 ref 中,用于后续上传。

Vue 3 组合式 API 配合ref管理状态很顺手。canvas 元素通过ref获取,识别返回后直接操纵 DOM。整个过程没有复杂的响应式数据嵌套,状态要么是文件引用,要么是检测结果数组,边界很清晰。

4.2 坐标换算与高 DPI 画布

这是前端最容易出错的两个点。

首先是坐标换算。后端返回的是原图坐标系下的绝对像素值,而 canvas 显示时可能因为容器宽度或图片缩放比例,和原图尺寸不一致。换算公式很简单:

const scaleX = canvasWidth / imageWidth; const scaleY = canvasHeight / imageHeight; const drawX1 = box.x1 * scaleX; const drawY1 = box.y1 * scaleY;

这里要注意scaleX和scaleY必须分别计算,不要图省事只取一个比例。虽然大多数时候图片等比缩放后两个比例相等,但只要容器不是严格的等比,单独取一个就会导致框错位。

其次是高 DPI 画布。如果直接把 canvas 的 CSS 宽度设成容器宽度,不处理devicePixelRatio,在 Retina 屏上画出来的线会发虚、文字会模糊。正确做法是把 canvas 的实际像素尺寸设为 CSS 尺寸乘以devicePixelRatio,再用缩放变换绘制:

const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr);

如果不做这一步,4K 屏上画出的检测框会看起来像低分辨率截图,用户会觉得整个系统很粗糙。我当时就是被这个问题困扰了一会儿,处理好之后清晰度明显不一样。

4.3 绘制检测框与标签的代码实现

绘制逻辑在拿到检测结果后统一执行。先清空画布,再把原图绘制上去,最后遍历检测结果画框和文字。

function drawDetections(image: HTMLImageElement, detections: Detection[]) { const canvas = canvasRef.value!; const ctx = canvas.getContext('2d')!; const dpr = window.devicePixelRatio || 1; const displayWidth = canvas.clientWidth; const displayHeight = canvas.clientHeight; canvas.width = displayWidth * dpr; canvas.height = displayHeight * dpr; canvas.style.width = displayWidth + 'px'; canvas.style.height = displayHeight + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, displayWidth, displayHeight); ctx.drawImage(image, 0, 0, displayWidth, displayHeight); const scaleX = displayWidth / image.naturalWidth; const scaleY = displayHeight / image.naturalHeight; ctx.lineWidth = 3; detections.forEach((item) => { const x = item.x1 * scaleX; const y = item.y1 * scaleY; const w = (item.x2 - item.x1) * scaleX; const h = (item.y2 - item.y1) * scaleY; ctx.strokeStyle = '#00ff88'; ctx.strokeRect(x, y, w, h); ctx.fillStyle = 'rgba(0, 255, 136, 0.85)'; ctx.font = 'bold 16px sans-serif'; const text = `${item.className} ${(item.confidence * 100).toFixed(1)}%`; ctx.fillText(text, x, y > 18 ? y - 6 : y + 18); }); }

绘制时我给每个类别固定了同一个颜色,如果要做多类别区分,可以定义一个颜色表按类别索引取色。文字如果贴近图片顶部,就要把文字画在框内部而不是框上方,否则一部分文字会溢出画布边界,这个细节我在代码里做了判断。

4.4 交互细节:loading、空结果、二次识别

用户点击“开始识别”后,按钮进入 loading 状态,同时禁用上传组件,防止识别过程中用户又换图,导致 UI 状态错乱。请求完成后根据结果做三种分支:有检测结果就绘制;没有检测结果就弹提示;请求失败则提示错误原因,并保留用户当前图片,方便重试。

axios 封装时给请求设置了超时时间,默认 30 秒。因为大图加上并发高的时候,后端处理时间可能比平时长,如果完全没有超时,用户会一直干等。

const api = axios.create({ baseURL: '/api', timeout: 30000 }); async function handleDetect() { if (!selectedFile.value) return; loading.value = true; try { const formData = new FormData(); formData.append('file', selectedFile.value); const { data } = await api.post<DetectionResponse>('/detection', formData); drawDetections(imageRef.value, data.detections); } catch (error) { // 透传后端错误信息,用 ElMessage 展示 } finally { loading.value = false; } }

二次识别是指同一张图反复检测的场景,通常是因为用户调整了置信度阈值或者想看更详细的结果。如果后端支持传阈值参数,前端可以加一个滑块控件,重新提交并重绘,给用户一种“实时调节”的感觉。这个功能作为进阶扩展,很能提升体验。

5. 实测数据与性能优化方向

5.1 基准测试数据

我搭了一个测试环境来摸清这套方案的性能底牌:一台普通办公机,CPU 是主流中端处理器,内存 16 GB,没有独立显卡,系统是 Windows 11。测试图片用了一个公开的测试图集,尺寸从 640×480 到 4000×3000 都有。

模型输入尺寸CPU 平均耗时/张峰值内存
YOLOv8n640×640约 180ms约 300 MB
YOLOv8s640×640约 320ms约 380 MB
YOLOv8m640×640约 700ms约 500 MB

如果机器只跑这个推理服务,IntraOpNumThreads不设限制会快一些;但考虑到同一台机器还跑着 Web 服务,我把线程数限制在 4 是更均衡的选择。实际观察下来,CPU 上的n模型已经能满足演示和内部使用场景,加上图片预处理的时间,单张总耗时基本在 250ms 上下。

5.2 瓶颈定位

实测的时候我把耗时拆成了三段:预处理、推理、后处理。结论出乎意料:预处理居然占了不少比例,尤其当原图很大时,ImageSharp 解码和缩放的耗时可能接近推理本身。后来我做了优化,在预处理时直接限制解码尺寸,超过 2000 像素的图片先做一次缩略,能让预处理耗时降一半。

推理本身反而是相对固定的开销,模型多大输入就是多大,和原图分辨率无关。后处理在大多数场景下耗时可以忽略,因为经过置信度过滤后,候选框通常只剩几十个,NMS 排序几毫秒就能完成。

5.3 优化手段与收益预估

如果将来图片量上来,我整理了几个方向,按投入产出比排序:

  1. 模型量化:把 ONNX 从 FP32 转成 FP16 或 INT8,推理速度通常能提升 1~2 倍,代价是精度轻微下降。YOLO 官方文档有量化命令,转完直接替换模型文件,代码不用改。
  2. 固定尺寸输入加批处理:YOLO 支持 batch 推理,把多张图凑成一个 batch,一次Run处理完。批量推理的单张均摊成本会显著下降,但对内存要求更高。
  3. 异步任务队列:如果同时上传的请求很多,同步接口会阻塞线程。可以把检测任务塞进 Channel 队列,由后台消费者逐个处理,处理完通过 SignalR 通知前端。前端体验也从“等响应”变成“收推送”。
  4. 前端图片压缩:手机拍的照片动辄 4000×3000、体积五六 MB,上传慢、后端解码也慢。前端可以在 Canvas 里先压缩到最大边长 1600 再上传,体积能缩到原来的十分之一,体验提升非常直观。

这几项里,前端压缩和异步队列是最容易落地且收益明显的,量化则需要花时间验证精度是否符合要求。

6. 踩坑清单:四个最值得记录的现场

6.1 坑:坐标偏移了“一掌宽”

第一次联调时,前端画出来的检测框位置明显不对,X 方向整体偏了一段距离,Y 方向倒还正常。第一反应以为是前端坐标换算写错了,但检查半天,scalX 和 scaleY 都算对了,逻辑没问题。后来我去翻后端的坐标还原代码,发现一个低级失误:letterbox 的padX参与计算时,我用的公式是(box[0] + padX) / ratio,符号搞反了,多算了一段位移。

排查思路其实很典型:先缩小范围,前端只画一个框调试,确认问题不在前端;然后回看后端输出,把还原前的坐标和还原后的坐标分别打印,发现只要图片不是正方形,还原结果就错,明显指向 letterbox 参数使用错误。修正符号后问题消失。

这个坑值得记下来,因为坐标换算没报错、没异常,只是看起来“偏移了一点点”,如果不用调试数据对拍,凭肉眼很难定位。

6.2 坑:每次请求都 new 模型实例导致内存暴涨

第一版代码我图省事,在控制器构造函数里直接new YoloDetector(...),结果压测刚跑起来,内存就蹭蹭往上涨,几次请求之后内存占用已经逼近 1 GB。原因很简单:每个请求都实例化一个新的InferenceSession,模型文件被反复加载,内存根本来不及释放。

排查过程:用任务管理器看进程内存,发现请求结束后内存没有明显回落,怀疑是 Session 没有被回收。再看看代码,每次请求都 new,内存自然不降。解决办法就是前面提到的依赖注入注册成单例,模型只加载一次。优化后内存稳定在 300 MB 上下,效果立竿见影。

6.3 坑:高 DPI 屏上 canvas 发虚

前端画框的线条在普通屏幕上看着正常,换到高分屏笔记本上一看,线条边缘明显发虚,文字也模糊不清。一开始我怀疑是ctx.lineWidth太粗或颜色问题,后来才意识到是 canvas 的物理像素没有跟随设备像素比。

处理方式是前面提到过的devicePixelRatio重设画布尺寸。顺便说一句,Canvas 清空和重设尺寸的顺序有讲究:必须先设置canvas.width和canvas.height,再调用setTransform(dpr, 0, 0, dpr, 0, 0),否则之前的状态可能被残留,画出来的内容会变形。

6.4 坑:大文件上传被服务器悄悄拒绝

有个用户反馈,上传一张十几 MB 的高清图时,前端一直报网络错误,但小图完全正常。查了半天发现不是前端问题,是 Kestrel 默认请求体大小限制在 30 MB 左右,但问题在于后端返回的 413 状态码被 axios 当成了普通错误,前端只显示“请求失败”,没有把具体原因透出。

解决分两层:后端在Program.cs里放宽请求体限制,同时保留文件大小上限 10 MB 的业务校验;前端把非 2xx 响应里的错误信息透传给ElMessage,让用户知道是“文件太大”而不是“服务挂了”。

builder.Services.Configure<KestrelServerOptions>(options => { options.Limits.MaxRequestBodySize = 30 * 1024 * 1024; });

如果你部署在 IIS 或 Nginx 后面,还要注意这些服务器也有自己的请求体大小限制,可能比 Kestrel 更严格。

这套方案从立项到第一版跑通,前后也就不到一周。核心工作集中在模型后处理和服务生命周期管理上,真正打通之后,日常使用非常稳定。我现在维护这个平台时,已经敢放心地说“AI 部分不需要 Python 也能跑得很好”了。给还在犹豫的同路人一个建议:先用最小的n模型把整个链路跑通,再逐步换大模型和加优化,一上来就追求高精度反而容易被模型加载、内存、延迟这些问题拖住。最后分享一个小技巧:把推理服务的耗时日志打出来,每次请求都记录预处理、推理、后处理三段耗时,长期积累下来,你能很清楚地知道系统哪里需要优化,这比凭感觉调参靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询