Skip to content

Commit

Permalink
Merge pull request #875 from arjunrajlaboratory/multiple-large-images
Browse files Browse the repository at this point in the history
Multiple large images
  • Loading branch information
arjunrajlab authored Jan 29, 2025
2 parents e78ef2f + e345589 commit e3b3fa2
Show file tree
Hide file tree
Showing 11 changed files with 469 additions and 15 deletions.
22 changes: 20 additions & 2 deletions devops/girder/annotation_client/annotation_client/tiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"region": "/item/{itemId}/tiles/region",
"tiles": "/item/{datasetId}/tiles",
"tilesInternal": "/item/{datasetId}/tiles/internal_metadata",
"dataset": "/folder/{datasetId}",
}


Expand All @@ -29,6 +30,8 @@ def __init__(self, apiUrl, token, datasetId):
:param str datasetId: The id of the dataset from which images are
downloaded
"""
# TODO: The naming conventions in this class are confusing.
# For instance, self.datasetId is the largeImageId, not the datasetId
self.client = girder_client.GirderClient(apiUrl=apiUrl)
self.client.setToken(token)

Expand Down Expand Up @@ -77,8 +80,20 @@ def getDataset(self, datasetId):
:return: The dataset item
:rtype: dict
"""
datasetInfo = self.client.get(
PATHS["dataset"].format(datasetId=datasetId))

items = self.client.get(PATHS["item"].format(datasetId=datasetId))
dataset = next(filter(lambda item: "largeImage" in item, items))

# Check if the key "selectedLargeImageId" exists in datasetInfo["meta"]
if "selectedLargeImageId" in datasetInfo["meta"]:
dataset = next(filter(
lambda item: item["_id"] ==
datasetInfo["meta"]["selectedLargeImageId"], items))
else:
# If there is no selected large image id, then choose the first
# large image in the dataset folder.
dataset = next(filter(lambda item: "largeImage" in item, items))
return dataset

def getTilesForDataset(self, datasetId):
Expand Down Expand Up @@ -134,7 +149,7 @@ def getRawImage(self, XY, Z=0, T=0, channel=0):
)
return response.content

