はじめに

この記事はMLKitで顔をリアルタイムで検出してみた(準備編) の続きです。

前回の記事で下準備が完了したので、今回から実際にアプリを作っていきます。

カメラプレビューの実装(Androidプラグイン)

フォルダ表示をAndroidに変更します。(以降の説明もAndroid表示を前提とします。)

AndroidManifest.xmlの設定

カメラ権限をfacemaskmodule/manifests/AndroidManifest.xmlに記述します。

メインクラスの作成

Unityから呼び出すJavaのメインクラスを作成します。

カメラ画像の取得処理を実装します。
本記事向けに最小構成で書きました。

public class FaceMaskManager
{
    /************************************************************************
     *
     * Private 変数
     *
     ************************************************************************/

    /**
     * カメラ解像度の幅
     */
    private int _cameraWidth = -1;

    /**
     * カメラ解像度の高さ
     */
    private int _cameraHeight = -1;

    /**
     * カメラデバイス
     */
    private CameraDevice _cameraDevice = null;

    /**
     * 撮影セッション
     */
    private CameraCaptureSession _session = null;

    /**
     * Image Reader
     */
    private ImageReader _imageReader = null;

    /**
     * 撮影処理のハンドラ
     */
    private Handler _captureHandler = null;

    /**
     * 撮影処理のスレッド
     */
    private HandlerThread _captureThread = null;

    /************************************************************************
     *
     * コールバック
     *
     ************************************************************************/

    /**
     * カメラデバイスの状態遷移時のコールバック
     */
    private CameraDevice.StateCallback _cameraStateCallback = new CameraDevice.StateCallback()
    {
        @Override
        public void onOpened(@NonNull CameraDevice camera)
        {
            // カメラデバイスを取得
            _cameraDevice = camera;

            // ImageReaderを作成する
            _imageReader = createImageReader(_cameraWidth, _cameraHeight, _captureHandler);

            // 繰り返し撮影を開始する
            startRepeatingCapture(_captureHandler);
        }

        @Override
        public void onDisconnected(@NonNull CameraDevice camera)
        {
            _cameraDevice.close();
        }

        @Override
        public void onError(@NonNull CameraDevice camera, int error)
        {
            onDisconnected(_cameraDevice);
            Log.e(TAG, "CameraDevice.StateCallback.onError: " + error);
        }
    };

    /**
     * 撮影した画像が利用可能になった時のコールバック
     */
    private ImageReader.OnImageAvailableListener _onImageAvailableListener = new ImageReader.OnImageAvailableListener()
    {
        @Override
        public void onImageAvailable(ImageReader reader)
        {
            Image image = reader.acquireNextImage();

            // TODO 諸々の画像処理

            image.close();
            image = null;
        }
    };


    /************************************************************************
     *
     * 定数
     *
     ************************************************************************/

    /**
     * ログ用タグ
     */
    private static final String TAG = "Gaprot";


    /************************************************************************
     *
     * Unityから呼び出されるメソッド
     *
     ************************************************************************/

    /**
     * 開始する
     *
     * @param width  カメラ解像度の幅
     * @param height カメラ解像度の高さ
     */
    public void Start(int width, int height)
    {
        _cameraWidth = width;
        _cameraHeight = height;

        // カメラを起動する
        openCamera();

        // 撮影スレッドを開始する
        startCaptureThread();
    }

    /**
     * 終了する
     */
    public void Stop()
    {
        // 撮影スレッドを停止
        stopCaptureThread();

        // セッションをClose
        if (_session != null)
        {
            try
            {
                _session.stopRepeating();
            }
            catch (CameraAccessException e)
            {
                Log.e(TAG, "Close: " + e.getMessage());
            }
            _session.close();
            _session = null;
        }

        // カメラをClose
        if (_cameraDevice != null)
        {
            _cameraDevice.close();
            _cameraDevice = null;
        }

        // ImageReaderをClose
        if (_imageReader != null)
        {
            _imageReader.close();
            _imageReader = null;
        }
    }


    /************************************************************************
     *
     * Private メソッド
     *
     ************************************************************************/

