腾讯高性能神经网络前向计算框架——ncnn联合yolov8模型、OpenCV框架交叉编译移植到Android平台。
本课题原本采用Android端采集实时画面帧,然后通过网络将画面帧传递到媒体服务器,服务器再用Python+Yolov8对画面帧做检测和识别,最后将结果返回给Android端去绘制目标检测结果。这样做最大的问题就是延时,经过局域网、4/5G/WiFi网络测试,延时大概1-2s,此方案并不是最优解。为了优化(解决)此痛点,就必须将目标检测和识别移植到Android端,否则这个延时不可能会降下来。
如题,采用 ncnn + yolov8 + opencv 三个框架实现这一目标
注意,必须是C++项目,否则交叉编译环境会把人搞疯,不要走弯路了,步骤如下:
图二根据自己熟悉的语言选择即可,不一定非得Kotlin(Java也是可以的)。至于其他的选项,建议按我的来,不然出问题了,会把人搞疯。
项目建完之后,按照Android Studio Giraffe版本的Android Studio会自动给你下载8.x版本的Gradle,高版本的Gradle的在Android Studio Giraffe上面不咋好使,咱改成本地的Gradle仓库路径(如果本地没有Gradle的,点击下载 ),如下图:
然后修改项目根目录下的 settings.gradle 如下:
rootProject.name = "Test"
include ':app'
注意,rootProject.name修改为自己的项目名。其余代码全部删除,用不到。
再修改项目根目录下的 build.gradle 如下:
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.6.10'
repositories {
maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
mavenCentral()
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
//阿里云镜像
maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
//依赖库
maven { url 'https://jitpack.io' }
mavenCentral()
google()
}
}
tasks.register('clean', Delete) {
delete rootProject.buildDir
}
这个不用改,直接复制进去
再然后修改 app 目录下面的 build.gradle 如下:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 33
defaultConfig {
applicationId "com.casic.test"
minSdkVersion 24
targetSdkVersion 33
versionCode 1000
versionName "1.0.0.0"
ndkVersion "26.1.10909125"
ndk {
abiFilters "arm64-v8a", "armeabi-v7a", "x86_64", "x86"
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.22.1'
}
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.6.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
//基础依赖库
implementation 'com.github.AndroidCoderPeng:Kotlin-lite-lib:1.0.10'
}
对着自己项目的修改此文件,dependencies里面的内容可以复制过去
然后点击OK,最后在主界面点击”Try Again“即可。到此,C++项目的的环境已经配置完毕,接下来修改Android的几个基本配置。
首先修改 AndroidManifest.xml 如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.casic.test">
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Test">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
对着自己项目的修改此文件。
然后修改 MainActivity.kt 如下:
class MainActivity : KotlinBaseActivity<ActivityMainBinding>() {
override fun initEvent() {
}
override fun initOnCreate(savedInstanceState: Bundle?) {
}
override fun initViewBinding(): ActivityMainBinding {
return ActivityMainBinding.inflate(layoutInflater)
}
override fun observeRequestState() {
}
override fun setupTopBarLayout() {
}
}
把原来自带的那部分代码全删了,用不到。
最后修改 gradle.properties 如下:
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.enableJetifier=true
直接复制即可。然后点击”Sync Now“
最后点击顶部三角形,如果能运行,就表示已经完全完成C++项目配置。
先下载腾讯ncnn开源库最新的框架,如图:
然后解压,复制到项目的cpp目录下,不要改任何文件以及代码,如下图:
修改新建项目时侯生成的 CMakeLists.txt,如下:
project("test")
cmake_minimum_required(VERSION 3.22.1)
set(ncnn_DIR ${CMAKE_SOURCE_DIR}/ncnn-20240410-android-vulkan/${ANDROID_ABI}/lib/cmake/ncnn)
find_package(ncnn REQUIRED)
project根据实际情况修改即可,然后点击”Sync Now“
最后点击顶部三角形,如果能运行,就表示已经完成ncnn框架配置。
同理,先去opencv-mobile 下载最新框架,如图(最新版本是:4.10.0):
然后解压,复制到项目的cpp目录下,不要改任何文件以及代码,如下图(最新版本是:4.10.0):
修改上一步的 CMakeLists.txt,添加如下代码:
set(OpenCV_DIR ${CMAKE_SOURCE_DIR}/opencv-mobile-4.10.0-android/sdk/native/jni)
find_package(OpenCV REQUIRED core imgproc)
在之前修改过的cmake文件里面加如上两行,然后点击”Sync Now“。最后点击顶部三角形,如果能运行,就表示已经完成opencv-mobile框架配置。
这里的OpenCV和3.3里面的opencv-mobile是有区别的,opencv-mobile是专门针对移动端做了优化。此处引入OpenCV的目的是为了后面的画面预览的数据矩阵。同样去OpenCV 官网下载最新的Android端SDK,如下图:
然后解压在桌面备用,按如下步骤操作:
导入进去之后会报错,别慌,按下面步骤修改即可:
- 首先修改app目录下面的build.gradle,在dependencies括号里面添加一行代码,如下图:
implementation project(':sdk')
这里的”sdk“就是刚刚导入进来的OpenCV依赖库的名字,如果按照我的步骤来没有改过名字的应该就是这个,如果自己改过名字的,这里填写你改过的依赖库名字。
- 然后再修改sdk里面的build.gradle ,将里面的compileSdkVersion、minSdkVersion和targetSdkVersion三个字段改为了主项目一致,并删掉其中的如下代码:
publishing {
singleVariant('release') {
withSourcesJar()
withJavadocJar()
}
}
- 最后在主界面点击”Try Again“即可完成OpenCV的集成,最后效果如下(最新版本是:4.10.0):
到此,三大框架集成完毕。
在 app 的 main 目录下新建 assets 文件夹(一定要这个名字,别自己另辟蹊径),将Python导出的yolov8模型(后缀是 *.bin 和 * .param,如果不是这俩后缀的自行查找解决方案)复制进去即可。暂时先不用管,备用。
JNI(Java Native Interface),是方便Java/Kotlin调用C/C++等Native代码封装的一层接口。简单来说就是Java/Kotlin与C/C++沟通的桥梁。
5.2、新建 Yolov8ncnn.java
在 app/src/main/java/自己的包名 目录下新建 Yolov8ncnn.kt(Yolov8ncnn.java 也是可以的),代码如下:
class Yolov8ncnn {
companion object {
init {
System.loadLibrary("yolov8ncnn")
}
}
/**
* @param mgr 手机内存资源管理器
* @param modelId 模型ID
* @param useGpu 是否使用GPU
* @param useClassify 是否使用分类模型
* @param useSegmentation 是否使用分割模型
* @param useDetect 是否使用检测模型
* */
external fun loadModel(
mgr: AssetManager, modelId: Int, useGpu: Boolean, useClassify: Boolean,
useSegmentation: Boolean, useDetect: Boolean
): Boolean
/**
* @param mgr 手机内存资源管理器
* @param ids 多模型ID数组
* @param useGpu 是否使用GPU
* */
external fun loadMultiModel(mgr: AssetManager, ids: IntArray, useGpu: Boolean): Boolean
/**
* @param facing 相机 0-前置镜头,1-后置镜头
* */
external fun openCamera(facing: Int): Boolean
external fun closeCamera(): Boolean
external fun setOutputWindow(
surface: Surface, nativeObjAddr: Long, callBack: INativeCallback
): Boolean
external fun onPause(): Boolean
external fun onRestart(): Boolean
}
companion object(伴生对象,类似Java里面的static关键字)包裹的代码意思是C/C++代码编译之后动态链接库的名字(此时还没有,因为还没有添加C/C++代码)。另外四个方法和普通的Kotlin方法的区别在于全部都有external关键字修饰(Java里面是native),表明这几个方法需要调用C/C++代码,也就是前文提到的”桥梁“。此时代码会报错,是因为还没有在C/C++里面实现它们,先不用管。
5.3、将本项目的 ndkcamera.cpp 和 ndkcamera.h 复制到自己项目
这是底层相机相关的代码逻辑,包括相机打开、关闭、预览、数据回调等,通用代码,无需修改。此时会爆一堆错误提示,别慌,先不用管。
这两代码文件主要的功能是对相机预览产生的nv21数据进行处理,包括nv21转换、nv21转Mat矩阵、图像裁剪、灰度处理、调用模型检测目标、显示检测结果、回调等一些列操作,底层逻辑就在此实现。此时依旧会爆一堆错误提示,别慌,先不用管。
5.5、将本项目的 yolov8ncnn.cpp 复制到自己项目
此代码文件主要包括相机初始化,参数初始化。整体来说就是各种初始化以及状态管理。
5.6、修改 app/src/main/cpp 目录下的 CMakeLists.txt
添加如下两行代码:
add_library(yolov8ncnn SHARED yolov8ncnn.cpp yolo.cpp ndkcamera.cpp)
target_link_libraries(yolov8ncnn ncnn ${OpenCV_LIBS} camera2ndk mediandk)
最终的cmake代码如下图:
project(yolov8ncnn)
cmake_minimum_required(VERSION 3.10)
set(OpenCV_DIR ${CMAKE_SOURCE_DIR}/opencv-mobile-4.10.0-android/sdk/native/jni)
find_package(OpenCV REQUIRED core imgproc)
set(ncnn_DIR ${CMAKE_SOURCE_DIR}/ncnn-20240102-android-vulkan/${ANDROID_ABI}/lib/cmake/ncnn)
find_package(ncnn REQUIRED)
add_library(yolov8ncnn SHARED yolov8ncnn.cpp yolo.cpp ndkcamera.cpp)
target_link_libraries(yolov8ncnn ncnn ${OpenCV_LIBS} camera2ndk mediandk)
复制过去的yolov8ncnn.cpp文件,有四个函数一定是没有高亮的,如下图:
此时需要将此函数根据情况修改为自己项目包名_函数名的方式,”.“用”_ “代替,比如:com.casic.test.Yolov8ncnn应改为Java_com_casic_test_Yolov8ncnn,改了之后就会发现,这四个函数已经高亮了,说明桥接代码已经生效。
可以看到Java和C++代码左侧已经出现相对应的代码标识。另外还有两个文件,就是setOutputWindow方法里面的DetectResult和INativeCallback,这俩都属于回调部分的代码,一个是数据模型类,一个是回调接口,直接复制即可。
如果没有出现以上效果的,先点击”Sync Now“,再”Build-Clean Project“,再”Build-Rebuild Project“,再”Build-Refresh Linked C++ Projects“,最后关闭工程重新启动Android Studio,此时应该就没问题了。
此模块需要有能看懂的C/C++代码的的能力,以及非常熟练的使用Kotlin/Java的能力。
- 定义全局指针变量
static JavaVM *javaVM = nullptr;
- 修改JNI_OnLoad方法,在相机初始化之前添加一行如下代码:
javaVM = vm;
- 修改Java_com_casic_test_Yolov8ncnn_loadModel方法(注意自己的包名)
将model_types、target_sizes、mean_values、norm_values改为如下代码:
//分割、分类、检测
const char *model_types[] = {"best-sim-opt-fp16", "model.ncnn", "yolov8s-detect-sim-opt-fp16"};
const int target_sizes[] = {320, 320, 320};
const float mean_values[][3] = {
{103.53f, 116.28f, 123.675f},
{103.53f, 116.28f, 123.675f},
{103.53f, 116.28f, 123.675f}
};
const float norm_values[][3] = {
{1 / 255.f, 1 / 255.f, 1 / 255.f},
{1 / 255.f, 1 / 255.f, 1 / 255.f},
{1 / 255.f, 1 / 255.f, 1 / 255.f}
};
其中model_types里面的值是你yolov8模型去掉后缀后剩下的部分,一定要注意,否则会报错,找不到模型。
- 修改Java_com_casic_test_Yolov8ncnn_setOutputWindow方法(同样注意包名),在return前面加一行代码:
g_yolo->initNativeCallback(javaVM, nativeObjAddr, native_callback);
以上这些,我在代码里面已经加好,注意下就可以了。有个值得注意的地方,在此文件的on_image_render函数,里面的注释我也写清楚了,可以根据需求选择draw和draw_fps,如果不需要,可以都注释掉,不影响后面的逻辑。
- 添加全局变量
JavaVM *javaVM;
//Java传过来的Mat对象内存地址
jlong j_mat_addr;
//回调类
jobject j_callback;
- 添加Java/C++初始化函数
void initNativeCallback(JavaVM *vm, jlong nativeObjAddr, jobject pJobject);
- 修改generate_proposals函数
根据自己模型能够识别的目标种类修改此函数的num_class字段,比如,此处我已改为如下:
const int num_class = 43;
- 实现自己在Yolo.h里面定义的setNativeCallback函数
void Yolo::initNativeCallback(JavaVM *vm, jobject input, jlong nativeObjAddr, jobject pJobject) {
javaVM = vm;
/**
* JNIEnv不支持跨线程调用
* */
JNIEnv *env;
vm->AttachCurrentThread(&env, nullptr);
j_mat_addr = nativeObjAddr;
j_callback = env->NewGlobalRef(pJobject);
}
有个注意点,JNIEnv不支持跨线程调用,一定要注意,否则会报错,之前在Yolo.h定义的全局变量也需要在此处初始化。 以上这些,我在代码里面已经加好,如果要加自己的逻辑,知道在此处改就行了。
- 修改detect函数(划重点!划重点!划重点!)
其实在以上步骤完成时候就已经能把自定义的模型在Android端跑起来了(运行了一下,没效果???那是自然,因为 MainActivity.kt 还没有实现逻辑),已经可以检测目标了。
但是有缺陷,第一就是检测的结果只能在C++层面使用,Java/Kotlin层无法用,所以修改此函数的目的就是把检测结果回传到应用层,让应用层去做具体业务逻辑处理。第二个就是,C++底层只能渲染英文字符,中文的显示不出来或者显示乱码,当然也不是没有解决思路,需要交叉编译freetype2这个库,有兴趣的可以自行实现。
知识点预热
- 基本类型签名
Java | JNI | 签名 |
---|---|---|
byte | jbyte | B |
char | jchar | C |
double | jdouble | D |
float | jfloat | F |
int | jint | I |
short | jshort | S |
long | jlong | J |
boolean | jboolean | Z |
void | void | V |
- 引用数据类型的转换
Java | JNI | 签名 |
---|---|---|
所有对象 | jobject | L+classname +; |
Class | jclass | Ljava/lang/Class; |
String | jstring | Ljava/lang/String; |
Throwable | jthrowable | Ljava/lang/Throwable; |
Object[] | jobjectArray | [L+classname +; |
byte[] | jbyteArray | [B |
char[] | jcharArray | [C |
double[] | jdoubleArray | [D |
float[] | jfloatArray | [F |
int[] | jintArray | [I |
short[] | jshortArray | [S |
long[] | jlongArray | [J |
boolean[] | jbooleanArray | [Z |
预热完毕,举几个例子:
函数:int add(int a, int b)
签名:(II)I
说明:入参两个整型,返回值为整型
函数:String concat(String str1, String str2)
签名:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
说明:入参两个String类型,返回值为String类型
[Ljava/lang/String;
表示 String 类型的一维数组
回归正题,回传结果给上层,理清步骤就很简单,用过Java反射的这里理解起来应该不难
- 获取Java代码的回调接口,得到回调接口的jclass
JNIEnv *env;
javaVM->AttachCurrentThread(&env, nullptr);
jclass callback_clazz = env->GetObjectClass(j_callback);
注意:JNIEnv不支持跨线程,所以必须通过之前定义的全局指针变量 javaVM 得到当前线程的JNIEnv。
- 根据或取到的jclass获取接口回调方法名,得到jmethodID
jmethodID j_method_id = env->GetMethodID(callback_clazz, "onDetect", "(Ljava/util/ArrayList;)V");
- 给回调入参的jobject设置值。此处只举个复杂点例子,基本类型的很简单就不展示了,具体返回值要看自己的逻辑
for (const auto &item: objects) {
auto rect = item.rect;
float array[5];
array[0] = rect.x;
array[1] = rect.y;
array[2] = rect.x + rect.width;
array[3] = rect.y + rect.height;
array[4] = (float) item.label;
jfloatArray result_array = env->NewFloatArray(5);
env->SetFloatArrayRegion(result_array, 0, 5, array);
//add
env->CallBooleanMethod(segment_array_obj, arraylist_add, result_array);
}
上面的代码意思是给float[]赋值,从签名”[F“可以看出来,然后将float[]数组通过反射添加进ArrayList
- 发起回调
回调就很简单了,看清参数含义就行。意思就是在什么类里面调用什么方法,填入什么值
env->CallVoidMethod(j_callback, j_method_id, arraylist_obj);
至此,JNI产生的目标检测结果已经回调到上层,上层可以接下来就可以用回调结果处理相应的业务逻辑。但是这里只能传常见的数据类型,还有一种数据无法回传上去,那就是图像的Mat矩阵,这个到后面会介绍。
修改 app/src/main/res/layout/activity_main.xml 如下:
private val yolov8ncnn by lazy { Yolov8ncnn() }
private val mat by lazy { Mat() }
override fun initOnCreate(savedInstanceState: Bundle?) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val openCVNativeLoader = OpenCVNativeLoader()
openCVNativeLoader.init()
yolov8ncnn.loadModel(
assets, 2, useGpu = false,
useClassify = false, useSegmentation = false, useDetect = true
)
binding.surfaceView.holder.setFormat(PixelFormat.RGBA_8888)
binding.surfaceView.holder.addCallback(this)
}
打开之前需要给应用授予相机的权限,否则会报错
override fun onResume() {
super.onResume()
if (ContextCompat.checkSelfPermission(
this, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_DENIED
) {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.CAMERA), 100
)
}
yolov8ncnn.openCamera(1)
}
只需要实现surfaceChanged方法,surfaceCreated和surfaceDestroyed不必管
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
yolov8ncnn.setOutputWindow(holder.surface, mat.nativeObjAddr, this)
}
override fun onDetect(output: ArrayList<FloatArray>) {
if (output.isEmpty()) {
return
}
//暂停算法
yolov8ncnn.onPause()
weakReferenceHandler.sendEmptyMessageDelayed(2024082901, 1000)
}
调用单模型(检测)获取目标所对应的场景,然后再根据场景切换为多模型(分割+检测)
override fun handleMessage(msg: Message): Boolean {
when (msg.what) {
2024082901 -> {
AlertControlDialog.Builder()
.setContext(this)
.setTitle("温馨提示")
.setMessage("识别到目标场景,是否开始识别目标?")
.setNegativeButton("重新识别")
.setPositiveButton("开始检查")
.setOnDialogButtonClickListener(object :
AlertControlDialog.OnDialogButtonClickListener {
override fun onConfirmClick() {
//调用多模型
yolov8ncnn.loadMultiModel(assets, intArrayOf(0, 2), false)
}
override fun onCancelClick() {
yolov8ncnn.onRestart()
}
}).build().show()
}
}
return true
}
处理多模型结果
override fun onSegmentation(segmentationOutput: ArrayList<FloatArray>, detectOutput: ArrayList<FloatArray>) {
//转成泛型集合
val segmentationResults = ArrayList<YoloResult>()
segmentationOutput.forEach {
segmentationResults.add(it.convert2YoloResult(this))
}
val detectResults = ArrayList<YoloResult>()
detectOutput.forEach {
detectResults.add(it.convert2YoloResult(this))
}
binding.detectView.updateTargetPosition(segmentationResults, detectResults)
// if (mat.width() > 0 || mat.height() > 0) {
// val bitmap = Bitmap.createBitmap(mat.width(), mat.height(), Bitmap.Config.ARGB_8888)
// Utils.matToBitmap(mat, bitmap, true)
// bitmap.saveImage("${createImageFileDir()}/${System.currentTimeMillis()}.png")
// } else {
// Log.d(kTag, "width: ${mat.width()}, height: ${mat.height()}")
// }
}
此时detectView会报错,因为这是个自定义控件,可先注释掉,后面再说。
这里还有个隐藏的细节,那就是mat,哪来的值?
setOutputWindow里面有个入参,mat.nativeObjAddr,这个是Java/Kotlin层通过JNI往C++传入内存地址(可以理解为指针),然后在在 yolo.cpp 里面给此指针赋值,那么,这样就实现了Mat矩阵数据回传的效果。 在 int Yolo::detect(const cv::Mat &rgb, std::vector &objects, float prob_threshold, float nms_threshold) 的 return 前面加上如下代码:
auto *res = (cv::Mat *) j_mat_addr;
res->create(rgb.rows, rgb.cols, rgb.type());
memcpy(res->data, rgb.data, rgb.rows * rgb.step);
override fun onPause() {
super.onPause()
yolov8ncnn.closeCamera()
}
自此,ncnn + yolov8 + opencv 这三个框架已完成在Android端的移植。将报错的地方先注释掉
// binding.detectView.updateTargetPosition(results)
- 结果回调:
如果没有特殊要求,直接复制过去即可,但是需要将里面的 classNames 改为自己模型对应的类别,虽然不会报错,但是会显示成错误的类别,注意下就行了。
然后修改app/src/main/res/layout/activity_main.xml
里面的内容如下:
红框里面改成自己包名,然后编译运行即可。这样既方便了后续逻辑处理,也规避了C++不方便渲染中文的尴尬,效果如下:
- ncnn :腾讯开源的一个为手机端极致优化的高性能神经网络前向计算框架,无第三方依赖,跨平台,ncnn主要基于C++和caffe。
- yolov8: YOLO(You Only Look Once)是一种流行的物体检测和图像分割模型,由华盛顿大学的约瑟夫-雷德蒙(Joseph Redmon)和阿里-法哈迪(Ali Farhadi)开发,YOLO 于 2015 年推出。
- opencv-mobile: The minimal opencv for Android, iOS, ARM Linux, Windows, Linux, MacOS, WebAssembly
- OpenCV: OpenCV(open source computer vision library)是一个基于BSD许可(开源)发行的跨平台计算机视觉库,可以运行在Linux、Windows、Android和Mac OS操作系统上。