Simplified auto gain/exposure algorithm

adjust_shutter_and_gain_from_raw now uses a simpler algorithm.
The code is longer overall but hides some details in fuctions.
I've also split the loop into one for shutter speed and one for gain.
This isn't the algorithm that I originally wrote, but it seems to
work and is more readable.
This commit is contained in:
Richard Bowman 2021-04-27 18:54:21 +01:00
parent 42f00b69a4
commit bb303d4766

View file

@ -1,17 +1,44 @@
"""
Functions to set up a Raspberry Pi Camera v2 for scientific use
This module provides slower, simpler functions to set the
gain, exposure, and white balance of a Raspberry Pi camera, using
`picamerax` (a fork of `picamera`) to get as-manual-as-possible
control over the camera. It's mostly used by the OpenFlexure
Microscope, though it deliberately has no hard dependencies on
said software, so that it's useful on its own.
There are three main calibration steps:
* Setting exposure time and gain to get a reasonably bright
image.
* Fixing the white balance to get a neutral image
* Taking a uniform white image and using it to calibrate
the Lens Shading Table
The most reliable way to do this, avoiding any issues relating
to "memory" or nonlinearities in the camera's image processing
pipeline, is to use raw images. This is quite slow, but very
reliable. The three steps above can be accomplished by:
```
picamera = picamerax.PiCamera()
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
picamera.lens_shading_table = lst
```
"""
import logging
import time
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, NamedTuple
import numpy as np
from picamerax import PiCamera
from picamerax.array import PiBayerArray, PiRGBArray
# I have used f strings throughout, for readability.
# The performance implication of using them in logging statements
# really doesn't matter here - also, loglevel is usually set to
# INFO to report progress via the LabThings action API
# pylint: disable=logging-fstring-interpolation
def rgb_image(
camera: PiCamera, resize: Optional[Tuple[int, int]] = None, **kwargs
@ -38,7 +65,10 @@ def flat_lens_shading_table(camera: PiCamera) -> np.ndarray:
def adjust_exposure_to_setpoint(camera: PiCamera, setpoint: int):
"""Adjust the camera's exposure time until the maximum pixel value is <setpoint>."""
"""Adjust the camera's exposure time until the maximum pixel value is <setpoint>.
NB this method uses RGB images (i.e. processed ones) not raw images.
"""
logging.info(f"Adjusting shutter speed to hit setpoint {setpoint}")
for _ in range(3):
camera.shutter_speed = int(
@ -47,6 +77,69 @@ def adjust_exposure_to_setpoint(camera: PiCamera, setpoint: int):
time.sleep(1)
def set_minimum_exposure(
camera: PiCamera
):
"""Enable manual exposure, with low gain and shutter speed
We set exposure mode to manual, analog and digital gain
to 1, and shutter speed to the minimum (8us for Pi Camera v2)
NB ISO is left at auto, because this is needed for the gains
to be set correctly.
"""
camera.exposure_mode = "off"
camera.iso = 0 # We must set ISO=0 (auto) or we can't set gain
camera.analog_gain = 1
camera.digital_gain = 1
# Setting the shutter speed to 1us will result in it being set
# to the minimum possible, which is probably 8us for PiCamera v2
camera.shutter_speed = 1
time.sleep(0.5)
class ExposureTest(NamedTuple):
"""Record the results of testing the camera's current exposure settings"""
level: int
shutter_speed: int
analog_gain: float
def test_exposure_settings(
camera: PiCamera,
percentile: float
) -> ExposureTest:
"""Evaluate current exposure settings using a raw image
We will acquire a raw image and calculate the given percentile
of the pixel values. We return a dictionary containing the
percentile (which will be compared to the target), as well as
the camera's shutter and gain values.
"""
max_brightness = np.max(get_channel_percentiles(camera, percentile))
# The reported brightness can, theoretically, be negative or zero
# because of black level compensation. The line below forces a
# minimum value of 1 which will keep things well-behaved!
if max_brightness < 1:
logging.warning(
f"Measured brightness of {max_brightness}. "
"This should normally be >= 1, and may indicate the "
"camera's black level compensation has gone wrong."
)
max_brightness = 1
shutter_speed = float(camera.shutter_speed)
analog_gain = float(camera.analog_gain)
logging.info(
f"Brightness: {max_brightness: >5.0f}, "
f"Gain: {analog_gain: >4.1f}, "
f"Shutter: {shutter_speed: >7.0f}"
)
return ExposureTest(max_brightness, shutter_speed, analog_gain)
def check_convergence(test: ExposureTest, target: int, tolerance: float):
"""Check whether the brightness is within the specified target range"""
converged = abs(test.level - target) < target * tolerance
return converged
def adjust_shutter_and_gain_from_raw(
camera: PiCamera,
target_white_level: int = 700,
@ -64,7 +157,7 @@ def adjust_shutter_and_gain_from_raw(
target_white_level:
The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible
is 1023, 700 is reasonable.
is about 900, 700 is reasonable.
max_iterations:
We will terminate once we perform this many iterations,
whether or not we converge. More than 10 shouldn't happen.
@ -79,70 +172,61 @@ def adjust_shutter_and_gain_from_raw(
of the brightness range, but seems much more reliable
than just ``np.max()``.
"""
# Start by setting fully manual exposure.
camera.exposure_mode = "off"
camera.iso = 0 # We must set ISO=0 (auto) or we can't set gain
camera.analog_gain = 1
camera.digital_gain = 1
camera.shutter_speed = 1 # This is not valid - we'll get the minimum
time.sleep(0.5)
if target_white_level * (tolerance + 1) >= 959:
raise ValueError("The target level is too high - a saturated image would be considered successful. target_white_level * (tolerance + 1) must be less than 959.")
set_minimum_exposure(camera)
# We start with very low exposure settings and work up in coarse steps
# We start with very low exposure settings and work up
# until either the brightness is high enough, or we can't increase the
# shutter speed any more.
iterations = 0
shutter_speed_increments = [10, 2, None]
shutter_speed_increment = shutter_speed_increments.pop(0)
while iterations < max_iterations:
iterations += 1
max_brightness = np.max(get_channel_percentiles(camera, percentile))
tested_shutter_speed = camera.shutter_speed
tested_analog_gain = camera.analog_gain
logging.info(
f"Brightness: {max_brightness: >5.0f}, "
f"Target: {target_white_level: >5.0f}, "
f"Gain: {float(tested_analog_gain): >4.1f}, "
f"Shutter: {float(tested_shutter_speed): >7.0f}"
)
test = test_exposure_settings(camera, percentile)
if abs(max_brightness - target_white_level) < target_white_level * tolerance:
logging.info(
f"Brightness has converged to within {tolerance * 100 :.0f}% "
f"after {iterations} iterations."
)
if check_convergence(test, target_white_level, tolerance):
break
iterations += 1
# if not shutter_speed_maximised: # Start by adjusting shutter speed
if max_brightness > target_white_level // 2:
shutter_speed_increment = (
None
) # If we're within a factor of 2, trigger fine-tuning
if shutter_speed_increment is None:
logging.info("Fine-tuning shutter speed")
new_shutter_speed = int(
tested_shutter_speed * target_white_level / max_brightness
)
camera.shutter_speed = new_shutter_speed
else:
if max_brightness > target_white_level:
logging.info("Reducing shutter speed")
camera.shutter_speed = int(
tested_shutter_speed / shutter_speed_increment
)
else:
logging.info("Increasing shutter speed")
camera.shutter_speed = tested_shutter_speed * shutter_speed_increment
# Adjust shutter speed so that the brightness approximates the target
# NB we put a maximum of 32 on this, to stop it increasing too quickly.
camera.shutter_speed = int(
test.shutter_speed * min(target_white_level / test.level, 32)
)
time.sleep(0.5)
# Check whether the shutter speed is still going up - if not, we've hit a maximum
current_shutter_speed = camera.shutter_speed
if current_shutter_speed == tested_shutter_speed:
camera.analog_gain *= 2
if camera.analog_gain == tested_analog_gain:
logging.info("Shutter speed and gain are both maxed out. Giving up!")
break
logging.info("Shutter speed has maxed out, doubled gain to compensate.")
return max_brightness
if camera.shutter_speed == test.shutter_speed:
logging.info("Shutter speed has maxed out.")
break
# Now, if we've not converged, increase gain until we converge or run out of options.
while iterations < max_iterations:
test = test_exposure_settings(camera, percentile)
if check_convergence(test, target_white_level, tolerance):
break
iterations += 1
# Adjust gain to make the white level hit the target, again with a maximum
camera.analog_gain *= min(target_white_level / test.level, 2)
time.sleep(0.5)
# Check the gain is still changing - if not, we have probably hit the maximum
if camera.analog_gain == test.analog_gain:
logging.info("Gain has maxed out.")
break
if check_convergence(test, target_white_level, tolerance):
logging.info(
f"Brightness has converged to within {tolerance * 100 :.0f}%."
)
else:
logging.warning(
f"Failed to reach target brightness of {target_white_level}."
f"Brightness reached {test.level} after {iterations} iterations."
)
return test.level
def adjust_white_balance_from_raw(
@ -186,11 +270,18 @@ def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray:
def get_channel_percentiles(camera: PiCamera, percentile: float) -> np.ndarray:
"""Calculate the brightness percentile of the pixels in each channel"""
"""Calculate the brightness percentile of the pixels in each channel
This is a number between -64 and 959 for each channel, because the
camera takes 10-bit images (maximum=1023) and its zero level is set
at 64 for denoising purposes (there's black level compensation built
in, and to avoid skewing the noise, the black level is set as 64 to
leave some room for negative values.
"""
with PiBayerArray(camera) as output:
camera.capture(output, format="jpeg", bayer=True)
channels = channels_from_bayer_array(output.array)
return np.percentile(channels, percentile, axis=(1, 2))
return np.percentile(channels, percentile, axis=(1, 2)) - 64
def lst_from_channels(channels: np.ndarray) -> np.ndarray: