目录
1、darknet 简介
2、yolov4
3、java 如何实现
3.1、OpenCV 原理和内存管理
3.2、实现详解
3.3、完整代码
4、结语
darknet 是 c 语言实现的开源 AI 深度学习框架,一般用于物体分类识别。他的优点就是轻量级、开源、没有什么依赖、支持 CPU 和 GPU 两种计算模式。官网默认的网络模型能够支持80种常见物体的分类识别。当然也可以使用自己的数据集进行训练自己的网络模型,实现自定义的场景。具体的请看darknet 官网文档。
YOLO(you only look once)的第4个版本。是非常优秀的卷积神经网络,对象检测和定位的实现算法,优点就是速度快,精度高。github 项目网址,上面对 yolov4 的环境和训练做了详细的说明。
darknet 框架是 c 语言的,是可以通过 JNI 或者 JNA 来实现调用,但是很有幸,java openCV 在 3.x 版本后推出了 DNN(深度学习)模块,已经内置实现了 darknet、torch、ONNX、caffe、tensorflow 等常见的深度学习框架,直接使用即可,非常简单。
当然实现之前,需要下载 3 个 yolov4 的文件(需要科学上网下载,不然贼慢或打不开)
配置文件:https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg
权重文件:https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights
类别名称:darknet/coco.names at master · AlexeyAB/darknet · GitHub (能够检测的对象)
我的百度网盘:https://pan.baidu.com/s/1tREeprjsq3mYGuCJ_vMnmg
提取码:1pia
java OpenCV 的原理是借助 javaCPP 包(底层依然是 JNI)实现 C 代码的调用;
(非常重要)只要涉及到 java 调用 C/C++ 这种情况,就一定要注意内存管理,因为 C/C++ 是 JVM 堆外内存,GC 是无法自动管理的,所以一定要记得手动释放 C/C++ 内存、一定要记得手动释放 C/C++ 内存、一定要记得手动释放 C/C++ 内存(重要的事情说3遍)。
java OpenCV 已经很人性化的帮我们封装了 release 和 delete 这两个释放 C/C++ 内存的方法了,所以只要使用 OpenCV 包中的类,就一定要记得看看有没有这两个方法,如果有的,在使用完这个对象一定调用下。
org.bytedeco
opencv-platform
4.5.3-1.5.6
只需要依赖 opencv-platform 即可,不需要依赖 java-opencv,后者依赖了很多平台,下载大量的包。
使用前面下载的配置文件、权重文件初始化 darknet,注意,文件最终都是在 C 代码中加载的,所以要绝对路径才能加载到。
// 加载 opencv
Loader.load(opencv_java.class);
// 指定配置文件和模型文件加载网络
String cfgFile = "D:\\xxx\\ai-demo\\src\\main\\resources\\yolov4.cfg";
String weights = "D:\\xxx\\ai-demo\\src\\main\\resources\\yolov4.weights";
// opencv 的 Dnn 模块初始化网络
Net net = Dnn.readNetFromDarknet(cfgFile, weights);
if(net.empty){
System.out.println("init net fail");
return;
}
// 设置计算后台:如果电脑有GPU,可以指定为:DNN_BACKEND_CUDA
net.setPreferableBackend(Dnn.DNN_BACKEND_OPENCV);
// 指定为 CPU 模式,如果电脑有 GPU,指定CUDA模式
net.setPreferableTarget(Dnn.DNN_TARGET_CPU);
// 读取类别名称
String[] names = new String[80];
try (BufferedReader reader = new BufferedReader(new InputStreamReader(DarknetMain.class.getClassLoader().getResourceAsStream("coco.names")))) {
for (int i = 0; i < names.length; i++) {
names[i] = reader.readLine();
}
}
现在就输入一张我们需要检测的图片给网络检测;我们输入的图片大小可能是不一样的,但是网络的图片输入都是一个尺寸,这个尺寸最好是与 yolov4.cfg 中配置的 width、height 一致,所以在输入之前需要对图片进行预处理;
// 图片绝对路径
String img_file = "D:\\xxx\\ai-demo\\src\\main\\resources\\dog_bike_car.jpg";
// 使用 opencv 提供的 api 读取图片
Mat im = Imgcodecs.imread(img_file, Imgcodecs.IMREAD_COLOR);
if (im.empty) {
System.out.println("read img fail");
return;
}
// 成功读取到了图片,进行预处理
float scale = 1 / 255F;
Mat inputBlob = Dnn.blobFromImage(im, scale, new Size(416, 416), new Scalar(0), true, false);
// 将处理后的图片输入到网络中
net.setInput(inputBlob);
这里简单讲解下 blobFromImage 的各个参数:
第一个参数:要处理的图片;
第二个参数:缩放比例因子,执行完平均减法后对图片的缩放因子,1表示不缩放,值见opencv文档的约定:https://github.com/opencv/opencv/blob/master/samples/dnn/models.yml#L31
第三个参数:处理后的大小,与网络配置 cfg 中的 width、height 一致;
第四个参数:平均减法的均值,通道顺序为RGB,是用来减少光照影响,值见opencv文档的约定:https://github.com/opencv/opencv/blob/master/samples/dnn/models.yml#L30 (非0图片会变色)
第五个参数:是否转换R和B通道的顺序,因为 openCV 的图片通道顺序为BGR,而平均减法的通道顺序是RGB,所以需要转换顺序。
第六个参数:是否在调整图片大小后裁剪图片,所以是false,不要裁剪。
opencv 的 blobFromImage 的论文参考:Deep learning: How OpenCV's blobFromImage works - PyImageSearch
yolov4 神经网络是存在 3 个yolo输出层,第1层507个单元,第2层2028个单元,第3层8112个单元,最后一个输出层才是输出最精准的结果,所以直接拿这个输出层的结果。
// 从网络中获取所有输出层,index =0对应的是第3层输出层
List outLayersNames = net.getUnconnectedOutLayersNames();
// 推理,并指定需要输出的层
Mat out = net.forward(outLayersNames.get(0));
if (outs.empty()) {
System.out.println("forward result is null");
return;
}
通过 forward 推理,我们已经拿到对象检测的结果集了;但是,这些结果集是不能直接使用的,需要丢弃掉置信度比较低的结果、box去重(box信息用来画框,在图片上标注出检测的物体位置)。
首先来过滤置信度比较低的结果,并转换 box 信息,记录每个类别的索引和该类别的置信度等结果信息:
List rect2dList = new ArrayList<>(); // box 信息集
List confList = new ArrayList<>(); // 置信度
List objIndexList = new ArrayList<>(); // 对象类别索引,与 names 的索引对应
// 每个 row 就是一个单元的预测结果,cols 就是当前单元的预测框信息和每个类型的置信度
for (int i = 0; i < out.rows(); i++) {
int size = out.cols() * out.channels();
float[] data = new float[size];
// 将结果拷贝到 data 中,0 表示从索引0开始拷贝
out.get(i, 0, data);
float confidence = -1; // 置信度
int objectClass = -1; // 类别索引
// data中的前4个是box的数据,第5个是分数,后面是每个 classes 的置信度,所以从5开始
int pro_index = 5;
for (int j = pro_index; j < out.cols(); j++) {
if (confidence < data[j]) {
// 记录本单元中最大的置信度及其类别索引
confidence = data[j];
objectClass = j - pro_index;
}
}
if (confidence > 0.5) { // 置信度大于 0.5 的才记录
for (int j = 0; j < out.cols(); j++) {
System.out.println(" " + j + ":" + data[j]); // 输出 data 中的所有数据看看
}
// 计算中点、长宽、左下角点位
float centerX = data[0] * im.cols();
float centerY = data[1] * im.rows();
float width = data[2] * im.cols();
float height = data[3] * im.rows();
float leftBottomX = centerX - width / 2;
float leftBottomY = centerY - height / 2;
System.out.println("Class: " + names[objectClass]); // names 是读取的类别名称
System.out.println("Confidence: " + confidence);
System.out.println("ROI: " + leftBottomX + "," + leftBottomY + "," + width + "," + height);
System.out.println("");
// 记录box信息、置信度、类型索引
rect2dList.add(new Rect2d(leftBottomX, leftBottomY, width, height));
confList.add(confidence);
objIndexList.add(objectClass);
}
}
置信度比较低的结果已经过滤掉了,经过这一步,已经拿到了 rect2dList、confList、objIndexList 三个结果集,这三个结果集 size 相等,并且 index 相互对应;但是现在还存在重复的结果,展示下效果:
所以进行去重:
// 去重后保留的索引值,用于获取三个List的结果集
MatOfInt indexs = new MatOfInt();
// 转换 box 的结果集
MatOfRect2d boxes = new MatOfRect2d(rect2dList.toArray(new Rect2d[0]));
// 转换置信度的结果集
float[] confArr = new float[confList.size()];
for (int i = 0; i < confList.size(); i++) {
confArr[i] = confList.get(i);
}
MatOfFloat con = new MatOfFloat(confArr);
// 使用 dnn 的 NMS 算法去重,并将去重后的索引结果保存在 indexs 中
Dnn.NMSBoxes(boxes, con, 0.5F, 0.5F, indexs);
if (indexs.empty()) {
System.out.println("indexs is empty");
return;
}
NMSBoxes 方法的简单讲解:
第一个参数:要去重的 box 数据;
第二个参数:要去重的置信度数据;
第三个参数:置信度阈值,如果低于这个置信度的 box 将被过滤(前面过滤了一次了)
第四个参数:NMS 的过滤阈值;
第五个参数:去重后的索引信息,用于从三个List中获取最后的结果;
NMS 算法就是通过计算重叠率(交并比 IoU),如果当前两个框的IoU大于了NMS阈值,保留置信度最高的一个。所以要注意了,当两个相同类别的对象,本来就重叠了,并且成功检测出来了,这个时候 NMS 的阈值就很重要了,遇到这种情况,多调整下阈值测试下。或者在校验权重文件的时候也是会输出 IoU 值,这里进行配置即可。
对图片画框、输出每种类别出现的次数
// 去重后的索引
int[] ints = indexs.toArray();
int[] classesNumberList = new int[names.length]; // 记录每种类别出现的次数
for (int i : ints) {
// i 与 names 的索引位置相对应
Rect2d rect2d = rect2dList.get(i);
Integer obj = objIndexList.get(i);
classesNumberList[obj] += 1; // 记录次数
// 将 box 信息画在图片上, Scalar 对象是 BGR 的顺序,与RGB顺序反着的。
Imgproc.rectangle(im, new Point(rect2d.x, rect2d.y), new Point(rect2d.x + rect2d.width, rect2d.y + rect2d.height),
new Scalar(0, 255, 0), 1);
}
String jpgFile = Paths.get("D:\\xxx\\ai-demo\\outs", "out_" + System.currentTimeMillis() + ".jpg").toString();
// 保存图片
Imgcodecs.imwrite(jpgFile, im);
// 输出每种类别的数量
for (int i = 0; i < names.length; i++) {
System.out.println(names[i] + ": " + classesNumberList[i]);
}
去重后的结果:
try {
//...上面的代码
} finally {
if (im != null) {
im.release(); // 输入图片释放
}
if (out != null) { // 推理的结果集释放
out.release();
}
// 去重过程中的对象释放
if (indexs != null) {
indexs.release();
}
if (boxes != null) {
boxes.release();
}
if (con != null) {
con.release();
}
}
其实就是上面的代码拼装到一起
package com.chc.ai.darknet;
import org.bytedeco.javacpp.Loader;
import org.bytedeco.opencv.opencv_java;
import org.opencv.core.*;
import org.opencv.dnn.Dnn;
import org.opencv.dnn.Net;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
/**
* 使用官方模型和配置
* 修改了网络大小为 416
*
* @author chc
* @date 2022/01/25
* @since 1.0
*/
public class DarknetMain {
public static void main(String[] args) throws IOException {
Loader.load(opencv_java.class); // 加载opencv
// 读取类别名称
String[] names = new String[80];
try (BufferedReader reader = new BufferedReader(new InputStreamReader(DarknetMain.class.getClassLoader().getResourceAsStream("coco.names")))) {
for (int i = 0; i < names.length; i++) {
names[i] = reader.readLine();
}
}
// 定义对象
Mat im = null;
Mat out = null;
MatOfInt indexs = null;
MatOfRect2d boxes = null;
MatOfFloat con = null;
try {
// 指定配置文件和模型文件加载网络
String cfgFile = "D:\\xxx\\ai-demo\\src\\main\\resources\\yolov4.cfg";
String weights = "D:\\xxx\\ai-demo\\src\\main\\resources\\yolov4.weights";
Net net = Dnn.readNetFromDarknet(cfgFile, weights);
if (net.empty()) {
System.out.println("init net fail");
return;
}
// 设置计算后台:如果电脑有GPU,可以指定为:DNN_BACKEND_CUDA
net.setPreferableBackend(Dnn.DNN_BACKEND_OPENCV);
// 指定为 CPU 模式
net.setPreferableTarget(Dnn.DNN_TARGET_CPU);
System.out.println("create net success");
// 读取要被推理的图片
String img_file = "D:\\xxx\\ai-demo\\src\\main\\resources\\dog_bike_car.jpg";
im = Imgcodecs.imread(img_file, Imgcodecs.IMREAD_COLOR);
if (im.empty()) {
System.out.println("read image fail");
return;
}
// 图片预处理:将图片转换为 416 大小的图片,这个数值最好与配置文件的网络大小一致
// 缩放因子大小,opencv 文档规定的:https://github.com/opencv/opencv/blob/master/samples/dnn/models.yml#L31
float scale = 1 / 255F;
Mat inputBlob = Dnn.blobFromImage(im, scale, new Size(416, 416), new Scalar(0), true, false);
// 输入图片到网络中
net.setInput(inputBlob);
// 推理
List outLayersNames = net.getUnconnectedOutLayersNames();
out = net.forward(outLayersNames.get(0));
if (outs.empty()) {
System.out.println("forward result is null");
return;
}
System.out.println("net forward success");
// 处理 out 的结果集: 移除小的置信度数据和去重
List rect2dList = new ArrayList<>();
List confList = new ArrayList<>();
List objIndexList = new ArrayList<>();
// 每个 row 就是一个单元,cols 就是当前单元的预测信息
for (int i = 0; i < out.rows(); i++) {
int size = out.cols() * out.channels();
float[] data = new float[size];
// 将结果拷贝到 data 中,0 表示从索引0开始拷贝
out.get(i, 0, data);
float confidence = -1; // 置信度
int objectClass = -1; // 类型索引
// data中的前4个是box的数据,第5个是分数,后面是每个 classes 的置信度
int pro_index = 5;
for (int j = pro_index; j < out.cols(); j++) {
if (confidence < data[j]) {
// 记录本单元中最大的置信度及其类型索引
confidence = data[j];
objectClass = j - pro_index;
}
}
if (confidence > 0.5) { // 置信度大于 0.5 的才记录
System.out.println("result unit index: " + i);
for (int j = 0; j < out.cols(); j++) {
System.out.println(" " + j + ":" + data[j]);
}
// 计算中点、长宽、左下角点位
float centerX = data[0] * im.cols();
float centerY = data[1] * im.rows();
float width = data[2] * im.cols();
float height = data[3] * im.rows();
float leftBottomX = centerX - width / 2;
float leftBottomY = centerY - height / 2;
System.out.println("Class: " + names[objectClass]);
System.out.println("Confidence: " + confidence);
System.out.println("ROI: " + leftBottomX + "," + leftBottomY + "," + width + "," + height);
System.out.println("");
// 记录box信息、置信度、类型索引
rect2dList.add(new Rect2d(leftBottomX, leftBottomY, width, height));
confList.add(confidence);
objIndexList.add(objectClass);
}
}
if (rect2dList.isEmpty()) {
System.out.println("not object");
return;
}
// box 去重
indexs = new MatOfInt();
boxes = new MatOfRect2d(rect2dList.toArray(new Rect2d[0]));
float[] confArr = new float[confList.size()];
for (int i = 0; i < confList.size(); i++) {
confArr[i] = confList.get(i);
}
con = new MatOfFloat(confArr);
// NMS 算法去重
Dnn.NMSBoxes(boxes, con, 0.5F, 0.5F, indexs);
if (indexs.empty()) {
System.out.println("indexs is empty");
return;
}
// 去重后的索引
int[] ints = indexs.toArray();
int[] classesNumberList = new int[names.length];
for (int i : ints) {
// 与 names 的索引位置相对应
Rect2d rect2d = rect2dList.get(i);
Integer obj = objIndexList.get(i);
classesNumberList[obj] += 1;
// 将 box 信息画在图片上, Scalar 对象是 BGR 的顺序,与RGB顺序反着的。
Imgproc.rectangle(im, new Point(rect2d.x, rect2d.y), new Point(rect2d.x + rect2d.width, rect2d.y + rect2d.height),
new Scalar(0, 255, 0), 1);
}
String jpgFile = Paths.get("D:\\xxx\\ai-demo\\outs", "out_" + System.currentTimeMillis() + ".jpg").toString();
Imgcodecs.imwrite(jpgFile, im);
for (int i = 0; i < names.length; i++) {
System.out.println(names[i] + ": " + classesNumberList[i]);
}
} finally {
// 释放资源
if (im != null) {
im.release();
}
if (out != null) {
out.release();
}
if (indexs != null) {
indexs.release();
}
if (boxes != null) {
boxes.release();
}
if (con != null) {
con.release();
}
}
}
}
java 语言其实不是非常适合做 AI,至少不是首选语言,唯一的优点就是对 java 工程师友好,没有学习新语言的成本(小公司可是很在意成本的)。我接触到的就是 opencv dnn 模块和 AWS 推出的 DJL。
opencv dnn 模块通过 JNI 桥接到 C/C++ 让java有能力实现 AI 的推理过程,还是非常棒的,填补了 java 在 AI 的空白。
DJL 官网说的是专门为 java 开发的 AI 套件,可以使用 DJL 做训练、推理,但是目前还没有过多的尝试,暂且不评论。
最后还是那句话,不要忘记释放 c 内存,不然内存爆了不好排除原因。