【程式碼吸貓】使用 Google MLKit 進行影象識別

語言: CN / TW / HK

theme: healer-readable highlight: atelier-sulphurpool-light


一起用程式碼吸貓!本文正在參與【喵星人徵文活動】 MLKit 是 Google 提供的移動端機器學習庫。工程師僅通過少量程式碼就能在 Andorid 或 iOS 上實現各種 AI 能力,例如影象、文字、人臉識別等等,藉助 TensorFlow Lite 其中很多能力可以在裝置上離線完成。

https://developers.google.com/ml-kit

本文帶大家在 Android 上體驗 MLKit 的以下功能:

  • 影象識別(Image Labeling)
  • 目標檢測(Object Detection)
  • 目標追蹤(Object Tracking)


1. 影象識別(Image Labeling)

影象識別是計算機視覺的一個重要領域,簡單說就是幫你提取圖片中的有效資訊。 MLKit 提供了 ImageLabeling 功能,可以識別影象資訊並進行分類標註。

比如輸入一張包含貓的圖片,ImageLabeling 能識別出圖片中的貓元素,並給出一個貓的標註,除了最顯眼的貓 ImageLabeling還能識別出花、草等圖片中所有可識別的事物,並分別給出出現的概率和佔比,識別的結果以 List<ImageLabel> 返回。 基於預置的預設模型,ImageLabeling可以對影象元素進行超過 400 種以上的標註分類,當然你可以使用自己訓練的模型擴充更多分類。

預設模型當前支援的分類標註:
https://developers.google.com/ml-kit/vision/image-labeling/label-map

Android 中引入 MLKit 的 ImageLabeliing 很簡單,在 gradle 中新增相關依賴即可

groovy implementation 'com.google.mlkit:image-labeling:17.0.5'

接下來寫一個 Android 的 Demo 來展示使用效果。我們使用 Compose 為 Demo 寫一個簡單的 UI:

```kotlin @Composable fun MLKitSample() {

Column {
    var imageLabel by remember { mutableStateOf("") }

    //Load Image
    val context = LocalContext.current
    val bmp = remember(context) {
        context.assetsToBitmap("cat.png")!!
    }

    Image(bitmap = bmp.asImageBitmap(), contentDescription = "")

    val coroutineScope = rememberCoroutineScope()

    Button(
        onClick = {
            //TODO : 影象識別具體邏輯,見後文
        }) 
    {  Text("Image Labeling") }

    Text(imageLabel, Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
}

} 將圖片資源放入 /assets,並載入為 Bitmapkotlin fun Context.assetsToBitmap(fileName: String): Bitmap? = assets.open(fileName).use { BitmapFactory.decodeStream(it) } ```

點選 Button 後,對 Bitmap 進行識別,獲取識別後的資訊更新 imageLabel

看一下 onClick 內的內容:

```kotlin val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) val image = InputImage.fromBitmap(bmp, 0) labeler.process(image).addOnSuccessListener { labels : List -> // Task completed successfully imageLabel = labels.scan("") { acc, label -> acc + "${label.text} : ${label.confidence}\n" }.last() }.addOnFailureListener { // Task failed with an exception }

```

首先建立 ImageLabeler 處理器,InputImage.fromBitmap 將 Bitmap 處理為 ImageLabeler 可接受的資源型別,處理結果通過 Listener 返回。

處理成功,返回 ImageLabel 的列表,ImageLabel 代表每一個種類的標註資訊,影象經識別後獲得一組這樣de 標註,包含每一種類的名字以及其出現概率,這些資訊可以在影象檢索等場景中作為權重使用。


2. 目標檢測(Object Detection)

目標檢測也是計算機視覺的一個基礎研究方向。這裡需要注意 “檢測” 和 “識別” 的區別: - 檢測(Detecting):關注的是 Where is,即目標在哪裡 - 識別(Lebeling):關注的是 What is,即目標是什麼

