Jetpack Compose + CameraX + yolo v8 で物体検知アプリを作る ~カメラ実装まで~

## 環境
Jetpack Compose 
CameraX 1.4.0
Android SDK 35

## 概要
Androidで物体検知アプリを作成します。
完成するアプリのイメージです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/cda0f9ff-6f37-825b-79bc-584ce6ac5e60.png" width=30%>

モデルはyolo v8を使用します。
事前準備編としてまずはJetpack Compose上でCameraXを実装します。

## 手順
プロジェクト新規作成→Empty Activityを選択しました。
(Empty Activityを選択するとJetpack Composeでプロジェクトが作成されます。)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/2f868242-cbd0-35c5-6468-612c5579deaf.png)

プロジェクト名をobject_detection_sampleとしました。
Build configuration languageはデフォルトのKotlin DSLを選択しました。
![ss.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/ebd64cb1-ceaf-7a7a-4c87-76208cbba015.png)


とりあえず実行してみたところエラーが発生します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/e99e3ab2-9cd5-1211-e804-1399f0ed78fc.png)

エラーの指示どおりにSDKバージョンを35に変更します。
```build.gradle.kts
android {
    namespace = "com.android.example.object_detection_sample"
//    compileSdk = 34
    compileSdk = 35

    defaultConfig {
        applicationId = "com.android.example.object_detection_sample"
        minSdk = 30
//        targetSdk = 34
        targetSdk = 35

```

起動確認
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/3cd26ef0-4fc2-94d4-ee5c-5156c3fd007f.png" width=30%>

次にカメラを利用するためパッケージと権限設定を追加します。
```build.gradle.kts
dependencies {
    // 追加
    // camerax
    val camerax_version = "1.4.0"
    implementation("androidx.camera:camera-camera2:$camerax_version")
    implementation("androidx.camera:camera-lifecycle:$camerax_version")
    implementation("androidx.camera:camera-view:$camerax_version")
    implementation("androidx.camera:camera-video:$camerax_version")
    implementation("com.google.accompanist:accompanist-permissions:0.30.1")
    // 以下略
```

```AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!--追加-->
    <uses-feature
        android:name="android.hardware.camera"
        android:required="false" />
    <uses-permission android:name="android.permission.CAMERA" />
    
    <!--以下略-->

</manifest>

```

MainActivityから既存のGreeting関連のコードを削除し、カメラの権限確認画面を追加します。
※importは必要に応じて追加してください。
```MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize()
                ){
                    CameraPreviewScreen()
                }
            }
        }
    }

    @OptIn(ExperimentalPermissionsApi::class)
    @Composable
    fun CameraPreviewScreen() {
        val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
        if (cameraPermissionState.status.isGranted) {
            // プレビュー画面を表示
        } else {
            PermissionRequest(cameraPermissionState)
        }
    }

    @OptIn(ExperimentalPermissionsApi::class)
    @Composable
    fun PermissionRequest(cameraPermissionState: PermissionState) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Button(onClick = {
                cameraPermissionState.launchPermissionRequest()
            }) {
                Text("Grant Camera Permission")
            }
        }
    }
}

```

起動して権限取得の動作を確認します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/9ceb9408-0dc7-62ef-3328-068f658f29de.png" width=30%> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/2ffd5aa9-d2c8-808e-14c5-44fa5f51f63b.png" width=30%>

