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.
This commit is contained in:
Julian Stirling 2025-09-17 21:18:48 +01:00
parent 5566125e6e
commit c16a0391df
4 changed files with 238 additions and 233 deletions

View file

@ -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:

View file

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

View file

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

View file

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