ImageLebeling 可以識別影象中的事物分類,但是無法確定哪個事物在哪裡。而目標檢測可以確定有幾個事物分別在哪裡,但是事物的分類資訊不清晰。

ObjectDetection 雖然也提供了一定的識別能力,但是其預設的模型檔案只能識別有限的幾個種類,無法像 ImageLebeling 那樣精確分類。想要識別更準確的資訊需要藉助額外的模型檔案。但是我們可以將上述兩套 API 配合使用,各取所長以達到目標檢測的同時進行準確的識別和分類。

首先新增 ObjectDetection 依賴

groovy implementation 'com.google.mlkit:object-detection:16.2.7'

接下來在上面例子中,增加一個 Button 用於點選後的目標檢測 ```kotlin @Composable fun MLKitSample() {

Column(Modifier.fillMaxSize()) {

    val detctedObject = remember { mutableStateListOf<DetectedObject>() }

    //Load Image
    val context = LocalContext.current
    val bmp = remember(context) {
        context.assetsToBitmap("dog_cat.jpg")!!
    }

    Canvas(Modifier.aspectRatio(
        bmp.width.toFloat() / bmp.height.toFloat())) {

        drawIntoCanvas { canvas ->
            canvas.withSave {
                canvas.scale(size.width / bmp.width)
                canvas.drawImage( // 繪製 image
                    image = bmp.asImageBitmap(), Offset(0f, 0f), Paint()
                )
                detctedObject.forEach {
                    canvas.drawRect( //繪製目標檢測的邊框
                        it.boundingBox.toComposeRect(),
                        Paint().apply {
                            color = Color.Red.copy(alpha = 0.5f)
                            style = PaintingStyle.Stroke
                            strokeWidth = bmp.width * 0.01f
                        })
                    if (it.labels.isNotEmpty()) {
                        canvas.nativeCanvas.drawText( //繪製物體識別資訊
                            it.labels.first().text,
                            it.boundingBox.left.toFloat(),
                            it.boundingBox.top.toFloat(),
                            android.graphics.Paint().apply {
                                color = Color.Green.toArgb()
                                textSize = bmp.width * 0.05f
                            })
                    }

                }
            }
        }
    }

    Button(
        onClick = {
            //TODO : 目標檢測具體邏輯,見後文
        }) 
    {  Text("Object Detect") }

}

} ```

由於我們要在影象上繪製目標邊界的資訊,所以這次採用 Canvas 繪製 UI,包括以下內容: - drawImage:繪製目標圖片 - drawRect:MLKit 檢測成功後會返回 List<DetectedObject> 資訊,基於 DetectedObject 繪製目標邊界 - drawText:基於 DetectedObject 繪製目標的分類標註

點選 Button 後進行目標檢測,具體實現如下:

```kotlin val options = ObjectDetectorOptions.Builder() .setDetectorMode(ObjectDetectorOptions.SINGLE_IMAGE_MODE) .enableMultipleObjects() .enableClassification() .build()

val objectDetector = ObjectDetection.getClient(options) val image = InputImage.fromBitmap(bmp, 0)

objectDetector.process(image) .addOnSuccessListener { detectedObjects -> // Task completed successfully coroutineScope.launch { detctedObject.clear() detctedObject.addAll(getLabels(bmp, detectedObjects).toList()) } } .addOnFailureListener { e -> // Task failed with an exception // ... } ```

通過 ObjectDetectorOptions 我們對檢測處理進行配置。可使用 Builder 進行多個配置: - setDetectorMode : ObjectDetection 有多種目標檢測方式,這裡使用的是最簡單的一種 SINGLE_IMAGE_MODE 即針對單張圖片的檢測。此外還有針對影片流的檢測等其他方式,後文介紹。 - enableMultipleObjects:可以只檢測最突出的事物或是檢測所有可事物,我們這裡啟動多目標檢測,檢測所有可檢測的事物。 - enableClassification: ObjectDetection 在影象識別上的能力有限,預設模型只能識別 5 個種類,且都是比較寬泛的分類,比如植物、動物等。enableClassification 可以開啟影象識別能力。開啟後,其識別結果會存入 DetectedObject.labels。由於這個識別結果沒有意義,我們在例子中會替換為使用 ImageLebeling 識別後的標註資訊

