Erste Schritte mit CameraX

In diesem Codelab erfahren Sie, wie Sie eine Kamera-App erstellen, die CameraX verwendet, um einen Sucher anzuzeigen, Fotos aufzunehmen und einen Bildstream von der Kamera zu analysieren.

Dazu führen wir das Konzept von Anwendungsfällen in CameraX ein, das für eine Vielzahl von Kameravorgängen verwendet werden kann, von der Anzeige eines Suchers bis zur Analyse von Frames in Echtzeit.

Lerninhalte

  • CameraX-Abhängigkeiten hinzufügen
  • So wird die Kameravorschau in einer Aktivität angezeigt. (Anwendungsfall in der Vorschau ansehen)
  • So nehmen Sie ein Foto auf und speichern es. (ImageCapture-Anwendungsfall)
  • So analysieren Sie Bilder von der Kamera in Echtzeit. (Anwendungsfall „Bildanalyse“)

Erforderliche Hardware

  • Ein Android-Gerät. Der Emulator von Android Studio ist aber auch ausreichend. Das unterstützte Mindest-API-Level ist 21.

Erforderliche Software

  • Android Studio ab Version 3.3.

Starten Sie über das Android Studio-Menü ein neues Projekt und wählen Sie bei Aufforderung Empty Activity (Leere Aktivität) aus.

Als Nächstes können wir einen beliebigen Namen auswählen. Wir haben uns für „CameraX App“ entschieden. Wir sollten darauf achten, dass die Sprache auf Kotlin eingestellt ist, das minimale API-Level 21 (das für CameraX erforderlich ist) und dass wir AndroidX-Artefakte verwenden.

Fügen wir zuerst die CameraX-Abhängigkeiten in der Gradle-Datei unserer App im Abschnitt dependencies hinzu:

// Use the most recent version of CameraX, currently that is alpha04
def camerax_version = "1.0.0-alpha04"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"

Klicken Sie bei Aufforderung auf Jetzt synchronisieren. CameraX kann dann in Ihrer App verwendet werden.

Wir verwenden eine SurfaceTexture, um den Sucher der Kamera anzuzeigen. In diesem Codelab wird der Sucher in einem quadratischen Format mit fester Größe angezeigt. Ein umfassenderes Beispiel mit einem responsiven Sucher finden Sie im offiziellen Beispiel.

Bearbeiten wir die Layoutdatei „activity_main“ unter „res“ > „layout“ > „activity_main.xml“:

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

Ein wichtiger Teil des Hinzufügens von Funktionen in unserem Projekt, die die Kamera verwenden, ist das Anfordern der entsprechenden KAMERA-Berechtigungen. Zuerst müssen wir sie im Manifest vor dem Application-Tag deklarieren:

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

In MainActivity müssen wir dann Berechtigungen zur Laufzeit anfordern. Wir nehmen die Änderungen in der Datei „MainActivity“ unter „java“ > „com.example.cameraxapp“ > „MainActivity.kt“ vor:

Fügen Sie oben in der Datei, außerhalb der Definition der MainActivity-Klasse, die folgenden Konstanten und Importe hinzu:

// Your IDE likely can auto-import these classes, but there are several
// different implementations so we list them here to disambiguate
import android.Manifest
import android.util.Size
import android.graphics.Matrix
import java.util.concurrent.TimeUnit

// This is an arbitrary number we are using to keep tab of the permission
// request. Where an app has multiple context for requesting permission,
// this can help differentiate the different contexts
private const val REQUEST_CODE_PERMISSIONS = 10

// This is an array of all the permission specified in the manifest
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)

Fügen Sie in der Klasse „MainActivity“ die folgenden Felder und Hilfsmethoden hinzu, die zum Anfordern von Berechtigungen und zum Auslösen unseres Codes verwendet werden, sobald alle Berechtigungen erteilt wurden:

class MainActivity : AppCompatActivity(), LifecycleOwner {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    }

    // Add this after onCreate

    private lateinit var viewFinder: TextureView

    private fun startCamera() {
        // TODO: Implement CameraX operations
    }

    private fun updateTransform() {
        // TODO: Implement camera viewfinder transformations
    }

    /**
     * Process result from permission request dialog box, has the request
     * been granted? If yes, start Camera. Otherwise display a 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,
                    "Permissions not granted by the user.", 
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    /**
     * Check if all permission specified in the manifest have been granted
     */
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
               baseContext, it) == PackageManager.PERMISSION_GRANTED
    }
}

Schließlich fügen wir alles in onCreate ein, um die Berechtigungsanfrage bei Bedarf auszulösen:

