From 32e281a30c550f2b5d6f5d3f03d362bf3955fcae Mon Sep 17 00:00:00 2001 From: Ruben Kluge <6981313+Rubenkl@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:45:23 +0100 Subject: [PATCH] fix(android): type physical cameras by field of view Physical cameras inside a logical multi-camera always reported `type: 'unknown'`. Type them by the diagonal field of view of their shortest focal length, computed from their own LENS_INFO_AVAILABLE_FOCAL_LENGTHS and SENSOR_INFO_PHYSICAL_SIZE, with the VisionCamera 4 thresholds: above 94 degrees ultra-wide-angle, 60 to 94 wide-angle, below 60 telephoto. If those characteristics are missing, the type stays `unknown`. Adds a Harness test that checks the type of each physical device of a virtual device, and skips when there is none. --- .../__tests__/visioncamera.devices.harness.ts | 28 +++++++++++++++++++ .../extensions/CameraInfo+deviceType.kt | 27 ++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.devices.harness.ts b/apps/simple-camera/__tests__/visioncamera.devices.harness.ts index df49bb6378..378f7ce3ad 100644 --- a/apps/simple-camera/__tests__/visioncamera.devices.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.devices.harness.ts @@ -1,6 +1,7 @@ import { assert, beforeAll, describe, expect, it } from 'react-native-harness' import type { CameraDeviceFactory, + PhysicalDeviceType, TargetCameraPosition, } from 'react-native-vision-camera' import { VisionCamera } from 'react-native-vision-camera' @@ -154,4 +155,31 @@ describe('VisionCamera - Devices', () => { } } }) + + it('reports a physical device type for each physical device of a virtual device', (context) => { + const virtualDevices = factory.cameraDevices.filter( + (d) => d.isVirtualDevice, + ) + if (virtualDevices.length === 0) { + return context.skip( + 'physical device types: no virtual device on this device', + ) + } + const physicalDeviceTypes: PhysicalDeviceType[] = [ + 'ultra-wide-angle', + 'wide-angle', + 'telephoto', + 'true-depth', + 'lidar-depth', + 'time-of-flight-depth', + 'external', + 'continuity', + ] + for (const device of virtualDevices) { + expect(device.physicalDevices).not.toHaveLength(0) + for (const physicalDevice of device.physicalDevices) { + expect(physicalDeviceTypes).toContain(physicalDevice.type) + } + } + }) }) diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/CameraInfo+deviceType.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/CameraInfo+deviceType.kt index d020ff5e09..88dbdcd751 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/CameraInfo+deviceType.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/CameraInfo+deviceType.kt @@ -5,13 +5,21 @@ import androidx.camera.camera2.adapter.PhysicalCameraInfoAdapter import androidx.camera.core.CameraInfo import com.margelo.nitro.camera.DeviceType import com.margelo.nitro.camera.utils.ImageFormatUtils +import kotlin.math.atan2 +import kotlin.math.hypot val CameraInfo.deviceType: DeviceType get() { if (this is PhysicalCameraInfoAdapter) { - // TODO: PhysicalCameraInfoAdapter throws when accessing `intrinsicZoomRatio`, - // so we need another way to figure out what kind of Camera this is... - return DeviceType.UNKNOWN + // A physical Camera is typed by its own field of view, like in VisionCamera 4. + // `intrinsicZoomRatio` is relative to the default Camera at this position, so the main lens + // could land just below or above `1` and be typed as ultra-wide-angle or telephoto. + val fieldOfView = cameraCharacteristicsOrNull?.maxFieldOfView ?: return DeviceType.UNKNOWN + return when { + fieldOfView > 94 -> DeviceType.ULTRA_WIDE_ANGLE + fieldOfView >= 60 -> DeviceType.WIDE_ANGLE + else -> DeviceType.TELEPHOTO + } } if (this.physicalCameraInfos.size > 1) { @@ -46,3 +54,16 @@ private val CameraCharacteristics.supportsDepthOnly: Boolean val isAllDepthFormats = supportedFormats.all { ImageFormatUtils.isDepthFormat(it) } return isAllDepthFormats } + +/** + * The diagonal field of view of this Camera at its shortest focal length, in degrees, + * or `null` if it does not report its focal lengths or sensor size. + */ +private val CameraCharacteristics.maxFieldOfView: Double? + get() { + val sensorSize = this[CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE] ?: return null + val focalLength = this[CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS]?.minOrNull() ?: return null + if (sensorSize.width <= 0f || sensorSize.height <= 0f || focalLength <= 0f) return null + val sensorDiagonal = hypot(sensorSize.width.toDouble(), sensorSize.height.toDouble()) + return Math.toDegrees(2.0 * atan2(sensorDiagonal, 2.0 * focalLength)) + }