Merge branch 'camera-and-bg-detect-setting-customisation' into 'v3'

Dynamic UI for camera and background detect settings

See merge request openflexure/openflexure-microscope-server!331
This commit is contained in:
Julian Stirling 2025-08-04 23:34:10 +00:00
commit b75af87b84
18 changed files with 880 additions and 353 deletions

Binary file not shown.

View file

@ -8,7 +8,7 @@ current camera field of view contains sample.
from typing import Optional, Any
import cv2
import numpy as np
from pydantic import BaseModel
from pydantic import BaseModel, Field, ConfigDict
from pydantic.errors import PydanticUserError
from scipy.stats import norm
from labthings_fastapi.thing_description import type_to_dataschema
@ -32,13 +32,19 @@ class BackgroundDetectorStatus(BaseModel):
``ready`` is used in case more complex methods are added in the future, which
need different initialisation.
"""
settings: BaseModel
"""The settings for this this background detect Algorithm"""
settings: dict[str, Any]
"""The settings for the current background detect Algorithm. These are a dictionary
dumped from the base model."""
# Setting schema is a dict until LabThings FastAPI issue #154 is fixed and
# DataSchema can be used directly. For now `model_dump()` must be used to dump schema
# to a dict.
settings_schema: dict[str, Any]
"""The schema for the settings for the current background detect Algorithm.
This is reported so that the UI can dynamically create a UI for any background detector
algorithm.
"""
class BackgroundDetectAlgorithm:
@ -63,7 +69,7 @@ class BackgroundDetectAlgorithm:
"""The status information needed for the GUI. Read only."""
return BackgroundDetectorStatus(
ready=self.background_data is not None,
settings=self.settings,
settings=self.settings.model_dump(),
# Dump model with `model_dump()` for reason explained when defining
# BackgroundDetectorStatus
settings_schema=type_to_dataschema(self.settings_data_model).model_dump(),
@ -152,13 +158,18 @@ class ChannelDistributions(BaseModel):
class ColourChannelDetectSettings(BaseModel):
"""A BaseModel for storing the settings for colour channel detectors."""
model_config = ConfigDict(extra="forbid")
channel_tolerance: float = 7.0
"""Channel Tolerance
The number of standard deviations a pixel value must be from the background mean
to be considered sample.
"""
min_sample_coverage: float = 25.0
# Use Field to set Title reported to UI. By default Pydantic will convert the name
# from snake_case to Title Case.
min_sample_coverage: float = Field(25, title="Sample Coverage Required (%)")
"""Sample Coverage Required (%)
The minimum percentage of the image that needs to be identified as sample for the

View file