override fun onCreate(savedInstanceState: Bundle?) {
    ...

    // Add this at the end of onCreate function

    viewFinder = findViewById(R.id.view_finder)

    // Request camera permissions
    if (allPermissionsGranted()) {
        viewFinder.post { startCamera() }
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
    }

    // Every time the provided texture view changes, recompute layout
    viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
        updateTransform()
    }
}

Beim Start der App wird nun geprüft, ob sie die erforderlichen Kameraberechtigungen hat. Falls ja, wird `startCamera()` direkt aufgerufen. Andernfalls werden die Berechtigungen angefordert und nach der Erteilung wird `startCamera()` aufgerufen.

Für die meisten Kameraanwendungen ist es sehr wichtig, den Nutzern einen Sucher zu präsentieren, da es sonst sehr schwierig ist, die Kamera auf den richtigen Ort auszurichten. Ein Sucher kann mit der CameraX-Klasse „Preview“ implementiert werden.

Wenn Sie die Vorschaufunktion verwenden möchten, müssen Sie zuerst eine Konfiguration definieren, die dann zum Erstellen einer Instanz des Anwendungsfalls verwendet wird. Die resultierende Instanz ist das, was wir an den CameraX-Lebenszyklus binden müssen. Das machen wir in der Methode `startCamera()`. Füllen Sie die Implementierung mit diesem Code aus:

private fun startCamera() {

    // Create configuration object for the viewfinder use case
    val previewConfig = PreviewConfig.Builder().apply {
        setTargetAspectRatio(Rational(1, 1))
        setTargetResolution(Size(640, 640))
    }.build()

    // Build the viewfinder use case
    val preview = Preview(previewConfig)

    // Every time the viewfinder is updated, recompute layout
    preview.setOnPreviewOutputUpdateListener {

        // To update the SurfaceTexture, we have to remove it and re-add it
        val parent = viewFinder.parent as ViewGroup
        parent.removeView(viewFinder)
        parent.addView(viewFinder, 0)

        viewFinder.surfaceTexture = it.surfaceTexture
        updateTransform()
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview)
}

Jetzt müssen wir die mysteriöse Methode `updateTransform()` implementieren. In `updateTransform()` soll die Änderung der Geräteausrichtung ausgeglichen werden, damit der Sucher aufrecht angezeigt wird:

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

    // Compute the center of the view finder
    val centerX = viewFinder.width / 2f
    val centerY = viewFinder.height / 2f

    // Correct preview output to account for display rotation
    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)

    // Finally, apply transformations to our TextureView
    viewFinder.setTransform(matrix)
}

Wenn Sie eine produktionsreife App implementieren möchten, sehen Sie sich das offizielle Beispiel an, um zu erfahren, was Sie noch beachten müssen. Damit dieses Codelab nicht zu lang wird, nehmen wir einige Abkürzungen. Beispielsweise werden einige Konfigurationsänderungen wie 180‑Grad-Drehungen des Geräts nicht erfasst, da sie unseren Listener für Layoutänderungen nicht auslösen. Nicht quadratische Sucher müssen auch das Seitenverhältnis anpassen, wenn das Gerät gedreht wird.

Wenn wir die App erstellen und ausführen, sollte jetzt eine Live-Vorschau angezeigt werden. Sehr gut!

Damit Nutzer Bilder aufnehmen können, stellen wir nach der TextureView im Layout unter „res“ > „layout“ > „activity_main.xml“ eine Schaltfläche bereit:

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

Andere Anwendungsfälle funktionieren sehr ähnlich wie die Vorschau. Zuerst müssen wir ein Konfigurationsobjekt definieren, das zum Instanziieren des eigentlichen Anwendungsfallobjekts verwendet wird. Wenn die Aufnahmetaste gedrückt wird, müssen wir die Methode `startCamera()` aktualisieren und am Ende einige weitere Codezeilen hinzufügen, bevor wir `CameraX.bindToLifecycle` aufrufen:

private fun startCamera() {

    ...

    // Add this before CameraX.bindToLifecycle

    // Create configuration object for the image capture use case
    val imageCaptureConfig = ImageCaptureConfig.Builder()
        .apply {
            setTargetAspectRatio(Rational(1, 1))
            // We don't set a resolution for image capture; instead, we
            // select a capture mode which will infer the appropriate
            // resolution based on aspect ration and requested mode
            setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
    }.build()

    // Build the image capture use case and attach button click listener
    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)
            }
        })
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview)
}

Aktualisieren Sie dann den Aufruf von CameraX.bindToLifecycle, um den neuen Anwendungsfall einzubeziehen:

CameraX.bindToLifecycle(this, preview, imageCapture)

So haben wir eine funktionale Schaltfläche zum Aufnehmen von Fotos implementiert.

