Merge branch 'gui-dzi-viewer' into 'v3'

Gui dzi viewer

See merge request openflexure/openflexure-microscope-server!266
This commit is contained in:
Julian Stirling 2025-05-23 15:45:30 +00:00
commit 58a4dc521d
6 changed files with 218 additions and 12 deletions

View file

@ -44,3 +44,10 @@ def add_static_files(app: FastAPI) -> None:
StaticFiles(directory=fpath), StaticFiles(directory=fpath),
name=f"static_{fname}", name=f"static_{fname}",
) )
# Mount the scan directory to .../scans/, to allow dzi viewing
app.mount(
"/scans/",
StaticFiles(directory="/var/openflexure/scans/"),
name="scans",
)

View file

@ -77,6 +77,7 @@ class ScanInfo(BaseModel):
modified: datetime modified: datetime
number_of_images: int number_of_images: int
stitch_available: bool stitch_available: bool
dzi: Optional[str]
DOWNLOADABLE_SCAN_FILES = ( DOWNLOADABLE_SCAN_FILES = (
@ -819,6 +820,11 @@ class SmartScanThing(Thing):
] ]
number_of_images = len(scan_images) number_of_images = len(scan_images)
stitch_available = len(stitches) > 0 stitch_available = len(stitches) > 0
dzi = [i for i in folder_contents if i.endswith("dzi")]
if len(dzi) > 0:
dzi = str(dzi[0])
else:
dzi = None
else: else:
number_of_images = 0 number_of_images = 0
stitch_available = False stitch_available = False
@ -831,6 +837,7 @@ class SmartScanThing(Thing):
modified=modified, modified=modified,
number_of_images=number_of_images, number_of_images=number_of_images,
stitch_available=stitch_available, stitch_available=stitch_available,
dzi=dzi,
) )
) )
return scans return scans
@ -925,7 +932,7 @@ class SmartScanThing(Thing):
raise FileNotFoundError("No latest scan found") raise FileNotFoundError("No latest scan found")
images_dir = self.images_dir_for_scan(self.latest_scan_name) images_dir = self.images_dir_for_scan(self.latest_scan_name)
stitch_path = os.path.join(images_dir, "stitched_from_stage.jpg") stitch_path = os.path.join(images_dir, "preview.jpg")
if not os.path.isfile(stitch_path): if not os.path.isfile(stitch_path):
raise FileNotFoundError("Latest scan has no preview stitch") raise FileNotFoundError("Latest scan has no preview stitch")
return stitch_path return stitch_path
@ -980,7 +987,7 @@ class SmartScanThing(Thing):
[ [
STITCHING_CMD, STITCHING_CMD,
"--stitching_mode", "--stitching_mode",
"only_stage_stitch", "preview_stitch",
"--minimum_overlap", "--minimum_overlap",
f"{min_overlap}", f"{min_overlap}",
self._ongoing_scan_images_dir, self._ongoing_scan_images_dir,
@ -1096,6 +1103,7 @@ class SmartScanThing(Thing):
"--stitching_mode", "--stitching_mode",
"all", "all",
f"{tiff_arg}", f"{tiff_arg}",
"--stitch_dzi",
"--minimum_overlap", "--minimum_overlap",
f"{round(overlap * 0.9, 2)}", f"{round(overlap * 0.9, 2)}",
images_folder, images_folder,
@ -1158,6 +1166,7 @@ class SmartScanThing(Thing):
"stitched_from", "stitched_from",
"stitched.om", "stitched.om",
"stitching_correlations", "stitching_correlations",
"preview.jp",
] ]
with zipfile.ZipFile(zip_fname, mode="a") as scan_zip: with zipfile.ZipFile(zip_fname, mode="a") as scan_zip:

View file

