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:
commit
21d0beedb9
17 changed files with 690 additions and 134 deletions
Binary file not shown.
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
123
webapp/src/components/genericComponents/toggleSwitch.vue
Normal file
123
webapp/src/components/genericComponents/toggleSwitch.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default {
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
src: {
|
src: {
|
||||||
type: String,
|
type: [String, Object],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
brightness: {
|
brightness: {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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: "" },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue