Merge branch 'temperature-ccm' into 'v3'

Ship our own default tuning files

Closes #427 and #565

See merge request openflexure/openflexure-microscope-server!410
This commit is contained in:
Julian Stirling 2025-11-11 12:04:07 +00:00
commit bb703ab2b1
13 changed files with 1163 additions and 312 deletions

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36ca9e6e8ee0af71a03dee4a6e2d4405c63d54ea9b123046d39c0187402504da
size 96857

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f65ef3712cb9986cb28f40000ddb283e89fe93671b55ad12d8b6fc057afeac9
size 111804

View file

@ -3,48 +3,9 @@
from copy import deepcopy
import tempfile
import pytest
import labthings_fastapi as lt
from openflexure_microscope_server.things.camera.picamera import (
MissingCalibrationError,
)
from .cam_test_utils import camera_test_client
def test_get_tuning_algo(picamera_thing):
"""Test that get_tuning algorithm retrieves tuning algorithms."""
# Missing algorithm raises an error.
with pytest.raises(MissingCalibrationError):
picamera_thing.get_tuning_algo("foo")
# Or returns None if explicitly told not to error.
assert picamera_thing.get_tuning_algo("foo", raise_if_missing=False) is None
# Real algorithm is a dict and contains expected keys
assert isinstance(picamera_thing.get_tuning_algo("rpi.geq"), dict)
assert "offset" in picamera_thing.get_tuning_algo("rpi.geq")
# Set the tuning to None as it is technically optional. And check this
# is handled gracefully. This needs to be done in a try block as LabThings
# tries and fails to emit an event.
try:
picamera_thing.tuning = None
except lt.exceptions.NotConnectedToServerError:
# Labthings will complain that it is not connected to a server
pass
# Check it is set to None despite the above LabThings issue.
assert picamera_thing.tuning is None
# Raises an error as there is no tuning file set.
with pytest.raises(MissingCalibrationError):
picamera_thing.get_tuning_algo("rpi.geq")
# Or returns None if explicitly told not to error.
assert picamera_thing.get_tuning_algo("rpi.geq", raise_if_missing=False) is None
def test_calibration(picamera_thing, picamera_client):
"""Check that full auto calibrate completes and set the expected values."""
# Check the calibration_required property used by the calibration wizard
@ -53,9 +14,6 @@ def test_calibration(picamera_thing, picamera_client):
original_default = deepcopy(picamera_thing.default_tuning)
# Tuning should start the same as the server is loading with no settings.
assert picamera_thing.default_tuning == picamera_thing.tuning
# lens_shading tables should be None when loaded on default tuning as it is
# adaptive
assert picamera_thing.lens_shading_tables is None
# The background detector isn't ready as there is no background image.
assert not picamera_thing.background_detector_status.ready
@ -68,13 +26,7 @@ def test_calibration(picamera_thing, picamera_client):
assert picamera_thing.default_tuning != picamera_thing.tuning
# The default should be unchanged
assert picamera_thing.default_tuning == original_default
# Lens shading tables should no longer be None
assert picamera_thing.lens_shading_tables is not None
# There should be exactly one colour correction matrix
# (no variations by colour temp)
assert len(picamera_thing.get_tuning_algo("rpi.ccm")["ccms"]) == 1
# Green equalisation offset should be set to maximum.
assert picamera_thing.get_tuning_algo("rpi.geq")["offset"] == 65535
assert picamera_thing.background_detector_status.ready

View file

@ -2,25 +2,16 @@
import logging
from fastapi.testclient import TestClient
import numpy as np
from labthings_fastapi.server import ThingServer
from labthings_fastapi.client import ThingClient
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
from .cam_test_utils import camera_test_client
logging.basicConfig(level=logging.DEBUG)
def test_sensor_mode():
"""Test capturing raw arrays in two different sensor modes."""
cam = StreamingPiCamera2()
server = ThingServer()
server.add_thing(cam, "/camera/")
with TestClient(server.app) as test_client:
client = ThingClient.from_url("/camera/", client=test_client)
with camera_test_client() as client:
for size in [(3280, 2464), (1640, 1232)]:
client.sensor_mode = {"output_size": size, "bit_depth": 10}
arr = np.array(client.capture_array(stream_name="raw"))

View file

@ -1,7 +1,5 @@
"""Tests that check that a tuning file can be reloaded."""
import os
import pytest
from picamera2 import Picamera2
@ -24,24 +22,6 @@ def generate_bad_tuning():
return bad_tuning
def print_tuning(read_file: bool = False):
"""Print the path of the default tuning file from the the environment variable.
:param read_file: Boolean, set true to also print the file contents.
This is useful for debugging. As pytest suppresses the printing by default the
-s option is needed when running pytest to see this.
"""
key = "LIBCAMERA_RPI_TUNING_FILE"
if key in os.environ:
print(f"Tuning file environment variable: {os.environ[key]}")
if read_file:
with open(os.environ[key], "r") as f:
print(f.read())
else:
print("Tuning file environment variable not set")
def _test_bad_tuning_after_good_tuning(configure: bool = False):
"""Test loading good, bad, then another good tuning files in sequence.
@ -56,10 +36,8 @@ def _test_bad_tuning_after_good_tuning(configure: bool = False):
"""
bad_tuning = generate_bad_tuning()
default_tuning = tf_utils.load_default_tuning("imx219")
print_tuning()
print("opening camera with default tuning")
with Picamera2(tuning=default_tuning) as cam:
print_tuning()
if configure:
cam.configure(cam.create_preview_configuration())
del cam

Binary file not shown.

View file

