From c16a0391df80cbef0f66faab9b041570c7a9b334 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Sep 2025 21:18:48 +0100 Subject: [PATCH] Divide up tuning file modification from camera actions for recalibration. * Ensure that funtions only called locally by recalibrate utils are private * Move public API to the top of the recalibrate utils file * Move files that only alter the tuning dictionary to their own file This makes the recalibration API used by PiCamera much more clearly defined, which helps to plan out how to allow for different models. --- .../picamera2/test_tuning.py | 13 +- .../things/camera/picamera.py | 24 +- .../camera/picamera_recalibrate_utils.py | 300 +++++------------- .../camera/picamera_tuning_file_utils.py | 134 ++++++++ 4 files changed, 238 insertions(+), 233 deletions(-) create mode 100644 src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py diff --git a/hardware-specific-tests/picamera2/test_tuning.py b/hardware-specific-tests/picamera2/test_tuning.py index 35a67676..5932af6f 100644 --- a/hardware-specific-tests/picamera2/test_tuning.py +++ b/hardware-specific-tests/picamera2/test_tuning.py @@ -8,20 +8,17 @@ from picamera2 import Picamera2 from openflexure_microscope_server.things.camera import ( picamera_recalibrate_utils as recalibrate_utils, ) +from openflexure_microscope_server.things.camera import ( + picamera_tuning_file_utils as tf_utils, +) MODEL = Picamera2.global_camera_info()[0]["Model"] -def load_default_tuning(): - """Return the default tuning file for the connected camera.""" - fname = f"{MODEL}.json" - return Picamera2.load_tuning_file(fname) - - def generate_bad_tuning(): """Return a tuning file with an invalid version number to force an error when loaded.""" - default_tuning = load_default_tuning() + default_tuning = tf_utils.load_default_tuning() bad_tuning = default_tuning.copy() bad_tuning["version"] = 999 return bad_tuning @@ -58,7 +55,7 @@ def _test_bad_tuning_after_good_tuning(configure: bool = False): PiCamera2 behaviour does not expect the tuning file to be reloaded. """ bad_tuning = generate_bad_tuning() - default_tuning = load_default_tuning() + default_tuning = tf_utils.load_default_tuning() print_tuning() print("opening camera with default tuning") with Picamera2(tuning=default_tuning) as cam: diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index efc1649f..a6096372 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -43,6 +43,8 @@ from openflexure_microscope_server.ui import ( property_control_for, ) from . import picamera_recalibrate_utils as recalibrate_utils +from . import picamera_tuning_file_utils as tf_utils + from . import BaseCamera, ArrayModel @@ -144,7 +146,7 @@ class StreamingPiCamera2(BaseCamera): self._picamera = None logging.info("Starting & reconfiguring camera to populate sensor_modes.") with Picamera2(camera_num=self.camera_num) as cam: - self.default_tuning = recalibrate_utils.load_default_tuning(cam) + self.default_tuning = tf_utils.load_default_tuning(cam) logging.info("Done reading sensor modes & default tuning.") # Set tuning to default tuning. This will be overwritten when the Thing is # connects to the server if tuning is saved to disk. @@ -718,7 +720,7 @@ class StreamingPiCamera2(BaseCamera): # luminance (L), red-difference chroma (Cr), and blue-difference chroma # (Cb). L, Cr, Cb = recalibrate_utils.lst_from_camera(cam) # noqa: N806 - recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb) + tf_utils.set_static_lst(self.tuning, L, Cr, Cb) self._initialise_picamera() @lt.thing_property @@ -735,14 +737,14 @@ class StreamingPiCamera2(BaseCamera): See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45. """ - return tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"]) + return tuple(tf_utils.get_static_ccm(self.tuning)[0]["ccm"]) @colour_correction_matrix.setter # type: ignore def colour_correction_matrix( self, value: tuple[float, float, float, float, float, float, float, float, float], ) -> None: - recalibrate_utils.set_static_ccm(self.tuning, value) + tf_utils.set_static_ccm(self.tuning, value) if self._picamera is not None: with self._streaming_picamera(pause_stream=True): @@ -781,7 +783,7 @@ class StreamingPiCamera2(BaseCamera): A value of 0 here does nothing, a value of 65535 is maximum correction. """ with self._streaming_picamera(pause_stream=True): - recalibrate_utils.set_static_geq(self.tuning, offset) + tf_utils.set_static_geq(self.tuning, offset) self._initialise_picamera() @lt.thing_action @@ -820,9 +822,7 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera(pause_stream=True): # Generate and array of ones of the correct size for each channel flat_array = np.ones((12, 16)) - recalibrate_utils.set_static_lst( - self.tuning, flat_array, flat_array, flat_array - ) + tf_utils.set_static_lst(self.tuning, flat_array, flat_array, flat_array) self._initialise_picamera() @lt.thing_property @@ -945,7 +945,7 @@ class StreamingPiCamera2(BaseCamera): def lens_shading_tables(self, lst: LensShading) -> None: """Set the lens shading tables.""" with self._streaming_picamera(pause_stream=True): - recalibrate_utils.set_static_lst( + tf_utils.set_static_lst( self.tuning, luminance=lst.luminance, cr=lst.Cr, @@ -996,7 +996,7 @@ class StreamingPiCamera2(BaseCamera): alsc = self.get_tuning_algo("rpi.alsc") luminance = alsc["luminance_lut"] flat = np.ones((12, 16)) - recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat) + tf_utils.set_static_lst(self.tuning, luminance, flat, flat) self._initialise_picamera() @lt.thing_action @@ -1007,7 +1007,7 @@ class StreamingPiCamera2(BaseCamera): by the Raspberry Pi camera. """ with self._streaming_picamera(pause_stream=True): - recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) + tf_utils.copy_alsc_section(self.default_tuning, self.tuning) self._initialise_picamera() @lt.thing_property @@ -1019,4 +1019,4 @@ class StreamingPiCamera2(BaseCamera): The default LST is not static, but all the calibration controls will set it to be static (except "reset") """ - return recalibrate_utils.lst_is_static(self.tuning) + return tf_utils.lst_is_static(self.tuning) 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 24fea7d8..837ba4a7 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -1,4 +1,4 @@ -"""Functions to set up a Raspberry Pi Camera v2 for scientific use. +"""Functions to set up a Raspberry Pi Camera (v2 and HQ) for scientific use. This module provides slower, simpler functions to set the gain, exposure, and white balance of a Raspberry Pi camera, using @@ -51,30 +51,6 @@ import picamera2 LensShadingTables = tuple[np.ndarray, np.ndarray, np.ndarray] -def load_default_tuning(cam: Picamera2) -> dict: - """Load the default tuning file for the camera. - - This will open and close the camera to determine its model. If you are - using a model that's supported by ``picamera2`` it should have a tuning - file built in. If not, this will probably crash with an error. - - Error handling for unsupported cameras is not something we are likely - to test in the short term. - """ - cp = cam.camera_properties - fname = f"{cp['Model']}.json" - try: - return cam.load_tuning_file(fname) - except RuntimeError: - tuning_dir = "/usr/share/libcamera/ipa/raspberrypi" - # from picamera2 v0.3.9 - # The directory above has been removed from the search path seems - # odd - as that's where the files currently are on a default - # Raspbian image. This may need updating if the files have moved - # in future updates to the system libcamera package - return cam.load_tuning_file(fname, dir=tuning_dir) - - def set_minimum_exposure(camera: Picamera2) -> None: """Enable manual exposure, with low gain and shutter speed. @@ -92,54 +68,6 @@ def set_minimum_exposure(camera: Picamera2) -> None: time.sleep(1) -class ExposureTest(BaseModel): - """Record the results of testing the camera's current exposure settings.""" - - level: int - exposure_time: int - analog_gain: float - - -def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest: - """Evaluate current exposure settings using a raw image. - - CAMERA SHOULD BE STARTED! - - 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. - """ - camera.capture_array("raw") # controls might not be updated for the first frame? - max_brightness = np.percentile( - channels_from_bayer_array(camera.capture_array("raw")), - 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 - metadata = camera.capture_metadata() - result = ExposureTest( - level=max_brightness, - exposure_time=int(metadata["ExposureTime"]), - analog_gain=float(metadata["AnalogueGain"]), - ) - logging.info(f"{result.model_dump()}") - return result - - -def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool: - """Check whether the brightness is within the specified target range.""" - return abs(test.level - target) < target * tolerance - - def adjust_shutter_and_gain_from_raw( camera: Picamera2, target_white_level: int = 3000, @@ -184,8 +112,8 @@ def adjust_shutter_and_gain_from_raw( # shutter speed any more. iterations = 0 while iterations < max_iterations: - test = test_exposure_settings(camera, percentile) - if check_convergence(test, target_white_level, tolerance): + test = _test_exposure_settings(camera, percentile) + if _check_convergence(test, target_white_level, tolerance): break iterations += 1 @@ -203,8 +131,8 @@ def adjust_shutter_and_gain_from_raw( # 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): + test = _test_exposure_settings(camera, percentile) + if _check_convergence(test, target_white_level, tolerance): break iterations += 1 @@ -219,7 +147,7 @@ def adjust_shutter_and_gain_from_raw( logging.info(f"Gain has maxed out at {test.analog_gain}") break - if check_convergence(test, target_white_level, tolerance): + if _check_convergence(test, target_white_level, tolerance): logging.info(f"Brightness has converged to within {tolerance * 100:.0f}%.") else: logging.warning( @@ -248,17 +176,17 @@ def adjust_white_balance_from_raw( config = camera.create_still_configuration(raw={"format": "SBGGR12"}) camera.configure(config) camera.start() - channels = channels_from_bayer_array(camera.capture_array("raw")) + channels = _channels_from_bayer_array(camera.capture_array("raw")) # TODO: read black level from camera rather than hard-coding 64 blacklevel = 256 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) + 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:]) + channel_gains = _upsample_channels(channel_gains, channels.shape[1:]) logging.info( f"Before gains, channel maxima are {np.max(channels, axis=(1, 2))}" ) @@ -303,7 +231,71 @@ def adjust_white_balance_from_raw( return new_awb_gains -def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray: +def lst_from_camera(camera: Picamera2) -> LensShadingTables: + """Acquire a raw image and use it to calculate a lens shading table.""" + channels = _raw_channels_from_camera(camera) + return _lst_from_channels(channels) + + +def recreate_camera_manager() -> None: + """Delete and recreate the camera manager. + + This is necessary to ensure the tuning file is re-read. + """ + del Picamera2._cm + gc.collect() + Picamera2._cm = picamera2.picamera2.CameraManager() + + +class _ExposureTest(BaseModel): + """Record the results of testing the camera's current exposure settings.""" + + level: int + exposure_time: int + analog_gain: float + + +def _test_exposure_settings(camera: Picamera2, percentile: float) -> _ExposureTest: + """Evaluate current exposure settings using a raw image. + + CAMERA SHOULD BE STARTED! + + 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. + """ + camera.capture_array("raw") # controls might not be updated for the first frame? + max_brightness = np.percentile( + _channels_from_bayer_array(camera.capture_array("raw")), + 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 + metadata = camera.capture_metadata() + result = _ExposureTest( + level=max_brightness, + exposure_time=int(metadata["ExposureTime"]), + analog_gain=float(metadata["AnalogueGain"]), + ) + logging.info(f"{result.model_dump()}") + return result + + +def _check_convergence(test: _ExposureTest, target: int, tolerance: float) -> bool: + """Check whether the brightness is within the specified target range.""" + return abs(test.level - target) < target * tolerance + + +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)] bayer_array = bayer_array.view(np.uint16) @@ -320,7 +312,7 @@ def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray: return channels -def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: +def _get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: """Compresses channel down to a 16x12 grid - from libcamera. This is taken from @@ -342,10 +334,10 @@ def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: return np.reshape(np.array(grid), (12, 16)) -def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: +def _upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: """Zoom an image in the last two dimensions. - This is effectively the inverse operation of ``get_16x12_grid`` + This is effectively the inverse operation of ``_get_16x12_grid`` """ zoom_factors = [ 1, @@ -353,7 +345,7 @@ def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: return zoom(grids, zoom_factors, order=1)[:, : shape[0], : shape[1]] -def downsampled_channels( +def _downsampled_channels( channels: np.ndarray, blacklevel: int = 256 ) -> list[np.ndarray]: """Generate a downsampled, un-normalised image from which to calculate the LST. @@ -365,7 +357,7 @@ def downsampled_channels( step = np.ceil(channel_shape / lst_shape).astype(int) return np.stack( [ - get_16x12_grid( + _get_16x12_grid( channels[i, ...].astype(float) - blacklevel, step[1], step[0] ) for i in range(channels.shape[0]) @@ -374,16 +366,16 @@ def downsampled_channels( ) -def lst_from_channels(channels: np.ndarray) -> LensShadingTables: +def _lst_from_channels(channels: np.ndarray) -> LensShadingTables: """Given the 4 Bayer colour channels from a white image, generate a LST. - Internally, is just calls ``downsampled_channels`` and ``lst_from_grids``. + Internally, is just calls ``_downsampled_channels`` and ``_lst_from_grids``. """ - grids = downsampled_channels(channels) - return lst_from_grids(grids) + grids = _downsampled_channels(channels) + return _lst_from_grids(grids) -def lst_from_grids(grids: np.ndarray) -> LensShadingTables: +def _lst_from_grids(grids: np.ndarray) -> LensShadingTables: """Given 4 downsampled grids, generate the luminance and chrominance tables. The grids are the 4 BAYER channels RGGB @@ -409,7 +401,7 @@ def lst_from_grids(grids: np.ndarray) -> LensShadingTables: return luminance_gains, cr_gains, cb_gains -def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray: +def _grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray: """Convert form luminance/chrominance dict to four RGGB channels. Note that these will be normalised - the maximum green value is always 1. @@ -423,110 +415,7 @@ def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarra return np.stack([B, G, G, R], axis=0) -def set_static_lst( - tuning: dict, - luminance: np.ndarray, - cr: np.ndarray, - cb: np.ndarray, -) -> None: - """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. - - ``tuning`` will be updated in-place to set its shading to static, and disable any - adaptive tweaking by the algorithm. - """ - for table in luminance, cr, cb: - assert np.array(table).shape == (12, 16), "Lens shading tables must be 12x16!" - alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") - alsc["n_iter"] = 0 # disable the adaptive part - alsc["luminance_strength"] = 1.0 - alsc["calibrations_Cr"] = [ - {"ct": 4500, "table": as_flat_rounded_list(cr, round_to=3)} - ] - alsc["calibrations_Cb"] = [ - {"ct": 4500, "table": as_flat_rounded_list(cb, round_to=3)} - ] - alsc["luminance_lut"] = as_flat_rounded_list(luminance, round_to=3) - - -def set_static_ccm( - tuning: dict, - col_corr_matrix: tuple[ - float, float, float, float, float, float, float, float, float - ], -) -> None: - """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. - - ``tuning`` will be updated in-place to set its shading to static, and disable any - adaptive tweaking by the algorithm. - """ - ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") - ccm["ccms"] = [{"ct": 2860, "ccm": col_corr_matrix}] - - -def get_static_ccm(tuning: dict) -> None: - """Get the ``rpi.ccm`` section of a camera tuning dict.""" - ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") - return ccm["ccms"] - - -def lst_is_static(tuning: dict) -> bool: - """Whether the lens shading table is set to static.""" - alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") - return alsc["n_iter"] == 0 - - -def set_static_geq( - tuning: dict, - offset: int = 65535, -) -> None: - """Update the ``rpi.geq`` section of a camera tuning dict. - - :param tuning: the raspberry pi tuning file. This will be updated in-place to - set the geq offset to the given value. - :param offset: The desired green equalisation offset. Default 65535. The default is - the maximum allowed value. This means the brightness will always be below the - threshold where averaging is used. This is default as we always need the green - equalisation to averages the green pixels in the red and blue rows due to the - chief ray angle compensation issue when the the stock lens is replaced by an - objective. - """ - geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") - geq["offset"] = offset # max out offset to disable the adaptive green equalisation - - -def _geq_is_static(tuning: dict) -> bool: - """Whether the green equalisation is set to static.""" - geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") - return geq["offset"] == 65535 - - -def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int: - """Find the index of an algorithm's section in the tuning file.""" - for i, a in enumerate(algorithms): - if algorithm in a: - return i - raise ValueError(f"Algorithm {algorithm} is not available.") - - -def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None: - """Copy the ``rpi.alsc`` algorithm from one tuning to another. - - This is done in-place, i.e. modifying to_tuning. - """ - # Using Picamera2 function to find the relevant sub-dict for each tuning file - from_i = index_of_algorithm(from_tuning["algorithms"], "rpi.alsc") - to_i = index_of_algorithm(to_tuning["algorithms"], "rpi.alsc") - # Updating the dictionary in place. - to_tuning["algorithms"][to_i] = from_tuning["algorithms"][from_i] - - -def lst_from_camera(camera: Picamera2) -> LensShadingTables: - """Acquire a raw image and use it to calculate a lens shading table.""" - channels = raw_channels_from_camera(camera) - return lst_from_channels(channels) - - -def raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: +def _raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: """Acquire a raw image and return a 4xNxM array of the colour channels.""" if camera.started: camera.stop_recording() @@ -545,19 +434,4 @@ def raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: # channels, 1/2 for green because there's twice as many green pixels). raw_format = camera.camera_configuration()["raw"]["format"] print(f"Acquired a raw image in format {raw_format}") - return channels_from_bayer_array(raw_image) - - -def recreate_camera_manager() -> None: - """Delete and recreate the camera manager. - - This is necessary to ensure the tuning file is re-read. - """ - del Picamera2._cm - gc.collect() - Picamera2._cm = picamera2.picamera2.CameraManager() - - -def as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]: - """Flatten array, round, and then convert to list.""" - return np.reshape(array, -1).round(round_to).tolist() + return _channels_from_bayer_array(raw_image) diff --git a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py new file mode 100644 index 00000000..95df25b1 --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py @@ -0,0 +1,134 @@ +"""Functions for loading, adjusting, or reading from the Picamera2 tuning file. + +The functions that edit the tuning files edit them in place. This will change in +the future. +""" + +from picamera2 import Picamera2 +import numpy as np + + +def load_default_tuning(cam: Picamera2) -> dict: + """Load the default tuning file for the camera. + + This will open and close the camera to determine its model. If you are + using a model that's supported by ``picamera2`` it should have a tuning + file built in. If not, this will probably crash with an error. + + Error handling for unsupported cameras is not something we are likely + to test in the short term. + """ + cp = cam.camera_properties + fname = f"{cp['Model']}.json" + try: + return cam.load_tuning_file(fname) + except RuntimeError: + tuning_dir = "/usr/share/libcamera/ipa/raspberrypi" + # from picamera2 v0.3.9 + # The directory above has been removed from the search path seems + # odd - as that's where the files currently are on a default + # Raspbian image. This may need updating if the files have moved + # in future updates to the system libcamera package + return cam.load_tuning_file(fname, dir=tuning_dir) + + +def set_static_lst( + tuning: dict, + luminance: np.ndarray, + cr: np.ndarray, + cb: np.ndarray, +) -> None: + """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. + + ``tuning`` will be updated in-place to set its shading to static, and disable any + adaptive tweaking by the algorithm. + """ + for table in luminance, cr, cb: + assert np.array(table).shape == (12, 16), "Lens shading tables must be 12x16!" + alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") + alsc["n_iter"] = 0 # disable the adaptive part + alsc["luminance_strength"] = 1.0 + alsc["calibrations_Cr"] = [ + {"ct": 4500, "table": _as_flat_rounded_list(cr, round_to=3)} + ] + alsc["calibrations_Cb"] = [ + {"ct": 4500, "table": _as_flat_rounded_list(cb, round_to=3)} + ] + alsc["luminance_lut"] = _as_flat_rounded_list(luminance, round_to=3) + + +def set_static_ccm( + tuning: dict, + col_corr_matrix: tuple[ + float, float, float, float, float, float, float, float, float + ], +) -> None: + """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. + + ``tuning`` will be updated in-place to set its shading to static, and disable any + adaptive tweaking by the algorithm. + """ + ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") + ccm["ccms"] = [{"ct": 2860, "ccm": col_corr_matrix}] + + +def get_static_ccm(tuning: dict) -> None: + """Get the ``rpi.ccm`` section of a camera tuning dict.""" + ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") + return ccm["ccms"] + + +def lst_is_static(tuning: dict) -> bool: + """Whether the lens shading table is set to static.""" + alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") + return alsc["n_iter"] == 0 + + +def set_static_geq( + tuning: dict, + offset: int = 65535, +) -> None: + """Update the ``rpi.geq`` section of a camera tuning dict. + + :param tuning: the raspberry pi tuning file. This will be updated in-place to + set the geq offset to the given value. + :param offset: The desired green equalisation offset. Default 65535. The default is + the maximum allowed value. This means the brightness will always be below the + threshold where averaging is used. This is default as we always need the green + equalisation to averages the green pixels in the red and blue rows due to the + chief ray angle compensation issue when the the stock lens is replaced by an + objective. + """ + geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") + geq["offset"] = offset # max out offset to disable the adaptive green equalisation + + +def geq_is_static(tuning: dict) -> bool: + """Whether the green equalisation is set to static.""" + geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") + return geq["offset"] == 65535 + + +def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None: + """Copy the ``rpi.alsc`` algorithm from one tuning to another. + + This is done in-place, i.e. modifying to_tuning. + """ + # Using Picamera2 function to find the relevant sub-dict for each tuning file + from_i = _index_of_algorithm(from_tuning["algorithms"], "rpi.alsc") + to_i = _index_of_algorithm(to_tuning["algorithms"], "rpi.alsc") + # Updating the dictionary in place. + to_tuning["algorithms"][to_i] = from_tuning["algorithms"][from_i] + + +def _index_of_algorithm(algorithms: list[dict], algorithm: str) -> int: + """Find the index of an algorithm's section in the tuning file.""" + for i, a in enumerate(algorithms): + if algorithm in a: + return i + raise ValueError(f"Algorithm {algorithm} is not available.") + + +def _as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]: + """Flatten array, round, and then convert to list.""" + return np.reshape(array, -1).round(round_to).tolist()