Android, Kotlin + Camera API v2 でカメラ機能を実装する

高専 Advent Calendar 2018 2日目

この記事は高専 Advent Calendar 2018の2日目の記事です。 前日の記事はTorikaraHNMさんの英語キーボードを使い始めた話です。

予定では、量子コンピュータに関して書く予定だったのですが、何を思ったか先に書いてしまったという失態...。

nomunomu.hateblo.jp

nomunomu.hateblo.jp

なので、題目を変更しました...。

Kotlin + Camera v2

タイトルにもあるように、KotlinでCamera API 2を使って実装する記事が結構少ないように思われました。なので、最低限の実装で撮影ができるようになるまでを書いていきたいと思います。

とりあえず、Kotlin等のインストールは省略します。

Camera API v2

Camera API v2はCamera API v1と比べて、かなり自由度は高くなりました。しかし、v1と違ってカメラのイベント関係をJava層まで落とし込んだインターフェスになっているので扱いは難しいです。

パーミッションの追加

カメラを利用するために、Manifestにuses-permissionを追加します。カメラで撮影した写真を保存するために、ストレージへアクセスすることになるため、ストレージへのアクセスも許可します。

// AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

requestPermission

このrequestPermissionでカメラの利用許可を行わないと、まずカメラが使えません。なので、カメラ使用のリクエストを発行します。このrequestPermissionを呼び出す順番としては以下の通りで、openCamera関数では、カメラ利用のための許可申請と、カメラを利用するための設定等を行います。そのため、この関数からカメラを使うためのいくつかの設定を行います。

TextureView に surfaceTextureListener を設定する
↓
コールバック関数が呼び出され、関数内のオーバーライドメソッド(onSurfaceTextureAvailable)が呼ばれる
↓
openCamera() を呼び出す → requestPermission() を呼び出す
↓
Cameraの状態をコールバックする関数を設定する(stateCallback)
↓
↓ カメラの準備ができたら
↓
Cameraから取得したデータをプレビューする(createCameraPreviewSession)

使う変数としてはこんな感じですね。もっと簡略化できるのかもしれませんが、僕のではこうなりました^^;

companion object {
     const val PERMISION_CAMERA        = 200
     const val PERMISION_WRITE_STORAGE = 1000
     const val PERMISION_READ_STORAGE  = 1001
}

/**
 * 各レイアウトオブジェクト変数を生成
 */
private lateinit var shutterButton : ImageButton
private lateinit var numberPicker  : NumberPicker
private lateinit var previewView   : TextureView
private lateinit var imageReader   : ImageReader

/**
 * 各種変数初期化
 */
private lateinit var previewRequestBuilder : CaptureRequest.Builder
private lateinit var previewRequest        : CaptureRequest
private var backgroundHandler              : Handler?                = null
private var backgroundThread               : HandlerThread?          = null
private var cameraDevice                   : CameraDevice?           = null
private lateinit var captureSession        : CameraCaptureSession

backgroundThread

カメラの処理はバックグラウンドで起動させるために、Thread処理を挟みます。カメラをバックグラウンドで起動させるための関数を作成して、onCreateで呼び出します。backgroundThread変数は後述するcreateCameraPreviewSession関数内でのカメラ設定に利用します。

/**
 * カメラをバックグラウンドで実行
 */
private fun startBackgroundThread() {
    backgroundThread = HandlerThread("CameraBackground").also { it.start() }
    backgroundHandler = Handler(backgroundThread?.looper)
}

// onCreate内で呼び出す
startBackgroundThread()

surfaceTextureListener

プレビューを表示するためのTextureView(previewView)にListenerを持たせます。これによってpreviewViewの変化をトリガーとして使うことができます。TextureViewが有効になったらプレビューをする準備をして、openCameraをコールします

/**
 * テクスチャビューにイベント作成
 */
previewView.surfaceTextureListener = surfaceTextureListener

/**
 * TextureView Listener
 */
private val surfaceTextureListener = object : TextureView.SurfaceTextureListener
{
    // TextureViewが有効になった
    override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int)
    {
        imageReader = ImageReader.newInstance(width, height, ImageFormat.JPEG,2)
        openCamera()
    }

    // TextureViewのサイズが変わった
    override fun onSurfaceTextureSizeChanged(p0: SurfaceTexture?, p1: Int, p2: Int) { }

    // TextureViewが更新された
    override fun onSurfaceTextureUpdated(p0: SurfaceTexture?) { }

    // TextureViewが破棄された
    override fun onSurfaceTextureDestroyed(p0: SurfaceTexture?): Boolean
    {
        return false
    }
}

openCamera

Camera API 2ではカメラを起動する際に、「カメラマネジャー」を使って、カメラを管理します。そのため、カメラマネジャーの設定、カメラIDの取得などを行います。

/**
 * カメラ起動処理関数
 */
