Let the tuning file utils handle lens shading table manipulation.

This commit is contained in:
Julian Stirling 2025-10-30 16:12:50 +00:00
parent ec16cc17f8
commit 6763845e38
2 changed files with 129 additions and 134 deletions

View file

@ -15,7 +15,7 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf
""" """
from __future__ import annotations 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 from types import TracebackType
import json import json
import logging import logging
@ -119,22 +119,6 @@ class SensorModeSelector(BaseModel):
bit_depth: 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 sent by FastAPI
"""
luminance: list[list[float]]
Cr: list[list[float]]
Cb: list[list[float]]
colour_temp: int
class StreamingPiCamera2(BaseCamera): class StreamingPiCamera2(BaseCamera):
"""A Thing that provides and interface to the Raspberry Pi Camera. """A Thing that provides and interface to the Raspberry Pi Camera.
@ -348,42 +332,6 @@ class StreamingPiCamera2(BaseCamera):
tuning = lt.ThingSetting(Optional[dict], None, readonly=True) tuning = lt.ThingSetting(Optional[dict], None, readonly=True)
"""The Raspberry PiCamera Tuning File JSON.""" """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: def _initialise_picamera(self, check_sensor_model: bool = False) -> None:
"""Acquire the picamera device and store it as ``self._picamera``. """Acquire the picamera device and store it as ``self._picamera``.
@ -737,6 +685,7 @@ class StreamingPiCamera2(BaseCamera):
luminance=L, luminance=L,
cr=Cr, cr=Cr,
cb=Cb, cb=Cb,
colour_temp=tf_utils.CALIBRATED_COLOUR_TEMP,
) )
# Re-initialise the picamera to reload the tuning file. # Re-initialise the picamera to reload the tuning file.
@ -826,28 +775,6 @@ class StreamingPiCamera2(BaseCamera):
time.sleep(0.5) time.sleep(0.5)
self.set_background(portal) 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_lst(
self.tuning,
luminance=flat_array,
cr=flat_array,
cb=flat_array,
)
self._initialise_picamera()
@lt.thing_property @lt.thing_property
def primary_calibration_actions(self) -> list[ActionButton]: def primary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions for both calibration wizard and settings panel.""" """The calibration actions for both calibration wizard and settings panel."""
@ -894,6 +821,12 @@ class StreamingPiCamera2(BaseCamera):
can_terminate=False, can_terminate=False,
button_primary=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( action_button_for(
self.reset_lens_shading, self.reset_lens_shading,
submit_label="Reset Flat Field Correction", submit_label="Reset Flat Field Correction",
@ -930,39 +863,21 @@ class StreamingPiCamera2(BaseCamera):
] ]
@lt.thing_property @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). """The current lens shading (i.e. flat-field correction).
Return the current lens shading correction, as three 2D lists each with 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: The colour temperature is returned. If the colour temperature us 5000 then this
- adaptive control is enabled means the lens shading tables have been calibrated (with our illumination which
- multiple LSTs in use (for different colour temperatures), has a 5000k colour temperature). Other numbers are set when flatening or
resetting the table.
""" """
# Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction" return tf_utils.get_lst(self.tuning)
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"]),
colour_temp=alsc["calibrations_Cb"][0]["ct"],
)
@lens_shading_tables.setter @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.""" """Set the lens shading tables."""
with self._streaming_picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
self.tuning = tf_utils.set_lst( self.tuning = tf_utils.set_lst(
@ -974,6 +889,21 @@ class StreamingPiCamera2(BaseCamera):
) )
self._initialise_picamera() self._initialise_picamera()
@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):
self.tuning = tf_utils.flatten_lst(self.tuning)
self._initialise_picamera()
@lt.thing_action @lt.thing_action
def flat_lens_shading_chrominance(self) -> None: def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction for colour only. """Disable flat-field correction for colour only.
@ -983,16 +913,7 @@ class StreamingPiCamera2(BaseCamera):
colour across the image. colour across the image.
""" """
with self._streaming_picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
alsc = self.get_tuning_algo("rpi.alsc") self.tuning = tf_utils.flatten_lst(self.tuning, keep_luminance=True)
luminance = alsc["luminance_lut"]
flat = np.ones((12, 16))
self.tuning = tf_utils.set_lst(
self.tuning,
luminance=luminance,
cr=flat,
cb=flat,
colour_temp=1234,
)
self._initialise_picamera() self._initialise_picamera()
@lt.thing_action @lt.thing_action

View file

