以下文章來源于Android高效開發,作者2BAB
本文作者 /Android 谷歌開發者專家 El Zhang (2BAB)
繼上一篇移植了 Mediapipe 的 LLM Inference 后,這篇文章我們將繼續探索 Object Detection Demo 的移植。通過本文你將了解到:
移植 Mediapipe 的 Object Detection Android 官方 Demo 到 KMP,支持在 iOS 上運行。項目地址:https://github.com/2BAB/MediaPiper/tree/object-detection
Compose Multiplatform 與 iOS 原生控件的集成與交互 (Camera Preview),包括權限申請。
在 KMP 使用依賴注入 iOS 控件的小技巧 (基于 Koin)。
該 Demo 里兩種 Object Detection 算法的簡單背景知識。
? ? ?
Object Detection Android Sample
首先,我們先打開 Object Detection 的原版工程,發現其 Android 部分既有 android.view.View 版本的實現,也有 Jetpack Compose 的版本。因此我們延續上一篇的方式,基于 Jetpack Compose 的版本直接移植到 KMP 上。
接著,仔細體驗該 App 會發現其復雜度更高。LLM Inference 中的 SDK 僅僅是提供文本的推理接口,可直接在 Kotlin 層封裝對應平臺的 SDK 方便抽象 (盡管因為一些 cinterop 支持原因我們最后用了備用方案),UI 上則完全復用。但 Object Detection 是基于圖像的實時處理,演示里涉及攝像頭實時檢測、本地視頻的檢測、本地圖片的檢測三種。攝像頭預覽的需求一般都強依賴于平臺實現,播放器在渲染層面也鮮有自繪 (即使用平臺 Native 方案)。
小結,在開始設計時我們就得考慮把 Compose Multiplatform (CMP) 難以實現的部分留出 (例如上圖中的 CameraView),抽象成獨立的 expect Composable 函數留給兩端各自實現。而為了方便學習需減少 Demo 的規模,我們也決定只實現 CameraView 的部分,把 Gallery (Video+Image) 的部分留給大家去嘗試。實際上,只要掌握了 Camera Preview 的嵌入方法,其他兩部分也可以參照實現,包括 Compose 和 UiKit 的交互、iOS 權限申請等。
結合 iOS 版的 Demo 交叉比對,我們把 CameraView 有關的 UI 層整理成了四個部分,如上圖所示。其中:
Camera Preview 層一定是交由兩端各自實現。
ResultOverlay 即各種結果的方框繪制,可以考慮在 Common 層實現,但涉及到其與 Camera Preview 的圖層匹配 (因 Camera Preview 的大小根據鏡頭的不同會有不同的比例選項)、坐標轉換,較為復雜,本次 Demo 繼續交由兩端各自實現。
Scaffold 和 Inference Time Label 在 Common 層實現。
移植流程
移植主體的 UI 和數據結構
我們在上一節的基礎上繼續在 Mediapiper 工程中增加一個新文件夾 objectdetection。有了上一節的經驗,我們發現其實很多 UI 的內容都不復雜——除了這節的重點,相機預覽界面。因此,我們可以先行把除了camera和 gallery的文件都移動過來:
此處需要的修改分為兩塊:
數據和邏輯部分:
我們采集原來的 SDK 中的 ObjectDetectionResult 屬性聲明,創建了一個 Common 版本的 data class,也包括其用到的各種附屬類型。如此一來,兩邊的 SDK 返回結果都可以通過簡單轉換直接替換成 Common 版本的,不管是要顯示推理時間、統一采樣埋點,甚至為以后把ResultOverlay 搬來 Common 做好了準備。
一些工具類和默認值枚舉也被一并移至 Common 層,并且基本不需要修改,只要把推理結果的類置換成上述 Common 版本的。
UI 部分:
一些統一的修改和上一節完全相同,R引用改 Res,主題換成上一節統一的,一些簡單的 Import 包修改。
而特別的部分在于該 Demo 沒有使用 CMP 版本的 Navigation,所以在 Home 和 Option 頁面切換只是在頂層做了一個簡單的 if...else...。
至此已經可以運行一個不含相機功能的應用了,下圖演示了這些 CMP 代碼在 iOS 上運行時的兩個頁面。
集成 CameraView 功能
如上文分析我們需要拆除 CameraView 的部分用 Native 實現,因此在 Common 的 CameraView里我們使用了兩個 expect的 Composable 函數 CameraPermissionControl 和CameraPreview:
@Composable fun CameraView( threshold: Float, maxResults: Int, delegate: Int, mlModel: Int, setInferenceTime: (newInferenceTime: Int) -> Unit, ) { CameraPermissionControl { CameraPreview( threshold, maxResults, delegate, mlModel, setInferenceTime, onDetectionResultUpdate = { detectionResults -> ... }) } } @Composable expect fun CameraPermissionControl(PermissionGrantedContent: @Composable @UiComposable () -> Unit) ```kotlin @Composable expect fun CameraPreview( threshold: Float, maxResults: Int, delegate: Int, mlModel: Int, setInferenceTime: (newInferenceTime: Int) -> Unit, onDetectionResultUpdate: (result: ObjectDetectionResult) -> Unit )
Android 側的 CameraView 實現
Android 端的實現十分簡單,直接將原有的 Jetpack Compose 代碼拷貝過來:
// Android implementation @OptIn(ExperimentalPermissionsApi::class) @Composable actual fun CameraPermissionControl( PermissionGrantedContent: @Composable @UiComposable () -> Unit) { val storagePermissionState: PermissionState = rememberPermissionState(Manifest.permission.CAMERA) LaunchedEffect(key1 = Unit) { if (!storagePermissionState.hasPermission) { storagePermissionState.launchPermissionRequest() } } if (!storagePermissionState.hasPermission) { Text(text = "No Storage Permission!") } else { PermissionGrantedContent() } } @Composable actual fun CameraPreview(...) { ... // Some properties' definition DisposableEffect(Unit) { onDispose { active = false; cameraProviderFuture.get().unbindAll() } } // Next we describe the UI of this camera view. BoxWithConstraints(..) { val cameraPreviewSize = getFittedBoxSize( containerSize = Size( width = this.maxWidth.value, height = this.maxHeight.value, ), boxSize = Size( width = frameWidth.toFloat(), height = frameHeight.toFloat() ) ) Box( Modifier .width(cameraPreviewSize.width.dp) .height(cameraPreviewSize.height.dp), ) { // We're using CameraX to use the phone's camera, and since it doesn't have a prebuilt // composable in Jetpack Compose, we use AndroidView to implement it AndroidView( factory = { ctx -> val previewView = PreviewView(ctx) val executor = ContextCompat.getMainExecutor(ctx) 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() // We instantiate an image analyser to apply some transformations on the // input frame before feeding it to the object detector val imageAnalyzer = ImageAnalysis.Builder() .setTargetAspectRatio(AspectRatio.RATIO_4_3) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) .build() // Now we're ready to apply object detection. For a better performance, we // execute the object detection process in a new thread. val backgroundExecutor = Executors.newSingleThreadExecutor() backgroundExecutor.execute { // To apply object detection, we use our ObjectDetectorHelper class, // which abstracts away the specifics of using MediaPipe for object // detection from the UI elements of the app val objectDetectorHelper = AndroidObjectDetector( context = ctx, threshold = threshold, currentDelegate = delegate, currentModel = mlModel, maxResults = maxResults, objectDetectorListener = ObjectDetectorListener( onErrorCallback = { _, _ -> }, onResultsCallback = { // On receiving results, we now have the exact camera // frame dimensions, so we set them here frameHeight = it.inputImageHeight frameWidth = it.inputImageWidth // Then we check if the camera view is still active, // if so, we set the state of the results and // inference time. if (active) { results = it.results.first() setInferenceTime(it.inferenceTime.toInt()) } } ), runningMode = RunningMode.LIVE_STREAM ) // Now that we have our ObjectDetectorHelper instance, we set is as an // analyzer and start detecting objects from the camera live stream imageAnalyzer.setAnalyzer( backgroundExecutor, objectDetectorHelper::detectLivestreamFrame ) } // We close any currently open camera just in case, then open up // our own to be display the live camera feed cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, cameraSelector, imageAnalyzer, preview ) }, executor) // We return our preview view from the AndroidView factory to display it previewView }, modifier = Modifier.fillMaxSize(), ) // Finally, we check for current results, if there's any, we display the results overlay results?.let { ResultsOverlay( results = it, frameWidth = frameWidth, frameHeight = frameHeight ) } } } }iOS 側的 CameraView 實現
iOS 則稍微需要一些精力。對于相機權限控制,我們直接在這個 Composable 函數中調用 iOS 的 platform.AVFoundation相關 API,異步發起請求然后根據結果顯示加載中、失敗、或成功時直接顯示相機預覽。可以看到我們做的 iOS 實現已十分完善,考慮到了三個不同場景 :D
... import platform.AVFoundation.AVAuthorizationStatusAuthorized import platform.AVFoundation.AVAuthorizationStatusDenied import platform.AVFoundation.AVAuthorizationStatusNotDetermined import platform.AVFoundation.AVAuthorizationStatusRestricted import platform.AVFoundation.AVCaptureDevice import platform.AVFoundation.AVMediaTypeVideo import platform.AVFoundation.authorizationStatusForMediaType import platform.AVFoundation.requestAccessForMediaType import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @Composable actual fun CameraPermissionControl(PermissionGrantedContent: @Composable @UiComposable () -> Unit) { var hasCameraPermission by remember { mutableStateOf(null) } LaunchedEffect(Unit) { hasCameraPermission = requestCameraAccess() } when (hasCameraPermission) { true -> { PermissionGrantedContent() } false -> { Text("Camera permission denied. Please grant access from settings.") } null -> { Text("Requesting camera permission...") } } } private suspend fun requestCameraAccess(): Boolean = suspendCoroutine { continuation -> val authorizationStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) when (authorizationStatus) { AVAuthorizationStatusNotDetermined -> { AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted -> continuation.resume(granted) } } AVAuthorizationStatusRestricted, AVAuthorizationStatusDenied -> { continuation.resume(false) } AVAuthorizationStatusAuthorized -> { continuation.resume(true) } else -> { continuation.resume(false) } } }
然后來到核心的相機預覽功能。從 CMP 的文檔中我們知道,使用 UIKitView 即可在 Composable 函數中嵌入一個 iOS 的 View。
// Example 1 UIKitView( factory = { MKMapView() }, modifier = Modifier.size(300.dp), ) // Example 2 @OptIn(ExperimentalForeignApi::class) @Composable fun UseUITextField(modifier: Modifier = Modifier) { var message by remember { mutableStateOf("Hello, World!") } UIKitView( factory = { val textField = object : UITextField(CGRectMake(0.0, 0.0, 0.0, 0.0)) { @ObjCAction fun editingChanged() { message = text ?: "" } } textField.addTarget( target = textField, action = NSSelectorFromString(textField::editingChanged.name), forControlEvents = UIControlEventEditingChanged ) textField }, modifier = modifier.fillMaxWidth().height(30.dp), update = { textField -> textField.text = message } ) }
文檔 https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-uikit-integration.html
仔細觀察這兩個示例會發現其使用的都是默認 UIKit 控件,而非工程自定義的;對應的引用則是 JetBrains 提前轉換了相關的代碼接口到 Kotlin,例如 platform.UIKit.UITextField 默認可以導入到 KMP 工程的 iOS target。但對于我們的工程情況不太相同,我們想要復用的是一個帶有識別功能的自定義 CameraPreview 視圖。
換個角度看,KMP 產出的 app.framework 是一個基礎共享層,iOS 原生代碼依賴于這個庫。從依賴關系上,我們無法直接調用 iOS App 源碼中的CamerePreview。解決方法也不難,一般分兩種:
把相關代碼打包成一個獨立模塊,產出 cameraview.freamework,讓app 依賴它。
iOS App 在初始化 app.framework 時,傳入一個 lambda 到app 用來初始化并返回一個UIView。
此處我們采用第二種方案,定義 IOSCameraPreviewCreator 作為兩側交互的協議。
// 定義 typealias IOSCameraPreviewCreator = ( threshold: Float, maxResults: Int, delegate: Int, mlModel: Int, setInferenceTime: (newInferenceTime: Int) -> Unit, callback: IOSCameraPreviewCallback ) -> UIView typealias IOSCameraPreviewCallback = (result: ObjectDetectionResult) -> Unit // 在啟動時從 iOS 端傳入相關實現,并加入到 Koin 的 Definition fun onStartup(iosCameraPreviewCreator: IOSCameraPreviewCreator) { Startup.run { koinApp -> koinApp.apply { modules(module { single { LLMOperatorFactory() } single{ iosCameraPreviewCreator } }) } } } // 回到 CameraPreview 的實現,我們只要執行注入, // 并 invoke 這個函數獲得 UIView 實例。 ... import androidx.compose.ui.viewinterop.UIKitView import platform.UIKit.UIView @Composable actual fun CameraPreview( threshold: Float, maxResults: Int, delegate: Int, mlModel: Int, setInferenceTime: (newInferenceTime: Int) -> Unit, onDetectionResultUpdate: (result: ObjectDetectionResult) -> Unit, ) { val iOSCameraPreviewCreator = koinInject () // 和 Android 端集成原生 Camera View 的方式有幾分相似 UIKitView( factory = { val iosCameraPreview: UIView = iOSCameraPreviewCreator( threshold, maxResults, delegate, mlModel, setInferenceTime, onDetectionResultUpdate) iosCameraPreview }, modifier = Modifier.fillMaxSize(), update = { _ -> } ) }
上述代碼使用 Koin 管理依賴簡化了流程。至此 CMP 的部分已經完成,我們順延啟動參數的注入去探究 iOS 的部分。
MainKt.onStartup(iosCameraPreviewCreator: { threshold, maxResults, delegate, mlModel, onInferenceTimeUpdate, resultCallback in return IOSCameraView.init( frame: CGRectMake(0, 0, 0, 0), modelName: Int(truncating: mlModel) == 0 ? "EfficientDet-Lite0" : "EfficientDet-Lite2", maxResults: Int(truncating: maxResults), scoreThreshold: Float(truncating: threshold), onInferenceTimeUpdate: onInferenceTimeUpdate, resultCallback: resultCallback ) })該IOSCameraView 實際上即原 iOS Demo 中的 CameraViewController,我們僅修改一些初始化和生命周期的內容,并簡化掉了參數變化監聽的部分以突出核心遷移內容:
生命周期處理:ViewController 使用 viewDidLoad 等生命周期方法,UIView 則用 didMoveToWindow 處理視圖添加或移除時的邏輯。ViewController 通過生命周期管理初始化,而 UIView 提供自定義初始化方法來傳遞模型和檢測參數。
子視圖設置:ViewController 使用@IBOutlet 和 Interface Builder,而UIView 通過 setupView 方法直接創建并添加子視圖,手動使用 AutoLayout 設置約束以及手動設置點擊事件。
回調和委托:ViewController 使用委托,而 UIView 增加了回調閉包 onInferenceTimeUpdate 和resultCallback,初始化時傳入這些參數并設置好類型轉換,方便后面回調到 KMP 層。
我們同時保留了OverlayView CameraFeedService ObjectDetectorService 和部分DefaultConstants,此處不對他們的代碼進行修改。其中ObjectDetectorService 即是對 Object Detection SDK 的封裝,如果觀察它的 API 調用,會發現其和 iOS 的 Camera API 緊密耦合 (CMSampleBuffer 等),說明了其難以在 Common 抽象,呼應了文初對 Camera 相關服務的分析。
至此,我們就可以把 iOS 端的相機預覽加 Object Detection 也跑起來。
簡單測試
上方的動圖展示了 EfficientDet-Lite0 加 CPU 模式在 iPhone 13mini 執行的效果。官方使用 Pixel 6 CPU/GPU 的測試中,轉去 GPU 執行還能再小幅提高一些性能。不難看出,其實時性已足夠滿足生產環境的需求,同時在準確率方面表現尚可。
隨 Demo 工程搭載的可選模型有兩個:
EfficientDet-Lite0 模型使用 320x320 輸入,平衡了延遲和準確性,適合輕量級應用。Demo 中默認搭載了其 float 32 版本的模型。
EfficientDet-Lite2 模型使用 448x448 輸入,準確性更高,但速度較慢,適合對準確性要求更高的場景。Demo 中默認搭載了其 float 32 版本的模型。
這兩種模型均使用包含 150 萬個實例和 80 種物體標簽的訓練集進行訓練。
總結
一些傳統的 ML 模型在移動設備上的應用已經相對成熟,可以應對不少單一和專途的場景。而本文的兩個模型亦只有 13~25MB,相比 LLM 的模型動輒 1GB 以上,這類模型完全沒有落地的負擔。
使用 Compose Multiplatform 內嵌 UiKit 的 View 可以解決很多高性能、需要原生 API 和硬件的情況。
為了盡可能還原 Demo 的效果同時減少遷移成本,ResultOverlay 在本次遷移中雖然已經放到 Common 層,且 iOS 側也已設置結果回調到 KMP,但 iOS 上依舊使用了原生 View 實現?,F實場景中,我們可進一步擴展思考:
倘若業務場景簡單,例如也是方框識別且全屏展示 camera preview,則可以在 Compose 層簡單復用ResultOverlay。
倘若業務場景復雜,例如視頻聊天時的人臉識別加貼圖選擇和渲染,因業務部分的高復雜度使得復用同一個 StickerOverlay 的價值非常高,這個情況下 Camera Preview 無論大小如何,適配成本反倒都可以接受。另外對于 StickerOverlay 的位置計算,理論上也存在優化的空間,例如采樣計算然后中間用插值動畫移動。
一些依賴管理的復雜場景包括 UI 視圖的注入,借助類似 Koin 依賴注入框架可大幅簡化。
這次遷移的部分還有相冊選擇、照片與視頻解析等等未實現,感興趣的朋友可以自行添加測試,像讀取權限申請、播放器 View 的嵌入和本文的遷移過程會非常類似。
-
Android
+關注
關注
12文章
3945瀏覽量
128005 -
攝像頭
+關注
關注
60文章
4871瀏覽量
96392 -
移植
+關注
關注
1文章
383瀏覽量
28198 -
LLM
+關注
關注
0文章
301瀏覽量
411
原文標題:【GDE 分享】移植 Mediapipe Demo 到 Kotlin Multiplatform (2)
文章出處:【微信號:Google_Developers,微信公眾號:谷歌開發者】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
【米爾百度大腦EdgeBoard邊緣AI計算盒試用連載】III. 板載深度學習DEMO-detection測試-上(ZMJ)
【米爾百度大腦EdgeBoard邊緣AI計算盒試用連載】III. 板載深度學習DEMO-detection測試-下(ZMJ)
STM32程序的移植詳解步驟
如何執行object_detection_demo.py以使用攝像頭作為對象檢測Python演示的輸入?
Java Object Serialization Spec
Object-Oriented Programming in
uCOSII在LPC2210上的移植詳解
什么是CORBA (Common Object Reques
Object類中的所有方法
![<b class='flag-5'>Object</b>類中的所有方法](https://file1.elecfans.com/web2/M00/A9/C1/wKgZomUovfqACzDIAAB-sGpEaUk706.jpg)
評論