In questo codelab, impareremo a creare un'app fotocamera che utilizza CameraX per mostrare un mirino, scattare foto e analizzare un flusso di immagini dalla fotocamera.
Per raggiungere questo obiettivo, introdurremo il concetto di casi d'uso in CameraX, che possono essere utilizzati per una serie di operazioni della fotocamera, 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 di anteprima)
- Come scattare una foto e salvarla nello spazio di archiviazione. (Caso d'uso di ImageCapture)
- Come analizzare i fotogrammi della videocamera in tempo reale. (Caso d'uso ImageAnalysis)
Hardware necessario
- Un dispositivo Android, anche se l'emulatore di Android Studio va benissimo. Il livello API minimo supportato è 21.
Software necessari
- Android Studio 3.3 o versioni successive.
Utilizzando il menu di Android Studio, avvia un nuovo progetto e seleziona Attività vuota quando richiesto.
Poi possiamo scegliere il nome che vogliamo. Noi abbiamo scelto "CameraX App". Dobbiamo assicurarci che il linguaggio sia impostato su Kotlin, che il livello API minimo sia 21 (il minimo richiesto per CameraX) e che utilizziamo gli artefatti AndroidX.
Per iniziare, aggiungiamo le dipendenze di CameraX al file Gradle dell'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 Sincronizza ora e CameraX sarà pronto per essere utilizzato nella nostra app.
Utilizzeremo una SurfaceTexture per visualizzare il mirino della fotocamera. In questo codelab, mostreremo il mirino in un formato quadrato di dimensioni fisse. Per un esempio più completo che mostra un mirino adattabile, consulta il campione ufficiale.
Modifichiamo 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 qualsiasi funzionalità nel nostro progetto che utilizza la fotocamera è la richiesta delle autorizzazioni CAMERA appropriate. Innanzitutto, dobbiamo dichiararli nel manifest, prima del tag Application:
<uses-permission android:name="android.permission.CAMERA" />
Poi, all'interno di MainActivity dobbiamo richiedere le autorizzazioni in fase di runtime. Apporteremo le modifiche nel file MainActivity in java > com.example.cameraxapp > MainActivity.kt:
Nella parte superiore del file, al di fuori della definizione della classe MainActivity, aggiungiamo le seguenti costanti e importazioni:
// 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 utilizzati per richiedere le autorizzazioni e attivare il nostro codice una volta che sappiamo che tutte le autorizzazioni sono state concesse:
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, mettiamo tutto insieme all'interno di onCreate per attivare la richiesta di autorizzazione quando è appropriato:
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()
}
}
Ora, all'avvio, l'applicazione controllerà di disporre delle autorizzazioni della fotocamera appropriate. In questo caso, chiamerà direttamente `startCamera()`. In caso contrario, richiederà le autorizzazioni e, una volta concesse, chiamerà `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 nel posto giusto. Un mirino può essere implementato utilizzando la classe `Preview` 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 all'interno del metodo `startCamera()`; completa 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 le modifiche all'orientamento del dispositivo per visualizzare il mirino in posizione 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 vedere cos'altro deve essere gestito. Per brevità, in questo codelab prenderemo alcune scorciatoie. Ad esempio, non teniamo traccia di alcune modifiche alla configurazione, come le rotazioni del dispositivo di 180 gradi, che non attivano il nostro listener di modifica del layout. I mirini non quadrati devono anche compensare il cambiamento delle proporzioni quando il dispositivo ruota.
Se creiamo ed eseguiamo l'app, ora dovremmo vedere un'anteprima live. Bene!
Per consentire agli utenti di acquisire 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" />
Gli altri casi d'uso funzionano in modo molto simile all'anteprima. Innanzitutto, dobbiamo definire un oggetto di configurazione che viene utilizzato per creare un'istanza dell'oggetto caso d'uso effettivo. Per scattare 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)
Ed ecco che abbiamo implementato un pulsante funzionale per scattare foto.
Una funzionalità molto interessante di CameraX è la classe ImageAnalysis. Ci consente di definire una classe personalizzata che implementa l'interfaccia ImageAnalysis.Analyzer, che verrà chiamata con i frame della fotocamera in arrivo. In linea con la visione principale di CameraX, non dovremo preoccuparci di gestire lo stato della sessione della fotocamera o persino di eliminare le immagini. È sufficiente il binding al ciclo di vita desiderato della nostra app, come per gli altri componenti sensibili al ciclo di vita.
Innanzitutto, implementeremo un analizzatore di immagini personalizzato. Il nostro analizzatore è piuttosto semplice: registra solo la luminanza media dell'immagine, ma esemplifica ciò che deve essere fatto per casi d'uso arbitrariamente complessi. Tutto ciò che dobbiamo fare è eseguire l'override della funzione `analyze` in una classe che implementa l'interfaccia ImageAnalysis.Analyzer. Possiamo definire la nostra implementazione come classe interna all'interno di 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 implementa l'interfaccia ImageAnalysis.Analyzer, tutto ciò che dobbiamo fare è creare un'istanza di ImageAnalysis come in tutti gli altri casi d'uso e aggiornare nuovamente la funzione `startCamera()` prima della chiamata 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 associare il nuovo caso d'uso:
CameraX.bindToLifecycle(
this, preview, imageCapture, analyzerUseCase)
Se esegui l'app ora, in logcat verrà visualizzato un messaggio simile a questo circa ogni secondo:
D/CameraXApp: Average luminosity: ...
Per testare l'app, è sufficiente fare clic sul pulsante Esegui in Android Studio e il progetto verrà creato, distribuito e avviato nel dispositivo o nell'emulatore selezionato. Una volta caricata l'app, dovremmo vedere il mirino, che rimarrà verticale anche dopo aver ruotato il dispositivo grazie al codice di gestione dell'orientamento che abbiamo aggiunto in precedenza. Dovremmo anche essere in grado di scattare foto utilizzando il pulsante:
Hai completato il codelab. Se guardi indietro, hai implementato quanto segue in una nuova app per Android da zero:
- Sono state incluse le dipendenze di CameraX nel tuo progetto.
- Visualizzazione di un mirino della fotocamera (utilizzando il caso d'uso Anteprima)
- Implementazione dell'acquisizione di foto, salvataggio delle immagini nello spazio di archiviazione (utilizzando lo scenario d'uso ImageCapture)
- Implementata l'analisi dei frame della videocamera in tempo reale (utilizzando il caso d'uso ImageAnalysis)
Se ti interessa saperne di più su CameraX e sulle cose che puoi fare, consulta la documentazione o clona l'esempio ufficiale.