ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于YOLO26的图像分类、目标检测与分割统一Web推理应用

基于YOLO26的图像分类、目标检测与分割统一Web推理应用

写在前面,我先说个实际场景。模型训练好之后,跑出的是命令行里一串Detections对象,或者是乱糟糟的马赛克可视化图。你想让同事、客户甚至女朋友看一眼效果,得把脚本改参数重新跑一遍,再把输出截图发过去。这套流程效率低,而且没法互动。

我做的这个项目就是围绕这个痛点展开的:把深度学习里的图像分类、目标检测、图像分割三个常见任务,全部收敛到一套基于yolo26模型的推理链路里,再包一层Web界面,让任何人打开浏览器就能上传图片、点按钮、看结果。整个过程涉及模型选型、推理接口设计、后端服务搭建、前端可视化渲染,踩了不少坑,也沉淀出不少可以直接拿走的经验。本文就把完整链路和实操细节写清楚,适合正在做CV Demo、模型演示平台或者内部工具平台的开发者参考。

1. 先把项目拆明白:yolo26 到底要做什么网页

1.1 一句话概括这个项目的核心形态

这个项目的最终形态是这样的:浏览器里打开一个页面,左侧是图片上传区,右侧是结果展示区,上面有三个按钮——分类、检测、分割。你拖一张图片进去,点一下按钮,模型推理完成后,页面上会叠加绘制出预测标签、置信度、边界框,或者像素级的目标掩码。整个过程不需要写一行代码,不需要懂深度学习,也不需要装任何环境。

用到的核心模型是yolo26。这里我解释一下,yolo26并不是这一系列里某个特定的官方版本号,而是社区里演进出来的工程化模型体系,它在分类、检测、分割这些任务上都有对应的权重结构。项目里真正跑通的,就是把这些权重统一加载、统一预处理、统一后处理,最后通过一套Web接口暴露出去。

和传统脚本演示相比,Web交互式推理有几个明显优势,我在选型时就认定了这几点:

  • 跨平台,不依赖操作系统和Python环境,浏览器就是客户端;
  • 多人可同时访问,适合部署在实验室或公司内部服务器上给团队演示;
  • 交互过程可视化,参数调整、结果对比、错误案例收集都方便,不用反复改代码。

这个项目适合谁来参考?如果你是做算法工程的,需要快速搭建一个模型效果展示平台;或者你是做毕业设计、课程项目,需要把CV模型做成一个能演示的产品;再或者你是前端工程师想了解后端推理和前端可视化的衔接方式,这篇都能给你一条完整的路线。

1.2 三个视觉任务的差异化设计

图像分类、目标检测、图像分割虽然都叫计算机视觉任务,但它们的输入输出差异非常大,放在同一个页面里需要好好设计交互逻辑。

分类任务的输出最简单:一个类别ID加一个置信度分数,模型看到的是整张图片的内容。检测任务要复杂一些,它需要输出多个目标框,每个框里有类别和置信度,框的位置用坐标表示。分割任务最重,它要输出一个和原图同尺寸的掩码,或者一组多边形的顶点坐标,用来精确定位每个目标的像素区域。

我最初犯过的一个错误是把三个任务当成三个独立的系统来做,后来发现这样的话前端要维护三套状态逻辑,后端要写三套API,图片上传、预处理、结果格式化全部重复。业内习惯是把它们收敛到同一个抽象接口里,用task_type参数区分,后面你会在推理接口设计部分看到具体做法。

三个任务还有一个共同点:它们都依赖torch、模型权重、数据预处理这些基础组件。把公共逻辑抽出来,就能避免三套代码各改各的、互相不一致的问题。尤其是预处理部分,分类模型一般用224或256的输入尺寸,检测和分割模型通常用640,这里需要统一管理,而不是散落在各处。

1.3 为什么选择Web界面作为可视化载体

有人会问,既然有matplotlib可以直接画框、画掩码,为什么非要折腾一个Web界面?

