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 />
#### 🚀 Next section: [HDR](hdr)
#### 🚀 Next section: [Exposure](exposure)

View File

@ -31,6 +31,7 @@ module.exports = {
},
'guides/zooming',
'guides/focusing',
'guides/exposure',
'guides/hdr',
'guides/stabilization',
'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 torch: Torch = Torch.OFF
var zoom: Float = 1f // in "factor"
var exposure: Float = 1f // TODO: Implement exposure bias
var orientation: Orientation = Orientation.PORTRAIT
var enableZoomGesture: Boolean = false
set(value) {
@ -198,6 +199,7 @@ class CameraView(context: Context) :
config.fps = fps
config.enableLowLightBoost = lowLightBoost ?: false
config.torch = torch
config.exposure = exposure
// Zoom
config.zoom = zoom

View File

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

View File

@ -33,6 +33,7 @@ data class CameraConfiguration(
var enableLowLightBoost: Boolean = false,
var torch: Torch = Torch.OFF,
var videoStabilizationMode: VideoStabilizationMode = VideoStabilizationMode.OFF,
var exposure: Float? = null,
// Zoom
var zoom: Float = 1f,
@ -94,7 +95,8 @@ data class CameraConfiguration(
val sidePropsChanged = outputsChanged || // depend on outputs
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(
deviceChanged,

View File

@ -6,6 +6,7 @@ import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.os.Build
import android.util.Range
import android.util.Rational
import android.util.Size
import com.facebook.react.bridge.Arguments
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 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 =
characteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES) ?: IntArray(0)
private val opticalStabilizationModes =
@ -174,6 +177,9 @@ class CameraDeviceDetails(private val cameraManager: CameraManager, private val
map.putInt("videoWidth", videoSize.width)
map.putInt("minISO", isoRange.lower)
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("maxFps", fpsRange.upper)
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)
}
// Set Exposure Bias
val exposure = config.exposure?.toInt()
if (exposure != null) {
captureRequest.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposure)
}
// Set Zoom
// TODO: Check if that zoom value is even supported
captureRequest.setZoom(config.zoom, cameraCharacteristics)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -285,6 +285,20 @@ extension CameraSession {
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
/**

View File

@ -154,13 +154,17 @@ class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVC
if difference.zoomChanged {
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)
// 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 {
try self.withDeviceLock { device in
try self.configureTorch(configuration: config, device: device)

View File

@ -80,6 +80,14 @@ export interface CameraDeviceFormat {
* Minimum supported ISO value
*/
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
*/

View File

@ -110,6 +110,19 @@ export interface CameraProps extends ViewProps {
enableZoomGesture?: boolean
//#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
/**
* Selects a given format. By default, the best matching format is chosen. See {@linkcode CameraDeviceFormat}