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()