Introduzione a CameraX

In questo codelab, scopriremo come creare un'app Fotocamera che utilizzi CameraX per mostrare un mirino, scattare foto e analizzare uno stream di immagini dalla fotocamera.

A tale scopo, introdurremo il concetto di caso d'uso in CameraX, che può essere utilizzato per varie operazioni, dalla visualizzazione di un mirino all'analisi dei frame in tempo reale.

Cosa impareremo

  • Come aggiungere le dipendenze CameraX.
  • Come visualizzare l'anteprima della videocamera in un'attività. (Caso d'uso in anteprima)
  • Come scattare una foto salvandola in uno spazio di archiviazione. (Caso d'uso di Image Capture)
  • Come analizzare i frame dalla fotocamera in tempo reale. (Caso d'uso di ImageAnalisi)

Hardware di cui abbiamo bisogno

  • Un dispositivo Android, anche se l'emulatore di Android Studio farà tutto bene. Il livello minimo supportato per l'API è 21.

Software di cui abbiamo bisogno

  • Android Studio 3.3 o versioni successive.

Utilizzando il menu di Android Studio, avvia un nuovo progetto e seleziona Attività vuota quando richiesto.

Quindi, possiamo scegliere il nome che preferisci: abbiamo scelto in modo ingegnoso "App FotocameraX". Dovremo assicurarci che la lingua sia impostata su Kotlin, il livello minimo dell'API è 21 (che è il minimo richiesto per CameraX) e che utilizziamo gli artefatti di AndroidX.

Per iniziare, aggiungi le dipendenze CameraX al file Gradle della nostra app, all'interno della sezione dependencies:

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

Quando richiesto, fai clic su Sync Now (Sincronizza ora) per iniziare a utilizzare CameraX nella nostra app.

Utilizzeremo un SurfaceTexture per visualizzare il mirino della fotocamera. In questo codelab, mostreremo il mirino in un formato quadrato con dimensioni fisse. Per un esempio più completo che mostra un mirino adattabile, consulta l'esempio ufficiale.

Modifica il file di layout activity_main in 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>

Una parte fondamentale dell'aggiunta di funzionalità al nostro progetto che utilizza la fotocamera richiede le autorizzazioni CAMERA appropriate. Prima devi dichiararle nel file manifest, prima del tag dell'applicazione:

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

Quindi, all'interno di MainActivity dobbiamo richiedere le autorizzazioni in fase di esecuzione. Applicheremo le modifiche al file MainActivity in java > com.example.cameraxapp > MainActivity.kt:

Nella parte superiore del file, all'esterno della definizione della classe MainActivity, aggiungi le seguenti costanti:

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

All'interno della classe MainActivity, aggiungi i seguenti campi e metodi helper per richiedere le autorizzazioni e attivare il nostro codice non appena sappiamo che sono state concesse tutte le autorizzazioni:

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

Infine, abbiamo messo tutti i componenti all'interno di onCreate per attivare la richiesta di autorizzazione quando opportuno:

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

All'avvio, l'app verifica se dispone delle autorizzazioni appropriate per la fotocamera. Se lo fa, chiamerà direttamente "startCamera()". In caso contrario, richiederà le autorizzazioni e, una volta concesse, chiama "startCamera()".

Per la maggior parte delle applicazioni della fotocamera, mostrare un mirino agli utenti è molto importante, altrimenti è molto difficile per gli utenti puntare la fotocamera verso il posto giusto. Un mirino può essere implementato utilizzando la classe "Anteprima" di CameraX.

Per utilizzare l'anteprima, dobbiamo prima definire una configurazione che verrà poi utilizzata per creare un'istanza del caso d'uso. L'istanza risultante è ciò che dobbiamo associare al ciclo di vita di CameraX. Lo faremo utilizzando il metodo "startCamera()"; compila l'implementazione con questo codice:

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

A questo punto, dobbiamo implementare il misterioso metodo "updateTransform()". All'interno di "updateTransform()" l'obiettivo è compensare i cambiamenti nell'orientamento del dispositivo per mostrare il nostro mirino in rotazione verticale:

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

Per implementare un'app pronta per la produzione, dai un'occhiata all'esempio ufficiale per scoprire quali altri elementi devono essere gestiti. Per mantenere breve questo codelab, stiamo adottando alcune scorciatoie. Ad esempio, non teniamo traccia di alcune modifiche alla configurazione come la rotazione di 180 gradi, che non attivano il listener del cambio di layout. I mirino non quadrati devono anche compensare le variazioni delle proporzioni quando il dispositivo ruota.

Se creiamo ed eseguiamo l'app, dovremmo vedere un'anteprima della durata. Bene!

Per consentire agli utenti di acquisire le immagini, forniremo un pulsante come parte del layout dopo la visualizzazione della texture in res > layout > 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" />

Altri casi d'uso funzionano in modo molto simile all'anteprima. Innanzitutto, dobbiamo definire un oggetto di configurazione utilizzato per creare un'istanza dell'oggetto del caso d'uso effettivo. Per acquisire le foto, quando viene premuto il pulsante di acquisizione, dobbiamo aggiornare il metodo "startCamera()" e aggiungere qualche altra riga di codice alla fine, prima della chiamata a CameraX.bindToLifecycle:

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

Quindi, aggiorna la chiamata a CameraX.bindToLifecycle per includere il nuovo caso d'uso:

CameraX.bindToLifecycle(this, preview, imageCapture)

E in questo modo abbiamo implementato un pulsante per scattare foto.

Una funzionalità molto interessante di CameraX è la classe ImageAnalysis. Ci permette di definire una classe personalizzata che implementa l'interfaccia ImageAnalysis.Analizzar, che viene chiamata con i frame della fotocamera in arrivo. In linea con la vision fondamentale di CameraX, non dobbiamo preoccuparci di gestire lo stato della sessione della videocamera o persino di smaltire le immagini; associare al ciclo di vita desiderato della nostra app è sufficiente come altri componenti sensibili al ciclo di vita.

Innanzitutto, implementeremo un analizzatore di immagini personalizzato. Il nostro analizzatore sintattico è semplice: registra solo la lumana media (luminosità) dell'immagine, ma è un esempio di ciò che è necessario fare per casi d'uso arbitrariamente complessi. Dobbiamo solo sostituire la funzione di analisi in una classe che implementa l'interfaccia di ImageAnalysis.Analizzar. Possiamo definire la nostra implementazione come classe interna in MainActivity:

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

Con la nostra classe che ha implementato l'interfaccia di ImageAnalysis.Analizzar, è sufficiente creare un'istanza di ImageAnalysis come tutti gli altri casi d'uso e aggiornare nuovamente la funzione "startCamera()" prima del richiamo a CameraX.bindToLifecycle:

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

Inoltre, aggiorniamo la chiamata a CameraX.bindtoLifecycle per vincolare il nuovo caso d'uso:

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

Ora, l'esecuzione dell'app produce un messaggio simile a questo in logcat circa ogni secondo:

D/CameraXApp: Average luminosity: ...

Per testare l'app, è sufficiente fare clic sul pulsante Esegui in Android Studio e il nostro progetto verrà creato, sottoposto a deployment e lanciato nel dispositivo o emulatore selezionato. Una volta caricata l'app, dovremmo vedere il mirino, che rimane in posizione verticale anche dopo aver ruotato il dispositivo grazie al codice di gestione dell'orientamento che abbiamo aggiunto in precedenza e che dovrebbe essere in grado di scattare foto utilizzando il pulsante:

Hai completato il lab del codice e hai completato correttamente l'operazione. Guardando al passato, hai implementato da zero i seguenti elementi in una nuova app Android:

  • Dipendenze CameraX incluse nel progetto.
  • Mirino della fotocamera visualizzato (utilizzando il caso d'uso Anteprima)
  • Implementata acquisizione foto, salvataggio delle immagini nello spazio di archiviazione (tramite il caso d'uso di Image Capture)
  • Implementata analisi dei frame della fotocamera in tempo reale (utilizzando il caso d'uso di ImageAnalysis)

Se ti interessa leggere ulteriori informazioni su CameraX e sulle operazioni che puoi eseguire, consulta la documentazione o clona il campione ufficiale.