Merge branch 'start-gallery-thing' into 'v3'

Start implementing GalleryThing

See merge request openflexure/openflexure-microscope-server!576
This commit is contained in:
Joe Knapper 2026-05-07 09:51:47 +00:00
commit 906b8be82c
13 changed files with 321 additions and 104 deletions

View file

@ -335,14 +335,25 @@ class ScanDirectoryManager:
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
@requires_lock
def all_scans_info(self, ongoing: Optional[str] = None) -> list[ScanInfo]:
"""Return a lists of ScanInfo objects for each scan."""
def all_scans_info(
self, *, ongoing: Optional[str] = None, include_ongoing: bool = True
) -> list[ScanInfo]:
"""Return a lists of ScanInfo objects for each scan.
:param ongoing: The name of the ongoing scan (or None if no scan is ongoing).
:param include_ongoing: True (dfault) to include the scan info of the ongoing
scan in the return. False to exclude it.
:return: A list of ScanInfo objects for each scan.
"""
all_info: list[ScanInfo] = []
for scan_name in self.all_scans:
# If the scan is ongoing send flag to skip reading the json data
skip_json = scan_name == ongoing
scan_is_ongoing = scan_name == ongoing
if scan_is_ongoing and not include_ongoing:
continue
scan_dir = ScanDirectory(scan_name, self.base_dir)
info = scan_dir.scan_info(skip_json=skip_json)
# If the scan is ongoing send flag to skip reading the json data
info = scan_dir.scan_info(skip_json=scan_is_ongoing)
all_info.append(info)
return all_info

View file

@ -15,6 +15,13 @@ class OFMThing(lt.Thing):
_data_dir: Optional[str] = None
_show_data_in_gallery: bool = False
@property
def show_data_in_gallery(self) -> bool:
"""Whether to show in the Gallery."""
return self._show_data_in_gallery
def __enter__(self) -> Self:
"""Set the data directory when the Thing is entered."""
# Note that the `application_config` was already validated when the

View file