    /**
     * カメラを起動する
     */
    private void openCamera()
    {
        CameraManager cameraManager = (CameraManager) UnityPlayer.currentActivity.getSystemService(Context.CAMERA_SERVICE);

        try
        {
            String cameraId = cameraManager.getCameraIdList()[0];

            // カメラ権限を確認
            if (UnityPlayer.currentActivity.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)
            {
                // 権限を要求
                requestCameraPermission();
                return;
            }
            cameraManager.openCamera(cameraId, _cameraStateCallback, null);
        }
        catch (CameraAccessException e)
        {
            Log.e(TAG, "open: " + e.getMessage());
        }
    }

    /**
     * 撮影スレッドを開始する
     */
    private void startCaptureThread()
    {
        _captureThread = new HandlerThread("Capture");
        _captureThread.start();
        _captureHandler = new Handler(_captureThread.getLooper());
    }

    /**
     * 撮影スレッドを停止する
     */
    private void stopCaptureThread()
    {
        _captureThread.quit();
        _captureThread = null;
        _captureHandler = null;
    }

    /**
     * カメラの権限を要求する
     */
    private void requestCameraPermission()
    {
        String[] permissions = {Manifest.permission.CAMERA};
        UnityPlayer.currentActivity.requestPermissions(permissions, 200);
    }

    /**
     * ImageReaderを作成する
     *
     * @param width   幅
     * @param height  高さ
     * @param handler 撮影スレッドのハンドラ
     */
    private ImageReader createImageReader(int width, int height, Handler handler)
    {
        ImageReader imageReader = ImageReader.newInstance(width, height, ImageFormat.JPEG, 1);
        imageReader.setOnImageAvailableListener(_onImageAvailableListener, handler);
        return imageReader;
    }

    /**
     * 繰り返し撮影を開始する
     *
     * @param handler 撮影スレッドのハンドラ
     */
    private void startRepeatingCapture(Handler handler)
    {
        try
        {
            final CaptureRequest.Builder captureRequestBuilder = _cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);

            CameraCaptureSession.StateCallback stateCallback = new CameraCaptureSession.StateCallback()
            {
                @Override
                public void onConfigured(@NonNull CameraCaptureSession session)
                {
                    // セッションを取得
                    _session = session;

                    // キャプチャリクエストを作成
                    captureRequestBuilder.set(
                            CaptureRequest.CONTROL_AF_MODE,
                            CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);

                    CaptureRequest captureRequest = captureRequestBuilder.build();

                    try
                    {
                        // 繰り返し撮影を開始
                        _session.setRepeatingRequest(captureRequest, null, null);
                    }
                    catch (CameraAccessException e)
                    {
                        Log.e(TAG, "onConfigured: " + e.getMessage());
                    }
                }

                @Override
                public void onConfigureFailed(@NonNull CameraCaptureSession session)
                {
                    Log.e(TAG, "キャプチャセッションの設定に失敗しました。");
                }
            };

            captureRequestBuilder.addTarget(_imageReader.getSurface());
            _cameraDevice.createCaptureSession(Arrays.asList(_imageReader.getSurface()), stateCallback, handler);
        }
        catch (CameraAccessException e)
        {
            Log.e(TAG, "startRepeatingCapture: " + e.getMessage());
        }
    }
}

コールバックだらけで非常にわかりづらいです。
大まかな流れを説明すると、

  1. FaceMaskManagerのインスタンスを外部から生成。
  2. 外部からStart()が呼ばれる。
  3. openCameraで権限の確認とカメラの開始処理をする。
  4. _cameraStateCallback.onOpened()が呼ばれる
  5. startRepeatingCapture()を実行。
  6. CameraCaptureSession.StateCallback.onConfigured()が呼ばれる。
  7. _session.setRepeatingRequest()が実行されて、繰り返し撮影を開始する。
  8. 撮影した画像が_onImageAvailableListener.onImageAvailable()にコールバックされる。

です。

_onImageAvailableListenerに順次カメラ画像が渡ってくるので、これをカメラプレビューや顔検出に利用していきます。

カメラから取得した画像をBitmapに変換する

ImageReaderから取得したImageBitmapに変換して保持するようにします。
コード全体を書くと長すぎるので、ここから先は、変更・追加のある箇所のみ書いていきます。

public class FaceMaskManager
{
    /************************************************************************
     *
     * Private 変数
     *
     ************************************************************************/

    /**
     * Bitmap
     */
    private Bitmap _bmp = null;


    /************************************************************************
     *
     * コールバック
     *
     ************************************************************************/

