はじめに
この記事はAndroidのデバイスを保守カメラとして実験的に運用する記事です。
カメラのついたAndoridデバイスを用いて、遠隔でバックグラウンドで写真を撮ってクラウドにアップロードする方法について解説をします。
背景
現在私は、株式会社東京でインターンをしており、Androidを搭載したデバイス上で放映するデジタルサイネージの作成をしています。
その中で、正しく広告が投影しているか(光源が切れていないか、正しい場所に放映されているか)を遠隔で取得することを検討しており、実験的に実装を行いました。
制約
デジタルサイネージのデバイスに搭載された外部カメラを動作させるには以下の様な制約があります。
- 外部のカメラをAndroid上で認識する
- Viewを出さずにカメラを撮影する
- 広告の放映をストップさせずに写真を撮影する
- 撮影した写真を適切なサイズや形式に変換する
今回は解説しませんが、遠隔で写真撮影を行うコマンドを受け取るという制約もあります。
それぞれの制約の解決策を解説していきます。
使用した技術
主にCamera XとCamera2のライブラリを用いることでカメラの撮影を行いました。
CameraXの概要
CameraXでは以下の手段に従って写真撮影を行い、クラウド上にアップロードすることができます。
各手段に沿ってポイントや方法を解説していきます。
- 権限の取得、カメラの取得
- ImageAnalysisで撮影する画像の設定
- ImageCaptureでカメラの制御の設定を行う
- カメラをライフサイクルに結びつける
- カメラの制御を行う
- Analyzerをsetして画像を撮影する
- 撮影した写真を適切な形式に変換してアップロードする
- メモリを解放する
1. 権限の取得、カメラの取得
まず、カメラを使用するための権限を取得します。
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
続いて、カメラを取得します。
今回は外部のカメラを用いるという制約があるため、特殊な方法を用いてカメラを取得します。
外部のカメラをAndroid上で認識する
CameraXは、市場に出ているほとんどのスマートフォンやタブレットのカメラに対応しており、従来のライブラリと比較して簡単にカメラのコードを書くことができます。
カメラを取得する際には、
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
のように背面か前面かのカメラを設定することでカメラを取得できるのですが、今回は外部のカメラを接続しているため、背面や前面といった情報がなく取得することができません。
Camera2のライブラリを用いる
そこでCamera2のライブラリを使ってカメラを取得します。
AndroidOSで最初に認識したカメラがCamera2のライブラリから取得した利用可能なカメラと一致するかを調べて、使用するカメラとしました。
@Provides
@Singleton
fun provideCamera(@ApplicationContext context: Context): CameraSelector {
val manager: CameraManager =
context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraId = manager.cameraIdList[0]
return CameraSelector.Builder()
.addCameraFilter { cameras ->
val result = cameras.filter {
cameraId == Camera2CameraInfo.from(it).cameraId
}
result
}
.build()
こちらのissueで議論されていた内容です
カメラの取得には時間がかかる
また、カメラの取得には時間がかかるため、Module内にSingletonでカメラの情報を持たせて再利用することで、取得時間を削減しています。
CameraXのドキュメントでも以下のように明言されています、
CameraX はハードウェア コンポーネントと通信する必要があるため、特にローエンドのデバイスでは、カメラごとに行われるこのプロセスにかなりの時間がかかることがあります。
カメラの取得に失敗する可能性
さらに、10回に1回ほどデバイスのカメラを認識できない場合があるため、WorkManagerのような定期実行を行う仕組みを使って、カメラの取得が失敗した場合にはカメラの再取得を行うことで、より正確にカメラの取得を行うことができます。
2. ImageAnalysisで撮影する画像の設定
続いてImageAnalysisを設定します。
今回は、Viewを出さずにカメラを撮影する必要があるため、追加で設定を行なっていきます。
Viewを出さずにカメラを撮影する
Viewなしのカメラを起動するには以下の様に、ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
を設定する必要があります。
@Provides
@Singleton
fun provideImageAnalysis(@ApplicationContext context: Context): ImageAnalysis {
return if (cameraSize(context) != null) {
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setTargetResolution(cameraSize(context)!!)
.build()
} else {
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
}
}
STRATEGY_KEEP_ONLY_LATEST
はメモリ内に常に1つしか写真を格納することができない代わりに、メインスレッドをブロックせずに写真撮影を行うことができるため、Viewと結びつけることなく(Mainスレッドでなくても)写真撮影をすることができます。
このモードでは、エグゼキュータは常に最新の画像を画像バッファにキャッシュし(深度 1 のキューと同様)、その間にアプリは前の画像を分析します。アプリが処理を完了する前に CameraX が新しい画像を受信すると、新しい画像が同じバッファに保存され、前の画像が上書きされます。
カメラの解像度を自動選択する
setTargetResolution
にSizeを渡すことで、写真の解像度を選択することができます。
CameraXには適切な解像度を選択する仕組みが備わっているのですが、画像解析目的ではデフォルトで[640×480]の解像度に設定されるため、より高画質な写真を撮影する際は適切なサイズを渡してやる必要があります。
設定可能なカメラの解像度は次のように取得することができるため、設定可能な解像度のうち適切なものを取り出してあげるアルゴリズムを記述することで、どのカメラであっても適切な解像度を選択できる様になります。
val cameraId = cameraManager.cameraIdList[0]
val sizeList = cameraManager.getCameraCharacteristics(cameraId)
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
?.getOutputSizes(ImageFormat.JPEG)
3. ImageCaptureでカメラの設定を行う
続いて、カメラの設定を行います。画質優先、遅延優先があり、今回は画質優先を選択しました。
@Provides
@Singleton
fun provideImageCapture(): ImageCapture {
return ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
}
4. カメラをライフサイクルに結びつける
カメラをライフサイクルに結びつけて適切にカメラのリソースを解放できる様にします。
この処理はメインスレッドで行う必要があるため、以下の様にメインスレッドを指定しましょう。
cameraProviderFuture.addListener({
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// ライフサイクルと結びつける onCreate後に行う必要がある
cameraProvider.unbindAll()
val camera = cameraProvider.bindToLifecycle(
this,
cameraSelector,
imageCapture,
imageAnalysis
)
...
}, ContextCompat.getMainExecutor(this.baseContext))
5. カメラの制御を行う
先ほどライフサイクルに結びつけたcameraを用いてフォーカスや露光の設定を行います。
val camera = cameraProvider.bindToLifecycle( ...
今回は自動フォーカスを行う様にしました。
フォーカスの設定
まず、フォーカスの設定を行います。
-
SurfaceOrientedMeteringPointFactory
でフォーカスを行う長方形の範囲を指定 -
createPoint(x, y)
を0から1の範囲で変更してフォーカスを行う場所を変更 -
setAutoCancelDuration(time)
でフォーカス時間の設定を行います。
private fun provideFocusMeteringAction(): FocusMeteringAction {
val meteringPointFactory =
SurfaceOrientedMeteringPointFactory(
MAX_IMAGE_QUALITY.width.toFloat(),
MAX_IMAGE_QUALITY.height.toFloat(),
imageAnalysis,
)
val meteringPoint =
meteringPointFactory.createPoint(CAMERA_FOCUS_X_COORDINATE, CAMERA_FOCUS_Y_COORDINATE)
return FocusMeteringAction.Builder(meteringPoint)
.setAutoCancelDuration(CAMERA_FOCUS_TIME, TimeUnit.SECONDS)
.build()
}
フォーカスを実行
上で設定したフォーカスの設定をライフサイクルに結びつけたcameraと結びつけてやると、フォーカスが実行されます。
camera.cameraControl.startFocusAndMetering(provideFocusMeteringAction())
6. Analyzerをsetして写真を撮影する
最後に写真の撮影を行います。CameraXでは、callback形式で写真撮影を呼び出すことができます。
cancelableSuspendCorotuine
このようなコールバック形式の関数は、cancelableSuspendCorotuine
でラップしてやることで、flowに組み込むことができ、キャンセル可能な関数となります。
また、resumeWithException
を用いることでコールバック内で生じたエラーを外部に伝播させることが可能になります。
private suspend fun setAnalyzer(executor: Executor): ImageProxy {
return suspendCancellableCoroutine { continuation ->
try {
if (continuation.isActive) {
imageAnalysis.setAnalyzer(executor) { image ->
continuation.resume(image)
}
}
} catch (exception: Exception) {
// エラー時にエラーをthrowする
if (continuation.isActive) {
continuation.resumeWithException(exception)
}
}
}
}
タイムアウト処理
また、撮影が失敗する場合があるため、timeout処理を付け加えてやることでより正確性の高い処理を行うことができます。(ただし、メモリの開放がうまくいかず連続撮影に失敗する場合があるため、実装には注意が必要です。)
override suspend fun takePhoto(): Result<ImageProxy> =
try {
// 写真撮影ができないとタイムアウト
val resultImage = withTimeout(CAMERA_TIMEOUT_MILLI_SEC) { setAnalyzer(cameraExecutor) }
Result.success(resultImage)
} catch (exception: Exception) {
Result.failure(exception)
}
広告の放映をストップさせずに写真を撮影する
また、今回は広告の放映をストップさせずに写真を撮影する制約があるため、バックグラウンドのスレッドを指定する必要があります。
上に示したsetAnalyzer
に渡しているexecutor
に別スレッドを渡してやることで、バックグラウンドで写真を撮影することができます。
imageAnalysis.setAnalyzer(executor)
今回は、cameraExecutorというスレッドを立ち上げてexecutor
に指定してやることで、ライフサイクルと結びつける時以外は完全にバックグラウンドで処理を行うことができました。
val cameraExecutor = Executors.newSingleThreadExecutor()
スレッドの作成は1回にする
また、スレッドを作成するとメモリやCPUのリソースを消費するため、ModuleやApplicationなどで1度のみ立ち上げましょう。
今回はアプリの起動時に一度だけSingltonなModuleによってCamaraExecutorが作られる様にしました。
さらに、今後複数のスレッドの型をInjcetできるようにするため、型に名前をつけることで同一の型で複数のInjectionができる様にしました。
@Provides
@Executor(ToukyouExecutor.Camera)
fun providesCameraExecutor(): ExecutorService = Executors.newSingleThreadExecutor()
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Executor(val toukyouExecutor: ToukyouExecutor)
enum class ToukyouExecutor {
Camera
}
以下の様にInjectすることでExecutorを使うことができます。
@Inject constructor(
...
@com.ad.toukyou.util.thread.Executor(ToukyouExecutor.Camera) private val cameraExecutor: ExecutorService,
...
7. 撮影した写真を適切な形式に変換してアップロードする
今回はFirebaseStorage上にアップロードするため、写真撮影時に受け取ったImageProxyの型を、対応しているJPEGの型に直す必要があります。
写真をBitmapに変換する
ドメインオブジェクトに以下の様なBitmapに変換を行う拡張関数を作成しました。
// ImageProxyをBitmapに変換する拡張関数
fun ImageProxy.toJPEG(): ByteArray {
val yBuffer = planes[0].buffer // Y
val vuBuffer = planes[2].buffer // VU
val ySize = yBuffer.remaining()
val vuSize = vuBuffer.remaining()
val nv21 = ByteArray(ySize + vuSize)
yBuffer.get(nv21, 0, ySize)
vuBuffer.get(nv21, ySize, vuSize)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, this.width, this.height, null)
val out = ByteArrayOutputStream()
yuvImage.compressToJpeg(Rect(0, 0, yuvImage.width, yuvImage.height), 50, out)
val imageBytes = out.toByteArray()
val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
return outputStream.toByteArray()
}
こちらのコードを参考にしています。
写真に命名してアップロードする
また、撮影した時間を画像ファイルの名前とし、FirebaseStorage上で/uid/(撮影した時間).jpg
の場所に格納することで、サイネージで撮影された写真をFirebase上で確認することができます。
override suspend fun uploadImage(image: ByteArray): Result<Unit> =
withContext(ioDispatcher) {
try {
val uid = FirestoreService.uid
// 画像ファイルの名前を日時に設定する
val name = SimpleDateFormat(
"yyyyMMddHHmmss",
Locale.JAPAN
).format(System.currentTimeMillis())
val storageImageRef = storageRef.child("snapshot/$uid/$name.jpg")
// 送信
storageImageRef.putBytes(image)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
このような手順を踏むことで、保守カメラとして正しく広告が投影しているかを遠隔で取得することができました。
8. メモリを解放する
CameraXでは、ImageProxy
をラップしているMedia.Image
の型を使用して画像を処理するため、ImageProxy.close()
を行なってメモリーをリリースしてあげましょう。
今回ImageProxy
からBitmapやJPEGに変換を行いましたが、ImageProxy
の型に対してメモリーの解放を行なってやらないと、正しくメモリーが解放されず、写真を撮影することができなくなります。
最後に
株式会社東京さんのインターンを通してこのタスクに取り組まさせていただきました。
ハード×Androidの面白い技術領域をやられており、裁量を持ってタスクを行わせていただきました。
非常に楽しかったです。ありがとうございました。
最後まで読んでいただきありがとうございました。よければ私のtwitterのフォローよろしくお願いします。
⚡️ "たかっしーの開発日記"https://t.co/aGE0WGERzU
— たかっしー(開発垢)@東北放浪中 (@takashiho_2) November 15, 2022
参考文献