CameraXを使ってみよう

Pocket

CameraXを使ってみよう

はじめに

先日のGoogle I/O 2019にてCameraXが発表されました。
この記事では、***CameraXを使ってみよう***ということで
さらっと触ってみたいと思いいます。


CameraXとは

CameraXとはAndroid jetpackに追加されたCameraのAPIを便利に使うことのできるライブラリです。
Androidのカメラ機能はCamera classが用意されていましたが、使い勝手がとっても悪く、
Camera2というclassが作られた歴史があります。
Camera2も使い勝手がよかったか? と言われるとそうでもなく・・・改善しようと作られたのがCameraXになります。

詳細はこちらをご覧ください。


Codelabs

GoogleはCodelabsというハンズオン形式で学ぶサイトを用意してくれています。
今回、CameraXを使ってみるにあたって、Codelabsを参考にしています。
具体的には以下のリンクです。

Getting Started with CameraX


準備

上記のリンクの先にも書いていますが、CamaraXを使うには Android Studio3.3以上 が必要になります。
まだ古いバージョンを使用している人は、ここでバージョンアップしておきましょう。


新規プロジェクトを作る

まずはプロジェクトを作成します。
以下の内容でAndroid Studioで新規プロジェクトを作成してください。

  • Empty Activity
  • 言語はKotlin
  • API level is 21以上
  • Use AndroidX artifactsにチェック


Gradleの依存関係を設定する

プロジェクトを作成したら、appのbuild.gradleに依存関係を記述しましょう。

build.graleのdependenciesに以下を追加してください。

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

viewFinder layoutを作成する

viewFinder layoutを作成します。
activity_main.xmlを編集して、TextureViewを追加しましょう。

<?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" />

</androidx.constraintlayout.widget.ConstraintLayout>

カメラパーミッションを要求する

AndroidManifest.xmlのapplicationタグの前にカメラのパーミッションの使用を宣言しましょう。

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

またMainActivityでランタイムパーミッションの要求処理を追加しましょう。

MainActivityクラスの外側、ファイルの一番上にimportと定義を追加します。

// IDEが自動インポートする場合もありますが
// それぞれ実装が異なる場合があるので、曖昧さを無くすためにここに列挙します
import android.Manifest
import android.util.Size
import android.graphics.Matrix
import java.util.concurrent.TimeUnit

// パーミッションを要求するときのリクエストコード番号です
// 複数のContextからパーミッションが要求された時にどこから要求されたかを区別するために使います
private const val REQUEST_CODE_PERMISSIONS = 10

// 要求する全パーミッションの配列
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)

onCreate()の後に以下の実装を追加します。

    private lateinit var viewFinder: TextureView

    private fun startCamera() {
        // TODO: CameraXの処理を実装します
    }

    private fun updateTransform() {
        // TODO: カメラのビューファインダーが変化した時の処理を実装します
    }

    /**
     * カメラのパーミッションリクエストダイアログ処理の結果を確認します
     * パーミッションが許可された場合はCameraを開始します
     * そうでない場合はToastを表示して終了します		
     */
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                viewFinder.post { startCamera() }
            } else {
                Toast.makeText(this,
                    "パーミッションが許可されませんでした", 
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    /**
    * 定義されたパーミッションをチェックします
     */
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
                this, it) == PackageManager.PERMISSION_GRANTED
    }

onCreate()内にパーミッション要求処理の呼び出しを記述します。

    // onCreate()の最後に以下を追加
    viewFinder = findViewById(R.id.view_finder)

    // カメラパーミッションの要求
    if (allPermissionsGranted()) {
        viewFinder.post { startCamera() }
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
    }

    // texture viewが変化した時にLayoutの再計算を行う
    viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
        updateTransform()
    }

Unresolved referenceが発生すると思いますが、適宜importして解消してください。


view finderの実装

ここで実装するを追加するとbindToLifecycleメソッドで

Type mismatch: inferred type is MainActivity but LifecycleOwner! was expected

が発生する場合があります。
これはandroidxのライブラリが古い場合に発生しますので、以下の記述に更新しておいてください。

implementation 'androidx.appcompat:appcompat:1.1.0-alpha05'

TODOとしていたstartCamera()とupdateTransform()を実装します。

private fun startCamera() {

    // viewfinder use caseのコンフィグレーションオブジェクトを生成
    val previewConfig = PreviewConfig.Builder().apply {
        setTargetAspectRatio(Rational(1, 1))
        setTargetResolution(Size(640, 640))
    }.build()

    // viewfinder use caseの生成
    val preview = Preview(previewConfig)

    // viewfinderが更新されたらLayoutを再計算
    preview.setOnPreviewOutputUpdateListener {

        // SurfaceTextureの更新して再度親Viewに追加する
        val parent = viewFinder.parent as ViewGroup
        parent.removeView(viewFinder)
        parent.addView(viewFinder, 0)

        viewFinder.surfaceTexture = it.surfaceTexture
        updateTransform()
    }

    // use caseをlifecycleにバインドする
    CameraX.bindToLifecycle(this, preview)
}
private fun updateTransform() {
    val matrix = Matrix()

    // view finderの中心の計算
    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)

    // TextureViewへのセット
    viewFinder.setTransform(matrix)
}

