Начало работы с CameraX

В этой лабораторной работе мы научимся создавать приложение камеры, которое использует CameraX для отображения видоискателя, съемки фотографий и анализа потока изображений с камеры.

Для достижения этой цели мы представим концепцию вариантов использования в CameraX, которую можно применять для различных операций камеры: от отображения видоискателя до анализа кадров в реальном времени.

Что мы узнаем

  • Как добавить зависимости CameraX.
  • Как отобразить предварительный просмотр камеры в действии. (Вариант использования предварительного просмотра)
  • Как сделать фотографию и сохранить ее в хранилище (пример использования ImageCapture)
  • Как анализировать кадры с камеры в режиме реального времени (пример использования ImageAnalysis)

Оборудование, которое нам понадобится

  • Устройство на Android, хотя эмулятор Android Studio тоже подойдёт. Минимальный поддерживаемый уровень API — 21.

Программное обеспечение, которое нам понадобится

  • Android Studio 3.3 или выше.

Используя меню Android Studio, начните новый проект и при появлении запроса выберите «Очистить активность» .

Далее мы можем выбрать любое имя — мы изобретательно выбрали «CameraX App». Необходимо убедиться, что выбран язык Kotlin, минимальный уровень API — 21 (это минимально необходимый минимум для CameraX), и что используются артефакты AndroidX.

Для начала давайте добавим зависимости CameraX в файл Gradle нашего приложения в раздел зависимостей :

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

При появлении запроса нажмите «Синхронизировать сейчас» , и мы будем готовы использовать CameraX в нашем приложении.

Мы будем использовать SurfaceTexture для отображения видоискателя камеры. В этой лабораторной работе мы отобразим видоискатель в квадрате фиксированного размера. Более подробный пример адаптивного видоискателя можно найти в официальном примере .

Давайте отредактируем файл макета activity_main в 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>

Важнейшей частью добавления любой функциональности, использующей камеру, в наш проект является запрос соответствующих разрешений CAMERA. Сначала необходимо объявить их в манифесте перед тегом Application:

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

Затем внутри MainActivity нам нужно запросить разрешения во время выполнения. Изменения будут внесены в файл MainActivity в разделе java > com.example.cameraxapp > MainActivity.kt:

В верхней части файла, за пределами определения класса MainActivity, добавим следующие константы и импорты:

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

Внутри класса MainActivity добавьте следующие поля и вспомогательные методы, которые используются для запроса разрешений и запуска нашего кода, как только мы узнаем, что все разрешения предоставлены:

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

Наконец, мы объединяем все внутри onCreate, чтобы инициировать запрос на разрешение при необходимости:

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

Теперь при запуске приложение проверит наличие соответствующих разрешений на доступ к камере. Если да, оно напрямую вызовет `startCamera()`. В противном случае оно запросит разрешения и, получив их, вызовет `startCamera()`.

Для большинства приложений, использующих камеру, крайне важно, чтобы пользователю был видеовизуализатор, иначе ему будет сложно направить камеру в нужное место. Видоискатель можно реализовать с помощью класса CameraX `Preview`.

Чтобы использовать Preview, необходимо сначала определить конфигурацию, которая затем будет использована для создания экземпляра варианта использования. Полученный экземпляр нужно привязать к жизненному циклу CameraX. Мы сделаем это в методе `startCamera()`; заполним реализацию следующим кодом:

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

На этом этапе нам нужно реализовать загадочный метод `updateTransform()`. Целью `updateTransform()` является компенсация изменений ориентации устройства для отображения видоискателя в вертикальном положении:

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

Чтобы реализовать готовое к использованию приложение, взгляните на официальный пример, чтобы понять, что ещё необходимо учесть. Чтобы сократить объём этой лабораторной работы, мы используем несколько сокращений. Например, мы не отслеживаем некоторые изменения конфигурации, такие как поворот устройства на 180 градусов, которые не активируют наш прослушиватель изменений макета. Неквадратные видоискатели также должны компенсировать изменение соотношения сторон при повороте устройства.

Если мы соберём и запустим приложение, мы увидим предварительный просмотр. Отлично!

Чтобы пользователи могли делать снимки, мы добавим кнопку как часть макета после представления текстуры в 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" />

Другие варианты использования работают очень похоже по сравнению с Preview. Сначала необходимо определить объект конфигурации, который используется для создания экземпляра объекта варианта использования. Чтобы делать снимки при нажатии кнопки съёмки, нужно обновить метод `startCamera()` и добавить несколько строк кода в конце, перед вызовом 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)
}

Затем обновите вызов CameraX.bindToLifecycle, включив в него новый вариант использования:

CameraX.bindToLifecycle(this, preview, imageCapture)

И вот так мы реализовали функциональную кнопку фотосъемки.

Очень интересная функция CameraX — класс ImageAnalysis. Он позволяет нам определить собственный класс, реализующий интерфейс ImageAnalysis.Analyzer, который будет вызываться при получении кадров с камеры. В соответствии с основной концепцией CameraX, нам не придётся беспокоиться об управлении состоянием сеанса камеры или даже об удалении изображений; достаточно привязки к желаемому жизненному циклу нашего приложения, как и для других компонентов, поддерживающих жизненный цикл .

Сначала мы реализуем собственный анализатор изображений. Наш анализатор довольно прост — он просто регистрирует среднюю яркость (освещённость) изображения, но демонстрирует, что нужно делать в произвольно сложных сценариях. Всё, что нам нужно сделать, — это переопределить функцию `analyze` в классе, реализующем интерфейс ImageAnalysis.Analyzer. Мы можем определить нашу реализацию как внутренний класс внутри 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
        }
    }
}

Поскольку наш класс реализует интерфейс ImageAnalysis.Analyzer, все, что нам нужно сделать, это создать экземпляр ImageAnalysis, как и во всех других случаях использования, и еще раз обновить функцию `startCamera()` перед вызовом 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)
}

И мы также обновляем вызов CameraX.bindtoLifecycle, чтобы привязать новый вариант использования:

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

Запуск приложения сейчас приведет к появлению в logcat сообщения, похожего на это, примерно каждую секунду:

D/CameraXApp: Average luminosity: ...

Чтобы протестировать приложение, достаточно нажать кнопку «Запустить» в Android Studio, и наш проект будет собран, развёрнут и запущен на выбранном устройстве или эмуляторе. После загрузки приложения мы должны увидеть видоискатель, который останется в вертикальном положении даже после поворота устройства благодаря коду управления ориентацией, который мы добавили ранее. Также должна появиться возможность делать фотографии с помощью кнопки:

Вы успешно завершили лабораторную работу по кодированию! Оглядываясь назад, вы реализовали следующее в новом приложении для Android с нуля:

  • Включили зависимости CameraX в ваш проект.
  • Отобразил видоискатель камеры (используя вариант использования «Предварительный просмотр»)
  • Реализована функция захвата фотографий, сохранения изображений в хранилище (с использованием ImageCapture).
  • Реализован анализ кадров с камеры в реальном времени (с использованием ImageAnalysis)

Если вам интересно узнать больше о CameraX и о том, что можно с его помощью сделать, ознакомьтесь с документацией или клонируйте официальный пример .