update action, property, and setting syntax for labthings-fastapi 0.0.12

This commit is contained in:
Julian Stirling 2025-12-14 11:52:07 +00:00
parent bbbfaf8602
commit 5eea78cac7
14 changed files with 139 additions and 192 deletions

View file

@ -203,7 +203,7 @@ class BaseCamera(lt.Thing):
"""Close hardware connection when the Thing context manager is closed."""
raise NotImplementedError("CameraThings must define their own __exit__ method")
@lt.thing_property
@lt.property
def calibration_required(self) -> bool:
"""Whether the camera needs calibrating.
@ -212,7 +212,7 @@ class BaseCamera(lt.Thing):
"""
return False
@lt.thing_action
@lt.action
def start_streaming(
self, main_resolution: tuple[int, int], buffer_count: int
) -> None:
@ -239,21 +239,21 @@ class BaseCamera(lt.Thing):
self.mjpeg_stream._streaming = False
self.lores_mjpeg_stream._streaming = False
@lt.thing_property
@lt.property
def stream_active(self) -> bool:
"""Whether the MJPEG stream is active."""
raise NotImplementedError(
"CameraThings must define their own stream_active method"
)
@lt.thing_action
@lt.action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
raise NotImplementedError(
"CameraThings must define their own discard_frames method"
)
@lt.thing_action
@lt.action
def capture_array(
self,
stream_name: Literal["main", "lores", "raw", "full"] = "main",
@ -264,10 +264,10 @@ class BaseCamera(lt.Thing):
"CameraThings must define their own capture_array method"
)
downsampled_array_factor = lt.ThingProperty(int, 2)
downsampled_array_factor: int = lt.property(default=2)
"""The downsampling factor when calling capture_downsampled_array."""
@lt.thing_action
@lt.action
def capture_downsampled_array(self) -> ArrayModel:
"""Acquire one image from the camera, downsample, and return as an array.
@ -279,7 +279,7 @@ class BaseCamera(lt.Thing):
img = self.capture_array()
return downsample(self.downsampled_array_factor, img)
@lt.thing_action
@lt.action
def capture_jpeg(
self,
metadata_getter: lt.deps.GetThingStates,
@ -316,7 +316,7 @@ class BaseCamera(lt.Thing):
return JPEGBlob.from_temporary_directory(directory, fname)
@lt.thing_action
@lt.action
def grab_jpeg(
self,
portal: lt.deps.BlockingPortal,
@ -340,7 +340,7 @@ class BaseCamera(lt.Thing):
frame = portal.call(stream.grab_frame)
return JPEGBlob.from_bytes(frame)
@lt.thing_action
@lt.action
def grab_as_array(
self,
portal: lt.deps.BlockingPortal,
@ -367,7 +367,7 @@ class BaseCamera(lt.Thing):
tries += 1
raise OSError("Could not open frames from MJPEG stream.")
@lt.thing_action
@lt.action
def grab_jpeg_size(
self,
portal: lt.deps.BlockingPortal,
@ -389,7 +389,7 @@ class BaseCamera(lt.Thing):
"CameraThings must define their own capture_image method"
)
@lt.thing_action
@lt.action
def capture_and_save(
self,
jpeg_path: str,
@ -420,7 +420,7 @@ class BaseCamera(lt.Thing):
save_resolution,
)
@lt.thing_action
@lt.action
def capture_to_memory(
self,
logger: lt.deps.InvocationLogger,
@ -444,7 +444,7 @@ class BaseCamera(lt.Thing):
image, metadata = self._robust_image_capture(metadata_getter, logger)
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
@lt.thing_action
@lt.action
def save_from_memory(
self,
jpeg_path: str,
@ -473,7 +473,7 @@ class BaseCamera(lt.Thing):
save_resolution=save_resolution,
)
@lt.thing_action
@lt.action
def clear_buffers(self) -> None:
"""Clear all images in memory."""
self._memory_buffer.clear()
@ -600,10 +600,10 @@ class BaseCamera(lt.Thing):
except Exception as e:
raise IOError(f"An error occurred while saving {jpeg_path}") from e
settling_time = lt.ThingSetting(float, 0.2)
settling_time: float = lt.setting(default=0.2)
"""The settling time when calling the ``settle()`` method."""
@lt.thing_action
@lt.action
def settle(self) -> None:
"""Sleep for the settling time, ready to provide a fresh frame.
@ -616,24 +616,24 @@ class BaseCamera(lt.Thing):
time.sleep(self.settling_time)
self.discard_frames()
@lt.thing_property
@lt.property
def primary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions for both calibration wizard and settings panel."""
return []
@lt.thing_property
@lt.property
def secondary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions that appear only in settings panel."""
return []
@lt.thing_property
@lt.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
@lt.setting
def detector_name(self) -> str:
"""The name of the active background selector."""
return self._detector_name
@ -650,12 +650,12 @@ class BaseCamera(lt.Thing):
"""The active background detector instance."""
return self.background_detectors[self.detector_name]
@lt.thing_property
@lt.property
def background_detector_status(self) -> BackgroundDetectorStatus:
"""The status of the active detector for the UI."""
return self.active_detector.status
@lt.thing_action
@lt.action
def update_detector_settings(self, data: dict[str, Any]) -> None:
"""Update the settings of the current detector.
@ -671,7 +671,7 @@ class BaseCamera(lt.Thing):
# Manually save settings as the setter is not called.
self.save_settings()
@lt.thing_setting
@lt.setting
def background_detector_data(self) -> dict:
"""The data for each background detector, used to save to disk."""
data = {}
@ -704,13 +704,13 @@ class BaseCamera(lt.Thing):
f"No background detector named {name}, settings will be discarded."
)
@lt.thing_action
@lt.action
def image_is_sample(self, portal: lt.deps.BlockingPortal) -> tuple[bool, str]:
"""Label the current image as either background or sample."""
current_image = self.grab_as_array(portal, stream_name="lores")
return self.active_detector.image_is_sample(current_image)
@lt.thing_action
@lt.action
def set_background(self, portal: lt.deps.BlockingPortal) -> None:
"""Grab an image, and use its statistics to set the background.