@ -3,16 +3,39 @@
The functions that edit the tuning files return a new dictionary that is updated. 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 from copy import deepcopy
import os import os
import json import json
from pydantic import BaseModel
import numpy as np import numpy as np
THIS_DIR = os.path.dirname(os.path.abspath(__file__)) 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): class TuningFileError(RuntimeError):
"""Raised if the tuning file cannot be loaded for any reason.""" """Raised if the tuning file cannot be loaded for any reason."""
@ -62,41 +85,92 @@ def find_tuning_algo(tuning: dict[str, dict], name: str) -> dict[str, Any]:
def set_lst( def set_lst(
tuning: dict, tuning: dict,
*, *,
luminance: np.ndarray, luminance: Optional[np.ndarray],
cr: np.ndarray, cr: Optional[np.ndarray],
cb: np.ndarray, cb: Optional[np.ndarray],
colour_temp: int = 5000, colour_temp: int,
) -> dict: ) -> dict:
"""Update the ``rpi.alsc`` section of with new lens shading tables. """Update the ``rpi.alsc`` section of with new lens shading tables.
Only one set of tables is set so no adaptive lens shading will be used. Only one set of tables is set so no adaptive lens shading will be used.
:param tuning: The current tuning file. :param tuning: The current tuning file.
:param luminance: The table of luminance values, as (12, 16) numpy array :param luminance: The table of luminance values, as (12, 16) numpy array. Or None
:param cr: The table of cr values, as (12, 16) numpy array to leave unchanged.
:param cb: The table of cb values, as (12, 16) numpy array :param cr: The table of cr values, as (12, 16) numpy array. Or None to leave
:param colour_temp: The colour temperature to set. By default this is 5000. Set a unchanged.
different value for the PiCamera Thing to report that the lens shading is not :param cb: The table of cb values, as (12, 16) numpy array. Or None to leave
calibrated. 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. :return: an updated tuning dict with the new lens shading tables.
""" """
output_tuning = deepcopy(tuning) output_tuning = deepcopy(tuning)
for table in luminance, cr, cb:
if np.array(table).shape != (12, 16):
raise ValueError("Lens shading tables must be 12x16!")
alsc = find_tuning_algo(output_tuning, "rpi.alsc") alsc = find_tuning_algo(output_tuning, "rpi.alsc")
alsc["n_iter"] = 0 # disable the adaptive part. alsc["n_iter"] = 0 # disable the adaptive part.
alsc["luminance_strength"] = 1.0 alsc["luminance_strength"] = 1.0
alsc["calibrations_Cr"] = [
{"ct": colour_temp, "table": _as_flat_rounded_list(cr, round_to=3)} def check_shape(table: np.ndarray) -> None:
] """Throw error if the lens shading table is the wrong shape."""
alsc["calibrations_Cb"] = [ if np.array(table).shape != (12, 16):
{"ct": colour_temp, "table": _as_flat_rounded_list(cb, round_to=3)} raise ValueError("Lens shading tables must be 12x16!")
]
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 cr 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 return output_tuning
def flatten_lst(tuning: dict, keep_luminance: bool = False) -> dict:
"""Flaten the len shading table ro an array of ones.
: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 lst_calibrated(tuning: dict) -> bool: def lst_calibrated(tuning: dict) -> bool:
"""Whether the lens shading table is calibrated. """Whether the lens shading table is calibrated.
@ -104,7 +178,7 @@ def lst_calibrated(tuning: dict) -> bool:
this is what we set on calibration. Our tuning file sets a temperature of 1234. this is what we set on calibration. Our tuning file sets a temperature of 1234.
""" """
alsc = find_tuning_algo(tuning, "rpi.alsc") alsc = find_tuning_algo(tuning, "rpi.alsc")
return alsc["calibrations_Cr"][0]["ct"] == 5000 return alsc["calibrations_Cr"][0]["ct"] == CALIBRATED_COLOUR_TEMP
def set_ccm( def set_ccm(
@ -121,7 +195,7 @@ def set_ccm(
""" """
output_tuning = deepcopy(tuning) output_tuning = deepcopy(tuning)
ccm = find_tuning_algo(output_tuning, "rpi.ccm") ccm = find_tuning_algo(output_tuning, "rpi.ccm")
ccm["ccms"] = [{"ct": 5000, "ccm": col_corr_matrix}] ccm["ccms"] = [{"ct": CALIBRATED_COLOUR_TEMP, "ccm": col_corr_matrix}]
return output_tuning return output_tuning