Camera calibration buttons specified by server. Simulation has option to remove sample

This commit is contained in:
Julian Stirling 2025-07-27 15:07:35 +01:00
parent 1e89ef0ff7
commit 0794d4777f
5 changed files with 175 additions and 79 deletions

View file

@ -20,6 +20,7 @@ import piexif
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.ui import ActionButton
from openflexure_microscope_server.background_detect import ( from openflexure_microscope_server.background_detect import (
ColourChannelDetectLUV, ColourChannelDetectLUV,
BackgroundDetectAlgorithm, BackgroundDetectAlgorithm,
@ -476,6 +477,16 @@ class BaseCamera(lt.Thing):
time.sleep(self.settling_time) time.sleep(self.settling_time)
self.discard_frames() 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 # Note that the default detector name is set at init. This is over written if
# setting is loaded from disk. # setting is loaded from disk.
@lt.thing_setting @lt.thing_setting

View file

@ -35,6 +35,7 @@ from picamera2.outputs import Output
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.exceptions import NotConnectedToServerError 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 picamera_recalibrate_utils as recalibrate_utils
from . import BaseCamera, JPEGBlob, ArrayModel from . import BaseCamera, JPEGBlob, ArrayModel
@ -777,6 +778,59 @@ class StreamingPiCamera2(BaseCamera):
) )
self._initialise_picamera() 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 @lt.thing_property
def lens_shading_tables(self) -> Optional[LensShading]: def lens_shading_tables(self) -> Optional[LensShading]:
"""The current lens shading (i.e. flat-field correction). """The current lens shading (i.e. flat-field correction).

View file

