From 0794d4777f240fd2b898d07345f954134d63e885 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 27 Jul 2025 15:07:35 +0100 Subject: [PATCH] Camera calibration buttons specified by server. Simulation has option to remove sample --- .../things/camera/__init__.py | 11 ++ .../things/camera/picamera.py | 54 +++++++++ .../things/camera/simulation.py | 37 +++++- src/openflexure_microscope_server/ui.py | 41 +++++++ .../cameraCalibrationSettings.vue | 111 ++++++------------ 5 files changed, 175 insertions(+), 79 deletions(-) create mode 100644 src/openflexure_microscope_server/ui.py diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 4225b43d..465ab97f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -20,6 +20,7 @@ import piexif import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray +from openflexure_microscope_server.ui import ActionButton from openflexure_microscope_server.background_detect import ( ColourChannelDetectLUV, BackgroundDetectAlgorithm, @@ -476,6 +477,16 @@ class BaseCamera(lt.Thing): time.sleep(self.settling_time) self.discard_frames() + @lt.thing_property + def primary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions for both calibration wizard and settings panel.""" + return [] + + @lt.thing_property + def secondary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions that appear only in settings panel.""" + return [] + # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @lt.thing_setting diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 6ee9e200..70d93799 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -35,6 +35,7 @@ from picamera2.outputs import Output import labthings_fastapi as lt from labthings_fastapi.exceptions import NotConnectedToServerError +from openflexure_microscope_server.ui import ActionButton, action_button_for from . import picamera_recalibrate_utils as recalibrate_utils from . import BaseCamera, JPEGBlob, ArrayModel @@ -777,6 +778,59 @@ class StreamingPiCamera2(BaseCamera): ) self._initialise_picamera() + @lt.thing_property + def primary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions for both calibration wizard and settings panel.""" + return [ + action_button_for( + self.full_auto_calibrate, + submit_label="Full Auto-Calibrate", + can_terminate=False, + requires_confirmation=True, + confirmation_message=( + "Start recalibration? This may take a while, and the microscope " + "will be locked during this time." + ), + ), + action_button_for( + self.auto_expose_from_minimum, + submit_label="Auto Gain & Shutter Speed", + can_terminate=False, + ), + action_button_for( + self.calibrate_white_balance, + submit_label="Auto White Balance", + can_terminate=False, + ), + action_button_for( + self.calibrate_lens_shading, + submit_label="Auto Flat Field Correction", + can_terminate=False, + requires_confirmation=True, + confirmation_message=( + "Is the microscope looking at an evenly illuminated, empty field " + "of view? If not, the current image will show through in any " + "images captured afterwards." + ), + ), + ] + + @lt.thing_property + def secondary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions that appear only in settings panel.""" + return [ + action_button_for( + self.flat_lens_shading, + submit_label="Disable Flat Field Correction", + can_terminate=False, + ), + action_button_for( + self.reset_lens_shading, + submit_label="Reset Flat Field Correction", + can_terminate=False, + ), + ] + @lt.thing_property def lens_shading_tables(self) -> Optional[LensShading]: """The current lens shading (i.e. flat-field correction). diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 76d11eb9..4aa790e4 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -21,6 +21,8 @@ from scipy.ndimage import gaussian_filter import labthings_fastapi as lt +from openflexure_microscope_server.ui import ActionButton, action_button_for + from . import BaseCamera, JPEGBlob, ArrayModel from ..stage import BaseStage @@ -40,6 +42,7 @@ class SimulatedCamera(BaseCamera): _stage: Optional[BaseStage] = None _server: Optional[lt.ThingServer] = None + _show_sample: bool = True def __init__( self, @@ -124,10 +127,11 @@ class SimulatedCamera(BaseCamera): Canvas is int16 so that random noise can be added to simulation image before changing to unit8 to stop wrapping. """ - self.canvas = np.ones(self.canvas_shape, dtype=np.int16) - self.canvas[:, :, 0] *= BG_COLOR[0] - self.canvas[:, :, 1] *= BG_COLOR[1] - self.canvas[:, :, 2] *= BG_COLOR[2] + self.blank_canvas = np.ones(self.canvas_shape, dtype=np.int16) + self.blank_canvas[:, :, 0] *= BG_COLOR[0] + self.blank_canvas[:, :, 1] *= BG_COLOR[1] + self.blank_canvas[:, :, 2] *= BG_COLOR[2] + self.canvas = self.blank_canvas.copy() w, h, _ = self.glyph_shape for x, y, sprite_size_index in self.blobs: self.canvas[ @@ -149,7 +153,8 @@ class SimulatedCamera(BaseCamera): x_slice = slice(top_left[0], top_left[0] + self.shape[0]) y_slice = slice(top_left[1], top_left[1] + self.shape[1]) z_slice = slice(None) - focused_image = self.canvas[(x_slice, y_slice, z_slice)] + canvas = self.canvas if self._show_sample else self.blank_canvas + focused_image = canvas[(x_slice, y_slice, z_slice)] image = gaussian_filter( focused_image, sigma=np.abs(pos[2]) / 5, @@ -279,3 +284,25 @@ class SimulatedCamera(BaseCamera): output = io.BytesIO() piexif.insert(piexif.dump(exif_dict), jpeg, output) return JPEGBlob.from_bytes(output.getvalue()) + + @lt.thing_action + def remove_sample(self): + """Show the simulated background with no sample.""" + if not self._show_sample: + raise RuntimeError("Sample is already removed.") + self._show_sample = False + + @lt.thing_action + def load_sample(self): + """Show the simulated sample.""" + if self._show_sample: + raise RuntimeError("Sample is already in place.") + self._show_sample = True + + @lt.thing_property + def secondary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions that appear only in settings panel.""" + return [ + action_button_for(self.load_sample, submit_label="Load Sample"), + action_button_for(self.remove_sample, submit_label="Remove Sample"), + ] diff --git a/src/openflexure_microscope_server/ui.py b/src/openflexure_microscope_server/ui.py new file mode 100644 index 00000000..9b3163a8 --- /dev/null +++ b/src/openflexure_microscope_server/ui.py @@ -0,0 +1,41 @@ +"""Functionality for communicating the required user interface for a thing.""" + +from pydantic import BaseModel + + +def action_button_for(action, **kwargs): + """Create an action button for the specified Thing Action. + + :param action: The thing action to create a button for. + :param kwargs: Any attribute of `ActionButton` except for ``thing`` or ``action``. + """ + thing = action.args[0] + thing_path = thing.path.strip("/") + action_name = action.func.__name__ + return ActionButton(thing=thing_path, action=action_name, **kwargs) + + +class ActionButton(BaseModel): + """The data required for creating an actionButton in Vue. + + Currently this cannot be used to submitData, or to submitOnEvent. + """ + + thing: str + """The Thing "path" for the Thing instance.""" + action: str + """The name of the action to be triggered.""" + poll_interval: int = 1 + """The interval for polling in seconds.""" + submit_label: str = "Submit" + """The label for the action button.""" + can_terminate: bool = True + """Specify whether the action can be terminated.""" + requires_confirmation: bool = False + """Specify whether a confirmation modal is needed.""" + confirmation_message: str = "Start task?" + """The message for the confirmation modal.""" + button_primary: bool = True + """Specify whether the button is styled as a primary button.""" + modal_progress: bool = False + """Specify whether to show a progress modal.""" diff --git a/webapp/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue b/webapp/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue index 348a9227..d449d024 100644 --- a/webapp/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue +++ b/webapp/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue @@ -1,87 +1,38 @@ @@ -97,6 +48,13 @@ export default { ActionButton }, + data() { + return { + primaryCalibrationActions: [], + secondaryCalibrationActions: [] + }; + }, + props: { showExtraSettings: { type: Boolean, @@ -115,6 +73,11 @@ export default { } }, + async created() { + this.primaryCalibrationActions = await this.readThingProperty("camera", "primary_calibration_actions"); + this.secondaryCalibrationActions = await this.readThingProperty("camera", "secondary_calibration_actions"); + }, + methods: { onRecalibrateResponse: function() { this.modalNotify("Finished recalibration.");