private fun openCamera() {
    /**
     * カメラマネジャーの取得
     */
    val manager: CameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager

    try {
        /**
         * カメラIDの取得
         */
        val camerId: String = manager.cameraIdList[0]

        /**
         * カメラ起動パーミッションの確認
         */
        val permission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)

        if (permission != PackageManager.PERMISSION_GRANTED) {
            requestCameraPermission()
            return
        }

        /**
         * カメラ起動
         */
        manager.openCamera(camerId, stateCallback, null)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

requestPermission

さて、openCamereパーミッションの確認が行われるわけですが、すでに許可されている時には、何事もなかったようにカメラが起動されますが、初回起動時などには、おなじみの許可ダイアログが表示されるようにします。

https://cdn-ak.f.st-hatena.com/images/fotolife/n/nomunomu0504/20230212/20230212043556.png

/**
 * カメラ利用許可取得ダイアログを表示
 */
private fun requestCameraPermission() {
    if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        AlertDialog.Builder(baseContext)
                .setMessage("Permission Check")
                .setPositiveButton(android.R.string.ok) { _, _ ->
                    requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISION)
                }
                .setNegativeButton(android.R.string.cancel) { _, _ ->
                    finish()
                }
                .show()
    } else {
        requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISION)
    }
}

.setPositiveButton(button_string) { dialog, which -> }.show()って書き方がどうも慣れない人が多いようです。{ _, _ -> }となっていても問題はなくて、dialog, whichを使わないのであれば不要です。って言っているようなものだと思ってください。

stateCallback

カメラの許可も取れて、あとは起動するだけですが、起動する時にカメラに接続できているかどうかを確認する必要があります。なので、manager.openCamera(camerId, stateCallback, null)でカメラの状態を取得するためのコールバック関数を指定して、その関数の中で状態を把握します。もし、カメラになんらかの変化があれば、この関数にあるメソッドが呼ばれることになります。

/**
 * カメラ状態取得コールバック関数
 */
private val stateCallback = object : CameraDevice.StateCallback() {
    
    /**
     * カメラ接続完了
     */
    override fun onOpened(cameraDevice: CameraDevice) {
        this@TakePicture.cameraDevice = cameraDevice
        createCameraPreviewSession()
    }

    /**
     * カメラ切断
     */
    override fun onDisconnected(cameraDevice: CameraDevice) {
        cameraDevice.close()
        this@TakePicture.cameraDevice = null
    }

    /**
     * カメラエラー
     */
    override fun onError(cameraDevice: CameraDevice, error: Int) {
        onDisconnected(cameraDevice)
        finish()
    }
}

createCameraPreviewSession

createCameraPreviewSessionでは、カメラから取得したデータをTextureViewにプレビューするための設定をします。setDefaultBufferSizepreviewViewの実機での表示サイズを取得して、textureのサイズを決めます。その後、カメラから取得したデータをtextureViewに投影します。

/**
 * カメラ画像生成許可取得ダイアログを表示
 */
private fun createCameraPreviewSession()
{
    try
    {
        val texture = previewView.surfaceTexture
        texture.setDefaultBufferSize(previewView.width, previewView.height)

        val surface = Surface(texture)
        previewRequestBuilder = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
        previewRequestBuilder.addTarget(surface)

        cameraDevice?.createCaptureSession(Arrays.asList(surface, imageReader.surface),
            @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
            object : CameraCaptureSession.StateCallback()
            {
                override fun onConfigured(cameraCaptureSession: CameraCaptureSession)
                {
                    if (cameraDevice == null) return
                    try
                    {
                        captureSession = cameraCaptureSession
                        previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                        previewRequest = previewRequestBuilder.build()
                        cameraCaptureSession.setRepeatingRequest(previewRequest, null, Handler(backgroundThread?.looper))
                    } catch (e: CameraAccessException) {
                        Log.e("erfs", e.toString())
                    }

                }

                override fun onConfigureFailed(session: CameraCaptureSession) {
                    //Tools.makeToast(baseContext, "Failed")
                }
            }, null)
    } catch (e: CameraAccessException) {
        Log.e("erf", e.toString())
    }
}

主に、カメラのデータをTextureViewにプレビューしている部分はここらへんになります。

cameraDevice?.createCaptureSession(Arrays.asList(surface, imageReader.surface),
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    object : CameraCaptureSession.StateCallback()
    {
        override fun onConfigured(cameraCaptureSession: CameraCaptureSession)
        {
            if (cameraDevice == null) return
            try
            {
                captureSession = cameraCaptureSession
                previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                previewRequest = previewRequestBuilder.build()
                cameraCaptureSession.setRepeatingRequest(previewRequest, null, Handler(backgroundThread?.looper))
            } catch (e: CameraAccessException) {
                Log.e("erfs", e.toString())
            }

        }

        override fun onConfigureFailed(session: CameraCaptureSession) {
            //Tools.makeToast(baseContext, "Failed")
        }
    },
    null
)

requestStoragePermission

