☰
.NET + Vue3 + YOLO 全栈 AI 识图平台实战:从模型部署到前端画框
2026/10/13 7:51:45 网站建设 项目流程

年中接了个模拟项目,需求一句话就能说清:让用户在网页上传一张图片,系统自动识别里面有哪些目标物体,框出位置、给出置信度,并把结果叠画在原图上。技术栈被限定为 .NET 后端 + Vue 3 前端,识别模型指定用 YOLO。如果你正在做类似的全栈 AI 识图平台,或者也在纠结“Python 是不是做 AI 的必经之路”,这篇文章正好能帮你省掉一轮轮试错成本。

平台上线后核心动作其实就三个:上传图片、后端推理、前端画框。看起来简单,真正落地时却处处是坑——模型怎么从 .pt 转成 .onnx?C# 里怎么高效调 YOLO?letterbox 预处理怎么还原坐标?Canvas 画的框为什么总是错位?这篇文章会把这几个问题一次性讲透,并提供可以直接抄走的代码和配置。无论你是准备用 .NET 做 AI 能力接入的后端工程师,还是想把 Vue 3 和检测模型整到一起的全栈学习者,都可以照着走一遍。

1. 项目定位与全栈技术选型:为什么是 .NET + Vue 3 + YOLO

1.1 并不是所有 YOLO 项目都必须用 Python 后端

很多人一听到 YOLO,第一反应就是“得写 Python”。如果你只是做模型训练,这话没错;但如果你只是做模型推理,完全可以把 YOLO 导成 ONNX 格式,然后用 C# 直接跑。我在这个项目里用的是Microsoft.ML.OnnxRuntime这个 NuGet 包,它在进程内加载 ONNX 模型,性能跟 Python 侧的推理引擎基本一致,而且少了 GIL 的限制,高并发下反而更稳。

为什么选 .NET 而不是让 Python 做推理服务?核心原因是“运维生态统一”。这个模拟项目的周边是完整的业务系统:认证、鉴权、数据库、日志、文件存储、消息队列,全都在 .NET 体系里。如果单独为 AI 推理起一个 Python 服务,等于在团队里多养一套技术栈,还得处理跨服务调用、容器编排、模型热更新。用 .NET 直接集成模型,推理能力就变成了业务 API 的一个内部服务,链路短、问题少、部署也简单。

对比维度Python 独立推理服务.NET 集成 ONNX Runtime
部署复杂度需要独立服务、独立镜像、跨服务调用打入现有 Web API 进程
并发能力受 GIL 影响,需要多进程配合线程安全,天然适合并发推理
团队门槛需要额外维护 Python 工程复用现有 .NET 技能栈
模型支持模型格式多,但链路长支持 ONNX,导出一次即可
适合场景训练、快速实验生产级业务集成

这个选型不是炫技,而是围绕“能交付、能维护、能扩展”来做取舍。只要模型能导出成 ONNX,C# 就能稳定推理,这是整个后端方案的基石。

1.2 Vue 3 在前端这边的取舍

前端锁定 Vue 3,不是因为 React 不好,而是这个项目的界面复杂度刚好在 Vue 3 的舒适区。整个页面有三个核心功能区:上传区、Canvas 预览区、结果表格区,三个区之间有大量状态联动。Vue 3 的组合式 API 对这种场景非常顺手——上传逻辑、画框逻辑、历史记录逻辑可以各自拆成 composable,代码不臃肿,也不容易互相污染。

另一个加分项是生态。Element Plus 的上传组件和表格组件开箱即用,Naive UI 也能提供类似体验,省掉很多造轮子的时间。加上 Vite 的开发服务器冷启动快,改完代码浏览器秒级刷新,调试“上传 → 识别 → 画框”这条主流程时效率很高。

还有一个实际原因:前端要处理图片预压缩、Canvas 绘制、异步轮询这些偏“浏览器原生能力”的逻辑,Vue 3 对 TypeScript 的支持很顺,类型定义能从后端 DTO 一路穿透到前端接口,前后端字段对不上这类低级问题能在编译期就被发现。

