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

View file

@ -20,7 +20,7 @@ from typing import Any, Literal, Mapping, Optional, Self
import numpy as np
import piexif
from fastapi import Response
from fastapi import HTTPException, Response
from PIL import Image
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):
"""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."""
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):
"""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()
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
def focus_fom(self) -> int:
"""Return the focus figure of merit.

View file

@ -36,6 +36,8 @@ class GalleryCompatibleThing(Protocol):
# Ignore D102: No docstrings for the protocol.
def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102
def delete_all_gallery_items(self) -> None: ... # noqa: D102
class GalleryThing(lt.Thing):
"""A Thing for communicating with the front end gallery."""
@ -95,5 +97,21 @@ class GalleryThing(lt.Thing):
"""
data_list = []
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
@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
)
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
# setting is loaded from disk.
@lt.setting
@ -608,20 +620,6 @@ class SmartScanThing(OFMThing):
if not deleted_scan_success:
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
def purge_empty_scans(self) -> None:
"""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")
@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
def simulation_test_env():
"""Yield a server with the configuration from the simulation json."""

View file

@ -1,9 +1,13 @@
"""Tests that captures have the expected metadata."""
import logging
from dataclasses import dataclass
from typing import Callable, Optional
import pytest
from pydantic import BaseModel
from labthings_fastapi.exceptions import InvocationCancelledError
from labthings_fastapi.testing import create_thing_without_server
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
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):
"""Mock gallery data."""
@ -36,6 +50,9 @@ class MinimalGalleryClass:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
class MinimalGallerySource(OFMThing, MinimalGalleryClass):
"""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 [MockGalleryData(mock="mock", foobar="foobar")]
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
def test_gallery_compatible_protocol():
"""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):
"""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")
snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow")
smart_scan = simulation_test_env.get_thing_by_name("smart_scan")
camera = simulation_test_env.get_thing_by_name("camera")
# 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 camera 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 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()
# Also check the runtime checkable protocol GalleryCompatibleThing works directly
# when called with isinstance.
assert not isinstance(snake_workflow, GalleryCompatibleThing)
assert isinstance(smart_scan, GalleryCompatibleThing)
@ -120,3 +148,119 @@ def test_gallery_logs_for_bad_providers(caplog):
record = caplog.records[0]
assert "Data from bad_thing cannot be shown in gallery" in record.message
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 os
import tempfile
import time
from io import BytesIO
@ -14,7 +21,7 @@ from pydantic import ValidationError
import labthings_fastapi as lt
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.stage.dummy import DummyStage
@ -279,3 +286,110 @@ def test_generate_image_output_size(camera):
img = camera.generate_image(pos)
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
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."""
mock_raise_if_cancelled = mocker.patch(
"openflexure_microscope_server.things.smart_scan.lt.raise_if_cancelled",
)
smart_scan_thing = entered_smart_scan_thing
with caplog.at_level(logging.INFO):
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)
os.makedirs(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:
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
assert not os.path.exists(fake_scan_path)
# No logs generated
assert len(caplog.records) == 0
# One log generated per scan deleted
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(

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">
<positionControl v-if="stageAvailable" />
<autofocusControl v-if="autofocusAvailable" />
<p>Image Capture</p>
<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>
<captureControl />
</div>
</template>
<script>
import axios from "axios";
import ActionButton from "../../labThingsComponents/actionButton.vue";
import captureControl from "./captureControl.vue";
import positionControl from "./positionControl.vue";
import autofocusControl from "./autofocusControl.vue";
import { eventBus } from "../../../eventBus.js";
export default {
name: "PaneControl",
components: {
ActionButton,
captureControl,
positionControl,
autofocusControl,
},
@ -42,28 +28,5 @@ export default {
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>

View file

@ -5,17 +5,17 @@
<div class="view-image uk-padding-remove uk-height-1-1">
<img
id="thumbnail-stitched-image"
:class="[itemData?.dzi ? 'thumbnail-fit clickable' : 'thumbnail-fit disabled']"
:class="[viewerAvailable ? 'thumbnail-fit clickable' : 'thumbnail-fit disabled']"
class="thumbnail-fit"
:src="thumbnailPath"
onerror="this.src = '/titleiconpink.svg'"
@click="itemData?.dzi && requestViewer()"
@click="viewerAvailable && requestViewer()"
/>
</div>
</div>
<h3 class="uk-card-title gallery-card-title">{{ itemData.name }}</h3>
<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
class="uk-width-1-2"
thing="smart_scan"
@ -55,7 +55,7 @@
Show Stitched Scan
</button>
</div>
<div class="item-info">
<div v-if="itemData.card_type === 'Scan'" class="item-info">
<ul>
<li>{{ itemData.number_of_images }} images</li>
<li>Created: {{ formatDate(itemData.created) }}</li>
@ -107,7 +107,17 @@ export default {
return `${this.baseUri}/smart_scan/get_stitch/${this.itemData.name}`;
},
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}/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() {
try {
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}`);
} else {
await axios.delete(
`${this.baseUri}/${this.itemData.thing}/capture/${this.itemData.name}`,
);
}
this.$emit("update-requested");
this.modalNotify(`Deleted ${this.itemData.name}`);
} catch (e) {

View file

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

View file

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

View file

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

View file

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