カメラ動画をCamera2APIでSurfaceViewに映す

# はじめに

Androidのカメラに写っている動画をレイアイとに表示するということをしたので、そのときのメモを記述する。

[camera2](https://developer.android.com/training/camera2) APIを使ってレイアウトの `<SurfaceView>` に表示させる方法について記述する。

`<TextureView>` に表示させる方法もあるが、ここでは  `<SurfaceView>` についてのみ記述する。

ちなみに、Camera2 は低レベルAPIなので、高レベルAPIの [cameraX](https://developer.android.com/training/camerax?hl=ja)  というのもある。

# 処理の概要説明

ActivityでCamera2を使ってSurfaceViewに動画を流し込むので、Activity側とSurfaceView（レイアウト）側でそれぞれ処理を記載する必要がある。

## Activity側

下記の処理の流れになる。

1. CameraManager で使うカメラを指定（フロントカメラやバックカメラなどがあるので）
1. カメラをopenする
1. カメラとセッションを繋ぐ
1. SurfaceViewにカメラから画像を流し込む

## SurfaceView（レイアウト）側

SurfaceView側も下記の処理が必要になる。

1. SurfaceViewをカメラ出力画像のサイズ固定にするカスタムSurfaceViewを作成する


# CameraManager で使うカメラを指定

カメラを指定といっても `cameraId` を取得する処理。

`CameraManager` から `cameraId` を複数取得して、その中からフロントカメラの `cameraId` を取得する処理が下記になる（ [参考ページ](https://developer.android.com/training/camera2/camera-enumeration) ）

```kotlin
class Camera2Activity : AppCompatActivity() {
    companion object {
        // フロントカメラを指定。バックカメラの場合は LENS_FACING_BACK
        const val LENS_FACING = CameraMetadata.LENS_FACING_FRONT
    }
    
    private lateinit var cameraId: String
    private val cameraManager: CameraManager
        get() = getSystemService(Context.CAMERA_SERVICE) as CameraManager	
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 略
        
        // 使うカメラのcameraIdを取得
        cameraId = getFirstCameraIdFacing(cameraManager, LENS_FACING)!!
        
        // 略
    }
    
    /**
     * 指定のCameraIDを取得する
     * @param facing カメラがある場所（前、後ろなど、CameraMetadata.LENS_FACING_XXX の値）
     */
    private fun getFirstCameraIdFacing(
        cameraManager: CameraManager, facing: Int = CameraMetadata.LENS_FACING_BACK): String? {

        // デバイスにある最低限の機能があるカメラのcameraId（複数）を取得する
        val cameraIds: List<String> = cameraManager.cameraIdList.filter {
            // 各カメラの capability（機能）を取得
            val capabilities: IntArray? = cameraManager
                .getCameraCharacteristics(it)
                .get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)

            // 最低限の機能があるカメラのみを抜き出す
            capabilities?.contains(
                CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) ?: false
        }

        // 指定したカメラがある場所の CameraID を返す
        cameraIds.forEach {
            val characteristics = cameraManager.getCameraCharacteristics(it)
            if (characteristics.get(CameraCharacteristics.LENS_FACING) == facing) {
                // getFirstCameraIdFacing() の戻り値としてカメラcameraId
                return it
            }
        }

        // 指定したカメラがない場合は、配列の最初のカメラのCameraIDを返す（配列がない場合はnull）
        return cameraIds.firstOrNull()
    }
}
```

# カメラをopenする

まずは `AndroidManifest.xml` にカメラのパーミッションを追記する

```xml
    <uses-permission android:name="android.permission.CAMERA" />
```

Activity側では基本的に `cameraManager.openCamera(cameraId, deviceStateCallback, null)` で `cameraId` とコールバック`CameraDevice.StateCallback`（ `deviceStateCallback`  ） を記述するだけでopenできる。`deviceStateCallback` については後述。

パーミッション許可のダイアログなどを含めると下記のコードになる。

```kotlin
    companion object {
        const val NEED_PERMISSION: String = Manifest.permission.CAMERA
    }
    
    /**
     * パーミッション許可ダイアログでボタンを押した後の処理
     */
    private val requestPermissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
            if (isGranted) {
                onPermissionGranted()
            }
        }
        
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 略
        
        // パーミッションをチェックして許可OKの場合はカメラを起動
        when {
            // 許可OKの場合
            ContextCompat.checkSelfPermission(this, NEED_PERMISSION )
                    == PackageManager.PERMISSION_GRANTED -> {
                onPermissionGranted()
            }

            // 許可NGで アプリが権限を必要とする理由を説明する 場合
            shouldShowRequestPermissionRationale(NEED_PERMISSION) -> {
                showRationaleDialog { _, _ -> requestPermissionLauncher.launch(NEED_PERMISSION) }
            }

            // 許可NGの場合
            else -> {
                requestPermissionLauncher.launch(NEED_PERMISSION)
            }
        }
    }
        
    /**
     * アプリが権限を必要とする理由を説明するダイアログを表示する
     * @param okListener ポジティブボタンを押したあとの処理
     */
    private fun showRationaleDialog(okListener: DialogInterface.OnClickListener?) {
        val builder = AlertDialog.Builder(this)
        builder.setMessage(R.string.camera2_rationale_dialog_message)
            .setPositiveButton(R.string.camera2_rationale_dialog_ok_button, okListener)
            .create()
            .show()
    }

    @SuppressLint("MissingPermission")
    private fun onPermissionGranted() {
        // カメラを起動する
        cameraManager.openCamera(cameraId, deviceStateCallback, null)
    }
```

# カメラとセッションを繋ぐ

基本的にはカメラをopenしたときに指定した `deviceStateCallback` の `onOpened()`内で `cameraDevice.createCaptureSession()` を実行する 。

引数にはカメラ画像の出力先の `List<Surface>` 、出力元カメラの `CameraDevice` 、コールバックの `CameraCaptureSession.StateCallback` （ `captureSessionStatusCallback`） などを指定する。`captureSessionStatusCallback` については後述。

具体的には下記のコードになる。

```kotlin
    /**
     * カメラをopenしたりcloseしたときに呼ばれるコールバック
     */
    private val deviceStateCallback = object : CameraDevice.StateCallback() {
        override fun onOpened(cameraDevice: CameraDevice) {
            // カメラ画像の出力先を指定。camera2SurfaceViewは後述。
            val surfaces: List<Surface> = listOf(camera2SurfaceView.holder.surface)

            // カメラとのセッションを接続する
            createCaptureSession(cameraDevice, surfaces)
        }

        /**
         * カメラとのセッションを接続する
         * @param cameraDevice 対象のカメラのcameraDevice
         * @param surfaces 対象のsurface（複数）
         */
        private fun createCaptureSession(cameraDevice: CameraDevice, surfaces: List<Surface>) {
            // APIレベルによって分岐
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                val configurations: List<OutputConfiguration> = surfaces.map { OutputConfiguration(it) }
                cameraDevice.createCaptureSession(
                    SessionConfiguration(
                        SessionConfiguration.SESSION_REGULAR,
                        configurations,
                        this@Camera2Activity.mainExecutor,
                        captureSessionStatusCallback
                    )
                )
            } else {
                @Suppress("DEPRECATION")
                cameraDevice.createCaptureSession(surfaces, captureSessionStatusCallback, null)
            }
        }

        override fun onDisconnected(cameraDevice: CameraDevice) {
            // セッションが切断されたときはカメラをcloseする処理を記述しないといけない
            cameraDevice.close()
        }

        override fun onError(cameraDevice: CameraDevice, error: Int) {
            cameraDevice.close()
        }
    }
```

# SurfaceViewにカメラから画像を流し込む

カメラとセッションを繋いだときの  `captureSessionStatusCallback` の `onConfigured()` 内で `cameraCaptureSession.setRepeatingRequest()` を実行して動画を流す。

ここでは、動画を流す先のsurfaceを `CaptureRequest.Builder` で指定する。

```kotlin
    /**
     * カメラとのセッションに関するコールバック
     */
    private val captureSessionStatusCallback = object: CameraCaptureSession.StateCallback() {
        /**
         * カメラとのセッションが確立が成功したときに呼ばれる
         */
        override fun onConfigured(cameraCaptureSession: CameraCaptureSession) {
            // カメラの設定をする
            val captureRequestBuilder: CaptureRequest.Builder = cameraCaptureSession
                .device
                // 引数はカメラのモード。例えばTEMPLATE_MANUALだとフォーカスなど手動設定になる。今回はプレビュー用のモードに設定。
                .createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)

            // レイアウトの SurfaceView を対象に設定
            captureRequestBuilder.addTarget(camera2SurfaceView.holder.surface)

            // 設定した対象（surfaceView） にカメラから動画を流す
            cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null)
        }

        override fun onConfigureFailed(session: CameraCaptureSession) = Unit
    }
```


# カスタムSurfaceViewを作成する

そのまま `SurfaceView` に出力するとアスペクト比がおかしい動画が流れてしまうので、  `SurfaceView` を継承した `Camera2SurfaceView` というのを作成する。

## レイアウト

レイアウトは今回は画面全面に表示するレイアウトにした。

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Camera2Activity">

    <!-- applicationId は自分のアプリケーションIDの値に置き換える -->
    <applicationId.Camera2PreviewSurfaceView
        android:id="@+id/camera2SurfaceView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
```

## Camera2SurfaceView

カスタムSurfaceViewは `onMeasure()` でアスペクト比を調整する。

また、`onAttachedToWindow()` のタイミングでサイズを固定する。

`initSurfaceView()` を作成して、Activityから画像サイズをSurafaceViewに伝えるようにした。

この辺は[Google公式](https://developer.android.com/training/camera2/camera-preview) を参考にした

```kotlin
class Camera2PreviewSurfaceView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : SurfaceView(context, attrs) {

    private lateinit var cameraCharacteristics: CameraCharacteristics
    /** カメラ画像サイズ、幅 */
    private var previewWidth: Int = 0
    /** カメラ画像サイズ、高さ */
    private var previewHeight: Int = 0

    companion object {
        /**
         * 画面回転角度（自動回転ONにして端末を横にしたら回転するやつ）を取得する
         * @return 0, 90, 180, 270
         */
        fun calcSurfaceRotationDegrees(context: Context): Int {
            val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager

            return when (windowManager.defaultDisplay.rotation) {
                Surface.ROTATION_0 -> 0
                Surface.ROTATION_90 -> 90
                Surface.ROTATION_180 -> 180
                Surface.ROTATION_270 -> 270
                else -> throw java.lang.IllegalStateException("rotationの値が不正です")
            }
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        
        // サイズを固定
        holder.setFixedSize(previewWidth, previewHeight)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // SurfaceViewのサイズを取得
        val width = MeasureSpec.getSize(widthMeasureSpec)
        val height = MeasureSpec.getSize(heightMeasureSpec)

        // 相対角度を計算する
        val relativeRotation = computeRelativeRotation(
            cameraCharacteristics, calcSurfaceRotationDegrees(context))

        // X軸とY軸のScaleを変更する
        if (previewWidth > 0f && previewHeight > 0f) {
            // X軸方向（width）の SurfaceView と カメラ画像サイズ の倍率を求める
            val scaleX: Float =
                if (relativeRotation % 180 == 0) {
                    width.toFloat() / previewWidth  // 相対角度が0°、180°のとき
                } else {
                    width.toFloat() / previewHeight // 相対角度が90°、270°のとき
                }

            // Y軸方向（height）の SurfaceView と カメラ画像サイズ の倍率を求める
            val scaleY: Float =
                if (relativeRotation % 180 == 0) {
                    height.toFloat() / previewHeight
                } else {
                    height.toFloat() / previewWidth
                }

            // カメラ画像サイズ を SurfaceView にフィットするための倍率を取得
            val finalScale = min(scaleX, scaleY)

            // フィットする軸は等倍、しない軸はアスペクト比を調整する
            setScaleX(1 / scaleX * finalScale)
            setScaleY(1 / scaleY * finalScale)
        }

        // SurfaceView のwidthとheightをセット（onMeasure()の最後に必要）
        setMeasuredDimension(width, height)
    }

    /**
     * カメラのセンサー角度と画面回転角度から相対角度を求める
     * @param surfaceRotationDegrees 画面回転角度（0, 90, 180, 270、自動回転ONにして端末を横にしたら回転するやつ）
     * @return 相対角度（0, 90, 180, 270）
     */
    private fun computeRelativeRotation(
        characteristics: CameraCharacteristics,
        surfaceRotationDegrees: Int
    ): Int {

        // カメラのセンサーの角度
        val sensorOrientationDegrees = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!!

        // 前カメラと後カメラで角度を反転させるための係数
        val sign = if (characteristics.get(CameraCharacteristics.LENS_FACING) ==
            CameraCharacteristics.LENS_FACING_FRONT
        ) 1 else -1

        // 相対角度を求める
        return (sensorOrientationDegrees - surfaceRotationDegrees * sign + 360) % 360
    }

    /**
     * 初期化、サイズ決定
     */
    fun initSurfaceView(size: Size, cameraCharacteristics: CameraCharacteristics) {
        previewWidth = size.width
        previewHeight = size.height
        this.cameraCharacteristics = cameraCharacteristics
    }
}
```

## Activity側

`getCameraImageSize()` で指定したカメラから画像サイズを取得して、カスタムSurfaceViewに値を伝えるコードを記述する。

```kotlin
    companion object {
        // 画像サイズが色々あるが、ひとまず固定で指定 
        const val SIZE_INDEX = 0
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 略
        
        // カメラ関連の変数を取得
        val cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraId)
        val size: Size = getCameraImageSize(SIZE_INDEX, cameraCharacteristics)

        // カスタムSurfaceViewを初期化
        camera2SurfaceView.initSurfaceView(size, cameraCharacteristics)
        
        // 略
    }
    
    
    /**
     * 複数ある画像サイズからサイズを取得する
     * @param sizeIndex サイズ配列から何番目のサイズを取得するか
     * @param cameraCharacteristics カメラのCameraCharacteristics
     */
    private fun getCameraImageSize(sizeIndex: Int, cameraCharacteristics: CameraCharacteristics): Size {
        val streamConfigurationMap: StreamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) as StreamConfigurationMap
        val sizes: Array<Size> = streamConfigurationMap.getOutputSizes(SurfaceHolder::class.java)

        return sizes[sizeIndex]
    }
```

以上で `SurafaceView` にカメラ動画が映るはず！