一、背景:Java程序员做CV的“痛点”
作为一名深耕后端的Java程序员,前阵子接了个内部需求:做一个“智能图片审核工具”,要求上传一张图片,自动识别出里面的人物、违规物品、敏感场景(比如明火、刀具),并输出结构化的文本描述——说白了就是让图片“说话”。
一开始想找Python同事帮忙,但对接起来太麻烦:Python做推理,Java做接口,跨语言调用不仅延迟高,还容易出序列化问题。索性咬牙自己搞:纯Java实现YOLO目标检测,从图片读取、预处理到推理、结果解析,全链路用Java搞定。
踩了无数坑(比如ByteBuffer内存对齐、ONNX模型加载失败、坐标转换出错),最终做出了能用的版本:上传一张图片,300ms内返回“图片中有1个人、1把剪刀、无违规物品”这类结构化描述,完全满足业务需求。
这篇文章就从Java程序员的视角,拆解“用YOLO让图片说话”的全流程——不用懂太多CV理论,不用写一行Python,纯Java代码落地,附可直接跑通的完整源码。
二、核心思路:Java+YOLO让图片“说话”的底层逻辑
所谓“让图片说话”,本质是三步:
核心依赖:ONNX Runtime Java版(不用自己编译YOLO源码,直接加载训练好的ONNX模型,对Java程序员最友好)。
三、实战准备:环境搭建(Java程序员秒懂)
3.1 依赖引入(Maven)
不用手动下载库,直接在pom.xml加依赖:
<dependencies>
<!– ONNX Runtime Java核心依赖 –>
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>1.15.1</version>
</dependency>
<!– 图片处理依赖(Java原生太麻烦,用Thumbnails简化) –>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.19</version>
</dependency>
<!– 日志依赖(方便调试) –>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.7</version>
</dependency>
</dependencies>
踩坑点:onnxruntime版本要和系统匹配(Windows/Linux/ARM),比如ARM架构的服务器要选onnxruntime-linux-aarch64,否则会报“找不到原生库”。
3.2 准备YOLOv8 ONNX模型
不用自己训练,直接下载官方预训练模型:
# 仅导出模型用,一行代码搞定
from ultralytics import YOLO
model = YOLO("yolov8s.pt")
model.export(format="onnx", imgsz=640) # 导出640×640输入的ONNX模型
导出后得到yolov8s.onnx,放到Java项目的resources目录下。
四、核心实战:Java实现YOLO目标检测全流程
4.1 第一步:图片预处理(Java读取并转张量)
Java读取图片的坑很多:比如ImageIO读取jpg会有色偏,不同格式图片的通道顺序(RGB/BGR)不对会导致推理结果全错。这里贴踩坑后的最优方案:
import net.coobird.thumbnailator.Thumbnails;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
/**
* 图片预处理工具:转640×640、BGR格式、归一化
*/
public class ImagePreprocessUtil {
// YOLOv8输入尺寸
private static final int INPUT_WIDTH = 640;
private static final int INPUT_HEIGHT = 640;
// 归一化参数(YOLO要求像素值/255)
private static final float NORM_VAL = 1.0f / 255.0f;
/**
* 图片转YOLO输入张量
* @param imagePath 图片路径
* @return FloatBuffer 输入张量(CHW格式:3×640×640)
*/
public static FloatBuffer imageToTensor(String imagePath) throws Exception {
// 1. 读取图片并缩放至640×640(保持比例,补黑边)
BufferedImage image = Thumbnails.of(new File(imagePath))
.size(INPUT_WIDTH, INPUT_HEIGHT)
.keepAspectRatio(true)
.addFilter(new BlackBorderFilter()) // 自定义补黑边过滤器
.asBufferedImage();
// 2. 转BGR格式(YOLO训练用BGR,Java默认RGB)
int[] pixels = image.getRGB(0, 0, INPUT_WIDTH, INPUT_HEIGHT, null, 0, INPUT_WIDTH);
float[] rgbData = new float[INPUT_WIDTH * INPUT_HEIGHT * 3];
for (int i = 0; i < INPUT_HEIGHT; i++) {
for (int j = 0; j < INPUT_WIDTH; j++) {
int pixel = pixels[i * INPUT_WIDTH + j];
// 拆分RGB,转BGR
float b = ((pixel & 0xFF0000) >> 16) * NORM_VAL;
float g = ((pixel & 0xFF00) >> 8) * NORM_VAL;
float r = (pixel & 0xFF) * NORM_VAL;
// CHW格式:通道优先(3×640×640)
int idx = 0 * INPUT_WIDTH * INPUT_HEIGHT + i * INPUT_WIDTH + j;
rgbData[idx] = b;
idx = 1 * INPUT_WIDTH * INPUT_HEIGHT + i * INPUT_WIDTH + j;
rgbData[idx] = g;
idx = 2 * INPUT_WIDTH * INPUT_HEIGHT + i * INPUT_WIDTH + j;
rgbData[idx] = r;
}
}
// 3. 转FloatBuffer(ONNX Runtime输入要求)
FloatBuffer floatBuffer = FloatBuffer.allocate(3 * INPUT_WIDTH * INPUT_HEIGHT);
floatBuffer.put(rgbData);
floatBuffer.flip();
return floatBuffer;
}
// 自定义补黑边过滤器(保持比例缩放后补黑边)
static class BlackBorderFilter implements net.coobird.thumbnailator.filters.ImageFilter {
@Override
public BufferedImage apply(BufferedImage img) {
BufferedImage newImg = new BufferedImage(INPUT_WIDTH, INPUT_HEIGHT, BufferedImage.TYPE_3BYTE_BGR);
int x = (INPUT_WIDTH – img.getWidth()) / 2;
int y = (INPUT_HEIGHT – img.getHeight()) / 2;
newImg.getGraphics().drawImage(img, x, y, null);
return newImg;
}
}
}
关键踩坑点:
- YOLO要求输入是CHW格式(通道优先),而Java图片默认是HWC(高度优先),不转换会导致推理结果全错;
- 必须转BGR格式,Java读取的是RGB,直接用的话检测不到任何目标;
- 缩放图片要保持比例并补黑边,直接拉伸会导致目标变形,置信度暴跌。
4.2 第二步:加载YOLO模型并推理
这是核心步骤,Java加载ONNX模型并执行推理:
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtSession;
import ai.onnxruntime.TensorInfo;
import ai.onnxruntime.OnnxTensor;
import java.nio.FloatBuffer;
import java.util.Collections;
import java.util.Map;
/**
* YOLOv8推理核心类
*/
public class YoloV8Infer {
private static OrtEnvironment env;
private static OrtSession session;
// 初始化模型(项目启动时执行一次)
public static void initModel(String modelPath) throws Exception {
env = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions options = new OrtSession.SessionOptions();
// 优化:启用CPU多核推理(Java程序员必懂的优化)
options.setIntraOpNumThreads(Runtime.getRuntime().availableProcessors() – 1);
// 加载ONNX模型
session = env.createSession(modelPath, options);
}
/**
* 执行推理
* @param tensor 预处理后的输入张量
* @return float[][] 推理结果:[类别ID, 置信度, x1, y1, x2, y2]
*/
public static float[][] infer(FloatBuffer tensor) throws Exception {
// 1. 创建ONNX输入张量
TensorInfo info = TensorInfo.builder("float32").shape(new long[]{1, 3, 640, 640}).build();
OnnxTensor inputTensor = OnnxTensor.createTensor(env, tensor, info.getShape());
// 2. 执行推理
Map<String, OnnxTensor> inputMap = Collections.singletonMap("images", inputTensor);
OrtSession.Result result = session.run(inputMap);
// 3. 解析推理结果
float[][] output = (float[][]) result.get(0).getValue();
inputTensor.close();
result.close();
// 4. 非极大值抑制(NMS):去除重复框(核心!否则会输出大量重复目标)
return NMSUtil.nms(output, 0.5f, 0.3f); // 置信度阈值0.5,IOU阈值0.3
}
// 销毁模型(项目关闭时执行)
public static void destroyModel() throws Exception {
if (session != null) session.close();
if (env != null) env.close();
}
}
关键说明:
- 模型只初始化一次,别每次推理都加载(否则耗时从300ms涨到2s);
- 非极大值抑制(NMS)是必做的,YOLO原生输出会有大量重复的检测框,NMS能过滤掉重叠的低置信度框;
- 输入张量的shape必须是[1,3,640,640](batch=1),否则报维度不匹配。
4.3 第三步:NMS过滤(去除重复框)
补全NMS工具类(Java程序员手写,不用依赖第三方库):
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 非极大值抑制工具:过滤重复检测框
*/
public class NMSUtil {
/**
* NMS核心逻辑
* @param boxes 原始检测框:[类别ID, 置信度, x1, y1, x2, y2]
* @param confThresh 置信度阈值
* @param iouThresh IOU阈值
* @return 过滤后的检测框
*/
public static float[][] nms(float[][] boxes, float confThresh, float iouThresh) {
// 1. 过滤低置信度框
List<float[]> validBoxes = new ArrayList<>();
for (float[] box : boxes) {
if (box[1] >= confThresh) {
validBoxes.add(box);
}
}
// 2. 按置信度降序排序
validBoxes.sort(Comparator.comparingFloat(box -> –box[1]));
// 3. NMS核心:去除IOU大于阈值的重复框
List<float[]> result = new ArrayList<>();
while (!validBoxes.isEmpty()) {
float[] firstBox = validBoxes.remove(0);
result.add(firstBox);
validBoxes.removeIf(box -> calculateIOU(firstBox, box) > iouThresh);
}
// 4. 转数组返回
return result.toArray(new float[0][]);
}
// 计算两个框的IOU(交并比)
private static float calculateIOU(float[] box1, float[] box2) {
float x1 = Math.max(box1[2], box2[2]);
float y1 = Math.max(box1[3], box2[3]);
float x2 = Math.min(box1[4], box2[4]);
float y2 = Math.min(box1[5], box2[5]);
if (x2 < x1 || y2 < y1) return 0.0f;
// 交集面积
float intersection = (x2 – x1) * (y2 – y1);
// 并集面积
float area1 = (box1[4] – box1[2]) * (box1[5] – box1[3]);
float area2 = (box2[4] – box2[2]) * (box2[5] – box2[3]);
float union = area1 + area2 – intersection;
return intersection / union;
}
}
4.4 第四步:结果转文本(让图片“说话”)
把检测框转换成人类能看懂的文本描述:
import java.util.HashMap;
import java.util.Map;
/**
* 结果解析工具:转结构化文本
*/
public class ResultParseUtil {
// YOLOv8类别映射(80类,这里只列常用的)
private static final Map<Integer, String> CLASS_MAP = new HashMap<>();
static {
CLASS_MAP.put(0, "人");
CLASS_MAP.put(2, "汽车");
CLASS_MAP.put(3, "摩托车");
CLASS_MAP.put(4, "自行车");
CLASS_MAP.put(6, "公交车");
CLASS_MAP.put(7, "火车");
CLASS_MAP.put(27, "刀");
CLASS_MAP.put(39, "瓶子");
// 可根据需求补充更多类别
}
/**
* 检测结果转文本描述
* @param boxes 过滤后的检测框
* @return String 结构化文本
*/
public static String parseToText(float[][] boxes) {
if (boxes == null || boxes.length == 0) {
return "图片中未检测到任何目标";
}
// 1. 统计各类别数量
Map<String, Integer> countMap = new HashMap<>();
// 2. 记录目标位置
StringBuilder detail = new StringBuilder();
detail.append("图片检测结果:\\n");
for (float[] box : boxes) {
int classId = (int) box[0];
float conf = box[1];
float x1 = box[2];
float y1 = box[3];
float x2 = box[4];
float y2 = box[5];
String className = CLASS_MAP.getOrDefault(classId, "未知目标");
// 统计数量
countMap.put(className, countMap.getOrDefault(className, 0) + 1);
// 拼接细节(坐标转整数,更易读)
detail.append(String.format("- %s(置信度:%.2f),位置:(%d,%d)-(%d,%d)\\n",
className, conf, (int)x1, (int)y1, (int)x2, (int)y2));
}
// 3. 生成总述
StringBuilder summary = new StringBuilder();
summary.append("图片中有");
for (Map.Entry<String, Integer> entry : countMap.entrySet()) {
summary.append(entry.getValue()).append("个").append(entry.getKey()).append("、");
}
// 去掉最后一个顿号
if (summary.charAt(summary.length() – 1) == '、') {
summary.deleteCharAt(summary.length() – 1);
}
summary.append("。");
return summary.toString() + "\\n" + detail;
}
}
4.5 第五步:完整测试(跑通全流程)
写个主类,一键测试:
/**
* 主测试类:Java+YOLO让图片“说话”
*/
public class YoloImageSpeakMain {
public static void main(String[] args) {
try {
// 1. 初始化模型(resources目录下的yolov8s.onnx)
String modelPath = YoloImageSpeakMain.class.getClassLoader().getResource("yolov8s.onnx").getPath();
YoloV8Infer.initModel(modelPath);
// 2. 测试图片路径
String imagePath = "test.jpg"; // 替换成你的图片路径
// 3. 图片预处理
FloatBuffer tensor = ImagePreprocessUtil.imageToTensor(imagePath);
// 4. 执行推理
float[][] boxes = YoloV8Infer.infer(tensor);
// 5. 结果转文本(让图片“说话”)
String resultText = ResultParseUtil.parseToText(boxes);
System.out.println(resultText);
// 6. 销毁模型
YoloV8Infer.destroyModel();
} catch (Exception e) {
e.printStackTrace();
}
}
}
测试效果示例:
图片中有1个人、1辆汽车、1个瓶子。
图片检测结果:
– 人(置信度:0.98),位置:(120,80)-(250,400)
– 汽车(置信度:0.95),位置:(300,200)-(580,450)
– 瓶子(置信度:0.89),位置:(200,420)-(230,480)
五、Java端优化技巧(程序员必看)
CompletableFuture<float[][]> inferFuture = CompletableFuture.supplyAsync(() -> {
try {
return YoloV8Infer.infer(tensor);
} catch (Exception e) {
e.printStackTrace();
return null;
}
});
float[][] boxes = inferFuture.get();
options.addConfigEntry("session.log_severity_level", "3"); // 3=ERROR级别


