[譯] 充分利用多攝像頭 API

_夏霂熠雨發表於2019-01-15

充分利用多攝像頭 API

這篇部落格是對我們的 Android 開發者峰會 2018 演講 的補充,是與來自合作伙伴開發者團隊中的 Vinit Modi、Android Camera PM 和 Emilie Roberts 合作完成的。檢視我們之前在該系列中的文章,包括 相機列舉相機拍攝會話和請求同時使用多個攝像機流

多攝像頭用例

多攝像頭是在 Android Pie 中引入的,自幾個月前釋出以來,現現在已有多個支援該 API 的裝置進入了市場,比如谷歌 Pixel 3 和華為 Mate 20 系列。許多多攝像頭用例與特定的硬體配置緊密結合;換句話說,並非所有的用例都適配每臺裝置 — 這使得多攝像頭功能成為模組 動態傳輸 的一個理想選擇。一些典型的用例包括:

  • 縮放:根據裁剪區域或所需焦距在相機之間切換
  • 深度:使用多個攝像頭構建深度圖
  • 背景虛化:使用推論的深度資訊來模擬類似 DSLR(digital single-lens reflex camera)的窄焦距範圍

邏輯和物理攝像頭

要了解多攝像頭 API,我們必須首先了解邏輯攝像頭和物理攝像頭之間的區別;這個概念最好用一個例子來說明。例如,我我們可以想像一個有三個後置攝像頭而沒有前置攝像頭的裝置。在本例中,三個後置攝像頭中的每一個都被認為是一個物理攝像頭。然後邏輯攝像頭就是兩個或更多這些物理攝像頭的分組。邏輯攝像頭的輸出可以是來自其中一個底層物理攝像機的一個流,也可以是同時來自多個底層物理攝像機的融合流;這兩種方式都是由相機的 HAL(Hardware Abstraction Layer)來處理的。

許多手機制造商也開發了他們自身的相機應用程式(通常預先安裝在他們的裝置上)。為了利用所有硬體的功能,他們有時會使用私有或隱藏的 API,或者從驅動程式實現中獲得其他應用程式沒有特權訪問的特殊處理。有些裝置甚至通過提供來自不同物理雙攝像頭的融合流來實現邏輯攝像頭的概念,但同樣,這隻對某些特權應用程式可用。通常,框架只會暴露一個物理攝像頭。Android Pie 之前第三方開發者的情況如下圖所示:

[譯] 充分利用多攝像頭 API

相機功能通常只對特權應用程式可用

從 Android Pie 開始,一些事情發生了變化。首先,在 Android 應用程式中使用 私有 API 不再可行。其次,Android 框架中包含了 多攝像頭支援,Android 已經 強烈推薦 手機廠商為面向同一方向的所有物理攝像頭提供邏輯攝像頭。因此,這是第三方開發人員應該在執行 Android Pie 及以上版本的裝置上看到的內容:

[譯] 充分利用多攝像頭 API

開發人員可完全訪問從 Android P 開始的所有攝像頭裝置

值得注意的是,邏輯攝像頭提供的功能完全依賴於相機 HAL 的 OEM 實現。例如,像 Pixel 3 是根據請求的焦距和裁剪區域選擇其中一個物理攝像頭,用於實現其邏輯相機。

多攝像頭 API

新 API 包含了以下新的常量、類和方法:

  • CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA
  • CameraCharacteristics.getPhysicalCameraIds()
  • CameraCharacteristics.getAvailablePhysicalCameraRequestKeys()
  • CameraDevice.createCaptureSession(SessionConfiguration config)
  • CameraCharactersitics.LOGICAL_MULTI_CAMERA_SENSOR_SYNC_TYPE
  • OutputConfiguration & SessionConfiguration

由於 Android CDD 的更改,多攝像頭 API 也滿足了開發人員的某些期望。雙攝像頭裝置在 Android Pie 之前就已經存在,但同時開啟多個攝像頭需要反覆試驗;Android 上的多攝像頭 API 現在給了我們一組規則,告訴我們什麼時候可以開啟一對物理攝像頭,只要它們是同一邏輯攝像頭的一部分。

