תחילת העבודה עם CameraX

ב-codelab הזה נלמד איך ליצור אפליקציית מצלמה שמשתמשת ב-CameraX כדי להציג עינית, לצלם תמונות ולנתח זרם תמונות מהמצלמה.

כדי לעשות את זה, נציג את הקונספט של תרחישי שימוש ב-CameraX, שאפשר להשתמש בהם למגוון פעולות במצלמה, החל מהצגת עינית ועד לניתוח פריימים בזמן אמת.

מה נלמד

  • איך מוסיפים את יחסי התלות של CameraX.
  • איך מציגים את התצוגה המקדימה של המצלמה בפעילות. (תרחיש שימוש בתצוגה מקדימה)
  • איך מצלמים תמונה ושומרים אותה באחסון. (תרחיש שימוש ב-ImageCapture)
  • איך מנתחים פריימים מהמצלמה בזמן אמת. (תרחיש שימוש ImageAnalysis)

החומרה שנדרשת

  • מכשיר Android, אבל אפשר להשתמש גם באמולטור של Android Studio. רמת ה-API המינימלית הנתמכת היא 21.

התוכנות שנדרשות

  • ‫Android Studio מגרסה 3.3 ואילך.

בתפריט של Android Studio, מתחילים פרויקט חדש ובוחרים באפשרות Empty Activity כשמוצגת בקשה.

אחר כך, אפשר לבחור כל שם שרוצים – אנחנו בחרנו בשם המקורי 'CameraX App'. צריך לוודא שהשפה מוגדרת ל-Kotlin, שרמת ה-API המינימלית היא 21 (שהיא הרמה המינימלית שנדרשת ל-CameraX) ושאנחנו משתמשים בארטיפקטים של AndroidX.

כדי להתחיל, נוסיף את יחסי התלות של CameraX לקובץ Gradle של האפליקציה, בקטע 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}"

כשמופיעה בקשה, לוחצים על Sync Now (סנכרון עכשיו), ואז אפשר להשתמש ב-CameraX באפליקציה.

נשתמש ב-SurfaceTexture כדי להציג את עינית המצלמה. ב-codelab הזה, נציג את העינית בפורמט ריבועי בגודל קבוע. דוגמה מקיפה יותר שכוללת עינית רספונסיבית מופיעה בדוגמה הרשמית.

עכשיו נשנה את קובץ הפריסה 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>

חלק חשוב בהוספת פונקציונליות לפרויקט שלנו שמשתמשת במצלמה הוא בקשת ההרשאות המתאימות לשימוש במצלמה. קודם צריך להצהיר עליהן במניפסט, לפני התג 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. נעשה זאת ב-method‏ `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)
}

כדי להטמיע אפליקציה שמוכנה לסביבת הייצור, כדאי לעיין בדוגמה הרשמית כדי לראות אילו עוד פעולות צריך לבצע. כדי שה-codelab הזה יהיה קצר, אנחנו משתמשים בכמה קיצורי דרך. לדוגמה, אנחנו לא עוקבים אחרי שינויים מסוימים בהגדרות, כמו סיבוב המכשיר ב-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" />

תרחישי שימוש אחרים פועלים בצורה דומה מאוד לזו של תצוגה מקדימה. קודם צריך להגדיר אובייקט הגדרה שמשמש ליצירת מופע של אובייקט תרחיש השימוש בפועל. כדי לצלם תמונות, כשלוחצים על לחצן הצילום, צריך לעדכן את השיטה <code>startCamera()</code> ולהוסיף עוד כמה שורות קוד בסוף, לפני הקריאה אל 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, לא נצטרך לדאוג לניהול מצב ההפעלה של המצלמה או אפילו לסילוק תמונות. מספיק לבצע binding למחזור החיים הרצוי של האפליקציה שלנו, כמו רכיבים אחרים שמודעים למחזור החיים.

קודם נטמיע כלי לניתוח תמונות בהתאמה אישית. הכלי שלנו לניתוח הוא פשוט למדי – הוא רק מתעד את בהירות התמונה הממוצעת, אבל הוא מדגים מה צריך לעשות בתרחישי שימוש מורכבים. כל מה שצריך לעשות הוא לשנות את הפונקציה 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: ...

כדי לבדוק את האפליקציה, כל מה שצריך לעשות הוא ללחוץ על הלחצן Run ב-Android Studio, והפרויקט ייבנה, ייפרס ויופעל במכשיר או באמולטור שנבחרו. אחרי שהאפליקציה נטענת, אמור להופיע מסך התצוגה המקדימה. הוא יישאר זקוף גם אחרי שמסובבים את המכשיר, בזכות קוד הטיפול בהתמצאות שהוספנו קודם. בנוסף, אמורה להיות אפשרות לצלם תמונות באמצעות הלחצן:

סיימתם את סדנת התכנות בהצלחה. במבט לאחור, הטמעת את הפריטים הבאים באפליקציית Android חדשה מאפס:

  • הוספתם את יחסי התלות של CameraX לפרויקט.
  • הצגת עינית של מצלמה (באמצעות תרחיש שימוש בתצוגה מקדימה)
  • הטמענו צילום תמונות ושמירת תמונות באחסון (באמצעות תרחיש השימוש ImageCapture)
  • הטמעת ניתוח של פריימים מהמצלמה בזמן אמת (באמצעות תרחיש לדוגמה של ImageAnalysis)

רוצים לקרוא מידע נוסף על CameraX ועל הפעולות שאפשר לבצע באמצעותה? כדאי לעיין בתיעוד או לשכפל את הדוגמה הרשמית.