YOLOv8训练好的模型怎么落地,这事我纠结过很久。模型在Python里跑得挺欢,一到真实业务要接入,就避不开接口怎么写、并发怎么控、环境怎么部署这一连串问题。后来我把Spring Boot、Docker、YOLOv8串成一条完整链路:训练好的pt权重先导出成ONNX,在Spring Boot里用ONNX Runtime做Java推理,最后用Docker容器化部署。这套方案的核心价值在于,把耗时、易碎的Python推理环境彻底隔离在容器外面,让检测能力变成一个标准HTTP接口。业务方不需要关心底层是Python还是C++,只要传一张图过来,就能拿到检测框坐标、类别和置信度。如果你也在做模型落地,或者想给团队提供一个统一的检测服务,这篇文章可以当一份完整参考。
1. 项目概述与整体设计思路
1.1 检测服务到底要解决什么问题
先说场景。多数算法团队做目标检测时,模型训练完只是第一步,真正头疼的是把模型给到业务方用。业务方不可能每个人都在自己电脑上装Python、配CUDA、装Ultralytics,也不会接受你抛过去一个Jupyter Notebook让他们自己跑。他们想要的是一个接口:图片传进去,坐标、类别、置信度返回。这就需要一个稳定的服务化封装。
用Spring Boot来做这个封装,最大的原因在于Java后端生态的成熟度。团队里如果有成熟Java服务,可以很自然地把推理能力融入现有系统,用上现成的注册发现、配置中心、日志链路、限流熔断。相比之下,直接用FastAPI来实现会更轻量,但遇到需要和其他Java微服务打通、统一权限体系时,反而要多维护一套技术栈。我做这个项目时也考虑过混合方案:Python推理微服务加Spring Boot网关,但后来觉得单机部署场景下,引入跨语言调用反而增加运维复杂度和网络开销,不如在JVM内直接推理来得干净。
Docker在这里解决的是环境一致性问题。YOLOv8在PyTorch里训练,底层依赖LibTorch、CUDA等一大堆动态库,模型一旦导出成ONNX,在JVM侧就只需要ONNX Runtime这一个核心依赖。把ONNX Runtime、Spring Boot应用、模型文件一起打进镜像,这个容器在任何一台装了Docker的服务器上跑起来,结果都是一样的。开发环境、测试环境、生产环境之间的“在我机器上好好的”现象,可以彻底杜绝。
1.2 技术选型的心路历程
选型上我纠结过几轮,核心矛盾是模型推理在Java侧到底行不行。2020年前后,JVM生态的目标检测方案还很别扭,要么调Python子进程,要么用DJL这种框架中转一层。直到ONNX Runtime的Java API逐渐稳定,我才觉得可以认真做。
我做这道题时确定的最终技术栈是:
- 模型训练与导出:Ultralytics YOLOv8,训练完成后导出为ONNX格式;
- 推理引擎:ONNX Runtime 1.17.x Java版,集成在Spring Boot进程内;
- 服务框架:Spring Boot 2.7/3.x,提供REST接口和基础服务能力;
- 容器化:多阶段Dockerfile构建,docker-compose编排;
- 镜像版本:基础镜像选用eclipse-temurin,JDK 17。
为什么不用Python侧直接推理再包一层RPC?因为多一个Python进程,就多一份内存开销和守护成本。ONNX Runtime Java版推理速度和Python版几乎一致,CPU上差距通常在个位数毫秒级别。而Spring Boot进程本来就常驻,推理引擎和业务逻辑在同一个进程内,内存复用效率更高,接口响应链路也更短。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与核心依赖
2.1 基础环境搭建清单
这个项目的对环境要求不算苛刻,但有几个版本坑要提前避开。
训练侧建议用Python 3.8到3.10,Ultralytics版本选8.0.x以上的稳定版,我这边用的是8.2.x。这里有个很重要的提醒:训练用的Python版本和导出ONNX时的opset版本是绑定的,不同组合下导出的模型行为会有细微差异。我遇到过在Python 3.11导出模型后,Java侧解析Tensor维度顺序出现不直观的情况,后来统一在Python 3.10环境操作,问题消失。
服务侧环境如下:
- JDK 17:ONNX Runtime 1.17最低要求Java 8,但Spring Boot 3.x要求Java 17,建议直接用17;
- Maven 3.8+:用于构建Spring Boot工程;
- Docker Desktop 4.x:Windows环境开发时的首选,macOS同理;
- Git:版本管理,不做过多解释。
Windows下特别注意,Docker Desktop启动时报“virtualisation support not detected”这类错误时,问题基本出在BIOS的虚拟化开关,或者Windows功能里的“虚拟机平台”没有勾选。这个在后面的问题排查章节单独说。
2.2 Maven依赖与项目骨架
Spring Boot的工程骨架直接通过Spring Initializr生成,只需要勾选Spring Web。额外要加的核心依赖只有ONNX Runtime一个。我在pom.xml里是这样配的:
xml复制<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>1.17.1</version>
</dependency>
ONNX Runtime的Java构件分CPU版和GPU版,artifactId分别是onnxruntime和onnxruntime-gpu。GPU版体积大一倍多,而且依赖CUDA和cuDNN的特定版本,如果不是对性能有硬性要求,建议先用CPU版跑通整个链路,后续再按需升级。我就是先用CPU版开发调试,确认整条链路没问题后,再在测试环境单独验证GPU版。
除了ONNX Runtime,有几个Spring Boot的常用依赖建议一起加上:
- spring-boot-starter-validation:接口参数校验;
- spring-boot-starter-actuator:提供健康检查端点,Docker Compose里做healthcheck要用;
- 图片处理不需要额外引库,Java自带的ImageIO足够,但要注意ImageIO对PNG和JPEG的编码支持有差异,稍后会说到。
项目结构上,我按分层方式组织,controller层只管HTTP接口,service层封装推理全流程,inference包放ONNX Runtime会话管理和预处理后处理逻辑,model包定义检测结果实体。模型文件放在resources/models目录下,打包时打进jar,也可以放外部目录挂载进容器。两种方式各有利弊:打进jar方便分发,挂载外部目录方便更新模型不用重新构建镜像。我的做法是默认打包进jar,生产环境改用挂载方式。
3. YOLOv8模型导出与预处理细节
3.1 从PyTorch权重导出ONNX模型
训练好的YOLOv8权重是.pt格式,这个格式依赖PyTorch环境,不能直接在Java里加载。要经过ONNX这个中间格式转换。
导出命令非常简单,Ultralytics已经帮你封装好了:
python复制from ultralytics import YOLO
model = YOLO("runs/detect/train/weights/best.pt")
model.export(
format="onnx",
opset=12,
simplify=True,
dynamic=False
)
这里几个参数值得单独说。opset=12是ONNX Runtime支持得比较稳的算子集版本,太高了反而可能出现算子兼容问题。simplify=True会调用onnx-simplifier做一些图优化,去掉冗余算子,体积能缩小一些。dynamic=False意味着输入尺寸固定为640x640,推理时不需要动态维度支持,速度和稳定性都更好;如果你的业务需要输入任意尺寸图片,那就得设置dynamic=True,但Java侧读取输出Tensor时要特别注意维度是动态的,处理逻辑会复杂不少。
导出后你会得到一个best.onnx文件,可以先用Netron打开看一眼模型结构。YOLOv8的输出节点会显示为[1, 84, 8400]这样的Tensor维度,其中84是4个坐标 + 80个类别,8400是三个尺度特征图的候选框总数(80x80、40x40、20x20)。如果你训练的是自定义数据集,只有N个类别,那输出维度就是[1, 4+N, 8400],后面做后处理时这个N会直接影响逻辑写法和输出长度。
这里有个对新人很友好的经验:如果你的模型导出后已经用了simplify,但体积还是偏大,可以考虑把推理用的模型用half=True导出为FP16精度,不过这会要求ONNX Runtime推理时开启相应配置,一般不建议在生产环境普通CPU机器上尝试。
3.2 预处理为什么必须和训练时保持一致
模型部署后效果不稳定,十有八九是预处理和训练时不一致导致的。YOLOv8官方训练时用的是letterbox缩放:把图片按比例缩放到模型输入尺寸,不足的部分用灰色填充到640x640,而不是直接把图片粗暴拉成640x640。直接拉伸会造成目标形变,检测框的位置和置信度都会受影响,尤其对长宽比差异大的图片影响非常明显。
Java侧实现letterbox的代码长这样:
java复制private static float[] letterboxAndNormalize(BufferedImage image, int inputSize) {
int srcW = image.getWidth();
int srcH = image.getHeight();
double scale = Math.min((double) inputSize / srcW, (double) inputSize / srcH);
int newW = (int) Math.round(srcW * scale);
int newH = (int) Math.round(srcH * scale);
BufferedImage resized = new BufferedImage(inputSize, inputSize, BufferedImage.TYPE_3BYTE_BGR);
Graphics2D g2d = resized.createGraphics();
g2d.setColor(new Color(114, 114, 114)); // YOLOv8 默认pad值
g2d.fillRect(0, 0, inputSize, inputSize);
int offsetX = (inputSize - newW) / 2;
int offsetY = (inputSize - newH) / 2;
g2d.drawImage(image, offsetX, offsetY, newW, newH, null);
g2d.dispose();
float[] rgbPixels = new float[3 * inputSize * inputSize];
int idx = 0;
for (int y = 0; y < inputSize; y++) {
for (int x = 0; x < inputSize; x++) {
int rgb = resized.getRGB(x, y);
float r = ((rgb >> 16) & 0xFF) / 255.0f;
float g = ((rgb >> 8) & 0xFF) / 255.0f;
float b = (rgb & 0xFF) / 255.0f;
rgbPixels[idx] = r;
rgbPixels[idx + inputSize * inputSize] = g;
rgbPixels[idx + 2 * inputSize * inputSize] = b;
idx++;
}
}
return rgbPixels;
}
这段代码做了两件事:一是letterbox缩放生成640x640的输入图,二是把RGB值从0-255归一化到0-1之间,并按“先所有像素的R通道,再G通道,再B通道”的CHW顺序填入数组。CHW顺序是ONNX Runtime的标准输入格式,顺序反了模型输出的结果完全不对,这是新手最容易踩的坑之一。
4. 推理服务核心实现
4.1 ONNX Runtime推理会话的封装
ONNX Runtime的Java API设计非常简洁,核心就两个对象:OrtEnvironment和OrtSession。OrtEnvironment一般是全局单例,一份模型对应一个OrtSession实例。
我在封装时踩过一个共性问题:OrtSession不是线程安全的。严格来说,多个线程并发调用同一个session的run方法是不被推荐的,虽然ONNX Runtime内部有锁不会崩溃,但并发性能会退化成串行。所以我在实现时做了一个简单的会话池,按并发量预先创建几个session实例,用线程池轮询分配。如果你用Spring的@Bean注册一个单例session,并发量不大时倒是能用,但压测到20并发以上就会明显感到吞吐上不去。
核心推理代码大致是这样:
java复制@Service
public class YoloV8Detector {
private OrtEnvironment environment;
private OrtSession session;
private static final long[] INPUT_SHAPE = new long[]{1, 3, 640, 640};
@PostConstruct
public void init() throws OrtException {
environment = OrtEnvironment.getEnvironment();
// 模型文件在resources/models目录下
InputStream modelStream = getClass().getResourceAsStream("/models/best.onnx");
session = environment.createSession(modelStream, new OrtSession.SessionOptions());
}
public float[][] inference(float[] inputPixels) throws OrtException {
try (OnnxTensor inputTensor = OnnxTensor.createTensor(
environment, inputPixels, INPUT_SHAPE)) {
OrtSession.Result result = session.run(Map.of("images", inputTensor));
OnnxTensor outputTensor = result.get(0);
float[][][] output = (float[][][]) outputTensor.getValue();
// 输出维度 [1, 84, 8400],转置成 [8400, 84] 方便处理
return transpose(output[0]);
}
}
}
注意输入Tensor的Map.of("images", inputTensor),key的名称需要和导出的ONNX模型的输入节点名一致。导出的YOLOv8模型输入节点名默认是images,输出节点名是output0。如果导出时做过重命名,这里也要同步改。验证方法很简单:用Netron打开onnx文件看输入输出名,或者用Python的onnxruntime包打印session.get_inputs()。
4.2 后处理里最需要小心的维度转换
后处理是所有环节里出bug最多的阶段。YOLOv8输出的Tensor维度是[1, 84, 8400],第一维是batch,第二维是85(或4+N),第三维是候选框数量。对于熟悉Faster R-CNN系输出格式的人来说,这个排列顺序很容易混淆,因为常规习惯是[框数量, 通道数],这里变成了[通道数, 框数量]。
我的做法是先做一次转置,把[84, 8400]变成[8400, 84]:
java复制private float[][] transpose(float[][] src) {
int rows = src[0].length; // 8400
int cols = src.length; // 84
float[][] dst = new float[rows][cols];
for (int i = 0; i < cols; i++) {
for (int j = 0; j < rows; j++) {
dst[j][i] = src[i][j];
}
}
return dst;
}
转置之后,每行代表一个候选框。前4个元素是[cx, cy, w, h]——注意是中心点加宽高,不是x1y1x2y2的左上角右下角格式。从第5个元素开始,是各类别的置信度分数。对自定义类别的模型,第5个元素起就是自定义类别的分数,不是COCO的80类。
接下来要做两件事:筛选和NMS。
筛选的逻辑是:遍历每个候选框,找出类别分数最大的那个类别,如果最大分数大于置信度阈值(我通常设0.25),就保留这个框。然后再把中心点格式转换成左上角右下角格式,方便后续业务方直接用。
NMS(非极大值抑制)的作用是去掉重叠的冗余框。两个框重叠度高且检测的是同一个目标时,只保留分数最高的那个。IoU阈值我一般设0.5,这个值调小了会保留较多重复框,调大了会漏掉挨得近的不同目标。具体场景可以微调:密集人群场景我调到0.4,大目标稀疏场景可以放到0.6。
NMS实现不长,但直接用双重循环做的话,候选框一多性能会明显下降。我的建议是先把框按分数从高到低排序,然后只和已保留的框做IoU比较,尽早剪枝。标准实现的复杂度在候选框数量几千个时是可以接受的。
4.3 REST接口设计与实际调用方式
接口设计上,我没有搞得太复杂,一个POST接口就能覆盖绝大多数场景。核心接口约定如下:
java复制@RestController
@RequestMapping("/api/detect")
public class DetectController {
@PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<DetectResponse> detectImage(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "conf", defaultValue = "0.25") float confThreshold,
@RequestParam(value = "iou", defaultValue = "0.5") float iouThreshold) {
// 1. 校验文件大小和类型
// 2. 解析为 BufferedImage
// 3. 调用检测服务
// 4. 返回检测结果
}
}
返回的JSON结构我定义为:
json复制{
"success": true,
"width": 1920,
"height": 1080,
"inferenceMs": 152,
"detections": [
{
"x1": 100.5,
"y1": 200.3,
"x2": 300.7,
"y2": 420.1,
"label": "person",
"confidence": 0.92
}
]
}
这里width和height返回的是原图的宽高,让前端能直接按比例在图片上画框。inferenceMs是整个推理流程的耗时,包括预处理、ONNX Runtime推理、后处理三部分,不含网络传输时间。这个字段在排障时非常重要,后续做性能分析都靠它。
接口层我特意做了两个参数:置信度阈值和IoU阈值,开放给调用方灵活调整。业务方在调低置信度阈值时能查出更多低置信度目标,在调高IoU时能合并相邻重复框。实际调参时,这两个参数的组合对结果影响很大,建议在接口文档里写清楚默认值和建议范围。
5. Docker容器化部署实践
5.1 多阶段Dockerfile的编写思路
把Spring Boot应用容器化,最忌讳的是直接用openjdk:17镜像加一个java -jar命令就跑。那样镜像体积动辄七八百MB,构建慢、传输慢、启动也慢。我采用多阶段构建,把Maven构建和运行环境分离,最终镜像只保留JRE和编译好的jar。
dockerfile复制# 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
COPY models ./models
RUN mvn package -DskipTests
# 运行阶段
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /build/target/detect-service-1.0.0.jar app.jar
COPY --from=builder /build/models/best.onnx /app/models/best.onnx
RUN useradd -r -u 1001 appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-Xmx1g", "-jar", "app.jar"]
有几个细节值得单独讲。mvn dependency:go-offline这一步先把依赖下载好,后面再构建业务代码时不用重复下载依赖,能大幅减少镜像构建时间。运行阶段用非root用户跑Java进程是一个好习惯,安全性和规范性都更强。-Xmx1g限制堆内存,避免容器内存溢出,这是团队规范里经常提到的容器内存问题的预防手段。
GPU推理时Dockerfile会复杂一些,需要安装CUDA运行库并挂载GPU设备,这一步在Windows Docker Desktop上还有额外的WSL2配置,不建议新手一上来就搞。如果你生产环境确实需要GPU,建议先用CPU版本跑通再扩展,尽量控制变量。
5.2 镜像构建与容器运行基本操作
构建命令很简单,在项目根目录执行:
bash复制docker build -t detect-service:1.0.0 .
构建完之后先本地跑一下验证:
bash复制docker run -d --name detect-service -p 8080:8080 detect-service:1.0.0
然后测试接口:
bash复制curl -X POST http://localhost:8080/api/detect/image \
-F "file=@test.jpg"
一切正常就可以把这个镜像推送到镜像仓库,供部署到服务器。如果构建时机器上没有历史缓存,mvn dependency:go-offline这一步会花几分钟,这是正常的,后续只要pom.xml没变,这段构建都会有缓存。
5.3 用docker-compose管理服务运行
单容器用docker run就行,但一旦涉及多个服务、环境变量、数据卷、健康检查,docker-compose会更高效。我这里的编排文件如下:
yaml复制version: '3.8'
services:
detect-service:
build: .
image: detect-service:1.0.0
container_name: yolo-detect
ports:
- "8080:8080"
volumes:
- ./models:/app/models
environment:
- JAVA_OPTS=-Xmx2g
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
这里有几个设计考虑。把模型目录挂载进去是因为模型文件更新频率高于代码,替换模型时只需要docker-compose restart,不用重新构建镜像。健康检查是线上部署的基本要求,配合负载均衡器可以自动剔除异常实例。启动时间设了40秒的宽限,Spring Boot加载模型文件在无GPU环境下通常需要5到15秒,如果模型文件大或机器配置低,时间会更久,设太短会导致健康检查误报。
启动命令是docker-compose up -d,查看日志用docker-compose logs -f,停掉服务用docker-compose down。这些就是日常部署和运维的全部操作了。
6. 常见问题与排查技巧实录
6.1 Docker Desktop启动失败的坑
Windows下开发,Docker Desktop启动失败真的是高频问题,我在这个项目里也折腾了挺久。最常见的报错是“Docker Desktop failed to start because virtualisation support wasn't detected”。
排查方向主要有三个。第一是到BIOS里确认Intel VT-x或AMD-V虚拟化功能有没有开启,很多品牌机默认是关的。通过任务管理器-性能-虚拟化可以快速看当前状态,显示“已启用”就说明BIOS没问题。第二是Windows功能的“虚拟机平台”和“适用于Linux的Windows子系统”有没有勾上,勾选后需要重启系统。第三是WSL2有没有正确安装,wsl --status命令可以确认。如果WSL版本是1而不是2,需要在PowerShell里执行wsl --set-default-version 2升级。
这个排查过程如果是线上服务,记得先确认服务器厂商的BIOS设置是否有锁定策略,部分云主机的嵌套虚拟化需要单独开启,这一点很容易被忽略。
6.2 镜像拉取慢与构建失败的应对思路
Docker镜像拉取速度,是另一个每个做容器化的人都会遇到的头疼事。官方镜像仓库的访问速度在某些网络环境下不稳定,最直接的解决办法是给Docker配置镜像加速器。Docker Desktop的Settings-Docker Engine里可以配置registry-mirror,填入对应云厂商提供的加速地址,重启后生效。
配置加速后如果还是慢,就要从镜像体积和分层缓存下手了。我见过很多团队把构建产物放在/tmp这种不缓存的位置,导致每次构建都重新下载大量依赖。正确做法是像前面Dockerfile里那样,先把pom.xml复制进去并执行依赖下载,再把源码复制进去构建,这样能够最大限度利用Docker构建缓存层。如果依赖下载频繁超时,可以考虑设置Maven国内镜像仓库地址,把依赖下载速度提上来。
6.3 推理性能优化心得
CPU环境下跑YOLOv8 ONNX推理,单张640x640图的耗时大约在100到300毫秒之间,具体看CPU主频和核心数。我用一台8核的测试机上实测,单张图片去掉预处理时间,ONNX Runtime推理约150毫秒。如果业务对吞吐量有要求,有几个优化思路。
第一个是ONNX Runtime的线程数配置。SessionOptions里可以设置setIntraOpNumThreads和setInterOpNumThreads。线程数不是越多越好,超过物理核心数反而会因为上下文切换损失性能。我建议先设成物理核心数的一半,然后压测调整,找到最优值。
第二个是Spring Boot接口层的并发控制。推理接口是CPU密集型操作,每个请求都会占用一段CPU时间,如果接口层不限流,并发一高CPU就飙升。可以用Spring的@EnableAsync配合线程池,限制同时执行推理的线程数,多余请求排队等待,这样吞吐量反而比无限并发更稳定。
第三个是对图片做大小限制。如果业务图超过4K分辨率,先在服务端做一次等比缩小,把长边缩到1600以内再走推理。YOLOv8对输入尺寸作了letterbox处理,原图过大时大部分内容在缩放后都无用,浪费了CPU。这个优化在实测中能把单图处理时间降低30%以上。
另外提一个常见的“yolov8可以检测线吗”的问题。YOLOv8本身是目标检测模型,输出的是目标边界框,不是直线或曲线参数。如果业务检测的是直线、车道线之类的线状目标,直接用它不合适,应该考虑YOLOv8-seg做像素级分割,或者用专门的车道线检测算法。如果只是要在一个线状物体周围框出区域,那YOLOv8的检测框也能做到,就看你把它当成什么目标了。
6.4 自定义数据集训练与增量训练注意事项
检测服务上线后很快会遇到模型更新需求,比如新增类别、修正误检。YOLOv8在自定义数据集上的训练流程是:准备好标注数据、配置yaml文件、用预训练权重做微调。这里最需要注意的就是类别数一致性。COCO预训练权重是80类,如果你自定义数据集只有10类,不能直接加载整个预训练权重作为初始权重,Ultralytics框架会剥掉输出头,只加载backbone部分,这是框架默认行为,但如果你手动写训练脚本就很容易踩坑。
增量训练时,如果是在已有模型基础上补数据,直接load自己上次训练的best.pt继续训练即可,但要注意学习率不能太大,否则模型会遗忘之前的特征。我习惯把增量训练的学习率设置为初始训练的一半以下,并且在训练初期用5个epoch热身,让损失曲线平稳下降。
模型更新上线时,记住替换模型后要做一条完整的回归测试:拿一批覆盖新类别、旧类别、边界情况的测试图,对比新旧模型的检测结果,确认没有明显回退再放量更新。这个流程看着麻烦,但能避免不少线上事故。
我在实际落地这个项目时最深的体会是,模型训练占整个项目的时间可能只有三分之一,剩下三分之二是工程化、部署、调优和排障。把推理服务跑起来很简单,但要让它在生产环境稳定、高效、可维护地运行,每一层都需要认真设计。Spring Boot、Docker和YOLOv8这一套组合,给了我一个既熟悉又足够强大的工具箱,让我能把心思放在模型效果和业务逻辑上,而不是被环境问题反复折腾。后面如果你也要做类似的服务,建议先把这篇里的坑提前绕开,能省下不少时间。