如上所述,我們可以預期,在大多數情況下,使用 Android Pie 釋出的新裝置將公開所有物理攝像頭(除了更奇特的感測器型別,如紅外線),以及更容易使用的邏輯攝像頭。此外,非常關鍵的是,我們可以預期,對於每個保證有效的融合流,屬於邏輯攝像頭的一個流可以被來自底層物理攝像頭的兩個流替換。讓我們通過一個例子更詳細地介紹它。

同時使用多個流

在上一篇博文中,我們詳細介紹了在單個攝像頭中 同時使用多個流 的規則。同樣的規則也適用於多個攝像頭,但在 這個文件 中有一個值得注意的補充說明:

對於每個有保證的融合流,邏輯攝像頭都支援將一個邏輯 YUV_420_888 或原始流替換為兩個相同大小和格式的物理流,每個物理流都來自一個單獨的物理攝像頭,前提是兩個物理攝像頭都支援給定的大小和格式。

換句話說,YUV 或 RAW 型別的每個流可以用相同型別和大小的兩個流替換。例如,我們可以從單攝像頭裝置的攝像頭視訊流開始,配置如下:

  • 流 1:YUV 型別,id = 0 的邏輯攝像機的最大尺寸

然後,一個支援多攝像頭的裝置將允許我們建立一個會話,用兩個物理流替換邏輯 YUV 流:

  • 流 1:YUV 型別,id = 1 的物理攝像頭的最大尺寸
  • 流 2:YUV 型別,id = 2 的物理攝像頭的最大尺寸

訣竅是,當且僅當這兩個攝像頭是一個邏輯攝像頭分組的一部分時,我們可以用兩個等效的流替換 YUV 或原始流 — 即被列在 CameraCharacteristics.getPhysicalCameraIds() 中的。

另一件需要考慮的事情是,框架提供的保證僅僅是同時從多個物理攝像頭獲取幀的最低要求。我們可以期望在大多數裝置中支援額外的流,有時甚至允許我們獨立地開啟多個物理攝像頭裝置。不幸的是,由於這不是框架的硬性保證,因此需要我們通過反覆試驗來執行每個裝置的測試和調優。

使用多個物理攝像頭建立會話

當我們在一個支援多攝像頭的裝置中與物理攝像頭互動時,我們應該開啟一個 CameraDevice(邏輯相機),並在一個會話中與它互動,這個會話必須使用 API CameraDevice.createCaptureSession(SessionConfiguration config) 建立,這個 API 自 SDK 級別 28 起可用。然後,這個 會話引數 將有很多 輸出配置,其中每個輸出配置將具有一組輸出目標,以及(可選的)所需的物理攝像頭 ID。

[譯] 充分利用多攝像頭 API

會話引數和輸出配置模型

稍後,當我們分派拍攝請求時,該請求將具有與其關聯的輸出目標。框架將根據附加到請求的輸出目標來決定將請求傳送到哪個物理(或邏輯)攝像頭。如果輸出目標對應於作為 輸出配置 的輸出目標之一和物理攝像頭 ID 一起傳送,那麼該物理攝像頭將接收並處理該請求。

使用一對物理攝像頭

面向開發人員的多攝像頭 API 中最重要的一個新增功能是識別邏輯攝像頭並找到它們背後的物理攝像頭。現在我們明白,我們可以同時開啟多個物理攝像頭(再次,通過開啟邏輯攝像頭和作為同一會話的一部分),並且有明確的融合流的規則,我們可以定義一個函式來幫助我們識別潛在的可以用來替換一個邏輯攝像機視訊流的一對物理攝像頭:

/**
* 幫助類,用於封裝邏輯攝像頭和兩個底層
* 物理攝像頭
*/
data class DualCamera(val logicalId: String, val physicalId1: String, val physicalId2: String)

