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

@ -8,6 +8,7 @@
},
"stage": "openflexure_microscope_server.things.stage.sangaboard:SangaboardThing",
"autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing",
"gallery": "openflexure_microscope_server.things.gallery.GalleryThing",
"camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper",
"system": "openflexure_microscope_server.things.system:OpenFlexureSystem",
"illumination": "openflexure_microscope_server.things.illumination:SangaIllumination",

View file

@ -3,6 +3,7 @@
"camera": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
"stage": "openflexure_microscope_server.things.stage.dummy:DummyStage",
"autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing",
"gallery": "openflexure_microscope_server.things.gallery.GalleryThing",
"camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper",
"illumination": "openflexure_microscope_server.things.illumination.SimulatorIllumination",
"system": "openflexure_microscope_server.things.system:OpenFlexureSystem",

Binary file not shown.

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)

View file

@ -1,6 +1,8 @@
"""Fixtures for all test suites."""
import json
import logging
import os
import re
from collections.abc import Iterable
from contextlib import contextmanager
@ -10,6 +12,24 @@ import pytest
from labthings_fastapi.testing import create_thing_without_server
from .shared_utils.lt_test_utils import LabThingsTestEnv
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(THIS_DIR)
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
@pytest.fixture
def simulation_test_env():
"""Yield a server with the configuration from the simulation json."""
with open(SIM_CONFIG, "r", encoding="utf-8") as f_obj:
config_dict = json.load(f_obj)
with LabThingsTestEnv(
things=config_dict["things"],
application_config=config_dict["application_config"],
) as env:
yield env
@pytest.fixture
def check_side_effect(caplog):

View file

@ -12,7 +12,6 @@ it has been moved as it artificaially inflated coverage.
"""
import json
import os
import numpy as np
import piexif
@ -21,29 +20,11 @@ from PIL import Image
import labthings_fastapi as lt
from ..shared_utils.lt_test_utils import LabThingsTestEnv
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(THIS_DIR))
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
@pytest.fixture
def test_env():
"""Yield a server with a very basic configuration."""
with open(SIM_CONFIG, "r", encoding="utf-8") as f_obj:
config_dict = json.load(f_obj)
with LabThingsTestEnv(
things=config_dict["things"],
application_config=config_dict["application_config"],
) as env:
yield env
def test_autofocus(test_env):
def test_autofocus(simulation_test_env):
"""Test Fast Autofocus can run doesn't raise an exception."""
stage = test_env.get_thing_client("stage")
autofocus = test_env.get_thing_client("autofocus")
stage = simulation_test_env.get_thing_client("stage")
autofocus = simulation_test_env.get_thing_client("autofocus")
assert stage.position["z"] == 0
# Autofocus 5 times and check each ends within 500 steps
for i in range(5):
@ -51,17 +32,17 @@ def test_autofocus(test_env):
assert abs(stage.position["z"]) < 500, f"Autofocus failed on iteration {i}"
def test_grab_jpeg(test_env):
def test_grab_jpeg(simulation_test_env):
"""Check that grab_jpeg returns a blob that can be opened."""
camera = test_env.get_thing_client("camera")
camera = simulation_test_env.get_thing_client("camera")
blob = camera.grab_jpeg()
image = Image.open(blob.open())
assert image.size == (820, 616)
def test_capture_jpeg_metadata(test_env):
def test_capture_jpeg_metadata(simulation_test_env):
"""Check that the position is encoded into the image metadata."""
camera = test_env.get_thing_client("camera")
camera = simulation_test_env.get_thing_client("camera")
blob = camera.capture_jpeg()
image = Image.open(blob.open())
exif_dict = piexif.load(image.info["exif"])
@ -72,9 +53,9 @@ def test_capture_jpeg_metadata(test_env):
assert image.size == (820, 616)
def test_stage(test_env):
def test_stage(simulation_test_env):
"""Test moving the stage forwards and backwards."""
stage = test_env.get_thing_client("stage")
stage = simulation_test_env.get_thing_client("stage")
start = stage.position
move = {"x": 1, "y": 2, "z": 3}
move_back = {"x": -1, "y": -2, "z": -3}
@ -95,20 +76,20 @@ def test_stage(test_env):
assert start["z"] == pos["z"]
def test_capture_array(test_env):
def test_capture_array(simulation_test_env):
"""Capture array from simulation and check the size is as expected."""
camera = test_env.get_thing_client("camera")
camera = simulation_test_env.get_thing_client("camera")
array = np.asarray(camera.capture_array())
assert array.shape == (616, 820, 3)
def test_camera_stage_mapping_calibration(test_env):
def test_camera_stage_mapping_calibration(simulation_test_env):
"""Check that camera stage mapping runs and returns the expected result."""
camera = test_env.get_thing_client("camera")
camera = simulation_test_env.get_thing_client("camera")
# Remove camera settling time for speed.
camera.settling_time = 0
csm = test_env.get_thing_client("camera_stage_mapping")
stage = test_env.get_thing_client("stage")
csm = simulation_test_env.get_thing_client("camera_stage_mapping")
stage = simulation_test_env.get_thing_client("stage")
# Check it starts uncalibrated
assert csm.calibration_required

View file

@ -0,0 +1,122 @@
"""Tests that captures have the expected metadata."""
import logging
from pydantic import BaseModel
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things import OFMThing
from openflexure_microscope_server.things.gallery import (
GalleryCompatibleThing,
GalleryThing,
)
from ..shared_utils.lt_test_utils import LabThingsTestEnv
class MockGalleryData(BaseModel):
"""Mock gallery data."""
mock: str
foobar: int
class MinimalGalleryClass:
"""A class that provides data to the gallery but is NOT a thing.
This should fail the protocol check as it is not an OFMThing
"""
gallery_data_name: str = "Mock"
gallery_data_schema: type[BaseModel] = MockGalleryData
def get_data_for_gallery(self) -> list[BaseModel]:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
class MinimalGallerySource(OFMThing, MinimalGalleryClass):
"""Minimal example of a Thing that can show data in the gallery.
Mixes MinimalGalleryClass into OFMThing to be recognised by the protocol.
"""
show_data_in_gallery = True
class BadGallerySource(OFMThing):
"""An OFMThing that almost defines enough to show in the gallery.
Shouldn't match the protocol as ``gallery_data_name`` is missing.
"""
show_data_in_gallery = True
gallery_data_schema: type[BaseModel] = MockGalleryData
def get_data_for_gallery(self) -> list[BaseModel]:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
def test_gallery_compatible_protocol():
"""Check the gallery compatible protocol detects compatible classes only."""
# Minimal class has the gallery specific methods but is not an OFMThing.
assert not isinstance(MinimalGalleryClass(), GalleryCompatibleThing)
# BadGallerySource is an OFM Thing but is missing one of the required properties.
assert not isinstance(
create_thing_without_server(BadGallerySource), GalleryCompatibleThing
)
# MinimalGallerySource should match!
assert isinstance(
create_thing_without_server(MinimalGallerySource), GalleryCompatibleThing
)
def test_gallery_thing_finds_all_providers(simulation_test_env):
"""Check the gallery identifies the correct Things that will provide data."""
gallery = simulation_test_env.get_thing_by_name("gallery")
snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow")
smart_scan = simulation_test_env.get_thing_by_name("smart_scan")
camera = simulation_test_env.get_thing_by_name("camera")
assert snake_workflow not in gallery.all_ofm_things.values()
assert camera in gallery.all_ofm_things.values()
assert smart_scan in gallery.all_ofm_things.values()
assert snake_workflow not in gallery.gallery_providing_things.values()
assert camera not in gallery.gallery_providing_things.values()
assert smart_scan in gallery.gallery_providing_things.values()
assert not isinstance(snake_workflow, GalleryCompatibleThing)
assert isinstance(smart_scan, GalleryCompatibleThing)
def test_gallery_logs_for_bad_providers(caplog):
"""Test that an error is logged if a gallery source doesn't match the protocol."""
# Create a config with the minimal Thing and the bad Thing
things = {
"minimal_thing": MinimalGallerySource,
"bad_thing": BadGallerySource,
"gallery": GalleryThing,
}
with caplog.at_level(logging.INFO):
# Start the server
with LabThingsTestEnv(
things=things, application_config={"data_folder": "./openflexure/data/"}
) as env:
gallery = env.get_thing_by_name("gallery")
# Minimal thing is listed as a gallery provider
assert "minimal_thing" in gallery.gallery_providing_things
# Bad thing isn't as the protocol doesn't match
assert "bad_thing" not in gallery.gallery_providing_things
# There is an error logged about bad_thing
assert len(caplog.records) == 1
record = caplog.records[0]
assert "Data from bad_thing cannot be shown in gallery" in record.message
assert record.levelname == "ERROR"

