From 99eee2e912415a20ba2d501bbd52ea2fe390a27f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 11:02:07 +0000 Subject: [PATCH] Fix camera type issues --- .../things/camera/picamera.py | 20 +++++-------------- .../camera/picamera_recalibrate_utils.py | 18 ++--------------- .../camera/picamera_tuning_file_utils.py | 9 ++++++--- .../things/camera/simulation.py | 15 +++++++------- .../unit_tests/test_picamera_tuning_files.py | 2 +- 5 files changed, 22 insertions(+), 42 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index a8bbf20d..b0886614 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -288,7 +288,7 @@ class StreamingPiCamera2(BaseCamera): "Sharpness": 1, } - _sensor_modes = None + _sensor_modes: Optional[list[SensorMode]] = None @lt.property def sensor_modes(self) -> list[SensorMode]: @@ -363,6 +363,9 @@ class StreamingPiCamera2(BaseCamera): camera_num=self._camera_num, tuning=self.tuning, ) + if self._picamera is None: + # Type narrow (error if failure) + raise RuntimeError("Failed to start Picamera") if check_sensor_model: hw_sensor_model = self._picamera.camera_properties["Model"] if hw_sensor_model != self._sensor_info.sensor_model: @@ -861,7 +864,7 @@ class StreamingPiCamera2(BaseCamera): ] @lt.property - def lens_shading_tables(self) -> Optional[tf_utils.LensShading]: + def lens_shading_tables(self) -> Optional[tf_utils.LensShadingModel]: """The current lens shading (i.e. flat-field correction). Return the current lens shading correction, as three 2D lists each with @@ -874,19 +877,6 @@ class StreamingPiCamera2(BaseCamera): """ return tf_utils.get_lst(self.tuning) - @lens_shading_tables.setter - def _set_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_lst( - self.tuning, - luminance=lst.luminance, - cr=lst.Cr, - cb=lst.Cb, - colour_temp=lst.colour_temp, - ) - self._initialise_picamera() - @lt.action def flat_lens_shading(self) -> None: """Disable flat-field correction. diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index b5b3c5ff..a11b6c91 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -51,7 +51,6 @@ import numpy as np import picamera2 from picamera2 import Picamera2 from pydantic import BaseModel -from scipy.ndimage import zoom LOGGER = logging.getLogger(__name__) @@ -322,18 +321,7 @@ 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: - """Zoom an image in the last two dimensions. - - This is effectively the inverse operation of ``_get_16x12_grid`` - """ - zoom_factors = [ - 1, - ] + list(np.ceil(np.array(shape) / np.array(grids.shape[1:]))) - return zoom(grids, zoom_factors, order=1)[:, : shape[0], : shape[1]] - - -def _downsampled_channels(channels: np.ndarray, blacklevel: int) -> list[np.ndarray]: +def _downsampled_channels(channels: np.ndarray, blacklevel: int) -> np.ndarray: """Generate a downsampled, un-normalised image from which to calculate the LST.""" channel_shape = np.array(channels.shape[1:]) lst_shape = np.array([12, 16]) @@ -398,9 +386,7 @@ def _grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarr return np.stack([B, G, G, R], axis=0) -def _raw_channels_from_camera( - camera: Picamera2, sensor_info: SensorInfo -) -> LensShadingTables: +def _raw_channels_from_camera(camera: Picamera2, sensor_info: SensorInfo) -> np.ndarray: """Acquire a raw image and return a 4xNxM array of the colour channels.""" if camera.started: camera.stop_recording() diff --git a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py index 50965f77..c6b4f77d 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py @@ -20,9 +20,12 @@ CALIBRATED_COLOUR_TEMP = 5000 DEFAULT_COLOUR_TEMP = 1234 -class LensShading(BaseModel): +class LensShadingModel(BaseModel): """A Pydantic model holding the lens shading tables. + Note this shouldn't be confused with the typehint for LensShadingTables in + recalibrate utils which is for the arrays. + 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). @@ -165,7 +168,7 @@ def flatten_lst(tuning: dict, keep_luminance: bool = False) -> dict: ) -def get_lst(tuning: dict) -> LensShading: +def get_lst(tuning: dict) -> LensShadingModel: """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") @@ -175,7 +178,7 @@ def get_lst(tuning: dict) -> LensShading: w, h = 16, 12 return [lin[w * i : w * (i + 1)] for i in range(h)] - return LensShading( + return LensShadingModel( luminance=reshape_lst(alsc["luminance_lut"]), Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]), Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]), diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index e1630597..f723c669 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -33,7 +33,7 @@ from . import BaseCamera LOGGER = logging.getLogger(__name__) -# The ratio between "motor" steps and pixels +# The ratio between "motor" steps and pixels in (x, y, z) # higher related to a faster movement RATIO = (2, 2, 0.2) @@ -203,10 +203,11 @@ class SimulatedCamera(BaseCamera): """ canvas_width, canvas_height, _ = self.canvas_shape image_width, image_height, _ = self.shape - pos = tuple(x * s for x, s in zip(pos, RATIO, strict=True)) + # Scale position by RATIO to get position in base image. + im_pos = tuple(x * ratio for x, ratio in zip(pos, RATIO, strict=True)) top_left = ( - int(pos[0]) - image_width // 2 + self.sample_limits[0] // 2, - int(pos[1]) - image_height // 2 + self.sample_limits[1] // 2, + int(im_pos[0]) - image_width // 2 + self.sample_limits[0] // 2, + int(im_pos[1]) - image_height // 2 + self.sample_limits[1] // 2, ) # Create index list with modulo rather than slicing to handle wrapping at the # canvas edge. @@ -217,7 +218,7 @@ class SimulatedCamera(BaseCamera): # Use npx to make each 1d index list 3D focused_image = canvas[np.ix_(x_indices, y_indices, z_indices)] - image = fast_pil_blur(focused_image, sigma=np.abs(pos[2]) / 5) + image = fast_pil_blur(focused_image, sigma=np.abs(im_pos[2]) / 5) if image.shape != self.shape: raise ValueError( @@ -251,7 +252,7 @@ class SimulatedCamera(BaseCamera): _traceback: Optional[TracebackType], ) -> None: """Close the capture thread when the Thing context manager is closed.""" - if self.stream_active: + if self._capture_thread is not None and self._capture_thread.is_alive(): self._capture_enabled = False self._capture_thread.join() @@ -300,7 +301,7 @@ class SimulatedCamera(BaseCamera): try: frame = self.generate_frame() self.mjpeg_stream.add_frame(_frame2bytes(frame)) - ds_frame = frame.resize((320, 240), resample=Image.NEAREST) + ds_frame = frame.resize((320, 240), resample=Image.Resampling.NEAREST) self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame)) except Exception as e: diff --git a/tests/unit_tests/test_picamera_tuning_files.py b/tests/unit_tests/test_picamera_tuning_files.py index 4938d8a3..47807766 100644 --- a/tests/unit_tests/test_picamera_tuning_files.py +++ b/tests/unit_tests/test_picamera_tuning_files.py @@ -111,7 +111,7 @@ def test_tuning_lst_tools(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) + assert isinstance(lst_model, tf_utils.LensShadingModel) # Check the tables are lists of lists assert isinstance(lst_model.luminance, list) assert isinstance(lst_model.luminance[0], list)