feat: Implement exposure (#2173)

* feat: Implement `exposure` (iOS)

* Update Podfile.lock

* Format

* Expose exposure in format

* Set exposure in Camera2

* fix: Fix exposure calculation

* Add exposure docs
This commit is contained in:
Marc Rousavy 2023-11-19 15:26:43 +01:00 committed by GitHub
parent a7e706150e
commit ef58d13b87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 156 additions and 8 deletions

View File

@ -0,0 +1,71 @@
---
id: exposure
title: Exposure
sidebar_label: Exposure
---
### Adjusting exposure
To adjust the exposure of the Camera, you can use the Camera's [`exposure`](/docs/api/interfaces/CameraProps#exposure) property:
```tsx
<Camera {...props} exposure={-1} />
```
Values for the `exposure` prop range from [`format.minExposure`](/docs/api/interfaces/CameraDeviceFormat#maxExposure) to [`format.maxExposure`](/docs/api/interfaces/CameraDeviceFormat#minExposure), inclusively. By default (`undefined`), it is set to neutral auto exposure.
Instead of manually adjusting ISO and Exposure-Duration, this acts as an "exposure compensation bias", meaning the Camera will still continuously automatically adjust exposure as it goes, but premultiplies the given exposure value to it's ISO and Exposure Duration settings.
### Examples
![Exposure Example (-2, 0, 2)](/img/exposure.jpg)
### Animating
Just like [`zoom`](zooming), this property can be animated using Reanimated.
1. Add the `exposure` prop to the whitelisted animateable properties:
```tsx
import Reanimated, { addWhitelistedNativeProps } from "react-native-reanimated"
const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera)
addWhitelistedNativeProps({
exposure: true,
})
```
2. Implement your animation, for example with an exposure slider:
```jsx
function App() {
// 1. create shared value for exposure slider (from -1..0..1)
const exposureSlider = useSharedValue(0)
// 2. map slider to [minExposure, 0, maxExposure]
const exposureValue = useDerivedValue(() => {
if (format == null) return 0
return interpolate(exposureSlider.value,
[-1, 0, 1],
[format.minExposure, 0, format.maxExposure])
}, [exposureSlider, format])
// 3. pass it as an animated prop
const animatedProps = useAnimatedProps(() => ({
exposure: exposureValue.value
}), [exposureValue])
// 4. render Camera
return (
<ReanimatedCamera
{...props}
animatedProps={animatedProps}
/>
)
}
```
<br />
#### 🚀 Next section: [HDR](hdr)

View File

@ -22,4 +22,4 @@ Focussing adjusts auto-focus (AF) and auto-exposure (AE).
<br /> <br />
#### 🚀 Next section: [HDR](hdr) #### 🚀 Next section: [Exposure](exposure)

View File

@ -31,6 +31,7 @@ module.exports = {
}, },
'guides/zooming', 'guides/zooming',
'guides/focusing', 'guides/focusing',
'guides/exposure',
'guides/hdr', 'guides/hdr',
'guides/stabilization', 'guides/stabilization',
'guides/performance', 'guides/performance',

BIN
docs/static/img/exposure.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@ -78,6 +78,7 @@ class CameraView(context: Context) :
var isActive = false var isActive = false
var torch: Torch = Torch.OFF var torch: Torch = Torch.OFF
var zoom: Float = 1f // in "factor" var zoom: Float = 1f // in "factor"
var exposure: Float = 1f // TODO: Implement exposure bias
var orientation: Orientation = Orientation.PORTRAIT var orientation: Orientation = Orientation.PORTRAIT
var enableZoomGesture: Boolean = false var enableZoomGesture: Boolean = false
set(value) { set(value) {
@ -198,6 +199,7 @@ class CameraView(context: Context) :
config.fps = fps config.fps = fps
config.enableLowLightBoost = lowLightBoost ?: false config.enableLowLightBoost = lowLightBoost ?: false
config.torch = torch config.torch = torch
config.exposure = exposure
// Zoom // Zoom
config.zoom = zoom config.zoom = zoom

View File

@ -140,8 +140,12 @@ class CameraViewManager : ViewGroupManager<CameraView>() {
@ReactProp(name = "zoom") @ReactProp(name = "zoom")
fun setZoom(view: CameraView, zoom: Double) { fun setZoom(view: CameraView, zoom: Double) {
val zoomFloat = zoom.toFloat() view.zoom = zoom.toFloat()
view.zoom = zoomFloat }
@ReactProp(name = "exposure")
fun setExposure(view: CameraView, exposure: Double) {
view.exposure = exposure.toFloat()
} }
@ReactProp(name = "orientation") @ReactProp(name = "orientation")

View File

@ -33,6 +33,7 @@ data class CameraConfiguration(
var enableLowLightBoost: Boolean = false, var enableLowLightBoost: Boolean = false,
var torch: Torch = Torch.OFF, var torch: Torch = Torch.OFF,
var videoStabilizationMode: VideoStabilizationMode = VideoStabilizationMode.OFF, var videoStabilizationMode: VideoStabilizationMode = VideoStabilizationMode.OFF,
var exposure: Float? = null,
// Zoom // Zoom
var zoom: Float = 1f, var zoom: Float = 1f,
@ -94,7 +95,8 @@ data class CameraConfiguration(
val sidePropsChanged = outputsChanged || // depend on outputs val sidePropsChanged = outputsChanged || // depend on outputs
left?.torch != right.torch || left.enableLowLightBoost != right.enableLowLightBoost || left.fps != right.fps || left?.torch != right.torch || left.enableLowLightBoost != right.enableLowLightBoost || left.fps != right.fps ||
left.zoom != right.zoom || left.videoStabilizationMode != right.videoStabilizationMode || left.isActive != right.isActive left.zoom != right.zoom || left.videoStabilizationMode != right.videoStabilizationMode || left.isActive != right.isActive ||
left.exposure != right.exposure
return Difference( return Difference(
deviceChanged, deviceChanged,

View File

@ -6,6 +6,7 @@ import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata import android.hardware.camera2.CameraMetadata
import android.os.Build import android.os.Build
import android.util.Range import android.util.Range
import android.util.Rational
import android.util.Size import android.util.Size
import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableArray
@ -68,6 +69,8 @@ class CameraDeviceDetails(private val cameraManager: CameraManager, private val
private val cameraConfig = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! private val cameraConfig = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
private val isoRange = characteristics.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE) ?: Range(0, 0) private val isoRange = characteristics.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE) ?: Range(0, 0)
private val exposureRange = characteristics.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) ?: Range(0, 0)
private val exposureStep = characteristics.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP) ?: Rational(1, 1)
private val digitalStabilizationModes = private val digitalStabilizationModes =
characteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES) ?: IntArray(0) characteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES) ?: IntArray(0)
private val opticalStabilizationModes = private val opticalStabilizationModes =
@ -174,6 +177,9 @@ class CameraDeviceDetails(private val cameraManager: CameraManager, private val
map.putInt("videoWidth", videoSize.width) map.putInt("videoWidth", videoSize.width)
map.putInt("minISO", isoRange.lower) map.putInt("minISO", isoRange.lower)
map.putInt("maxISO", isoRange.upper) map.putInt("maxISO", isoRange.upper)
// TODO: Implement minExposureBias
map.putDouble("minExposure", exposureRange.lower.toDouble() / exposureStep.toDouble())
map.putDouble("maxExposure", exposureRange.upper.toDouble() / exposureStep.toDouble())
map.putInt("minFps", fpsRange.lower) map.putInt("minFps", fpsRange.lower)
map.putInt("maxFps", fpsRange.upper) map.putInt("maxFps", fpsRange.upper)
map.putDouble("maxZoom", maxZoom) map.putDouble("maxZoom", maxZoom)

View File

@ -434,6 +434,12 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
captureRequest.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_NIGHT) captureRequest.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_NIGHT)
} }
// Set Exposure Bias
val exposure = config.exposure?.toInt()
if (exposure != null) {
captureRequest.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposure)
}
// Set Zoom // Set Zoom
// TODO: Check if that zoom value is even supported // TODO: Check if that zoom value is even supported
captureRequest.setZoom(config.zoom, cameraCharacteristics) captureRequest.setZoom(config.zoom, cameraCharacteristics)

