做计算机视觉落地时,最常遇到的尴尬局面是:图像分类、图像分割、目标检测三个任务,在团队里各搞一套模型、一套接口、一套可视化Demo。给业务同事演示的时候,光是来回切窗口就能把人绕晕。所以我当时决定做一个内部代号为 YOLO26 的统一视觉推理服务,把分类、分割、检测三类任务装进同一个模型工程,再配一个 Web 界面,交互式上传图片、实时看结果。这篇文章就把这个项目从思路、模型设计到前后端实现的关键细节全部分享出来,适合正在做视觉平台、算法服务化或者想统一自己算法工具链的开发者参考。
1. 项目整体思路与模型选型
1.1 为什么要把分类、分割、检测塞进同一个模型服务
先说一个很实际的问题:业务方要的不只是一个框或者一个标签,而是“这个东西是什么、在哪个位置、轮廓长什么样”三件事同时知道。以前我们习惯分类、检测、分割分开做,每个任务训练一个模型,单独写一套推理接口。结果就是算法同学互相之间耦合很重,前端也要对接三份文档,工程团队要维护三套部署环境,出错概率翻了好几倍。
举个生活化的类比:你想知道一桌菜里哪些是辣的,找一个人帮你试吃;想知道辣菜放在什么位置,又找另一个人记位置;想知道辣菜的汤汁覆盖范围,再找第三个人画图。三个人各干各的,信息还要手动汇总,不是不行,但特别费事。如果让一个人同时完成试吃、定位、画轮廓,效率立刻就不一样了。统一模型服务就是这个思路:共享底层特征,分任务输出结果。
回到工程上,分类、分割、检测三个任务其实都依赖“图片里有什么结构”这个底层信息。如果三个模型各自独立,等于每个模型都要从零学习边缘、纹理、形状这些通用特征,重复劳动很大。统一模型让骨干网络学习一次特征,然后三个任务头分别从同一组特征里提取自己需要的信息。这样模型体积变小,推理延迟降下来,部署也不用同时拉起好几个容器。
1.2 YOLO26作为统一模型的核心思路
先澄清一点:YOLO26 并不是某个官方正式发布版本,而是我们对当前项目所用 YOLO 系多任务模型工程的一个内部代号。YOLO 系列从最初的回归框检测开始,逐渐发展出了分类、分割、姿态估计、旋转框等不同分支。我们在这个工程里参考了 anchor-free 的检测设计、轻量级骨干网络和任务解耦头,把它们组合成一个适合自己数据集的统一结构,内部代号就叫 YOLO26。
这个设计的核心思路是“一个骨架,多个头”。骨架网络负责把输入图片变成多尺度的特征图,然后三个分支:
- 分类头:从全局特征里输出每个类别的概率。
- 检测头:在特征图每个位置预测目标的中心、宽高和置信度。
- 分割头:对检测到的目标区域逐像素生成前景/背景掩码。
之所以不直接用三个独立模型,是因为大多数图片任务之间并非完全独立。比如分割某个目标前,通常已经知道目标在哪;检测某个目标时,也知道它属于哪个类别。三个任务头共享同一个特征提取器,相当于我们让同一个骨干网络同时服务三个老板,但每个老板只拿自己需要的报表,互不干扰。
从落地效果看,统一模型最大的优势是部署路径短。以前三套模型要分别做 ONNX 导出、分别做 TensorRT 加速,现在只需要处理一份模型文件。可视化交互页面也可以把分类概率、检测框、分割掩码叠在一张图上,体验很直观。
1.3 技术栈与整体架构
这个项目的技术栈我选了以下组合:
- 训练框架:PyTorch,生态成熟,动态图方便调试。
- 模型导出:ONNX,后面可以接 ONNX Runtime 或 TensorRT。
- 后端服务:FastAPI,自带 OpenAPI 文档,前后端联调方便。
- 前端界面:原生 HTML + JavaScript + Canvas,避免引入太重的前端框架,先保证功能跑通。
- 交互通信:HTTP 上传图片 + JSON 返回结果,简单可靠。
整体流程大致是:用户在前端页面上传图片,前端把图片压缩到合适尺寸后发送给后端 FastAPI 接口,后端调用 YOLO26 模型得到分类、检测、分割结果,整理成统一 JSON 返回,前端用 Canvas 把掩码和框叠加到原图上。
架构上没有搞复杂的微服务,因为一个推理 Demo 最重要的不是“炫技”,而是稳定、清晰、方便别人接手。等算法验证完,再拆成独立推理服务也不迟。
2. 图像分类、分割、检测的核心实现要点
2.1 图像分类:不是简单贴标签,要能解释
图像分类在传统理解里就是给整张图一个类别标签。但在可视化交互场景里,只给一个标签远远不够。用户希望看到“在选这个类别时,模型到底有多少把握”,最好还能看到其他候选类别的分数排名,这样才敢信任模型。
我们在 YOLO26 的分类头里,对骨干网络输出的全局特征做全局平均池化,再接一个全连接层,输出每个类别的 logits。推理时用 softmax 转成概率,同时保留 top-5 的类别和概率,返回到前端展示成横向条形图。对于单标签分类场景,直接取最大概率;对于多标签场景,可以用 sigmoid 加阈值判断,每个类别独立输出 0 到 1 的分数。
我实操中踩过一个坑:如果分类头和检测头共用同一个特征图,而训练数据里分类类别和检测类别不一致,模型会学得比较混乱。解决办法是后面给分类头单独加了一个轻量的注意力模块,让分类分支更关注全局语义,检测分支更关注局部细节。
2.2 图像分割:从检测框内到像素级掩码
分割任务的目标是对图像中的目标进行像素级分类。传统语义分割要处理整张图的每个像素,计算量大、标注成本也高。我们这里用的实例分割思路是:先由检测头找出目标框,再在框内预测每个像素是否属于该目标。
分割头从特征金字塔的不同层级收集特征,在目标框对应的区域上做 ROI 对齐,然后通过一个小卷积网络输出掩码 logits,尺寸和该区域分辨率相关。后处理时对掩码 logits 做 sigmoid,再以 0.5 作为阈值二值化。
二值化后的掩码只是 0/1 矩阵,直接显示会显得很生硬。我在前端把掩码转成带 alpha 通道的彩色图层,边缘再做一点点羽化,看起来就自然多了。后端返回掩码时不要直接传矩阵,更通用的做法是先用 PNG 编码成 base64,前端直接放到 Canvas 上叠加,省去自己解析矩阵的麻烦。
2.3 目标检测:边界框、置信度与 NMS
目标检测是最核心的部分,因为分割和分类很多时候都是围绕检测结果展开的。YOLO26 的检测头采用 anchor-free 设计,每个特征图位置直接回归到目标中心点的距离、目标宽高和目标置信度,省去了预设锚框的复杂度。相比传统 anchor-based 方法,anchor-free 对长尾形状目标更友好,也不需要手工调锚框参数。
推理时,模型输出的原始预测里会包含大量冗余框,必须做非极大值抑制(NMS)。这里有一个容易忽略的细节:NMS 需要按类别分别执行。如果所有类别混在一起做 NMS,一个行人和一辆车重叠时,可能错误地把二者合并掉。正确做法是先把所有框按类别分组,组内按置信度排序,再逐步去掉与高置信度框 IoU 过大的低置信度框。
我实践中采用的 IoU 阈值一般是 0.45 到 0.5,置信度阈值根据场景调整。在交互页面里,我会把阈值暴露成前端可调的滑块,这样演示的时候可以直接看到阈值变化对结果的影响,特别有助于解释模型行为。
2.4 多任务统一输出结构
为了让前端只对接一套协议,我把三个任务的输出统一成一个 JSON 结构。大致是这样:
{ "classifications": [ {"class_id": 3, "class_name": "cat", "confidence": 0.98}, {"class_id": 5, "class_name": "dog", "confidence": 0.02} ], "detections": [ { "class_id": 3, "class_name": "cat", "confidence": 0.96, "bbox": [50, 30, 220, 180], "mask": "data:image/png;base64,....", "mask_polygon": [[50,30],[55,31],...] } ] }注意 bbox 坐标我统一用原图尺寸表示,而不是模型输入尺寸。因为前端要把结果画回原图,如果用模型输入尺寸,还要再做一次坐标变换,容易出错。mask 字段返回的是 PNG base64,前端直接用 Image 对象加载,减少解析成本。
这个输出结构看起来简单,但我建议在项目一开始就定义清楚,并且让前后端同学一起评审。我在真实项目里吃过亏:一开始 mask 返回的是 0/1 数组,前端为了绘制掩码写了大量循环处理代码,后来改成 base64,处理速度提升了一个数量级,前端代码也简单了很多。
3. Web交互式可视化推理的实现细节
3.1 前端交互流程:上传、预览、提交推理
Web 界面我做了三个主要区域:左侧上传区、中间图片预览区、右侧结果面板。上传区支持点击选择和拖拽上传,图片一旦选中,立即在 Canvas 上绘制预览图。这里要注意图片方向问题,很多手机照片自带 EXIF 旋转信息,如果前端不处理,预览和后端推理结果会方向不一致。
一个比较省事的做法是:前端把图片绘制到 Canvas 之前,先读取 EXIF 信息并旋转到正常方向,再把旋转后的图片转成 Blob 上传。这样后端看到的图片和用户看到的一致,也避免了后续坐标对齐的困惑。
提交推理按钮按下后,前端先把图片压缩到长边不超过 1280 像素,转成 JPEG 格式,然后用 FormData 发送给后端。为什么不直接传原图?因为原图可能达到十几兆,传太慢,而且模型推理通常也会先把图缩放到固定尺寸,先在前端压缩可以显著降低网络开销。
3.2 后端推理接口设计:一个接口同时返回三类结果
后端我用 FastAPI 写了一个/api/predict接口,接收图片文件,返回统一 JSON。核心代码逻辑很清晰:
from fastapi import FastAPI, File, UploadFile import numpy as np from PIL import Image import io app = FastAPI() # 模型在启动时加载一次,避免每次请求都重新加载 model = load_yolo26_model() @app.post("/api/predict") async def predict(file: UploadFile = File(...)): image_bytes = await file.read() image = Image.open(io.BytesIO(image_bytes)).convert("RGB") result = model.run(image) return result这里有几个关键点:模型对象必须是全局单例,不能写在函数里每次加载。因为加载一个视觉模型需要几百毫秒到几秒,如果每次请求都加载,服务基本没法用。同时,FastAPI 的异步接口里如果跑 CPU 密集的模型推理,建议用线程池执行,避免阻塞事件循环。我实际用的是run_in_executor把推理任务丢到线程池,否则并发一高,整个服务就卡住了。
输出阶段,我会在把 mask 编码成 PNG,这一步也需要用 Pillow 的保存逻辑。由于推理结果可能在多线程环境里并行产生,要注意编码函数是否线程安全,我踩过一次并发写同一个字节流的坑,后来改成每个线程独立创建BytesIO就好了。
3.3 可视化叠加:把数组变成人眼能看的图
前端拿到 JSON 之后,核心工作是在 Canvas 上叠加绘制。绘制逻辑分成三层:
- 第一层:原始图片。
- 第二层:分割掩码,用带 alpha 的彩色覆盖。
- 第三层:检测框、类别标签和置信度。
掩码绘制我用ImageData实现,因为 PNG base64 解码后可以直接通过drawImage绘制到 Canvas,效率非常高。例如:
async function drawMask(canvas, maskBase64, color) { const ctx = canvas.getContext('2d'); const maskImg = new Image(); maskImg.onload = () => { ctx.globalAlpha = 0.45; ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height); ctx.globalAlpha = 1.0; }; maskImg.src = maskBase64; }检测框的绘制用strokeRect,标签用fillText。为了避免文字和框重叠看不清,我习惯先画一个半透明背景色块,再在色块上面写字。不同类别使用不同的颜色,颜色表固定住,不要每次随机生成,否则同一个目标多次刷新颜色会变,用户会困惑。
还应该加一个“清空叠加层”的按钮,方便用户对比原图和结果图。我甚至在界面上做了一个透明度滑条,让用户调节掩码层的透明程度。这些都是很小的工作量,但对演示体验的提升非常明显。
4. 实操过程与踩坑记录
4.1 环境与依赖准备
训练环境我用的 Python 3.10 + CUDA 12.1 + PyTorch 2.x。依赖项不多,核心是以下几个:
torch:训练和导出。ultralytics:YOLO 系模型工具链,方便做数据加载和验证。opencv-python:图像读写和数据增强。onnxruntime-gpu:部署推理加速。fastapi、uvicorn:Web 服务。python-multipart:FastAPI 接收文件上传必需的包。
版本匹配是最容易踩坑的地方。PyTorch 和 CUDA 必须配对,ONNX Runtime 的 GPU 版本也要和显卡驱动配套。我建议第一次搭环境时直接参考官方提供的对应版本搭配,不要凭感觉升级。曾经有一次我把 PyTorch 升级到新版本,结果旧的 CUDA 扩展全部失效,重新编译浪费了半天时间。
4.2 训练与导出模型
数据标注阶段,我用的公开标注工具生成 COCO 格式数据。这里要提醒一下:如果同一个项目里同时涉及分类和检测,标注标签必须统一。比如分类任务里的“桌子”和检测任务里的“桌子”,如果名称不完全一致,模型训练时会当成两个类别,导致分类分支和检测分支互相干扰。
训练超参数我给出一个可复现的配置:
| 参数 | 取值 | 备注 |
|---|---|---|
| 输入尺寸 | 640x640 | 兼顾精度与速度 |
| 批量大小 | 16 | 视显存大小调整 |
| 初始学习率 | 0.01 | 配合余弦退火 |
| 优化器 | AdamW | 收敛稳定 |
| 训练轮数 | 100 | 后期早停 |
| 数据增强 | Mosaic + HSV扰动 + 随机翻转 | 提升泛化能力 |
训练完成后,导出 ONNX 时我用的opset=12,并用动态输入尺寸。这样同一个模型可以处理 640、768、1024 等不同分辨率的输入,前端也能根据图片尺寸选择合适的分辨率。不过动态输入尺寸会让某些部署优化失效,所以正式上线时如果分辨率固定,还是建议导出固定尺寸的静态模型,性能更好。
4.3 部署与性能调优
模型部署阶段,最影响体验的是首帧延迟。我做的第一版服务没有预热,第一次推理时 CUDA 上下文初始化、ONNX 缓存创建都特别慢,前端要等好几秒才有结果。后来我在服务启动时加载模型后主动跑一次空数据推理,把 CUDA kernel 都预热好,后续每帧延迟直接降到了几百毫秒内。
另外,图片缩放方式也会影响结果一致性。YOLO 系模型训练时采用的是 letterbox,也就是把图片等比例缩放后,用灰色填充到正方形输入尺寸。如果推理时直接拉伸到正方形,会导致目标形变,检测精度明显下降。所以后端推理管线必须和训练保持一致,否则离线测试指标再高,线上效果也会打折扣。
我在前端还做了一个小优化:同一张图片重复推理时,如果图片没有变化,直接返回缓存结果,不再调用后端。这样演示过程中拖动阈值滑块时,不用每次都重新上传图片,体验流畅很多。
4.4 常见问题与排查速查表
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 首次推理特别慢 | CUDA 上下文未初始化 | 启动时 warmup 一次空推理 |
| 检测框坐标偏移 | 前后端坐标系不一致 | 明确 bbox 使用原图坐标 |
| 掩码错位 | 掩码分辨率与输入图尺寸不一致 | 统一 letterbox 后对齐坐标 |
| 并发请求时服务卡死 | 模型推理阻塞事件循环 | 用线程池执行推理 |
| 上传图片方向不正确 | EXIF 信息未处理 | 前端 Canvas 旋转后再上传 |
| mask PNG 返回为空 | 编码线程不安全 | 每个线程独立 BytesIO |
| 横竖屏图片检测不准 | 训练数据方向单一 | 训练时做随机 90 度旋转增强 |
这张表是我实际排查出的高频问题,很多都是“看起来能跑,但细节没对”的典型。如果大家遇到类似现象,可以先对照表里排查一遍,往往比重新看代码更快。
5. 应用场景与扩展方向
5.1 典型场景:工业质检、辅助标注与教学演示
这套交互式可视化推理服务,最直接的应用场景是工业质检。比如某个元件的表面缺陷检测,业务方既想知道缺陷是什么类型,又想知道缺陷出现在哪个位置、覆盖范围多大。传统做法是分别部署分类模型和检测模型,再写一个拼接逻辑。用 YOLO26 统一模型后,一个接口直接返回所有信息,现场质检员通过浏览器就能看到高亮叠加结果,省去了复杂的客户端程序。
另一个很实用的场景是辅助标注。交互页面上传图片后先给出模型预测的框和掩码,标注人员只需要微调边缘,不用从零开始画。这个流程能把标注效率提升不少。实测下来,以前一个复杂的实例分割目标要标两三分钟,现在二十秒左右就能修正完成。虽然这里说的是内部工具,但思路完全可以移植到数据生产平台。
教学演示也是这个项目的重要定位。学生只用一个页面就能同时看到分类、检测、分割三种任务的输出,还能调节阈值观察模型敏感度,理解深度神经网络的行为特征比看一堆指标曲线的效果好得多。技术分享或项目汇报时,现场上传一张照片,几秒后叠加结果直接投出来,比 PPT 里的静态截图有说服力。
5.2 还能怎么扩展:视频流推理与批量分析
如果想把 Demo 变成可落地的服务,下一步我建议扩展视频流推理。后端增加一个 WebSocket 接口,前端打开摄像头或上传视频文件,后端抽帧后送入模型,结果实时回传。注意视频推理不要逐帧全部分割,可以每隔几帧做一次检测,中间帧通过简单的目标跟踪关联框,这样能大幅降低计算量。
批量分析也是常见需求。把单张图片的/api/predict扩展成一个批量任务接口,上传压缩包或指定文件夹,后台用消息队列消费图片,结果统一写入数据库或导出 CSV。我实践中的做法是先落盘原始结果 JSON,再做可视化统计,比如各类别出现频次、置信度分布、检测框面积分布等。这些统计信息放在交互页面里,比单纯看单张图更有业务价值。
模型本身的优化空间也很大。如果觉得统一模型体积还是偏大,可以对 YOLO26 做剪枝和蒸馏,用一个更大的教师模型指导当前模型学习,把参数量压到原来的三分之一。剪枝后量化到 FP16 或 INT8,部署延迟能进一步降低,特别适合边缘设备和实时场景。
最后再分享一个小技巧
如果让我重新做一遍这个项目,我会在一开始就把 mask 的返回格式定为“PNG base64 + 可选多边形坐标”,而不是 0/1 数组。不要小看这个字段设计,它直接关系到前端渲染效率和代码简洁度。第二个小技巧是,把分类、检测、分割三类结果放在同一条时间轴上打日志,每次请求都记录处理耗时、模型置信度和前端渲染耗时,方便后续做性能回归。这个项目看起来只是一个“模型 + 网页”,但当交互方式足够顺手后,它就不再是演示工具,而会成为你日常验证算法、排查数据问题的高频工作站。