From 506693f6532c10a0db9681ddfeb8997363358c85 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 23 Oct 2025 14:55:23 +0100 Subject: [PATCH 1/4] Set colour gains directly from Lens Shading Table --- .../things/camera/picamera.py | 46 +---------- .../camera/picamera_recalibrate_utils.py | 79 +------------------ 2 files changed, 5 insertions(+), 120 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 68f5c281..7e93ba9a 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -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,12 @@ 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) + self._initialise_picamera() + with self._streaming_picamera(pause_stream=True) as cam: + self.colour_gains = (float(np.min(Cr)), float(np.min(Cb))) + @lt.thing_property def colour_correction_matrix( self, @@ -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", diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index ff15e02d..fdd0fab6 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -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) From ba3f7f6cf5a423287cfa7223bb995755cb15bca6 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 23 Oct 2025 17:16:54 +0100 Subject: [PATCH 2/4] Remove unused code, add comments --- .../things/camera/picamera.py | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 7e93ba9a..0b9a00c8 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -732,10 +732,11 @@ class StreamingPiCamera2(BaseCamera): 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() - with self._streaming_picamera(pause_stream=True) as cam: - self.colour_gains = (float(np.min(Cr)), float(np.min(Cb))) + # 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( @@ -993,37 +994,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. From 2bc315f85da8d686b33f32111b7f08d87a3fb080 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 28 Oct 2025 13:57:22 +0000 Subject: [PATCH 3/4] Update picamera cal docstring now white balance isn't its own method. --- src/openflexure_microscope_server/things/camera/picamera.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 0b9a00c8..430e7a40 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -836,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() From 49b757a0917b7bde02cdcd6761af8ec8a45bf507 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 28 Oct 2025 14:42:37 +0000 Subject: [PATCH 4/4] Update picamera coverage info --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 9592a404ce9e303eb508960d7a7215bcdbb32015..2e0aa752f5a3dfcc2baaa851ab87848f52cda866 100644 GIT binary patch delta 723 zcmbQXjCtBJW}yIYW)=|!5Rfg6iSFm{+qO|i$$?pl^ZI0S2RZRp9zPxq?sMFexC6Ka zxXyC*bA@mTa9-c+C@`Co)m)01p>(pNx6$qio4j1rUO{Z%Kw^b%zhpZwp; zmQif7t+x}S=;Q`(dq$DT2fQ6wgqaztCm;0Dnyl}`$0#(}&c~8fkcFXlf(o2Lxa+VJdtB@;8BkyF!XoJbC zeFPZ=C!dd11Nnsu?iYc{|Dtr!y%OW5EL6zA|C9eB|4IH-zFU0r_*(c1H#-VM@=fmT z(`B<_V`1bhn|!cOO65|^W?=@khV}#IOYeIr{1a;U*JZ#cvxS90u-c-LgO!z$vq@kw zXTKuj{>j??mKqHPnG6SBu(2^Pm^4V=VfbO*kjK!#$iToL!N72UL2Z#OvjKy|frhfp zQ~Q@XaDWoQg)D(dlbtSH)G#qNNHtG1F|bTAG)}QdPBczSG&M0cOg1$)Hcd=RHcBx~ zF*Hp{v6y_}g3aXei&E+-DF$hYNoJ-=#s(&)7M4ktmT4(wDHfJy$)+YLNhzk5Nr|RO zX%O{aE=Jp>Ca0yDm>L?Jm?x)MBpaF=n;4m;q?sp~8=58>rKDJz87CX0q#Bqi)dqMo UGKnywCe+Ejmn;wtK6}X%0P>jNlK=n! delta 805 zcmbQXjCtBJW}yIYW)=|!5ZGN575(RBh2lmbB?o3nF44*64sxPBJZ?NZ+!wg}xc#`f zxz2J;;_~AX-RvlEo0HXCl9{1&@&aGw$@*SGtP(5?jhs*(+vI#NF-Gyp^8Tum^}N{m zb5he2lPlvB3o`WzDs99jAM~>RhpdO!w!-aDo)HxPfW^BEK1QUsI(GdW~fGz=4TX~2}W%5{!!EngHLJ2wxH zJI@hrf9{Li{XD(=nmqqEI||(4oXinr!6-S|G0K@m!iu?a^20b~kf-=iJS9H)K$I<` z*yMjvPK=_H9i#0TMJ6{!JF*B{Ggm|X$Hyo%`G2$}tDq}OBk$ygaR!rfeFPZ=CfCQ9 zZT=kNtSl7E!2gr~BL7MLRKAORU3>+6v6~$QT=^z1?9;7xWn*RJY~*5R0!7r1?K!u% z{d_Lp$;`mO^X9{2PJRZqrXP&Ef4rYt@TG26K^fBlmJb3ymVJrlJQ9ECa=f5gL-Vu- z^(21wMnP6qM$RTBHYSFD@3$wEp3!MIJ^A7J13U$6j5QXAU+&t+6x03fOGbS^TmAfh z<+rjou$lE3vVEBB)UR#G*6^R5twox_U{@m}0|V~^HgkqQ<_&oa4U8a1u`w_hG<0U1 zXJBDqU|?flmbk#Td20Vs2M$oO652SEakA5eiyCGYX{O0W7KUbN7Ks)q21y3V1}2Fq zW(H}=#!0DWDdv``hN(u$sRolTT(Fs3eo;z2&CoQ}z$nEc*~Gvo*(fzJ#nRj|IVIWH z)Fe5@&@#m$Ej885*un^`{>#N^JJZA@BTF-b#6)uwQ`0mPkg=&|h9;?rmgZ?GMu}zy gspb|&hGxl1wE^CYOd`yvNq=(hB@2Xu&tCEb08|7A)Bpeg