はじめに
この記事は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());
}
}
}
コールバックだらけで非常にわかりづらいです。
大まかな流れを説明すると、
FaceMaskManager
のインスタンスを外部から生成。- 外部から
Start()
が呼ばれる。 openCamera
で権限の確認とカメラの開始処理をする。_cameraStateCallback.onOpened()
が呼ばれるstartRepeatingCapture()
を実行。CameraCaptureSession.StateCallback.onConfigured()
が呼ばれる。_session.setRepeatingRequest()
が実行されて、繰り返し撮影を開始する。- 撮影した画像が
_onImageAvailableListener.onImageAvailable()
にコールバックされる。
です。
_onImageAvailableListener
に順次カメラ画像が渡ってくるので、これをカメラプレビューや顔検出に利用していきます。
カメラから取得した画像をBitmapに変換する
ImageReader
から取得したImage
をBitmap
に変換して保持するようにします。
コード全体を書くと長すぎるので、ここから先は、変更・追加のある箇所のみ書いていきます。
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テクスチャに変換します。
_onImageAvailableListener .onImageAvailable()
で_bmp
がnull
で無くなる。- 外部から
Update()
が呼ばれる。 - さらに
updateTexture()
が呼ばれる。 _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側に実装していきます。
カメラプレビュー用のシーンを作成する
- スクリプト(
FaceMaskController
)を新規作成します。 - シーンに空オブジェクト(以下
Root
と呼びます)を1つ追加し、FaceMaskController
をアタッチします。 Root
の子オブジェクトとしてCanvas
を1つ追加し、Canvas Scaler
のUI Scale Mode
をScale With Screen Size
に変更します。- 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側の実装完了です。
一旦ビルドしてみる
ここまででカメラプレビューの実装ができたので、一旦ビルドしたいと思います。
ビルド手順は以下の通りです。
- AARファイルにビルドします。
- AARファイルをUnityの
Assets¥Plugins¥Android
内にインポートします。 - Unityの
Project Settings
->Player
->Resolution and Presentation
->Orientation
->Default Orientation
をLandscape Left
に変更します。 - Unityの
Project Settings
->Player
->Other Settings
を以下のように変更します。Package Name
は任意に変更してOKです。 Assets
->Play Services Resolver
->Android Resolver
->Force Resolve
を実行し、Androidプラグインの依存関係を解決します。- ビルドしてapkファイルを端末にインストールします。
- 画面に背面カメラの映像が映っていれば成功です。
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が落ちてしまいます。