    /**
     * 撮影した画像が利用可能になった時のコールバック
     */
    private ImageReader.OnImageAvailableListener _onImageAvailableListener = new ImageReader.OnImageAvailableListener()
    {
        @Override
        public void onImageAvailable(ImageReader reader)
        {
            // Imageを取得する
            Image image = reader.acquireNextImage();

            // ImageをByteArrayに変換する
            byte[] bytes = imageToByteArray(image);

            if (bytes != null)
            {
                // Bitmapを作成する
                _bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
            }

            // Imageを閉じる
            image.close();
            image = null;
        }
    };


    /************************************************************************
     *
     * Private メソッド
     *
     ************************************************************************/

    /**
     * Image -> Byte Array
     *
     * @param image Image
     * @return Byte Array
     */
    private static byte[] imageToByteArray(Image image)
    {
        Bitmap bmp = null;
        if (image.getFormat() == ImageFormat.JPEG)
        {
            Image.Plane[] planes = image.getPlanes();
            ByteBuffer buffer = planes[0].getBuffer();
            byte[] bytes = new byte[buffer.capacity()];
            buffer.get(bytes);
            return bytes;
        }
        else
        {
            Log.e(TAG, "ImageToByteArray: 対応していない画像フォーマットです。");
            return null;
        }
    }
}

OpenGL ES のテクスチャIDを取得する

カメラから取得した画像をOpenGL ES 2テクスチャに変換します。

  1. _onImageAvailableListener .onImageAvailable()_bmpnullで無くなる。
  2. 外部からUpdate()が呼ばれる。
  3. さらにupdateTexture()が呼ばれる。
  4. _textureIdのテクスチャが_bmpで更新される。

また、Start()に戻り値intを追加して、Unity側にテクスチャIDを返すようにしました。

public class FaceMaskManager
{
    /************************************************************************
     *
     * Private 変数
     *
     ************************************************************************/

    /**
     * テクスチャID
     */
    private int _textureId = -1;


    /************************************************************************
     *
     * Unityから呼び出されるメソッド
     *
     ************************************************************************/

    /**
     * 開始する
     *
     * @param width  カメラ解像度の幅
     * @param height カメラ解像度の高さ
     */
    public int Start(int width, int height)
    {
        _cameraWidth = width;
        _cameraHeight = height;

        // カメラプレビュー用のテクスチャを作成する
        _textureId = createTexture(width, height);

        // カメラを起動する
        openCamera();

        // 撮影スレッドを開始する
        startCaptureThread();

        return _textureId;
    }

    /**
     * 更新する
     */
    public void Update()
    {
        if (_bmp != null)
        {
            updateTexture(_bmp);

            // 処理済みのBitmapを解放する
            if (!_bmp.isRecycled())
            {
                _bmp.recycle();
            }
            _bmp = null;
        }
    }


    /************************************************************************
     *
     * Private メソッド
     *
     ************************************************************************/

    /**
     * テクスチャを作成する
     *
     * @param width  作成するテクスチャの幅
     * @param height 作成するテクスチャの高さ
     * @return テクスチャID
     */
    private int createTexture(int width, int height)
    {
        Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);

        int textures[] = new int[1];

        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        GLES20.glGenTextures(1, textures, 0);
        int textureId = textures[0];

        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bmp, 0);

        // 使い終わったBitmapを破棄
        if (!bmp.isRecycled())
        {
            bmp.recycle();
        }
        bmp = null;
        return textureId;
    }

    /**
     * Textureを更新する
     *
     * @param bmp Bitmap
     */
    private void updateTexture(Bitmap bmp)
    {
        if (bmp == null)
        {
            Log.d(TAG, "updateTexture: Bitmap is null");
            return;
        }

        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, _textureId);

        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bmp, 0);
    }
}

カメラプレビューの実装(Unityアプリ)

Androidプラグイン側で作成したテクスチャを表示する処理をUnity側に実装していきます。

カメラプレビュー用のシーンを作成する

  1. スクリプト(FaceMaskController)を新規作成します。
  2. シーンに空オブジェクト(以下Rootと呼びます)を1つ追加し、FaceMaskControllerをアタッチします。
  3. Rootの子オブジェクトとしてCanvasを1つ追加し、Canvas ScalerUI Scale ModeScale With Screen Sizeに変更します。
  4. Canvasの子オブジェクトとしてImageを1つ追加し、以下の画像の様に変更します。
    このImageにカメラプレビューを表示します。
    今回の実装だとカメラプレビューの上下が反転してしまうので、ScaleYを-1にして、元に戻します。

カメラプレビュー処理を実装する

FaceMaskControllerにカメラプレビュー処理を実装します。

Start()でプラグインに渡す解像度の値は端末によって調整が必要です。

public class FaceMaskController : MonoBehaviour
{
    /************************************************************************
     *
     * Serialize Field
     * 
     ************************************************************************/

    /// <summary>
    /// カメラプレビュー表示用イメージ
    /// </summary>
    [SerializeField] private Image _previewImage;


    /************************************************************************
     *
     * Private 変数
     * 
     ************************************************************************/

    /// <summary>
    /// Androidプラグインのインスタンス
    /// </summary>
    private AndroidJavaObject _plugin = null;


    /************************************************************************
     *
     * Unity ライフサイクル メソッド
     * 
     ************************************************************************/

    /// <summary>
    /// Start
    /// </summary>
    private void Start()
    {
        // Androidプラグインをインスタンス化
        _plugin = new AndroidJavaObject("com.gaprot.facemaskmodule.FaceMaskManager");

        // Androidプラグインの処理を開始し、テクスチャIDを取得する
        var textureId = _plugin.Call<int>("Start", 960, 540);

        // OpenGL EL テクスチャをカメラプレビュー用イメージに紐づける
        applyTextureId(textureId);
    }

    /// <summary>
    /// Update
    /// </summary>
    private void Update()
    {
        _plugin.Call("Update");
    }

    /// <summary>
    /// OnDestroy
    /// </summary>
    private void OnDestroy()
    {
        // Androidプラグインを解放する
        _plugin?.Dispose();
        _plugin = null;
    }


    /************************************************************************
     *
     * Private メソッド
     * 
     ************************************************************************/

    /// <summary>
    /// OpenGL EL テクスチャを紐づける 
    /// </summary>
    /// <param name="id">Texture ID</param>
    private void applyTextureId(int? id)
    {
        if (id == null) return;
        var nativeTexture =
            Texture2D.CreateExternalTexture(256, 256, TextureFormat.ARGB32, false, false, (IntPtr) id);
        // 表示用のマテリアルを作成
        _previewImage.material = new Material(_previewImage.material) {mainTexture = nativeTexture};
        nativeTexture.UpdateExternalTexture(nativeTexture.GetNativeTexturePtr());
    }
}

PreviewImageにカメラプレビュー用のImageオブジェクトを設定したら、Unity側の実装完了です。

一旦ビルドしてみる

ここまででカメラプレビューの実装ができたので、一旦ビルドしたいと思います。

ビルド手順は以下の通りです。

  1. AARファイルにビルドします。
  2. AARファイルをUnityのAssets¥Plugins¥Android内にインポートします。
  3. UnityのProject Settings -> Player -> Resolution and Presentation -> Orientation -> Default OrientationLandscape Leftに変更します。
  4. UnityのProject Settings -> Player -> Other Settingsを以下のように変更します。
    Package Nameは任意に変更してOKです。
  5. Assets -> Play Services Resolver -> Android Resolver -> Force Resolveを実行し、Androidプラグインの依存関係を解決します。
  6. ビルドしてapkファイルを端末にインストールします。
  1. 画面に背面カメラの映像が映っていれば成功です。

UnityとAndroidプラグイン間でやり取りする為のデータクラスを定義する

カメラプレビューが正常に動作したので、次は顔検出を実装します。

準備の段階でも書きましたが、C#には文字列でしかデータを送れないので、顔情報をJSONにシリアライズします。そのために、JavaとC#両方に共通のデータ構造のクラスを定義します。

パラメータ名説明
FaceData顔情報クラス
┗ .CenterX顔の中心のX座標float
┗ .CenterY顔の中心のY座標float
┗ .Width顔の幅float
┗ .Height顔の高さfloat

FaceDataクラスのインスタンス1つにつき1人分のデータなので、複数人を同時に検出したい場合は、FaceDataのリストを持ったクラスをさらに定義し、そのクラスのインスタンスをJSON化して、Unity側に通知する必要があります。

顔検出の実装(Androidプラグイン)

Bitmapから顔を検出する

カメラプレビューの時に使ったBitmapだと解像度が高すぎて検出に時間がかかってしまうので、幅と高さをそれぞれ1/4にしたBitmapを作成しています。