def getRegion(self, datasetId=None, **kwargs):
def getRegion(self, datasetId=None, refreshImage=False, **kwargs):
"""
Get a region of the dataset as a numpy array.
Expand All @@ -151,13 +166,16 @@ def getRegion(self, datasetId=None, **kwargs):
:param str datasetId: The dataset id. None to use the value used when
instantiating the class.
:param bool refreshImage: Whether to refresh the largeImageId from the
server or use the cached version.
:return: The tiles metadata
:rtype: dict
"""
if (
datasetId is None
or datasetId == self.datasetId
or datasetId == self.dataset["folderId"]
or refreshImage is False
):
itemId = self.datasetId
else:
Expand Down
21 changes: 17 additions & 4 deletions src/components/ImageViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
<script lang="ts">
// in cosole debugging, you can access the map via
// $('.geojs-map').data('data-geojs-map')
import { Vue, Component, Watch } from "vue-property-decorator";
import { Vue, Component, Watch, Prop } from "vue-property-decorator";
import annotationStore from "@/store/annotation";
import progressStore from "@/store/progress";
import store from "@/store";
Expand Down Expand Up @@ -260,6 +260,19 @@ function isMouseStartEvent(evt: MouseEvent): boolean {
},
})
export default class ImageViewer extends Vue {
// TODO: I'm not sure if we really need this watcher, because I'm not sure this is how to redraw
// the images after a large image change.
@Prop({ type: Boolean, default: false }) readonly shouldResetMaps!: boolean;
@Watch("shouldResetMaps")
onShouldResetMaps(newValue: boolean) {
if (newValue) {
this.resetMapsOnDraw = true;
this.draw();
this.$emit("reset-complete");
}
}
readonly store = store;
readonly annotationStore = annotationStore;
readonly girderResources = girderResources;
Expand Down Expand Up @@ -531,9 +544,9 @@ export default class ImageViewer extends Vue {
}
// TODO: This currently does nothing. However, this used to be where the
// histogram cachine was reloaded based on the running jobs. We could implement
// something like that again if we want to show the progress bars for the
// various caching processes (histograms, annotations, quad frames, etc.).
// histogram cache progress was reloaded based on the running jobs. We could
// implement something like that again if we want to show the progress bars for
// the various caching processes (histograms, annotations, quad frames, etc.).
async datasetReset() {
const datasetId = this.dataset?.id;
if (!datasetId) {
Expand Down
171 changes: 171 additions & 0 deletions src/components/LargeImageDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<template>
<div style="position: relative">
<v-select
v-if="shouldShow"
v-model="currentLargeImage"
:items="formattedLargeImages"
item-text="displayName"
item-value="_id"
label="Select Image"
dense
style="width: auto; padding: 4px 0"
hide-details
>
<template v-slot:item="{ item }">
<v-list-item-content style="flex: 1 1 auto; min-width: 0">
<v-list-item-title>{{ item.displayName }}</v-list-item-title>
<v-list-item-subtitle
v-if="item.meta"
class="text--secondary"
style="font-size: 0.875rem; opacity: 0.7"
>{{ formatMeta(item.meta) }}</v-list-item-subtitle
>
</v-list-item-content>
<v-btn
v-if="item.name !== DEFAULT_LARGE_IMAGE_SOURCE"
icon
small
color="error"
class="ml-2"
:loading="deletingImageId === item._id"
@click.stop="deleteImage(item)"
>
<v-icon small>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:selection="{ item }">
<v-list-item-content
style="flex: 1 1 auto; min-width: 0; white-space: normal"
>
<v-list-item-title>{{ item.displayName }}</v-list-item-title>
<v-list-item-subtitle
v-if="item.meta"
class="text--secondary"
style="white-space: normal; font-size: 0.875rem; opacity: 0.7"
>{{ formatMeta(item.meta) }}</v-list-item-subtitle
>
</v-list-item-content>
</template>
</v-select>

<v-fade-transition>
<v-overlay
v-if="showNewImageIndicator"
absolute
opacity="0.2"
color="success"
>
</v-overlay>
</v-fade-transition>
</div>
</template>

<script lang="ts">
import { Vue, Component, Watch } from "vue-property-decorator";
import store from "@/store";
import { IGirderLargeImage } from "@/girder";
import { DEFAULT_LARGE_IMAGE_SOURCE } from "@/girder/index";
import { logError } from "@/utils/log";
@Component
export default class LargeImageDropdown extends Vue {
readonly store = store;
readonly DEFAULT_LARGE_IMAGE_SOURCE = DEFAULT_LARGE_IMAGE_SOURCE;
deletingImageId: string | null = null;
previousNumberOfImages = 0;
showNewImageIndicator = false;
mounted() {
this.previousNumberOfImages = this.numberOfLargeImages;
}
get largeImages() {
return this.store.allLargeImages;
}
get numberOfLargeImages() {
return this.largeImages.length;
}
@Watch("numberOfLargeImages")
onNumberOfLargeImagesChange(newValue: number) {
if (
newValue > this.previousNumberOfImages &&
this.previousNumberOfImages > 0
) {
this.showNewImageIndicator = true;
setTimeout(() => {
this.showNewImageIndicator = false;
}, 1500); // Hide after 1.5 seconds
}
this.previousNumberOfImages = newValue;
}
get formattedLargeImages() {
return this.largeImages.map((img: IGirderLargeImage) => ({
...img,
displayName: this.formatName(img.name),
}));
}
formatName(name: string): string {
if (name === DEFAULT_LARGE_IMAGE_SOURCE) {
return "Original image";
}
// Handle cases like "output.tiff (1)" -> "output (1)"
const baseName = name.replace(/^(.+)\.[^.\s(]+(.*)$/, "$1$2");
return baseName;
}
formatMeta(meta: Record<string, any>): string {
const pairs: string[] = [];
// Add tool first if it exists
if (meta.tool) {
pairs.push(`tool: ${meta.tool}`);
}
// Add remaining keys in alphabetical order
Object.entries(meta)
.filter(([key]) => key !== "tool")
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([key, value]) => {
pairs.push(`${key}: ${value}`);
});
return pairs.join("; ");
}
async deleteImage(image: IGirderLargeImage) {
this.deletingImageId = image._id;
try {
await this.store.deleteLargeImage(image);
} finally {
this.deletingImageId = null;
}
}
get currentLargeImage() {
return this.store.currentLargeImage?._id || null;
}
set currentLargeImage(imageId: string | null) {
if (imageId) {
// Find the full image object from the ID
const image = this.largeImages.find(
(img: IGirderLargeImage) => img._id === imageId,
);
if (image) {
this.store.updateCurrentLargeImage(image);
} else {
logError("LargeImageDropdown", "Current large image not found");
}
}
}
get shouldShow(): boolean {
return this.largeImages.length > 1;
}
}
</script>
6 changes: 5 additions & 1 deletion src/components/ViewerToolbar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div style="overflow-y: auto; scrollbar-width: none">
<div style="display: inline-block">
<div v-mousetrap="mousetrapSliders" id="viewer-toolbar-tourstep">
<v-layout>
<value-slider
Expand Down Expand Up @@ -67,6 +67,8 @@
:title="'Track window size'"
/>
</v-layout>
<!-- TODO: Only display if there is more than one large image -->
<large-image-dropdown />
<v-layout v-if="timelapseMode">
<tag-picker
id="timelapse-tags-tourstep"
Expand Down Expand Up @@ -141,6 +143,7 @@ import { Vue, Component, Watch } from "vue-property-decorator";
import ValueSlider from "./ValueSlider.vue";
import SwitchToggle from "./SwitchToggle.vue";
import Toolset from "@/tools/toolsets/Toolset.vue";
import LargeImageDropdown from "./LargeImageDropdown.vue";
import store from "@/store";
import filterStore from "@/store/filters";
import annotationStore from "@/store/annotation";
Expand All @@ -152,6 +155,7 @@ import { IHotkey } from "@/utils/v-mousetrap";
ValueSlider,
SwitchToggle,
Toolset,
LargeImageDropdown,
},
})
export default class ViewerToolbar extends Vue {
Expand Down
15 changes: 15 additions & 0 deletions src/girder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ export interface IGirderFile extends IGirderBase {
_modelType: "file";
}

// TODO: This type is essentially a wrapper around the IGirderItem type for now.
// It is defined in case we want to add more properties to the largeImage object in the future.
export interface IGirderLargeImage extends IGirderItem {
largeImage: {
fileId: string;
[key: string]: any;
};
}

// For whatever reason, the default large image source was named "multi-source2.json"
// This constant is used to identify the default large image source throughout the interface.
// See, for instance, the LargeImageDropdown.vue component, in which it is used to determine
// which large image is the "original" large image.
export const DEFAULT_LARGE_IMAGE_SOURCE = "multi-source2.json";

export type IGirderLocation =
| IGirderUser
| IGirderFolder
Expand Down
28 changes: 28 additions & 0 deletions src/store/GirderAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IGirderUser,
IGirderFile,
IGirderAssetstore,
IGirderLargeImage,
} from "@/girder";
import {
configurationBaseKeys,
Expand Down Expand Up @@ -330,6 +331,10 @@ export default class GirderAPI {
);
}

