Z tego modułu dowiesz się, jak utworzyć aplikację aparatu, która korzysta z CameraX, aby wyświetlać wizjer, robić zdjęcia i analizować strumień obrazu z aparatu.
W tym celu wprowadzimy w CameraX koncepcję przypadków użycia, które można wykorzystywać do różnych operacji związanych z aparatem, od wyświetlania wizjera po analizowanie klatek w czasie rzeczywistym.
Czego się nauczymy
- Jak dodać zależności CameraX.
- Jak wyświetlić podgląd z kamery w aktywności. (Przypadek użycia podglądu)
- Jak zrobić zdjęcie i zapisać je w pamięci. (przypadek użycia ImageCapture)
- Jak analizować klatki z kamery w czasie rzeczywistym. (przypadek użycia ImageAnalysis)
Potrzebny sprzęt
- Urządzenie z Androidem, chociaż emulator Androida Studio też się sprawdzi. Minimalny obsługiwany poziom interfejsu API to 21.
Oprogramowanie, którego będziemy potrzebować
- Android Studio w wersji 3.3 lub nowszej.
W menu Android Studio rozpocznij nowy projekt i gdy pojawi się odpowiedni komunikat, wybierz Empty Activity (Pusta aktywność).
Następnie możemy wybrać dowolną nazwę – my wybraliśmy „CameraX App”. Upewnij się, że język jest ustawiony na Kotlin, minimalny poziom API to 21 (minimalny wymagany dla CameraX) i że używasz artefaktów AndroidX.
Zacznijmy od dodania zależności CameraX do pliku Gradle aplikacji w sekcji 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}"
Gdy pojawi się odpowiedni komunikat, kliknij Synchronizuj teraz. W ten sposób przygotujemy CameraX do użycia w naszej aplikacji.
Do wyświetlania wizjera aparatu będziemy używać obiektu SurfaceTexture. W tym laboratorium pokażemy wizjer w kwadratowym formacie o stałym rozmiarze. Bardziej szczegółowy przykład, który pokazuje elastyczny wizjer, znajdziesz w oficjalnym przykładzie.
Edytujmy plik układu activity_main w folderze 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>
Kluczowym elementem dodawania do projektu dowolnej funkcji korzystającej z aparatu jest poproszenie o odpowiednie uprawnienia CAMERA. Najpierw musimy zadeklarować je w pliku manifestu przed tagiem Application:
<uses-permission android:name="android.permission.CAMERA" />
Następnie w pliku MainActivity musimy poprosić o uprawnienia w czasie działania. Zmiany wprowadzimy w pliku MainActivity w folderze java > com.example.cameraxapp > MainActivity.kt:
U góry pliku, poza definicją klasy MainActivity, dodajmy te stałe i instrukcje importu:
// 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)
W klasie MainActivity dodaj te pola i metody pomocnicze, które służą do wysyłania próśb o uprawnienia i wywoływania naszego kodu, gdy wiemy, że wszystkie uprawnienia zostały przyznane:
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
}
}
Na koniec łączymy wszystko w metodzie onCreate, aby w odpowiednim momencie wywołać prośbę o uprawnienia:
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()
}
}
Teraz, gdy aplikacja się uruchomi, sprawdzi, czy ma odpowiednie uprawnienia do korzystania z aparatu. Jeśli tak, wywoła bezpośrednio funkcję `startCamera()`. W przeciwnym razie poprosi o uprawnienia, a po ich przyznaniu wywoła funkcję `startCamera()`.
W większości aplikacji aparatu wyświetlanie wizjera jest bardzo ważne, ponieważ w przeciwnym razie użytkownikom trudno jest skierować aparat w odpowiednie miejsce. Wizjer można zaimplementować za pomocą klasy CameraX `Preview`.
Aby użyć podglądu, musimy najpierw zdefiniować konfigurację, która zostanie następnie użyta do utworzenia instancji przypadku użycia. Wynikowa instancja jest potrzebna do powiązania z cyklem życia CameraX. Zrobimy to w metodzie `startCamera()`. Wypełnij implementację tym kodem:
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)
}
W tym momencie musimy zaimplementować tajemniczą metodę `updateTransform()`. Funkcja `updateTransform()` ma na celu kompensowanie zmian orientacji urządzenia, aby wyświetlać wizjer w pionie:
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)
}
Aby wdrożyć aplikację gotową do produkcji, zapoznaj się z oficjalnym przykładem i sprawdź, co jeszcze musisz zrobić. Aby te ćwiczenia nie były zbyt długie, zastosujemy kilka skrótów. Nie śledzimy na przykład niektórych zmian konfiguracji, takich jak obracanie urządzenia o 180 stopni, które nie wywołują naszego odbiornika zmian układu. Wizjery inne niż kwadratowe muszą też kompensować zmianę współczynnika proporcji po obróceniu urządzenia.
Jeśli skompilujemy i uruchomimy aplikację, powinniśmy zobaczyć podgląd na żywo. Super!
Aby umożliwić użytkownikom robienie zdjęć, udostępnimy przycisk w układzie po widoku tekstury w folderze 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" />
Inne przypadki użycia działają w bardzo podobny sposób jak podgląd. Najpierw musimy zdefiniować obiekt konfiguracji, który będzie używany do tworzenia instancji rzeczywistego obiektu przypadku użycia. Aby robić zdjęcia, po naciśnięciu przycisku robienia zdjęć musimy zaktualizować metodę `startCamera()` i dodać kilka wierszy kodu na końcu, przed wywołaniem 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)
}
Następnie zaktualizuj wywołanie CameraX.bindToLifecycle, aby uwzględnić nowy przypadek użycia:
CameraX.bindToLifecycle(this, preview, imageCapture)
W ten sposób zaimplementowaliśmy działający przycisk robienia zdjęć.
Bardzo interesującą funkcją CameraX jest klasa ImageAnalysis. Umożliwia to zdefiniowanie niestandardowej klasy implementującej interfejs ImageAnalysis.Analyzer, która będzie wywoływana z przychodzącymi klatkami z kamery. Zgodnie z główną ideą CameraX nie musimy się martwić zarządzaniem stanem sesji aparatu ani nawet usuwaniem obrazów. Wystarczy powiązać go z żądanym cyklem życia aplikacji, tak jak w przypadku innych komponentów uwzględniających cykl życia.
Najpierw wdrożymy niestandardowy analizator obrazów. Nasz analizator jest dość prosty – rejestruje tylko średnią jasność obrazu, ale pokazuje, co należy zrobić w przypadku dowolnie złożonych zastosowań. Wystarczy zastąpić funkcję „analyze” w klasie, która implementuje interfejs ImageAnalysis.Analyzer. Naszą implementację możemy zdefiniować jako klasę wewnętrzną w klasie 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
}
}
}
W naszej klasie implementującej interfejs ImageAnalysis.Analyzer wystarczy utworzyć instancję ImageAnalysis tak jak w przypadku innych przypadków użycia i ponownie zaktualizować funkcję `startCamera()` przed wywołaniem 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)
}
Aktualizujemy też wywołanie CameraX.bindtoLifecycle, aby powiązać nowy przypadek użycia:
CameraX.bindToLifecycle(
this, preview, imageCapture, analyzerUseCase)
Uruchomienie aplikacji spowoduje wyświetlanie w logcat komunikatu podobnego do tego co sekundę:
D/CameraXApp: Average luminosity: ...
Aby przetestować aplikację, wystarczy kliknąć przycisk Uruchom w Android Studio. Projekt zostanie skompilowany, wdrożony i uruchomiony na wybranym urządzeniu lub emulatorze. Po wczytaniu aplikacji powinien pojawić się wizjer, który pozostanie w pozycji pionowej nawet po obróceniu urządzenia dzięki dodanemu wcześniej kodowi obsługi orientacji. Powinna też być możliwość robienia zdjęć za pomocą przycisku:
Moduł został ukończony. W nowej aplikacji na Androida od zera zaimplementowano te elementy:
- Uwzględnij w projekcie zależności CameraX.
- Wyświetlanie wizjera aparatu (w przypadku podglądu)
- Wdrożono robienie zdjęć i zapisywanie obrazów w pamięci (za pomocą funkcji ImageCapture).
- Wdrożono analizę klatek z kamery w czasie rzeczywistym (za pomocą funkcji ImageAnalysis).
Jeśli chcesz dowiedzieć się więcej o CameraX i możliwościach tego interfejsu, zapoznaj się z dokumentacją lub sklonuj oficjalny przykład.