View file

@ -60,7 +60,7 @@ class OpenCVCamera(BaseCamera):
self._capture_thread.join()
self.cap.release()
@lt.thing_property
@lt.property
def stream_active(self) -> bool:
"""Whether the MJPEG stream is active."""
if self._capture_enabled and self._capture_thread:
@ -81,12 +81,12 @@ class OpenCVCamera(BaseCamera):
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
@lt.thing_action
@lt.action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
self.capture_array()
@lt.thing_action
@lt.action
def capture_array(
self,
stream_name: Literal["main", "full"] = "full",

View file

@ -169,30 +169,19 @@ class StreamingPiCamera2(BaseCamera):
# trigger a NotConnectedToServerError
self._colour_gains = tf_utils.get_colour_gains_from_lst(self.tuning)
stream_resolution = lt.ThingProperty(
tuple[int, int],
initial_value=(820, 616),
)
stream_resolution: tuple[int, int] = lt.property(default=(820, 616))
"""Resolution to use for the MJPEG stream."""
mjpeg_bitrate = lt.ThingProperty(
Optional[int],
initial_value=100000000,
)
mjpeg_bitrate: Optional[int] = lt.property(default=100000000)
"""Bitrate for MJPEG stream (None for default)."""
stream_active = lt.ThingProperty(
bool,
initial_value=False,
observable=True,
readonly=True,
)
stream_active: bool = lt.property(default=False, observable=True, readonly=True)
"""Whether the MJPEG stream is active."""
def save_settings(self) -> None:
"""Override save_settings to ensure that camera properties don't recurse.
This method is run by any Thing when a ThingSetting is saved. However, the
This method is run by any Thing when a setting is saved. However, the
method reads the thing_setting. As reading the thing setting talks to the
camera and calls save_settings if the value is not as expected, this could
cause recursion. Also this means that saving one setting causes all others
@ -204,7 +193,7 @@ class StreamingPiCamera2(BaseCamera):
finally:
self._setting_save_in_progress = False
@lt.thing_property
@lt.property
def calibration_required(self) -> bool:
"""Whether the camera needs calibrating."""
# Check if the lens shading table is calibrated.
@ -214,7 +203,7 @@ class StreamingPiCamera2(BaseCamera):
_analogue_gain: float = 1.0
@lt.thing_setting
@lt.setting
def analogue_gain(self) -> float:
"""The Analogue gain applied by the camera sensor."""
if not self._setting_save_in_progress and self.streaming:
@ -234,7 +223,7 @@ class StreamingPiCamera2(BaseCamera):
_colour_gains: tuple[float, float] = (1.0, 1.0)
@lt.thing_setting
@lt.setting
def colour_gains(self) -> tuple[float, float]:
"""The red and blue colour gains, must be between 0.0 and 32.0."""
if not self._setting_save_in_progress and self.streaming:
@ -254,7 +243,7 @@ class StreamingPiCamera2(BaseCamera):
_exposure_time: int = 500
@lt.thing_setting
@lt.setting
def exposure_time(self) -> int:
"""The camera exposure time in microseconds.
@ -296,7 +285,7 @@ class StreamingPiCamera2(BaseCamera):
_sensor_modes = None
@lt.thing_property
@lt.property
def sensor_modes(self) -> list[SensorMode]:
"""All the available modes the current sensor supports."""
if not self._sensor_modes:
@ -306,7 +295,7 @@ class StreamingPiCamera2(BaseCamera):
_sensor_mode: Optional[dict] = None
@lt.thing_property
@lt.property
def sensor_mode(self) -> Optional[SensorModeSelector]:
"""The intended sensor mode of the camera."""
if self._sensor_mode is None:
@ -329,13 +318,13 @@ class StreamingPiCamera2(BaseCamera):
with self._streaming_picamera(pause_stream=True):
pass
@lt.thing_property
@lt.property
def sensor_resolution(self) -> Optional[tuple[int, int]]:
"""The native resolution of the camera's sensor."""
with self._streaming_picamera() as cam:
return cam.sensor_resolution
tuning = lt.ThingSetting(Optional[dict], None, readonly=True)
tuning: Optional[dict] = lt.setting(default=None, readonly=True)
"""The Raspberry PiCamera Tuning File JSON."""
def _initialise_picamera(self, check_sensor_model: bool = False) -> None:
@ -437,7 +426,7 @@ class StreamingPiCamera2(BaseCamera):
cam.close()
del self._picamera
@lt.thing_action
@lt.action
def start_streaming(
self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6
) -> None:
@ -509,7 +498,7 @@ class StreamingPiCamera2(BaseCamera):
"Started MJPEG stream at %s on port %s", self.stream_resolution, 1
)
@lt.thing_action
@lt.action
def stop_streaming(self, stop_web_stream: bool = True) -> None:
"""Stop the MJPEG stream."""
with self._streaming_picamera() as picam:
@ -529,7 +518,7 @@ class StreamingPiCamera2(BaseCamera):
# Adding a sleep to prevent camera getting confused by rapid commands
time.sleep(self._sensor_info.short_pause)
@lt.thing_action
@lt.action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
with self._streaming_picamera() as cam:
@ -587,7 +576,7 @@ class StreamingPiCamera2(BaseCamera):
else:
raise ValueError(f'Unknown stream name "{stream_name}"')
@lt.thing_action
@lt.action
def capture_array(
self,
stream_name: Literal["main", "lores", "raw", "full"] = "main",
@ -620,7 +609,7 @@ class StreamingPiCamera2(BaseCamera):
# as array
return np.array(self.capture_image(stream_name, wait))
@lt.thing_property
@lt.property
def camera_configuration(self) -> Mapping:
"""The "configuration" dictionary of the picamera2 object.
@ -635,13 +624,13 @@ class StreamingPiCamera2(BaseCamera):
with self._streaming_picamera() as cam:
return cam.camera_configuration()
@lt.thing_property
@lt.property
def capture_metadata(self) -> dict:
"""Return the metadata from the camera."""
with self._streaming_picamera() as cam:
return cam.capture_metadata()
@lt.thing_action
@lt.action
def auto_expose_from_minimum(
self,
target_white_level: Optional[int] = None,
@ -671,7 +660,7 @@ class StreamingPiCamera2(BaseCamera):
percentile=percentile,
)
@lt.thing_action
@lt.action
def calibrate_lens_shading(self) -> None:
"""Take an image and use it for flat-field correction.
@ -699,7 +688,7 @@ class StreamingPiCamera2(BaseCamera):
self.colour_gains = tf_utils.get_colour_gains_from_lst(self.tuning)
@lt.thing_property
@lt.property
def colour_correction_matrix(
self,
) -> tuple[float, float, float, float, float, float, float, float, float]:
@ -724,7 +713,7 @@ class StreamingPiCamera2(BaseCamera):
with self._streaming_picamera(pause_stream=True):
self._initialise_picamera()
@lt.thing_action
@lt.action
def reset_ccm(self) -> None:
"""Overwrite the colour correction matrix in camera tuning with default values."""
self.tuning = tf_utils.copy_algo_from_other_tuning(
@ -733,7 +722,7 @@ class StreamingPiCamera2(BaseCamera):
copy_from=self.default_tuning,
)
@lt.thing_action
@lt.action
def set_static_green_equalisation(self, offset: int = 65535) -> None:
"""Set the green equalisation to a static value.
@ -748,7 +737,7 @@ class StreamingPiCamera2(BaseCamera):
self.tuning = tf_utils.set_static_geq(self.tuning, offset)
self._initialise_picamera()
@lt.thing_action
@lt.action
def set_ce_enable_to_off(self) -> None:
"""Set the contrast enhancement to disabled.
@ -759,7 +748,7 @@ class StreamingPiCamera2(BaseCamera):
self.tuning = tf_utils.set_ce_to_disabled(self.tuning)
self._initialise_picamera()
@lt.thing_action
@lt.action
def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None:
"""Perform a full auto-calibration.
@ -787,7 +776,7 @@ class StreamingPiCamera2(BaseCamera):
pass
raise RuntimeError("Couldn't set background")
@lt.thing_property
@lt.property
def primary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions for both calibration wizard and settings panel."""
return [
@ -805,7 +794,7 @@ class StreamingPiCamera2(BaseCamera):
),
]
@lt.thing_property
@lt.property
def secondary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions that appear only in settings panel."""
return [
@ -847,7 +836,7 @@ class StreamingPiCamera2(BaseCamera):
),
]
@lt.thing_property
@lt.property
def manual_camera_settings(self) -> list[PropertyControl]:
"""The camera settings to expose as property controls in the settings panel."""
return [
@ -874,7 +863,7 @@ class StreamingPiCamera2(BaseCamera):
),
]
@lt.thing_property
@lt.property
def lens_shading_tables(self) -> Optional[tf_utils.LensShading]:
"""The current lens shading (i.e. flat-field correction).
@ -901,7 +890,7 @@ class StreamingPiCamera2(BaseCamera):
)
self._initialise_picamera()
@lt.thing_action
@lt.action
def flat_lens_shading(self) -> None:
"""Disable flat-field correction.
@ -916,7 +905,7 @@ class StreamingPiCamera2(BaseCamera):
self.tuning = tf_utils.flatten_lst(self.tuning)
self._initialise_picamera()
@lt.thing_action
@lt.action
def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction for colour only.
@ -928,7 +917,7 @@ class StreamingPiCamera2(BaseCamera):
self.tuning = tf_utils.flatten_lst(self.tuning, keep_luminance=True)
self._initialise_picamera()
@lt.thing_action
@lt.action
def reset_lens_shading(self) -> None:
"""Revert to default lens shading settings.