この時点でアプリをエミュレータで起動すると以下のようになります。


image capture use caseの実装

activity_main.xmlにキャプチャボタンを追加します。

<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" />

startCamera()のCameraX.bindToLifecycleの呼び出しの前に以下を追加します。

    // image capture use caseのコンフィグレーションオブジェクトを生成
    val imageCaptureConfig = ImageCaptureConfig.Builder()
        .apply {
            setTargetAspectRatio(Rational(1, 1))
            // イメージキャプチャの解像度は設定しない。
            // 代わりに、アスペクト比と要求されたモードに基づいて
            // 適切な解像度を推測するキャプチャモードを選択します。
            setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
    }.build()

    // image capture use caseの生成とボタンのClickリスナーの登録
    val imageCapture = ImageCapture(imageCaptureConfig)
    findViewById<ImageButton>(R.id.capture_button).setOnClickListener {
        val file = File(externalMediaDirs.first(),
            "${System.currentTimeMillis()}.jpg")
        imageCapture.takePicture(file,
            object : ImageCapture.OnImageSavedListener {
            override fun onError(error: ImageCapture.UseCaseError,
                                 message: String, exc: Throwable?) {
                val msg = "Photo capture failed: $message"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                Log.e("CameraXApp", msg)
                exc?.printStackTrace()
            }

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

CameraX.bindToLifecycle()の呼び出しにimage capture use caseを追加します。

CameraX.bindToLifecycle(this, preview, imageCapture)

この時点でアプリをエミュレータで起動すると以下のようになります。


image analysis use caseの実装

ImageAnalysis.Analyzerのinterfaceを実装したクラスを実装します。
ここでは画像の輝度を分析して表示する処理を実装します。
MainActivityクラスの内部クラスとして記述しましょう。

private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
    private var lastAnalyzedTimestamp = 0L

     /*
     * image plane bufferからbyte配列を抽出するHelper
     */
    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()    // バッファを0にする
        val data = ByteArray(remaining())
        get(data)   // Byte配列にバッファをコピー
        return data // Byte配列を返却
    }

    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val currentTimestamp = System.currentTimeMillis()
        // 毎秒ごとに平均輝度を計算する
        if (currentTimestamp - lastAnalyzedTimestamp >=
            TimeUnit.SECONDS.toMillis(1)) {
            // ImageAnalysisはYUV形式なのでimage.planes[0]にはY (輝度) planeが格納されている
            val buffer = image.planes[0].buffer
            // callback objectからimage dataの抽出 
            val data = buffer.toByteArray()
            // pixel値の配列にデータを変換
            val pixels = data.map { it.toInt() and 0xFF }
            // imageの平均輝度の計算
            val luma = pixels.average()
            // 輝度のログ表示
            Log.d("CameraXApp", "Average luminosity: $luma")
            // 最後に分析したフレームのタイムスタンプに更新
            lastAnalyzedTimestamp = currentTimestamp
        }
    }
}

startCamera()のCameraX.bindToLifecycleの呼び出しの前に以下を追加します。

    // 平均輝度を計算するimage analysis pipelineのセットアップ
    val analyzerConfig = ImageAnalysisConfig.Builder().apply {
        // 不具合を防ぐためにワーカースレッドを使う
        val analyzerThread = HandlerThread(
            "LuminosityAnalysis").apply { start() }
        setCallbackHandler(Handler(analyzerThread.looper))
        // ここではすべての画像を分析するよりも、最新の画像を重視する
        setImageReaderMode(
            ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
    }.build()

    // image analysis use caseの生成とanalyzerのインスタンス生成
    val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
        analyzer = LuminosityAnalyzer()
    }

    // use caseをlifecycleにバインドする
    CameraX.bindToLifecycle(this, preview, imageCapture)

CameraX.bindToLifecycle()の呼び出しにimage analysis use caseを追加します。

CameraX.bindToLifecycle(this, preview, imageCapture, analyzerUseCase)

この状態でアプリを起動すると、毎秒ごとに以下のようなログが表示されるようになります。

D/CameraXApp: Average luminosity: ...

終わりに

出来上がったMainActivityは以下のようになりました。

package opst.co.jp.cameraxapp

import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Matrix
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.util.Rational
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.nio.ByteBuffer
import java.util.concurrent.TimeUnit

// IDEが自動インポートする場合もありますが
// それぞれ実装が異なる場合があるので、曖昧さを無くすためにここに列挙します

// パーミッションを要求するときのリクエストコード番号です
// 複数のContextからパーミッションが要求された時にどこから要求されたかを区別するために使います
private const val REQUEST_CODE_PERMISSIONS = 10

// 要求する全パーミッションの配列
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)