@ -13,6 +13,7 @@
"material-design-icons": "^3.0", "material-design-icons": "^3.0",
"material-symbols": "^0.14.3", "material-symbols": "^0.14.3",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"openseadragon": "^5.0.0",
"vue-observe-visibility": "^0.4" "vue-observe-visibility": "^0.4"
}, },
"devDependencies": { "devDependencies": {
@ -10539,6 +10540,14 @@
"opener": "bin/opener-bin.js" "opener": "bin/opener-bin.js"
} }
}, },
"node_modules/openseadragon": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/openseadragon/-/openseadragon-5.0.1.tgz",
"integrity": "sha512-a/hjouW9i3UfWxRADVYN2MyRhXMGnE7x9VVL7/4jXCcDLFyO4UM5o4RStYtqa5BfaHw/wMNAaD2WbxQF8f1pJg==",
"funding": {
"url": "https://opencollective.com/openseadragon"
}
},
"node_modules/opn": { "node_modules/opn": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz", "resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz",

View file

@ -17,6 +17,7 @@
"material-design-icons": "^3.0", "material-design-icons": "^3.0",
"material-symbols": "^0.14.3", "material-symbols": "^0.14.3",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"openseadragon": "^5.0.0",
"vue-observe-visibility": "^0.4" "vue-observe-visibility": "^0.4"
}, },
"devDependencies": { "devDependencies": {

View file

@ -0,0 +1,96 @@
<template>
<div v-observe-visibility="visibilityChanged">
<div id="openseadragon"></div>
</div>
</template>
<script>
import OpenSeaDragon from "openseadragon";
// Export main app
export default {
name: "OpenSeadragonViewer",
components: { },
props: {
src: {
type: String,
required: true
}
},
data: function() {
return {
osdViewer: null
};
},
watch: {
src: {
immediate: true,
handler(newVal) {
this.loadOpenSeaDragon(newVal);
}
}
},
async mounted() {
if (this.src) {
this.loadOpenSeaDragon(this.src);
}
},
beforeDestroy() {
// Remove global signal listener to perform a gallery refresh
this.osdViewer.destroy();
},
methods: {
visibilityChanged(isVisible) {
if (isVisible) {
this.loadOpenSeaDragon();
}else{
this.osdViewer.destroy();
}
},
async loadOpenSeaDragon() {
if (this.osdViewer) {
this.osdViewer.destroy();
}
this.osdViewer = OpenSeaDragon({
id: "openseadragon",
crossOriginPolicy: 'Anonymous',
tileSources: this.src,
showNavigationControl: false,
maxZoomPixelRatio: 2,
gestureSettingsMouse: {
clickToZoom: false
}
});
},
openFullscreen() {
if (this.osdViewer) {
this.osdViewer.setFullScreen(true);
}
},
}
};
</script>
<style lang="less" scoped>
#openseadragon {
width: 100%;
height: 100%;
background-color: black;
z-index:1;
}
#info-panel {
position: relative;
top: 10px;
left: 10px;
background-color: rgba(255, 255, 255, 0.7);
padding: 5px;
border-radius: 1px;
z-index: 1000;
}
</style>

View file