View file

@ -86,7 +86,7 @@ class SimulatedCamera(BaseCamera):
self.generate_blobs()
self.generate_canvas()
@lt.thing_property
@lt.property
def calibration_required(self) -> bool:
"""Whether the camera needs calibrating."""
return not self.background_detector_status.ready
@ -274,7 +274,7 @@ class SimulatedCamera(BaseCamera):
self._capture_enabled = False
self._capture_thread.join()
@lt.thing_action
@lt.action
def start_streaming(
self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 1
) -> None:
@ -300,14 +300,14 @@ class SimulatedCamera(BaseCamera):
self._capture_thread = Thread(target=self._capture_frames)
self._capture_thread.start()
@lt.thing_property
@lt.property
def stream_active(self) -> bool:
"""Whether the MJPEG stream is active."""
if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive()
return False
noise_level = lt.ThingProperty(float, 2.0)
noise_level: float = lt.property(default=2.0)
def _capture_frames(self) -> None:
portal = lt.get_blocking_portal(self)
@ -326,14 +326,14 @@ class SimulatedCamera(BaseCamera):
except Exception as e:
LOGGER.exception(f"Failed to capture frame: {e}, retrying...")
@lt.thing_action
@lt.action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh.
There is nothing to do as this is a simulation!
"""
@lt.thing_action
@lt.action
def capture_array(
self,
stream_name: Literal["main", "full"] = "full",
@ -370,7 +370,7 @@ class SimulatedCamera(BaseCamera):
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}")
return self.generate_frame()
@lt.thing_action
@lt.action
def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None:
"""Perform a full auto-calibration.
@ -386,21 +386,21 @@ class SimulatedCamera(BaseCamera):
time.sleep(0.2)
self.load_sample()
@lt.thing_action
@lt.action
def remove_sample(self) -> None:
"""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
@lt.action
def load_sample(self) -> None:
"""Show the simulated sample."""
if self._show_sample:
raise RuntimeError("Sample is already in place.")
self._show_sample = True
@lt.thing_property
@lt.property
def primary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions for both calibration wizard and settings panel."""
return [
@ -409,7 +409,7 @@ class SimulatedCamera(BaseCamera):
),
]
@lt.thing_property
@lt.property
def secondary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions that appear only in settings panel."""
return [
@ -417,7 +417,7 @@ class SimulatedCamera(BaseCamera):
action_button_for(self.remove_sample, submit_label="Remove Sample"),
]
@lt.thing_property
@lt.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")]