问题描述

情况描述:业务需求,在安卓系统上使用人脸检测功能,一开始使用的方案是在unity中直接调用unity资源商店中的插件“opencv-unity.unitypacage”,魔改一下关于人脸检测的脚本就可以用了。实际测试也没有问题
但是,在安卓广告机上使用发现会无缘无故闪退,经过一系列排查,最终确定是因为调用了webcamtexture之类的脚本,调取相机出现异常,导致的闪退
在网上找了两三天的帖子寻求帮助,发现这是安卓主板3588和3568不兼容unity的webcamtexture关于相机的api的脚本,因此在unity中直接使用插件进行人脸检测功能是没有什么可能了。找了技术支持也是提供不了什么解决方案的(想想也知道是个大工程…)

解决思路

那么怎么办呢?

我在排查的过程中发现,调用安卓原生的相机这些api时,这两个主板上是可以正常跑通的。(实际使用过·opencv安卓原生sdk·以及·facedetector·),调用原生相机没问题的话,那么只要将原生调用相机的插件接入到unity中不是就可以了吗?

解决过程

准备工作

为了印证该方案是否可行

  1. 需要确定opencv安卓原生的sdk能正常使用人脸检测功能,并且作为一个aar包被unity调用
  2. 找到人脸检测的代码,在识别到人脸的逻辑中发送消息给unity
  3. unity接收并处理信息(图片数据或图片路径)

opencv安卓原生sdk

在官网上下载安卓原生sdk包(SDK下载地址),然后导入到AndroidStudio中,由于它本身就是一个模块,所以很方便进行调用,在主模块中创建一个activity,继承sdk中的OpencvActivity,然后打包运行,会出现一个显示相机画面的界面,检测到人脸就会自动在人脸的部分绘制一个框框,这和unity的插件效果是一致的,然后打包到安卓主板(3588和3568)上进行测试,也是可以正常检测到的。这一步没问题
tips:需要自行处理gradle版本和compilesdk版本的问题,以及打包过程中可能出现的异常(需要在build.gradle中屏蔽掉一些代码,以及添加一些代码)时间问题就直接贴上来了

apply plugin: 'com.android.library'
apply plugin: 'maven-publish'
//apply plugin: 'kotlin-android'
//apply plugin: 'kotlin-android-extensions'

def openCVersionName = "4.10.0"
def openCVersionCode = ((4 * 100 + 10) * 100 + 0) * 10 + 0

println "OpenCV: " +openCVersionName + " " + project.buildscript.sourceFile