@ -21,6 +21,8 @@ from scipy.ndimage import gaussian_filter
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.ui import ActionButton, action_button_for
from . import BaseCamera, JPEGBlob, ArrayModel from . import BaseCamera, JPEGBlob, ArrayModel
from ..stage import BaseStage from ..stage import BaseStage
@ -40,6 +42,7 @@ class SimulatedCamera(BaseCamera):
_stage: Optional[BaseStage] = None _stage: Optional[BaseStage] = None
_server: Optional[lt.ThingServer] = None _server: Optional[lt.ThingServer] = None
_show_sample: bool = True
def __init__( def __init__(
self, self,
@ -124,10 +127,11 @@ class SimulatedCamera(BaseCamera):
Canvas is int16 so that random noise can be added to simulation image before Canvas is int16 so that random noise can be added to simulation image before
changing to unit8 to stop wrapping. changing to unit8 to stop wrapping.
""" """
self.canvas = np.ones(self.canvas_shape, dtype=np.int16) self.blank_canvas = np.ones(self.canvas_shape, dtype=np.int16)
self.canvas[:, :, 0] *= BG_COLOR[0] self.blank_canvas[:, :, 0] *= BG_COLOR[0]
self.canvas[:, :, 1] *= BG_COLOR[1] self.blank_canvas[:, :, 1] *= BG_COLOR[1]
self.canvas[:, :, 2] *= BG_COLOR[2] self.blank_canvas[:, :, 2] *= BG_COLOR[2]
self.canvas = self.blank_canvas.copy()
w, h, _ = self.glyph_shape w, h, _ = self.glyph_shape
for x, y, sprite_size_index in self.blobs: for x, y, sprite_size_index in self.blobs:
self.canvas[ self.canvas[
@ -149,7 +153,8 @@ class SimulatedCamera(BaseCamera):
x_slice = slice(top_left[0], top_left[0] + self.shape[0]) x_slice = slice(top_left[0], top_left[0] + self.shape[0])
y_slice = slice(top_left[1], top_left[1] + self.shape[1]) y_slice = slice(top_left[1], top_left[1] + self.shape[1])
z_slice = slice(None) 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( image = gaussian_filter(
focused_image, focused_image,
sigma=np.abs(pos[2]) / 5, sigma=np.abs(pos[2]) / 5,
@ -279,3 +284,25 @@ class SimulatedCamera(BaseCamera):
output = io.BytesIO() output = io.BytesIO()
piexif.insert(piexif.dump(exif_dict), jpeg, output) piexif.insert(piexif.dump(exif_dict), jpeg, output)
return JPEGBlob.from_bytes(output.getvalue()) 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"),
]

View file

@ -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."""

View file

@ -1,87 +1,38 @@
<template> <template>
<div> <div>
<!--Show auto calibrate if default plugin is enabled--> <!--Show auto calibrate if default plugin is enabled-->
<div v-if="'full_auto_calibrate' in actions" class="uk-margin-small">
<action-button <div
:can-terminate="false" v-for="(action, index) in secondaryCalibrationActions"
:requires-confirmation="true"
:confirmation-message="
'Start recalibration? This may take a while, and the microscope will be locked during this time.'
"
thing="camera"
action="full_auto_calibrate"
:submit-label="'Full Auto-Calibrate'"
@response="onRecalibrateResponse"
@error="modalError"
/>
</div>
<div v-if="'auto_expose_from_minimum' in actions" class="uk-margin-small">
<action-button
:can-terminate="false"
:requires-confirmation="false"
thing="camera"
action="auto_expose_from_minimum"
:submit-label="'Auto Gain &amp; Shutter Speed'"
@response="onRecalibrateResponse"
@error="modalError"
/>
</div>
<div v-if="'calibrate_white_balance' in actions" class="uk-margin-small">
<action-button
:can-terminate="false"
:requires-confirmation="false"
thing="camera"
action="calibrate_white_balance"
:submit-label="'Auto White Balance'"
@response="onRecalibrateResponse"
@error="modalError"
/>
</div>
<div v-if="'calibrate_lens_shading' in actions" class="uk-margin-small">
<action-button
: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.'
"
thing="camera"
action="calibrate_lens_shading"
:submit-label="'Auto Flat Field Correction'"
@response="onRecalibrateResponse"
@error="modalError"
/>
</div>
<div
v-show="showExtraSettings"
v-if="'flatten_lens_shading_table' in actions"
class="uk-child-width-expand" class="uk-child-width-expand"
:key = "'primary_cal' + index"
> >
<action-button <action-button
:can-terminate="false" :can-terminate="action.can_terminate"
:requires-confirmation="false" :requires-confirmation="action.requires_confirmation"
thing="camera" :thing="action.thing"
action="flat_lens_shading" :action="action.action"
:submit-label="'Disable Flat Field Correction'" :submit-label="action.submit_label"
@response="onRecalibrateResponse" @response="onRecalibrateResponse"
@error="modalError" @error="modalError"
/> />
</div> </div>
<div <div v-if="showExtraSettings">
v-show="showExtraSettings" <div
v-if="'reset_lens_shading' in actions" v-for="(action, index) in primaryCalibrationActions"
class="uk-child-width-expand" class="uk-child-width-expand"
> :key = "'secondary_cal' + index"
<action-button >
:can-terminate="false" <action-button
:requires-confirmation="false" :can-terminate="action.can_terminate"
thing="camera" :requires-confirmation="action.requires_confirmation"
action="reset_lens_shading" :thing="action.thing"
:submit-label="'Reset Flat Field Correction'" :action="action.action"
@response="onRecalibrateResponse" :submit-label="action.submit_label"
@error="modalError" @response="onRecalibrateResponse"
/> @error="modalError"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -97,6 +48,13 @@ export default {
ActionButton ActionButton
}, },
data() {
return {
primaryCalibrationActions: [],
secondaryCalibrationActions: []
};
},
props: { props: {
showExtraSettings: { showExtraSettings: {
type: Boolean, 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: { methods: {
onRecalibrateResponse: function() { onRecalibrateResponse: function() {
this.modalNotify("Finished recalibration."); this.modalNotify("Finished recalibration.");