class MainActivity : AppCompatActivity() {

    private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
        private var lastAnalyzedTimestamp = 0L

        /*
        * image plane bufferからbyte配列を抽出するHelper
        */
        private fun ByteBuffer.toByteArray(): ByteArray {
            rewind()    // バッファを0にする
            val data = ByteArray(remaining())
            get(data)   // Byte配列にバッファをコピー
            return data // Byte配列を返却
        }

        override fun analyze(image: ImageProxy, rotationDegrees: Int) {
            val currentTimestamp = System.currentTimeMillis()
            // 毎秒ごとに平均輝度を計算する
            if (currentTimestamp - lastAnalyzedTimestamp >=
                TimeUnit.SECONDS.toMillis(1)) {
                // ImageAnalysisはYUV形式なのでimage.planes[0]にはY (輝度) planeが格納されている
                val buffer = image.planes[0].buffer
                // callback objectからimage dataの抽出 
                val data = buffer.toByteArray()
                // pixel値の配列にデータを変換
                val pixels = data.map { it.toInt() and 0xFF }
                // imageの平均輝度の計算
                val luma = pixels.average()
                // 輝度のログ表示
                Log.d("CameraXApp", "Average luminosity: $luma")
                // 最後に分析したフレームのタイムスタンプに更新
                lastAnalyzedTimestamp = currentTimestamp
            }
        }
    }

    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)
        }

        // texture viewが変化した時にLayoutの再計算を行う
        viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
            updateTransform()
        }
    }

    private lateinit var viewFinder: TextureView

    private fun startCamera() {

        // viewfinder use caseのコンフィグレーションオブジェクトを生成
        val previewConfig = PreviewConfig.Builder().apply {
            setTargetAspectRatio(Rational(1, 1))
            setTargetResolution(Size(640, 640))
        }.build()

        // viewfinder use caseの生成
        val preview = Preview(previewConfig)

        // viewfinderが更新されたらLayoutを再計算
        preview.setOnPreviewOutputUpdateListener {

            // SurfaceTextureの更新して再度親Viewに追加する
            val parent = viewFinder.parent as ViewGroup
            parent.removeView(viewFinder)
            parent.addView(viewFinder, 0)

            viewFinder.surfaceTexture = it.surfaceTexture
            updateTransform()
        }

        // image capture use caseのコンフィグレーションオブジェクトを生成
        // Create configuration object for the image capture use case
        val imageCaptureConfig = ImageCaptureConfig.Builder()
            .apply {
                setTargetAspectRatio(Rational(1, 1))
                // イメージキャプチャの解像度は設定しない。
                // 代わりに、アスペクト比と要求されたモードに基づいて
                // 適切な解像度を推測するキャプチャモードを選択します。
                setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
            }.build()

        // image capture use caseの生成とボタンのClickリスナーの登録
        val imageCapture = ImageCapture(imageCaptureConfig)
        findViewById<ImageButton>(R.id.capture_button).setOnClickListener {
            val file = File(externalMediaDirs.first(),
                "${System.currentTimeMillis()}.jpg")
            imageCapture.takePicture(file,
                object : ImageCapture.OnImageSavedListener {
                    override fun onError(error: ImageCapture.UseCaseError,
                                         message: String, exc: Throwable?) {
                        val msg = "Photo capture failed: $message"
                        Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                        Log.e("CameraXApp", msg)
                        exc?.printStackTrace()
                    }

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

        // 平均輝度を計算するimage analysis pipelineのセットアップ
        val analyzerConfig = ImageAnalysisConfig.Builder().apply {
            // 不具合を防ぐためにワーカースレッドを使う
            val analyzerThread = HandlerThread(
                "LuminosityAnalysis").apply { start() }
            setCallbackHandler(Handler(analyzerThread.looper))
            // ここではすべての画像を分析するよりも、最新の画像を重視する
            setImageReaderMode(
                ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
        }.build()

        // image analysis use caseの生成とanalyzerのインスタンス生成
        val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
            analyzer = LuminosityAnalyzer()
        }

        // use caseをlifecycleにバインドする
        CameraX.bindToLifecycle(this, preview, imageCapture, analyzerUseCase)
    }

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

        // view finderの中心の計算
        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)

        // TextureViewへのセット
        viewFinder.setTransform(matrix)
    }

    /**
     * カメラのパーミッションリクエストダイアログ処理の結果を確認します
     * パーミッションが許可された場合はCameraを開始します
     * そうでない場合はToastを表示して終了します
     */
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                viewFinder.post { startCamera() }
            } else {
                Toast.makeText(this,
                    "パーミッションが許可されませんでした",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    /**
     * 定義されたパーミッションをチェックします
     */
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            this, it) == PackageManager.PERMISSION_GRANTED
    }
}
  • カメラを起動してキャプチャする
  • キャプチャした画像を分析して輝度を表示する

この二つがCamera2に比べてとても簡単に実装することができましたね。
正式リリースの際にはもう少し触ってみたいと思います。

Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です