@ -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, PropertyControl
from openflexure_microscope_server.background_detect import (
ColourChannelDetectLUV,
BackgroundDetectAlgorithm,
@ -476,6 +477,21 @@ 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 []
@lt.thing_property
def manual_camera_settings(self) -> list[PropertyControl]:
"""The camera settings to expose as property controls in the 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
@ -500,6 +516,22 @@ class BaseCamera(lt.Thing):
"""The status of the active detector for the UI."""
return self.active_detector.status
@lt.thing_action
def update_detector_settings(self, data) -> None:
"""Update the settings of the current detector.
This is an action not a setting/property as the data model depends on the
selected detector. As such, it cannot be specified with the necessary precision
to be included in a ThingDescription as a setting/property, while retaining
enough useful information to communicate to the UI how it is set and read.
The information on how to read the settings is exposed in
``background_detector_status``.
"""
self.active_detector.settings = data
# Manually save settings as the setter is not called.
self.save_settings()
@lt.thing_setting
def background_detector_data(self) -> dict:
"""The data for each background detector, used to save to disk."""

View file

@ -35,6 +35,12 @@ from picamera2.outputs import Output
import labthings_fastapi as lt
from labthings_fastapi.exceptions import NotConnectedToServerError
from openflexure_microscope_server.ui import (
ActionButton,
PropertyControl,
action_button_for,
property_control_for,
)
from . import picamera_recalibrate_utils as recalibrate_utils
from . import BaseCamera, JPEGBlob, ArrayModel
@ -777,6 +783,88 @@ 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."
),
notify_on_success=True,
success_message="Finished recalibration.",
),
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 manual_camera_settings(self) -> list[PropertyControl]:
"""The camera settings to expose as property controls in the settings panel."""
return [
property_control_for(
self,
"exposure_time",
label="Exposure Time (0-33251)",
read_back=True,
read_back_delay=1000,
),
property_control_for(
self,
"analogue_gain",
label="Analogue Gain",
read_back=True,
read_back_delay=1000,
),
property_control_for(
self,
"colour_gains",
label="Colour Gains",
read_back=True,
read_back_delay=1000,
),
]
@lt.thing_property
def lens_shading_tables(self) -> Optional[LensShading]:
"""The current lens shading (i.e. flat-field correction).

View file

@ -21,6 +21,13 @@ from scipy.ndimage import gaussian_filter
import labthings_fastapi as lt
from openflexure_microscope_server.ui import (
ActionButton,
PropertyControl,
action_button_for,
property_control_for,
)
from . import BaseCamera, JPEGBlob, ArrayModel
from ..stage import BaseStage
@ -40,6 +47,7 @@ class SimulatedCamera(BaseCamera):
_stage: Optional[BaseStage] = None
_server: Optional[lt.ThingServer] = None
_show_sample: bool = True
def __init__(
self,
@ -124,10 +132,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 +158,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,
@ -161,7 +171,7 @@ class SimulatedCamera(BaseCamera):
)
# Add noise and convert to uint8
image += RNG.normal(scale=2, size=self.shape).astype("int16")
image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16")
image[image < 0] = 0
image[image > 255] = 255
return image.astype("uint8")
@ -216,6 +226,8 @@ class SimulatedCamera(BaseCamera):
return self._capture_thread.is_alive()
return False
noise_level = lt.ThingProperty(float, 2.0)
def _capture_frames(self):
portal = lt.get_blocking_portal(self)
while self._capture_enabled:
@ -279,3 +291,30 @@ 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"),
]
@lt.thing_property
def manual_camera_settings(self) -> list[PropertyControl]:
"""The camera settings to expose as property controls in the settings panel."""
return [property_control_for(self, "noise_level", label="Noise Level")]

View file

@ -0,0 +1,94 @@
"""Functionality for communicating the required user interface for a thing."""
from pydantic import BaseModel
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."""
notify_on_success: bool = False
"""Specify whether to a notification on successful completion."""
success_message: str = "Success!"
"""The message to show on successful completion."""
def action_button_for(action, **kwargs) -> ActionButton:
"""Create a ActionButton data 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 PropertyControl(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."""
property_name: str
"""The name of the property (or setting)."""
label: str
"""The label to show in the UI"""
read_back: bool = False
"""Whether or not to read back the property after setting.
This is useful for hardware settings that may be coerced to the closest value.
"""
read_back_delay: int = 1000
"""The delay in ms before reading back the property."""
def property_control_for(thing, property_name, **kwargs) -> PropertyControl:
"""Create an PropertyControl data for the specified Thing Property.
:param thing: The instance of the thing that has the property to be controlled.
:param property_name: The name of the property to create a control for.
:param kwargs: Any attribute of `PropertyControl` except for ``thing`` or
``property_name``. If label is not set here it will be the property name.
"""
thing_path = thing.path.strip("/")
if "label" not in kwargs:
kwargs["label"] = property_name
return PropertyControl(thing=thing_path, property_name=property_name, **kwargs)

View file

@ -23,6 +23,6 @@ class MockCameraThing:
background_detector_status = BackgroundDetectorStatus(
ready=True,
settings=ColourChannelDetectSettings(),
settings=ColourChannelDetectSettings().model_dump(),
settings_schema={"fake": "schema"},
)

View file

@ -70,7 +70,8 @@ def test_partial_base_class(background_image):
bad_algo1 = BadAlgo1()
status = bad_algo1.status
assert not status.ready
assert isinstance(status.settings, ColourChannelDetectSettings)
# Check the settings dictionary can be validated as ``ColourChannelDetectSettings``
ColourChannelDetectSettings(**status.settings)
with pytest.raises(NotImplementedError):
# Should error on any dictionary input. This simulates loading settings from

133
tests/test_cameras.py Normal file
View file

@ -0,0 +1,133 @@
"""Generic tests for cameras, specific camera hardware reqipres hardware specific tests."""
import pytest
from openflexure_microscope_server.things.camera import BaseCamera
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.camera.opencv import OpenCVCamera
@pytest.fixture
def mock_picam_thing(mocker):
"""Import PiCamera without hardware well enough to get a ThingDescription."""
dummy_cam = mocker.Mock()
mock_picamera2 = mocker.MagicMock()
mock_picamera2.return_value.__enter__.return_value = dummy_cam
mock_picamera2.return_value.__exit__.return_value = None
mocker.patch.dict(
"sys.modules",
{
"picamera2": mock_picamera2,
"picamera2.encoders": mocker.Mock(),
"picamera2.outputs": mocker.Mock(),
},
)
mocker.patch(
"openflexure_microscope_server.things.camera.picamera.recalibrate_utils.load_default_tuning",
return_value={"mock": "tuning"},
)
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
return StreamingPiCamera2()
def _get_clean_camera_description(camera_thing):
"""Return actions and properties for a camera Thing, separating those exposed to the UI.
Return cleaned sets of actions and properties for camera_thing, excluding
calibration actions exposed to the UI as primary or secondary calibration actions,
and excluding the properties exposed in manual camera settings.
:returns: Dict with keys:
* "actions" - Actions not explicitly exposed to the UI by this camera.
* "properties" - Properties not explicitly exposed to the UI as manual camera
settings.
* "calibration_actions" - Actions explicitly exposed to the UI by this camera.
* "manual_properties" - Properties explicitly exposed to the UI as manual
camera settings.
"""
camera_thing.path = "/mock/"
td = camera_thing.thing_description()
actions = set(td.actions.keys())
properties = set(td.properties.keys())
# Calibration actions are camera specific
primary = [btn.action for btn in camera_thing.primary_calibration_actions]
secondary = [btn.action for btn in camera_thing.secondary_calibration_actions]
calibration_actions = set(primary + secondary)
actions -= calibration_actions
# Manual properties are also camera specific
manual_props = {ctrl.property_name for ctrl in camera_thing.manual_camera_settings}
properties -= manual_props
return {
"actions": actions,
"properties": properties,
"calibration_actions": calibration_actions,
"manual_properties": manual_props,
}
def test_thing_description_equivalence(mock_picam_thing):
"""Ensure the built-in camera Thing subclasses retain consistent core functionality.
This test verifies that extra actions are not unintentionally introduced to
camera child classes. Any addition of actions must be accompanied by an update
to this test, prompting discussion of whether the action belongs in the subclass
or the base camera class.
"""
base_td = BaseCamera().thing_description()
base_actions = set(base_td.actions.keys())
base_props = set(base_td.properties.keys())
sim_description = _get_clean_camera_description(SimulatedCamera())
opencv_description = _get_clean_camera_description(OpenCVCamera())
picamera_description = _get_clean_camera_description(mock_picam_thing)
# Note. These are the actions and properties that are not exposed to the UI via
# `primary_calibration_actions`, `secondary_calibration_actions`, or
# `manual_camera_settings`.
sim_actions = sim_description["actions"]
sim_props = sim_description["properties"]
opencv_actions = opencv_description["actions"]
opencv_props = opencv_description["properties"]
picamera_actions = picamera_description["actions"]
picamera_props = picamera_description["properties"]
# Camera actions and properties should generally be equivalent except for exposed
# manual settings and calibration actions.
assert opencv_actions == sim_actions == base_actions
assert opencv_props == sim_props == base_props
# For now PiCamera has a number of extra actions and properties. These should be
# reduced over time by creating a way to use the functionality in a way as clearly
# defined as PiCamera specific, or by replicating the functionality for other
# cameras.
picamera_extra_actions = {
"flat_lens_shading_chrominance",
"set_static_green_equalisation",
"stop_streaming",
"reset_ccm",
}
picamera_extra_props = {
"mjpeg_bitrate",
"colour_correction_matrix",
"lens_shading_tables",
"sensor_resolution",
"lens_shading_is_static",
"capture_metadata",
"camera_configuration",
"stream_resolution",
"tuning",
"sensor_modes",
"sensor_mode",
}
assert picamera_actions - base_actions == picamera_extra_actions
assert picamera_props - base_props == picamera_extra_props

View file

@ -18,6 +18,7 @@
<div>
<button
type="button"
:disabled="isDisabled"
:hidden="taskStarted"
class="uk-button uk-width-1-1"
:class="[buttonPrimary ? 'uk-button-primary' : 'uk-button-default']"
@ -132,7 +133,12 @@ export default {
type: Boolean,
required: false,
default: false
}
},
isDisabled: {
type: Boolean,
required: false,
default: false
},
},
data: function() {

View file

@ -0,0 +1,227 @@
<template>
<div>
<label v-if="dataType == 'number'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
v-model="internalValue"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="requestUpdate">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</label>
<div v-if="dataType == 'boolean'" class="input-and-buttons-container">
<label class="uk-form-label numeric-setting-line-input">
<input
ref="checkbox"
v-model="internalValue"
class="uk-checkbox"
type="checkbox"
@change="sendValue"
/>
{{ label }}
</label>
<a class="button-next-to-input" @click="requestUpdate">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
<label v-if="dataType == 'number_array'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
v-for="i in valueLength"
:key="i"
v-model="internalValue[i - 1]"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="requestUpdate">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</label>
<label v-if="dataType == 'number_object'" class="uk-form-label"
>{{ label }}
<div v-for="(val, key) in value" :key="key">
<label>{{internalLabels[key]}}</label>
<div class="input-and-buttons-container" >
<input
v-model="internalValue[key]"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="requestUpdate">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</div>
</label>
<label v-if="dataType == 'other'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
v-model="internalValue"
class="uk-form-small numeric-setting-line-input"
type="text"
disabled="true"
/>
</div>
</label>
</div>
</template>
<script>
export default {
name: "InputFromSchema",
props: {
dataSchema: {
type: Object
},
value: {
type: null
},
label: {
type: String,
default: ""
},
},
data() {
return {
internalValue: this.value,
valueOnEnter: undefined,
focused: false
};
},
watch: {
value(newValue) {
this.internalValue = newValue;
}
},
computed: {
internalLabels: function() {
if (this.dataType == "number_object") {
let labels = {};
for (const key in this.internalValue){
labels[key] = this.dataSchema.properties[key].title
}
return labels;
}
return [];
},
valueLength: function() {
if (this.dataType == "number_array") {
if (this.internalValue == undefined) {
return 0;
}
return this.internalValue.length;
} else {
return 1;
}
},
dataType: function() {
let prop = this.dataSchema;
if (prop == undefined) {
return "undefined";
}
const num_types = ["integer", "float", "number"];
if (num_types.includes(prop.type)) {
return "number";
}
if (prop.type == "array") {
if (num_types.includes(prop.items.type)) {
return "number_array";
}
if (Array.isArray(prop.items)) {
if (prop.items.every(t => num_types.includes(t.type))) {
return "number_array";
}
}
}
if (prop.type == "boolean") {
return "boolean";
}
if (prop.type == "object") {
let numeric = true;
for (let key in prop.properties) {
if (!num_types.includes(prop.properties[key].type)) {
numeric = false;
break;
}
}
if (numeric) {
return "number_object";
}
}
return "other";
}
},
methods: {
requestUpdate: async function() {
this.$emit("requestUpdate")
},
sendValue: async function() {
this.$emit("sendValue", this.internalValue)
},
checkboxUpdated: function() {
if (this.internalValue != this.$refs.checkbox.checked) {
this.internalValue = this.$refs.checkbox.checked;
this.sendValue();
}
},
focusIn: function(event) {
this.valueOnEnter = event.target.value;
},
focusOut: function(event) {
if (this.valueOnEnter != event.target.value) {
this.sendValue(event.target.value);
}
},
keyDown: function(event) {
// Pressing enter should set the property, whether or not we think it's changed.
if (event.keyCode == 13) {
this.sendValue();
}
}
}
};
</script>
<style scoped>
.input-and-buttons-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
flex-grow: 1;
margin-left: 5px;
margin-right: 5px;
width: 6em;
}
.button-next-to-input {
flex-grow: 0;
padding-left: 5px;
padding-right: 5px;
vertical-align: middle;
cursor: pointer;
}
</style>

View file

@ -1,90 +1,23 @@
<template>
<div>
<label v-if="dataType == 'number'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
v-model="value"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="readProperty">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</label>
<div v-if="dataType == 'boolean'" class="input-and-buttons-container">
<label class="uk-form-label numeric-setting-line-input">
<input
ref="checkbox"
v-model="value"
class="uk-checkbox"
type="checkbox"
@change="writeProperty"
/>
{{ label }}
</label>
<a class="button-next-to-input" @click="readProperty">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
<label v-if="dataType == 'number_array'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
v-for="i in valueLength"
:key="i"
v-model="value[i - 1]"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="readProperty">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</label>
<label v-if="dataType == 'number_object'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
v-for="(_v, key) in value"
:key="key"
v-model="value[key]"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="readProperty">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</label>
<label v-if="dataType == 'other'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
:value="value"
class="uk-form-small numeric-setting-line-input"
type="text"
disabled="true"
/>
</div>
</label>
</div>
<input-from-schema
v-model="value"
:data-schema="propertyDescription"
:label="label"
@requestUpdate="readProperty"
@sendValue="writeProperty"
/>
</template>
<script>
import InputFromSchema from "./inputFromSchema.vue";
export default {
name: "PropertyControl",
components: {
InputFromSchema
},
props: {
label: {
type: String,
@ -98,35 +31,25 @@ export default {
type: String,
required: true
},
readBack: {
type: Boolean,
required: false,
default: false
},
readBackDelay: {
type: Number,
default: undefined,
default: 1000,
required: false
}
},
data: () => {
data() {
return {
value: undefined,
valueOnEnter: undefined,
focused: false
value: undefined
};
},
computed: {
valueLength: function() {
if (this.dataType == "number_array") {
if (this.value == undefined) {
return 0;
}
return this.value.length;
} else {
return 1;
}
},
readBack: function() {
return this.readBackDelay !== undefined;
},
propertyDescription: function() {
try {
return this.thingDescription(this.thingName).properties[
@ -135,42 +58,6 @@ export default {
} catch (error) {
return undefined;
}
},
dataType: function() {
let prop = this.propertyDescription;
if (prop == undefined) {
return "undefined";
}
const num_types = ["integer", "float", "number"];
if (num_types.includes(prop.type)) {
return "number";
}
if (prop.type == "array") {
if (num_types.includes(prop.items.type)) {
return "number_array";
}
if (Array.isArray(prop.items)) {
if (prop.items.every(t => num_types.includes(t.type))) {
return "number_array";
}
}
}
if (prop.type == "boolean") {
return "boolean";
}
if (prop.type == "object") {
let numeric = true;
for (let key in prop.properties) {
if (!num_types.includes(prop.properties[key].type)) {
numeric = false;
break;
}
}
if (numeric) {
return "number_object";
}
}
return "other";
}
},
@ -199,10 +86,9 @@ export default {
this.value = data;
return data;
},
writeProperty: async function() {
writeProperty: async function(requestedValue) {
try {
let requestedValue = this.value;
console.log("writing", requestedValue);
this.value=requestedValue;
await this.writeThingProperty(
this.thingName,
this.propertyName,
@ -224,51 +110,9 @@ export default {
} catch (error) {
this.modalError(error); // Let mixin handle error
}
},
checkboxUpdated: function() {
if (this.value != this.$refs.checkbox.checked) {
this.value = this.$refs.checkbox.checked;
this.writeProperty();
}
},
focusIn: function(event) {
this.valueOnEnter = event.target.value;
},
focusOut: function(event) {
if (this.valueOnEnter != event.target.value) {
this.writeProperty(event.target.value);
}
},
keyDown: function(event) {
// Pressing enter should set the property, whether or not we think it's changed.
if (event.keyCode == 13) {
this.writeProperty();
}
}
}
};
</script>
<style scoped>
.input-and-buttons-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
flex-grow: 1;
margin-left: 5px;
margin-right: 5px;
width: 6em;
}
.button-next-to-input {
flex-grow: 0;
padding-left: 5px;
padding-right: 5px;
vertical-align: middle;
cursor: pointer;
}
</style>
<style scoped></style>

View file

@ -0,0 +1,46 @@
<template>
<action-button
:thing="actionData.thing"
:action="actionData.action"
:poll-interval="actionData.poll_interval"
:submit-label="actionData.submit_label"
:can-terminate="actionData.can_terminate"
:requires-confirmation="actionData.requires_confirmation"
:confirmation-message="actionData.confirmation_message"
:button-primary="actionData.button_primary"
:modal-progress="actionData.modal_progress"
@response="actionResponse"
@error="modalError"
/>
</template>
<script>
import ActionButton from "./actionButton.vue";
// Export main app
export default {
name: "ServerSpecifiedActionButton",
components: {
ActionButton
},
props: {
actionData: {
type: Object,
required: true,
}
},
methods: {
actionResponse: function() {
if (this.actionData.notify_on_success) {
this.modalNotify(this.actionData.success_message);
}
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,32 @@
<template>
<property-control
:thing="propertyData.thing"
:property-name="propertyData.property_name"
:thing-name="propertyData.thing"
:label="propertyData.label"
:read-back="propertyData.read_back"
:read-back-delay="propertyData.read_back_delay"
/>
</template>
<script>
import PropertyControl from "./propertyControl.vue";
// Export main app
export default {
name: "ServerSpecifiedPropertyControl",
components: {
PropertyControl
},
props: {
propertyData: {
type: Object,
required: true,
}
}
};
</script>
<style lang="less"></style>

View file

@ -5,7 +5,14 @@
<li>
<a class="uk-accordion-title" href="#">Settings</a>
<div class="uk-accordion-content">
<p> TODO!</p>
<input-from-schema
v-if="backgroundDetectorStatus"
v-model="backgroundDetectorStatus.settings"
:data-schema="backgroundDetectorStatus.settings_schema"
label=""
@requestUpdate="readSettings"
@sendValue="writeSettings"
/>
</div>
</li>
</ul>
@ -17,6 +24,7 @@
:can-terminate="false"
:poll-interval="0.1"
@response="alertBackgroundSet"
@error="modalError"
/>
</div>
<div class="uk-margin">
@ -25,10 +33,11 @@
thing="camera"
action="image_is_sample"
submit-label="Check Current Image"
:isDisabled="!ready"
:can-terminate="false"
:poll-interval="0.1"
@response="alertImageLabel"
@error="backgroundDetectError"
@error="modalError"
/>
</div>
</div>
@ -37,10 +46,25 @@
<script>
import ActionButton from "../../labThingsComponents/actionButton.vue";
import InputFromSchema from "../../labThingsComponents/inputFromSchema.vue";
export default {
components: {
ActionButton
ActionButton,
InputFromSchema
},
data() {
return {
backgroundDetectorStatus: undefined,
};
},
computed: {
ready() {
const status = this.backgroundDetectorStatus;
return status && status.ready === true;
}
},
methods: {
@ -51,12 +75,25 @@ export default {
let label = r.output[0] ? "sample" : "background";
this.modalNotify(`Current image is ${label} (${r.output[1]})`);
},
backgroundDetectError() {
this.modalError(
"Background detection failed, most likely you need to set a background image." +
" There may be more information in the log."
readSettings: async function() {
this.backgroundDetectorStatus = await this.readThingProperty(
"camera",
"background_detector_status"
);
},
writeSettings: async function(requestedValue) {
await this.invokeAction(
"camera",
"update_detector_settings",
{"data": requestedValue}
);
}
},
async created() {
this.backgroundDetectorStatus = await this.readThingProperty(
"camera",
"background_detector_status"
);
}
};
</script>

View file

@ -4,37 +4,14 @@
<div class="uk-width-large">
<h3>Automatic calibration</h3>
<cameraCalibrationSettings :camera-uri="cameraUri" />
<h3>Manual camera settings</h3>
<form @submit.prevent="applySettingsRequest">
<div class="uk-margin-small-bottom">
<ul uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">Pi Camera Settings</a>
<div class="uk-accordion-content">
<PropertyControl
label="Exposure Time (0-33251)"
property-name="exposure_time"
thing-name="camera"
:read-back-delay="1000"
/>
<PropertyControl
label="Analogue Gain"
property-name="analogue_gain"
thing-name="camera"
:read-back-delay="1000"
/>
<PropertyControl
label="Colour Gains"
property-name="colour_gains"
thing-name="camera"
:read-back-delay="1000"
/>
</div>
</li>
</ul>
</div>
</form>
<div class="uk-margin-small-bottom">
<server-specified-property-control
v-for="(setting, index) in manualCameraSettings"
:key="'cam_setting' + index"
:property-data="setting"
/>
</div>
</div>
<div id="mini-stream">
@ -47,7 +24,7 @@
<script>
import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
import PropertyControl from "../../labThingsComponents/propertyControl.vue";
import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue";
// Export main app
export default {
@ -56,37 +33,12 @@ export default {
components: {
cameraCalibrationSettings,
miniStreamDisplay,
PropertyControl
ServerSpecifiedPropertyControl
},
data: function() {
data() {
return {
picamera: {
shutter_speed: undefined,
analog_gain: undefined,
digital_gain: undefined,
framerate: undefined,
awb_gains: undefined
},
mjpeg_bitrate: undefined,
stream_resolution: undefined,
jpeg_quality: undefined,
bitrateOptions: [
{ text: "Maximum (unlimited)", value: -1 },
{ text: "High (25Mbps)", value: 25000000 },
{ text: "Normal (17Mbps)", value: 17000000 },
{ text: "Low (5Mbps)", value: 5000000 },
{ text: "Very low (2.5Mbps)", value: 2500000 }
],
resolutionOptions: [
{ text: "Higher (832, 624)", value: [832, 624] },
{ text: "Normal (640, 480)", value: [640, 480] }
],
framerateOptions: [
{ text: "Normal (30fps)", value: 30 },
{ text: "Low (15fps)", value: 15 },
{ text: "Very low (10fps)", value: 10 }
]
manualCameraSettings: [],
};
},
@ -94,6 +46,13 @@ export default {
cameraUri: function() {
return `${this.$store.getters.baseUri}/camera/`;
}
},
async created() {
this.manualCameraSettings = await this.readThingProperty(
"camera",
"manual_camera_settings"
);
}
};
</script>

View file

@ -1,100 +1,34 @@
<template>
<div>
<!--Show auto calibrate if default plugin is enabled-->
<div v-if="'full_auto_calibrate' in actions" class="uk-margin-small">
<action-button
:can-terminate="false"
: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>
<!--Show calibration actions as specified by the camera.-->
<div
v-show="showExtraSettings"
v-if="'flatten_lens_shading_table' in actions"
v-for="(action, index) in primaryCalibrationActions"
:key="'primary_cal' + index"
class="uk-child-width-expand"
>
<action-button
:can-terminate="false"
:requires-confirmation="false"
thing="camera"
action="flat_lens_shading"
:submit-label="'Disable Flat Field Correction'"
@response="onRecalibrateResponse"
@error="modalError"
/>
<server-specified-action-button :action-data="action" />
</div>
<div
v-show="showExtraSettings"
v-if="'reset_lens_shading' in actions"
class="uk-child-width-expand"
>
<action-button
:can-terminate="false"
:requires-confirmation="false"
thing="camera"
action="reset_lens_shading"
:submit-label="'Reset Flat Field Correction'"
@response="onRecalibrateResponse"
@error="modalError"
/>
<div v-if="showExtraSettings">
<div
v-for="(action, index) in secondaryCalibrationActions"
:key="'secondary_cal' + index"
class="uk-child-width-expand"
>
<server-specified-action-button :action-data="action" />
</div>
</div>
</div>
</template>
<script>
import ActionButton from "../../../labThingsComponents/actionButton.vue";
import ServerSpecifiedActionButton from "../../../labThingsComponents/serverSpecifiedActionButton.vue";
// Export main app
export default {
name: "CameraCalibrationSettings",
components: {
ActionButton
ServerSpecifiedActionButton
},
props: {
@ -109,16 +43,28 @@ export default {
}
},
data() {
return {
primaryCalibrationActions: [],
secondaryCalibrationActions: []
};
},
computed: {
actions() {
return this.$store.getters["wot/thingDescription"]("camera").actions;
}
},
methods: {
onRecalibrateResponse: function() {
this.modalNotify("Finished recalibration.");
}
async created() {
this.primaryCalibrationActions = await this.readThingProperty(
"camera",
"primary_calibration_actions"
);
this.secondaryCalibrationActions = await this.readThingProperty(
"camera",
"secondary_calibration_actions"
);
}
};
</script>

View file

@ -67,6 +67,21 @@ Vue.mixin({
}
await axios.put(url, value);
},
async invokeAction(thing, action, data) {
let url = this.$store.getters["wot/thingActionUrl"](
thing,
action,
"invokeaction",
false
);
try {
let response = await axios.post(url, data);
return response;
} catch (error) {
this.modalError(error);
return undefined;
}
},
thingActionUrl(thing, action, allow_missing = false) {
let url = this.$store.getters["wot/thingActionUrl"](
thing,
@ -132,7 +147,6 @@ Vue.mixin({
modalError: function(error) {
var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg);
console.log("Modal error:", error);
UIkit.notification({
message: `${errormsg}`,
status: "danger"
@ -140,25 +154,43 @@ Vue.mixin({
},
getErrorMessage: function(error) {
// If a response was obtained, format it nicely and return it
// Format the error.
let data = this.getErrorData(error)
// Get error data may get an object. Handle edge cases and if not try to use
// JSON to get the best string. This stops "objectObject" showing as an error.
if (data === null) return "null";
if (data === undefined) return "undefined";
if (typeof data === 'string') return data;
try {
return JSON.stringify(data, null, 2);
} catch (err) {
return String(data);
}
},
getErrorData: function(error){
// If a response was obtained, extract the most specific message
if (error.response) {
// If the response is a nicely formatted JSON response from the server
if (error.response.data.message) {
return `${error.response.data.message}`;
return error.response.data.message;
}
if (error.response.data.detail) {
return `${error.response.data.detail}`;
try {
return error.response.data.detail[0].msg
} catch (err) {
return error.response.data.detail;
}
}
// If the response is just some generic error response
if (error.response.data) {
return `${error.response.data}`;
return error.response.data;
}
return `${error.response}`;
return error.response;
}
// If we have an error object with a message, use that
if (error.message) return `${error.message}`;
// Otherwise attempt to cast it to a string.
return `${error}`;
if (error.message) return error.message;
// At this point just formatting the whole error object is the best we can do.
return error;
},
showModalElement: function(element) {