android {
//    namespace 'org.opencv'
    compileSdkVersion 31

    defaultConfig {
        minSdkVersion 21
        targetSdkVersion 31

        versionCode openCVersionCode
        versionName openCVersionName

        externalNativeBuild {
            cmake {
                arguments "-DANDROID_STL=c++_shared"
                targets "opencv_jni_shared"
            }
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    buildTypes {
        debug {
            packagingOptions {
                doNotStrip '**/*.so'  // controlled by OpenCV CMake scripts
            }
        }
        release {
            packagingOptions {
                doNotStrip '**/*.so'  // controlled by OpenCV CMake scripts
            }
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
        }
    }
    lintOptions {
        checkReleaseBuilds false
        abortOnError false
    }

//    buildFeatures {
//        prefabPublishing true
//        buildConfig true
//    }
//    prefab {
//        opencv_jni_shared {
//            headers "native/jni/include"
//        }
//    }

    sourceSets {
        main {
            jniLibs.srcDirs = ['native/libs']
            java.srcDirs = ['java/src']
            res.srcDirs = ['java/res']
            manifest.srcFile 'java/AndroidManifest.xml'
        }
    }

//    publishing {
//        singleVariant('release') {
//            withSourcesJar()
//            withJavadocJar()
//        }
//    }

    externalNativeBuild {
        cmake {
            path (project.projectDir.toString() + '/libcxx_helper/CMakeLists.txt')
        }
    }
}

publishing {
    publications {
        release(MavenPublication) {
            groupId = 'org.opencv'
            artifactId = 'opencv'
            version = '4.10.0'

            afterEvaluate {
               from components.release
           }
        }
    }
    repositories {
        maven {
            name = 'myrepo'
            url = "${project.buildDir}/repo"
        }
    }
}

dependencies {
    implementation files('libs\\unity-classes.jar')

    implementation 'com.android.support:appcompat-v7:28.0.0'
//implementation files('libs\\unity-classes.jar')
}

task CopyPlugin(type: Copy) {
    dependsOn assemble
    from('build/outputs/aar')
    into('../../Assets/Plugins/Android')
    include(project.name + '-release.aar')
}

找到人脸检测的代码,检测成功后发送消息给unity

这一步比较简单,找到opencvactivity中的visualize方法,其中的faces.rows()就是检测到的人脸数量。目前我只需要它告诉我有人脸就行了,所以就直接在这个方法中发送消息给unity

UnityPlayer.UnitySendMessage("receiveObj", "faceresult", "人脸数量为" + faces.rows());

unity接收消息

这一步就更简单了
在场景中创建receiveObj这个对象,然后把脚本挂载到这个对象中,添加下面的代码


    public void faceresult(string path)
    {
    	//接收到消息之后的逻辑处理
    }

遇到的问题

那么实际上,在按照这个思路和方案执行的过程中,会遇到很多,很多,很多小问题

问题一

如果要调用人脸校测的脚本,就要使用到opencvactivity,而观察代码可发现它是一个activity,那么调用这个activity,势必会将unity 的activiy进行遮挡,导致无法点击和看到unity的界面,十分影响体验,可以说是十分致命的问题

问题二

传输数据。在opencvactivity中获取到的数据,需要转化成二进制数据或者base64或者图片路径然后再传给unity才能使用,实际上在使用过程中发现无法正确获取到二进制和base64这两个数据

问题三

原生的opencv插件打包成aar后,在unity中调用会出现些许问题,如无法正确找到opencvactivity,无法找到主题等异常
这几个是主要的问题,其他零散的可能一时间想不起来了

如何解决遇到的问题

问题一:opencvactivity遮挡unity的界面问题

这个问题在前面的方案思路中有提到,为了使用这个插件,就需要调用这个activity,但我本身unity就有一个activity了,如果调用这个activity的话,势必会暂停unity的界面,然后显示opencvactivity,从而导致体验感下降
解决方法:

  1. 隐藏这个activity
  2. 魔改这个activity,改成fragment或者dialog
    由于时间和能力有限,方法1是我的唯一选择
    那么这个方法在网上能找到很多解决方案
    我是这么解决的

1.1 在res的values文件夹中的style.xml和theme.xml文件中,添加关于透明主题和样式的信息
style.xml


    <style name="TranslucentActivity" parent="Theme.AppCompat.Light.NoActionBar">//无标题
        <item name="android:windowIsTranslucent">true</item>//透明
    </style>

theme.xml

<resources>

    <style name="TranslucentActivity.NoActionBar">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
    </style>

    <style name="TranslucentActivity.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />

    <style name="TranslucentActivity.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</resources>

1.2 在app的AndroidManifest.xml的application节点中添加


        android:theme="@style/TranslucentActivity"

1.3 在opencvactivity脚本中设置一些参数
需要将这个opencvactivity脚本对应的view进行隐藏,去掉点击事件和返回事件。也就是在onCreate方法的最后添加下面的代码

getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);

        setContentView(R.layout.face_detect_surface_view);

        mOpenCvCameraView = (CameraBridgeViewBase) findViewById(R.id.fd_activity_surface_view);
//        mOpenCvCameraView.setVisibility(CameraBridgeViewBase.VISIBLE);
        mOpenCvCameraView.setCvCameraViewListener(this);

        // 设置透明沉浸状态栏
        if (Build.VERSION.SDK_INT >= 21) {
            View decorView = getWindow().getDecorView();
            decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); //使背景图与状态栏融合到一起,这里需要在setcontentview前执行
            getWindow().setStatusBarColor(Color.TRANSPARENT);
        }
        //设置1像素
        Window window = getWindow();
        window.setGravity(Gravity.LEFT | Gravity.TOP);
        WindowManager.LayoutParams params = window.getAttributes();
        params.x = 0;
        params.y = 0;
        params.height = 1;
        params.width = 1;
        window.setAttributes(params);

1.4 可能会出现的异常(attr之类的)
需要在app的builder.gradle中添加


    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'