@ -1,8 +1,7 @@
<template> <template>
<div <div
v-observe-visibility="visibilityChanged" v-observe-visibility="visibilityChanged"
class="galleryDisplay uk-padding uk-padding-remove-top" class="galleryDisplay uk-padding uk-padding-remove-top">
>
<!-- Gallery nav bar --> <!-- Gallery nav bar -->
<nav <nav
class="gallery-navbar uk-navbar-container uk-navbar-transparent" class="gallery-navbar uk-navbar-container uk-navbar-transparent"
@ -35,6 +34,26 @@
</div> </div>
</nav> </nav>
<!-- Modal for scan display -->
<div id="scan-modal" ref="scanModal" style="padding: 10px;" uk-modal>
<div class="uk-modal-dialog uk-modal-body " v-if="selectedScan" style="padding: 10px; width: 95%; height: 95%;">
<h2 class="uk-modal-title">
{{ selectedScan.name }}
<button class="uk-modal-close uk-float-right" type="button"><span class="material-symbols-outlined">close</span></button>
<button class="uk-float-right" type="button" @click="goFullscreen">
<span class="material-symbols-outlined">fullscreen</span>
</button>
</h2>
<div v-if="selectedScanDZIAvailable" id="viewer_container" style="height: 80%;">
<OpenSeadragonViewer
id="openseadragon"
ref="openseadragon"
:src="selectedScanDZI"
/>
</div>
</div>
</div>
<!-- Gallery --> <!-- Gallery -->
<div <div
v-if="$store.getters.ready" v-if="$store.getters.ready"
@ -61,11 +80,13 @@
class="thumbnail-fit" class="thumbnail-fit"
:src="thumbnailPath(item.name)" :src="thumbnailPath(item.name)"
onerror="this.src='/titleiconpink.svg';" onerror="this.src='/titleiconpink.svg';"
@click="showScan(item)"
/> />
</div> </div>
</div> </div>
<h3 class="uk-card-title" style="text-align: center;">{{ item.name }}</h3> <h3 class="uk-card-title" style="text-align: center;">{{ item.name }}</h3>
<action-button <div class="button-container">
<action-button
thing="smart_scan" thing="smart_scan"
action="download_zip" action="download_zip"
submit-label="Download ZIP" submit-label="Download ZIP"
@ -85,40 +106,54 @@
submit-label="Stitch Images" submit-label="Stitch Images"
thing="smart_scan" thing="smart_scan"
action="stitch_scan" action="stitch_scan"
v-if="item.can_stitch" v-if="item.can_stitch | !item.dzi"
:can-terminate="false" :can-terminate="false"
:submit-data="{ scan_name: item.name }" :submit-data="{ scan_name: item.name }"
:button-primary="false" :button-primary="false"
:modal-progress="true" :modal-progress="true"
@error="modalError" @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> <ul>
<li>{{ item.number_of_images }} images</li> <li>{{ item.number_of_images }} images</li>
<li>created: {{ formatDate(item.created) }}</li> <li>created: {{ formatDate(item.created) }}</li>
<li>modified: {{ formatDate(item.modified) }}</li> <li>modified: {{ formatDate(item.modified) }}</li>
<li v-if="item.number_of_images<3" style="color:red; font-weight: bold;">Not enough images to stitch</li>
<li v-else-if=!item.stitch_available style="color:red; font-weight: bold;">Scan not stitched</li>
</ul> </ul>
</div> <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>
</div>
</template> </template>
<script> <script>
import axios from "axios"; import axios from "axios";
import UIkit from "uikit";
import actionButton from "../labThingsComponents/actionButton.vue"; import actionButton from "../labThingsComponents/actionButton.vue";
import OpenSeadragonViewer from "./scanListComponents/openSeadragonViewer.vue";
// Export main app // Export main app
export default { export default {
name: "ScanListContent", name: "ScanListContent",
components: { actionButton }, components: { actionButton, OpenSeadragonViewer },
data: function() { data: function() {
return { return {
scans: [] scans: [],
selectedScan: null,
osdViewer: null
}; };
}, },
@ -133,6 +168,16 @@ export default {
}, },
scansEmpty() { scansEmpty() {
return this.scans.length == 0; return this.scans.length == 0;
},
selectedScanDZI() {
if (this.selectedScan && this.dzi!="") {
return `${this.$store.getters.baseUri}/scans/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
} else {
return null;
}
},
selectedScanDZIAvailable() {
return this.selectedScan && this.selectedScan.dzi;
} }
}, },
@ -185,6 +230,9 @@ export default {
this.updateScans(); this.updateScans();
} }
}, },
goFullscreen() {
this.$refs.openseadragon.openFullscreen();
},
async updateScans() { async updateScans() {
try { try {
let scans = await this.readThingProperty("smart_scan", "scans"); let scans = await this.readThingProperty("smart_scan", "scans");
@ -256,7 +304,16 @@ export default {
console.log(link); console.log(link);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
} },
showScan(scan) {
if (scan.dzi){
this.selectedScan = scan;
UIkit.modal(this.$refs.scanModal).show();
}
else {
this.modalError(`Scan not stitched for viewing in webapp, please download or stitch`)
}
},
} }
}; };
</script> </script>
@ -285,6 +342,20 @@ export default {
margin-bottom: 20px; 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 { /deep/ .capture-card {
width: 300px; width: 300px;
height: 100%; // Used to have all cards in a row match their heights height: 100%; // Used to have all cards in a row match their heights
@ -296,5 +367,18 @@ ul {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
list-style-type:none; 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>