Bắt đầu với Máy ảnhX

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

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

Những điều chúng ta sẽ tìm hiểu

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

Phần cứng mà chúng tôi sẽ cần

  • Có thiết bị Android, mặc dù trình mô phỏng Android Studio vẫn hoạt động bình thường. Cấp độ API tối thiểu được hỗ trợ là 21.

Phần mềm mà chúng tôi cần

  • Android Studio phiên bản 3.3 trở lên.

Sử dụng trình đơn Android Studio, bắt đầu dự án mới và chọn 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à chúng ta muốn -- chúng tôi thật sự chọn "CameraX App&quot. 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 (mức tối thiểu bắt buộc đối với Máy ảnhX) và chúng ta sử dụng cấu phần phần mềm AndroidX.

Để bắt đầu, hãy thêm các phần phụ thuộc Máy ảnh vào tệp Gradle ứng dụng của chúng ta, bên trong phần phần phụ thuộc:

// 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ộ hóa ngay và chúng ta sẽ sẵn sàng sử dụng Máy ảnh X trong ứng dụng của mình.

Chúng ta sẽ dùng một SurfaceSurface để hiển thị kính ngắm của máy ảnh. Trong lớp học lập trình này, chúng ta sẽ hiển thị kính ngắm ở định dạng hình vuông có kích thước cố định. Để có ví dụ toàn diện hơn cho thấy một kính 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 trong việc thêm bất kỳ chức năng nào trong dự án của chúng tôi là sử dụng máy ảnh là yêu cầu các quyền thích hợp của CAMERA. Trước tiên, chúng ta phải khai báo các tệp này trong tệp kê khai, trước thẻ Ứng dụng:

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

Sau đó, bên trong MainActivity, chúng ta cần yêu cầu quyền vào 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 hằng số và các hằng số 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)

Bên trong lớp MainActivity, hãy thêm các trường và phương thức trợ giúp sau để yêu cầu quyền và kích hoạt mã của chúng ta khi 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ẽ kết hợp mọi thứ bên trong onOnCreate để 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()
    }
}

Bây giờ, khi ứng dụng khởi động, ứng dụng sẽ kiểm tra xem ứng dụng có các quyền phù hợp với máy ảnh hay không. Nếu có, Analytics sẽ gọi trực tiếp `startCamera()". Nếu không, ứng dụng này sẽ yêu cầu các quyền và sau khi được cấp, hãy gọi `startCamera()`.

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

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

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. 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ị kính ngắm của bạn trong chế độ xoay thẳng đứng:

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 ứng dụng sẵn sàng sản xuất, hãy xem mẫu chính thức để biết những việc khác cần xử lý. Chúng tôi đang thực hiện một vài phím tắt để giữ cho lớp học lập trình này ngắn gọn. 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 xử lý thay đổi bố cục của chúng tôi. Kính ngắm không phải hình vuông cũng cần bù đắp cho tỷ lệ khung hình thay đổi khi thiết bị xoay.

Nếu chúng ta xây dựng và chạy ứng dụng, thì giờ đây chúng ta sẽ thấy bản xem trước trong đời. 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 chế độ xem kết cấu bằng 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 tương tự như 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 để tạo đối tượng cho 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 phải cập nhật phương thức `startCamera() và thêm một vài dòng mã nữa ở cuối, trước khi gọi lệnh đến Máy ảnhX.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 đó, cập nhật lệnh gọi lên CameraX.bindToLifecycle để thêm trường hợp sử dụng mới:

CameraX.bindToLifecycle(this, preview, imageCapture)

Như vậy, chúng tôi đã triển khai nút chụp ảnh chức năng.

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

Trước tiên, chúng tôi sẽ triển khai trình phân tích hình ảnh tùy chỉnh. Công cụ phân tích của chúng tôi khá đơn giản -- trình phân tích chỉ ghi lại độ sáng trung bình (độ sáng) của hình ảnh, nhưng minh họa những việc cần làm cho các trường hợp sử dụng tùy ý phức tạp. 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 ImageAnalytics.Analytics. Chúng ta có thể xác định cách triển khai của mình làm 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
        }
    }
}

Khi lớp của chúng ta triển khai giao diện ImageAnalytics.Analyticsr, chúng ta chỉ cần tạo thực thể cho công cụ Phân tích hình ảnh giống 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()` trước khi thực hiện lệnh gọi đến 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)
}

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

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

Chạy ứng dụng ngay bây giờ sẽ tạo ra thông báo tương tự như thế này trong logcat khoảng mỗi giây:

D/CameraXApp: Average luminosity: ...

Để thử nghiệm ứng dụng, chúng tôi chỉ cần nhấp vào nút Chạy trong Android Studio. Dự án của chúng tôi sẽ được xây dựng, triển khai và triển khai trên 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 kính ngắm. Kính ngắm sẽ đứng thẳng ngay cả sau khi xoay thiết bị nhờ mã xử lý hướng mà chúng ta đã thêm trước đó, và cũng có thể chụp ảnh bằng nút:

Bạn đã hoàn thành phòng thí nghiệm mã và thành công! Nhìn lại, bạn đã triển khai những mục sau vào một ứng dụng Android mới từ đầu:

  • Đưa các phần phụ thuộc Máy ảnh vào dự án của bạn.
  • Hiển thị kính ngắm máy ảnh (sử dụng trường hợp sử dụng Chế độ 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 Imagechụp)
  • Triển khai hoạt động phân tích các khung hình từ máy ảnh theo thời gian thực (sử dụng trường hợp sử dụng công cụ Phân tích hình ảnh)

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