カテゴリー: Android

【kotlin】CameraXでAndroidカメラを実装してみた

はじめに

こんにちは、matsunariです。
今回はGoogleCodeLabsでCameraXの実装を行いました。以前の記事でJavaでPreviewを実装しましたが今回はkotlinでイメージキャプチャまで行います。
GoogleCodeLabsでのチュートリアルがありますが、英語で説明も少ないので今回少し詳しく書いてみます。

概要

今回実装を行ったMainActivityのソースコードを先に載せておきます。

package com.example.mycameraxapp

//CameraXに必要となるインポートです
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Matrix
import android.os.Bundle
import android.util.Log
import android.util.Size
import android.view.Surface
import android.view.TextureView
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.*
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.io.File
import java.util.concurrent.Executors

private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA,Manifest.permission.WRITE_EXTERNAL_STORAGE)

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        viewFinder = findViewById(R.id.view_finder)

        if (allPermissionsGranted()) {
            viewFinder.post { startCamera() }
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
        }
    }
    //カメラ撮影時に利用する単一のスレッドを取得し再利用できるようにexecutorを作成します。
    private val executor = Executors.newSingleThreadExecutor()

    //後に利用するTextureViewをここで定義します。
    private lateinit var viewFinder: TextureView

    private fun startCamera() {
        val previewConfig = PreviewConfig.Builder().apply {
            setTargetResolution(Size(640, 480))
        }.build()

        val preview = Preview(previewConfig)

        preview.setOnPreviewOutputUpdateListener {

            val parent = viewFinder.parent as ViewGroup

            parent.removeView(viewFinder)
            parent.addView(viewFinder, 0)

            viewFinder.surfaceTexture = it.surfaceTexture

            updateTransform()
        }
        val imageCaptureConfig = ImageCaptureConfig.Builder()
            .apply {
                setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
            }.build()

        val imageCapture = ImageCapture(imageCaptureConfig)

        findViewById(R.id.capture_button).setOnClickListener { 
            val file = File(externalMediaDirs.first(), 
                      "${System.currentTimeMillis()}.jpg") 

            imageCapture.takePicture(file, executor,
                object : ImageCapture.OnImageSavedListener {

                   override fun onError( 
                       imageCaptureError: ImageCapture.ImageCaptureError,
                       message: String,
                       exc: Throwable? 
                       ) {
                           val msg = "Photo capture failed: $message"
                           Log.e("CameraXApp", msg, exc)
                           viewFinder.post { 
                           Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                          }
                      } 

                   override fun onImageSaved(file: File) {
                           val msg = "Photo capture succeeded: ${file.absolutePath}"
                           Log.d("CameraXApp", msg)
                           viewFinder.post {
                               Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                           }
                   } 
                }) 
         }

         CameraX.bindToLifecycle(this, preview, imageCapture) 
       } 

       private fun updateTransform() { 
         val matrix = Matrix() 

         val centerX = viewFinder.width / 2f 
         val centerY = viewFinder.height / 2f 
        
         val rotationDegrees = when(viewFinder.display.rotation) { 
            Surface.ROTATION_0 ->; 0
            Surface.ROTATION_90 -> 90
            Surface.ROTATION_180 -> 180
            Surface.ROTATION_270 -> 270
            else -> return
        }

        matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)

        viewFinder.setTransform(matrix)
    }

    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                viewFinder.post { startCamera() }
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }
    //マニフェストで指定されたすべての権限が付与されているかどうかを確認します
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it) == PackageManager.PERMISSION_GRANTED
    }
}

使用前の準備

CameraXを使用する為に依存関係の設定と権限の設定が必要です。
build.gradleの依存関係(dependenciesの中)にCameraXのライブラリを追加します。

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.core:core-ktx:1.0.2'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'

    //ここに追加しましょう!

    def camerax_version = '1.0.0-alpha06'
    implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
}

ViewFinderLayoutを実装する

画面のレイアウトは以下のような実装になっています。

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

    <TextureView
        android:id="@+id/view_finder"
        android:layout_width="640px"
        android:layout_height="640px"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />
    <ImageButton
        android:id="@+id/capture_button"
        android:layout_width="72dp"
        android:layout_height="72dp"
        android:layout_margin="24dp"
        app:srcCompat="@android:drawable/ic_menu_camera"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

画面はこのようになります。

Camera Permissionのリクエスト

AndroidManifest.xmlに以下の記述を追加します。

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

Android10以降の端末であればWRITE_EXTERNAL_STORAGEの記述は必要ないので、書かなくても良いです。
今回はAndroid9端末で確認した為、記述しています。

カメラ撮影機能を実装する