通过以上步骤应该就可以实现将opencvactivity对应的view进行隐藏掉的功能了

问题二:数据传输

因为没时间研究sdk的逻辑和代码,所以我只是大概的看了一下,发现将获取到的人脸信息转成byte[]或者base64再传给unity的话,他的值始终是不变的。原因还没有研究,但是这个路子行不通的话就只能在sdk检测到人脸之后存到本地,然后返回给unity一个文件名,unity接收到文件名,在相应的路径下读取文件然后获取字节流了

  1. sdk保存图片到本地(路径我设置为了私有路径,也即是包名下的路径)
    在opencvactivity的visualize中添加savealum方法,savealbum方法和相应的其他方法如下
 public static String saveAlbum(Context context, Mat rbga, Bitmap.CompressFormat format, int quality, boolean recycle) {
        Bitmap bitmap = null;
        bitmap = Bitmap.createBitmap(rbga.cols(), rbga.rows(), Bitmap.Config.ARGB_8888);
        ByteArrayOutputStream byStream = new ByteArrayOutputStream();
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, byStream);
        byte[] byteArray = byStream.toByteArray();
        Utils.matToBitmap(rbga, bitmap);
        String suffix;
        if (Bitmap.CompressFormat.JPEG == format)
            suffix = "JPG";
        else
            suffix = format.name();
        String fileName = System.currentTimeMillis() + "_" + quality + "." + suffix;
        if (Build.VERSION.SDK_INT < 29) {
            if (!isGranted(context)) {
                Log.e("ImageUtils", "save to album need storage permission");
                return null;
            }
            File picDir = Environment.getExternalStoragePublicDirectory("");
            File destFile = new File(context.getFilesDir(), fileName);
            if (!save(bitmap, destFile, format, quality, recycle))
                return null;
            Uri uri = null;
            if (destFile.exists()) {
                uri = Uri.parse("file://" + destFile.getAbsolutePath());
                Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
                intent.setData(uri);
                context.sendBroadcast(intent);
            }
            return fileName;
        } else {
            // 获取内部存储的目录
            File dir = context.getExternalFilesDir(null);

            // 创建文件对象
            File file = new File(dir, fileName);

            // 创建一个用于写入文件的FileOutputStream
            try (FileOutputStream fos = new FileOutputStream(file)) {
                // 压缩图片到文件输出流中(这里以PNG格式为例)
                // 注意:你也可以选择其他格式,如JPEG,但需要使用不同的compress方法
                bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
                return fileName;
            } catch (IOException e) {
                e.printStackTrace();
                return "fail";
            }
        }

//        return Base64.encodeToString(byteArray, Base64.DEFAULT);
    }

    private static boolean save(Bitmap bitmap, File file, Bitmap.CompressFormat format, int quality, boolean recycle) {
        if (isEmptyBitmap(bitmap)) {
            Log.e("ImageUtils", "bitmap is empty.");
            return false;
        }
        if (bitmap.isRecycled()) {
            Log.e("ImageUtils", "bitmap is recycled.");
            return false;
        }
        if (!createFile(file, true)) {
            Log.e("ImageUtils", "create or delete file <$file> failed.");
            return false;
        }
        OutputStream os = null;
        boolean ret = false;
        try {
            os = new BufferedOutputStream(new FileOutputStream(file));
            ret = bitmap.compress(format, quality, os);
            if (recycle && !bitmap.isRecycled()) bitmap.recycle();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (os != null)
                    os.close();
            } catch (IOException e) {
                // ignore
            }
        }
        return ret;
    }

    private static boolean isEmptyBitmap(Bitmap bitmap) {
        return bitmap == null || bitmap.isRecycled() || bitmap.getWidth() == 0 || bitmap.getHeight() == 0;
    }

    private static boolean createFile(File file, boolean isDeleteOldFile) {
        if (file == null) return false;
        if (file.exists()) {
            if (isDeleteOldFile) {
                if (!file.delete()) return false;
            } else
                return file.isFile();
        }
        if (!createDir(file.getParentFile())) return false;
        try {
            return file.createNewFile();
        } catch (IOException e) {
            return false;
        }
    }

    private static boolean createDir(File file) {
        if (file == null) return false;
        if (file.exists())
            return file.isDirectory();
        else
            return file.mkdirs();
    }

    private static boolean isGranted(Context context) {
        return (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE));
    }

