前阵子接到一个需求,要在既有业务系统里加一个图像识别模块,上传图片后能自动标出画面里的目标物体并返回位置信息。这类功能放在今天并不稀奇,难点在于技术选型:业务后端是 .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 平均耗时/张 | 峰值内存 |
|---|---|---|---|
| YOLOv8n | 640×640 | 约 180ms | 约 300 MB |
| YOLOv8s | 640×640 | 约 320ms | 约 380 MB |
| YOLOv8m | 640×640 | 约 700ms | 约 500 MB |
如果机器只跑这个推理服务,IntraOpNumThreads不设限制会快一些;但考虑到同一台机器还跑着 Web 服务,我把线程数限制在 4 是更均衡的选择。实际观察下来,CPU 上的n模型已经能满足演示和内部使用场景,加上图片预处理的时间,单张总耗时基本在 250ms 上下。
5.2 瓶颈定位
实测的时候我把耗时拆成了三段:预处理、推理、后处理。结论出乎意料:预处理居然占了不少比例,尤其当原图很大时,ImageSharp 解码和缩放的耗时可能接近推理本身。后来我做了优化,在预处理时直接限制解码尺寸,超过 2000 像素的图片先做一次缩略,能让预处理耗时降一半。
推理本身反而是相对固定的开销,模型多大输入就是多大,和原图分辨率无关。后处理在大多数场景下耗时可以忽略,因为经过置信度过滤后,候选框通常只剩几十个,NMS 排序几毫秒就能完成。
5.3 优化手段与收益预估
如果将来图片量上来,我整理了几个方向,按投入产出比排序:
- 模型量化:把 ONNX 从 FP32 转成 FP16 或 INT8,推理速度通常能提升 1~2 倍,代价是精度轻微下降。YOLO 官方文档有量化命令,转完直接替换模型文件,代码不用改。
- 固定尺寸输入加批处理:YOLO 支持 batch 推理,把多张图凑成一个 batch,一次
Run处理完。批量推理的单张均摊成本会显著下降,但对内存要求更高。 - 异步任务队列:如果同时上传的请求很多,同步接口会阻塞线程。可以把检测任务塞进 Channel 队列,由后台消费者逐个处理,处理完通过 SignalR 通知前端。前端体验也从“等响应”变成“收推送”。
- 前端图片压缩:手机拍的照片动辄 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模型把整个链路跑通,再逐步换大模型和加优化,一上来就追求高精度反而容易被模型加载、内存、延迟这些问题拖住。最后分享一个小技巧:把推理服务的耗时日志打出来,每次请求都记录预处理、推理、后处理三段耗时,长期积累下来,你能很清楚地知道系统哪里需要优化,这比凭感觉调参靠谱得多。