我的答案是:可视化本身不是目的,交互才是。matplotlib一次只能跑一张图,跑完就结束了。Web界面能让你反复试图、对比不同模型的输出、收集bad case,甚至让别人远程投票评价模型效果。在项目验收、客户演示、教学场景里,一个能点的界面比一百张截图都有说服力。

而且Web技术栈做可视化有天然优势。Canvas可以自由绘制边界框和掩码,SVG可以叠加矢量标签,同一份结果数据既能渲染成图片也能导出成JSON。项目做到后面,你会发现这个界面不只是模型演示工具,它还顺便变成了一个数据标注质检工具——点开一张图,看看模型有没有漏检、有没有误检,直接在页面上打标签就行。

正是基于这些考虑,我的技术选型定为:后端用Python生态,FastAPI提供接口,Uvicorn做服务;前端用原生HTML、CSS、JavaScript加Canvas绘制,不引入重型前端框架。这样能最大程度降低部署成本和依赖体积,换一台机器也能很快跑起来。

2. 模型选型与权重准备:分类、检测、分割三线并行

2.1 同一框架下的多任务模型怎么选

把三个任务放在同一个项目里,最大的麻烦就是模型来源不统一。有些开源模型用的是TensorFlow的权重,有些是PyTorch的,有些甚至要自己去转换格式,处理起来非常费劲。

所以我直接选定了yolo26这套体系。理由很简单:它在分类、检测、分割三个任务上都提供了统一风格的权重文件,而且推理代码风格一致,预处理和后处理接口也相似。你不需要为一个任务写一套定制的模型加载逻辑,多数情况下只需要改一下模型配置的task字段。

这里还要考虑模型尺寸的选择。我实际跑的时候,给三个任务各准备了s和m两种体积的权重。小权重适合CPU环境跑快速验证,大权重适合追求精度的场合。以我的实测数据为例:

任务输入尺寸小权重推理耗时(GPU)大权重推理耗时(GPU)适用场景
分类224x224约3ms约6ms快速批量预测
检测640x640约12ms约20ms通用目标定位
分割640x640约18ms约35ms精细区域标注

这个耗时是在一张算力中等的GPU卡上测出来的,CPU环境整体会慢五到八倍,但页面交互仍然可接受。如果你要在CPU上跑,我建议统一用小权重,后面我会讲怎么控制并发的策略。

选模型时还有一个必须注意的细节:yolo26虽然是同一系列,但分类模型、检测模型、分割模型的输出结构完全不同。分类模型的输出是个一维向量,检测模型的输出是某个特定格式的张量,分割模型除了目标框还会输出掩码系数。后面封装统一接口的时候,这些差异都需要处理。

2.2 权重下载与格式转换

拿到权重之后,第一步不是直接加载,而是确认格式和输入要求。项目里我把权重放在独立的weights/目录下,按任务分文件夹存放,这样结构清晰:

weights/ ├── classify/ │ ├── cls_s.pt │ └── cls_m.pt ├── detect/ │ ├── det_s.pt │ └── det_m.pt └── segment/ ├── seg_s.pt └── seg_m.pt

加载权重我用的是项目统一的api模块:

from core import YOLO26API # 初始化,加载三种任务的模型 api = YOLO26API( cls_model_path="weights/classify/cls_s.pt", det_model_path="weights/detect/det_s.pt", seg_model_path="weights/segment/seg_s.pt", device="cuda:0" # 没有GPU就换 "cpu" )

如果你从外部转换过来的权重只有.weights或.pth格式,建议先转成.pt格式再统一加载。转换主要做两件事:加载原始权重,重新包装成带输入输出结构定义的模型对象,然后保存。不要直接在推理脚本里去处理各种历史格式,那样会污染主逻辑。

2.3 输入尺寸与预处理统一

三个任务的输入尺寸不同,但预处理步骤可以抽成一套公共流程。我设计了一个ImagePreprocessor,它的职责是:

  1. 读取图片,解码成RGB数组;
  2. 按模型要求的尺寸做等比缩放;
  3. 保持长宽比,多余部分用灰色填充(letterbox);
  4. 归一化到0~1的范围,调整成模型输入张量的形状。

