Làm quen với CameraX

Trong lớp học lập trình này, chúng ta sẽ tìm hiểu cách tạo một ứng dụng máy ảnh sử dụng CameraX để hiện kính ngắm, chụp ảnh và phân tích luồng hình ảnh từ camera.

Để đạt được điều này, chúng ta sẽ giới thiệu khái niệm về các trường hợp sử dụng trong CameraX. Bạn có thể dùng các trường hợp này cho nhiều thao tác của máy ảnh, từ việc hiển thị một khung ngắm đến phân tích khung hình theo thời gian thực.

Nội dung chúng ta sẽ tìm hiểu

  • Cách thêm các phần phụ thuộc CameraX.
  • Cách hiển thị bản xem trước của camera trong một hoạt động. (Trường hợp sử dụng xem trước)
  • Cách chụp ảnh và lưu vào bộ nhớ. (Trường hợp sử dụng ImageCapture)
  • Cách phân tích khung hình từ camera theo thời gian thực. (Trường hợp sử dụng ImageAnalysis)

Phần cứng cần thiết

  • Một thiết bị Android, mặc dù trình mô phỏng của Android Studio sẽ hoạt động tốt. Cấp độ API tối thiểu được hỗ trợ là 21.

Phần mềm cần thiết

  • Android Studio 3.3 trở lên.

Sử dụng trình đơn Android Studio, hãy bắt đầu một dự án mới và chọn Empty Activity (Hoạt động trống) khi được nhắc.

Tiếp theo, chúng ta có thể chọn bất kỳ tên nào mình muốn – chúng ta đã khéo léo chọn "CameraX App". Chúng ta nên đảm bảo rằng ngôn ngữ được đặt thành Kotlin, cấp độ API tối thiểu là 21 (đây là cấp độ tối thiểu cần thiết cho CameraX) và chúng ta sử dụng các cấu phần phần mềm AndroidX.

Để bắt đầu, hãy thêm các phần phụ thuộc CameraX vào tệp Gradle của ứng dụng, bên trong phần 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}"

Khi được nhắc, hãy nhấp vào Đồng bộ hoá ngay và chúng ta sẽ sẵn sàng sử dụng CameraX trong ứng dụng của mình.

Chúng ta sẽ sử dụng SurfaceTexture để hiển thị kính ngắm của camera. Trong lớp học lập trình này, chúng ta sẽ hiển thị khung ngắm ở định dạng vuông có kích thước cố định. Để xem một ví dụ toàn diện hơn về khung ngắm thích ứng, hãy xem mẫu chính thức.

Hãy chỉnh sửa tệp bố cục activity_main trong 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>

Một phần quan trọng của việc thêm bất kỳ chức năng nào vào dự án của chúng ta sử dụng camera là yêu cầu các quyền CAMERA thích hợp. Trước tiên, chúng ta phải khai báo các quyền đó trong tệp kê khai, trước thẻ Application:

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

Sau đó, trong MainActivity, chúng ta cần yêu cầu cấp quyền trong thời gian chạy. Chúng ta sẽ thực hiện các thay đổi trong tệp MainActivity trong java > com.example.cameraxapp > MainActivity.kt:

Ở đầu tệp, bên ngoài định nghĩa lớp MainActivity, hãy thêm các hằng số và nội dung nhập sau:

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

Trong lớp MainActivity, hãy thêm các trường và phương thức trợ giúp sau đây. Các trường và phương thức này được dùng để yêu cầu cấp quyền và kích hoạt mã của chúng ta sau khi chúng ta biết rằng tất cả các quyền đã được cấp:

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

Cuối cùng, chúng ta sẽ đặt mọi thứ vào bên trong onCreate để kích hoạt yêu cầu cấp quyền khi thích hợp:

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

Giờ đây, khi ứng dụng khởi động, ứng dụng sẽ kiểm tra xem có quyền truy cập camera phù hợp hay không. Nếu có, phương thức này sẽ gọi trực tiếp `startCamera()`. Nếu không, ứng dụng sẽ yêu cầu cấp quyền và gọi `startCamera()` sau khi được cấp quyền.

Đối với hầu hết các ứng dụng máy ảnh, việc cho người dùng thấy kính ngắm là rất quan trọng. Nếu không, người dùng sẽ rất khó hướng camera đến đúng vị trí. Bạn có thể triển khai kính ngắm bằng cách sử dụng lớp "Bản xem trước" của CameraX.

Để sử dụng bản Xem trước, trước tiên, chúng ta cần xác định một cấu hình, sau đó cấu hình này sẽ được dùng để tạo một thực thể của trường hợp sử dụng. Phiên bản kết quả là phiên bản chúng ta cần liên kết với vòng đời CameraX. Chúng ta sẽ thực hiện việc này trong phương thức `startCamera()`; hãy điền nội dung triển khai bằng mã này:

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

Tại thời điểm này, chúng ta cần triển khai phương thức "updateTransform()" bí ẩn. Trong `updateTransform()`, mục tiêu là bù đắp cho những thay đổi về hướng thiết bị để hiển thị khung ngắm ở chế độ xoay dọc:

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