保存本地图片的方法我是在网上扒的,网上在安卓10以后都可以访问到DCIM文件夹了,他们也大多都是使用的这个方法,但unity是不支持访问这些路径的,所以我还是改成了保存到报名下的私有路径中,这样unity就可以直接通过Application.persistdatapath这个api去访问文件了
然后只需要visulize这个方法中保存图片成功后,将图片名称返回给unity就可以了

问题三:打包失败的一些解决方法

问题三关于打包过程的问题在问题1中已经解决的差不多了,其他的可能就是一些版本问题,可以查看一下和我下面的信息是否有出入
需要实现一些样式的插件,否则在打包的时候会提示无法找到对应的UI属性的问题
因为compilesdkversion版本导致闪退的问题
在unity中可能会出现的无法正常拉取opencvactivity导致闪退的异常问题

其他注意事项

  1. 如果按照我的实现方法,就不能只在unity中打包,因为unity原生默认的主题是黑色的(涉及到unityplayeractivity这个类,而这个类里的方法都是在unityclass.jar中的,超出了我的能力范围),这个我尝试过改成透明的,这样会导致整个unityactivity都变成透明度的,这样是不合理的现象,所以你需要
    1.1 unity中打包成安卓工程,而不是apk。
    1.2 然后导入到androidstudio项目中,最好新建一个
    1.3 新建一个项目,然后在app的src中创建一个activity,这个activity的作用很简单,就是调用unityplayer这个脚本,如下
public class StartActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent intent = new Intent(getApplicationContext(), UnityPlayerActivity.class);
        startActivity(intent);
    }
}
  1. opencv插件如何打包成aar包
    2.1 在插件模块中的build.gradle最后添加

task CopyPlugin(type: Copy) {
    dependsOn assemble
    from('build/outputs/aar')
    into('../../Assets/Plugins/Android')
    include(project.name + '-release.aar')
}

然后在androidstudio如下图点击对应的copyplguins然后运行即可
在这里插入图片描述
2.2 直接在如下图中点击生成
在这里插入图片描述
如果找不到上图的gradle的命令选项的话,需要在settings中打开,如下图
在这里插入图片描述
3. 小技巧
可以直接使用unity的library中的路径在androidstudio中直接打开unity打包的项目,然后使用androidstudio进行打包,不用unity的打包。如下图
在这里插入图片描述

找到对应的路径,然后在androidstudio中打开,就可以用androidstudio进行打包了
这一个的目的是因为unity中修改一些配置类的文件比较繁琐,所以可以直接在library中以androidstudio的方式打开安卓工程,然后以我们比较熟悉的界面去进行配置的修改和打包测试

  1. 在unity调用opencvactivity过程中,会出现unity界面内容停止的情况
    这个原因很简单,因为是从一个activity打开另一个activity,上一个activity是肯定会暂停的。那么为了体验感,就需要unity的activity保持运行。我的解决方法比较粗暴,不太优美。直接修改unity的unityplayeractivity这个脚本的生命周期。如下图,将onPause方法中的mUnityPlayer.onPause()这一行代码注释掉即可
    在这里插入图片描述

补充一下,这么做有一个问题,在切换应用重新切换回来时,界面会卡住不动,分析原因就是因为这行代码被注释掉了,解决方案目前还没有想到,这边先做记录
嗯更新一下,想到了一个鸡贼的办法,注释掉pause切换应用回来界面会卡着不动,但是回来的时候会触发onresume这个方法
因此解决方法就是在onresume方法里再加上mUnityPlayer.Stop()这个方法
实验打包后发现确实能行…就是不知道这样魔改会不会造成其他逻辑的问题…
总结,以上就是大致实现通过opencv安卓原生的sdk插件实现人脸检测的同时不影响unity本身的activity运行,然后将检测到的信息保存到本地,通知unity,unity将信息读取出来进行处理的功能了。
这个流程走通大概花了4、5天左右,中间试错成本也不低。不过总算是能勉强解决这个因为安卓主板无法调用webcam相机然后闪退从而导致无法进行人脸检测的问题了。

太痛苦了…

更多推荐