赞
踩
腾讯高性能神经网络前向计算框架——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" 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 下载框架,如图:
然后解压,复制到项目的cpp目录下,不要改任何文件以及代码,如下图:
修改上一步的 CMakeLists.txt,添加如下代码:
set(OpenCV_DIR ${CMAKE_SOURCE_DIR}/opencv-mobile-2.4.13.7-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,如下图:
然后解压在桌面备用,按如下步骤操作:
导入进去之后会报错,别慌,按下面步骤修改即可:
implementation project(':sdk')
这里的”sdk“就是刚刚导入进来的OpenCV依赖库的名字,如果按照我的步骤来没有改过名字的应该就是这个,如果自己改过名字的,这里填写你改过的依赖库名字。
publishing {
singleVariant('release') {
withSourcesJar()
withJavadocJar()
}
}
到此,三大框架集成完毕。
在 app 的 main 目录下新建 assets 文件夹(一定要这个名字,别自己另辟蹊径),将Python导出的yolov8模型(后缀是 *.bin 和 *.param,如果不是这俩后缀的自行查找解决方案)复制进去即可。暂时先不用管,备用。
JNI(Java Native Interface),是方便Java/Kotlin调用C/C++等Native代码封装的一层接口。简单来说就是Java/Kotlin与C/C++沟通的桥梁。
在 app/src/main/java/自己的包名 目录下新建 Yolov8ncnn.java(Yolov8ncnn.kt 也是可以的),代码如下:
public class Yolov8ncnn {
static {
System.loadLibrary("yolov8ncnn");
}
public native boolean loadModel(AssetManager mgr, int model_id, int processor);
public native boolean openCamera(int facing);
public native boolean closeCamera();
public native boolean setOutputWindow(Surface surface, DetectResult input, long nativeObjAddr, INativeCallback nativeCallback);
}
static(Kotlin里面是companion object,伴生对象)包裹的代码意思是C/C++代码编译之后动态链接库的名字(此时还没有,因为还没有添加C/C++代码)。另外四个方法和普通的Java方法的区别在于全部都有native关键字修饰(Kotlin里面是external),表明这几个方法需要调用C/C++代码,也就是前文提到的”桥梁“。此时代码会报错,是因为还没有在C/C++里面实现它们,先不用管。
这是底层相机相关的代码逻辑,包括相机打开、关闭、预览、数据回调等,通用代码,无需修改。此时会爆一堆错误提示,别慌,先不用管。
这两代码文件主要的功能是对相机预览产生的nv21数据进行处理,包括nv21转换、nv21转Mat矩阵、图像裁剪、灰度处理、调用模型检测目标、显示检测结果、回调等一些列操作,底层逻辑就在此实现。此时依旧会爆一堆错误提示,别慌,先不用管。
此代码文件主要包括相机初始化,参数初始化。整体来说就是各种初始化。
添加如下两行代码:
add_library(yolov8ncnn SHARED yolov8ncnn.cpp yolo.cpp ndkcamera.cpp)
target_link_libraries(yolov8ncnn ncnn ${OpenCV_LIBS} camera2ndk mediandk)
最终的cmake代码如下图:
复制过去的yolov8ncnn.cpp文件,有四个函数一定是没有高亮的,如下图:
此时需要将此函数根据情况修改为自己项目包名_函数名的方式,”.“用”_“代替,比如:com.casic.test.Yolov8ncnn应改为Java_com_casic_test_Yolov8ncnn,改了之后就会发现,这四个函数已经高亮了,说明桥接代码已经生效。
如果没有出现以上效果的,先点击”Sync Now“,再”Build-Clean Project“,再”Build-Rebuild Project“,再”Build-Refresh Linked C++ Projects“,最后关闭工程重新启动Android Studio,此时应该就没问题了。
此模块需要有能看懂的C/C++代码的的能力,以及非常熟练的使用Kotlin/Java的能力。
static JavaVM *javaVM = nullptr;
javaVM = vm;
将model_types、target_sizes、mean_values、norm_values改为如下代码:
const char *model_types[] = {"s-detect-sim-opt-fp16"};
const int target_sizes[] = {320};
const float mean_values[][3] = {
{103.53f, 116.28f, 123.675f}
};
const float norm_values[][3] = {
{1 / 255.f, 1 / 255.f, 1 / 255.f}
};
其中model_types里面的值是你yolov8模型去掉前缀以及后缀剩下的部分,比如:yolov8s-detect-sim-opt-fp16.bin的值应该是 s-detect-sim-opt-fp16,一定要注意,否则会报错,找不到模型。
g_yolo->initNativeCallback(javaVM, input, nativeObjAddr, native_callback);
以上这些,我在代码里面已经加好,注意下就可以了。有个值得注意的地方,在此文件的on_image_render函数,里面的注释我也写清楚了,可以根据需求选择draw和draw_fps,如果不需要,可以都注释掉,不影响后面的逻辑。
JavaVM *javaVM;
//输出结果类
jobject j_output;
//Java传过来的Mat对象内存地址
jlong j_mat_addr;
//回调类
jobject j_callback;
void initNativeCallback(JavaVM *vm, jobject result, jlong nativeObjAddr, jobject pJobject);
根据自己模型能够识别的目标种类修改此函数的num_class字段,比如,此处我已改为如下:
const int num_class = 43;
void Yolo::initNativeCallback(JavaVM *vm, jobject input, jlong nativeObjAddr, jobject pJobject) {
javaVM = vm;
/**
* JNIEnv不支持跨线程调用
* */
JNIEnv *env;
vm->AttachCurrentThread(&env, nullptr);
//此时input转为output
j_output = env->NewGlobalRef(input);
j_mat_addr = nativeObjAddr;
j_callback = env->NewGlobalRef(pJobject);
}
有个注意点,JNIEnv不支持跨线程调用,一定要注意,否则会报错,之前在Yolo.h定义的全局变量也需要在此处初始化。
以上这些,我在代码里面已经加好,如果要加自己的逻辑,知道在此处改就行了。
其实在以上步骤完成时候就已经能把自定义的模型在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反射的这里理解起来应该不难
JNIEnv *env;
javaVM->AttachCurrentThread(&env, nullptr);
jclass callback_clazz = env->GetObjectClass(j_callback);
jclass output_clazz = env->GetObjectClass(j_output);
注意:JNIEnv不支持跨线程,所以必须通过之前定义的全局指针变量 javaVM 得到当前线程的JNIEnv。
jclass output_clazz = env->GetObjectClass(j_output);
jmethodID j_method_id = env->GetMethodID(callback_clazz, "onDetect", "(Ljava/util/ArrayList;)V");
jfieldID position = env->GetFieldID(output_clazz, "position", "[F");
float array[4];
array[0] = item.rect.x;
array[1] = item.rect.y;
array[2] = item.rect.width;
array[3] = item.rect.height;
jfloatArray rectArray = env->NewFloatArray(4);
env->SetFloatArrayRegion(rectArray, 0, 4, array);
env->SetObjectField(j_output, position, rectArray);
上面的代码意思是给float[]赋值,从签名”[F“可以看出来
回调就很简单了,看清参数含义就行。意思就是在什么类里面调用什么方法,填入什么值
env->CallVoidMethod(j_callback, j_method_id, arraylist_obj);
至此,JNI产生的目标检测结果已经回调到上层,上层可以接下来就可以用回调结果处理相应的业务逻辑。但是这里只能传常见的数据类型,还有一种数据无法回传上去,那就是图像的Mat矩阵,这个到后面会介绍。
修改 app/src/main/res/layout/activity_main.xml 如下:
报错的原因是没有配置此参数,在 app/src/main/res/values/strings.xml 里面配置下就好了,如下:
<resources>
<string name="app_name">Test</string>
<string-array name="cpu_gpu_array">
<item>CPU</item>
<item>GPU</item>
</string-array>
</resources>
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) OpenCVLoader.initLocal() binding.surfaceView.holder.setFormat(PixelFormat.RGBA_8888) binding.surfaceView.holder.addCallback(this) reloadModel() } private fun reloadModel() { val result = yolov8ncnn.loadModel(assets, currentModel, currentProcessor) if (!result) { Log.d(kTag, "reload: yolov8ncnn loadModel failed") } }
打开之前需要给应用授予相机的权限,否则会报错
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(facing)
}
只需要实现surfaceChanged方法,surfaceCreated和surfaceDestroyed不必管
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
yolov8ncnn.setOutputWindow(holder.surface, DetectResult(), mat.nativeObjAddr, this)
}
override fun onDetect(output: ArrayList<DetectResult>) {
Log.d(kTag, output.toJson())
binding.detectView.updateTargetPosition(output)
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(output)
结果回调:
Mat矩阵转PNG结果:
如果没有特殊要求,直接复制过去即可,但是需要将里面的 classNames 改为自己模型对应的类别,虽然不会报错,但是会显示成错误的类别,注意下就行了。然后修改 app/src/main/res/layout/activity_main.xml 里面的内容如下:
红框里面改成自己包名,然后编译运行即可。这样既方便了后续逻辑处理,也规避了C++不方便渲染中文的尴尬,效果如下:
源码地址:ncnn-yolov8-android
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。