1.3 整体架构与目录规划

整个平台的数据流可以概括成一张图:

浏览器(Vue 3 + Pinia + Canvas) │ axios + FormData(二进制上传) ▼ ASP.NET Core Web API(/api/detection/upload) │ ▼ YOLO 检测服务(ONNX Runtime 加载 yolov8n.onnx) │ 预处理 letterbox → 推理 → NMS → 坐标还原 ▼ JSON:{ label, confidence, box(原图坐标)} │ ▼ 浏览器 Canvas 叠画检测框 + 结果表格联动展示

代码仓库我按“前端 + 后端 + 模型资产”三个目录切:

detect-platform/ ├── frontend/ # Vue 3 工程 │ ├── src/ │ │ ├── api/ # axios 封装 │ │ ├── stores/ # Pinia 状态 │ │ ├── components/ # 上传、Canvas、结果表格组件 │ │ └── views/ ├── backend/ # ASP.NET Core 工程 │ ├── Controllers/ │ ├── Services/ # YOLO 检测核心服务 │ ├── Models/ # DTO │ └── Program.cs └── models/ # ONNX 模型文件,独立目录方便替换

这个结构的好处是模型文件不混进代码目录,后续升级模型版本只需要替换models/下的文件,后端服务重启即生效,前端完全无感知。工程层面前后端彻底分离,也方便将来拆成独立部署单元。

2. 后端核心:YOLO 模型接入与推理服务设计

2.1 工程骨架与依赖

后端我用的是 ASP.NET Core 8 Web API,核心依赖只有这几个:

<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.1" /> <PackageReference Include="OpenCvSharp4.Windows" Version="4.9.0.20240103" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />

Microsoft.ML.OnnxRuntime负责模型推理;OpenCvSharp4.Windows负责图像读取、letterbox 缩放、颜色通道转换。如果你不想引入 OpenCvSharp,也可以换成SixLabors.ImageSharp,但 OpenCvSharp 的Mat类型跟 ONNX Runtime 的输入张量衔接更直接,所以我选了它。需要注意:OpenCvSharp4.Windows 这个包自带运行库,发布时体积会大一些,但省去了手动拷贝OpenCvSharp.dll的麻烦。

控制器层只负责收请求、调服务、返回 JSON;真正跟模型打交道的逻辑全部隔离在DetectionService里。这样后续换模型、调参数、加 GPU 支持,都只改服务层,不会污染 API 层。

2.2 模型准备:从 .pt 到 .onnx

YOLO 的官方权重是.pt格式,C# 没法直接加载,必须先导出成 ONNX。我用的是 YOLOv8 的小模型yolov8n.pt,导出命令很简单:

yolo export model=yolov8n.pt format=onnx opset=12 imgsz=640

如果希望输入尺寸动态化,可以加dynamic=True参数。但我建议生产环境用固定 640×640:固定输入尺寸能让 ONNX Runtime 对算子做更多静态优化,推理更稳定;动态输入在 C# 端还要处理 shape 变化,性能开销没有优势。

导出后用 Netron 看一眼模型的输入输出:

  • 输入名:images,形状1×3×640×640
  • 输出名:默认可能是output0,形状1×84×8400

这个 84 的含义是:4 个坐标(中心点 x、中心点 y、宽、高)+ 80 个 COCO 类别分数。8400 是三个特征层加起来的总锚框数。导出的输出名和形状在代码里会直接用,一定要记清楚。

2.3 推理服务完整核心流程

我在DetectionService里封装了四个步骤:预处理、推理、后处理、坐标还原。这是整个项目最容易出错的地方,拆开讲。

第一步:letterbox 预处理

直接把原图 reszie 到 640×640 会破坏宽高比,导致检测框变形、漏检。YOLO 官方训练时用的是 letterbox 方式:保持宽高比缩放,再把多余部分用灰色(114, 114, 114)填充。