低解像度の画像を使っても端末によっては検出に時間がかかってしまいます。
その場合は、DetectInImageを呼ぶ頻度を下げるなどの方法が有効です。

public class FaceMaskManager
{
    /************************************************************************
     *
     * Private 変数
     *
     ************************************************************************/

    /**
     * 顔検出器
     */
    private FirebaseVisionFaceDetector _detector = null;


    /************************************************************************
     *
     * コールバック
     *
     ************************************************************************/

    /**
     * 撮影した画像が利用可能になった時のコールバック
     */
    private ImageReader.OnImageAvailableListener _onImageAvailableListener = new ImageReader.OnImageAvailableListener()
    {
        @Override
        public void onImageAvailable(ImageReader reader)
        {
            // Imageを取得する
            Image image = reader.acquireNextImage();
            // ImageをByteArrayに変換する
            byte[] bytes = imageToByteArray(image);
            if (bytes != null)
            {
                _bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
                BitmapFactory.Options options = new BitmapFactory.Options();
                options.inSampleSize = 4;
                Bitmap smallBmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
                if (smallBmp != null)
                {
                    // 顔の検出を開始する
                    detectInImage(smallBmp);
                }
            }
            // Imageを閉じる
            image.close();
            image = null;
        }
    };


    /************************************************************************
     *
     * Unityから呼び出されるメソッド
     *
     ************************************************************************/

    /**
     * 開始する
     *
     * @param width  カメラ解像度の幅
     * @param height カメラ解像度の高さ
     */
    public int Start(int width, int height)
    {
        _cameraWidth = width;
        _cameraHeight = height;

        // カメラプレビュー用のテクスチャを作成する
        _textureId = createTexture(width, height);

        // カメラを起動する
        openCamera();

        // 撮影スレッドを開始する
        startCaptureThread();

        // 顔検出器を作成する
        _detector = createFaceDetector();

        return _textureId;
    }


    /************************************************************************
     *
     * Private メソッド
     *
     ************************************************************************/

    /**
     * 顔検出器を作成する
     *
     * @return 検出器
     */
    private FirebaseVisionFaceDetector createFaceDetector()
    {
        // Firebaseの初期化処理
        FirebaseApp.initializeApp(UnityPlayer.currentActivity);

        // 検出器を作成
        FirebaseVisionFaceDetector detector = FirebaseVision.getInstance().getVisionFaceDetector();

        return detector;
    }

    /**
     * 画像から顔を検出する
     *
     * @param bmp 検出用画像
     */
    private void detectInImage(Bitmap bmp)
    {
        if (bmp == null) return;

        // Firebase用のデータを作成する
        final FirebaseVisionImage fvi = FirebaseVisionImage.fromBitmap(bmp);
        // 検出処理を開始する
        _detector.detectInImage(fvi).addOnCompleteListener(
                _detectExecutor,
                new OnCompleteListener<List<FirebaseVisionFace>>()
                {
                    @Override
                    public void onComplete(@NonNull Task<List<FirebaseVisionFace>> task)
                    {
                        Bitmap bmp = fvi.getBitmap();
                        try
                        {
                            if (task.isSuccessful())
                            {
                                // 検出結果を取得
                                List<FirebaseVisionFace> faces = task.getResult();

                                // TODO: 検出結果を使う
                            }
                        }
                        finally
                        {
                            if (!bmp.isRecycled())
                            {
                                bmp.recycle();
                            }
                            bmp = null;
                        }
                    }
                });
    }
}

検出した顔の情報をUnityに通知する

検出結果をUnityに通知します。

Unity側で扱いやすいように、顔の中心の座標と幅・高さは0~1で正規化した値に変換しています。

また、FaceMaskManagerクラスのコンストラクタで、UnitySendMessageの第1引数で使用するGameObjectの名前を受け取っておきます。 

public class FaceMaskManager
{
    /************************************************************************
     *
     * Private クラス
     *
     ************************************************************************/

    /**
     * 顔情報クラス
     */
    private class FaceData
    {
        /**
         * 顔の中心のX座標(元画像の幅に対して0〜1)
         */
        public float CenterX;

        /**
         * 顔の中心のY座標(元画像の高さに対して0〜1)
         */
        public float CenterY;

        /**
         * 顔の幅(元画像の幅に対して0〜1)
         */
        public float Width;

