マルチカメラ API

注: このページでは、Camera2 パッケージについて言及しています。アプリで Camera2 の特定の低レベル機能が必要な場合を除き、CameraX を使用することをおすすめします。CameraX と Camera2 は、どちらも Android 5.0(API レベル 21)以降に対応しています。

マルチカメラは Android 9(API レベル 28)で導入されました。リリース以降、この API をサポートするデバイスが市場に登場しています。多くのマルチカメラのユースケースは、特定のハードウェア構成と密結合されています。つまり、すべてのユースケースがすべてのデバイスに対応しているわけではないため、マルチカメラ機能は Play Feature Delivery の有力な候補となります。

一般的なユースケースは次のとおりです。

  • ズーム: クロップ領域または選択したレンズ焦点距離に応じてカメラを切り替えます。
  • 深度: 複数のカメラを使用して深度マップを作成します。
  • ボケ: 推定された深度情報を使用して、デジタル一眼レフカメラのような狭い被写界深度をシミュレートします。

論理カメラと物理カメラの違い

マルチカメラ API を理解するには、論理カメラと物理カメラの違いを理解する必要があります。参考として、3 つの背面カメラを搭載したデバイスを考えてみましょう。この例では、3 つの背面カメラのそれぞれが物理カメラと見なされます。論理カメラは、これらの物理カメラを 2 つ以上グループ化したものです。論理カメラの出力は、基盤となる物理カメラの 1 つからのストリーム、または複数の基盤となる物理カメラからの融合ストリームです。いずれの場合も、ストリームはカメラの Hardware Abstraction Layer(HAL)によって処理されます。

多くのスマートフォン メーカーは、ファーストパーティのカメラアプリを開発しており、通常はデバイスにプリインストールされています。ハードウェアのすべての機能を使用するために、非公開 API や非表示 API を使用したり、他のアプリがアクセスできないドライバ実装から特別な処理を受けたりすることがあります。一部のデバイスでは、異なる物理カメラからのフレームの融合ストリームを提供することで論理カメラのコンセプトを実装していますが、これは特定の特権アプリにのみ提供されます。多くの場合、フレームワークに公開されるのは物理カメラの 1 つだけです。Android 9 より前のサードパーティ デベロッパーの状況を次の図に示します。

通常は特権アプリのみが利用できるカメラ機能
図 1. 通常は特権アプリでのみ利用可能なカメラ機能

Android 9 以降、Android アプリで非公開 API を使用することはできなくなりました。フレームワークにマルチカメラ サポートが組み込まれたことで、Android のベスト プラクティスでは、同じ方向を向いたすべての物理カメラに対して論理カメラを公開することが強く推奨されています。サードパーティ デベロッパーは、Android 9 以降を搭載したデバイスで以下を確認する必要があります。

Android 9 以降のすべてのカメラデバイスへの完全なデベロッパー アクセス
図 2. Android 9 以降、デベロッパーはすべてのカメラデバイスにフルアクセス可能

論理カメラが提供するものは、カメラ HAL の OEM 実装によって完全に異なります。たとえば、Google Pixel 3 などのデバイスは、リクエストされたレンズ焦点距離と切り抜き領域に基づいて物理カメラの 1 つを選択するように論理カメラを実装しています。

マルチカメラ API

新しい API では、次の新しい定数、クラス、メソッドが追加されています。

Android 互換性定義ドキュメント(CDD)の変更により、マルチカメラ API にはデベロッパーからの一定の期待も寄せられています。デュアルカメラを搭載したデバイスは Android 9 より前から存在していましたが、複数のカメラを同時に開くには試行錯誤が必要でした。Android 9 以降では、マルチカメラは、同じ論理カメラの一部である物理カメラのペアを開くことができるタイミングを指定する一連のルールを提供します。

ほとんどの場合、Android 9 以降を搭載したデバイスは、すべての物理カメラ(赤外線などの一般的でないセンサータイプを除く)と、より使いやすい論理カメラを公開します。動作が保証されているストリームの組み合わせごとに、論理カメラに属する 1 つのストリームを、その構成要素である物理カメラからの 2 つのストリームに置き換えることができます。

複数のストリームを同時に

複数のカメラ ストリームを同時に使用するでは、1 台のカメラで複数のストリームを同時に使用するためのルールについて説明します。1 つの注目すべき追加機能により、複数のカメラに同じルールが適用されます。CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA では、1 つの論理 YUV_420_888 または RAW ストリームを 2 つの物理ストリームで置き換える方法について説明しています。つまり、YUV または RAW タイプの各ストリームを、同じタイプとサイズの 2 つのストリームに置き換えることができます。シングルカメラ デバイスの場合、次の保証された構成のカメラ ストリームから開始できます。

  • ストリーム 1: YUV タイプ、論理カメラ id = 0 からの MAXIMUM サイズ

次に、マルチカメラをサポートするデバイスで、その論理 YUV ストリームを 2 つの物理ストリームに置き換えるセッションを作成します。

  • ストリーム 1: YUV タイプ、物理カメラ id = 1 からの MAXIMUM サイズ
  • ストリーム 2: YUV タイプ、物理カメラ id = 2 からの MAXIMUM サイズ

