diff --git a/sample_figures/zarr_collection.json b/sample_figures/zarr_collection.json new file mode 100644 index 00000000..64f1dd38 --- /dev/null +++ b/sample_figures/zarr_collection.json @@ -0,0 +1,99 @@ +// we want to convert exisiting figure.json to ome-zarr collection format +// the figure json will have "panels" and "figureName" keys +{ + "ome": { + "version": "0.x", + "type": "collection", + // the "name" of the collection is taken from the figure's "figureName" key + "name": "My figure", + // each panel in the figure will be represented as a node in this collection + "nodes": [ + { + // the panel["zarr"] json contains info we use to create the multiscale nodes... + // if panel["zarr"]["version"] is NOT "0.5" then we IGNORE this panel (only zarr v3 supported) + "type": "multiscale", + // the panel 'imageId' is the zarr URL. We get the name from the ...url/image_name.zarr/ path segment + // BUT the name MUST be unique within the collection, even if the same image is used in multiple panels + // So we add a unique identifier to the name, e.g., "my_image_name_1" + "name": "my_image_name_1", + // panel["zarr"]["multiscales"][0]["datasets"] list maps to "nodes" + "nodes": [ + { + // each node has name "s0", "s1", etc. corresponding to the dataset it represents + "name": "s0", + "type": "singlescale", + "path": { + "type": "zarr", + // Path is the URL, joined to the dataset "path". e.g. if "path": "0", the full path is "https://example.com/image.ome.zarr/0" + "path": "https://example.com/image.ome.zarr/0" + }, + "attributes": { + // each dataset["coordinateTransformations"] maps to "coordinateTransformations" in the node's attributes + // with addition of "input" with "id" of the dataset name and "output" with "id" of the coordinate system + "coordinateTransformations": [ + { + "type": "scale", + "scale": [1, 1, 1], + "input": {"id": "s0"}, + "output": {"id": "my_image_name_1_physical"} + } + ] + } + }, + { + "name": "s1", + "type": "singlescale", + "path": { + "type": "zarr", + "path": "https://example.com/image.ome.zarr/s1" + }, + "attributes": { + "coordinateTransformations": [ + { + "type": "scale", + "scale": [1, 2, 2], + "input": {"id": "s1"}, + "output": {"id": "my_image_name_1_physical"} + } + ] + } + } + ], + "attributes": { + "coordinateSystems": [ + { + // id MUST be unique within the collection. Let's use the "node_name_physical" convention + "id": "my_image_name_1_physical", + // the "axes" are defined in panel["zarr"]["multiscales"][0]["axes"] + "axes": [ + { + "name": "x", + "unit": "micrometer" + }, + { + "name": "y", + "unit": "micrometer" + }, + { + "name": "z", + "unit": "micrometer" + } + ] + } + ], + "figure:panel": { + // here we dump all the other panel attributes apart from "zarr" + + } + } + } + ], + // ALL top-level attributes for the collection can go here. Everything except the "panels" key should be included here. + "attributes": { + "figure:figure": { + "figureName": "my_figure_name" + // etc... + } + } + } +} diff --git a/sample_figures/zarr_scene.json b/sample_figures/zarr_scene.json new file mode 100644 index 00000000..a730c02a --- /dev/null +++ b/sample_figures/zarr_scene.json @@ -0,0 +1,65 @@ + +{ + "ome": { + "version": "0.x", + "type": "collection", + "name": "My figure", + + "attributes": { + "scene": { + "coordinateSystems": [ + { + "id": "world", + "axes": [...] + } + ], + "coordinateTransformations": [ + { + "type": "translation", + "translation": [0, 0, 100], + "input": { + "path": { + "type": "zarr", + "path": "./tile_0.zarr" + }, + "id": "physical" + }, // references coordinate system "physical" defined in tile_0 + "output": {"id": "world"} // references coordinate system "world" defined in same node + }, + { + "type": "translation", + "translation": [100, 0, 0], + "input": { + "path": { + "type": "zarr", + "path": "./tile_1.zarr" + }, + "id": "physical" + }, // references coordinate system "physical" defined in tile_1 + "output": {"id": "world"} // references coordinate system "world" defined in same node + } + ] + } + }, + "nodes": [ + { + "type": "multiscale", + "id": "tile_0", + "name": "Tile 0", + "path": { + "type": "zarr", + "path": "./tile_0.zarr" + } + }, + { + "type": "multiscale", + "id": "tile_1", + "name": "Tile 1", + "path": { + "type": "zarr", + "path": "./tile_1.zarr" + } + } + ] + } +} diff --git a/src/html/dialogs/saveAsOmeZarrModal.html b/src/html/dialogs/saveAsOmeZarrModal.html new file mode 100644 index 00000000..de46149d --- /dev/null +++ b/src/html/dialogs/saveAsOmeZarrModal.html @@ -0,0 +1,45 @@ + diff --git a/src/index.html b/src/index.html index bcac5a46..a65174ad 100644 --- a/src/index.html +++ b/src/index.html @@ -1276,6 +1276,7 @@
Example Label
+ diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index ed517e6a..88eae202 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -11,7 +11,7 @@ getJsonWithCredentials, saveFigureToStorage, normalizeZProjectionBounds} from "../views/util"; - import { loadZarrForPanel } from "./zarr_utils"; + import { loadZarrForPanel, figure_to_ome_zarr_collection } from "./zarr_utils"; // Version of the json file we're saving. // This only needs to increment when we make breaking changes (not linked to release versions.) @@ -341,6 +341,11 @@ return json; }, + to_ome_zarr_collection: function() { + var figureJSON = this.figure_toJSON(); + return figure_to_ome_zarr_collection(figureJSON); + }, + figure_toJSON: function() { // Turn panels into json var p_json = [], @@ -415,9 +420,15 @@ if (options.figureName) { figureJSON.figureName = options.figureName; } - let fileName = figureJSON.figureName || "figure"; - let jsonText = JSON.stringify(this.figure_toJSON(), null, 2); - downloadAsFile(jsonText, "application/json", fileName + ".json"); + let fileName = (figureJSON.figureName || "figure") + ".json"; + let jsonText; + if (options.saveAsOmeZarr) { + fileName = "zarr.json"; + jsonText = JSON.stringify(this.to_ome_zarr_collection(), null, 2); + } else { + jsonText = JSON.stringify(this.figure_toJSON(), null, 2); + } + downloadAsFile(jsonText, "application/json", fileName); this.set({unsaved: false}); }, diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 520bf910..119850b2 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -274,6 +274,114 @@ function dtypeToPixelsType(dtype) { return dt; }; +// last path segment of a zarr URL, with any trailing ".zarr" extension removed +function getZarrUrlName(url) { + let u = url.endsWith("/") ? url.slice(0, -1) : url; + let name = u.split("/").pop(); + if (name.endsWith(".zarr")) { + name = name.slice(0, -".zarr".length); + } + return name; +} + +// Converts a figure.json (as returned by FigureModel.figure_toJSON()) into +// an OME-Zarr "collection" JSON document. See sample_figures/zarr_collection.json +// for the target format and mapping rules. +export function figure_to_ome_zarr_collection(figureJson) { + let nodes = []; + let nodeIndex = 0; + + (figureJson.panels || []).forEach((panel) => { + let zarrAttrs = panel.zarr; + // Only OME-Zarr v0.5 (zarr v3) multiscale metadata is supported + if (!zarrAttrs || zarrAttrs.version !== "0.5") { + return; + } + let multiscale = zarrAttrs.multiscales?.[0]; + if (!multiscale) { + return; + } + + // append an incrementing index to guarantee a unique node name, + // even if the same image URL is used in multiple panels + nodeIndex++; + let baseUrl = panel.imageId; + let nodeName = `${getZarrUrlName(baseUrl)}_${nodeIndex}`; + let coordSystemId = `${nodeName}_physical`; + + let datasetNodes = (multiscale.datasets || []).map((dataset, idx) => { + let datasetName = `s${idx}`; + let transforms = (dataset.coordinateTransformations || []).map((ct) => ({ + ...ct, + input: {id: datasetName}, + output: {id: coordSystemId}, + })); + return { + name: datasetName, + type: "singlescale", + path: { + type: "zarr", + path: `${baseUrl}/${dataset.path}`, + }, + attributes: { + coordinateTransformations: transforms, + }, + }; + }); + + let axes = (multiscale.axes || []).map((axis) => { + let a = {name: axis.name}; + if (axis.unit) { + a.unit = axis.unit; + } + return a; + }); + + // dump all other panel attributes apart from 'zarr' + let panelAttrs = {}; + Object.keys(panel).forEach((key) => { + if (key !== "zarr") { + panelAttrs[key] = panel[key]; + } + }); + + nodes.push({ + type: "multiscale", + name: nodeName, + nodes: datasetNodes, + attributes: { + coordinateSystems: [ + { + id: coordSystemId, + axes: axes, + }, + ], + "figure:panel": panelAttrs, + }, + }); + }); + + // all top-level figure attributes, apart from 'panels', go under figure:figure + let figureAttrs = {}; + Object.keys(figureJson).forEach((key) => { + if (key !== "panels") { + figureAttrs[key] = figureJson[key]; + } + }); + + return { + ome: { + version: "0.x", + type: "collection", + name: figureJson.figureName || "Untitled Figure", + nodes: nodes, + attributes: { + "figure:figure": figureAttrs, + }, + }, + }; +} + export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect, targetSize=500) { let paths = attrs.multiscales[0].datasets.map((d) => d.path); // for v0.3 each axes is a string, for v0.4+ it is an object diff --git a/src/js/views/figure_view.js b/src/js/views/figure_view.js index bf67b36d..20be3626 100644 --- a/src/js/views/figure_view.js +++ b/src/js/views/figure_view.js @@ -26,6 +26,7 @@ showModal, hideModals, hideModal, + showSaveAsOmeZarrDialog, updateRoiIds} from "./util"; const RELEASE_VERSION = import.meta.env.VITE_VERSION; @@ -596,9 +597,14 @@ options.fileId = fileId; this.model.save_to_OMERO(options); } else { - this.save_as(options); + var self = this; + showSaveAsOmeZarrDialog(function(saveAsOmeZarr) { + if (saveAsOmeZarr) { + options.saveAsOmeZarr = true; + } + self.save_as(options); + }); } - }, save_as_event: function(event) { diff --git a/src/js/views/util.js b/src/js/views/util.js index 888da315..6dfbf281 100644 --- a/src/js/views/util.js +++ b/src/js/views/util.js @@ -230,6 +230,36 @@ $(function(){ }); +var saveAsOmeZarrModal; + +// Shows the "Save as OME-Zarr?" dialog and invokes callback(true) if the user +// chooses "Yes", or callback(false) if they choose "No" or dismiss the dialog. +export function showSaveAsOmeZarrDialog(callback) { + var $modal = $("#saveAsOmeZarrModal"); + if (!saveAsOmeZarrModal) { + saveAsOmeZarrModal = new bootstrap.Modal($modal[0]); + } + + saveAsOmeZarrModal.show(); + + // default handler for 'cancel' or close - treat as "No" + $modal.one('hide.bs.modal', function() { + $(".modal-footer .btn", $modal).off('click'); + if (callback) { + callback(false); + } + }); + + $(".modal-footer .btn", $modal).one('click', function(event) { + // remove the default 'one' handler above + $modal.off('hide.bs.modal'); + var saveAsOmeZarr = $(event.target).hasClass('save_as_ome_zarr_yes'); + if (callback) { + callback(saveAsOmeZarr); + } + }); +} + export function hideModals() { // Calls hide() on all bootstrap Modal dialogs $(".modal").each(function() {