private static Mat Letterbox(Mat src, int targetSize = 640) { int w = src.Width, h = src.Height; float ratio = Math.Min((float)targetSize / w, (float)targetSize / h); int newW = (int)Math.Round(w * ratio); int newH = (int)Math.Round(h * ratio); int padW = targetSize - newW; int padH = targetSize - newH; int top = padH / 2; int left = padW / 2; Mat resized = new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); Mat output = new Mat(new Size(targetSize, targetSize), MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(output[new Rect(left, top, newW, newH)]); return output; }

为什么要这样做?因为模型训练时看到的就是“缩放+填充”后的图,推理时输入必须和训练时的分布保持一致。很多人随便 Resize 后精度暴跌,问题就出在这里。

第二步:组装输入张量并推理

ONNX Runtime 期望的输入是 NCHW 布局、RGB 通道、数值归一化到 0~1。OpenCvSharp 读出来的图默认是 BGR、数值 0~255,直接喂进去结果全是乱的。用BlobFromImage一步搞定通道转换和归一化:

using Mat letterboxed = Letterbox(src); Mat blob = Cv2.Dnn.BlobFromImage(letterboxed, 1.0 / 255.0, new Size(640, 640), new Scalar(0, 0, 0), true, false);

参数含义依次是:缩放系数1/255、目标尺寸、均值(设为 0)、swapRB=true(把 BGR 转成 RGB)、crop=false(不做裁剪)。

推理本身只有一行:

using var results = _session.Run(new[] { OnnxValue.CreateTensor("images", blob) }, _outputNames); using var output = results.First().AsTensor<float>();

_session是InferenceSession,我在服务构造函数里只初始化一次,整个进程复用。ONNX Runtime 的 Session 是线程安全的,多个请求并发进来可以放心使用同一个实例,千万别在每次请求里重新加载模型。

第三步:后处理与 NMS

输出张量1×84×8400,每一列代表一个预测框。前 4 行是cx、cy、w、h,后面 80 行是各类别分数。先遍历所有列,把坐标格式从中心点转成左上角右下角,筛掉低于置信度阈值的框,再找出每个框得分最高的类别:

const float ConfThreshold = 0.25f; const int ClassCount = 80; var rawBoxes = new List<YoloBox>(); for (int i = 0; i < 8400; i++) { float cx = output[0, 0, i]; float cy = output[0, 1, i]; float w = output[0, 2, i]; float h = output[0, 3, i]; float maxScore = 0; int label = -1; for (int c = 0; c < ClassCount; c++) { float score = output[0, 4 + c, i]; if (score > maxScore) { maxScore = score; label = c; } } if (maxScore < ConfThreshold) continue; rawBoxes.Add(new YoloBox( cx - w / 2f, cy - h / 2f, cx + w / 2f, cy + h / 2f, label, maxScore)); }

筛完后还有大量重叠框,需要做 NMS(非极大值抑制)。我写了个简单版本:按分数降序,依次拿最高分的框,剔除所有与它 IoU 超过 0.45 的框,再处理下一个。这里有两个阈值建议先按默认来:ConfThreshold=0.25、NmsThreshold=0.45。置信度阈值调太低会显示满屏垃圾框,调太高又容易漏检。

第四步:坐标还原

NMS 之后拿到的坐标是 640×640 letterbox 图上的坐标,必须逆运算回原图尺寸,否则前端画的框一定错位:

float x1 = (box.X1 - left) / ratio; float y1 = (box.Y1 - top) / ratio; float x2 = (box.X2 - left) / ratio; float y2 = (box.Y2 - top) / ratio;

这里的ratio、left、top就是从Letterbox方法里带回来的缩放比例和填充偏移。我把它们包在一个LetterboxInfo对象里,预处理生成的 Resize 比例、填充尺寸都会一起返回,避免在后续步骤里重复计算。

检测服务最终返回一个干净的List<DetectionDto>,每个元素包含类别名、置信度、原始图像坐标框。到这一步,后端核心逻辑就闭环了。

2.4 API 接口设计

接口层我保持了最小化设计,只暴露两个动作:

