Android端集成NCNN+Yolov8+OpenCV

腾讯高性能神经网络前向计算框架——ncnn联合yolov8模型、OpenCV框架交叉编译移植到Android平台。

1、课题背景

本课题原本采用Android端采集实时画面帧,然后通过网络将画面帧传递到媒体服务器,服务器再用Python+Yolov8对画面帧做检测和识别,最后将结果返回给Android端去绘制目标检测结果。这样做最大的问题就是延时,经过局域网、4/5G/WiFi网络测试,延时大概1-2s,此方案并不是最优解。为了优化(解决)此痛点,就必须将目标检测和识别移植到Android端,否则这个延时不可能会降下来。

2、解决方案

如题,采用 ncnn + yolov8 + opencv 三个框架实现这一目标

3、集成三个框架移植到Android端

3.1、Android Studio 新建C++项目

注意,必须是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++项目配置。

3.2、集成腾讯神经网络框架-ncnn

先下载腾讯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框架配置。

3.3、集成opencv-mobile框架

同理,先去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框架配置。

3.4、集成OpenCV框架

这里的OpenCV和3.3里面的opencv-mobile是有区别的,opencv-mobile是专门针对移动端做了优化。此处引入OpenCV的目的是为了后面的画面预览的数据矩阵。同样去OpenCV 官网下载最新的Android端SDK,如下图:

在这里插入图片描述

然后解压在桌面备用,按如下步骤操作:
在这里插入图片描述
在这里插入图片描述
导入进去之后会报错,别慌,按下面步骤修改即可:

  1. 首先修改app目录下面的 build.gradle,在dependencies括号里面添加一行代码,如下图:
implementation project(':sdk')

这里的”sdk“就是刚刚导入进来的OpenCV依赖库的名字,如果按照我的步骤来没有改过名字的应该就是这个,如果自己改过名字的,这里填写你改过的依赖库名字。

  1. 然后再修改sdk里面的 build.gradle,将里面的compileSdkVersion、minSdkVersion和targetSdkVersion三个字段改为了主项目一致,并删掉其中的如下代码:
    publishing {
        singleVariant('release') {
            withSourcesJar()
            withJavadocJar()
        }
    }
  1. 最后在主界面点击”Try Again“即可完成OpenCV的集成,最后效果如下:

在这里插入图片描述
到此,三大框架集成完毕。

4、导入自研yolov8的模型

在 app 的 main 目录下新建 assets 文件夹(一定要这个名字,别自己另辟蹊径),将Python导出的yolov8模型(后缀是 *.bin 和 *.param,如果不是这俩后缀的自行查找解决方案)复制进去即可。暂时先不用管,备用。

5、JNI配置

5.1、什么是JNI?

JNI(Java Native Interface),是方便Java/Kotlin调用C/C++等Native代码封装的一层接口。简单来说就是Java/Kotlin与C/C++沟通的桥梁。

5.2、新建 Yolov8ncnn.java

在 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++里面实现它们,先不用管。

5.3、将本项目的 ndkcamera.cpp 和 ndkcamera.h 复制到自己项目

这是底层相机相关的代码逻辑,包括相机打开、关闭、预览、数据回调等,通用代码,无需修改。此时会爆一堆错误提示,别慌,先不用管。

5.4、将本项目的 yolo.cpp 和 yolo.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代码如下图:
在这里插入图片描述

5.7、Java/C/C++代码调整

复制过去的yolov8ncnn.cpp文件,有四个函数一定是没有高亮的,如下图:
在这里插入图片描述
此时需要将此函数根据情况修改为自己项目包名_函数名的方式,”.“用”_“代替,比如:com.casic.test.Yolov8ncnn应改为Java_com_casic_test_Yolov8ncnn,改了之后就会发现,这四个函数已经高亮了,说明桥接代码已经生效。

  • Java
    在这里插入图片描述
  • Cpp
    在这里插入图片描述
    可以看到Java和C++代码左侧已经出现相对应的代码标识。另外还有两个文件,就是setOutputWindow方法里面的DetectResult和INativeCallback,这俩都属于回调部分的代码,一个是数据模型类,一个是回调接口,直接复制即可。

如果没有出现以上效果的,先点击”Sync Now“,再”Build-Clean Project“,再”Build-Rebuild Project“,再”Build-Refresh Linked C++ Projects“,最后关闭工程重新启动Android Studio,此时应该就没问题了。

6、JNI编码

此模块需要有能看懂的C/C++代码的的能力,以及非常熟练的使用Kotlin/Java的能力。

6.1、修改yolov8ncnn.cpp
  • 定义全局指针变量
 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[] = {"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,一定要注意,否则会报错,找不到模型。

  • 修改Java_com_casic_test_Yolov8ncnn_setOutputWindow方法(同样注意包名),在return前面加一行代码:
g_yolo->initNativeCallback(javaVM, input, nativeObjAddr, native_callback);

以上这些,我在代码里面已经加好,注意下就可以了。有个值得注意的地方,在此文件的on_image_render函数,里面的注释我也写清楚了,可以根据需求选择draw和draw_fps,如果不需要,可以都注释掉,不影响后面的逻辑。

6.2、修改Yolo.h
  • 添加全局变量
    JavaVM *javaVM;
    //输出结果类
    jobject j_output;
    //Java传过来的Mat对象内存地址
    jlong j_mat_addr;
    //回调类
    jobject j_callback;
  • 添加Java/C++初始化函数
void initNativeCallback(JavaVM *vm, jobject result, jlong nativeObjAddr, jobject pJobject);
6.3、修改Yolo.cpp
  • 修改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);
    //此时input转为output
    j_output = env->NewGlobalRef(input);

    j_mat_addr = nativeObjAddr;

    j_callback = env->NewGlobalRef(pJobject);
}

