1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-03-01

はじめに

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

camera2 APIを使ってレイアウトの <SurfaceView> に表示させる方法について記述する。

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

ちなみに、Camera2 は低レベルAPIなので、高レベルAPIの cameraX というのもある。

処理の概要説明

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

Activity側

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

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

SurfaceView(レイアウト)側

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

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

CameraManager で使うカメラを指定

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

CameraManager から cameraId を複数取得して、その中からフロントカメラの cameraId を取得する処理が下記になる( 参考ページ

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 にカメラのパーミッションを追記する

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

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

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

    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したときに指定した deviceStateCallbackonOpened()内で cameraDevice.createCaptureSession() を実行する 。

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

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

    /**
     * カメラを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にカメラから画像を流し込む

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

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

    /**
     * カメラとのセッションに関するコールバック
     */
    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 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公式 を参考にした

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に値を伝えるコードを記述する。

    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 にカメラ動画が映るはず!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?