カメラのデータを保存するために、ストレージアクセスをします。そのため、ストレージにアクセスするための権限を取得します。要領はカメラの時と同じです。以下のコードはパーミッションを確認する時に呼び出すものです。基本的には、アプリ起動時にすぐ呼び出す方がいいかと思います。が、ご自由にどうぞ。

/**
 * ストレージ読み書きパーミッションの確認
 */
val writePermission  = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
val readPermission  = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)

if ( (writePermission != PackageManager.PERMISSION_GRANTED) || (readPermission != PackageManager.PERMISSION_GRANTED) ) {
    requestStoragePermission()
}

requestStoragePermission関数の内容は以下の通りです。カメラとの違いは、書き込みと読み込みの2つを行なっていることです。もしかしたら片方すればいいかと思うんですが、とりあえず2つしておきます。詳しい人教えてください...。

private fun requestStoragePermission() {
    /**
     * 書き込み権限
     */
    if (shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
        AlertDialog.Builder(baseContext)
                .setMessage("Permission Here")
                .setPositiveButton(android.R.string.ok) { _, _ ->
                    requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), WRITE_STORAGE_PERMISION)
                }
                .setNegativeButton(android.R.string.cancel) { _, _ ->
                    finish()
                }
                .create()
    } else {
        requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), WRITE_STORAGE_PERMISION)
    }

    /**
     * 読み込み権限
     */
    if (shouldShowRequestPermissionRationale(Manifest.permission.READ_EXTERNAL_STORAGE)) {
        AlertDialog.Builder(baseContext)
                .setMessage("Permission Here")
                .setPositiveButton(android.R.string.ok) { _, _ ->
                    requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), READ_STORAGE_PERMISION)
                }
                .setNegativeButton(android.R.string.cancel) { _, _ ->
                    finish()
                }
                .create()
    } else {
        requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), READ_STORAGE_PERMISION)
    }
}

シャッターボタンへのアクション

これでカメラのプレビューまで終わったわけですが、撮影しないと始まらないので、シャッターボタンを作成して押されたら表示してるプレビュー画像をファイルとして保存します。保存先はDocumentsになります。DIRECTORY_DOCUMENTSの部分を変更すれば、別のディレクトリに対してフォルダを作成することができます(この場合だとcameraPreviewというフォルダを作成しています) シャッターボタンが押されると、TextureViewの更新を停止して表示されている画像をファイルとして保存します。そして、再度TextureViewの更新を行うことでサイクルが完成します。

/**
 * シャッターボタンにイベント生成
 */
shutterButton.setOnClickListener {

    val appDir   = File(getExternalStoragePublicDirectory(DIRECTORY_DOCUMENTS), "cameraPreview")

    try {
        val filename = "test_picture.jpg"
        var savefile : File? = null


        /**
         * プレビューの更新を止める
         */
        captureSession.stopRepeating()
        if (previewView.isAvailable) {

            savefile = File(appDir, filename)
            val fos = FileOutputStream(savefile)
            val bitmap: Bitmap = previewView.bitmap
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos)
            fos.close()
        }

        if (savefile != null) {
            Log.d("edulog", "Image Saved On: $savefile")
            Toast.makeText(this, "Saved: $savefile", Toast.LENGTH_SHORT).show()
        }

    } catch (e: CameraAccessException) {
        Log.d("edulog", "CameraAccessException_Error: $e")
    } catch (e: FileNotFoundException) {
        Log.d("edulog", "FileNotFoundException_Error: $e")
    } catch (e: IOException) {
        Log.d("edulog", "IOException_Error: $e")
    }

    /**
     * プレビューを再開
     */
    captureSession.setRepeatingRequest(previewRequest, null, null)
}

まとめ

最低限必要な実装だけで、Camera API 2をKotlinで使うことができるようになったはずです。(これが最小限かどうかはわかりませんが...。)ただし、画面の回転でTextureViewの変更などは考えていません。画面の回転等はちょっと面倒なんですよねー。端末の物理的回転とシステム上で認識されている回転とがずれているので、そこを補助する必要があります...。回転とか、カメラのいろいろに関しては、こちらのソースを参考にしてください。こちらのOSSは自分が一昔前に関わらさせていただいていたものです。更新はされてないようですが。。。Javaで書かれているんですが、カメラ部分などは参考にできるかと(結構面倒な処理とかもして大変でした...。)

github.com

実はiPhone版もあったりするので、みるだけでも損はないと思います!笑

github.com

ソースコード

ソースコードですが、現在開発中のシステムで、これを構築していたので全体の公開はすることができません。そのため、コメントやTwitterなどでリクエストがあれば、詳しく説明 or 一部コードの提供をさせていただきます!!!

次の記事

次の「高専 Advent Calendar 2018」記事は、ykbr_さんの「Goで機能を追加しつつ私のクローンを書き直した」です。ぜひそちらもみてみてください!!

m76r.hateblo.jp

adventar.org