Start showing images in gallery (requires tidy)

This commit is contained in:
Julian Stirling 2026-06-04 17:30:28 +01:00
parent 1e12900506
commit 5a07721fc2
6 changed files with 123 additions and 29 deletions

View file

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

View file

@ -20,7 +20,7 @@ from typing import Any, Literal, Mapping, Optional, Self
import numpy as np import numpy as np
import piexif import piexif
from fastapi import Response from fastapi import HTTPException, Response
from PIL import Image from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
@ -199,6 +199,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 +285,65 @@ 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 get_data_for_gallery(self) -> list[CaptureInfo]:
"""Return all the information about the saved captures."""
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 (
BASE_IMAGE_FORMATS["jpeg"].path_matches(filename)
or BASE_IMAGE_FORMATS["png"].path_matches(filename)
):
captures.append(
CaptureInfo(
name=filename,
created=os.path.getctime(full_path),
modified=os.path.getmtime(full_path),
thing=self.name,
)
)
return captures
@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
"""
full_path = os.path.join(self.data_dir, name)
if not os.path.exists(full_path):
self.logger.warning(f"Cannot find a capture of name {name}")
raise HTTPException(400, "Capture not found")
try:
os.remove(full_path)
except IOError as e:
raise HTTPException(
400, "Couldn't delete capture, check log for details"
) from e
@property @property
def focus_fom(self) -> int: def focus_fom(self) -> int:
"""Return the focus figure of merit. """Return the focus figure of merit.

View file

@ -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,14 @@ export default {
return `${this.baseUri}/smart_scan/get_stitch/${this.itemData.name}`; return `${this.baseUri}/smart_scan/get_stitch/${this.itemData.name}`;
}, },
thumbnailPath() { thumbnailPath() {
return `${this.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.itemData.name}&modified=${this.itemData.modified}`; 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}`;
},
viewerAvailable() {
if (this.itemData.card_type === "Scan") return Boolean(this.itemData?.dzi);
return true;
}, },
}, },
@ -144,7 +151,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}?`);
await axios.delete(`${this.baseUri}/smart_scan/scans/${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.$emit("update-requested");
this.modalNotify(`Deleted ${this.itemData.name}`); this.modalNotify(`Deleted ${this.itemData.name}`);
} catch (e) { } catch (e) {

View file

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

View file

@ -1,8 +1,8 @@
<template> <template>
<div id="scan-modal" ref="scanModal" uk-modal> <div id="scan-modal" ref="scanModal" uk-modal>
<div v-if="selectedScan" id="scan-modal-body" class="uk-modal-dialog uk-modal-body"> <div v-if="selectedItem" id="scan-modal-body" class="uk-modal-dialog uk-modal-body">
<h2 id="scan-modal-title" class="uk-modal-title"> <h2 id="scan-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
@ -63,7 +63,7 @@ export default {
OpenSeadragonViewer, OpenSeadragonViewer,
}, },
props: { props: {
selectedScan: { selectedItem: {
type: Object, type: Object,
default: null, default: null,
}, },
@ -79,9 +79,14 @@ 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;
}, },

View file

@ -41,7 +41,7 @@
</div> </div>
</nav> </nav>
<ScanViewerModal ref="scanViewer" :selected-scan="selectedScan" :base-uri="baseUri" /> <ScanViewerModal ref="scanViewer" :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 +54,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>
@ -95,7 +95,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,
@ -112,9 +112,9 @@ export default {
noItems() { noItems() {
return !this.all_items || this.all_items?.length === 0; return !this.all_items || this.all_items?.length === 0;
}, },
selectedScanDZI() { selectedItemDZI() {
if (this.selectedScan && this.selectedScan.dzi != "") { if (this.selectedItem && 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 { } else {
return null; return null;
} }
@ -209,12 +209,17 @@ export default {
if (e) this.modalError(e); if (e) this.modalError(e);
} }
}, },
showScan(scan) { showItem(itemData) {
if (scan.dzi) { if (itemData.card_type === "Scan") {
this.selectedScan = scan; if (itemData.dzi) {
this.$refs.scanViewer.show(); this.selectedItem = itemData;
this.$refs.scanViewer.show();
} else {
this.modalError("Scan not stitched for viewing in webapp, please download or stitch");
}
} else { } else {
this.modalError("Scan not stitched for viewing in webapp, please download or stitch"); this.selectedItem = itemData;
this.$refs.scanViewer.show();
} }
}, },
changePage(page) { changePage(page) {