基於 ObjectDetectorOptions 建立 ObjectDetector 處理器,傳入圖片後開始檢測。getLabels 是自定義方法,基於 ImageLebeling 新增影象識別資訊。檢測的最終結果更新至 detctedObject 這個 MutableStateList,重新整理 Compose UI。

```kotlin private fun getLabels( bitmap: Bitmap, objects: List ) = flow {

val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
for (obj in objects) {
    val bounds = obj.boundingBox
    val croppedBitmap = Bitmap.createBitmap(
        bitmap,
        bounds.left,
        bounds.top,
        bounds.width(),
        bounds.height()
    )

    emit(
        DetectedObject(
            obj.boundingBox,
            obj.trackingId,
            getLabel(labeler, croppedBitmap).map {
                //轉換為 DetectedObject.Label
                DetectedObject.Label(it.text, it.confidence, it.index)
            })
    )
}

} ```

首先根據 DetectedObject 的邊框資訊 boundingBox 將 Bitmap 分解為小圖片,然後對其呼叫 getLabel 獲取標註資訊補充進 DetectedObject 例項(這裡實際是重建了一個例項)

getLabel 中的 ImageLebeling 是一個非同步過程,為了呼叫方便,定義為一個掛起函式:

kotlin suspend fun getLabel(labeler: ImageLabeler, image: Bitmap): List<ImageLabel> = suspendCancellableCoroutine { cont -> labeler.process(InputImage.fromBitmap(image, 0)) .addOnSuccessListener { labels -> // Task completed successfully cont.resume(labels) } }


3. 目標追蹤(Object Tracking)

目標追蹤就是通過對影片逐幀進行 ObjectDetection ,以達到連續捕捉的效果。接下來的例子中我們啟動一個相機預覽,對拍攝到影象進行 ObjectTracking。

ezgif.com-gif-maker (15).gif

我們使用 CameraX 啟動相機,因為 CameraX 封裝的 API 更易用。

groovy implementation "androidx.camera:camera-camera2:1.0.0-rc01" implementation "androidx.camera:camera-lifecycle:1.0.0-rc01" implementation "androidx.camera:camera-view:1.0.0-alpha20" implementation "com.google.accompanist:accompanist-permissions:0.16.1"

如上,引入 CameraX 相關類庫,同時引入 accompanist-permissions 用來動態申請相機許可權。

CameraX 的預覽需要使用 androidx.camera.view.PreviewView,我們通過 AndroidView 整合到 Composable 中,AndroidView 上方覆蓋 Canvas ,Canvas 繪製目標邊框。

整個 UI 佈局如下:

```kotlin val detectedObjects = mutableStateListOf()

Box { CameraPreview(detectedObjects) Canvas(modifier = Modifier.fillMaxSize()) { drawIntoCanvas { canvas -> detectedObjects.forEach { canvas.scale(size.width / 480, size.height / 640) canvas.drawRect( //繪製邊框 it.boundingBox.toComposeRect(), Paint().apply { color = Color.Red style = PaintingStyle.Stroke strokeWidth = 5f }) canvas.nativeCanvas.drawText( // 繪製文字 "TrackingId_${it.trackingId}", it.boundingBox.left.toFloat(), it.boundingBox.top.toFloat(), android.graphics.Paint().apply { color = Color.Green.toArgb() textSize = 20f }) }

    }
}

} ```

detectedObjects 是 ObjectDetection 逐幀實時檢測的結果。CameraPreview 中集成了相機預覽的 AndroidView,並實時更新 detectedObjects 。drawRect 和 drawText 在前面例子中也出現過,但需要注意這裡 drawText 繪製的是 trackingId 。 影片的 ObjectDetection 會為 DetectedObject 新增 trackingId 資訊, 影片目標的邊框位置會不斷變換,但是 trackingId 是不變的,這便於在多目標中更好地鎖定個體。

```kotlin @Composable private fun CameraPreview(detectedObjects: SnapshotStateList) { val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }

val coroutineScope = rememberCoroutineScope()
val objectAnalyzer = remember { ObjectAnalyzer(coroutineScope, detectedObjects) }

AndroidView(
    factory = { ctx ->
        val previewView = PreviewView(ctx)
        val executor = ContextCompat.getMainExecutor(ctx)

        val imageAnalyzer = ImageAnalysis.Builder()
            .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
            .build()
            .also {
                it.setAnalyzer(executor, objectAnalyzer)
            }

        cameraProviderFuture.addListener({
            val cameraProvider = cameraProviderFuture.get()
            val preview = Preview.Builder().build().also {
                it.setSurfaceProvider(previewView.surfaceProvider)
            }

            val cameraSelector = CameraSelector.Builder()
                .requireLensFacing(CameraSelector.LENS_FACING_BACK)
                .build()

            cameraProvider.unbindAll()
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                preview,
                imageAnalyzer
            )
        }, executor)
        previewView
    },
    modifier = Modifier.fillMaxSize(),
)

} ```

CameraPreview 主要是關於 CameraX 的使用,本文不會逐行說明 CameraX 的使用,只關注與主題相關的程式碼: CameraX 可以設定 ImageAnalyzer 用於對影片幀進行解析,這正是用於我們的需求,這裡自定義了 ObjectAnalyzer 做目標檢測。

最後看一下 ObjectAnalyzer 的實現

```kotlin class ObjectAnalyzer( private val coroutineScope: CoroutineScope, private val detectedObjects: SnapshotStateList ) : ImageAnalysis.Analyzer { private val options = ObjectDetectorOptions.Builder() .setDetectorMode(ObjectDetectorOptions.STREAM_MODE) .build() private val objectDetector = ObjectDetection.getClient(options)

@SuppressLint("UnsafeExperimentalUsageError")
override fun analyze(imageProxy: ImageProxy) {
    val frame = InputImage.fromMediaImage(
        imageProxy.image,
        imageProxy.imageInfo.rotationDegrees
    )

    coroutineScope.launch {
        objectDetector.process(frame)
            .addOnSuccessListener { detectedObjects ->
                // Task completed successfully
                with([email protected]) {
                    clear()
                    addAll(detectedObjects)
                }
            }
            .addOnFailureListener { e ->
                // Task failed with an exception
                // ...
            }
            .addOnCompleteListener {
                imageProxy.close()
            }
    }

}

} ```

ObjectAnalyzer 中獲取相機預覽的影片幀對其進行 ObjectDetection,檢測結果更新至 detectedObjects 。注意此處 ObjectDetectorOptions 設定為 STREAM_MODE 專門處理影片檢測。雖然把每一幀都當做 SINGLE_IMAGE_MODE 處理理論上也是可行的,但只有 STREAM_MODE 的檢測結果才帶有 trackingId 的值,而且 STREAM_MODE 下的邊框位置經過防抖處理,位移更加順滑。


最後

本文為了參加平臺的活動,以喵為例介紹了 MLKit 影象識別的能力, MLKit 還有很多實用功能,比如人臉檢測相較於 Android 自帶的 android.media.FaceDetector 無論效能還是識別率都有質的飛躍。此外國內不少 AI 大廠也有很多不錯的解決方案,比如曠視 。相信隨著 AI 技術的發展,未來在移動端上的應用場景也會越來越多。

本文程式碼:https://github.com/vitaviva/JetpackComposePlayground/tree/main/mlkit_exp