有个注意点,JNIEnv不支持跨线程调用,一定要注意,否则会报错,之前在Yolo.h定义的全局变量也需要在此处初始化。
以上这些,我在代码里面已经加好,如果要加自己的逻辑,知道在此处改就行了。

  • 修改detect函数(划重点!划重点!划重点!)

其实在以上步骤完成时候就已经能把自定义的模型在Android端跑起来了(运行了一下,没效果???那是自然,因为 MainActivity.kt 还没有实现逻辑),已经可以检测目标了。但是有缺陷,第一就是检测的结果只能在C++层面使用,Java/Kotlin层无法用,所以修改此函数的目的就是把检测结果回传到应用层,让应用层去做具体业务逻辑处理。第二个就是,C++底层只能渲染英文字符,中文的显示不出来或者显示乱码,当然也不是没有解决思路,需要交叉编译freetype2这个库,有兴趣的可以自行实现。

知识点预热

  1. 基本类型签名
JavaJNI签名
bytejbyteB
charjcharC
doublejdoubleD
floatjfloatF
intjintI
shortjshortS
longjlongJ
booleanjbooleanZ
voidvoidV
  1. 引用数据类型的转换
JavaJNI签名
所有对象jobjectL+classname +;
ClassjclassLjava/lang/Class;
StringjstringLjava/lang/String;
ThrowablejthrowableLjava/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反射的这里理解起来应该不难

  1. 获取Java代码的回调接口,得到回调接口的jclass
    JNIEnv *env;
    javaVM->AttachCurrentThread(&env, nullptr);
    jclass callback_clazz = env->GetObjectClass(j_callback);
    jclass output_clazz = env->GetObjectClass(j_output);

注意:JNIEnv不支持跨线程,所以必须通过之前定义的全局指针变量 javaVM 得到当前线程的JNIEnv。

  1. 获取Java代码的回调接口里面的入参,得到回调入参的jclass
    jclass output_clazz = env->GetObjectClass(j_output);
  1. 根据或取到的jclass获取接口回调方法名,得到jmethodID
jmethodID j_method_id = env->GetMethodID(callback_clazz, "onDetect", "(Ljava/util/ArrayList;)V");
  1. 给回调入参的jobject设置值。此处只举个复杂点例子,基本类型的很简单就不展示了,具体返回值要看自己的逻辑
        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“可以看出来

  1. 发起回调

回调就很简单了,看清参数含义就行。意思就是在什么类里面调用什么方法,填入什么值

env->CallVoidMethod(j_callback, j_method_id, arraylist_obj);

至此,JNI产生的目标检测结果已经回调到上层,上层可以接下来就可以用回调结果处理相应的业务逻辑。但是这里只能传常见的数据类型,还有一种数据无法回传上去,那就是图像的Mat矩阵,这个到后面会介绍。

7、Kotlin编码

7.1、搭建界面布局

修改 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>
7.2、初始化Yolov8ncnn和矩阵Mat对象,懒汉模式
private val yolov8ncnn by lazy { Yolov8ncnn() }
private val mat by lazy { Mat() }
7.3、在initOnCreate中加载模型以及初始化OpenCV和SurfaceView
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")
    }
}
7.4、在onResume里面打开相机

打开之前需要给应用授予相机的权限,否则会报错

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)
}
7.5、实现SurfaceHolder.Callback回调

只需要实现surfaceChanged方法,surfaceCreated和surfaceDestroyed不必管

override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
    yolov8ncnn.setOutputWindow(holder.surface, DetectResult(), mat.nativeObjAddr, this)
}
7.6、实现INativeCallback回调
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);
7.7、在onPause里面关闭相机
override fun onPause() {
    super.onPause()
    yolov8ncnn.closeCamera()
}

自此,ncnn + yolov8 + opencv 这三个框架已完成在Android端的移植。将报错的地方先注释掉

// binding.detectView.updateTargetPosition(output)
  • 运行起来的效果如下:

在这里插入图片描述

  • 结果回调:
    在这里插入图片描述

  • Mat矩阵转PNG结果:
    在这里插入图片描述

7.8、实现自定义控件

如果没有特殊要求,直接复制过去即可,但是需要将里面的 classNames 改为自己模型对应的类别,虽然不会报错,但是会显示成错误的类别,注意下就行了。然后修改 app/src/main/res/layout/activity_main.xml 里面的内容如下:
在这里插入图片描述

红框里面改成自己包名,然后编译运行即可。这样既方便了后续逻辑处理,也规避了C++不方便渲染中文的尴尬,效果如下:

在这里插入图片描述
源码地址:ncnn-yolov8-android

以上就是完整的ncnn + yolov8 + opencv 移植到Android端的详细步骤,希望能帮到需要此需求的人!最后感谢各个开源者的辛勤付出(排名不分先后):

  • 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操作系统上。
  • 30
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值