这里最容易踩的坑就是分类模型的预处理和检测/分割模型不一致。分类模型通常不做letterbox,而是直接resize到正方形;检测和分割模型如果不做letterbox,目标框坐标换算起来会非常麻烦。我后来把差异封装成了配置参数:

def preprocess(image: np.ndarray, task: str) -> torch.Tensor: if task == "classify": image = resize(image, (224, 224)) elif task in ["detect", "segment"]: image, ratio, offset = letterbox(image, (640, 640)) meta = {"ratio": ratio, "offset": offset} ...

这里插一句,letterbox之后的坐标映射逻辑非常重要。你在前端看到的边界框坐标,是原图坐标,不是resize之后的坐标。推理得到的框坐标必须先减去offset,再除以ratio,才能映射回原图。如果这个映射做错,检测框和分割掩码就会发生很明显的偏移,这是视觉上最容易暴露的bug。

3. 推理引擎的封装思路:一个接口吃下三个任务

3.1 统一推理入口的设计

我把三种任务的接口抽象成了一个统一入口,前端只需要发一个请求,指定要跑哪个任务。核心API设计长这样:

@app.post("/api/inference") async def inference(req: InferenceRequest): image = await decode_image(req.image) if req.task == "classify": result = api.classify(image, topk=req.topk) elif req.task == "detect": result = api.detect(image, conf_thres=req.conf_thres, iou_thres=req.iou_thres) elif req.task == "segment": result = api.segment(image, conf_thres=req.conf_thres, iou_thres=req.iou_thres) else: raise HTTPException(status_code=400, detail=f"Unsupported task: {req.task}") return result

前端传的内容包括:图片数据、任务类型、置信度阈值、IoU阈值。返回的JSON统一包含task_type、boxes、masks、labels、scores这些字段,没有对应内容的就返回空数组。

这样设计的好处是,前端代码只需要处理一张结果表,而不是三套不同的返回结构。后期如果要在同一张图上同时跑检测和分割,也可以在这个统一结构上扩展。

3.2 后处理差异:NMS、掩码生成、标签映射

后处理是三个任务差异最大的地方,也是最容易出现细节bug的环节。

分类任务的后处理最简单:从输出向量里取前K个最高分的索引,映射到类别名,然后按置信度从高到低排序。这里要注意的是,有些模型输出的置信度没有经过softmax,只有原始logits,直接看分数容易误判。我封装的时候统一做了一步softmax,保证返回的分数是0~1之间的概率。这样前端展示"置信度 82.5%"才有意义。

检测任务的后处理是经典的NMS。模型会输出一大堆候选框,里面有很多重叠的冗余框,需要用非极大值抑制保留最可靠的那个。NMS的两个核心参数是置信度阈值和IoU阈值。置信度阈值越低,检出的目标越多,但误检也会变多;IoU阈值越高,重叠框越难被抑制,容易出现同一个目标标多个框。

分割任务最复杂。它的输出除了边界框,还有一组掩码系数,需要结合模型里的原型掩码(prototype masks)做矩阵运算,才能得到每个目标的像素级掩码。之后还要把浮点掩码做二值化,然后转成多边形坐标,方便前端Canvas绘制。

掩码生成里我踩过一个很深的坑:直接返回整张二值掩码图像,会导致JSON体积爆炸。一张640x640的掩码图,如果以数组形式返回,大小是40万个数值,接口响应时间直接爆炸。后来改成用opencv的findContours提取多边形轮廓,只返回轮廓顶点坐标。一张图上几十个目标,每个目标几十到几百个点,JSON体积立刻降到几十KB,前端绘制也更流畅。

3.3 性能基线:张量、批处理与显存控制

推理性能直接影响Web页面的交互体验。实际使用中,如果点一次按钮要等两秒,用户就会觉得卡。我跑下来的性能基线是:单张图片,分类不超过20ms,检测不超过30ms,分割不超过50ms,这个量级在GPU环境下是没问题的。