YUV または RAW ストリームを 2 つの同等のストリームに置き換えることができるのは、それら 2 つのカメラが CameraCharacteristics.getPhysicalCameraIds() にリストされている論理カメラ グループの一部である場合に限られます。

フレームワークが提供する保証は、複数の物理カメラから同時にフレームを取得するために必要な最小限のものです。ほとんどのデバイスでは追加のストリームがサポートされており、複数の物理カメラデバイスを個別に開くことができる場合もあります。フレームワークによる厳密な保証ではないため、これを行うには、デバイスごとに試行錯誤しながらテストとチューニングを行う必要があります。

複数の物理カメラを使用したセッションの作成

マルチカメラ対応デバイスで物理カメラを使用する場合は、1 つの CameraDevice(論理カメラ)を開き、1 つのセッション内で操作します。API レベル 28 で追加された API CameraDevice.createCaptureSession(SessionConfiguration config) を使用して、単一のセッションを作成します。セッション構成には複数の出力構成があり、それぞれに出力ターゲットのセットと、必要に応じて選択された物理カメラ ID があります。

SessionConfiguration モデルと OutputConfiguration モデル
図 3. SessionConfiguration モデルと OutputConfiguration モデル

キャプチャ リクエストには、出力ターゲットが関連付けられています。フレームワークは、アタッチされている出力ターゲットに基づいて、リクエストの送信先となる物理(または論理)カメラを決定します。出力ターゲットが、物理カメラ ID とともに出力構成として送信された出力ターゲットのいずれかに対応している場合、その物理カメラがリクエストを受信して処理します。

物理カメラのペアを使用する

マルチカメラ用の Camera API に追加されたもう 1 つの機能は、論理カメラを識別し、その背後にある物理カメラを見つける機能です。論理カメラ ストリームの 1 つを置き換えるために使用できる物理カメラのペアを特定する関数を定義できます。

/**
 * Helper class used to encapsulate a logical camera and two underlying
 * physical cameras
 */
data class DualCamera(val logicalId: String, val physicalId1: String, val physicalId2: String)

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

    // Iterate over all the available camera characteristics.
    manager.cameraIdList.map {
        Pair(manager.getCameraCharacteristics(it), it)
    }.filter {
        // Filter by cameras facing the requested direction.
        facing == null || it.first.get(CameraCharacteristics.LENS_FACING) == facing
    }.filter {
        // Filter by logical cameras.
        // CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA requires API >= 28.
        it.first.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)?.contains(
            CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA
        ) ?: false
    }.forEach {
        // All possible pairs from the list of physical cameras are valid results.
        // NOTE: There could be N physical cameras as part of a logical camera grouping.
        // getPhysicalCameraIds() requires API >= 28.
        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
}

物理カメラの状態処理は、論理カメラによって制御されます。「デュアルカメラ」を開くには、物理カメラに対応する論理カメラを開きます。

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

    // openCamera() requires API >= 28.
    cameraManager.openCamera(
        dualCamera.logicalId, executor,
        object : CameraDevice.StateCallback() {
            override fun onOpened(device: CameraDevice) = callback(device)
            // Omitting for brevity...
            override fun onError(device: CameraDevice, error: Int) = onDisconnected(device)
            override fun onDisconnected(device: CameraDevice) = device.close()
        }
    )
}

開くカメラを選択する以外は、以前の Android バージョンでカメラを開く場合と同じプロセスです。新しいセッション構成 API を使用してキャプチャ セッションを作成すると、特定のターゲットを特定の物理カメラ ID に関連付けるようフレームワークに指示します。

/**
 * Helper type definition that encapsulates 3 sets of output targets:
 *
 * 1. Logical camera
 * 2. First physical camera
 * 3. Second physical camera
 */
typealias DualCameraOutputs =
    Triple<MutableList<Surface>?, MutableList<Surface>?, MutableList<Surface>?>

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

    // Create 3 sets of output configurations: one for the logical camera, and
    // one for each of the physical cameras.
    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) }
    }

    // Put all the output configurations into a single flat array.
    val outputConfigsAll = arrayOf(
        outputConfigsLogical, outputConfigsPhysical1, outputConfigsPhysical2
    )
        .filterNotNull().flatMap { it }

    // Instantiate a session configuration that can be used to create a session.
    val sessionConfiguration = SessionConfiguration(
        SessionConfiguration.SESSION_REGULAR,
        outputConfigsAll, executor,
        object : CameraCaptureSession.StateCallback() {
            override fun onConfigured(session: CameraCaptureSession) = callback(session)
            // Omitting for brevity...
            override fun onConfigureFailed(session: CameraCaptureSession) = session.device.close()
        }
    )

    // Open the logical camera using the previously defined function.
    openDualCamera(cameraManager, dualCamera, executor = executor) {

        // Finally create the session and return using the callback.
        it.createCaptureSession(sessionConfiguration)
    }
}

サポートされているストリームの組み合わせについては、createCaptureSession をご覧ください。ストリームの結合は、1 つの論理カメラ上の複数のストリームを対象としています。この互換性は、同じ構成を使用し、それらのストリームの 1 つを、同じ論理カメラの一部である 2 つの物理カメラからの 2 つのストリームに置き換える場合にも適用されます。