fun findDualCameras(manager: CameraManager, facing: Int? = null): Array<DualCamera> {
    val dualCameras = ArrayList<DualCamera>()

    // 遍歷所有可用的攝像頭特徵
    manager.cameraIdList.map {
        Pair(manager.getCameraCharacteristics(it), it)
    }.filter {
        // 通過攝像頭的方向這個請求引數進行過濾
        facing == null || it.first.get(CameraCharacteristics.LENS_FACING) == facing
    }.filter {
        // 邏輯攝像頭過濾
        it.first.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)!!.contains(
                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA)
    }.forEach {
        // 物理攝像頭列表中的所有可能對都是有效結果
        // 注意:可能有 N 個物理攝像頭作為邏輯攝像頭分組的一部分
        val physicalCameras = it.first.physicalCameraIds.toTypedArray()
        for (idx1 in 0 until physicalCameras.size) {
            for (idx2 in (idx1 + 1) until physicalCameras.size) {
                dualCameras.add(DualCamera(
                        it.second, physicalCameras[idx1], physicalCameras[idx2]))
            }
        }
    }

    return dualCameras.toTypedArray()
}
複製程式碼

物理攝像頭的狀態處理由邏輯攝像頭控制。因此,要開啟我們的“雙攝像頭”,我們只需要開啟與我們感興趣的物理攝像頭相對應的邏輯攝像頭:

fun openDualCamera(cameraManager: CameraManager,
                   dualCamera: DualCamera,
                   executor: Executor = AsyncTask.SERIAL_EXECUTOR,
                   callback: (CameraDevice) -> Unit) {

    cameraManager.openCamera(
            dualCamera.logicalId, executor, object : CameraDevice.StateCallback() {
        override fun onOpened(device: CameraDevice) = callback(device)
        // 為了簡便起見,我們省略...
        override fun onError(device: CameraDevice, error: Int) = onDisconnected(device)
        override fun onDisconnected(device: CameraDevice) = device.close()
    })
}
複製程式碼

在此之前,除了選擇開啟哪臺攝像頭之外,沒有什麼不同於我們過去開啟任何其他攝像頭所做的事情。現在是時候使用新的 會話引數 API 建立一個拍攝會話了,這樣我們就可以告訴框架將某些目標與特定的物理攝像機 ID 關聯起來:

/**
 * 幫助類,封裝了定義 3 組輸出目標的型別:
 *
 *   1. 邏輯攝像頭
 *   2. 第一個物理攝像頭
 *   3. 第二個物理攝像頭
 */
typealias DualCameraOutputs =
        Triple<MutableList<Surface>?, MutableList<Surface>?, MutableList<Surface>?>

fun createDualCameraSession(cameraManager: CameraManager,
                            dualCamera: DualCamera,
                            targets: DualCameraOutputs,
                            executor: Executor = AsyncTask.SERIAL_EXECUTOR,
                            callback: (CameraCaptureSession) -> Unit) {

    // 建立三組輸出配置:一組用於邏輯攝像頭,
    // 另一組用於邏輯攝像頭。
    val outputConfigsLogical = targets.first?.map { OutputConfiguration(it) }
    val outputConfigsPhysical1 = targets.second?.map {
        OutputConfiguration(it).apply { setPhysicalCameraId(dualCamera.physicalId1) } }
    val outputConfigsPhysical2 = targets.third?.map {
        OutputConfiguration(it).apply { setPhysicalCameraId(dualCamera.physicalId2) } }

    // 將所有輸出配置放入單個陣列中
    val outputConfigsAll = arrayOf(
            outputConfigsLogical, outputConfigsPhysical1, outputConfigsPhysical2)
            .filterNotNull().flatMap { it }

    // 例項化可用於建立會話的會話配置
    val sessionConfiguration = SessionConfiguration(SessionConfiguration.SESSION_REGULAR,
            outputConfigsAll, executor, object : CameraCaptureSession.StateCallback() {
        override fun onConfigured(session: CameraCaptureSession) = callback(session)
        // 省略...
        override fun onConfigureFailed(session: CameraCaptureSession) = session.device.close()
    })

    // 使用前面定義的函式開啟邏輯攝像頭
    openDualCamera(cameraManager, dualCamera, executor = executor) {

        // 最後建立會話並通過回撥返回
        it.createCaptureSession(sessionConfiguration)
    }
}
複製程式碼

