Начало работы с 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.

Чтобы использовать предварительный просмотр, нам нужно сначала определить конфигурацию, которая затем используется для создания экземпляра варианта использования. Полученный экземпляр — это то, что нам нужно привязать к жизненному циклу 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 и о том, что с ним можно делать, ознакомьтесь с документацией или клонируйте официальный образец .