openflexure-microscope-server/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py
2021-04-27 20:17:07 +01:00

408 lines
16 KiB
Python

"""
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, NamedTuple, Optional, Tuple
import numpy as np
from picamerax import PiCamera
from picamerax.array import PiBayerArray, PiRGBArray
def rgb_image(
camera: PiCamera, resize: Optional[Tuple[int, int]] = None, **kwargs
) -> PiRGBArray:
"""Capture an image and return an RGB numpy array"""
with PiRGBArray(camera, size=resize) as output:
camera.capture(output, format="rgb", resize=resize, **kwargs)
return output.array
def flat_lens_shading_table(camera: PiCamera) -> np.ndarray:
"""Return a flat (i.e. unity gain) lens shading table.
This is mostly useful because it makes it easy to get the size
of the array correct. NB if you are not using the forked picamera
library (with lens shading table support) it will raise an error.
"""
if not hasattr(PiCamera, "lens_shading_table"):
raise ImportError(
"This program requires the forked picamera library with lens shading support"
)
# pylint: disable=protected-access
return np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) + 32
def adjust_exposure_to_setpoint(camera: PiCamera, setpoint: int):
"""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(
camera.shutter_speed * setpoint / np.max(rgb_image(camera))
)
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 = int(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,
max_iterations: int = 20,
tolerance: float = 0.05,
percentile: float = 99.9,
) -> float:
"""Adjust exposure and analog gain based on raw images.
This routine is slow but effective. It uses raw images, so we
are not affected by white balance or digital gain.
Arguments:
target_white_level:
The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible
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.
tolerance:
How close to the target value we consider "done". Expressed
as a fraction of the ``target_white_level`` so 0.05 means
+/- 5%
percentile:
Rather then use the maximum value for each channel, we
calculate a percentile. This makes us robust to single
pixels that are bright/noisy. 99.9% still picks the top
of the brightness range, but seems much more reliable
than just ``np.max()``.
"""
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
# until either the brightness is high enough, or we can't increase the
# shutter speed any more.
iterations = 0
while iterations < max_iterations:
test = test_exposure_settings(camera, percentile)
if check_convergence(test, target_white_level, tolerance):
break
iterations += 1
# 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
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(
camera: PiCamera, percentile: float = 99
) -> Tuple[float, float]:
"""Adjust the white balance in a single shot, based on the raw image.
NB if ``channels_from_raw_image`` is broken, this will go haywire.
We should probably have better logic to verify the channels really
are BGGR...
"""
blue, g1, g2, red = get_channel_percentiles(camera, percentile)
green = (g1 + g2) / 2.0
new_awb_gains = (green / red, green / blue)
logging.info(
f"Raw white point is R: {red} G: {green} B: {blue}, "
f"setting AWB gains to ({new_awb_gains[0]:.2f}, "
f"{new_awb_gains[1]:.2f})."
)
camera.awb_mode = "off"
camera.awb_gains = new_awb_gains
return new_awb_gains
def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray:
"""Given the 'array' from a PiBayerArray, return the 4 channels."""
bayer_pattern: List[Tuple[int, int]] = [(0, 0), (0, 1), (1, 0), (1, 1)]
channels_shape: Tuple[int, ...] = (
4,
bayer_array.shape[0] // 2,
bayer_array.shape[1] // 2,
)
channels: np.ndarray = np.zeros(channels_shape, dtype=bayer_array.dtype)
for i, offset in enumerate(bayer_pattern):
# We simplify life by dealing with only one channel at a time.
channels[i, :, :] = np.sum(
bayer_array[offset[0] :: 2, offset[1] :: 2, :], axis=2
)
return channels
def get_channel_percentiles(camera: PiCamera, percentile: float) -> np.ndarray:
"""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)) - 64
def lst_from_channels(channels: np.ndarray) -> np.ndarray:
"""Given the 4 Bayer colour channels from a white image, generate a LST."""
full_resolution: np.ndarray = np.array(
channels.shape[1:]
) * 2 # channels have been binned
# NOTE: the size of the LST is 1/64th of the image, but rounded UP.
lst_resolution: List[int] = [(r // 64) + 1 for r in full_resolution]
logging.info("Generating a lens shading table at %sx%s", *lst_resolution)
lens_shading: np.ndarray = np.zeros(
[channels.shape[0]] + lst_resolution, dtype=float
)
for i in range(lens_shading.shape[0]):
image_channel: np.ndarray = channels[i, :, :]
iw: int
ih: int
iw, ih = image_channel.shape
ls_channel: np.ndarray = lens_shading[i, :, :]
lw: int
lh: int
lw, lh = ls_channel.shape
# The lens shading table is rounded **up** in size to 1/64th of the size of
# the image. Rather than handle edge images separately, I'm just going to
# pad the image by copying edge pixels, so that it is exactly 32 times the
# size of the lens shading table (NB 32 not 64 because each channel is only
# half the size of the full image - remember the Bayer pattern... This
# should give results very close to 6by9's solution, albeit considerably
# less computationally efficient!
padded_image_channel: np.ndarray = np.pad(
image_channel, [(0, lw * 32 - iw), (0, lh * 32 - ih)], mode="edge"
) # Pad image to the right and bottom
logging.info(
"Channel shape: %sx%s, shading table shape: %sx%s, after padding %s",
iw,
ih,
lw * 32,
lh * 32,
padded_image_channel.shape,
)
# Next, fill the shading table (except edge pixels). Please excuse the
# for loop - I know it's not fast but this code needn't be!
box: int = 3 # We average together a square of this side length for each pixel.
# NB this isn't quite what 6by9's program does - it averages 3 pixels
# horizontally, but not vertically.
for dx in np.arange(box) - box // 2:
for dy in np.arange(box) - box // 2:
ls_channel[:, :] += (
padded_image_channel[16 + dx :: 32, 16 + dy :: 32] - 64
)
ls_channel /= box ** 2
# The original C code written by 6by9 normalises to the central 64 pixels in each channel.
# ls_channel /= np.mean(image_channel[iw//2-4:iw//2+4, ih//2-4:ih//2+4])
# I have had better results just normalising to the maximum:
ls_channel /= np.max(ls_channel)
# NB the central pixel should now be *approximately* 1.0 (may not be exactly
# due to different averaging widths between the normalisation & shading table)
# For most sensible lenses I'd expect that 1.0 is the maximum value.
# NB ls_channel should be a "view" of the whole lens shading array, so we don't
# need to update the big array here.
# What we actually want to calculate is the gains needed to compensate for the
# lens shading - that's 1/lens_shading_table_float as we currently have it.
gains: np.ndarray = 32.0 / lens_shading # 32 is unity gain
gains[gains > 255] = 255 # clip at 255, maximum gain is 255/32
gains[gains < 32] = 32 # clip at 32, minimum gain is 1 (is this necessary?)
lens_shading_table: np.ndarray = gains.astype(np.uint8)
return lens_shading_table[::-1, :, :].copy()
def lst_from_camera(camera: PiCamera) -> np.ndarray:
"""Acquire a raw image and use it to calculate a lens shading table."""
with PiBayerArray(camera) as a:
camera.capture(a, format="jpeg", bayer=True)
raw_image = a.array.copy()
# Now we need to calculate a lens shading table that would make this flat.
# raw_image is a 3D array, with full resolution and 3 colour channels. No
# de-mosaicing has been done, so 2/3 of the values are zero (3/4 for R and B
# channels, 1/2 for green because there's twice as many green pixels).
channels = channels_from_bayer_array(raw_image)
return lst_from_channels(channels)
def recalibrate_camera(camera: PiCamera):
"""Reset the lens shading table and exposure settings.
This method first resets to a flat lens shading table, then auto-exposes,
then generates a new lens shading table to make the current view uniform.
It should be run when the camera is looking at a uniform white scene.
NB the only parameter ``camera`` is a ``PiCamera`` instance and **not** a
``StreamingCamera``.
"""
camera.lens_shading_table = flat_lens_shading_table(camera)
_ = rgb_image(camera) # for some reason the camera won't work unless I do this!
lens_shading_table = lst_from_camera(camera)
camera.lens_shading_table = lens_shading_table
_ = rgb_image(camera)
# Fix the AWB gains so the image is neutral
channel_means = np.mean(np.mean(rgb_image(camera), axis=0, dtype=float), axis=0)
old_gains = camera.awb_gains
camera.awb_gains = (
channel_means[1] / channel_means[0] * old_gains[0],
channel_means[1] / channel_means[2] * old_gains[1],
)
time.sleep(1)
# Ensure the background is bright but not saturated
adjust_exposure_to_setpoint(camera, 230)
if __name__ == "__main__":
with PiCamera() as main_camera:
main_camera.start_preview()
time.sleep(3)
logging.info("Recalibrating...")
recalibrate_camera(main_camera)
logging.info("Done.")
time.sleep(2)