但Web服务是并发场景,不能只按单张图片来算。多个用户同时上传图片时,Python的服务会因为全局解释器锁(GIL)和推理线程的串行限制,导致请求排队。我用的是ThreadPoolExecutor做推理任务的异步化,同时把模型推理的torch操作放在独立线程里,避免阻塞事件循环。

显存控制更关键。如果你的显卡只有8G显存,同时加载分类、检测、分割三个模型,显存很容易爆。我的做法是:

  1. 推理按需切换模型,比如前端请求检测,只加载检测模型;如果请求分割,再切到分割模型。这样同一时刻显存里只占一个模型;
  2. 或者把不用的模型权重卸载到CPU,只保留当前任务在GPU上;
  3. 为每个模型设置固定的批处理大小,默认等于1。

模型切换有个额外开销,大约50到200ms,但对于交互式演示场景完全能接受。优先保证的是稳定性和不崩溃。

4. Web界面交互:从上传图片到流式渲染的全流程

4.1 前端页面布局与组件选择

前端的页面布局我是这样设计的:上方是标题栏,下方分成左右两栏。左栏是图片预览区和上传按钮,支持拖拽上传;右栏是结果面板,有任务类型选择按钮、参数滑动条,以及Canvas画布。

整个页面没有引入任何前端框架,只用了原生HTML和JavaScript,原因有两个:减少构建环节的复杂程度,部署时只需把静态文件丢给后端;原生Canvas绘制已经够用,用Vue或React反而要多学一套状态管理。

需要同样布局的话,可以参考这个骨架:

<div class="container"> <div class="left"> <div id="uploader">拖拽图片到这里 / 点击上传</div> <img id="preview" alt="预览"> </div> <div class="right"> <div class="task-buttons"> <button>function drawBoxes(ctx, boxes, labels, scores) { boxes.forEach((box, i) => { ctx.strokeStyle = colorMap[labels[i]]; ctx.lineWidth = 3; ctx.strokeRect(box[0], box[1], box[2] - box[0], box[3] - box[1]); ctx.fillStyle = colorMap[labels[i]]; ctx.fillText(`${labels[i]} ${(scores[i]*100).toFixed(1)}%`, box[0], box[1] - 5); }); } function drawMasks(ctx, masks) { masks.forEach(points => { ctx.fillStyle = 'rgba(255, 0, 0, 0.3)'; ctx.beginPath(); points.forEach((p, idx) => { idx === 0 ? ctx.moveTo(p[0], p[1]) : ctx.lineTo(p[0], p[1]); }); ctx.closePath(); ctx.fill(); ctx.stroke(); }); }

绘制坐标需要特别注意Canvas的坐标系。Canvas的左上角是原点,x轴向右,y轴向下,这和很多图像坐标系是吻合的。但如果你用了ctx.setTransform做缩放,坐标映射就会乱套。我的解决方案是:Canvas的像素尺寸和原图原始尺寸保持一致,然后用CSS控制显示大小。这样绘制坐标直接使用原图坐标,不需要在Canvas内部做二次缩放。

掩码的填充色我用了半透明红色,边框和标签用了按类别映射的不同颜色。同一个类在不同图片里保持统一颜色,这个细节很多人忽略,但对用户体验影响很大。我实现时维护了一个简单的哈希映射,把类别ID映射到一组预设颜色上。

5. 实测中的坑与优化:并发、延迟与结果一致性

5.1 踩坑实录:大图超时与内存暴涨的排查链路

这个项目刚跑通的时候,我用一张5000x3000像素的卫星图做测试,结果页面直接卡死,后端日志显示请求超时。排查过程是这样的:

第一步,检查前端是否传图成功。浏览器Network面板显示图片上传成功,请求状态pending了很久才超时,说明问题出在服务端。

第二步,检查推理耗时。单独写了个小脚本,把同一张图片喂给模型,发现检测耗时200ms,并不算慢。但结合日志发现,图片解码阶段耗时极长。原因很简单,5000x3000像素的图片,解码成numpy数组后,单通道占用内存是15MB,三通道45MB。再加上letterbox、缩放、归一化,每一步都产生中间数组,峰值内存轻松突破300MB。

第三步,优化方向定为两步:一是在前端上传前压缩图片,限制长边不超过2048像素;二是在后端增加图片尺寸检查,超限直接等比例缩小。这个改动之后,同样一张图片的接口耗时从超时降到300ms,内存峰值降到100MB以内。

这个坑给我的教训是:CV推理服务不能默认输入是干净的。生产环境里,用户上传的图片什么尺寸都有,8K超高清、几十MB的大图、甚至损坏的图片都会出现。在入口统一做尺寸和格式清洗,比在算法层做防御要有效得多。

5.2 用队列和缓存提升并发体验

当多个用户同时点击按钮时,推理请求会竞争GPU资源。如果不加控制,请求会全部塞进模型推理,导致显存溢出或推理互相阻塞。

我的解决方案是引入一个简单的请求队列:所有推理请求先进队列,线程池从队列里取任务,按顺序执行。因为单张推理本来就只要几十毫秒,排队带来的额外等待完全可以接受,但能保证系统不会因为并发崩掉。

除了推理队列,我还加了结果缓存。对同一个任务的同一张图片,如果请求参数完全一致,直接返回上一次的推理结果,不再重新推理。这样在演示时反复点同一个按钮,不会造成无意义的重复计算。

缓存的key我这样设计:

cache_key = f"{task}_{hash(image_bytes)}_{conf_thres}_{iou_thres}"

需要注意的是,不能用文件路径做缓存key,因为用户可能上传两张内容相同但文件名不同的图。用图片内容的哈希值更可靠。

5.3 结果一致性:随机性、缓存与公平性

模型推理本身是确定性的,同样输入同样权重,输出应该完全一致。但实测中我遇到过两次结果不一致的情况。

第一次是发现检测框的坐标偶尔有非常细微的抖动。排查后发现,图片解出来后,某个步骤用到了线程共享的随机种子,导致预处理随机状态被污染。解决方法是,在推理线程里显式地固定随机种子:

def inference_thread(task_id): torch.manual_seed(0) np.random.seed(0) ...

第二次是GPU和CPU的结果不一致。同一张图在GPU上推理,和切到CPU推理,检测框的坐标会有零点几像素的偏移。这是因为浮点运算在不同的硬件指令集上会有微小差异。对于纯演示项目,这点差异肉眼不可见,但如果你要做严格的自动化测试或结果比对,建议固定用同一个设备。

体验设计上还有个小细节:多用户并发时的公平性问题。如果没有队列限制,前一个用户的超大任务会把后一个用户的小任务挤到队尾,造成体验差异。我后来给每个任务预算一个预计耗时,超时直接返回错误提示,而不是让用户无限等待。页面端也加了loading动画和"推理中"状态,避免用户重复点击按钮触发多个相同的请求。

写在最后的实操经验

做这个项目最大的收获不是模型效果本身,而是让我重新认识了"模型→服务→界面"这条链路的复杂度。模型在脚本里跑得再快,服务端接口设计不好、前端可视化不直观,效果都展现不出来。真正能让非技术人员动手体验的,一定是交互干净、反馈及时、结果清晰的完整系统。

如果你要复现这个项目,我建议从最小闭环开始:先跑通检测任务,用FastAPI暴露一个最简单的接口,前端画一个能显示框和置信度的页面。确认这个链路稳定之后,再接入分类和分割。一次上三个任务,你会被调试问题淹没。

最后分享一个小技巧:如果你用的Canvas在绘制时发现边框位置和原图对不上,先检查图片是不是被CSS缩放了,再检查请求里的坐标单位是否统一。这个问题的定位优先级高于所有算法参数调整。祝大家都能跑出一个让同事眼前一亮的交互式推理Demo。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进