カメラのプレビュー画面のコードを追加します。
CameraXはJetpack Composeに対応していないため実装の際はAndroidViewを使います。
```MainActivity.kt
    
    // 中略
    
    @OptIn(ExperimentalPermissionsApi::class)
    @Composable
    fun CameraPreviewScreen() {
        val cameraPermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
        if (cameraPermissionState.status.isGranted) {
            // プレビュー画面を表示
            CameraPreview(modifier = Modifier.aspectRatio(3f/4f)) // 追加
        } else {
            PermissionRequest(cameraPermissionState)
        }
    }
    
    // 中略

    // 追加
    @Composable
    fun CameraPreview(modifier: Modifier = Modifier) {
        val lifecycleOwner = LocalLifecycleOwner.current
        val previewView = remember { PreviewView(this) }

        val rotation = LocalConfiguration.current.orientation

        val cameraSelector = CameraSelector.Builder()
            .requireLensFacing(CameraSelector.LENS_FACING_BACK)
            .build()

        val preview = androidx.camera.core.Preview.Builder()
            .setTargetRotation(rotation)
            .setResolutionSelector(
                ResolutionSelector.Builder()
                    .setAspectRatioStrategy(
                        AspectRatioStrategy(
                            AspectRatio.RATIO_4_3,
                            AspectRatioStrategy.FALLBACK_RULE_AUTO
                        )
                    )
                    .build()
            )
            .build()
            .also {
                it.surfaceProvider = previewView.surfaceProvider
            }

        LaunchedEffect(Unit) {
            val cameraProvider = getCameraProvider()
            cameraProvider.unbindAll()
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                preview,
            )
        }

        AndroidView({ previewView }, modifier = modifier)
    }

    private suspend fun Context.getCameraProvider(): ProcessCameraProvider =
        suspendCoroutine { continuation ->
            ProcessCameraProvider.getInstance(this).also { cameraProvider ->
                cameraProvider.addListener({
                    continuation.resume(cameraProvider.get())
                }, ContextCompat.getMainExecutor(this))
            }
        }

```

無事プレビュー画面が表示されました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/d3f2c93e-ce9b-0ab2-1391-5cd375283e02.png" width=30%>


最終的なMainActivity.ktのコードは以下の通りになります。
```MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize()
                ){
                    CameraPreviewScreen()
                }
            }
        }
    }

    @OptIn(ExperimentalPermissionsApi::class)
    @Composable
    fun CameraPreviewScreen() {
        val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
        if (cameraPermissionState.status.isGranted) {
            CameraPreview(modifier = Modifier.aspectRatio(3f/4f))
        } else {
            PermissionRequest(cameraPermissionState)
        }
    }

    @OptIn(ExperimentalPermissionsApi::class)
    @Composable
    fun PermissionRequest(cameraPermissionState: PermissionState) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Button(onClick = {
                cameraPermissionState.launchPermissionRequest()
            }) {
                Text("Grant Camera Permission")
            }
        }
    }

    @Composable
    fun CameraPreview(modifier: Modifier = Modifier) {
        val lifecycleOwner = LocalLifecycleOwner.current
        val previewView = remember { PreviewView(this) }

        val rotation = LocalConfiguration.current.orientation

        val cameraSelector = CameraSelector.Builder()
            .requireLensFacing(CameraSelector.LENS_FACING_BACK)
            .build()

        val preview = androidx.camera.core.Preview.Builder()
            .setTargetRotation(rotation)
            .setResolutionSelector(
                ResolutionSelector.Builder()
                    .setAspectRatioStrategy(
                        AspectRatioStrategy(
                            AspectRatio.RATIO_4_3,
                            AspectRatioStrategy.FALLBACK_RULE_AUTO
                        )
                    )
                    .build()
            )
            .build()
            .also {
                it.surfaceProvider = previewView.surfaceProvider
            }

        LaunchedEffect(Unit) {
            val cameraProvider = getCameraProvider()
            cameraProvider.unbindAll()
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                preview,
            )
        }

        AndroidView({ previewView }, modifier = modifier)
    }

    private suspend fun Context.getCameraProvider(): ProcessCameraProvider =
        suspendCoroutine { continuation ->
            ProcessCameraProvider.getInstance(this).also { cameraProvider ->
                cameraProvider.addListener({
                    continuation.resume(cameraProvider.get())
                }, ContextCompat.getMainExecutor(this))
            }
        }
}
```

お疲れさまでした。
物体検知実装編に続きます。

## 補足
実際の端末のウェブカメラを使いたい場合はエミュレーターの設定を変更しましょう。

デバイスマネージャーからエミュレーターのEditを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/8265b23d-19aa-37b5-3121-e24348b2bbf0.png)

Show Advanced Settingsを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/b292bb23-8b1d-0269-3aea-63e4052950d2.png)

CameraでWebcam0を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032663/98ae7f34-1b00-a27c-ac40-4717c0e0d2a9.png)