Merge branch 'remove-whitebalance' into 'v3'

Set Colour Gains from lens shading tables

Closes #451

See merge request openflexure/openflexure-microscope-server!417
This commit is contained in:
Joe Knapper 2025-10-28 15:29:25 +00:00
commit 5cd8a0a38d
3 changed files with 7 additions and 153 deletions

Binary file not shown.

View file

@ -715,41 +715,6 @@ class StreamingPiCamera2(BaseCamera):
percentile=percentile,
)
@lt.thing_action
def calibrate_white_balance(
self,
method: Literal["percentile", "centre"] = "centre",
luminance_power: float = 1.0,
) -> None:
"""Correct the white balance of the image.
This calibration requires a neutral image, such that the 99th centile
of each colour channel should correspond to white. We calculate the
centiles and use this to set the colour gains. This is done on the raw
image with the lens shading correction applied, which should mean
that the image is uniform, rather than weighted towards the centre.
If ``method`` is ``"centre"``, we will correct the mean of the central 10%
of the image.
"""
with self._streaming_picamera(pause_stream=True) as cam:
if self.lens_shading_is_static:
lst: LensShading = self.lens_shading_tables
recalibrate_utils.adjust_white_balance_from_raw(
cam,
self._sensor_info,
percentile=99,
luminance=lst.luminance,
Cr=lst.Cr,
Cb=lst.Cb,
luminance_power=luminance_power,
method=method,
)
else:
recalibrate_utils.adjust_white_balance_from_raw(
cam, self._sensor_info, percentile=99, method=method
)
@lt.thing_action
def calibrate_lens_shading(self) -> None:
"""Take an image and use it for flat-field correction.
@ -766,8 +731,13 @@ class StreamingPiCamera2(BaseCamera):
# (Cb).
L, Cr, Cb = recalibrate_utils.lst_from_camera(cam, self._sensor_info) # noqa: N806
tf_utils.set_static_lst(self.tuning, L, Cr, Cb)
# Re-initialise the picamera to reload the tuning file.
self._initialise_picamera()
# Set colour gains based on the LST results
self.colour_gains = (float(np.min(Cr)), float(np.min(Cb)))
@lt.thing_property
def colour_correction_matrix(
self,
@ -866,9 +836,8 @@ class StreamingPiCamera2(BaseCamera):
* ``flat_lens_shading`` to disable flat-field
* ``auto_expose_from_minimum``
* ``set_static_green_equalisation`` to set geq offset to max
* ``calibrate_lens_shading``
* ``calibrate_lens_shading`` (also sets colour gains for white balance)
* ``reset_ccm``
* ``calibrate_white_balance``
* ``set_background``
"""
self.flat_lens_shading()
@ -877,7 +846,6 @@ class StreamingPiCamera2(BaseCamera):
self.set_ce_enable_to_off()
self.calibrate_lens_shading()
self.reset_ccm()
self.calibrate_white_balance()
time.sleep(0.5)
self.set_background(portal)
@ -926,12 +894,6 @@ class StreamingPiCamera2(BaseCamera):
can_terminate=False,
button_primary=False,
),
action_button_for(
self.calibrate_white_balance,
submit_label="Auto White Balance",
can_terminate=False,
button_primary=False,
),
action_button_for(
self.calibrate_lens_shading,
submit_label="Auto Flat Field Correction",
@ -1031,37 +993,6 @@ class StreamingPiCamera2(BaseCamera):
)
self._initialise_picamera()
def correct_colour_gains_for_lens_shading(
self, colour_gains: tuple[float, float]
) -> tuple[float, float]:
"""Correct white balance gains for the effect of lens shading.
The white balance algorithm we use assumes the brightest pixels
should be white, and that the only thing affecting the colour of
said pixels is the ``colour_gains``.
The lens shading correction is normalised such that the *minimum*
gain in the ``Cr`` and ``Cb`` channels is 1. The white balance
assumption above requires that the gain for the brightest pixels
is 1. The solution might be that, when calibrating, we note which
pixels are brightest (usually the centre) and explicitly use
the LST values for there. However, for now I will assume that we
need to normalise by the **maximum** of the ``Cr`` and ``Cb``
channels, which is correct the majority of the time.
"""
if not self.lens_shading_is_static:
return colour_gains
lst = self.lens_shading_tables
# The Cr and Cb corrections are normalised to have a minimum of 1,
# but the white balance algorithm normalises the brightest pixels
# to be white, assuming the brightest pixels have equal gain from
# the LST.
gain_r, gain_b = colour_gains
return (
float(gain_r / np.max(lst.Cr)),
float(gain_b / np.max(lst.Cb)),
)
@lt.thing_action
def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction.