[HttpPost("upload")] public async Task<IActionResult> Upload(IFormFile file) { if (file == null || file.Length == 0) return BadRequest(new { message = "文件不能为空" }); using var ms = new MemoryStream(); await file.CopyToAsync(ms); var sw = Stopwatch.StartNew(); var detections = await _detector.DetectAsync(ms.ToArray()); sw.Stop(); return Ok(new DetectionResponse { Model = _detector.ModelName, DurationMs = (int)sw.ElapsedMilliseconds, Detections = detections }); }

返回的 DTO 长这样:

public record BoxDto(int X, int Y, int Width, int Height); public record DetectionDto(string Label, float Confidence, BoxDto Box); public record DetectionResponse(string Model, int DurationMs, List<DetectionDto> Detections);

我刻意没有直接返回内部模型对象,而是用 DTO 包了一层。好处有三个:第一,内部YoloBox的坐标格式是左上角右下角,前端更习惯x/y/width/height,DTO 里直接换算好;第二,JSON 序列化时字段名会自动转成小驼峰,前端用起来不用纠结命名;第三,将来加 Mask 分割、加多模型支持,都只需要扩展 DTO,不会破坏已有接口契约。

另外别忘了在Program.cs里限制上传体积,防止大图直接把后端内存吃满:

builder.Services.Configure<FormOptions>(options => { options.MultipartBodyLengthLimit = 50 * 1024 * 1024; });

3. Vue 3 前端实现:上传交互与检测结果可视化

3.1 初始化工程与基础设施

前端工程用 Vite 官方脚手架创建,选用 Vue 3 + TypeScript 组合:

npm create vue@latest

随后装上项目需要的依赖:

npm install axios pinia element-plus

axios负责走后端接口,pinia负责跨组件共享检测状态,element-plus提供上传、表格、消息提示等基础组件。启动项目后,我在src/api里封装了一个最小化的 axios 实例:

import axios from 'axios' export const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE ?? '/api', timeout: 30000 })

baseURL在开发环境指到http://localhost:5000/api,生产环境由 Nginx 反向代理统一转发,前端代码不用区分环境。这个配置虽然基础,但能避免“前端直连后端地址”导致跨域配置混乱的老问题。

3.2 图片上传与本地预压缩

上传部分用 Element Plus 的el-upload组件。默认行为是选中即提交,但我在before-upload钩子里拦了一步,做两件事:校验文件类型和大小,然后执行前端压缩。

function compressImage(file: File): Promise<Blob> { return new Promise((resolve, reject) => { const img = new Image() const reader = new FileReader() reader.onload = () => { img.onload = () => { const maxSide = 2000 let { width, height } = img const scale = Math.min(1, maxSide / Math.max(width, height)) width *= scale height *= scale const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const ctx = canvas.getContext('2d')! ctx.drawImage(img, 0, 0, width, height) canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('压缩失败')), 'image/jpeg', 0.85) } img.src = reader.result as string } reader.readAsDataURL(file) }) }

为什么要做这一步?我实测过,一个 4000×3000 的手机照片未经压缩直接上传,后端拿到手光解码就要 300 多毫秒,识别一张图整体耗时逼近一秒,用户体感非常差。压缩到 2000 像素以内、JPEG 质量 0.85,图像的检测结果基本不变,但后端解码速度能快一倍多。这个优化效果立竿见影,而且把压力分散到了用户浏览器端,后端能做更多并发。

3.3 Canvas 叠画检测结果

拿到后端返回的 JSON 后,核心工作是把检测框画到原图上。这里的坐标系直接采用图片原始宽高,canvas 的width和height也设置成图片原始宽高,浏览器会自动按 CSS 缩放画布内容,不需要手动换算缩放比。

function drawBoxes(canvas: HTMLCanvasElement, image: HTMLImageElement, boxes: DetectionDto[]) { const ctx = canvas.getContext('2d')! canvas.width = image.naturalWidth canvas.height = image.naturalHeight ctx.drawImage(image, 0, 0) ctx.lineWidth = Math.max(2, Math.round(image.naturalWidth / 600)) ctx.font = '16px sans-serif' for (const item of boxes) { const color = colorMap[item.label] ?? '#e74c3c' const { x, y, width, height } = item.box ctx.strokeStyle = color ctx.strokeRect(x, y, width, height) const text = `${item.label} ${(item.confidence * 100).toFixed(1)}%` const metrics = ctx.measureText(text) ctx.fillStyle = 'rgba(0, 0, 0, 0.65)' ctx.fillRect(x, y - 22, metrics.width + 10, 20) ctx.fillStyle = color ctx.fillText(text, x + 5, y - 7) } }

代码里有一个很影响观感的细节:标签文字下方的背景条。如果没有这个背景条,文字会直接叠在图片内容上,浅色背景下根本看不清。先用measureText量出文字宽度,再画一个半透明黑底,最后填上彩色文字,在任何图片上都能一眼读出类别和置信度。

前后端坐标能直接对应,全靠第 2.3 节里“坐标还原”那一步做得到位。如果后端返回的是 letterbox 后的坐标,前端 Canvas 上就会看到框整体偏移、位置不对,这个问题我排过很久,最后定位到就是忘了还原left/top偏移。

3.4 Pinia 状态管理与历史记录

为什么这个项目一定要引入 Pinia?因为检测结果要被三个组件同时使用:上传组件触发请求、Canvas 组件渲染图片和框、结果表格组件展示检测明细。如果用组件 props / emit 逐层传递,跨三层组件的通讯会非常痛苦。Pinia 的 store 天然解决了这个问题:

import { defineStore } from 'pinia' import { ref } from 'vue' export const useDetectStore = defineStore('detect', () => { const currentImage = ref<string>('') const detections = ref<DetectionDto[]>([]) const history = ref<HistoryItem[]>([]) function setResult(imageUrl: string, result: DetectionResponse) { currentImage.value = imageUrl detections.value = result.detections history.value.unshift({ id: Date.now(), imageUrl, result }) } return { currentImage, detections, history, setResult } })

我特意把历史记录也放到了 Pinia 里,这样用户上传一张新图后,上一张图片的结果还在内存里,切换查看非常流畅。如果想做持久化,可以再接一个pinia-plugin-persistedstate把历史记录存到 localStorage,代码成本很低。

4. 联调、性能优化与踩坑记录

4.1 开发期高频问题速查表

整个项目开发过程中,我攒了一张问题表。很多问题都是“看着代码没毛病,跑起来就出事”的类型,值得收藏:

现象根本原因解决办法
后端识别结果全为空,置信度全为 0BGR 和 RGB 通道没转换,或没有归一化到 0~1用BlobFromImage做swapRB=true+1/255
检测框位置偏移、比例不对letterbox 的 pad 偏移没有还原后处理时用left/top/ratio反向换算坐标
手机上传的竖图识别成横图JPEG 的 EXIF 方向信息没处理后端读取图片后先做自动旋转矫正
第一次请求耗时 4~5 秒,后面很快模型加载发生在第一次推理时构造函数里初始化 Session,启动时预热一次
并发请求一多 CPU 打满,接口响应变慢无限制的并发推理挤占 CPU用SemaphoreSlim限制并发推理数
Canvas 画出来的文字边缘模糊画布分辨率跟 CSS 尺寸不一致按设备像素比设置 canvas 尺寸
中文标签显示为方框Canvas 没有加载中文字体用英文标签,或通过FontFace加载中文

这里面最容易被忽视的是 EXIF 方向。手机拍的照片不是标准正向存储,而是靠 EXIF 里的Orientation字段标记方向。OpenCvSharp的ImRead不会自动处理这个字段,所以务必先做自动旋转。我用的是ImageSharp的AutoOrient,把图片矫正后再交给检测服务。

4.2 性能优化:从肉眼可见卡顿到稳定快速返回