@ -15,7 +15,7 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf
"""
from __future__ import annotations
from typing import Annotated, Iterator, Literal, Mapping, Optional, overload, Any
from typing import Annotated, Iterator, Literal, Mapping, Optional, Any
from types import TracebackType
import json
import logging
@ -112,28 +112,13 @@ class SensorModeSelector(BaseModel):
These values are the output size and the bit depth.
This is a Pydantic model so that it can be saved to disk.
This is a Pydantic model so that it can sent by FastAPI
"""
output_size: tuple[int, int]
bit_depth: int
class LensShading(BaseModel):
"""A Pydantic model holding the lens shading tables.
PiCamera needs three numpy arrays for lens shading correction. Each array is
(12, 16) in size. The arrays are luminance, red-difference chroma (Cr), and
blue-difference chroma (Cb).
This is a Pydantic model so that it can be saved to the disk.
"""
luminance: list[list[float]]
Cr: list[list[float]]
Cb: list[list[float]]
class StreamingPiCamera2(BaseCamera):
"""A Thing that provides and interface to the Raspberry Pi Camera.
@ -170,7 +155,7 @@ class StreamingPiCamera2(BaseCamera):
)
# Set tuning to default tuning. This will be overwritten when the Thing is
# connects to the server if tuning is saved to disk.
# connected to the server if tuning is saved to disk.
try:
self.tuning = copy.deepcopy(self.default_tuning)
except NotConnectedToServerError:
@ -178,6 +163,10 @@ class StreamingPiCamera2(BaseCamera):
# a server. But we know this, so we ignore the error.
pass
# Also set the colour gains based on the tuning. Set to _colour_gains to not
# trigger a NotConnectedToServerError
self._colour_gains = tf_utils.get_colour_gains_from_lst(self.tuning)
stream_resolution = lt.ThingProperty(
tuple[int, int],
initial_value=(820, 616),
@ -216,7 +205,8 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property
def calibration_required(self) -> bool:
"""Whether the camera needs calibrating."""
return not self.lens_shading_is_static
# Check if the lens shading table is calibrated.
return not tf_utils.lst_calibrated(self.tuning)
## Persistent controls! These are settings
@ -260,7 +250,7 @@ class StreamingPiCamera2(BaseCamera):
with self._streaming_picamera() as cam:
cam.set_controls({"ColourGains": value})
_exposure_time: int = 0
_exposure_time: int = 500
@lt.thing_setting
def exposure_time(self) -> int:
@ -346,42 +336,6 @@ class StreamingPiCamera2(BaseCamera):
tuning = lt.ThingSetting(Optional[dict], None, readonly=True)
"""The Raspberry PiCamera Tuning File JSON."""
# Use overload to clarify that only a dictionary is returned if `raise_if_missing`
# is True
@overload
def get_tuning_algo(
self, algorithm_name: str, raise_if_missing: Literal[True]
) -> dict: ...
# Otherwise may also be None
@overload
def get_tuning_algo(
self, algorithm_name: str, raise_if_missing: bool
) -> Optional[dict]: ...
def get_tuning_algo(
self, algorithm_name: str, raise_if_missing: bool = True
) -> Optional[dict]:
"""Return the active tuning algorithm settings for the given algorithm.
:returns: The algorithm dictionary if found, returns None if no tuning data
is loaded or if the tuning algorithm is not found.
:raises MissingCalibrationError: If raise_if_missing is true and there is no
tuning file is available, or the requested algorithm is not present.
"""
if self.tuning is None:
if raise_if_missing:
raise MissingCalibrationError("No tuning data is set.")
return None
try:
return tf_utils.find_tuning_algo(self.tuning, algorithm_name)
except StopIteration as e:
if raise_if_missing:
raise MissingCalibrationError(
f"No tuning algorithm with name {algorithm_name}."
) from e
return None
def _initialise_picamera(self, check_sensor_model: bool = False) -> None:
"""Acquire the picamera device and store it as ``self._picamera``.
@ -730,13 +684,18 @@ class StreamingPiCamera2(BaseCamera):
# luminance (L), red-difference chroma (Cr), and blue-difference chroma
# (Cb).
L, Cr, Cb = recalibrate_utils.lst_from_camera(cam, self._sensor_info) # noqa: N806
self.tuning = tf_utils.set_static_lst(self.tuning, L, Cr, Cb)
self.tuning = tf_utils.set_lst(
self.tuning,
luminance=L,
cr=Cr,
cb=Cb,
colour_temp=tf_utils.CALIBRATED_COLOUR_TEMP,
)
# 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)))
self.colour_gains = tf_utils.get_colour_gains_from_lst(self.tuning)
@lt.thing_property
def colour_correction_matrix(
@ -749,18 +708,15 @@ class StreamingPiCamera2(BaseCamera):
It is a 9 value tuple used to specify the 3x3 matrix that the GPU pipeline uses
to convert from the camera R,G,B vector to the standard R,G,B.
The value here is interpolated from the IMX219 defaults for the colour temperatures
above and below our LED temperature of 5000K.
"""
return tuple(tf_utils.get_static_ccm(self.tuning)[0]["ccm"])
return tuple(tf_utils.get_ccm(self.tuning))
@colour_correction_matrix.setter # type: ignore
def colour_correction_matrix(
self,
value: tuple[float, float, float, float, float, float, float, float, float],
) -> None:
self.tuning = tf_utils.set_static_ccm(self.tuning, value)
self.tuning = tf_utils.set_ccm(self.tuning, value)
if self._picamera is not None:
with self._streaming_picamera(pause_stream=True):
@ -768,38 +724,12 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_action
def reset_ccm(self) -> None:
"""Overwrite the colour correction matrix in camera tuning with default values.
These values are from the Raspberry Pi Camera Algorithm and Tuning Guide, page
45.
"""
# This is flattened 3x3 matrix. See `colour_correction_matrix`
if self._camera_board == "picamera_v2":
col_corr_matrix = [
2.222935,
-0.759672,
-0.463262,
-0.683489,
2.711882,
-1.028399,
-0.261375,
-0.668016,
1.929391,
]
else:
# Note the only other option is the HQ
col_corr_matrix = [
2.164374,
-0.97259,
-0.191778,
-0.376957,
2.099377,
-0.722417,
-0.11787,
-0.489362,
1.607232,
]
self.colour_correction_matrix = col_corr_matrix
"""Overwrite the colour correction matrix in camera tuning with default values."""
self.tuning = tf_utils.copy_algo_from_other_tuning(
algo="rpi.ccm",
base_tuning_file=self.tuning,
copy_from=self.default_tuning,
)
@lt.thing_action
def set_static_green_equalisation(self, offset: int = 65535) -> None:
@ -837,7 +767,7 @@ class StreamingPiCamera2(BaseCamera):
* ``auto_expose_from_minimum``
* ``set_static_green_equalisation`` to set geq offset to max
* ``calibrate_lens_shading`` (also sets colour gains for white balance)
* ``reset_ccm``
* ``set_background``
"""
self.flat_lens_shading()
@ -845,29 +775,9 @@ class StreamingPiCamera2(BaseCamera):
self.set_static_green_equalisation()
self.set_ce_enable_to_off()
self.calibrate_lens_shading()
self.reset_ccm()
time.sleep(0.5)
self.set_background(portal)
@lt.thing_action
def flat_lens_shading(self) -> None:
"""Disable flat-field correction.
This method will set a completely flat lens shading table. It is not the
same as the default behaviour, which is to use an adaptive lens shading
table.
This flat table is used to take an image with no lens shading so that the
correct lens shading table can be calibrated.
"""
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))
self.tuning = tf_utils.set_static_lst(
self.tuning, flat_array, flat_array, flat_array
)
self._initialise_picamera()
@lt.thing_property
def primary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions for both calibration wizard and settings panel."""
@ -914,6 +824,12 @@ class StreamingPiCamera2(BaseCamera):
can_terminate=False,
button_primary=False,
),
action_button_for(
self.flat_lens_shading_chrominance,
submit_label="Disable Flat Field Chrominance",
can_terminate=False,
button_primary=False,
),
action_button_for(
self.reset_lens_shading,
submit_label="Reset Flat Field Correction",
@ -950,64 +866,57 @@ class StreamingPiCamera2(BaseCamera):
]
@lt.thing_property
def lens_shading_tables(self) -> Optional[LensShading]:
def lens_shading_tables(self) -> Optional[tf_utils.LensShading]:
"""The current lens shading (i.e. flat-field correction).
Return the current lens shading correction, as three 2D lists each with
dimensions 16x12, if a static lens shading table is in use.
dimensions 16x12.
Return None if:
- adaptive control is enabled
- multiple LSTs in use (for different colour temperatures),
The colour temperature is returned. If the colour temperature us 5000 then this
means the lens shading tables have been calibrated (with our illumination which
has a 5000k colour temperature). Other numbers are set when flatening or
resetting the table.
"""
if not self.lens_shading_is_static:
return None
# Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction"
alsc = self.get_tuning_algo("rpi.alsc")
# Check there is exactly 1 correction table for red-difference chroma (Cr)
# and blue-difference chroma (Cb)
if len(alsc["calibrations_Cr"]) != 1 or len(alsc["calibrations_Cb"]) != 1:
# If there is not exactly one table, then lens shading isn't static.
return None
def reshape_lst(lin: list[float]) -> list[list[float]]:
"""Reshape the 192 element list into a 2D 16x12 list."""
w, h = 16, 12
return [lin[w * i : w * (i + 1)] for i in range(h)]
return LensShading(
luminance=reshape_lst(alsc["luminance_lut"]),
Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]),
Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]),
)
return tf_utils.get_lst(self.tuning)
@lens_shading_tables.setter
def lens_shading_tables(self, lst: LensShading) -> None:
def lens_shading_tables(self, lst: tf_utils.LensShading) -> None:
"""Set the lens shading tables."""
with self._streaming_picamera(pause_stream=True):
self.tuning = tf_utils.set_static_lst(
self.tuning = tf_utils.set_lst(
self.tuning,
luminance=lst.luminance,
cr=lst.Cr,
cb=lst.Cb,
colour_temp=lst.colour_temp,
)
self._initialise_picamera()
@lt.thing_action
def flat_lens_shading_chrominance(self) -> None:
def flat_lens_shading(self) -> None:
"""Disable flat-field correction.
This method will set a completely flat lens shading table. It is not the
same as the default behaviour, which is to use an adaptive lens shading
table.
This flat table is used to take an image with no lens shading so that the
correct lens shading table can be calibrated.
"""
with self._streaming_picamera(pause_stream=True):
self.tuning = tf_utils.flatten_lst(self.tuning)
self._initialise_picamera()
@lt.thing_action
def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction for colour only.
This method will set the chrominance of the lens shading table to be
flat, i.e. we'll correct vignetting of intensity, but not any change in
colour across the image.
"""
with self._streaming_picamera(pause_stream=True):
alsc = self.get_tuning_algo("rpi.alsc")
luminance = alsc["luminance_lut"]
flat = np.ones((12, 16))
self.tuning = tf_utils.set_static_lst(self.tuning, luminance, flat, flat)
self.tuning = tf_utils.flatten_lst(self.tuning, keep_luminance=True)
self._initialise_picamera()
@lt.thing_action
@ -1018,22 +927,13 @@ class StreamingPiCamera2(BaseCamera):
by the Raspberry Pi camera.
"""
with self._streaming_picamera(pause_stream=True):
self.tuning = tf_utils.copy_tuning_with_alsc_section_from_other(
base_tuning_file=self.tuning, copy_alsc_from=self.default_tuning
self.tuning = tf_utils.copy_algo_from_other_tuning(
algo="rpi.alsc",
base_tuning_file=self.tuning,
copy_from=self.default_tuning,
)
self._initialise_picamera()
@lt.thing_property
def lens_shading_is_static(self) -> bool:
"""Whether the lens shading is static.
This property is true if the lens shading correction has been set to use
a static table (i.e. the number of automatic correction iterations is zero).
The default LST is not static, but all the calibration controls will set it
to be static (except "reset")
"""
return tf_utils.lst_is_static(self.tuning)
@property
def thing_state(self) -> Mapping[str, Any]:
"""Update generic camera metadata with Picamera-specific data."""

View file

@ -1,32 +1,61 @@
"""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.
The functions that edit the tuning files return a new dictionary that is updated.
"""
from typing import Any
from typing import Any, Optional
from copy import deepcopy
import os
import json
from picamera2 import Picamera2
from pydantic import BaseModel
import numpy as np
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
# The colour temperature to use when setting a value
CALIBRATED_COLOUR_TEMP = 5000
# The colour temperature to use for default uncalibrated values
DEFAULT_COLOUR_TEMP = 1234
class LensShading(BaseModel):
"""A Pydantic model holding the lens shading tables.
PiCamera needs three numpy arrays for lens shading correction. Each array is
(12, 16) in size. The arrays are luminance, red-difference chroma (Cr), and
blue-difference chroma (Cb).
This is a Pydantic model so that it can sent by FastAPI
"""
luminance: list[list[float]]
Cr: list[list[float]]
Cb: list[list[float]]
colour_temp: int
class TuningFileError(RuntimeError):
"""Raised if the tuning file cannot be loaded for any reason."""
def load_default_tuning(sensor_model: str) -> dict:
"""Load the default tuning file for the camera.
This will load the tuning file based on the specified sensor model.
"""
fname = f"{sensor_model}.json"
# Note the vc4 here. This locks us to Pi4. We will need to update this to support
# the Raspberry Pi 5.
tuning_path = os.path.join(THIS_DIR, "tuning_files", "vc4", fname)
try:
return Picamera2.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 Picamera2.load_tuning_file(fname, dir=tuning_dir)
with open(tuning_path, "r", encoding="utf-8") as file_obj:
return json.load(file_obj)
except (json.decoder.JSONDecodeError, IOError) as e:
raise TuningFileError(f"Could not load tuning from {tuning_path}.") from e
def find_tuning_algo(tuning: dict[str, dict], name: str) -> dict[str, Any]:
@ -44,70 +73,162 @@ def find_tuning_algo(tuning: dict[str, dict], name: str) -> dict[str, Any]:
# Version 1 of the tuning files was simply a dictionary of algorithms. Later
# versions have an "algorithms" key, the value of which is a list of algorithms.
if version == 1:
return tuning[name]
try:
return tuning[name]
except KeyError as e:
raise KeyError(f"No algorithm {name} in tuning.") from e
# The tuning file "algorithms" is a list of dictionaries
if "algorithms" not in tuning:
raise KeyError("A v2 tuning file must specify an algorithms key")
algorithms = tuning["algorithms"]
# The list is a list of dictionaries that have 1 key: the algorithm name
algo_dict = next(algo for algo in algorithms if name in algo)
try:
algo_dict = next(algo for algo in algorithms if name in algo)
except StopIteration as e:
raise KeyError(f"No algorithm {name} in tuning.") from e
# We want the value for that key, which is a dictionary of algorithm parameters
return algo_dict[name]
def set_static_lst(
def set_lst(
tuning: dict,
luminance: np.ndarray,
cr: np.ndarray,
cb: np.ndarray,
*,
luminance: Optional[np.ndarray],
cr: Optional[np.ndarray],
cb: Optional[np.ndarray],
colour_temp: int,
) -> dict:
"""Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction.
"""Update the ``rpi.alsc`` section of with new lens shading tables.
``tuning`` will be updated in-place to set its shading to static, and disable any
adaptive tweaking by the algorithm.
Only one set of tables is set so that the camera pipeline does not adaptively
switch between lens shading tables based on its estimation of colour temperature.
Also ``n_iter`` is set to 0 so that the pipeline doesn't perform an iterative
adaption of our table to try to "correct" if interprets the image as having
different types of lighting.
:param tuning: The current tuning file.
:param luminance: The table of luminance values, as (12, 16) numpy array. Or None
to leave unchanged.
:param cr: The table of Cr values, as (12, 16) numpy array. Or None to leave
unchanged.
:param cb: The table of Cb values, as (12, 16) numpy array. Or None to leave
unchanged.
:param colour_temp: The colour temperature to set. On calibration this should be
set to 5000. Set a different value for the PiCamera Thing to report that the
lens shading is not calibrated.
:return: an updated tuning dict with the new lens shading tables.
"""
output_tuning = deepcopy(tuning)
for table in luminance, cr, cb:
alsc = find_tuning_algo(output_tuning, "rpi.alsc")
alsc["n_iter"] = 0 # disable the adaptive part.
alsc["luminance_strength"] = 1.0
def check_shape(table: np.ndarray) -> None:
"""Throw error if the lens shading table is the wrong shape."""
if np.array(table).shape != (12, 16):
raise ValueError("Lens shading tables must be 12x16!")
alsc = find_tuning_algo(output_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)
if cr is not None:
check_shape(cr)
alsc["calibrations_Cr"] = [
{"ct": colour_temp, "table": _as_flat_rounded_list(cr, round_to=3)}
]
if cb is not None:
check_shape(cb)
alsc["calibrations_Cb"] = [
{"ct": colour_temp, "table": _as_flat_rounded_list(cb, round_to=3)}
]
if luminance is not None:
check_shape(luminance)
alsc["luminance_lut"] = _as_flat_rounded_list(luminance, round_to=3)
return output_tuning
def set_static_ccm(
tuning: dict,
col_corr_matrix: tuple[
float, float, float, float, float, float, float, float, float
],
) -> dict:
"""Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction.
def flatten_lst(tuning: dict, keep_luminance: bool = False) -> dict:
"""Flaten the len shading table ro an array of ones.
``tuning`` will be updated in-place to set its shading to static, and disable any
adaptive tweaking by the algorithm.
:param tuning: The current tuning dictionary.
:param keep_luminance: Set to True to only flatten the cr and cb tables.
:return: An updated tuning dict.
"""
flat = np.ones((12, 16))
return set_lst(
tuning,
luminance=None if keep_luminance else flat,
cr=flat,
cb=flat,
colour_temp=DEFAULT_COLOUR_TEMP,
)
def get_lst(tuning: dict) -> LensShading:
"""Return the lens shading as a LenSading Base Model."""
# Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction"
alsc = find_tuning_algo(tuning, "rpi.alsc")
def reshape_lst(lin: list[float]) -> list[list[float]]:
"""Reshape the 192 element list into a 2D 16x12 list."""
w, h = 16, 12
return [lin[w * i : w * (i + 1)] for i in range(h)]
return LensShading(
luminance=reshape_lst(alsc["luminance_lut"]),
Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]),
Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]),
colour_temp=alsc["calibrations_Cb"][0]["ct"],
)
def get_colour_gains_from_lst(tuning: dict) -> tuple[float, float]:
"""Get the colour gains that are needed from the lens shading tables.
The lens shading tables are calculated to create a white balanced image, but the
ISP normalises by the minimum Cr and Cb value. So these need to be set as colour
gains.
"""
alsc = find_tuning_algo(tuning, "rpi.alsc")
min_cr = float(min(alsc["calibrations_Cr"][0]["table"]))
min_cb = float(min(alsc["calibrations_Cb"][0]["table"]))
return (min_cr, min_cb)
def lst_calibrated(tuning: dict) -> bool:
"""Whether the lens shading table is calibrated.
This checks whether the lens shading table is has a colour temperature of 5000. As
this is what we set on calibration. Our tuning file sets a temperature of 1234.
"""
alsc = find_tuning_algo(tuning, "rpi.alsc")
return alsc["calibrations_Cr"][0]["ct"] == CALIBRATED_COLOUR_TEMP
def set_ccm(
tuning: dict,
col_corr_matrix: list,
) -> dict:
"""Update the ``rpi.ccm`` section of a camera tuning dict set the colour correction matrix.
:param tuning: The current tuning dict
:param col_corr_matrix: The colour correction matrix to set
:return: an updated tuning dict with the new colour correction matrix.
"""
output_tuning = deepcopy(tuning)
if len(col_corr_matrix) != 9:
raise ValueError("col_corr_matrix should be a list of 9 floats")
ccm = find_tuning_algo(output_tuning, "rpi.ccm")
ccm["ccms"] = [{"ct": 5000, "ccm": col_corr_matrix}]
ccm["ccms"] = [{"ct": CALIBRATED_COLOUR_TEMP, "ccm": list(col_corr_matrix)}]
return output_tuning
def get_static_ccm(tuning: dict) -> None:
def get_ccm(tuning: dict) -> None:
"""Get a copy of the the ``rpi.ccm`` section of a camera tuning dict."""
ccm = find_tuning_algo(tuning, "rpi.ccm")
return deepcopy(ccm["ccms"])
def lst_is_static(tuning: dict) -> bool:
"""Whether the lens shading table is set to static."""
alsc = find_tuning_algo(tuning, "rpi.alsc")
return alsc["n_iter"] == 0
return deepcopy(ccm["ccms"][0]["ccm"])
def set_static_geq(
@ -116,14 +237,14 @@ def set_static_geq(
) -> dict:
"""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 tuning: the Raspberry Pi camera tuning dictionary
: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.
:return: An updated tuning dictionary
"""
output_tuning = deepcopy(tuning)
geq = find_tuning_algo(output_tuning, "rpi.geq")
@ -158,24 +279,25 @@ def ce_enable_is_static(tuning: dict) -> bool:
return contrast["ce_enable"] == 0
def copy_tuning_with_alsc_section_from_other(
*, base_tuning_file: dict, copy_alsc_from: dict
def copy_algo_from_other_tuning(
algo: str, *, base_tuning_file: dict, copy_from: dict
) -> dict:
"""Return a copy of tuning_file with the lens shading correction from another file.
"""Return a copy of tuning_file with an algorithm copied from another file.
All parameters are keyword only for clarity.
Tuning dict arguments are keyword only for clarity.
:param algo: The algorithm to copy. Eg ``rpi.alsc`` for lens shading correction.
:param base_tuning_file: The tuning file to copy.
:param copy_alsc_from: The tuning file to take the alsc section from.
:return: A deep copy of base_tuning_file with the alsc section copied in from the
other tuning file.
:param copy_from: The tuning file to copy the algorithm from section from.
:return: A deep copy of base_tuning_file with the specified algorithm copied in
from the other tuning file.
"""
output_tuning = deepcopy(base_tuning_file)
# Find the relevant sub-dict for each tuning file
from_i = _index_of_algorithm(copy_alsc_from["algorithms"], "rpi.alsc")
to_i = _index_of_algorithm(base_tuning_file["algorithms"], "rpi.alsc")
# Updating the dictionary in place.
output_tuning["algorithms"][to_i] = deepcopy(copy_alsc_from["algorithms"][from_i])
from_i = _index_of_algorithm(copy_from["algorithms"], algo)
to_i = _index_of_algorithm(base_tuning_file["algorithms"], algo)
# Updating the output_tuning copy.
output_tuning["algorithms"][to_i] = deepcopy(copy_from["algorithms"][from_i])
return output_tuning