View file

@ -44,7 +44,7 @@ from __future__ import annotations
import gc
import logging
import time
from typing import List, Literal, Optional, Tuple
from typing import List, Tuple
from pydantic import BaseModel
import numpy as np
from scipy.ndimage import zoom
@ -201,83 +201,6 @@ def adjust_shutter_and_gain_from_raw(
return test.level
# Explicitly allow this to have 8 arguments as the later arguments are keyword only
# We should be able to enforce this without noqa once PyLint moves PLR0917 out of
# preview
def adjust_white_balance_from_raw( # noqa: PLR0913
camera: Picamera2,
sensor_info: SensorInfo,
*,
percentile: float = 99,
luminance: Optional[np.ndarray] = None,
Cr: Optional[np.ndarray] = None,
Cb: Optional[np.ndarray] = None,
luminance_power: float = 1.0,
method: Literal["percentile", "centre"] = "centre",
) -> 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...
"""
config = camera.create_still_configuration(
raw={"format": sensor_info.unpacked_pixel_format}
)
camera.configure(config)
camera.start()
channels = _channels_from_bayer_array(camera.capture_array("raw"))
if luminance is not None and Cr is not None and Cb is not None:
# Reconstruct a low-resolution image from the lens shading tables
# and use it to normalise the raw image, to compensate for
# the brightest pixels in each channel not coinciding.
grids = _grids_from_lst(np.array(luminance) ** luminance_power, Cr, Cb)
channel_gains = 1 / grids
if channel_gains.shape[1:] != channels.shape[1:]:
channel_gains = _upsample_channels(channel_gains, channels.shape[1:])
LOGGER.info(f"Before gains, channel maxima are {np.max(channels, axis=(1, 2))}")
channels = channels * channel_gains
LOGGER.info(f"After gains, channel maxima are {np.max(channels, axis=(1, 2))}")
if method == "centre":
_, height, width = channels.shape
# Cut out the central 10% from 9/20 to 11/20...
low_y_range = 9 * height // 20
hi_y_range = 11 * height // 20
low_x_range = 9 * width // 20
hi_x_range = 11 * width // 20
# ... and then take the mean of each bayer channel.
centre_means = np.mean(
channels[:, low_y_range:hi_y_range, low_x_range:hi_x_range],
axis=(1, 2),
)
# Subtract blacklevel before splitting into channels
blue, g1, g2, red = centre_means - sensor_info.blacklevel
else:
blue, g1, g2, red = (
np.percentile(channels, percentile, axis=(1, 2)) - sensor_info.blacklevel
)
green = (g1 + g2) / 2.0
new_awb_gains = (green / red, green / blue)
if Cr is not None and Cb is not None:
# The LST algorithm normalises Cr and Cb by their minimum.
# The lens shading correction only ever boosts the red and blue values.
# Here, we decrease the gains by the minimum value of Cr and Cb.
new_awb_gains = (green / red * np.min(Cr), green / blue * np.min(Cb))
LOGGER.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.controls.AwbEnable = False
camera.controls.ColourGains = new_awb_gains
time.sleep(sensor_info.long_pause)
m = camera.capture_metadata()
LOGGER.debug(f"Camera confirms gains are now {m['ColourGains']}")
return new_awb_gains
def lst_from_camera(camera: Picamera2, sensor_info: SensorInfo) -> LensShadingTables:
"""Acquire a raw image and use it to calculate a lens shading table."""
channels = _raw_channels_from_camera(camera, sensor_info)