7
3

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 3 years have passed since last update.

KotlinでAutoFitTextureViewを使ってCamera2する

Last updated at Posted at 2020-01-06

#KotlinでCamera2を使いたい
Android開発に乗り出してからCamera初挑戦であります。
「Kotlinで」なんて言ってはいますが、Javaでだってやったことありません。

ただ、最近のマイブームがKotlinなのでKotlinを使用してCameraを使ってみたいなと思った(ドライブレコーダーアプリを作りたかった)ところ、想像を絶するほどにサンプルが少なくて苦戦したので、メモ程度に書いておきます。

誰かのお役に立てれば光栄です。

#環境
AndroidStudio 3.5.3

#とりあえずGoogle様
まぁね。とりあえずAndroidで何か新しいことやるよって言ったらGoogle様のサンプルを見ますよ。少なくとも僕は。

Google様のCamera2サンプル

はいドン。
さすがGoogle様。スパゲティーすぎて何もわかりません。さすがです。

なんでもかんでも詰め込むから分からなくなるんです。

ということで、今回はちゃんとCamera2を使ってちゃんとプレビューを表示できるようになるまで頑張ってみましょう。撮影とかフラッシュとかAFとかは調べて今回のコードに適当に張り付ければ動きます。多分。

#Googleの次はQiitaでしょ
天下のGoogle様のコードは細かすぎて難しかったので、次はQiitaを見てみましょう。日本人プログラマなら公式の次にQiitaを見るでしょ。少なくとも僕は。

Qiitaのユーザーが書いたCamera2のサンプル

はいドン。

いいですね。簡潔にまとめられていて最小限やることが分かると思います。
この流れに沿ってやっていきましょう。

#コード
###AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="caios.android.drive_recorder">

    <!--必要な権限-->
    <uses-permission android:name="android.hardware.camera.autofocus" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application
        android:name=".GlobalClass"
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@drawable/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <!--略-->
        <activity
            android:name=".DriveRecorderActivity"
            android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
        <!--略-->
    </application>

</manifest>

今回はあくまでサンプルなので細かいことは気にしません(何度も言いますが)。ぜーんぶUIスレッドで実行しますけど、本番では真似しないように。

あとできればActivityに直書きするのはやめたほうがいいと思います。できればFragment使いましょう。
###使用する変数
使わないやつもありますが、将来のために予約済みです。
Vertical,Horizontalの命名に関しては何も言わないでください。英語苦手なんです。

    private lateinit var textureView: AutoFitTextureView
    private lateinit var previewSize: Size
    private lateinit var previewRequest: CaptureRequest
    private lateinit var previewRequestBuilder: CaptureRequest.Builder

    private var permissionRequestCount = 0
    private var cameraId = "0"
    private var cameraDevice: CameraDevice? = null
    private var cameraCaptureSession: CameraCaptureSession? = null

    private val MAX_PREVIEW_WIDTH = 1920
    private val MAX_PREVIEW_HEIGHT = 1080
    private val ASPECT_VERTICAL = 16
    private val ASPECT_HORIZONTAL = 9
    
    private val cameraManager: CameraManager by lazy {
        getSystemService(Context.CAMERA_SERVICE) as CameraManager
    }

###TextureView.SurfaceTextureListener
TextureViewが初期化済みならcameraOpen。まだなら初期化できるまで待ちます。

後述しますが、端末の回転などでAppの向きが変わった場合など、TextureViewのサイズに変更があった場合にTextureViewのサイズを調整しなければならないので、onSurfaceTextureSizeChangedで調整用の関数を呼びます。

override fun onResume() {
        super.onResume()

        if (textureView.isAvailable) {
            openCamera(textureView.width, textureView.height)
        }
        else {
            textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
                override fun onSurfaceTextureAvailable(p0: SurfaceTexture?, p1: Int, p2: Int) {
                    openCamera(p1, p2)
                }

                override fun onSurfaceTextureDestroyed(p0: SurfaceTexture?): Boolean = true

                override fun onSurfaceTextureUpdated(p0: SurfaceTexture?) = Unit

                override fun onSurfaceTextureSizeChanged(p0: SurfaceTexture?, p1: Int, p2: Int) {
                    configureTransform(p1, p2)
                }
            }
        }
    }