Để triển khai một ứng dụng sẵn sàng phát hành công khai, hãy xem mẫu chính thức để biết những việc khác cần xử lý. Để rút ngắn lớp học lập trình này, chúng ta sẽ sử dụng một số lối tắt. Ví dụ: chúng tôi không theo dõi một số thay đổi về cấu hình, chẳng hạn như xoay thiết bị 180 độ. Những thay đổi này không kích hoạt trình nghe thay đổi bố cục của chúng tôi. Khung ngắm không vuông cũng cần bù cho tỷ lệ khung hình thay đổi khi thiết bị xoay.

Nếu tạo và chạy ứng dụng, giờ đây chúng ta sẽ thấy bản xem trước trực tiếp. Tuyệt vời!

Để cho phép người dùng chụp ảnh, chúng ta sẽ cung cấp một nút trong bố cục sau khung hiển thị kết cấu trong 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" />

Các trường hợp sử dụng khác hoạt động theo cách rất tương tự so với bản xem trước. Trước tiên, chúng ta phải xác định một đối tượng cấu hình được dùng để khởi tạo đối tượng trường hợp sử dụng thực tế. Để chụp ảnh, khi nhấn nút chụp, chúng ta cần cập nhật phương thức `startCamera()` và thêm một vài dòng mã ở cuối, trước khi gọi 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)
}

Sau đó, hãy cập nhật lệnh gọi thành CameraX.bindToLifecycle để thêm trường hợp sử dụng mới:

CameraX.bindToLifecycle(this, preview, imageCapture)

Và như vậy, chúng ta đã triển khai một nút chụp ảnh hoạt động được.

Một tính năng rất thú vị của CameraX là lớp ImageAnalysis. Thao tác này cho phép chúng ta xác định một lớp tuỳ chỉnh triển khai giao diện ImageAnalysis.Analyzer. Lớp này sẽ được gọi bằng các khung hình camera đến. Theo tầm nhìn cốt lõi của CameraX, chúng ta sẽ không phải lo lắng về việc quản lý trạng thái phiên camera hoặc thậm chí là loại bỏ hình ảnh; chỉ cần liên kết với vòng đời mong muốn của ứng dụng là đủ, giống như các thành phần nhận biết vòng đời khác.

Trước tiên, chúng ta sẽ triển khai một trình phân tích hình ảnh tuỳ chỉnh. Trình phân tích của chúng tôi khá đơn giản – trình phân tích này chỉ ghi lại độ chói trung bình của hình ảnh, nhưng minh hoạ những việc cần làm cho các trường hợp sử dụng phức tạp tuỳ ý. Tất cả những gì chúng ta cần làm là ghi đè hàm "phân tích" trong một lớp triển khai giao diện ImageAnalysis.Analyzer. Chúng ta có thể xác định quá trình triển khai của mình dưới dạng một lớp bên trong trong 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
        }
    }
}

Với lớp triển khai giao diện ImageAnalysis.Analyzer, tất cả những gì chúng ta cần làm là tạo thực thể ImageAnalysis như tất cả các trường hợp sử dụng khác và cập nhật lại hàm `startCamera()` một lần nữa, trước khi gọi 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)
}

Chúng ta cũng cập nhật lệnh gọi đến CameraX.bindtoLifecycle để liên kết trường hợp sử dụng mới:

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

Bây giờ, khi chạy ứng dụng, bạn sẽ thấy một thông báo tương tự như thông báo này trong logcat sau mỗi giây:

D/CameraXApp: Average luminosity: ...

Để kiểm thử ứng dụng, tất cả những gì chúng ta phải làm là nhấp vào nút Run (Chạy) trong Android Studio và dự án của chúng ta sẽ được tạo, triển khai và khởi chạy trong thiết bị hoặc trình mô phỏng đã chọn. Sau khi ứng dụng tải, chúng ta sẽ thấy khung ngắm. Khung ngắm này sẽ vẫn thẳng đứng ngay cả sau khi xoay thiết bị nhờ mã xử lý hướng mà chúng ta đã thêm trước đó. Ngoài ra, chúng ta cũng có thể chụp ảnh bằng nút này:

Bạn đã hoàn tất lớp học lập trình này một cách thành công! Nhìn lại, bạn đã triển khai những nội dung sau vào một ứng dụng Android mới từ đầu:

  • Đưa các phần phụ thuộc CameraX vào dự án của bạn.
  • Hiển thị kính ngắm camera (bằng trường hợp sử dụng Xem trước)
  • Triển khai tính năng chụp ảnh, lưu hình ảnh vào bộ nhớ (sử dụng trường hợp sử dụng ImageCapture)
  • Phân tích các khung hình từ camera theo thời gian thực (bằng trường hợp sử dụng ImageAnalysis)

Nếu bạn muốn đọc thêm về CameraX và những việc bạn có thể làm với CameraX, hãy xem tài liệu hoặc sao chép mẫu chính thức.