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 @@
+
+
+
+
+
+
+ Would you like to save this figure as an OME-Zarr Collection?
+ Experimental RFC-8 spec (subject to change).
+ NB: Only images in OME-Zarr v0.5 (zarr v3) are compatible.
+ Other images will not be included.
+
+
+
+
+
+
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() {