###OpenCamera
カメラをオープンします。
その時についでにTextureViewのサイズ調整や、Permission確認を行います。
setUpCameraOptionsでAFやフラッシュの設定をするならしやがれって感じです。
checkPermissionに関しては各自でお願いします。

private fun openCamera(width: Int, height: Int) {
        if(ActivityCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
            checkPermission()
            return
        }

        setUpCameraOptions()
        configureTransform(width, height)

        val cameraStateCallback = object : CameraDevice.StateCallback() {
            override fun onOpened(p0: CameraDevice) {
                cameraDevice = p0
                createCameraPreview()
            }

            override fun onDisconnected(p0: CameraDevice) {
                cameraDevice?.close()
                cameraDevice = null
            }

            override fun onError(p0: CameraDevice, p1: Int) {
                cameraDevice?.close()
                cameraDevice = null
            }
        }

        cameraManager.openCamera(cameraId, cameraStateCallback, handler)
    }

###CloseCamera
開けたら閉める。
お母さんにそう習ったでしょう?
onPauseで呼びます。

private fun closeCamera() {
        cameraCaptureSession?.close()
        cameraDevice?.close()
    }

###SetUpCameraOptions
順番飛ばしました。
openCameraするにあたってカメラのオプションを設定します。

後述するgetPreviewSizeで端末でプレビューできるサイズを取得するのですが、どうもAndroid標準ではCameraを横向き(横画面)で使うことを想定しているらしく、横長のサイズで帰ってきます。

横画面で使う場合はそれでいいのですが、縦で使う場合は縦横入れ替えて使いましょう。

private fun setUpCameraOptions(){
        previewSize = getPreviewSize()

        if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
            textureView.setAspectRatio(previewSize.width, previewSize.height)
        } else {
            textureView.setAspectRatio(previewSize.height, previewSize.width)
        }
    }

###CreateCameraPreview
カメラプレビューを表示するためにTextureViewにいろいろとお願いします。
エラー処理とかやる気ないです。(大問題)

private fun createCameraPreview(){
        try {
            val surfaceTexture = textureView.surfaceTexture
            surfaceTexture.setDefaultBufferSize(previewSize.width, previewSize.height)
    
            val surface = Surface(surfaceTexture)

            previewRequestBuilder = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
            previewRequestBuilder.addTarget(surface)
    
            val captureSessionStateCallback = object : CameraCaptureSession.StateCallback() {
                override fun onConfigured(p0: CameraCaptureSession) {
                    cameraCaptureSession = p0

                    previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                    previewRequest = previewRequestBuilder.build()

                    cameraCaptureSession?.setRepeatingRequest(previewRequest, null, null)
                }
        
                override fun onConfigureFailed(p0: CameraCaptureSession) = Unit
            }
    
            cameraDevice?.createCaptureSession(listOf(surface), captureSessionStateCallback, null)
        }
        catch (e: Exception){
            Log.d(G_TAG, "$e")
        }
    }

###getPreviewSize
ここ難しかったです。(知るか)

まず、cameraIdの取得に関してはcameraIdListの一番最初が多くの場合、背面カメラの1倍率なのでそれを使うとします。(本番ではちゃんと確認するように)

でそのカメラのpreviewSizeを取得するには、

        val characteristic = cameraManager.getCameraCharacteristics(cameraId)
        val scm = characteristic.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
        val mPreviewSize = scm?.getOutputSizes(SurfaceTexture::class.java) ?: return Size(1, 1)

こういう風にしなければいけないんですね。知りませんでした。

previewSizeのリストが分かってしまえばもう大丈夫。目的のアスペクト比のサイズを探し出して、Sizeで返します。

アスペクト比を出すためには縦横の最大公約数で割ればいいですね。

private fun getPreviewSize(): Size {
        cameraId = cameraManager.cameraIdList[0]

        val characteristic = cameraManager.getCameraCharacteristics(cameraId)
        val scm = characteristic.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
        val mPreviewSize = scm?.getOutputSizes(SurfaceTexture::class.java) ?: return Size(1, 1)
        var maxSize: Size? = null

        for(ps in mPreviewSize) {
            val gcd = getGCD(ps.width, ps.height)
            val width = ps.width / gcd
            val height = ps.height / gcd

            if ((height == ASPECT_VERTICAL && width == ASPECT_HORIZONTAL) || (width == ASPECT_VERTICAL && height == ASPECT_HORIZONTAL)) {
                if (maxSize == null) {
                    maxSize = ps
                } else {
                    if (ps.height > maxSize.height && ps.width > maxSize.width) {
                        maxSize = ps
                    }
                }
            }
        }

        return maxSize ?: mPreviewSize[0]
    }

    private fun getGCD(a: Int, b: Int): Int {
        if(a < b) return getGCD(b, a)
        if(b == 0) return a
        return getGCD(b, a % b)
    }