CameraXのPreviewクラスとImageCaptureクラスを使ってカメラ撮影の機能を実装します。
CameraXのUseCaseクラスが複数ありますが、使用する流れは概ね以下のような感じです。
1.実装したい機能のオプションを設定する
2.リスナーを設定する
3.作成したUseCaseクラスをライフサイクルに紐づける
今回はPreviewクラスとImageCaptureクラスを使います。

Previewクラスの実装

1.SurfaceTextureをつくる

viewFinder = findViewById(R.id.view_finder)

CameraXの実装にはこのSurfaceTextureを使います。
プレビュー画面用に画像を描画する為に作成します。

2.プレビュー時のオプションの設定をする

val previewConfig = PreviewConfig.Builder().apply {
            setTargetResolution(Size(640, 480))
        }.build()

ここでは、レンズの解像度や画像のアスペクト比などが設定できます。
今回使用している、setTargetResolutionは解像度を設定しています。
Builderクラスにパラメータを設定します。

3.Previewクラスを作成してリスナーを付与する

        val preview = Preview(previewConfig)
        preview.setOnPreviewOutputUpdateListener {
            val parent = viewFinder.parent as ViewGroup
            parent.removeView(viewFinder)
            parent.addView(viewFinder, 0)
            viewFinder.surfaceTexture = it.surfaceTexture
        }

出力結果をもとにしてプレビューを更新するにはremoveView()で以前に取得したSurfaceTextureを解放し、addView()で再度追加し直す必要があります。

こちらでsetOnPreviewOutoutUpdateListenerの詳細な説明があります。

画面の回転に対する対応

取得できる画像データは画面の回転やアスペクト比が考慮されていないので、画面の回転に対してビューファインダー (TextureView)の回転がずれたり、潰れた状態で表示されることがあります。その為回転に対応した実装を行う必要があります。

private fun updateTransform() {
        val matrix = Matrix()
        //viewFinderの中心を計算します。
        val centerX = viewFinder.width / 2f
        val centerY = viewFinder.height / 2f 
        //カメラの回転を考慮してプレビュー出力を修正します
        val rotationDegrees = when(viewFinder.display.rotation) {
            Surface.ROTATION_0 -> 0
            Surface.ROTATION_90 -> 90
            Surface.ROTATION_180 -> 180
            Surface.ROTATION_270 -> 270
            else -> return
        }
        matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)
        //最後にviewFinderに変換を適用します
        viewFinder.setTransform(matrix)
    }

流れを順番に書くと
1.回転をTextureViewに適用する為のMatrixと回転の中心点を用意する。
2.画面の向きから回転させたい角度を取得してMatrixを更新する。
3.viewFinderにMatrixを適用する。
という感じです。

ImageCaptureクラスの実装

・撮影時のオプションを設定する
カメラの向きを指定する等のカスタマイズをしたい場合は、ImageCaptureConfigを作るときのBuilderクラスにパラメータを設定します。

val imageCaptureConfig = ImageCaptureConfig.Builder()
            .apply {
                setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
       }.build()

今回の場合だと、撮影時低遅延モードに設定しています。

・ImageCaptureクラスを作成し、撮影メソッドを呼ぶ

        val imageCapture = ImageCapture(imageCaptureConfig)

        findViewById(R.id.capture_button).setOnClickListener {
            val file = File(externalMediaDirs.first(),
                "${System.currentTimeMillis()}.jpg")

            imageCapture.takePicture(file, executor,
                object : ImageCapture.OnImageSavedListener {
                    override fun onError(
                        imageCaptureError: ImageCapture.ImageCaptureError,
                        message: String,
                        exc: Throwable?
                    ) {
                        val msg = "Photo capture failed: $message"
                        Log.e("CameraXApp", msg, exc)
                        viewFinder.post {
                            Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                        }
                    }

撮影ボタン押下時にjpgファイルが出力されます。
takePictureメソッドの引数に指定している場所の保存されます。
画像保存が成功した場合Toastを出します。逆に失敗した時はログを出力します。

CameraX.bindToLifecycle(this, preview, imageCapture)

最後にImageCaptureクラスをライフサイクルと紐づけます。

さいごに

CameraXはCameraAPI2と比べると、とても簡単に実装できました。Camera2APIのように凝った設定はできないですが、シンプルなカメラ機能の実装には向いているのではないかと思います。またImageAnalysisクラスも使って画像の解析もできたら楽しそうですね。

おすすめ書籍

narimatsutetsuya

コメントを見る

シェア
執筆者:
narimatsutetsuya
タグ: AndroidKotlin

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

2週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

4週間 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前