        /**
         * 顔の高さ(元画像の高さに対して0〜1)
         */
        public float Height;

        /**
         * コンストラクタ
         *
         * @param face                顔情報
         * @param originalImageWidth  元画像の幅
         * @param originalImageHeight 元画像の高さ
         */
        public FaceData(FirebaseVisionFace face, int originalImageWidth, int originalImageHeight)
        {
            try
            {
                Rect rect = face.getBoundingBox();

                Width = (float) rect.width() / originalImageWidth;
                Height = (float) rect.height() / originalImageHeight;
            }
            catch (Exception e)
            {
                Log.e(TAG, "FaceData: " + e.getMessage());
            }
        }
    }

    /**
     * 検出結果コールバック用データクラス
     */
    private class DetectCallbackData
    {
        /**
         * 顔情報の一覧
         */
        public List<FaceData> FaceDataList;

        /**
         * コンストラクタ
         */
        public DetectCallbackData()
        {
            FaceDataList = new ArrayList<>();
        }

        /**
         * 顔情報を追加する
         *
         * @param data
         */
        public void Add(FaceData data)
        {
            FaceDataList.add(data);
        }
    }


/************************************************************************
 *
 * Private 変数
 *
 ************************************************************************/

    /**
     * Unity側のGameObject名
     */
    private String _unityName = null;


/************************************************************************
 *
 * コンストラクタ
 *
 ************************************************************************/

    /**
     * コンストラクタ
     *
     * @param unityName Unity側のGameObject名
     */
    public FaceMaskManager(String unityName)
    {
        _unityName = unityName;
    }


/************************************************************************
 *
 * Private メソッド
 *
 ************************************************************************/

    /**
     * 画像から顔を検出する
     *
     * @param bmp 検出用画像
     */
    private void detectInImage(Bitmap bmp)
    {
        if (bmp == null) return;

        // Firebase用のデータを作成する
        final FirebaseVisionImage fvi = FirebaseVisionImage.fromBitmap(bmp);

        // 検出処理を開始する
        _detector.detectInImage(fvi).addOnCompleteListener(
                _detectExecutor,
                new OnCompleteListener<List<FirebaseVisionFace>>()
                {
                    @Override
                    public void onComplete(@NonNull Task<List<FirebaseVisionFace>> task)
                    {
                        Bitmap bmp = fvi.getBitmap();
                        try
                        {
                            if (task.isSuccessful())
                            {
                                List<FirebaseVisionFace> faces = task.getResult();

                                // 検出結果を1人ずつ取り出して、リストに格納する
                                DetectCallbackData callbackData = new DetectCallbackData();
                                for (FirebaseVisionFace face : faces)
                                {
                                    callbackData.Add(new FaceData(face, bmp.getWidth(), bmp.getHeight()));
                                }

                                // JSON化する
                                Gson gson = new Gson();
                                String json = gson.toJson(callbackData);

                                // Unity側に送信
                                UnityPlayer.UnitySendMessage(_unityName, "OnDetectComplete", json);
                            }
                        }
                        finally
                        {
                            if (!bmp.isRecycled())
                            {
                                bmp.recycle();
                            }
                            bmp = null;
                        }
                    }
                });
    }
}

これで、Androidプラグインが完成しました。
あとは、Androidプラグインから送られてくる顔情報を使う処理をUnity側に実装したらアプリの完成です。

顔検出の実装(Unityアプリ)

あと残るは、送られてきたデータの座標と大きさでマスク画像を表示するだけです。

マスク画像表示処理を実装する

Androidプラグインから受け取った位置と大きさでマスク画像を表示します。
マスク画像のオブジェクトを毎フレーム破棄して再度生成すると負荷が高かったので、アクティブ/非アクティブを切り替える方式にしました。
本来は、しばらく使われていないオブジェクトは破棄されるように実装するべきですが、今回は省略しました。

public class FaceMaskController : MonoBehaviour
{ 
    /************************************************************************
     *
     * Private クラス
     *
     ************************************************************************/

    /// <summary>
    /// 顔情報クラス
    /// </summary>
    [Serializable]
    private class FaceData
    {
        /// <summary>
        /// 顔の中心のX座標(元画像の幅に対して0〜1)
        /// </summary>
        public float CenterX;