###configureTransform
最難関。

何らかの影響でTextureViewのサイズが変わってしまった場合や、画面が回転したときに呼ばれます。

画面が回転すればアスペクト比が縦横逆になりますし、なによりPreviewを回転させなければなりません。(端末の回転とは逆方向に同じ角度だけ)

TextureViewは厄介でして、てきとーにwidthとかを設定しただけでは動いてくれないんですよね。なので、今回はViewの大きさを決定する門番。「onMesure」さんに動いてもらうことにしましょう。(後述します)

MatrixでViewを回転させます。

 private fun configureTransform(width: Int, height: Int) {
        val rectView = RectF(0f, 0f, width.toFloat(), height.toFloat())
        val rectPreview = RectF(0f, 0f, previewSize.height.toFloat(), previewSize.width.toFloat())
        val centerX = rectView.centerX()
        val centerY = rectView.centerY()
        val matrix = Matrix()
        val rotation = windowManager.defaultDisplay.rotation

        when (rotation) {
            Surface.ROTATION_90, Surface.ROTATION_270 -> {
                rectPreview.offset(centerX - rectPreview.centerX(), centerY - rectPreview.centerY())

                val scale = Math.max(
                    height.toFloat() / previewSize.height,
                    width.toFloat() / previewSize.width
                )

                with(matrix) {
                    setRectToRect(rectView, rectPreview, Matrix.ScaleToFit.FILL)
                    postScale(scale, scale, centerX, centerY)
                    postRotate((90 * (rotation - 2)).toFloat(), centerX, centerY)
                }
            }
            Surface.ROTATION_180 -> {
                matrix.postRotate(180f, centerX, centerY)
            }
        }

        textureView.setTransform(matrix)
    }

###AutoFitTextureView
TextureViewさんではプレビューサイズに応じてViewのサイズを頑なに変えてくれないので、カスタムビューのAutoFitTextureViewさんに来ていただきました。

setAspectRatioでpreviewSizeを事前に伝えて置き(setUpCameraOptions)、onMeasureでプレビューサイズに応じて自身の大きさを決めます。

class AutoFitTextureView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : TextureView(context, attrs, defStyle) {

    private var ratioWidth = 0
    private var ratioHeight = 0

    fun setAspectRatio(width: Int, height: Int) {
        if (width < 0 || height < 0) {
            throw IllegalArgumentException("Size cannot be negative.")
        }
        ratioWidth = width
        ratioHeight = height
        requestLayout()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        val width = View.MeasureSpec.getSize(widthMeasureSpec)
        val height = View.MeasureSpec.getSize(heightMeasureSpec)

        if (ratioWidth == 0 || ratioHeight == 0) {
            setMeasuredDimension(width, height)
        } else {
            if (width < height * ratioWidth / ratioHeight) {
                setMeasuredDimension(width, width * ratioHeight / ratioWidth)
            } else {
                setMeasuredDimension(height * ratioWidth / ratioHeight, height)
            }
        }
    }
}

###レイアウトの配置
最後にレイアウトを配置します。

onMeasureでサイズを変更する場合はwrap_content使えないって情報があったんですけど、Googleのサンプルがwrap_contentなのでこれでやってみても普通に動きました。どうなんだろう?

適宜、横画面のレイアウトも作ってください。
今回は超シンプルレイアウトです。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    android:id="@+id/ADR_RootView"
    tools:context=".DriveRecorderActivity">

    <caios.android.drive_recorder.AutoFitTextureView
        android:id="@+id/ADR_TextureView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true" />

</RelativeLayout>

#完成
どうでしたか?
まずは普通にプレビューができるアプリができたと思います。Qiitaの記事の中では回転させると横に伸びてしまう(ストレッチしてしまう)ものが多かったので書いておきました。

Qiita初心者なので間違ってることもあると思いますが、その時はコメントしてください。

7
3
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
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?