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

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

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

מה נלמד

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

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

  • מכשיר Android הוא אמולטור של Android Studio, אבל אין בעיה לבצע אותו. רמת התמיכה המינימלית הנדרשת היא 21.

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

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

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

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

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

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

לאחר מכן, בתוך 'פעילות ראשית', נצטרך לבקש הרשאות בזמן הריצה. אנחנו נבצע את השינויים בקובץ ל'ראשי' לפי Java > com.example.cameraxapp > primaryActivity.kt:

בחלק העליון של הקובץ, מחוץ להגדרת המחלקה 'פעילות', מוסיפים את הקבועים והייבוא הבאים:

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

מתוך הכיתה 'פעילות ראשית', מוסיפים את השדות ואת שיטות העזרה הבאים שמשמשים לבקשת הרשאות ומפעילים את הקוד שלנו אחרי שנקבל את כל ההרשאות:

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() '.

עבור רוב אפליקציות המצלמה, חשוב מאוד להציג את העינית למשתמשים – אחרת, קשה מאוד למשתמשים להפנות את המצלמה למקום הנכון. אפשר להטמיע עינית באמצעות הכיתה 'תצוגה מקדימה' של מצלמת XX.

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

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

ראשית, נטמיע כלי לניתוח תמונות בהתאמה אישית. כלי הניתוח שלנו פשוט למדי: הוא מתעד את מידת התאורה הממוצעת (lumination) של התמונה, אך הוא ממחיש מה צריך לעשות עבור תרחישים לדוגמה שרירותיים. כל מה שאנחנו צריכים לעשות הוא לעקוף את הפונקציה 'analyze' בכיתה שמטמיעה את ממשק Analysis Analysis. אנחנו יכולים להגדיר את ההטמעה ככיתה פנימית ב-PrimaryActivity:

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

באמצעות הכיתה שלנו שמטמיעים את ממשק Analysis Analysis.כל מה שאנחנו צריכים לעשות הוא ליצור את Analysis Analysis כמו כל שאר תרחישי השימוש, ולעדכן שוב את הפונקציה '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)
  • הטמעה של ניתוח מסגרות מהמצלמה בזמן אמת (באמצעות תרחיש לדוגמה של Image Analysis)

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