Eine sehr interessante Funktion von CameraX ist die Klasse „ImageAnalysis“. So können wir eine benutzerdefinierte Klasse definieren, die die ImageAnalysis.Analyzer-Schnittstelle implementiert und mit eingehenden Kamerabildern aufgerufen wird. Entsprechend der grundlegenden Vision von CameraX müssen wir uns nicht um die Verwaltung des Kamera-Sitzungsstatus oder das Verwerfen von Bildern kümmern. Es reicht aus, die Bindung an den gewünschten Lebenszyklus unserer App vorzunehmen, wie bei anderen lebenszyklusbewussten Komponenten.

Zuerst implementieren wir einen benutzerdefinierten Bildanalysator. Unser Analysetool ist recht einfach. Es protokolliert nur die durchschnittliche Helligkeit des Bildes, veranschaulicht aber, was für beliebig komplexe Anwendungsfälle erforderlich ist. Dazu müssen wir nur die Funktion „analyze“ in einer Klasse überschreiben, die die ImageAnalysis.Analyzer-Schnittstelle implementiert. Wir können unsere Implementierung als innere Klasse in MainActivity definieren:

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

    /**
     * Helper extension function used to extract a byte array from an
     * image plane buffer
     */
    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()    // Rewind the buffer to zero
        val data = ByteArray(remaining())
        get(data)   // Copy the buffer into a byte array
        return data // Return the byte array
    }

    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val currentTimestamp = System.currentTimeMillis()
        // Calculate the average luma no more often than every second
        if (currentTimestamp - lastAnalyzedTimestamp >=
            TimeUnit.SECONDS.toMillis(1)) {
            // Since format in ImageAnalysis is YUV, image.planes[0]
            // contains the Y (luminance) plane
            val buffer = image.planes[0].buffer
            // Extract image data from callback object
            val data = buffer.toByteArray()
            // Convert the data into an array of pixel values
            val pixels = data.map { it.toInt() and 0xFF }
            // Compute average luminance for the image
            val luma = pixels.average()
            // Log the new luma value
            Log.d("CameraXApp", "Average luminosity: $luma")
            // Update timestamp of last analyzed frame
            lastAnalyzedTimestamp = currentTimestamp
        }
    }
}

Da unsere Klasse die ImageAnalysis.Analyzer-Schnittstelle implementiert, müssen wir ImageAnalysis nur wie alle anderen Anwendungsfälle instanziieren und die Funktion „startCamera()“ vor dem Aufruf von CameraX.bindToLifecycle noch einmal aktualisieren:

private fun startCamera() {

    ...

    // Add this before CameraX.bindToLifecycle

    // Setup image analysis pipeline that computes average pixel luminance
    val analyzerConfig = ImageAnalysisConfig.Builder().apply {
        // Use a worker thread for image analysis to prevent glitches
        val analyzerThread = HandlerThread(
            "LuminosityAnalysis").apply { start() }
        setCallbackHandler(Handler(analyzerThread.looper))
        // In our analysis, we care more about the latest image than
        // analyzing *every* image
        setImageReaderMode(
            ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
    }.build()

    // Build the image analysis use case and instantiate our analyzer
    val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
        analyzer = LuminosityAnalyzer()
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview, imageCapture)
}

Außerdem aktualisieren wir den Aufruf von CameraX.bindtoLifecycle, um den neuen Anwendungsfall zu binden:

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

Wenn Sie die App jetzt ausführen, wird in logcat etwa jede Sekunde eine Meldung wie diese angezeigt:

D/CameraXApp: Average luminosity: ...

Um die App zu testen, müssen wir nur in Android Studio auf die Schaltfläche Run (Ausführen) klicken. Unser Projekt wird dann auf dem ausgewählten Gerät oder Emulator erstellt, bereitgestellt und gestartet. Nachdem die App geladen wurde, sollte der Sucher angezeigt werden. Dieser bleibt dank des zuvor hinzugefügten Codes zur Verarbeitung der Ausrichtung auch nach dem Drehen des Geräts aufrecht. Außerdem sollten Sie mit der Schaltfläche Fotos aufnehmen können:

Sie haben das Codelab erfolgreich abgeschlossen. Sie haben Folgendes in eine neue Android-App implementiert:

  • Sie haben CameraX-Abhängigkeiten in Ihr Projekt aufgenommen.
  • Es wurde ein Kamerasucher angezeigt (mit dem Anwendungsfall „Vorschau“).
  • Implementierung der Fotoaufnahme und des Speicherns von Bildern (mit dem ImageCapture-Anwendungsfall)
  • Analyse von Bildern der Kamera in Echtzeit implementiert (mit dem Anwendungsfall „ImageAnalysis“)

Wenn Sie mehr über CameraX und die Möglichkeiten, die sich damit bieten, erfahren möchten, sehen Sie sich die Dokumentation an oder klonen Sie das offizielle Beispiel.