カメラ セッションの準備ができたら、選択したキャプチャ リクエストをディスパッチします。キャプチャ リクエストの各ターゲットは、関連付けられた物理カメラ(使用中の場合)からデータを受信するか、論理カメラにフォールバックします。

Zoom のユースケースの例

物理カメラを 1 つのストリームに統合することで、ユーザーは異なる物理カメラを切り替えて異なる画角を体験し、異なる「ズームレベル」を効果的にキャプチャできます。

ズームレベルのユースケースでカメラを切り替える例
図 4. ズームレベルのユースケースでカメラを切り替える例(Google Pixel 3 の広告より)

まず、ユーザーが切り替えられるようにする物理カメラのペアを選択します。効果を最大にするには、利用可能な最小レンズ焦点距離と最大レンズ焦点距離を提供するカメラのペアを選択します。

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)

        // Query the focal lengths advertised by each physical camera.
        val focalLengths1 = characteristics1.get(
            CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS
        ) ?: floatArrayOf(0F)
        val focalLengths2 = characteristics2.get(
            CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS
        ) ?: floatArrayOf(0F)

        // Compute the largest difference between min and max focal lengths between cameras.
        val focalLengthsDiff1 = (focalLengths2.maxOrNull() ?: 0f) - (focalLengths1.minOrNull() ?: 0f)
        val focalLengthsDiff2 = (focalLengths1.maxOrNull() ?: 0f) - (focalLengths2.minOrNull() ?: 0f)

        // Return the pair of camera IDs and the difference between min and max focal lengths.
        if (focalLengthsDiff1 < focalLengthsDiff2) {
            Pair(DualCamera(it.logicalId, it.physicalId1, it.physicalId2), focalLengthsDiff1)
        } else {
            Pair(DualCamera(it.logicalId, it.physicalId2, it.physicalId1), focalLengthsDiff2)
        }

        // Return only the pair with the largest difference, or null if no pairs are found.
    }.maxByOrNull { it.second }?.first
}

この場合の適切なアーキテクチャは、2 つの SurfaceViews(ストリームごとに 1 つ)を使用することです。これらの SurfaceViews はユーザー操作に基づいて入れ替えられ、一度に 1 つだけ表示されます。

次のコードは、論理カメラを開き、カメラ出力を構成し、カメラ セッションを作成して、2 つのプレビュー ストリームを開始する方法を示しています。

val cameraManager: CameraManager = ...
val cameraExecutor: Executor = ...  // owned by your activity / fragment

// Get the two output targets from the activity / fragment.
val surface1 = ...  // from SurfaceView
val surface2 = ...  // from SurfaceView

val dualCamera = findShortLongCameraPair(cameraManager) ?: return
val outputTargets = DualCameraOutputs(
    null, mutableListOf(surface1), mutableListOf(surface2)
)

// Here you open the logical camera, configure the outputs and create a session.
createDualCameraSession(
    cameraManager, dualCamera, targets = outputTargets, executor = cameraExecutor
) { session ->

    // Create a single request which has one target for each physical camera.
    // NOTE: Each target receive frames from only its associated physical camera.
    val requestTemplate = CameraDevice.TEMPLATE_PREVIEW
    val captureRequest = session.device.createCaptureRequest(requestTemplate).apply {
        arrayOf(surface1, surface2).forEach { addTarget(it) }
    }.build()

    // Set the sticky request for the session and you are done.
    session.setRepeatingRequest(captureRequest, null, null)
}

あとは、ボタンや SurfaceView のダブルタップなど、ユーザーが 2 つのサーフェスを切り替えるための UI を提供するだけです。なんらかのシーン分析を行い、2 つのストリームを自動的に切り替えることもできます。

レンズ歪み

すべてのレンズは、ある程度の歪みを生じます。Android では、CameraCharacteristics.LENS_DISTORTION を使用してレンズによって生じる歪みをクエリできます。これは、非推奨となった CameraCharacteristics.LENS_RADIAL_DISTORTION に代わるものです。論理カメラの場合、歪みは最小限に抑えられ、アプリはカメラから取得したフレームをほぼそのまま使用できます。物理カメラの場合、特に広角レンズでは、レンズ構成が大きく異なる可能性があります。

一部のデバイスでは、CaptureRequest.DISTORTION_CORRECTION_MODE を使用して自動歪み補正を実装している場合があります。歪み補正は、ほとんどのデバイスでデフォルトでオンになっています。

val cameraSession: CameraCaptureSession = ...

// Use still capture template to build the capture request.
val captureRequest = cameraSession.device.createCaptureRequest(
    CameraDevice.TEMPLATE_STILL_CAPTURE
)

// Determine if this device supports distortion correction.
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
    )
}

// Add output target, set other capture request parameters...

// Dispatch the capture request.
cameraSession.capture(captureRequest.build(), ...)

このモードでキャプチャ リクエストを設定すると、カメラで生成できるフレームレートに影響する可能性があります。歪み補正を静止画キャプチャのみに設定することもできます。