@ -25,6 +25,7 @@ from pydantic import BaseModel, Field
import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.things import OFMThing
from openflexure_microscope_server.things.background_detect import (
BackgroundDetectAlgorithm,
)
@ -164,7 +165,7 @@ class CameraMemoryBuffer:
del self._storage[key]
class BaseCamera(lt.Thing):
class BaseCamera(OFMThing):
"""The base class for all cameras. All cameras must directly inherit from this class.
The connection to the camera hardware should be added to the ``__enter__`` method not

View file

@ -0,0 +1,99 @@
"""Server-side gallery functionality.
The types of data that are captured and how they display in the gallery are defined by
the Things that capture the Data. This thing just provides a unified way for the gallery
front end to access the data.
"""
from typing import Any, Mapping, Optional, Protocol, Self, cast, runtime_checkable
from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.things import OFMThing
@runtime_checkable
class GalleryCompatibleThing(Protocol):
"""Protocol for checking Things using the gallery are complete.
Note that runtime checkable protocols only check that the methods exist, not the
full signatures. This means that anything attempting to define the methods will
be picked up, but may throw an error when used.
"""
# Ensure it is a Thing:
_thing_server_interface: lt.ThingServerInterface
# Ensure it is an OFMThing:
show_data_in_gallery: bool
gallery_data_name: str
gallery_data_schema: type[BaseModel]
# Ignore D102: No docstrings for the protocol.
def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102
class GalleryThing(lt.Thing):
"""A Thing for communicating with the front end gallery."""
all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot()
_gallery_providing_things: Optional[Mapping[str, GalleryCompatibleThing]] = None
@property
def gallery_providing_things(self) -> Mapping[str, GalleryCompatibleThing]:
"""All Things that provide data to the gallery."""
if self._gallery_providing_things is None:
raise RuntimeError(
"Cannot access gallery_providing_things before server has started."
)
return self._gallery_providing_things
def __enter__(self) -> Self:
"""Check for all gallery providing things on server startup."""
self._set_gallery_providers()
return self
def _set_gallery_providers(self) -> None:
"""Set the mapping of gallery providing things.
This is called on ``__enter__`` when the server starts.
"""
gallery_providers = {
name: thing
for name, thing in self.all_ofm_things.items()
if thing.show_data_in_gallery
}
# cache initial list of keys as it may change in the loop
keys = list(gallery_providers.keys())
for key in keys:
if not isinstance(gallery_providers[key], GalleryCompatibleThing):
self.logger.error(
f"Data from {key} cannot be shown in gallery as it does not "
"provide all necessary properties/methods for the gallery."
)
gallery_providers.pop(key)
# Cast the type of each thing to "GalleryCompatibleThing" as other Things have
# been popped.
self._gallery_providing_things = cast(
Mapping[str, GalleryCompatibleThing], gallery_providers
)
@lt.property
def list_data(self) -> list[dict[str, Any]]:
"""List the data from all registered things.
Currently only works with `smart_scan` as the UI cards are not customisable.
This will change to an action (or another type of endpoint) at a
later date to enable filtering, and returning only a specific page.
"""
data_list = []
for thing in self.gallery_providing_things.values():
data_list += [model.model_dump() for model in thing.get_data_for_gallery()]
return data_list

View file

@ -71,16 +71,6 @@ class ActiveScanData(scan_directories.BaseScanData):
self.scan_result = result
class ScanListInfo(BaseModel):
"""The information to be sent to the Scan List tab."""
scans: list[scan_directories.ScanInfo]
"""The list of scans as ScanInfo objects"""
ongoing: Optional[str]
"""The name of the ongoing scan or None"""
class JPEGBlob(lt.blob.Blob):
"""A class representing a JPEG image as a LabThings FastAPI Blob."""
@ -176,6 +166,31 @@ class SmartScanThing(OFMThing):
In this case it doesn't need to do anything.
"""
# Register with gallery.
_show_data_in_gallery = True
@property
def gallery_data_name(self) -> str:
"""Name under which data shows up in gallery."""
return "Scans"
@property
def gallery_data_schema(self) -> type[scan_directories.ScanInfo]:
"""The schema (BaseModel) for passing data to the gallery."""
return scan_directories.ScanInfo
def get_data_for_gallery(self) -> list[scan_directories.ScanInfo]:
"""Return all the information from the scan directories.
It is preferable to use the method rather than calling
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read.
"""
ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name
return self._scan_dir_manager.all_scans_info(
ongoing=ongoing_name, include_ongoing=False
)
# Note that the default detector name is set at init. This is over written if
# setting is loaded from disk.
@lt.setting
@ -544,31 +559,6 @@ class SmartScanThing(OFMThing):
stitch_automatically: bool = lt.setting(default=True)
"""Whether to run a final stitch at the end of a successful scan."""
def _get_all_scan_info(self) -> list[scan_directories.ScanInfo]:
"""Return all the information from the scan directories.
It is preferable to use the method rather than calling
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read.
"""
ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name
return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name)
@lt.property
def scans(self) -> ScanListInfo:
"""All the available scans.
Each scan has a name (which can be used to access it), along with
its modified and created times (according to the filesystem) and
the number of items in the ``images`` folder. Note that image count
uses a regular expression, and changes to the naming scheme will
break it.
"""
return ScanListInfo(
scans=self._get_all_scan_info(),
ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name,
)
@lt.endpoint(
"get",
"get_stitch/{scan_name}",
@ -632,8 +622,8 @@ class SmartScanThing(OFMThing):
@lt.action
def purge_empty_scans(self) -> None:
"""Delete all scan folders containing no images at the top level."""
# JSON is ignored as it's created before any images are captured
for scan_info in self._get_all_scan_info():
# Use the scan list (the data read by the gallery) to check for empty scans.
for scan_info in self.get_data_for_gallery():
if scan_info.number_of_images == 0:
self._delete_scan(scan_info.name)
@ -748,6 +738,8 @@ class SmartScanThing(OFMThing):
"""
if self._scan_lock.locked():
raise RuntimeError("Can't stitch previous scans while a scan is ongoing")
for scan in self._get_all_scan_info():
# Use the scan list (the data read by the gallery) to find any scans that
# need stitching.
for scan in self.get_data_for_gallery():
if scan.dzi is None:
self.stitch_scan(scan_name=scan.name)