Merge branch 'tweak-scan-list' into 'v3'

Scan list improvements

Closes #391

See merge request openflexure/openflexure-microscope-server!397
This commit is contained in:
Julian Stirling 2025-09-29 08:55:13 +00:00
commit 4345f8c336
3 changed files with 236 additions and 189 deletions

View file

@ -17,6 +17,7 @@ from subprocess import SubprocessError
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import numpy as np import numpy as np
from pydantic import BaseModel
import labthings_fastapi as lt import labthings_fastapi as lt
@ -39,6 +40,16 @@ CSMDep = lt.deps.direct_thing_client_dependency(
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
class ScanListData(BaseModel):
"""The data to be sent to the Scan List tab."""
scans: list[scan_directories.ScanInfo]
"""The list of scans as ScanInfo objects"""
ongoing: Optional[str]
"""The name of the ongoing scan or None"""
class JPEGBlob(lt.blob.Blob): class JPEGBlob(lt.blob.Blob):
"""A class representing a JPEG image as a LabThings FastAPI Blob.""" """A class representing a JPEG image as a LabThings FastAPI Blob."""
@ -557,7 +568,7 @@ class SmartScanThing(lt.Thing):
"""Whether to run a final stitch at the end of a successful scan.""" """Whether to run a final stitch at the end of a successful scan."""
@lt.thing_property @lt.thing_property
def scans(self) -> list[scan_directories.ScanInfo]: def scans(self) -> ScanListData:
"""All the available scans. """All the available scans.
Each scan has a name (which can be used to access it), along with Each scan has a name (which can be used to access it), along with
@ -566,7 +577,10 @@ class SmartScanThing(lt.Thing):
uses a regular expression, and changes to the naming scheme will uses a regular expression, and changes to the naming scheme will
break it. break it.
""" """
return self._scan_dir_manager.all_scans_info() return ScanListData(
scans=self._scan_dir_manager.all_scans_info(),
ongoing=None if self._ongoing_scan is None else self._ongoing_scan.name,
)
@lt.fastapi_endpoint( @lt.fastapi_endpoint(
"get", "get",

View file

@ -0,0 +1,196 @@
<template>
<div class="uk-card">
<div class="uk-card-body">
<div class="uk-card-media-top">
<div
class="view-image uk-padding-remove uk-height-1-1"
>
<img
id="thumbnail-stitched-image"
class="thumbnail-fit"
:src="thumbnailPath"
onerror="this.src='/titleiconpink.svg';"
@click="requestViewer"
/>
</div>
</div>
<h3 class="uk-card-title scan-card-title">{{ scanData.name }}</h3>
<h4 class="ongoing-msg" v-if="ongoing">Scan in progress</h4>
<div class="button-container" v-if="!ongoing">
<div class="uk-button-group scan-card-buttons">
<action-button
class="uk-width-1-2"
thing="smart_scan"
action="download_zip"
submit-label="Download All"
:can-terminate="false"
:submit-data="{ scan_name: scanData.name }"
:button-primary="true"
@response="downloadZipFile"
@error="modalError"
/>
<EndpointButton
class="uk-width-1-2"
:buttonPrimary=true
:isDisabled=!scanData.stitch_available
:URL="downloadStitchFile"
buttonLabel="Download JPEG"
/>
</div>
<button
class="uk-button uk-button-default uk-width-1-1"
@click="deleteScan"
>
Delete
</button>
<action-button
submit-label="Stitch Images"
thing="smart_scan"
action="stitch_scan"
v-if="scanData.can_stitch | (scanData.stitch_available & !scanData.dzi)"
:can-terminate="true"
:submit-data="{ scan_name: scanData.name }"
:button-primary="false"
:modal-progress="true"
@error="modalError"
/>
<button
v-if="scanData.dzi" class="uk-button uk-button-default uk-width-1-1"
@click="requestViewer"
>
Show Stitched Scan
</button>
</div>
<div>
<ul>
<li>{{ scanData.number_of_images }} images</li>
<li>created: {{ formatDate(scanData.created) }}</li>
<li>modified: {{ formatDate(scanData.modified) }}</li>
</ul>
<ul v-if="!ongoing">
<li v-if="scanData.number_of_images<3" class="warning-msg">Not enough images to stitch</li>
<li v-else-if="!scanData.dzi & scanData.stitch_available" class="alert-msg">Interactive preview not available</li>
<li v-else-if=!scanData.stitch_available class="alert-msg">High quality stitch not available</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import actionButton from "../../labThingsComponents/actionButton.vue";
import EndpointButton from "../../labThingsComponents/endpointButton.vue";
// Export main app
export default {
name: "ScanCard",
components: { actionButton, EndpointButton },
props: {
scanData: {
type: Object,
required: true
},
scansUri: {
type: String,
required: true
},
ongoing: {
type: Boolean,
required: true
}
},
computed: {
downloadStitchFile() {
return `${this.$store.getters.baseUri}/smart_scan/get_stitch/${this.scanData.name}`;
},
thumbnailPath() {
return `${this.$store.getters.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.scanData.name}&modified=${this.scanData.modified}`;
},
},
methods: {
formatDate(timestamp) {
// Multiply by 1000 as JS uses ms not s
let d = new Date(timestamp*1000);
// Convert to a string in a very javascript way!
let yyyy = d.getFullYear();
let mm = d.getMonth() + 1;
let dd = d.getDate();
let HH = d
.getHours()
.toString()
.padStart(2, "0");
let MM = d
.getMinutes()
.toString()
.padStart(2, "0");
return `${HH}:${MM} ${dd}/${mm}/${yyyy}`;
},
requestViewer() {
// Notify parent that thumbnail was clicked
if (!this.ongoing) {
this.$emit('viewer-requested', this.scanData);
}
},
async deleteScan() {
try {
await this.modalConfirm(`Are you sure you want to delete ${this.scanData.name}?`);
await axios.delete(`${this.scansUri}/${this.scanData.name}`);
this.$emit('update-requested');
this.modalNotify(`Deleted ${this.scanData.name}`);
} catch (e) {
// if the confirmation was cancelled, it's rejected with null error
if (e) this.modalError(e);
}
},
async downloadZipFile(response) {
const scan_name = response.input.scan_name;
const filename = `${scan_name}_images.zip`;
const url = response.output.href;
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
console.log(link);
document.body.appendChild(link);
link.click();
}
}
}
</script>
<style lang="less" scoped>
ul {
display: inline-block;
text-align: center;
list-style-type:none;
margin: 5px 0px 10px 0px;
}
.warning-msg {
color: red;
text-align: center;
font-weight: bold;
}
.alert-msg {
color: orange;
text-align: center;
font-weight: bold;
}
.scan-card-buttons {
width: 100%;
}
.scan-card-title {
text-align: center;
}
.ongoing-msg {
text-align: center;
padding: 2rem 0;
}
</style>

View file

@ -60,12 +60,12 @@
<span class="material-symbols-outlined">fullscreen</span> <span class="material-symbols-outlined">fullscreen</span>
</button> </button>
</h2> </h2>
<div v-if="selectedScanDZIAvailable" id="viewer_container" style="height: 80%;"> <div v-if="selectedScanDZI" id="viewer_container" style="height: 80%;">
<OpenSeadragonViewer <OpenSeadragonViewer
id="openseadragon" id="openseadragon"
ref="openseadragon" ref="openseadragon"
:src="selectedScanDZI" :src="selectedScanDZI"
/> />
</div> </div>
</div> </div>
</div> </div>
@ -84,101 +84,36 @@
There are no scans available to show. There are no scans available to show.
</p> </p>
</div> </div>
<div v-for="item in scans" :key="item.id"> <div v-for="scanData in scans" :key="scanData.id">
<div class="uk-card"> <scan-card
<div class="uk-card-body"> :scan-data="scanData"
<div class="uk-card-media-top"> :scans-uri="scansUri"
<div :ongoing="isOngoing(scanData.name)"
class="view-image uk-padding-remove uk-height-1-1" @viewer-requested="showScan"
> @update-requested="updateScans"
<img />
id="thumbnail-stitched-image"
class="thumbnail-fit"
:src="thumbnailPath(item.name)"
onerror="this.src='/titleiconpink.svg';"
@click="showScan(item)"
/>
</div>
</div>
<h3 class="uk-card-title" style="text-align: center;">{{ item.name }}</h3>
<div class="button-container">
<div class="uk-button-group" style="width:100%">
<action-button
class="uk-width-1-2"
thing="smart_scan"
action="download_zip"
submit-label="Download All"
:can-terminate="false"
:submit-data="{ scan_name: item.name }"
:button-primary="true"
@response="downloadZipFile"
@error="modalError"
/>
<EndpointButton
class="uk-width-1-2"
:buttonPrimary=true
:isDisabled=!item.stitch_available
:URL="downloadStitchFile( item.name )"
buttonLabel="Download JPEG"
/>
</div>
<button
class="uk-button uk-button-default uk-width-1-1"
@click="deleteScan(item.name)"
>
Delete
</button>
<action-button
submit-label="Stitch Images"
thing="smart_scan"
action="stitch_scan"
v-if="item.can_stitch | (item.stitch_available & !item.dzi)"
:can-terminate="true"
:submit-data="{ scan_name: item.name }"
:button-primary="false"
:modal-progress="true"
@error="modalError"
/>
<button
v-if="item.dzi" class="uk-button uk-button-default uk-width-1-1"
@click="showScan(item)"
>
Show Stitched Scan
</button>
</div>
<div>
<ul>
<li>{{ item.number_of_images }} images</li>
<li>created: {{ formatDate(item.created) }}</li>
<li>modified: {{ formatDate(item.modified) }}</li>
</ul>
<li v-if="item.number_of_images<3" class="warning-msg">Not enough images to stitch</li>
<li v-else-if="!item.dzi & item.stitch_available" class="alert-msg">Interactive preview not available</li>
<li v-else-if=!item.stitch_available class="alert-msg">High quality stitch not available</li>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
import axios from "axios"; import axios from "axios";
import UIkit from "uikit"; import UIkit from "uikit";
import actionButton from "../labThingsComponents/actionButton.vue"; import actionButton from "../labThingsComponents/actionButton.vue";
import scanCard from "./scanListComponents/scanCard.vue";
import OpenSeadragonViewer from "./scanListComponents/openSeadragonViewer.vue"; import OpenSeadragonViewer from "./scanListComponents/openSeadragonViewer.vue";
import EndpointButton from "../labThingsComponents/endpointButton.vue";
// Export main app // Export main app
export default { export default {
name: "ScanListContent", name: "ScanListContent",
components: { actionButton, OpenSeadragonViewer, EndpointButton }, components: { actionButton, OpenSeadragonViewer, scanCard },
data: function() { data: function() {
return { return {
scans: [], scans: [],
ongoing: null,
selectedScan: null, selectedScan: null,
osdViewer: null osdViewer: null
}; };
@ -197,14 +132,11 @@ export default {
return this.scans.length == 0; return this.scans.length == 0;
}, },
selectedScanDZI() { selectedScanDZI() {
if (this.selectedScan && this.dzi!="") { if (this.selectedScan && this.selectedScan.dzi!="") {
return `${this.$store.getters.baseUri}/scans/${this.selectedScan.name}/images/${this.selectedScan.dzi}`; return `${this.$store.getters.baseUri}/scans/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
} else { } else {
return null; return null;
} }
},
selectedScanDZIAvailable() {
return this.selectedScan && this.selectedScan.dzi;
} }
}, },
@ -251,15 +183,6 @@ export default {
}, },
methods: { methods: {
downloadStitchFile: function(name) {
return `${this.$store.getters.baseUri}/smart_scan/get_stitch/${name}`;
},
thumbnailPath(scan_name) {
return (
`${this.$store.getters.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=` +
scan_name
);
},
visibilityChanged(isVisible) { visibilityChanged(isVisible) {
if (isVisible) { if (isVisible) {
this.updateScans(); this.updateScans();
@ -270,7 +193,9 @@ export default {
}, },
async updateScans() { async updateScans() {
try { try {
let scans = await this.readThingProperty("smart_scan", "scans"); let scans_information = await this.readThingProperty("smart_scan", "scans");
let scans = scans_information.scans
this.ongoing = scans_information.ongoing
if (!scans | (scans.length == 0)) { if (!scans | (scans.length == 0)) {
this.scans = scans; this.scans = scans;
} }
@ -287,33 +212,8 @@ export default {
this.scans = []; this.scans = [];
} }
}, },
formatDate(timestamp) { isOngoing(name) {
// Multiply by 1000 as JS uses ms not s return name === this.ongoing
let d = new Date(timestamp*1000);
// Convert to a string in a very javascript way!
let yyyy = d.getFullYear();
let mm = d.getMonth() + 1;
let dd = d.getDate();
let HH = d
.getHours()
.toString()
.padStart(2, "0");
let MM = d
.getMinutes()
.toString()
.padStart(2, "0");
return `${HH}:${MM} ${dd}/${mm}/${yyyy}`;
},
async deleteScan(name) {
try {
await this.modalConfirm(`Are you sure you want to delete ${name}?`);
await axios.delete(`${this.scansUri}/${name}`);
await this.updateScans();
this.modalNotify(`Deleted ${name}`);
} catch (e) {
// if the confirmation was cancelled, it's rejected with null error
if (e) this.modalError(e);
}
}, },
async deleteAllScans() { async deleteAllScans() {
try { try {
@ -329,17 +229,6 @@ export default {
if (e) this.modalError(e); if (e) this.modalError(e);
} }
}, },
async downloadZipFile(response) {
const scan_name = response.input.scan_name;
const filename = `${scan_name}_images.zip`;
const url = response.output.href;
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
console.log(link);
document.body.appendChild(link);
link.click();
},
showScan(scan) { showScan(scan) {
if (scan.dzi){ if (scan.dzi){
this.selectedScan = scan; this.selectedScan = scan;
@ -348,7 +237,7 @@ export default {
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`)
} }
}, }
} }
}; };
</script> </script>
@ -364,57 +253,5 @@ export default {
.gallery-folder-heading { .gallery-folder-heading {
margin-bottom: 30px; margin-bottom: 30px;
} }
/*
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 320px);
justify-content: center;
overflow-y: hidden;
}
.gallery-grid > div {
display: inline-block;
margin-bottom: 20px;
}
#openseadragon {
width: 100%;
height: 100%;
}
#info-panel {
position: absolute;
top: 10px;
left: 10px;
background-color: rgba(255, 255, 255, 0.7);
padding: 5px;
border-radius: 1px;
z-index: 1000;
}
/deep/ .capture-card {
width: 300px;
height: 100%; // Used to have all cards in a row match their heights
margin-left: auto;
margin-right: auto;
}*/
ul {
display: inline-block;
text-align: center;
list-style-type:none;
margin: 5px 0px 10px 0px;
}
.warning-msg {
color: red;
text-align: center;
font-weight: bold;
}
.alert-msg{
color: orange;
text-align: center;
font-weight: bold;
}
</style> </style>