Merge branch 'image-capture' into 'v3'

Add a toggle switch for image capture to gallery

See merge request openflexure/openflexure-microscope-server!599
This commit is contained in:
Joe Knapper 2026-06-24 11:43:46 +00:00
commit 21d0beedb9
17 changed files with 690 additions and 134 deletions

Binary file not shown.

View file

@ -8,7 +8,7 @@ import shutil
import threading import threading
import zipfile import zipfile
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Mapping, Optional, Self from typing import Any, Literal, Mapping, Optional, Self
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
@ -55,6 +55,8 @@ class ScanInfo(BaseModel):
number_of_images: int number_of_images: int
stitch_available: bool stitch_available: bool
dzi: Optional[str] dzi: Optional[str]
thing: str = "smart_scan"
card_type: Literal["Scan"] = "Scan"
class BaseScanData(BaseModel): class BaseScanData(BaseModel):

View file

@ -20,7 +20,7 @@ from typing import Any, Literal, Mapping, Optional, Self
import numpy as np import numpy as np
import piexif import piexif
from fastapi import Response from fastapi import HTTPException, Response
from PIL import Image from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
@ -62,6 +62,13 @@ BASE_IMAGE_FORMATS: dict[str, ImageFormatInfo] = {
} }
def _file_is_capture(filename: str) -> bool:
"""Return whether this filename is a capture."""
return BASE_IMAGE_FORMATS["jpeg"].path_matches(filename) or BASE_IMAGE_FORMATS[
"png"
].path_matches(filename)
class CaptureError(RuntimeError): class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing.""" """An error trying to capture from a CameraThing."""
@ -199,6 +206,16 @@ class CaptureMode(BaseModel):
"""The resolution to save the image. Use None to save as captured.""" """The resolution to save the image. Use None to save as captured."""
class CaptureInfo(BaseModel):
"""Summary information for the UI about an image."""
name: str
created: float
modified: float
thing: str
card_type: Literal["Capture"] = "Capture"
class BaseCamera(OFMThing, ABC): class BaseCamera(OFMThing, ABC):
"""The base class for all cameras. All cameras must directly inherit from this class. """The base class for all cameras. All cameras must directly inherit from this class.
@ -275,6 +292,79 @@ class BaseCamera(OFMThing, ABC):
jpeg_data = await self.lores_mjpeg_stream.grab_frame() jpeg_data = await self.lores_mjpeg_stream.grab_frame()
return Response(content=jpeg_data, media_type="image/jpeg") return Response(content=jpeg_data, media_type="image/jpeg")
# Register with gallery.
_show_data_in_gallery = True
@property
def gallery_data_name(self) -> str:
"""Name under which data shows up in gallery."""
return "Captures"
@property
def gallery_data_schema(self) -> type[CaptureInfo]:
"""The schema (BaseModel) for passing data to the gallery."""
return CaptureInfo
def _all_captures(self) -> list[str]:
"""Return the full path for all captures on disk."""
files = os.listdir(self._data_dir)
captures = []
for filename in files:
full_path = os.path.join(self.data_dir, filename)
if os.path.isfile(full_path) and _file_is_capture(filename):
captures.append(full_path)
return captures
def get_data_for_gallery(self) -> list[CaptureInfo]:
"""Return all the information about the saved captures."""
return [
CaptureInfo(
name=os.path.basename(capture),
created=os.path.getctime(capture),
modified=os.path.getmtime(capture),
thing=self.name,
)
for capture in self._all_captures()
]
def delete_all_gallery_items(self) -> None:
"""Delete all the captures on the microscope.
Use with extreme caution.
"""
for capture in self._all_captures():
lt.raise_if_cancelled()
self.logger.info(f"Deleting: {capture}")
os.remove(capture)
@lt.endpoint(
"delete",
"capture/{name}",
responses={
200: {"description": "Successfully deleted capture"},
400: {"description": "An error occurred while trying to delete capture"},
},
)
def delete_capture(self, name: str) -> None:
"""Delete the specified capture.
This endpoint allows captures to be deleted from disk.
:param name: The name of the capture to delete
"""
if not _file_is_capture(name):
self.logger.warning(f"{name} is not an image file.")
raise HTTPException(400, f"{name} is not an image file.")
full_path = os.path.normpath(os.path.join(self.data_dir, name))
try:
os.remove(full_path)
except IOError as e:
self.logger.warning(f"Failed to delete {name}.")
raise HTTPException(
400, "Couldn't delete capture, check log for details"
) from e
@property @property
def focus_fom(self) -> int: def focus_fom(self) -> int:
"""Return the focus figure of merit. """Return the focus figure of merit.

View file

@ -36,6 +36,8 @@ class GalleryCompatibleThing(Protocol):
# Ignore D102: No docstrings for the protocol. # Ignore D102: No docstrings for the protocol.
def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102 def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102
def delete_all_gallery_items(self) -> None: ... # noqa: D102
class GalleryThing(lt.Thing): class GalleryThing(lt.Thing):
"""A Thing for communicating with the front end gallery.""" """A Thing for communicating with the front end gallery."""
@ -95,5 +97,21 @@ class GalleryThing(lt.Thing):
""" """
data_list = [] data_list = []
for thing in self.gallery_providing_things.values(): for thing in self.gallery_providing_things.values():
data_list += [model.model_dump() for model in thing.get_data_for_gallery()] try:
data_list += [
model.model_dump() for model in thing.get_data_for_gallery()
]
except Exception as e:
# If any of the providers errors producing a list, log the exception
# and continue.
self.logger.exception(e)
return data_list return data_list
@lt.action
def delete_all_data(self) -> None:
"""Delete all the gallery data on this microscope."""
for thing in self.gallery_providing_things.values():
try:
thing.delete_all_gallery_items()
except Exception as e:
self.logger.exception(e)

View file

@ -192,6 +192,18 @@ class SmartScanThing(OFMThing):
ongoing=ongoing_name, include_ongoing=False ongoing=ongoing_name, include_ongoing=False
) )
def delete_all_gallery_items(self) -> None:
"""Delete all the scans on the microscope.
**This will irreversibly remove all scanned data from the microscope!**
Use with extreme caution.
"""
for scan_name in self._scan_dir_manager.all_scans:
lt.raise_if_cancelled()
self.logger.info(f"Deleting: {scan_name}")
self._delete_scan(scan_name)
# 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.setting @lt.setting
@ -608,20 +620,6 @@ class SmartScanThing(OFMThing):
if not deleted_scan_success: if not deleted_scan_success:
raise HTTPException(400, "Couldn't delete scan, check log for details") raise HTTPException(400, "Couldn't delete scan, check log for details")
@lt.endpoint(
"delete",
"scans",
)
def delete_all_scans(self) -> None:
"""Delete all the scans on the microscope.
**This will irreversibly remove all scanned data from the
microscope!**
Use with extreme caution.
"""
for scan_name in self._scan_dir_manager.all_scans:
self._delete_scan(scan_name)
@lt.action @lt.action
def purge_empty_scans(self) -> None: def purge_empty_scans(self) -> None:
"""Delete all scan folders containing no images at the top level.""" """Delete all scan folders containing no images at the top level."""

View file

@ -24,6 +24,21 @@ REPO_ROOT = os.path.dirname(THIS_DIR)
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json") SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
@pytest.fixture(autouse=True)
def restore_root_logger():
"""Restore the logging level of the root logger after each test.
As we change the root logging level if any code runs ``configure_logging``
this can make logs leak between tests.
"""
root = logging.getLogger()
old_level = root.level
yield
root.setLevel(old_level)
@pytest.fixture @pytest.fixture
def simulation_test_env(): def simulation_test_env():
"""Yield a server with the configuration from the simulation json.""" """Yield a server with the configuration from the simulation json."""

View file

@ -1,9 +1,13 @@
"""Tests that captures have the expected metadata.""" """Tests that captures have the expected metadata."""
import logging import logging
from dataclasses import dataclass
from typing import Callable, Optional
import pytest
from pydantic import BaseModel from pydantic import BaseModel
from labthings_fastapi.exceptions import InvocationCancelledError
from labthings_fastapi.testing import create_thing_without_server from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things import OFMThing from openflexure_microscope_server.things import OFMThing
@ -15,6 +19,16 @@ from openflexure_microscope_server.things.gallery import (
from ..shared_utils.lt_test_utils import LabThingsTestEnv from ..shared_utils.lt_test_utils import LabThingsTestEnv
def test_error_if_accessing_gallery_things_before_started():
"""Check that gallery_providing_things property errors before init."""
thing = create_thing_without_server(GalleryThing)
with pytest.raises(
RuntimeError,
match="Cannot access gallery_providing_things before server has started.",
):
thing.gallery_providing_things
class MockGalleryData(BaseModel): class MockGalleryData(BaseModel):
"""Mock gallery data.""" """Mock gallery data."""
@ -36,6 +50,9 @@ class MinimalGalleryClass:
"""Return a list of Mock Gallery Data.""" """Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")] return [MockGalleryData(mock="mock", foobar="foobar")]
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
class MinimalGallerySource(OFMThing, MinimalGalleryClass): class MinimalGallerySource(OFMThing, MinimalGalleryClass):
"""Minimal example of a Thing that can show data in the gallery. """Minimal example of a Thing that can show data in the gallery.
@ -60,6 +77,9 @@ class BadGallerySource(OFMThing):
"""Return a list of Mock Gallery Data.""" """Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")] return [MockGalleryData(mock="mock", foobar="foobar")]
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
def test_gallery_compatible_protocol(): def test_gallery_compatible_protocol():
"""Check the gallery compatible protocol detects compatible classes only.""" """Check the gallery compatible protocol detects compatible classes only."""
@ -79,19 +99,27 @@ def test_gallery_compatible_protocol():
def test_gallery_thing_finds_all_providers(simulation_test_env): def test_gallery_thing_finds_all_providers(simulation_test_env):
"""Check the gallery identifies the correct Things that will provide data.""" """Check the gallery identifies the correct Things that will provide data."""
# Started the full simulation in a test environment.
# Get the gallery and 3 other Things from the environment.
gallery = simulation_test_env.get_thing_by_name("gallery") gallery = simulation_test_env.get_thing_by_name("gallery")
snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow") snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow")
smart_scan = simulation_test_env.get_thing_by_name("smart_scan") smart_scan = simulation_test_env.get_thing_by_name("smart_scan")
camera = simulation_test_env.get_thing_by_name("camera") camera = simulation_test_env.get_thing_by_name("camera")
# Check that all_ofm_things are correct. Camera and smart scan are ofm things
# snake workflow is not.
assert snake_workflow not in gallery.all_ofm_things.values() assert snake_workflow not in gallery.all_ofm_things.values()
assert camera in gallery.all_ofm_things.values() assert camera in gallery.all_ofm_things.values()
assert smart_scan in gallery.all_ofm_things.values() assert smart_scan in gallery.all_ofm_things.values()
# Also check that both the camera and smart scan are recognised as gallery
# providers based on the protocol.
assert snake_workflow not in gallery.gallery_providing_things.values() assert snake_workflow not in gallery.gallery_providing_things.values()
assert camera not in gallery.gallery_providing_things.values() assert camera in gallery.gallery_providing_things.values()
assert smart_scan in gallery.gallery_providing_things.values() assert smart_scan in gallery.gallery_providing_things.values()
# Also check the runtime checkable protocol GalleryCompatibleThing works directly
# when called with isinstance.
assert not isinstance(snake_workflow, GalleryCompatibleThing) assert not isinstance(snake_workflow, GalleryCompatibleThing)
assert isinstance(smart_scan, GalleryCompatibleThing) assert isinstance(smart_scan, GalleryCompatibleThing)
@ -120,3 +148,119 @@ def test_gallery_logs_for_bad_providers(caplog):
record = caplog.records[0] record = caplog.records[0]
assert "Data from bad_thing cannot be shown in gallery" in record.message assert "Data from bad_thing cannot be shown in gallery" in record.message
assert record.levelname == "ERROR" assert record.levelname == "ERROR"
@pytest.fixture
def minimal_gallery_env():
"""Return a minimal working environment for testing the gallery."""
things = {
"minimal_thing1": MinimalGallerySource,
"minimal_thing2": MinimalGallerySource,
"gallery": GalleryThing,
}
with LabThingsTestEnv(
things=things, application_config={"data_folder": "./openflexure/data/"}
) as env:
return env
def test_gallery_lists_data(minimal_gallery_env, mocker, caplog):
"""Test that the gallery calls all gallery things to list data."""
# Create a config with the two minimal things and get the things by name.
gallery = minimal_gallery_env.get_thing_by_name("gallery")
thing_1 = minimal_gallery_env.get_thing_by_name("minimal_thing1")
thing_2 = minimal_gallery_env.get_thing_by_name("minimal_thing2")
# Add some mock data to them the gallery data things
thing_1_data = [
MockGalleryData(mock="thing_1", foobar=1),
MockGalleryData(mock="thing_1", foobar=2),
]
thing_2_data = [
MockGalleryData(mock="thing_2", foobar=1),
MockGalleryData(mock="thing_2", foobar=2),
]
# Also mock the get_data_for_gallery methods.
mocker.patch.object(thing_1, "get_data_for_gallery", return_value=thing_1_data)
mocker.patch.object(thing_2, "get_data_for_gallery", return_value=thing_2_data)
# Set the expected returns.
thing_1_return = [
{"mock": "thing_1", "foobar": 1},
{"mock": "thing_1", "foobar": 2},
]
thing_2_return = [
{"mock": "thing_2", "foobar": 1},
{"mock": "thing_2", "foobar": 2},
]
# Check that the gallery returns the expected concatenated data
assert gallery.list_data == thing_1_return + thing_2_return
# Check that if the first thing called throws an error the data from the second
# thing is still returned.
thing_1.get_data_for_gallery.side_effect = RuntimeError
with caplog.at_level(logging.INFO):
assert gallery.list_data == thing_2_return
# Check an error was logged about the first thing failing to collect data.
assert len(caplog.records) == 1
assert caplog.records[0].levelname == "ERROR"
@dataclass
class GalleryDeleteTestCase:
"""Inputs and expected outputs for testing ``save_from_memory``.
The default save kwargs assume a jpeg.
"""
thing_1_side_effect: Optional[Callable]
thing_2_call_count: int
# Expect directly calling the action will raise the thing 1 side_effect error
action_errors: bool
GALLERY_DELETE_TEST_CASES = [
# No errors from thing 1, thing 2 gets called
GalleryDeleteTestCase(
thing_1_side_effect=None, thing_2_call_count=1, action_errors=False
),
# Thing 1 errors, thing 2 is still called
GalleryDeleteTestCase(
thing_1_side_effect=RuntimeError, thing_2_call_count=1, action_errors=False
),
# Thing 1 raises invocation cancelled error. Thing 2 is not called. The InvocationError is raised
GalleryDeleteTestCase(
thing_1_side_effect=InvocationCancelledError,
thing_2_call_count=0,
action_errors=True,
),
]
@pytest.mark.parametrize("test_case", GALLERY_DELETE_TEST_CASES)
def test_gallery_calls_delete(test_case, minimal_gallery_env, mocker):
"""Test that the gallery deletes on all gallery providing things."""
# Create a config with the minimal Thing and the bad Thing
gallery = minimal_gallery_env.get_thing_by_name("gallery")
thing_1 = minimal_gallery_env.get_thing_by_name("minimal_thing1")
thing_2 = minimal_gallery_env.get_thing_by_name("minimal_thing2")
# Set up the desired side effect for the test case.
mocker.patch.object(
thing_1, "delete_all_gallery_items", side_effect=test_case.thing_1_side_effect
)
mocker.patch.object(thing_2, "delete_all_gallery_items")
# Call delete_all_data, checking for errors if the test case expects an error.
if test_case.action_errors:
with pytest.raises(test_case.thing_1_side_effect):
gallery.delete_all_data()
else:
gallery.delete_all_data()
# Check the call counts are as expected from the test case.
assert thing_1.delete_all_gallery_items.call_count == 1
assert thing_2.delete_all_gallery_items.call_count == test_case.thing_2_call_count

View file

@ -1,6 +1,13 @@
"""Test the functionality specific to the simulated camera.""" """Test the the simulated camera.
This includes both unit tests for functions specific to the simulation camera or
base camera functionality that is not camera specific, but requires the camera to
start and take images.
"""
import logging import logging
import os
import tempfile
import time import time
from io import BytesIO from io import BytesIO
@ -14,7 +21,7 @@ from pydantic import ValidationError
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV
from openflexure_microscope_server.things.camera import simulation from openflexure_microscope_server.things.camera import CaptureInfo, simulation
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.stage.dummy import DummyStage
@ -279,3 +286,110 @@ def test_generate_image_output_size(camera):
img = camera.generate_image(pos) img = camera.generate_image(pos)
assert img.size == (camera.shape[1], camera.shape[0]) assert img.size == (camera.shape[1], camera.shape[0])
def test_gallery_responses(camera, mocker, caplog):
"""Check camera responds to gallery as expected."""
mock_raise_if_cancelled = mocker.patch(
"openflexure_microscope_server.things.camera.lt.raise_if_cancelled",
)
with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.INFO):
camera._data_dir = tmpdir
assert camera.gallery_data_name == "Captures"
assert camera.gallery_data_schema == CaptureInfo
# When we start there are no captures
assert camera.get_data_for_gallery() == []
filenames = [
"doe.png",
"ray.png",
"me.jpg",
"far.jpeg",
"so.txt",
"la.json",
"tea.leaf",
]
images = filenames[:4]
non_images = filenames[4:]
for filename in filenames:
with open(os.path.join(camera._data_dir, filename), "w") as f_obj:
f_obj.write("mock")
# Check all files written
assert set(os.listdir(camera._data_dir)) == set(filenames)
# Check only images are returned by get_data_for_galler
captures = camera.get_data_for_gallery()
assert {capture.name for capture in captures} == set(images)
# delete everything
camera.delete_all_gallery_items()
# No captures remain
assert camera.get_data_for_gallery() == []
# Non-images not deleted
assert set(os.listdir(camera._data_dir)) == set(non_images)
assert len(caplog.records) == len(images)
for record in caplog.records:
assert record.message.startswith("Deleting: ")
assert mock_raise_if_cancelled.call_count == len(images)
def test_delete_capture(test_env, caplog):
"""Check camera deletes images when requested."""
camera = test_env.get_thing_by_type(SimulatedCamera)
http_client = test_env.get_thing_client("camera").client
with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.WARNING):
cam_dir = os.path.join(tmpdir, "camera")
os.makedirs(cam_dir)
camera._data_dir = cam_dir
filenames = [
os.path.join(cam_dir, "foo.png"),
# Make an image outside the camera dir
os.path.join(tmpdir, "external.png"),
os.path.join(cam_dir, "bar.txt"),
]
for filename in filenames:
with open(filename, "w") as f_obj:
f_obj.write("mock")
# Check files created.
assert "foo.png" in os.listdir(camera._data_dir)
assert "bar.txt" in os.listdir(camera._data_dir)
assert os.path.isfile(os.path.join(tmpdir, "external.png"))
response = http_client.delete("camera/capture/foo.png")
assert response.status_code == 200 # OK
# File is gone
assert "foo.png" not in os.listdir(camera._data_dir)
# No longs
assert len(caplog.records) == 0
# Try to be evil and delete the file outside the cameras data dir"
response = http_client.delete(r"camera/capture/%2E%2E%2Ffoo.png")
assert response.status_code == 404 # Route not allowed by fastAPI
# No records
assert len(caplog.records) == 0
# Try to delete an image that doesn't exist
response = http_client.delete("camera/capture/fake.png")
assert response.status_code == 400 # Error deleting as it doesn't exist
# A new log
assert len(caplog.records) == 1
# Try to delete something that isn't an image.
response = http_client.delete("camera/capture/bar.txt")
assert response.status_code == 400 # Error deleting as it isn't and image
# File still exists.
assert "bar.txt" in os.listdir(camera._data_dir)
# A new log
assert len(caplog.records) == 2

View file

@ -281,8 +281,11 @@ def test_public_delete_scan(entered_smart_scan_thing, caplog):
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_delete_all_scans(entered_smart_scan_thing, caplog): def test_delete_all_scans(entered_smart_scan_thing, caplog, mocker):
"""Check the delete_all_scan API really does delete all the scans.""" """Check the delete_all_scan API really does delete all the scans."""
mock_raise_if_cancelled = mocker.patch(
"openflexure_microscope_server.things.smart_scan.lt.raise_if_cancelled",
)
smart_scan_thing = entered_smart_scan_thing smart_scan_thing = entered_smart_scan_thing
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
fake_scan_names = [ fake_scan_names = [
@ -295,12 +298,17 @@ def test_delete_all_scans(entered_smart_scan_thing, caplog):
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name) fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
os.makedirs(fake_scan_path) os.makedirs(fake_scan_path)
assert os.path.exists(fake_scan_path) assert os.path.exists(fake_scan_path)
smart_scan_thing.delete_all_scans() smart_scan_thing.delete_all_gallery_items()
for fake_scan_name in fake_scan_names: for fake_scan_name in fake_scan_names:
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name) fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
assert not os.path.exists(fake_scan_path) assert not os.path.exists(fake_scan_path)
# No logs generated # One log generated per scan deleted
assert len(caplog.records) == 0 assert len(caplog.records) == 4
for record in caplog.records:
assert record.message.startswith("Deleting: fake_scan_000")
assert record.levelname == "INFO"
# Check that raise_if_cancelled is called once for each scan.
assert mock_raise_if_cancelled.call_count == 4
def _run_only_outer_scan( def _run_only_outer_scan(

View file

@ -0,0 +1,123 @@
<template>
<div>
<p v-if="labelText !== undefined" class="toggle-label uk-form-label">{{ labelText }}</p>
<div class="toggle-switch" :class="{ checked: modelValue }" @click="toggle" />
<p v-if="showStateLabel" class="toggle-state-label">{{ stateLabelText }}</p>
</div>
</template>
<script>
export default {
name: "ToggleSwitch",
props: {
modelValue: {
required: true,
type: Boolean,
},
labelText: {
required: false,
type: String,
default: undefined,
},
falseLabel: {
required: false,
type: String,
default: undefined,
},
trueLabel: {
required: false,
type: String,
default: undefined,
},
},
emits: ["update:modelValue"],
computed: {
showStateLabel() {
return this.trueLabel !== undefined && this.falseLabel !== undefined;
},
stateLabelText() {
return this.modelValue ? this.trueLabel : this.falseLabel;
},
},
methods: {
toggle() {
this.$emit("update:modelValue", !this.modelValue);
},
},
};
</script>
<style lang="less" scoped>
@import url("../../assets/less/variables.less");
.toggle-label {
margin-bottom: 0.2rem;
}
.toggle-state-label {
margin-top: 0.2rem;
font-style: italic;
}
.toggle-switch {
background: #ddd;
border-radius: 12px;
box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.08);
cursor: pointer;
flex: none;
height: 24px;
position: relative;
transition: background-color 150ms;
width: 48px;
}
.toggle-switch::before {
background: #fff;
background-image: radial-gradient(
circle at 6px 6px,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 0.05) 16px
);
border-radius: 10px;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.08);
content: "";
display: block;
height: 20px;
left: 2px;
position: absolute;
top: 2px;
transition: left 150ms;
width: 20px;
will-change: left;
}
.checked {
background-color: @global-primary-background;
}
.checked::before {
background-image: radial-gradient(
circle at 6px 6px,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 0.05) 16px
);
left: 26px;
}
.toggle-switch:hover {
box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.2);
}
.toggle-switch:hover::before {
background-image: radial-gradient(
circle at 6px 6px,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 0.1375) 16px
);
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<div id="captureControl">
<p><b>Image Capture</b></p>
<toggle-switch
v-model="saveToGallery"
label-text="Save to Gallery?"
true-label="Saving"
false-label="Downloading"
/>
<div class="uk-margin">
<action-button
thing="camera"
action="capture"
:submit-data="submitData"
submit-label="Capture"
:submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse"
@error="modalError"
/>
</div>
</div>
</template>
<script>
import axios from "axios";
import toggleSwitch from "@/components/genericComponents/toggleSwitch.vue";
import ActionButton from "@/components/labThingsComponents/actionButton.vue";
export default {
name: "CaptureControl",
components: {
toggleSwitch,
ActionButton,
},
data: function () {
return {
saveToGallery: true,
};
},
computed: {
submitData() {
return {
capture_mode: "standard",
retain_image: this.saveToGallery,
};
},
},
methods: {
handleCaptureResponse: async function (response) {
// Retrieve the captured image and save it
if (this.saveToGallery) return;
let imageUri = response.output.href;
if (!imageUri) {
this.modalError("No image URI returned from capture task.");
return;
}
// To save the returned data, we make a virtual link and click it
let imageResponse = await axios.get(imageUri, { responseType: "blob" });
const url = window.URL.createObjectURL(new Blob([imageResponse.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `OFM_${new Date().toISOString()}.jpeg`);
document.body.appendChild(link);
link.click();
},
},
};
</script>

View file

@ -2,34 +2,20 @@
<div id="paneControl" class="uk-padding-small"> <div id="paneControl" class="uk-padding-small">
<positionControl v-if="stageAvailable" /> <positionControl v-if="stageAvailable" />
<autofocusControl v-if="autofocusAvailable" /> <autofocusControl v-if="autofocusAvailable" />
<p>Image Capture</p> <captureControl />
<div class="uk-margin">
<action-button
thing="camera"
action="capture"
:submit-data="{ capture_mode: 'standard' }"
submit-label="Capture"
:submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse"
@error="modalError"
/>
</div>
</div> </div>
</template> </template>
<script> <script>
import axios from "axios"; import captureControl from "./captureControl.vue";
import ActionButton from "../../labThingsComponents/actionButton.vue";
import positionControl from "./positionControl.vue"; import positionControl from "./positionControl.vue";
import autofocusControl from "./autofocusControl.vue"; import autofocusControl from "./autofocusControl.vue";
import { eventBus } from "../../../eventBus.js";
export default { export default {
name: "PaneControl", name: "PaneControl",
components: { components: {
ActionButton, captureControl,
positionControl, positionControl,
autofocusControl, autofocusControl,
}, },
@ -42,28 +28,5 @@ export default {
return this.thingAvailable("autofocus"); return this.thingAvailable("autofocus");
}, },
}, },
methods: {
handleCaptureResponse: async function (response) {
// Retrieve the captured image and save it
let imageUri = response.output.href;
if (!imageUri) {
this.modalError("No image URI returned from capture task.");
return;
}
// Emit flash capture stream animation
eventBus.emit("globalFlashStream");
// To save the returned data, we make a virtual link and click it
let imageResponse = await axios.get(imageUri, { responseType: "blob" });
const url = window.URL.createObjectURL(new Blob([imageResponse.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `OFM_${new Date().toISOString()}.jpeg`);
document.body.appendChild(link);
link.click();
},
},
}; };
</script> </script>

View file

@ -5,17 +5,17 @@
<div class="view-image uk-padding-remove uk-height-1-1"> <div class="view-image uk-padding-remove uk-height-1-1">
<img <img
id="thumbnail-stitched-image" id="thumbnail-stitched-image"
:class="[itemData?.dzi ? 'thumbnail-fit clickable' : 'thumbnail-fit disabled']" :class="[viewerAvailable ? 'thumbnail-fit clickable' : 'thumbnail-fit disabled']"
class="thumbnail-fit" class="thumbnail-fit"
:src="thumbnailPath" :src="thumbnailPath"
onerror="this.src = '/titleiconpink.svg'" onerror="this.src = '/titleiconpink.svg'"
@click="itemData?.dzi && requestViewer()" @click="viewerAvailable && requestViewer()"
/> />
</div> </div>
</div> </div>
<h3 class="uk-card-title gallery-card-title">{{ itemData.name }}</h3> <h3 class="uk-card-title gallery-card-title">{{ itemData.name }}</h3>
<div class="button-container"> <div class="button-container">
<div class="uk-button-group gallery-card-buttons"> <div v-if="itemData.card_type === 'Scan'" class="uk-button-group gallery-card-buttons">
<action-button <action-button
class="uk-width-1-2" class="uk-width-1-2"
thing="smart_scan" thing="smart_scan"
@ -55,7 +55,7 @@
Show Stitched Scan Show Stitched Scan
</button> </button>
</div> </div>
<div class="item-info"> <div v-if="itemData.card_type === 'Scan'" class="item-info">
<ul> <ul>
<li>{{ itemData.number_of_images }} images</li> <li>{{ itemData.number_of_images }} images</li>
<li>Created: {{ formatDate(itemData.created) }}</li> <li>Created: {{ formatDate(itemData.created) }}</li>
@ -107,7 +107,17 @@ export default {
return `${this.baseUri}/smart_scan/get_stitch/${this.itemData.name}`; return `${this.baseUri}/smart_scan/get_stitch/${this.itemData.name}`;
}, },
thumbnailPath() { thumbnailPath() {
if (this.itemData.card_type === "Scan") {
return `${this.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.itemData.name}&modified=${this.itemData.modified}`; return `${this.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.itemData.name}&modified=${this.itemData.modified}`;
}
return `${this.baseUri}/data/${this.itemData.thing}/${this.itemData.name}`;
},
/**
* Return True if there is a viewer available for this card's data.
*/
viewerAvailable() {
if (this.itemData.card_type === "Scan") return Boolean(this.itemData?.dzi);
return true;
}, },
}, },
@ -144,7 +154,13 @@ export default {
async deleteScan() { async deleteScan() {
try { try {
await this.modalConfirm(`Are you sure you want to delete ${this.itemData.name}?`); await this.modalConfirm(`Are you sure you want to delete ${this.itemData.name}?`);
if (this.itemData.card_type === "Scan") {
await axios.delete(`${this.baseUri}/smart_scan/scans/${this.itemData.name}`); await axios.delete(`${this.baseUri}/smart_scan/scans/${this.itemData.name}`);
} else {
await axios.delete(
`${this.baseUri}/${this.itemData.thing}/capture/${this.itemData.name}`,
);
}
this.$emit("update-requested"); this.$emit("update-requested");
this.modalNotify(`Deleted ${this.itemData.name}`); this.modalNotify(`Deleted ${this.itemData.name}`);
} catch (e) { } catch (e) {

View file

@ -1,8 +1,8 @@
<template> <template>
<div id="scan-modal" ref="scanModal" uk-modal> <div id="viewer-modal" ref="viewerModal" uk-modal>
<div v-if="selectedScan" id="scan-modal-body" class="uk-modal-dialog uk-modal-body"> <div v-if="selectedItem" id="viewer-modal-body" class="uk-modal-dialog uk-modal-body">
<h2 id="scan-modal-title" class="uk-modal-title"> <h2 id="viewer-modal-title" class="uk-modal-title">
{{ selectedScan.name }} {{ selectedItem.name }}
<button class="uk-modal-close uk-float-right" type="button"> <button class="uk-modal-close uk-float-right" type="button">
<span class="material-symbols-outlined">close</span> <span class="material-symbols-outlined">close</span>
</button> </button>
@ -12,11 +12,11 @@
</h2> </h2>
<!-- Viewer --> <!-- Viewer -->
<div v-if="selectedScanDZI" id="viewer_container" class="viewer_container"> <div v-if="imageSource" id="viewer_container" class="viewer_container">
<OpenSeadragonViewer <OpenSeadragonViewer
id="openseadragon" id="openseadragon"
ref="openseadragon" ref="openseadragon"
:src="selectedScanDZI" :src="imageSource"
:brightness="brightness" :brightness="brightness"
:contrast="contrast" :contrast="contrast"
:saturation="saturation" :saturation="saturation"
@ -25,7 +25,7 @@
</div> </div>
<!-- Controls --> <!-- Controls -->
<div v-if="selectedScanDZI" class="viewer-controls"> <div v-if="imageSource" class="viewer-controls">
<div class="controlsContainer"> <div class="controlsContainer">
<label> <label>
Brightness Brightness
@ -58,12 +58,12 @@ import UIkit from "uikit";
import OpenSeadragonViewer from "./openSeadragonViewer.vue"; import OpenSeadragonViewer from "./openSeadragonViewer.vue";
export default { export default {
name: "ScanViewerModal", name: "GalleryModal",
components: { components: {
OpenSeadragonViewer, OpenSeadragonViewer,
}, },
props: { props: {
selectedScan: { selectedItem: {
type: Object, type: Object,
default: null, default: null,
}, },
@ -79,15 +79,20 @@ export default {
}; };
}, },
computed: { computed: {
selectedScanDZI() { imageSource() {
if (this.selectedScan && this.selectedScan.dzi) { if (this.selectedItem?.card_type === "Scan" && this.selectedItem?.dzi) {
return `${this.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`; return `${this.baseUri}/data/smart_scan/${this.selectedItem.name}/images/${this.selectedItem.dzi}`;
} else if (this.selectedItem?.card_type === "Capture") {
return {
type: "image",
url: `${this.baseUri}/data/${this.selectedItem.thing}/${this.selectedItem.name}`,
};
} }
return null; return null;
}, },
}, },
mounted() { mounted() {
this.modalEl = this.$refs.scanModal; this.modalEl = this.$refs.viewerModal;
this.beforeHideHandler = (event) => { this.beforeHideHandler = (event) => {
if (this.enteringFullscreen) { if (this.enteringFullscreen) {
event.preventDefault(); event.preventDefault();
@ -103,10 +108,10 @@ export default {
}, },
methods: { methods: {
show() { show() {
UIkit.modal(this.$refs.scanModal).show(); UIkit.modal(this.$refs.viewerModal).show();
}, },
hide() { hide() {
UIkit.modal(this.$refs.scanModal).hide(); UIkit.modal(this.$refs.viewerModal).hide();
}, },
goFullscreen() { goFullscreen() {
this.$refs.openseadragon.openFullscreen(); this.$refs.openseadragon.openFullscreen();
@ -126,11 +131,11 @@ input[type="range"] {
z-index: 1001; z-index: 1001;
} }
#scan-modal { #viewer-modal {
padding: 10px; padding: 10px;
} }
#scan-modal-body { #viewer-modal-body {
padding: 10px; padding: 10px;
width: 95%; width: 95%;
height: 95%; height: 95%;
@ -138,7 +143,7 @@ input[type="range"] {
flex-direction: column; flex-direction: column;
} }
#scan-modal-title { #viewer-modal-title {
flex: 0 0 auto; flex: 0 0 auto;
} }

View file

@ -13,7 +13,7 @@ export default {
props: { props: {
src: { src: {
type: String, type: [String, Object],
required: true, required: true,
}, },
brightness: { brightness: {

View file

@ -19,14 +19,19 @@
@error="modalError" @error="modalError"
/> />
</div> </div>
<div> <div class="gallery-button">
<button <action-button
class="uk-button uk-button-default uk-width-1-1 gallery-button" class="uk-width-1-1"
type="button" thing="gallery"
@click="deleteAllScans()" action="delete_all_data"
> submit-label="Delete All"
Delete All Scans :can-terminate="true"
</button> :button-primary="false"
:modal-progress="true"
:requires-confirmation="true"
:confirmation-message="'<p>Are you sure you want to delete all gallery data from the microscope?</p><p>This is <b>irreversible</b>!</p>'"
@error="modalError"
/>
</div> </div>
<div> <div>
<button <button
@ -41,7 +46,7 @@
</div> </div>
</nav> </nav>
<ScanViewerModal ref="scanViewer" :selected-scan="selectedScan" :base-uri="baseUri" /> <gallery-modal ref="viewerModal" :selected-item="selectedItem" :base-uri="baseUri" />
<!-- Gallery --> <!-- Gallery -->
<div v-if="ready" class="uk-padding-remove-top" uk-lightbox="toggle: .lightbox-link"> <div v-if="ready" class="uk-padding-remove-top" uk-lightbox="toggle: .lightbox-link">
@ -54,7 +59,7 @@
<div v-for="itemData in paginatedItems" :key="itemData.id"> <div v-for="itemData in paginatedItems" :key="itemData.id">
<gallery-card <gallery-card
:item-data="itemData" :item-data="itemData"
@viewer-requested="showScan" @viewer-requested="showItem"
@update-requested="refreshGallery" @update-requested="refreshGallery"
/> />
</div> </div>
@ -69,11 +74,10 @@
</template> </template>
<script> <script>
import axios from "axios";
import PaginateLinks from "@/components/genericComponents/paginateLinks.vue"; import PaginateLinks from "@/components/genericComponents/paginateLinks.vue";
import actionButton from "../labThingsComponents/actionButton.vue"; import actionButton from "../labThingsComponents/actionButton.vue";
import galleryCard from "./galleryComponents/galleryCard.vue"; import galleryCard from "./galleryComponents/galleryCard.vue";
import ScanViewerModal from "./galleryComponents/scanViewer.vue"; import galleryModal from "./galleryComponents/galleryViewer.vue";
import { eventBus } from "../../eventBus.js"; import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js"; import { useSettingsStore } from "@/stores/settings.js";
@ -86,7 +90,7 @@ export default {
components: { components: {
actionButton, actionButton,
galleryCard, galleryCard,
ScanViewerModal, galleryModal,
PaginateLinks, PaginateLinks,
}, },
@ -95,7 +99,7 @@ export default {
data: function () { data: function () {
return { return {
all_items: [], all_items: [],
selectedScan: null, selectedItem: null,
osdViewer: null, osdViewer: null,
currentPage: 1, currentPage: 1,
itemsPerPage: 18, itemsPerPage: 18,
@ -104,21 +108,9 @@ export default {
computed: { computed: {
...mapState(useSettingsStore, ["baseUri", "ready"]), ...mapState(useSettingsStore, ["baseUri", "ready"]),
scansUri() {
// 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);
},
noItems() { noItems() {
return !this.all_items || this.all_items?.length === 0; return !this.all_items || this.all_items?.length === 0;
}, },
selectedScanDZI() {
if (this.selectedScan && this.selectedScan.dzi != "") {
return `${this.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
} else {
return null;
}
},
totalPages() { totalPages() {
return Math.ceil((this.all_items?.length || 0) / this.itemsPerPage); return Math.ceil((this.all_items?.length || 0) / this.itemsPerPage);
}, },
@ -195,27 +187,18 @@ export default {
this.all_items = []; this.all_items = [];
} }
}, },
async deleteAllScans() { showItem(itemData) {
try { if (itemData.card_type === "Scan") {
await this.modalConfirm( if (itemData.dzi) {
"Are you sure you want to delete all scans from the microscope? " + this.selectedItem = itemData;
"This is <b>irreversible</b>!", this.$refs.viewerModal.show();
);
await axios.delete(`${this.scansUri}`);
await this.refreshGallery();
this.modalNotify("Deleted all scans.");
} catch (e) {
// if the confirmation was cancelled, it's rejected with null error
if (e) this.modalError(e);
}
},
showScan(scan) {
if (scan.dzi) {
this.selectedScan = scan;
this.$refs.scanViewer.show();
} else { } else {
this.modalError("Scan not stitched for viewing in webapp, please download or stitch"); this.modalError("Scan not stitched for viewing in webapp, please download or stitch");
} }
} else {
this.selectedItem = itemData;
this.$refs.viewerModal.show();
}
}, },
changePage(page) { changePage(page) {
if (page >= 1 && page <= this.totalPages && this.currentPage != page) { if (page >= 1 && page <= this.totalPages && this.currentPage != page) {

View file

@ -54,6 +54,9 @@ export const componentOverrides = {
"matrixDisplay.vue": { "matrixDisplay.vue": {
props: { matrix: [] }, props: { matrix: [] },
}, },
"toggleSwitch.vue": {
props: { modelValue: true },
},
"actionProgressBar.vue": { "actionProgressBar.vue": {
props: { taskStatus: "" }, props: { taskStatus: "" },
}, },