View file

@ -0,0 +1,183 @@
# A list of all the settings in the Raspberry Pi Camera (vc4) tuning files.
Note that VC4 is the patform used by the Pi for with the broadcom ISP. From Pi 5 onwards Raspberry Pi uses their own ISP (PiSP). These have their own tuning file structure.
More data is available in this datasheet: https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf
## version:
This needs to be 2.0, version one was a normal dictionary of algorithms, rather than this current structure with the algorithms as a list.
## target
Needs to be bcm2835. This is the broadcom system on a chip used by vc4.
## Algorithms
### rpi.black_level - Sensor Black Level
Contains one key-value pair. i.e.: `black_level: 4096`
Note that this is not the actual black level of the sensor, but the black level once the image has been scaled up to 16 bit. So for a 10-bit sensor this number would need to be divided by 2^6.
### rpi.dpc - Defective Pixel Correction
This has three strength parameters:
* 0 - None
* 1 - Normal (default)
* 2 - Strong
### rpi.lux - Lux Calculation
This is used to create the lux value. This lux value is not used in the ISP but is sent to the metadata for the image for downstream control algorithms. The camera may crash if this is removed even though we turn off the adaptive algorithms.
### rpi.noise - Noise Profile
This calculates the noise profile for the current images.
Values from the default tuning files for imx219 and imx477 are:
```
"reference_constant": 0,
"reference_slope": 3.67
```
These can be optimised using the methods in the camera tuning tool shipped with libcamera. Though most of the camera tuning tool methods make assumptions that are invalid for a microscope. As such any tuning would need further consideration.
### rpi.geq - Green Equalisation
#### The problems with green equalisation
This tried to calculate whether adjacent grren pixels should be equal. This interacts very porly with the chief ray angle compensation when using the camera. The imx219 has a lenslet array:
![](../../../../../../docs/images/ChiefRayAngle.png)
Normally as shown in (a) the light comes in at an angle as we get to the edge of the sensor due to how close the lens is to the camera. The lenslet array is designed for this and so if you are in the centre (b), or at the edge (d) the light is focussed onto the correct pixel.
For a microscope the light comes in perpendicular to the sensor, this makes no difference in the centre but at the edge of the image (e) there is a lot of cross talk between pixels.
But it makes sense that this also strongly affects the green imbalance (which is something I didn't know about, but should, and will need to read about in more detail so we can improve tuning). If we consider the case of red light, we expect that the red pixels are the only illuminated pixels. If we consider what happens on the sensor in the case of the microscope, I think the light will be focussing on the positions shown:
![](../../../../../../docs/images/GreenEqualisation.png)
So in the case of the diagonals this is illuminating the green in both the red and the blue row. Where as in the areas we were seeing issues the red light will be strongly illuminating the the greens in one row but not the other.
The result is that for strongly red on the edges every other geen pixel is illuminated causing an overflow.
#### Turning off the adaptive green equalisation
The parameters for geeen equalisation are a `slope` and `offset`
The "green equalisation" algorithm averages Gr and Gb when they "don't look that different". The threshold below which things get averaged is given by
`threshold = offset + pixel_value * slope`
As we always want the green pixels to be averaged to remove the effect above. We can set `offset` the maximum 16-bit value (65535). In this case it will always average green pixels, no matter the pixel value or the value of `slope`.
### rpi.sdn - Spatial Denoise
This algorithm is used on the VC4 (Pi 4) but not on PiSP (Pi 5) which uses `rpi.denoise`. A warning on startup asks us to move it into `rpi.denoise` even though we are on VC4 that does not support `rpi.denoise`.
### rpi.awb - Automatic White Balance
Even though we don't use it, camera can't start without it being minimally populated.
### rpi.agc - Automatic Gain Control / Automatic Exposure Control
For calculating the shutter time of the sensor if AeEnable is on. Only the required fields are populated with minimal data as AeEnable is always off.
### rpi.alsc - Automatic Lens Shading Correction
Auto lens shading correction is calibrated by our microscope. This contains the tables for luninance, and colour shading. It can contain multiple tables depending on estimated colour temperature (`ct`). As the microscope has fixed lighting we only provide one set of tables as we don't want the correction to be adaptive.
We also set `n_iter: 0`. This stops it adding iterative adaptuion.
As there is only one table, the colour temperature is ignored. We set the colour temperature to 1234 in the tuning file to make it clear that we have not calibrated it on the microscope. We set the colour temperature to 5000 (our actual illumination colour temperature) once recalibrated.
The tables are flat 1s for luminance on for both sensors.
For the IMX477 (PiCamera HQ), the Cr and Cb tables are flat, but Cb is 2.0 not 1.0 for all values. This is so that the initial colour gains are set to (1.0, 2.0)
For the IMX219 (PiCamera v2), the Cr and Cb tables are symmetric tables taken from the top quadrant of a calibrated microscope. They are used to create a good enough correction that the microscope is initially usable for alignment.
### rpi.contrast - Contrast
This does the gamma correction. If `ce_enable` is 1 then the gamma correction is somewhat adaptive. We set this to zero to always use the specified gamma table.
The table of gamma values is a 1d list of alternating x,y values in the gamma curve.
We may want to adjust the gamma curve in future.
### rpi.ccm - Colour Correction Matrix
It is possible tp ship different CCMs to be used based on the estimated colour temperatire. We only ship one CCM as our illumination colour temperature is fixed to 5000K, the colour temperature of the LED board. However, the diffuser might affect this.
The CCMs are different for PiCamera HQ and PiCamera version 2. The default tuning files for these cameras (`/usr/share/libcamera/ipa/rpi/vc4/imx219.json` or `/usr/share/libcamera/ipa/rpi/vc4/imx477.json`) specify CCMs for a number of temperatures but not for 5000K. As such we linearly interpolate between temperatures around 5000K with the following code:
```
import numpy as np
temps = {4650:
np.array([
2.18174, -0.70887, -0.47287,
-0.70196, 2.76426, -1.06231,
-0.25157, -0.71978, 1.97135
]),
5858:
np.array(
[
2.32392, -0.88421, -0.43971,
-0.63821, 2.58348, -0.94527,
-0.28541, -0.54112, 1.82653
])
}
target_temp = 5000
temp_low, temp_high = sorted(temps.keys())
alpha = (target_temp - temp_low) / (temp_high - temp_low)
print(alpha)
ccm_interp = (1 - alpha) * temps[temp_low] + alpha * temps[temp_high]
print(np.round(ccm_interp, 6))
temps = {4559: np.array(
[
2.15423, -0.98143, -0.17279,
-0.38131, 2.14763, -0.76632,
-0.10069, -0.54383, 1.64452
]),
5881:np.array(
[
2.18464, -0.95493, -0.22971,
-0.36826, 2.00298, -0.63471,
-0.15219, -0.38055, 1.53274
])
}
target_temp = 5000
temp_low, temp_high = sorted(temps.keys())
alpha = (target_temp - temp_low) / (temp_high - temp_low)
print(alpha)
ccm_interp = (1 - alpha) * temps[temp_low] + alpha * temps[temp_high]
print(np.round(ccm_interp, 6))
```
#### Calculating our own CCMs
The CCM calculation is done using a MacBeth target. Lib camera provides a method for this using the Colour Tuning Tool. We will need to adapt this algorithm as they image the target at once and pick out the areas. We have fixed lighting but do not have colour uniformity.
In the future, with a colour calibration slide, we can take images of each colour with fixed camera settings to create a CCM using the same calculations as the Camera Tuning Tool.
### rpi.sharpen - Sharpening
This is empty. We do not want to be artificially sharpening images as this can add in artifacts.
### rpi.hdr - High dynamic range
Not relevant for us, we don't use high dynamic range capture.
### rpi.sync - Software Camera Synchronisation
Not relevant for us, we only have 1 camera.

View file

@ -0,0 +1,226 @@
{
"version": 2.0,
"target": "bcm2835",
"algorithms": [
{
"rpi.black_level": {
"black_level": 4096
}
},
{
"rpi.dpc": {}
},
{
"rpi.lux": {
"reference_shutter_speed": 27685,
"reference_gain": 1.0,
"reference_aperture": 1.0,
"reference_lux": 998,
"reference_Y": 12744
}
},
{
"rpi.noise": {
"reference_constant": 0,
"reference_slope": 3.67
}
},
{
"rpi.geq": {
"offset": 65535,
"slope": 0.01633
}
},
{
"rpi.sdn": {}
},
{
"rpi.awb":
{
"modes":
{
"auto":
{
"lo": 1000,
"hi": 8000
}
},
"bayes": 1,
"sensitivity_r": 1.0,
"sensitivity_b": 1.0
}
},
{
"rpi.agc": {
"channels": [
{
"metering_modes": {
"centre-weighted": {
"weights": [3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1, 0, 0, 0, 0]
}
},
"exposure_modes": {
"normal": {
"shutter": [100, 10000, 30000, 60000, 66666],
"gain": [ 1.0, 2.0, 4.0, 6.0, 8.0]
}
},
"constraint_modes": {
"normal": [
{
"bound": "LOWER",
"q_lo": 0.98,
"q_hi": 1.0,
"y_target": [
0, 0.5,
1000, 0.5
]
}
]
},
"y_target": [
0, 0.16,
1000, 0.165,
10000, 0.17
]
}
]
}
},
{
"rpi.alsc": {
"omega": 1.3,
"n_iter": 0,
"luminance_strength": 1.0,
"calibrations_Cr": [
{
"ct": 1234,
"table": [
1.209, 1.200, 1.197, 1.189, 1.169, 1.150, 1.135, 1.128, 1.128, 1.135, 1.150, 1.169, 1.189, 1.197, 1.200, 1.209,
1.180, 1.195, 1.216, 1.233, 1.240, 1.238, 1.235, 1.233, 1.233, 1.235, 1.238, 1.240, 1.233, 1.216, 1.195, 1.180,
1.147, 1.175, 1.217, 1.264, 1.304, 1.324, 1.338, 1.345, 1.345, 1.338, 1.324, 1.304, 1.264, 1.217, 1.175, 1.147,
1.110, 1.144, 1.207, 1.279, 1.346, 1.391, 1.414, 1.425, 1.425, 1.414, 1.391, 1.346, 1.279, 1.207, 1.144, 1.110,
1.083, 1.116, 1.184, 1.278, 1.363, 1.426, 1.464, 1.473, 1.473, 1.464, 1.426, 1.363, 1.278, 1.184, 1.116, 1.083,
1.068, 1.101, 1.170, 1.267, 1.365, 1.441, 1.485, 1.497, 1.497, 1.485, 1.441, 1.365, 1.267, 1.170, 1.101, 1.068,
1.068, 1.101, 1.170, 1.267, 1.365, 1.441, 1.485, 1.497, 1.497, 1.485, 1.441, 1.365, 1.267, 1.170, 1.101, 1.068,
1.083, 1.116, 1.184, 1.278, 1.363, 1.426, 1.464, 1.473, 1.473, 1.464, 1.426, 1.363, 1.278, 1.184, 1.116, 1.083,
1.110, 1.144, 1.207, 1.279, 1.346, 1.391, 1.414, 1.425, 1.425, 1.414, 1.391, 1.346, 1.279, 1.207, 1.144, 1.110,
1.147, 1.175, 1.217, 1.264, 1.304, 1.324, 1.338, 1.345, 1.345, 1.338, 1.324, 1.304, 1.264, 1.217, 1.175, 1.147,
1.180, 1.195, 1.216, 1.233, 1.240, 1.238, 1.235, 1.233, 1.233, 1.235, 1.238, 1.240, 1.233, 1.216, 1.195, 1.180,
1.209, 1.200, 1.197, 1.189, 1.169, 1.150, 1.135, 1.128, 1.128, 1.135, 1.150, 1.169, 1.189, 1.197, 1.200, 1.209
]
}
],
"calibrations_Cb": [
{
"ct": 1234,
"table": [
1.318, 1.320, 1.322, 1.329, 1.330, 1.327, 1.337, 1.343, 1.343, 1.337, 1.327, 1.330, 1.329, 1.322, 1.320, 1.318,
1.296, 1.311, 1.332, 1.357, 1.374, 1.382, 1.400, 1.421, 1.421, 1.400, 1.382, 1.374, 1.357, 1.332, 1.311, 1.296,
1.279, 1.296, 1.334, 1.376, 1.410, 1.435, 1.462, 1.490, 1.490, 1.462, 1.435, 1.410, 1.376, 1.334, 1.296, 1.279,
1.268, 1.284, 1.325, 1.387, 1.439, 1.471, 1.508, 1.541, 1.541, 1.508, 1.471, 1.439, 1.387, 1.325, 1.284, 1.268,
1.259, 1.286, 1.324, 1.392, 1.457, 1.506, 1.550, 1.586, 1.586, 1.550, 1.506, 1.457, 1.392, 1.324, 1.286, 1.259,
1.262, 1.291, 1.337, 1.398, 1.461, 1.522, 1.576, 1.617, 1.617, 1.576, 1.522, 1.461, 1.398, 1.337, 1.291, 1.262,
1.262, 1.291, 1.337, 1.398, 1.461, 1.522, 1.576, 1.617, 1.617, 1.576, 1.522, 1.461, 1.398, 1.337, 1.291, 1.262,
1.259, 1.286, 1.324, 1.392, 1.457, 1.506, 1.550, 1.586, 1.586, 1.550, 1.506, 1.457, 1.392, 1.324, 1.286, 1.259,
1.268, 1.284, 1.325, 1.387, 1.439, 1.471, 1.508, 1.541, 1.541, 1.508, 1.471, 1.439, 1.387, 1.325, 1.284, 1.268,
1.279, 1.296, 1.334, 1.376, 1.410, 1.435, 1.462, 1.490, 1.490, 1.462, 1.435, 1.410, 1.376, 1.334, 1.296, 1.279,
1.296, 1.311, 1.332, 1.357, 1.374, 1.382, 1.400, 1.421, 1.421, 1.400, 1.382, 1.374, 1.357, 1.332, 1.311, 1.296,
1.318, 1.320, 1.322, 1.329, 1.330, 1.327, 1.337, 1.343, 1.343, 1.337, 1.327, 1.330, 1.329, 1.322, 1.320, 1.318
]
}
],
"luminance_lut":[
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0
],
"sigma": 0.00381,
"sigma_Cb": 0.00216
}
},
{
"rpi.contrast": {
"ce_enable": 0,
"gamma_curve": [
0, 0,
1024, 5040,
2048, 9338,
3072, 12356,
4096, 15312,
5120, 18051,
6144, 20790,
7168, 23193,
8192, 25744,
9216, 27942,
10240, 30035,
11264, 32005,
12288, 33975,
13312, 35815,
14336, 37600,
15360, 39168,
16384, 40642,
18432, 43379,
20480, 45749,
22528, 47753,
24576, 49621,
26624, 51253,
28672, 52698,
30720, 53796,
32768, 54876,
36864, 57012,
40960, 58656,
45056, 59954,
49152, 61183,
53248, 62355,
57344, 63419,
61440, 64476,
65535, 65535
]
}
},
{
"rpi.ccm": {
"ccms": [
{
"ct": 5000,
"ccm": [
2.222935, -0.759672, -0.463262,
-0.683489, 2.711882, -1.028399,
-0.261375, -0.668016, 1.929391
]
}
]
}
},
{
"rpi.sharpen": {}
},
{
"rpi.hdr": {
"MultiExposureUnmerged": {
"cadence": [
1,
2
],
"channel_map": {
"short": 1,
"long": 2
}
}
}
},
{
"rpi.sync": {}
}
]
}

View file

@ -0,0 +1,226 @@
{
"version": 2.0,
"target": "bcm2835",
"algorithms": [
{
"rpi.black_level": {
"black_level": 4096
}
},
{
"rpi.dpc": {}
},
{
"rpi.lux": {
"reference_shutter_speed": 27685,
"reference_gain": 1.0,
"reference_aperture": 1.0,
"reference_lux": 998,
"reference_Y": 12744
}
},
{
"rpi.noise": {
"reference_constant": 0,
"reference_slope": 3.67
}
},
{
"rpi.geq": {
"offset": 65535,
"slope": 0.01633
}
},
{
"rpi.sdn": {}
},
{
"rpi.awb":
{
"modes":
{
"auto":
{
"lo": 1000,
"hi": 8000
}
},
"bayes": 1,
"sensitivity_r": 1.0,
"sensitivity_b": 1.0
}
},
{
"rpi.agc": {
"channels": [
{
"metering_modes": {
"centre-weighted": {
"weights": [3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1, 0, 0, 0, 0]
}
},
"exposure_modes": {
"normal": {
"shutter": [100, 10000, 30000, 60000, 66666],
"gain": [ 1.0, 2.0, 4.0, 6.0, 8.0]
}
},
"constraint_modes": {
"normal": [
{
"bound": "LOWER",
"q_lo": 0.98,
"q_hi": 1.0,
"y_target": [
0, 0.5,
1000, 0.5
]
}
]
},
"y_target": [
0, 0.16,
1000, 0.165,
10000, 0.17
]
}
]
}
},
{
"rpi.alsc": {
"omega": 1.3,
"n_iter": 0,
"luminance_strength": 1.0,
"calibrations_Cr": [
{
"ct": 1234,
"table": [
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0
]
}
],
"calibrations_Cb": [
{
"ct": 1234,
"table": [
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0
]
}
],
"luminance_lut":[
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0
],
"sigma": 0.00381,
"sigma_Cb": 0.00216
}
},
{
"rpi.contrast": {
"ce_enable": 0,
"gamma_curve": [
0, 0,
1024, 5040,
2048, 9338,
3072, 12356,
4096, 15312,
5120, 18051,
6144, 20790,
7168, 23193,
8192, 25744,
9216, 27942,
10240, 30035,
11264, 32005,
12288, 33975,
13312, 35815,
14336, 37600,
15360, 39168,
16384, 40642,
18432, 43379,
20480, 45749,
22528, 47753,
24576, 49621,
26624, 51253,
28672, 52698,
30720, 53796,
32768, 54876,
36864, 57012,
40960, 58656,
45056, 59954,
49152, 61183,
53248, 62355,
57344, 63419,
61440, 64476,
65535, 65535
]
}
},
{
"rpi.ccm": {
"ccms": [
{
"ct": 5000,
"ccm": [
2.164374, -0.97259, -0.191778,
-0.376957, 2.099377, -0.722417,
-0.11787, -0.489362, 1.607232
]
}
]
}
},
{
"rpi.sharpen": {}
},
{
"rpi.hdr": {
"MultiExposureUnmerged": {
"cadence": [
1,
2
],
"channel_map": {
"short": 1,
"long": 2
}
}
}
},
{
"rpi.sync": {}
}
]
}

View file

@ -112,7 +112,6 @@ def test_thing_description_equivalence(mock_picam_thing):
# defined as PiCamera specific, or by replicating the functionality for other
# cameras.
picamera_extra_actions = {
"flat_lens_shading_chrominance",
"set_static_green_equalisation",
"set_ce_enable_to_off",
"stop_streaming",
@ -123,7 +122,6 @@ def test_thing_description_equivalence(mock_picam_thing):
"colour_correction_matrix",
"lens_shading_tables",
"sensor_resolution",
"lens_shading_is_static",
"capture_metadata",
"camera_configuration",
"stream_resolution",
@ -131,5 +129,10 @@ def test_thing_description_equivalence(mock_picam_thing):
"sensor_modes",
"sensor_mode",
}
# Note these are only the action not exposed as calibration actions.
for action in picamera_extra_actions:
assert action in picamera_actions
for props in picamera_extra_props:
assert props in picamera_props
assert picamera_actions - base_actions == picamera_extra_actions
assert picamera_props - base_props == picamera_extra_props

View file

@ -0,0 +1,264 @@
"""Tests for interactions with tuning files and dictionaries."""
from copy import deepcopy
import pytest
import numpy as np
from openflexure_microscope_server.things.camera import (
picamera_tuning_file_utils as tf_utils,
)
RNG = np.random.default_rng()
@pytest.fixture
def imx219_tuning():
"""Load the default tuning file for the PiCamera v2."""
return tf_utils.load_default_tuning("imx219")
@pytest.fixture
def imx477_tuning():
"""Load the default tuning file for the PiCamera HQ."""
return tf_utils.load_default_tuning("imx477")
def test_load_tuning(imx219_tuning, imx477_tuning):
"""Check the sctandard tuning files load and contain the correct information."""
assert imx219_tuning != imx477_tuning
for tuning in [imx219_tuning, imx477_tuning]:
assert tuning["version"] == 2.0
assert tuning["target"] == "bcm2835"
assert isinstance(tuning["algorithms"], list)
# The algorithms in order, and whether we expect them to be the same for the cameras.
algos = [
("rpi.black_level", True),
("rpi.dpc", True),
("rpi.lux", True),
("rpi.noise", True),
("rpi.geq", True),
("rpi.sdn", True),
("rpi.awb", True),
("rpi.agc", True),
("rpi.alsc", False),
("rpi.contrast", True),
("rpi.ccm", False),
("rpi.sharpen", True),
("rpi.hdr", True),
("rpi.sync", True),
]
# Strict= true so this will error if the tuning files are not the same length as
# the algorithms
zipped_algos = zip(
algos, imx219_tuning["algorithms"], imx477_tuning["algorithms"], strict=True
)
# Split up the algorithm name, either we expect them to be the same in both files
# and the algorithm for the two tuning files.
for (algo_name, algo_same), algo219, algo477 in zipped_algos:
# Check this is the correct algorithm
assert algo_name in algo219
assert algo_name in algo477
# Check they are they are the same if expected to be.
if algo_same:
assert algo219 == algo477
else:
assert algo219 != algo477
def test_find_tuning_algo_v1():
"""Check tuning algorithms are read directly from dict if no version is set."""
# Version 1 onf the tuning file was just a dictionary of algorithms
mock_tuning = {"mock": {"param": 1}, "foo": {"param": "bar"}}
assert tf_utils.find_tuning_algo(mock_tuning, "mock") == {"param": 1}
assert tf_utils.find_tuning_algo(mock_tuning, "foo") == {"param": "bar"}
with pytest.raises(KeyError, match="No algorithm who in tuning."):
assert tf_utils.find_tuning_algo(mock_tuning, "who")
# Also check that if version is not 1 it is read differently.
mock_tuning["version"] = 2
with pytest.raises(KeyError, match="A v2 tuning file must specify"):
assert tf_utils.find_tuning_algo(mock_tuning, "mock")
def test_find_tuning_algo_v2():
"""Check reading version 2 dictionary structure."""
mock_tuning = {
"version": 2.0,
"algorithms": [{"mock": {"param": 1}}, {"foo": {"param": "bar"}}],
}
assert tf_utils.find_tuning_algo(mock_tuning, "mock") == {"param": 1}
assert tf_utils.find_tuning_algo(mock_tuning, "foo") == {"param": "bar"}
with pytest.raises(KeyError, match="No algorithm who in tuning."):
assert tf_utils.find_tuning_algo(mock_tuning, "who")
def _assert_lst_table_equivalence(array, table):
"""Check equivalence between input numpy array and output table."""
for i in range(12):
for j in range(16):
assert table[i][j] == round(float(array[i, j]), 3)
assert table[i][j] == round(float(array[i, j]), 3)
def test_tuning_lst_tools(imx219_tuning, imx477_tuning):
"""Check reading the default lens shading tables."""
for tuning in (imx219_tuning, imx477_tuning):
assert not tf_utils.lst_calibrated(tuning)
lst_model = tf_utils.get_lst(tuning)
assert isinstance(lst_model, tf_utils.LensShading)
# Check the tables are lists of lists
assert isinstance(lst_model.luminance, list)
assert isinstance(lst_model.luminance[0], list)
assert isinstance(lst_model.Cr, list)
assert isinstance(lst_model.Cr[0], list)
assert isinstance(lst_model.Cb, list)
assert isinstance(lst_model.Cb[0], list)
# Loaded table has default lens tuning,
assert lst_model.colour_temp == tf_utils.DEFAULT_COLOUR_TEMP
def test_tuning_initial_colour_gains(imx219_tuning, imx477_tuning):
"""The initial colour gains are as expected."""
assert tf_utils.get_colour_gains_from_lst(imx219_tuning) == (1.068, 1.259)
assert tf_utils.get_colour_gains_from_lst(imx477_tuning) == (1.0, 2.0)
def test_setting_lst(imx219_tuning):
"""Check that the LST can be set."""
lum = RNG.normal(size=(12, 16))
cr = RNG.normal(size=(12, 16))
cb = RNG.normal(size=(12, 16))
updated_tuning = tf_utils.set_lst(
imx219_tuning,
luminance=lum,
cr=cr,
cb=cb,
colour_temp=tf_utils.CALIBRATED_COLOUR_TEMP,
)
# Check it wasn't modified in place.
assert updated_tuning != imx219_tuning
# Check it now reports as calibrated
assert tf_utils.lst_calibrated(updated_tuning)
lst_model = tf_utils.get_lst(updated_tuning)
assert lst_model.colour_temp == tf_utils.CALIBRATED_COLOUR_TEMP
# Check the numbers are equivalent from the input array and the output table
# except for formatting and rounding.
_assert_lst_table_equivalence(lum, lst_model.luminance)
_assert_lst_table_equivalence(cr, lst_model.Cr)
_assert_lst_table_equivalence(cb, lst_model.Cb)
# Flatten the lens shading tables
flat_tuning = tf_utils.flatten_lst(updated_tuning)
# In this case only the colour channels
flat_chroma_tuning = tf_utils.flatten_lst(updated_tuning, keep_luminance=True)
# Check each was changed
assert updated_tuning != flat_tuning
assert updated_tuning != flat_chroma_tuning
assert flat_tuning != flat_chroma_tuning
# Check the flattened tunings report as not calibrated
assert not tf_utils.lst_calibrated(flat_tuning)
assert not tf_utils.lst_calibrated(flat_chroma_tuning)
flat_lst_model = tf_utils.get_lst(flat_tuning)
flat_chroma_lst_model = tf_utils.get_lst(flat_chroma_tuning)
flat_array = np.ones((12, 16))
# Check the that it really was flattened for the flat tuning
_assert_lst_table_equivalence(flat_array, flat_lst_model.luminance)
_assert_lst_table_equivalence(flat_array, flat_lst_model.Cr)
_assert_lst_table_equivalence(flat_array, flat_lst_model.Cb)
# Check only colour channels flattened when keep_luminance=True
_assert_lst_table_equivalence(lum, flat_chroma_lst_model.luminance)
_assert_lst_table_equivalence(flat_array, flat_chroma_lst_model.Cr)
_assert_lst_table_equivalence(flat_array, flat_chroma_lst_model.Cb)
def test_setting_ccm(imx219_tuning):
"""Check the colour correction matrix can be set."""
# CCM is set as a flat list. Create a mock ccm
new_ccm = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]
updated_tuning = tf_utils.set_ccm(imx219_tuning, new_ccm)
# Check it wasn't modified in place.
assert updated_tuning != imx219_tuning
# Check the value was set
assert tf_utils.get_ccm(updated_tuning) == new_ccm
# Check that our dictionary doesn't update if the input list is updated.
new_ccm[0] = 0.0
assert tf_utils.get_ccm(updated_tuning) != new_ccm
new_ccm.append(1.0)
with pytest.raises(
ValueError, match="col_corr_matrix should be a list of 9 floats"
):
tf_utils.set_ccm(imx219_tuning, new_ccm)
def test_green_equalisation(imx219_tuning):
"""Test setting the offset parameter of green equalisation."""
assert tf_utils.geq_is_static(imx219_tuning)
bad_geq_tuning = tf_utils.set_static_geq(imx219_tuning, offset=1234)
# Check it wasn't modified in place.
assert bad_geq_tuning != imx219_tuning
# No longer static
assert not tf_utils.geq_is_static(bad_geq_tuning)
fixed_geq_tuning = tf_utils.set_static_geq(bad_geq_tuning)
assert tf_utils.geq_is_static(fixed_geq_tuning)
def test_ce_enable(imx219_tuning):
"""Check the setting of ce enable in the contrast algorithm."""
assert tf_utils.ce_enable_is_static(imx219_tuning)
# No way to enable it so do it manually
bad_tuning = deepcopy(imx219_tuning)
contrast = tf_utils.find_tuning_algo(bad_tuning, "rpi.contrast")
contrast["ce_enable"] = 1
assert not tf_utils.ce_enable_is_static(bad_tuning)
fixed_tuning = tf_utils.set_ce_to_disabled(bad_tuning)
assert tf_utils.ce_enable_is_static(fixed_tuning)
def test_copying_algorithms(imx219_tuning):
"""Check an algorithm can be copied from one file to another."""
# Update the CCM
new_ccm = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]
updated_ccm_tuning = tf_utils.set_ccm(imx219_tuning, new_ccm)
# It has changed
assert updated_ccm_tuning != imx219_tuning
# Create another tuning file with the GEQ updated.
updated_geq_tuning = tf_utils.set_static_geq(imx219_tuning, offset=1234)
# It has changed
assert updated_geq_tuning != imx219_tuning
# Copy the CCM from the dict with the bad GEQ value into the one with the updated
# CCM
original_tuning = tf_utils.copy_algo_from_other_tuning(
"rpi.ccm", base_tuning_file=updated_ccm_tuning, copy_from=updated_geq_tuning
)
# This should now be equivalent to the original.
assert original_tuning == imx219_tuning