[譯] 如何在 Android 開發中充分利用多攝像頭 API

這篇博客是對咱們的 Android 開發者峯會 2018 演講 的補充,是與來自合做夥伴開發者團隊中的 Vinit Modi、Android Camera PM 和 Emilie Roberts 合做完成的。查看咱們以前在該系列中的文章,包括 相機枚舉相機拍攝會話和請求同時使用多個攝像機流html

多攝像頭用例

多攝像頭是在 Android Pie 中引入的,自幾個月前發佈以來,現如今已有多個支持該 API 的設備進入了市場,好比谷歌 Pixel 3 和華爲 Mate 20 系列。許多多攝像頭用例與特定的硬件配置緊密結合;換句話說,並不是全部的用例都適配每臺設備 — 這使得多攝像頭功能成爲模塊 動態傳輸 的一個理想選擇。一些典型的用例包括:前端

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

邏輯和物理攝像頭

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

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

相機功能一般只對特權應用程序可用git

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

開發人員可徹底訪問從 Android P 開始的全部攝像頭設備算法

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

多攝像頭 API

新 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。

會話參數和輸出配置模型

稍後,當咱們分派拍攝請求時,該請求將具備與其關聯的輸出目標。框架將根據附加到請求的輸出目標來決定將請求發送到哪一個物理(或邏輯)攝像頭。若是輸出目標對應於做爲 輸出配置 的輸出目標之一和物理攝像頭 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)
    }
}
複製代碼

如今,咱們能夠參考 文檔之前的博客文章 來了解支持哪些流的融合。咱們只須要記住這些是針對單個邏輯攝像頭上的多個流的,而且兼容使用相同的配置的並將其中一個流替換爲來自同一邏輯攝像頭的兩個物理攝像頭的兩個流。

攝像頭會話 就緒後,剩下要作的就是發送咱們想要的 拍攝請求。拍攝請求的每一個目標將從相關的物理攝像頭(若是有的話)接收數據,或者返回到邏輯攝像頭。

縮放示例用例

爲了將全部這一切與最初討論的用例之一聯繫起來,讓咱們看看如何在咱們的相機應用程序中實現一個功能,以便用戶可以在不一樣的物理攝像頭之間切換,體驗到不一樣的視野——有效地拍攝不一樣的「縮放級別」。

將相機轉換爲縮放級別用例的示例(來自 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索