View file

@ -13,8 +13,7 @@
</div>
</div>
<h3 class="uk-card-title scan-card-title">{{ scanData.name }}</h3>
<h4 v-if="ongoing" class="ongoing-msg">Scan in progress</h4>
<div v-if="!ongoing" class="button-container">
<div class="button-container">
<div class="uk-button-group scan-card-buttons">
<action-button
class="uk-width-1-2"
@ -59,10 +58,9 @@
<ul>
<li>{{ scanData.number_of_images }} images</li>
<li>Created: {{ formatDate(scanData.created) }}</li>
<li v-if="!ongoing">Duration: {{ formatDuration(scanData.duration) }}</li>
<li v-if="ongoing">Duration: <i>Ongoing</i></li>
<li>Duration: {{ formatDuration(scanData.duration) }}</li>
</ul>
<ul v-if="!ongoing">
<ul>
<li v-if="scanData.number_of_images < 3" class="warning-msg">
Not enough images to stitch
</li>
@ -102,10 +100,6 @@ export default {
type: String,
required: true,
},
ongoing: {
type: Boolean,
required: true,
},
},
emits: ["viewer-requested", "update-requested"],
@ -148,9 +142,7 @@ export default {
},
requestViewer() {
// Notify parent that thumbnail was clicked
if (!this.ongoing) {
this.$emit("viewer-requested", this.scanData);
}
this.$emit("viewer-requested", this.scanData);
},
async deleteScan() {
try {
@ -204,9 +196,4 @@ ul {
.scan-card-title {
text-align: center;
}
.ongoing-msg {
text-align: center;
padding: 2rem 0;
}
</style>

View file

@ -55,7 +55,6 @@
<scan-card
:scan-data="scanData"
:scans-uri="scansUri"
:ongoing="isOngoing(scanData.name)"
@viewer-requested="showScan"
@update-requested="updateScans"
/>
@ -97,7 +96,6 @@ export default {
data: function () {
return {
scans: [],
ongoing: null,
selectedScan: null,
osdViewer: null,
currentPage: 1,
@ -108,7 +106,9 @@ export default {
computed: {
...mapState(useSettingsStore, ["baseUri", "ready"]),
scansUri() {
return this.thingPropertyUrl("smart_scan", "scans");
// The scans URI is currently used for creating endpoint URIs.
// The actual property does not exist. So allowUndefined=true
return this.thingPropertyUrl("smart_scan", "scans", true);
},
scansEmpty() {
return this.scans.length == 0;
@ -179,9 +179,7 @@ export default {
},
async updateScans() {
try {
let scans_information = await this.readThingProperty("smart_scan", "scans");
let scans = scans_information.scans;
this.ongoing = scans_information.ongoing;
let scans = await this.readThingProperty("gallery", "list_data");
if (!scans | (scans.length == 0)) {
this.scans = scans;
}
@ -198,9 +196,6 @@ export default {
this.scans = [];
}
},
isOngoing(name) {
return name === this.ongoing;
},
async deleteAllScans() {
try {
await this.modalConfirm(