View File

@ -13,6 +13,8 @@ data class CameraDeviceFormat(
val maxFps: Double, val maxFps: Double,
val minISO: Double, val minISO: Double,
val maxISO: Double, val maxISO: Double,
val minExposure: Double,
val maxExposure: Double,
val fieldOfView: Double, val fieldOfView: Double,
val maxZoom: Double, val maxZoom: Double,
val videoStabilizationModes: List<VideoStabilizationMode>, val videoStabilizationModes: List<VideoStabilizationMode>,
@ -46,6 +48,8 @@ data class CameraDeviceFormat(
value.getDouble("maxFps"), value.getDouble("maxFps"),
value.getDouble("minISO"), value.getDouble("minISO"),
value.getDouble("maxISO"), value.getDouble("maxISO"),
value.getDouble("minExposure"),
value.getDouble("maxExposure"),
value.getDouble("fieldOfView"), value.getDouble("fieldOfView"),
value.getDouble("maxZoom"), value.getDouble("maxZoom"),
videoStabilizationModes, videoStabilizationModes,

View File

@ -507,7 +507,7 @@ PODS:
- libwebp (~> 1.0) - libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10) - SDWebImage/Core (~> 5.10)
- SocketRocket (0.6.1) - SocketRocket (0.6.1)
- VisionCamera (3.6.4): - VisionCamera (3.6.6):
- React - React
- React-callinvoker - React-callinvoker
- React-Core - React-Core
@ -747,7 +747,7 @@ SPEC CHECKSUMS:
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
VisionCamera: db57ca079c4f933f1ddd07f2f8fb2d171a826b85 VisionCamera: 47d2342b724c78fb9ff3e2607c21ceda4ba21e75
Yoga: 8796b55dba14d7004f980b54bcc9833ee45b28ce Yoga: 8796b55dba14d7004f980b54bcc9833ee45b28ce
PODFILE CHECKSUM: 27f53791141a3303d814e09b55770336416ff4eb PODFILE CHECKSUM: 27f53791141a3303d814e09b55770336416ff4eb

View File

@ -189,6 +189,7 @@ export function CameraPage({ navigation }: Props): React.ReactElement {
onError={onError} onError={onError}
enableZoomGesture={false} enableZoomGesture={false}
animatedProps={cameraAnimatedProps} animatedProps={cameraAnimatedProps}
exposure={0}
enableFpsGraph={true} enableFpsGraph={true}
orientation="portrait" orientation="portrait"
photo={true} photo={true}

View File

@ -46,6 +46,7 @@ public final class CameraView: UIView, CameraSessionDelegate {
@objc var isActive = false @objc var isActive = false
@objc var torch = "off" @objc var torch = "off"
@objc var zoom: NSNumber = 1.0 // in "factor" @objc var zoom: NSNumber = 1.0 // in "factor"
@objc var exposure: NSNumber = 1.0
@objc var enableFpsGraph = false @objc var enableFpsGraph = false
@objc var videoStabilizationMode: NSString? @objc var videoStabilizationMode: NSString?
@objc var resizeMode: NSString = "cover" { @objc var resizeMode: NSString = "cover" {
@ -218,6 +219,9 @@ public final class CameraView: UIView, CameraSessionDelegate {
// Zoom // Zoom
config.zoom = zoom.doubleValue config.zoom = zoom.doubleValue
// Exposure
config.exposure = exposure.floatValue
// isActive // isActive
config.isActive = isActive config.isActive = isActive
} }

View File

@ -44,6 +44,7 @@ RCT_EXPORT_VIEW_PROPERTY(pixelFormat, NSString);
// other props // other props
RCT_EXPORT_VIEW_PROPERTY(torch, NSString); RCT_EXPORT_VIEW_PROPERTY(torch, NSString);
RCT_EXPORT_VIEW_PROPERTY(zoom, NSNumber); RCT_EXPORT_VIEW_PROPERTY(zoom, NSNumber);
RCT_EXPORT_VIEW_PROPERTY(exposure, NSNumber);
RCT_EXPORT_VIEW_PROPERTY(enableZoomGesture, BOOL); RCT_EXPORT_VIEW_PROPERTY(enableZoomGesture, BOOL);
RCT_EXPORT_VIEW_PROPERTY(enableFpsGraph, BOOL); RCT_EXPORT_VIEW_PROPERTY(enableFpsGraph, BOOL);
RCT_EXPORT_VIEW_PROPERTY(orientation, NSString); RCT_EXPORT_VIEW_PROPERTY(orientation, NSString);

View File

@ -39,6 +39,9 @@ class CameraConfiguration {
// Zoom // Zoom
var zoom: CGFloat? var zoom: CGFloat?
// Exposure
var exposure: Float?
// isActive (Start/Stop) // isActive (Start/Stop)
var isActive = false var isActive = false
@ -59,6 +62,7 @@ class CameraConfiguration {
enableLowLightBoost = other.enableLowLightBoost enableLowLightBoost = other.enableLowLightBoost
torch = other.torch torch = other.torch
zoom = other.zoom zoom = other.zoom
exposure = other.exposure
isActive = other.isActive isActive = other.isActive
audio = other.audio audio = other.audio
} else { } else {
@ -77,6 +81,7 @@ class CameraConfiguration {
let sidePropsChanged: Bool let sidePropsChanged: Bool
let torchChanged: Bool let torchChanged: Bool
let zoomChanged: Bool let zoomChanged: Bool
let exposureChanged: Bool
let audioSessionChanged: Bool let audioSessionChanged: Bool
@ -113,6 +118,8 @@ class CameraConfiguration {
torchChanged = left?.isActive != right.isActive || left?.torch != right.torch torchChanged = left?.isActive != right.isActive || left?.torch != right.torch
// zoom (depends on format) // zoom (depends on format)
zoomChanged = formatChanged || left?.zoom != right.zoom zoomChanged = formatChanged || left?.zoom != right.zoom
// exposure (depends on format)
exposureChanged = formatChanged || left?.exposure != right.exposure
// audio session // audio session
audioSessionChanged = left?.audio != right.audio audioSessionChanged = left?.audio != right.audio

View File

@ -285,6 +285,20 @@ extension CameraSession {
device.videoZoomFactor = clamped device.videoZoomFactor = clamped
} }
// pragma MARK: Exposure
/**
Configures exposure (`exposure`) as a bias that adjusts exposureTime and ISO.
*/
func configureExposure(configuration: CameraConfiguration, device: AVCaptureDevice) {
guard let exposure = configuration.exposure else {
return
}
let clamped = max(min(exposure, device.maxExposureTargetBias), device.minExposureTargetBias)
device.setExposureTargetBias(clamped)
}
// pragma MARK: Audio // pragma MARK: Audio
/** /**

View File

@ -154,13 +154,17 @@ class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVC
if difference.zoomChanged { if difference.zoomChanged {
self.configureZoom(configuration: config, device: device) self.configureZoom(configuration: config, device: device)
} }
// 8. Configure exposure bias
if difference.exposureChanged {
self.configureExposure(configuration: config, device: device)
}
} }
} }
// 8. Start or stop the session if needed // 9. Start or stop the session if needed
self.checkIsActive(configuration: config) self.checkIsActive(configuration: config)
// 9. Enable or disable the Torch if needed (requires session to be running) // 10. Enable or disable the Torch if needed (requires session to be running)
if difference.torchChanged { if difference.torchChanged {
try self.withDeviceLock { device in try self.withDeviceLock { device in
try self.configureTorch(configuration: config, device: device) try self.configureTorch(configuration: config, device: device)

View File

@ -80,6 +80,14 @@ export interface CameraDeviceFormat {
* Minimum supported ISO value * Minimum supported ISO value
*/ */
minISO: number minISO: number
/**
* The minimum Exposure-Bias value this format supports. When setting the `exposure` to this value, the image is almost completely dark (under-exposed).
*/
minExposure: number
/**
* The maximum Exposure-Bias value this format supports. When setting the `exposure` to this value, the image is almost completely bright (over-exposed).
*/
maxExposure: number
/** /**
* The video field of view in degrees * The video field of view in degrees
*/ */

View File

@ -110,6 +110,19 @@ export interface CameraProps extends ViewProps {
enableZoomGesture?: boolean enableZoomGesture?: boolean
//#endregion //#endregion
//#region Camera Controls
/**
* Specifies the Exposure bias of the current camera. A lower value means darker images, a higher value means brighter images.
*
* The Camera will still continue to auto-adjust exposure and focus, but will premultiply the exposure setting with the provided value here.
*
* This values ranges from {@linkcode CameraDeviceFormat.minExposure format.minExposure} to {@linkcode CameraDeviceFormat.maxExposure format.maxExposure}.
*
* The value between min- and max supported exposure is considered the default, neutral value.
*/
exposure?: number
//#endregion
//#region Format/Preset selection //#region Format/Preset selection
/** /**
* Selects a given format. By default, the best matching format is chosen. See {@linkcode CameraDeviceFormat} * Selects a given format. By default, the best matching format is chosen. See {@linkcode CameraDeviceFormat}