单张推理的性能是平台体验的关键。我拿yolov8n模型做了几轮优化,实测数据如下:

  • 640×640 输入,纯 CPU 推理,单张耗时大约 90~120ms,加上 letterbox 和 NMS,总耗时稳定在 150ms 左右。这个速度对单用户交互来说已经完全够用。
  • 320×320 输入,推理时间降到 30~50ms,总耗时 60ms 上下,但小目标漏检率明显上升。如果业务场景里有大量小物体,不能无脑缩减输入尺寸。
  • 开启 ORT 图优化后,推理又快了大约 10%~15%。代码在创建 Session 时设置:
var sessionOptions = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_EXTENDED }; _session = new InferenceSession(modelPath, sessionOptions);
  • 模型用 FP16 精度导出,在 GPU 上有明显收益,CPU 上反而可能更慢,所以纯 CPU 部署不要碰 FP16。

还有一个很关键的热身技巧。我第一次部署时发现,第一个请求要等模型加载,耗时直接飙到 4 秒多。后来我在IHostedService里加了一个启动预热任务:

public class ModelWarmupHostedService : IHostedService { private readonly IDetectionService _detector; public ModelWarmupHostedService(IDetectionService detector) => _detector = detector; public async Task StartAsync(CancellationToken cancellationToken) { await _detector.WarmupAsync(); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; }

WarmupAsync内部就是构造一张 640×640 黑色空图走一遍完整推理流程。服务启动后模型已经加载进内存,用户看到的第一发请求就是正常速度,体验提升非常明显。

4.3 生产化细节:文件限制、模型版本与并发控制

真正的平台不能只管“能跑通”,还得考虑“稳定跑”。我在生产化阶段做了这几个改造:

并发控制。同一台服务器上同时涌入十个请求时,十个推理同时跑会让 CPU 瞬间打满,单个请求反而更慢。我用SemaphoreSlim把并发推理数限制在 4:

private readonly SemaphoreSlim _semaphore = new(4); public async Task<List<DetectionDto>> DetectAsync(byte[] imageData) { await _semaphore.WaitAsync(); try { return DetectInternal(imageData); } finally { _semaphore.Release(); } }

文件类型校验。不能只信上传时的Content-Type,要用文件头做二次判断。JPEG 以FF D8 FF开头,PNG 以89 50 4E 47开头,检查前几个字节就能拦掉大部分伪装文件。

模型版本信息。在DetectionResponse里带上模型名和版本号,排查问题的时候能快速定位“这个结果是哪个模型产出的”。模型文件放在独立目录,通过配置指定路径,升级只换文件,服务重启生效。

4.4 联调部署实践

开发环境下,前端 Vite 服务通过代理把/api转发到后端 Kestrel,省去跨域配置。生产环境我用 Docker 分两个容器部署:前端 Nginx 托管静态文件,后端容器跑 ASP.NET Core。

后端 Dockerfile 用多阶段构建:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY backend/ . RUN dotnet publish -c Release -o /app/out FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY --from=build /app/out . COPY models ./models EXPOSE 8080 ENTRYPOINT ["dotnet", "DetectPlatform.dll"]

前端 Nginx 配置的核心是两条 location 规则:

location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }

这里有个容易踩的坑:Vue 3 默认使用 History 路由模式,刷新/history这类路径时 Nginx 会返回 404,必须加try_files ... /index.html把路由请求回退到首页。

部署完成后,整个平台就是一个标准的三层架构:Nginx 收流量、.NET 提供业务和 AI 接口、模型文件作为资源被加载进推理服务。前后端独立扩容、模型单独升级,这些都是后续维护时实实在在省心的地方。

项目做到这里,我最大的感受是:YOLO 模型本身并不神秘,真正拉开差距的都在“接入细节”里。通道格式、letterbox 还原、Session 复用、前端压缩、并发控制——任何一环出问题,整个识别平台的表现都会大打折扣。最后分享一个小技巧:我把模型路径、类别映射表、置信度阈值全部放进了配置文件,预热逻辑放在 HostedService 里启动时执行。这一个小小的架构决策,让我后面调了几次模型版本都只改了配置和模型文件,业务代码一行没动。你要是也准备在公司里搭类似的识别平台,建议先把“模型作为可替换资源”这条原则想清楚,后面会省下无数改动成本。

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

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

立即咨询