現在,我們可以參考 文件以前的部落格文章 來了解支援哪些流的融合。我們只需要記住這些是針對單個邏輯攝像頭上的多個流的,並且相容使用相同的配置的並將其中一個流替換為來自同一邏輯攝像頭的兩個物理攝像頭的兩個流。

攝像頭會話 就緒後,剩下要做的就是傳送我們想要的 拍攝請求。拍攝請求的每個目標將從相關的物理攝像頭(如果有的話)接收資料,或者返回到邏輯攝像頭。

縮放示例用例

為了將所有這一切與最初討論的用例之一聯絡起來,讓我們看看如何在我們的相機應用程式中實現一個功能,以便使用者能夠在不同的物理攝像頭之間切換,體驗到不同的視野——有效地拍攝不同的“縮放級別”。

[譯] 充分利用多攝像頭 API

將相機轉換為縮放級別用例的示例(來自 Pixel 3 Ad

首先,我們必須選擇我們想允許使用者在其中進行切換的一對物理攝像機。為了獲得最大的效果,我們可以分別搜尋提供最小焦距和最大焦距的一對攝像機。通過這種方式,我們選擇一種可以在儘可能短的距離上對焦的攝像裝置,另一種可以在儘可能遠的點上對焦:

fun findShortLongCameraPair(manager: CameraManager, facing: Int? = null): DualCamera? {

    return findDualCameras(manager, facing).map {
        val characteristics1 = manager.getCameraCharacteristics(it.physicalId1)
        val characteristics2 = manager.getCameraCharacteristics(it.physicalId2)

        // 查詢每個物理攝像頭公佈的焦距
        val focalLengths1 = characteristics1.get(
                CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) ?: floatArrayOf(0F)
        val focalLengths2 = characteristics2.get(
                CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) ?: floatArrayOf(0F)

        // 計算相機之間最小焦距和最大焦距之間的最大差異
        val focalLengthsDiff1 = focalLengths2.max()!! - focalLengths1.min()!!
        val focalLengthsDiff2 = focalLengths1.max()!! - focalLengths2.min()!!

        // 返回相機 ID 和最小焦距與最大焦距之間的差值
        if (focalLengthsDiff1 < focalLengthsDiff2) {
            Pair(DualCamera(it.logicalId, it.physicalId1, it.physicalId2), focalLengthsDiff1)
        } else {
            Pair(DualCamera(it.logicalId, it.physicalId2, it.physicalId1), focalLengthsDiff2)
        }

        // 只返回差異最大的對,如果沒有找到對,則返回 null
    }.sortedBy { it.second }.reversed().lastOrNull()?.first
}
複製程式碼

一個合理的架構應該是有兩個 SurfaceViews,每個流一個,在使用者互動時交換,因此在任何給定的時間只有一個是可見的。在下面的程式碼片段中,我們將演示如何開啟邏輯攝像頭、配置攝像頭輸出、建立攝像頭會話和啟動兩個預覽流;利用前面定義的功能:

val cameraManager: CameraManager = ...

// 從 activity/fragment 中獲取兩個輸出目標
val surface1 = ...  // 來自 SurfaceView
val surface2 = ...  // 來自 SurfaceView

val dualCamera = findShortLongCameraPair(manager)!!
val outputTargets = DualCameraOutputs(
        null, mutableListOf(surface1), mutableListOf(surface2))

// 在這裡,我們開啟邏輯攝像頭,配置輸出並建立一個會話
createDualCameraSession(manager, dualCamera, targets = outputTargets) { session ->

    // 為每個物理相頭建立一個目標的單一請求
    // 注意:每個目標只會從它相關的物理相頭接收幀
    val requestTemplate = CameraDevice.TEMPLATE_PREVIEW
    val captureRequest = session.device.createCaptureRequest(requestTemplate).apply {
        arrayOf(surface1, surface2).forEach { addTarget(it) }
    }.build()

    // 設定會話的粘性請求,就完成了
    session.setRepeatingRequest(captureRequest, null, null)
}
複製程式碼

現在我們需要做的就是為使用者提供一個在兩個介面之間切換的 UI,比如一個按鈕或者雙擊 “SurfaceView”;如果我們想變得更有趣,我們可以嘗試執行某種形式的場景分析,並在兩個流之間自動切換。

鏡頭失真

所有的鏡頭都會產生一定的失真。在 Android 中,我們可以使用 CameraCharacteristics.LENS_DISTORTION(它替換了現在已經廢棄的 CameraCharacteristics.LENS_RADIAL_DISTORTION)查詢鏡頭建立的失真。可以合理地預期,對於邏輯攝像頭,失真將是最小的,我們的應用程式可以使用或多或少的框架,因為他們來自這個攝像頭。然而,對於物理攝像頭,我們應該期待潛在的非常不同的鏡頭配置——特別是在廣角鏡頭上。

一些裝置可以通過 CaptureRequest.DISTORTION_CORRECTION_MODE 實現自動失真校正。很高興知道大多數裝置的失真校正預設為開啟。文件中有一些更詳細的資訊:

FAST/HIGH_QUALITY 均表示將應用相機裝置確定的失真校正。HIGH_QUALITY 模式表示相機裝置將使用最高質量的校正演算法,即使它會降低捕獲率。快速意味著相機裝置在應用校正時不會降低捕獲率。如果任何校正都會降低捕獲速率,則 FAST 可能與 OFF 相同 [...] 校正僅適用於 YUV、JPEG 或 DEPTH16 等已處理的輸出 [...] 預設情況下,此控制元件將在支援此功能的裝置上啟用控制。

如果我們想用最高質量的物理攝像頭拍攝一張照片,那麼我們應該嘗試將校正模式設定為 HIGH_QUALITY(如果可用)。下面是我們應該如何設定拍攝請求:

val cameraSession: CameraCaptureSession = ...

// 使用靜態拍攝模板來構建拍攝請求
val captureRequest = cameraSession.device.createCaptureRequest(
        CameraDevice.TEMPLATE_STILL_CAPTURE)

// 確定該裝置是否支援失真校正
val characteristics: CameraCharacteristics = ...
val supportsDistortionCorrection = characteristics.get(
        CameraCharacteristics.DISTORTION_CORRECTION_AVAILABLE_MODES)?.contains(
        CameraMetadata.DISTORTION_CORRECTION_MODE_HIGH_QUALITY) ?: false

if (supportsDistortionCorrection) {
    captureRequest.set(
            CaptureRequest.DISTORTION_CORRECTION_MODE,
            CameraMetadata.DISTORTION_CORRECTION_MODE_HIGH_QUALITY)
}

// 新增輸出目標,設定其他拍攝請求引數...

// 傳送拍攝請求
cameraSession.capture(captureRequest.build(), ...)
複製程式碼

請記住,在這種模式下設定拍攝請求將對相機可以產生的幀速率產生潛在的影響,這就是為什麼我們只在靜態影像拍攝中設定設定校正。

未完待續

唷!我們介紹了很多與新的多攝像頭 API 相關的東西:

  • 潛在的用例
  • 邏輯攝像頭 vs 物理攝像頭
  • 多攝像頭 API 概述
  • 用於開啟多個攝像頭視訊流的擴充套件規則
  • 如何為一對物理攝像頭設定攝像機流
  • 示例“縮放”用例交換相機
  • 校正鏡頭失真

請注意,我們還沒有涉及幀同步和計算深度圖。這是一個值得在部落格上發表的話題。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章