deleteLargeImage(largeImage: IGirderLargeImage) {
return this.client.delete(`/item/${largeImage._id}`);
}

createDatasetView(datasetViewBase: IDatasetViewBase) {
return this.client
.post("dataset_view", datasetViewBase)
Expand Down Expand Up @@ -418,6 +423,7 @@ export default class GirderAPI {
"metadata",
JSON.stringify({
subtype: "contrastDataset",
selectedLargeImageId: null,
}),
);
return this.client.post("folder", data).then((r) => asDataset(r.data));
Expand All @@ -431,6 +437,28 @@ export default class GirderAPI {
.then((r) => asDataset(r.data));
}

async updateDatasetMetadata(
datasetId: string,
metadata: Record<string, any>,
) {
// First get existing metadata
const response = await this.client.get(`folder/${datasetId}`);
const existingMetadata = response.data.meta || {};

// Merge existing metadata with new metadata
const updatedMetadata = {
...existingMetadata,
...metadata,
};

const data = new FormData();
data.set("id", datasetId);
data.set("metadata", JSON.stringify(updatedMetadata));

// Update the dataset metadata
return this.client.put(`folder/${datasetId}/metadata`, data);
}

deleteDataset(dataset: IDataset): Promise<IDataset> {
return this.client.delete(`/folder/${dataset.id}`).then(() => dataset);
}
Expand Down
Loading

0 comments on commit e3b3fa2

Please sign in to comment.