        /// <summary>
        /// 顔の中心のY座標(元画像の高さに対して0〜1)
        /// </summary>
        public float CenterY;

        /// <summary>
        /// 顔の幅(元画像の幅に対して0〜1)
        /// </summary>
        public float Width;

        /// <summary>
        /// 顔の高さ(元画像の高さに対して0〜1)
        /// </summary>
        public float Height;
    }

    /// <summary>
    /// 検出結果コールバック用データクラス
    /// </summary>
    [Serializable]
    private class DetectCallbackData
    {
        /// <summary>
        /// 顔情報の一覧
        /// </summary>
        public List<FaceData> FaceDataList;
    }


    /************************************************************************
     *
     * Serialize Field
     * 
     ************************************************************************/

    /// <summary>
    /// マスク画像プレハブ
    /// </summary>
    [SerializeField] private Image _maskImagePref;


    /************************************************************************
     *
     * Private 変数
     * 
     ************************************************************************/

    /// <summary>
    /// マスク画像一覧
    /// </summary>
    private List<Image> _maskImages = null;

    /// <summary>
    /// カメラプレビュー用イメージのRectTransform
    /// </summary>
    private RectTransform _previewImageRt = null;


    /************************************************************************
     *
     * Unity ライフサイクル メソッド
     * 
     ************************************************************************/

    /// <summary>
    /// Start
    /// </summary>
    private void Start()
    {
        // Androidプラグインをインスタンス化(このスクリプトがアタッチされているGameObject名を渡す)
        _plugin = new AndroidJavaObject("com.gaprot.facemaskmodule.FaceMaskManager", name);

        // Androidプラグインの処理を開始し、テクスチャIDを取得する
        var textureId = _plugin.Call<int>("Start", 800, 400);

        // テクスチャIDをカメラプレビュー用イメージに適用する
        applyTextureId(textureId);

        _maskImages = new List<Image>();
        _previewImageRt = _previewImage.GetComponent<RectTransform>();
    }


    /************************************************************************
     *
     * コールバック メソッド
     * 
     ************************************************************************/

    /// <summary>
    /// 検出完了コールバック
    /// </summary>
    /// <param name="json">JSON(DetectCallbackData)</param>
    public void OnDetectComplete(string json)
    {
        _maskImages.ForEach(mask => mask.gameObject.SetActive(false));

        var callbask = JsonUtility.FromJson<DetectCallbackData>(json);
        for (var i = 0; i < callbask.FaceDataList.Count; i++)
        {
            createMask(callbask.FaceDataList[i], i);
        }
    }


    /************************************************************************
     *
     * Private メソッド
     * 
     ************************************************************************/

    /// <summary>
    /// マスク画像を作成する
    /// </summary>
    /// <param name="faceData">顔情報</param>
    /// <param name="index">インデックス</param>
    private void createMask(FaceData faceData, int index)
    {
        Image mask = null;
        if (_maskImages.Count > index)
        {
            mask = _maskImages[index];
            mask.gameObject.SetActive(true);
        }
        else
        {
            mask = Instantiate(_maskImagePref, _previewImage.transform);
            mask.transform.SetAsLastSibling();
            _maskImages.Add(mask);
        }

        var imageRect = _previewImageRt.rect;
        var maskRt = mask.GetComponent<RectTransform>();
        maskRt.anchoredPosition = new Vector2(
            faceData.CenterX * imageRect.width,
            faceData.CenterY * imageRect.height);
        maskRt.sizeDelta = new Vector2(
            faceData.Width * imageRect.width,
            faceData.Height * imageRect.height);
    }
}

顔マスク用のプレハブを作成する

顔を隠すための画像(Imageコンポーネント)が付加されたプレハブを作ります。
MLKitの原点が左上のためカメラプレビューイメージの上下を反転させているので、マスク画像のアンカーを左下に設定します。
このプレハブを_maskPrefが参照するようにします。

完成

完成しました。
実際に動かしてみるとこんな感じになります。

素早く動くと検出処理が追いつかず顔が出てしまいました。

おわりに

今回は、機能を絞ってリアルタイムで検出することに拘ったアプリを作りました。
重要なポイントは、

  • 低解像度の画像で検出を行う
  • 使い終わったBitmapをしっかりリサイクルする

の2点だと思います。

特に、使い終わったBitmapをしっかりリサイクルしないと、どんどん端末温度が上がってFPSが落ちてしまいます。