From 44e0e5dc04a31186a9d5c61d8ea0125971720a8e Mon Sep 17 00:00:00 2001 From: William Moore Date: Mon, 2 Dec 2024 16:28:56 +0000 Subject: [PATCH 01/42] First step - mostly add a zarr image --- src/js/models/figure_model.js | 105 ++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 59b73c31a..cc62a11ab 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -518,6 +518,11 @@ // new image panels appropriately in a grid. var invalidIds = []; for (var i=0; i rsp.json()); + let zarrName = zarrUrl.split("/").pop(); + let multiscales = zattrs?.multiscales; + console.log("zarr zattrs", zattrs); + if (!multiscales) { + alert(`Image loading from ${imgDataUrl} included an Error: ${message}`); + return; + } + + // if we got multiscales, load first dataset... + // TODO: handle bioformats2raw.layout + let dsPath = multiscales[0]?.datasets[0].path; + let imgName = multiscales[0].name || zarrName; + let axes = multiscales[0].axes; + let axesNames = axes.map(axis => axis.name); + + let zarray = await fetch(`${zarrUrl}/${dsPath}/.zarray`).then(rsp => rsp.json()); + console.log("zarray", zarray); + let shape = zarray.shape; + let dims = shape.length; + let sizeX = shape[dims - 1]; + let sizeY = shape[dims - 2]; + let sizeZ = 1; + let sizeT = 1; + if (axesNames.includes('z')) { + sizeZ = shape[axesNames.indexOf('z')] + } + let defaultZ = parseInt(sizeZ / 2); + if (axesNames.includes('t')) { + sizeT = shape[axesNames.indexOf('t')] + } + let defaultT = parseInt(sizeT / 2); + self.set('loading_count', self.get('loading_count') - 1); + + // channels... + // TODO: if no omero data, need to construct channels! + let channels = zattrs.omero?.channels || []; + + coords.spacer = coords.spacer || sizeX/20; + var full_width = (coords.colCount * (sizeX + coords.spacer)) - coords.spacer, + full_height = (coords.rowCount * (sizeY + coords.spacer)) - coords.spacer; + coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); + coords.scale = Math.min(coords.scale, 1); // only scale down + // For the FIRST IMAGE ONLY (coords.px etc undefined), we + // need to work out where to start (px,py) now that we know size of panel + // (assume all panels are same size) + coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; + coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; + + // calculate panel coordinates from index... + var row = parseInt(index / coords.colCount, 10); + var col = index % coords.colCount; + var panelX = coords.px + ((sizeX + coords.spacer) * coords.scale * col); + var panelY = coords.py + ((sizeY + coords.spacer) * coords.scale * row); + + // ****** This is the Data Model ****** + //------------------------------------- + // Any changes here will create a new version + // of the model and will also have to be applied + // to the 'version_transform()' function so that + // older files can be brought up to date. + // Also check 'previewSetId()' for changes. + var n = { + 'imageId': zarrUrl, + 'name': imgName, + 'width': sizeX * coords.scale, + 'height': sizeY * coords.scale, + 'sizeZ': sizeZ, + 'theZ': defaultZ, + 'sizeT': sizeT, + 'theT': defaultT, + 'rdefs': {'model': "-"}, + 'channels': channels, + 'orig_width': sizeX, + 'orig_height': sizeY, + 'x': panelX, + 'y': panelY, + // 'datasetName': data.meta.datasetName, + // 'datasetId': data.meta.datasetId, + // 'pixel_size_x': data.pixel_size.valueX, + // 'pixel_size_y': data.pixel_size.valueY, + // 'pixel_size_z': data.pixel_size.valueZ, + // 'pixel_size_x_symbol': data.pixel_size.symbolX, + // 'pixel_size_z_symbol': data.pixel_size.symbolZ, + // 'pixel_size_x_unit': data.pixel_size.unitX, + // 'pixel_size_z_unit': data.pixel_size.unitZ, + // 'deltaT': data.deltaT, + // 'pixelsType': data.meta.pixelsType, + // 'pixel_range': data.pixel_range, + }; + // create Panel (and select it) + // We do some additional processing in Panel.parse() + self.panels.create(n, {'parse': true}).set('selected', true); + self.notifySelectionChange(); + }, + importImage: function(imgDataUrl, coords, baseUrl, index) { var self = this, From 7ca2f61740dc583976bceb0f390dc7c1c92540de Mon Sep 17 00:00:00 2001 From: William Moore Date: Tue, 3 Dec 2024 17:00:39 +0000 Subject: [PATCH 02/42] Load all .zarray metadata. Convert pixelType --- src/js/models/figure_model.js | 39 +++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index cc62a11ab..f0bfbfb9b 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -558,8 +558,41 @@ let axes = multiscales[0].axes; let axesNames = axes.map(axis => axis.name); - let zarray = await fetch(`${zarrUrl}/${dsPath}/.zarray`).then(rsp => rsp.json()); + let datasets = multiscales[0].datasets; + let zarrays = []; + // 'consolidate' the metadata for all arrays + for (let ds of datasets) { + let zarray = await fetch(`${zarrUrl}/${ds.path}/.zarray`).then(rsp => rsp.json()); + zarrays.push(zarray); + } + // store under 'arrays' key + zattrs['arrays'] = zarrays; + + let zarray = zarrays[0]; console.log("zarray", zarray); + + // e.g. " "uint8" + let dtypeToPixelsType = (dtype) => { + let dt = ""; + if (dtype.includes("u")) { + dt += "uint"; + } else if (dtype.includes("i")) { + dt += "int"; + } else if (dtype.includes("f")) { + dt += "float"; + } + if (dtype.includes("8")) { + dt += "64"; + } else if (dtype.includes("4")) { + dt += "32"; + } else if (dtype.includes("2")) { + dt += "16"; + } else if (dtype.includes("1")) { + dt += "8"; + } + return dt; + } + let dtype = zarray.dtype; let shape = zarray.shape; let dims = shape.length; let sizeX = shape[dims - 1]; @@ -629,8 +662,10 @@ // 'pixel_size_x_unit': data.pixel_size.unitX, // 'pixel_size_z_unit': data.pixel_size.unitZ, // 'deltaT': data.deltaT, - // 'pixelsType': data.meta.pixelsType, + 'pixelsType': dtypeToPixelsType(dtype), // 'pixel_range': data.pixel_range, + // let's dump the zarr data into the panel + 'zarr': zattrs, }; // create Panel (and select it) // We do some additional processing in Panel.parse() From 746bcd3f0e677630bf045695b9c6179fd55101c4 Mon Sep 17 00:00:00 2001 From: William Moore Date: Tue, 3 Dec 2024 17:04:34 +0000 Subject: [PATCH 03/42] npm install zarrita@next --- package-lock.json | 81 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc102cb26..c95016f8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", - "vite-plugin-html-inject": "^1.1.2" + "vite-plugin-html-inject": "^1.1.2", + "zarrita": "^0.4.0-next.17" }, "devDependencies": { "sass": "^1.54.1", @@ -756,6 +757,40 @@ "license": "MIT", "optional": true }, + "node_modules/@zarrita/core": { + "version": "0.1.0-next.15", + "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.1.0-next.15.tgz", + "integrity": "sha512-ObMFklHfKMGah4juLo3mz2HOGkAK6jLmWv0QpWG6Qp4SU6+juqXms57ULAD6eE00NBCC+u8wq//NqQrk8ozuhQ==", + "dependencies": { + "@zarrita/storage": "^0.1.0-next.7", + "@zarrita/typedarray": "^0.1.0-next.3", + "numcodecs": "^0.3.2" + } + }, + "node_modules/@zarrita/indexing": { + "version": "0.1.0-next.17", + "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.1.0-next.17.tgz", + "integrity": "sha512-ix222Rz23zApfdIfpa5ovalaiVXg74hoBo6bFmL8sLiNRQ2blWD1GwkEZbQsBlknWz3g0I5M0PTg1DSZBRWI6g==", + "dependencies": { + "@zarrita/core": "^0.1.0-next.15", + "@zarrita/storage": "^0.1.0-next.7", + "@zarrita/typedarray": "^0.1.0-next.3" + } + }, + "node_modules/@zarrita/storage": { + "version": "0.1.0-next.7", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.0-next.7.tgz", + "integrity": "sha512-rxaY161KiD+SqsNZUV6a7WdcxDg7q1O0ggvKa37IvOLjv+9lVJNU6DFuvSK9/mmjO0eRml6ysmwXqAZTC+Kymg==", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "^1.4.3" + } + }, + "node_modules/@zarrita/typedarray": { + "version": "0.1.0-next.3", + "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.1.0-next.3.tgz", + "integrity": "sha512-DpSaU3Cr6HmYDC/v8oM+e219cHU/kzKma309Z9E+QbpRnZycKNbSTKcxFR7FqB6HgB9640gzNUVFG5P+wzX5Xg==" + }, "node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -914,6 +949,11 @@ "resolved": "https://registry.npmjs.org/eve-raphael/-/eve-raphael-0.5.0.tgz", "integrity": "sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug==" }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1058,6 +1098,14 @@ "node": ">=0.10.0" } }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "dependencies": { + "fflate": "^0.8.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1126,6 +1174,11 @@ "node": ">=8.10.0" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==" + }, "node_modules/rollup": { "version": "4.39.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz", @@ -1262,6 +1315,22 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.4.tgz", "integrity": "sha512-BQFnUDuAQ4Yf/cYY5LNrK9NCJFKriaRbD9uR1fTeXnBeoa97W0i41qkZfGO9pSo8I5KzjAcSY2XYtdf0oKd7KQ==" }, + "node_modules/unzipit": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", + "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "dependencies": { + "uzip-module": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/uzip-module": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", + "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -1387,6 +1456,16 @@ "bin": { "watch": "cli.js" } + }, + "node_modules/zarrita": { + "version": "0.4.0-next.17", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.4.0-next.17.tgz", + "integrity": "sha512-5vN+B+IY4GSh2JN9tqFNPJtAP20Q+eVPokDuG/TwDKn33bS3g8wyW5hENFfWLzxAEmSWvndWy+EU1xJ1Dlt5AA==", + "dependencies": { + "@zarrita/core": "^0.1.0-next.15", + "@zarrita/indexing": "^0.1.0-next.17", + "@zarrita/storage": "^0.1.0-next.7" + } } } } diff --git a/package.json b/package.json index 8bc6dea97..416992896 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", - "vite-plugin-html-inject": "^1.1.2" + "vite-plugin-html-inject": "^1.1.2", + "zarrita": "^0.4.0-next.17" } } From fc9fe8a79447e2032f52677c84fb7feb1a12bc74 Mon Sep 17 00:00:00 2001 From: William Moore Date: Wed, 4 Dec 2024 12:17:17 +0000 Subject: [PATCH 04/42] Zarr rendering in panel_view.js --- package-lock.json | 48 ++++---- package.json | 2 +- src/js/models/figure_model.js | 7 +- src/js/models/panel_model.js | 11 +- src/js/models/zarr_utils.js | 205 ++++++++++++++++++++++++++++++++++ src/js/views/panel_view.js | 4 +- 6 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 src/js/models/zarr_utils.js diff --git a/package-lock.json b/package-lock.json index c95016f8b..8da7263a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "sortablejs": "^1.15.2", "underscore": "^1.13.4", "vite-plugin-html-inject": "^1.1.2", - "zarrita": "^0.4.0-next.17" + "zarrita": "^0.4.0-next.19" }, "devDependencies": { "sass": "^1.54.1", @@ -758,38 +758,38 @@ "optional": true }, "node_modules/@zarrita/core": { - "version": "0.1.0-next.15", - "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.1.0-next.15.tgz", - "integrity": "sha512-ObMFklHfKMGah4juLo3mz2HOGkAK6jLmWv0QpWG6Qp4SU6+juqXms57ULAD6eE00NBCC+u8wq//NqQrk8ozuhQ==", + "version": "0.1.0-next.17", + "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.1.0-next.17.tgz", + "integrity": "sha512-VTf1KWLz3vqX4IUdg1lJYBpHo/cT3NbpFQ47JOCEaVsmGv09So5yY7UkXPTQ6ef7oy9BFven7sD5EKSutiFn8A==", "dependencies": { - "@zarrita/storage": "^0.1.0-next.7", - "@zarrita/typedarray": "^0.1.0-next.3", + "@zarrita/storage": "^0.1.0-next.8", + "@zarrita/typedarray": "^0.1.0-next.4", "numcodecs": "^0.3.2" } }, "node_modules/@zarrita/indexing": { - "version": "0.1.0-next.17", - "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.1.0-next.17.tgz", - "integrity": "sha512-ix222Rz23zApfdIfpa5ovalaiVXg74hoBo6bFmL8sLiNRQ2blWD1GwkEZbQsBlknWz3g0I5M0PTg1DSZBRWI6g==", + "version": "0.1.0-next.19", + "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.1.0-next.19.tgz", + "integrity": "sha512-GRu6CxtEeXnZUYR0Z/sEGFPcll7pG2Ek9muUkdpl5U5fUdPW7YaSj1tMBxDFhkgw9MNy87ksnN2VO4Tgjjl5fw==", "dependencies": { - "@zarrita/core": "^0.1.0-next.15", - "@zarrita/storage": "^0.1.0-next.7", - "@zarrita/typedarray": "^0.1.0-next.3" + "@zarrita/core": "^0.1.0-next.17", + "@zarrita/storage": "^0.1.0-next.8", + "@zarrita/typedarray": "^0.1.0-next.4" } }, "node_modules/@zarrita/storage": { - "version": "0.1.0-next.7", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.0-next.7.tgz", - "integrity": "sha512-rxaY161KiD+SqsNZUV6a7WdcxDg7q1O0ggvKa37IvOLjv+9lVJNU6DFuvSK9/mmjO0eRml6ysmwXqAZTC+Kymg==", + "version": "0.1.0-next.8", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.0-next.8.tgz", + "integrity": "sha512-9d8bIaR2JuiG98gg5lF15Tpgc/+G/XOXlY53UtVP0LABwHbrIGFzpO2djzGB5bQe1jDj92VeXE8Z525Bs2vb6Q==", "dependencies": { "reference-spec-reader": "^0.2.0", "unzipit": "^1.4.3" } }, "node_modules/@zarrita/typedarray": { - "version": "0.1.0-next.3", - "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.1.0-next.3.tgz", - "integrity": "sha512-DpSaU3Cr6HmYDC/v8oM+e219cHU/kzKma309Z9E+QbpRnZycKNbSTKcxFR7FqB6HgB9640gzNUVFG5P+wzX5Xg==" + "version": "0.1.0-next.4", + "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.1.0-next.4.tgz", + "integrity": "sha512-sGqc5Ldh8nt/FE9gDA89OsL+FH37wgSxCF1Liv08O8SbZ4w3N48Wngv0EWAXvU1aSEdGhb2ABtSfErVDwnp45Q==" }, "node_modules/anymatch": { "version": "3.1.2", @@ -1458,13 +1458,13 @@ } }, "node_modules/zarrita": { - "version": "0.4.0-next.17", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.4.0-next.17.tgz", - "integrity": "sha512-5vN+B+IY4GSh2JN9tqFNPJtAP20Q+eVPokDuG/TwDKn33bS3g8wyW5hENFfWLzxAEmSWvndWy+EU1xJ1Dlt5AA==", + "version": "0.4.0-next.19", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.4.0-next.19.tgz", + "integrity": "sha512-7/O3ph+5BGnZ36Bc+DjMym2M1C/xY/klMn4V4N0FpOXFlAsUpvNqFqgYID1p8SdjJNh+aF4QFmuOoxceyz6KKA==", "dependencies": { - "@zarrita/core": "^0.1.0-next.15", - "@zarrita/indexing": "^0.1.0-next.17", - "@zarrita/storage": "^0.1.0-next.7" + "@zarrita/core": "^0.1.0-next.17", + "@zarrita/indexing": "^0.1.0-next.19", + "@zarrita/storage": "^0.1.0-next.8" } } } diff --git a/package.json b/package.json index 416992896..701b89937 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "sortablejs": "^1.15.2", "underscore": "^1.13.4", "vite-plugin-html-inject": "^1.1.2", - "zarrita": "^0.4.0-next.17" + "zarrita": "^0.4.0-next.19" } } diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index f0bfbfb9b..cf79e31b8 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -559,11 +559,12 @@ let axesNames = axes.map(axis => axis.name); let datasets = multiscales[0].datasets; - let zarrays = []; + let zarrays = {}; // 'consolidate' the metadata for all arrays for (let ds of datasets) { - let zarray = await fetch(`${zarrUrl}/${ds.path}/.zarray`).then(rsp => rsp.json()); - zarrays.push(zarray); + let path = ds.path; + let zarray = await fetch(`${zarrUrl}/${path}/.zarray`).then(rsp => rsp.json()); + zarrays[path] = zarray; } // store under 'arrays' key zattrs['arrays'] = zarrays; diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index c00cacdf0..72534c06d 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -3,7 +3,7 @@ import _ from "underscore"; import $ from "jquery"; import { rotatePoint } from "../views/util"; - + import {renderZarrToSrc} from "./zarr_utils"; // Corresponds to css - allows us to calculate size of labels var LINE_HEIGHT = 1.43; @@ -977,7 +977,14 @@ return this.get('orig_width') * this.get('orig_height') > MAX_PLANE_SIZE; }, - get_img_src: function(force_no_padding) { + get_zarr_img_src: async function() { + return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels')); + }, + + get_img_src: async function(force_no_padding) { + if (this.get("zarr")) { + return this.get_zarr_img_src(); + } var chs = this.get('channels'); var cStrings = chs.map(function(c, i){ return (c.active ? '' : '-') + (1+i) + "|" + c.window.start + ":" + c.window.end + "$" + c.color; diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js new file mode 100644 index 000000000..bb35cfc0f --- /dev/null +++ b/src/js/models/zarr_utils.js @@ -0,0 +1,205 @@ + +import * as zarr from "zarrita"; +import { slice } from "@zarrita/indexing"; + + +export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { + let paths = attrs.multiscales[0].datasets.map((d) => d.path); + let axes = attrs.multiscales[0].axes.map((a) => a.name); + let zarrays = attrs.arrays; + + // Pick first resolution that is below a max size... + const MAX_SIZE = 2000; + console.log("Zarr pick size to render..."); + let path; + for (let p of paths) { + let arrayAttrs = zarrays[p]; + console.log(path, arrayAttrs); + let shape = arrayAttrs.shape; + if (shape.at(-1) * shape.at(-2) < MAX_SIZE * MAX_SIZE) { + path = p; + break; + } + } + + if (!path) { + console.error(`Lowest resolution too large for rendering: > ${MAX_SIZE} x ${MAX_SIZE}`); + return; + } + + console.log("Init zarr.FetchStore:", source + "/" + path); + const store = new zarr.FetchStore(source + "/" + path); + + const arr = await zarr.open(store, { kind: "array" }); + + let chDim = axes.indexOf("c"); + let shape = zarrays[path].shape; + let dims = shape.length; + + let activeChIndicies = []; + let colors = []; + let minMaxValues = []; + channels.forEach((ch, index) => { + if (ch.active) { + activeChIndicies.push(index); + colors.push(hexToRGB(ch.color)); + minMaxValues.push([ch.window.start, ch.window.end]); + } + }); + console.log("activeChIndicies", activeChIndicies); + console.log("colors", colors); + console.log("minMaxValues", minMaxValues); + + let promises = activeChIndicies.map((chIndex) => { + let slices = shape.map((dimSize, index) => { + // channel + if (index == chDim) return chIndex; + // x and y + if (index >= dims - 2) { + return slice(0, dimSize); + } + // z + if (axes[index] == "z") { + return parseInt(dimSize / 2 + ""); + } + if (axes[index] == "t") { + return parseInt(dimSize / 2 + ""); + } + return 0; + }); + console.log("Zarr chIndex slices:", chIndex, slices); + console.log("Zarr chIndex shape:", chIndex, shape); + // TODO: add controller: { opts: { signal: controller.signal } } + return zarr.get(arr, slices); + }); + + let ndChunks = await Promise.all(promises); + // let minMaxValues = ndChunks.map((ch) => getMinMaxValues(ch)); + let rbgData = renderTo8bitArray(ndChunks, minMaxValues, colors); + + let width = shape.at(-1); + let height = shape.at(-2); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.putImageData(new ImageData(rbgData, width, height), 0, 0); + let dataUrl = canvas.toDataURL("image/png"); + console.log("Zarr dataUrl", dataUrl); + return dataUrl; +} + + +export function renderTo8bitArray(ndChunks, minMaxValues, colors) { + // Render chunks (array) into 2D 8-bit data for new ImageData(arr) + // ndChunks is list of zarr arrays + + // assume all chunks are same shape + const shape = ndChunks[0].shape; + const height = shape[0]; + const width = shape[1]; + const pixels = height * width; + + if (!minMaxValues) { + minMaxValues = ndChunks.map(getMinMaxValues); + } + + // let rgb = [255, 255, 255]; + + let rgba = new Uint8ClampedArray(4 * height * width).fill(0); + let offset = 0; + for (let y = 0; y < pixels; y++) { + for (let p = 0; p < ndChunks.length; p++) { + let rgb = colors[p]; + let data = ndChunks[p].data; + let range = minMaxValues[p]; + let rawValue = data[y]; + let fraction = (rawValue - range[0]) / (range[1] - range[0]); + // for red, green, blue, + for (let i = 0; i < 3; i++) { + // rgb[i] is 0-255... + let v = (fraction * rgb[i]) << 0; + // increase pixel intensity if value is higher + rgba[offset * 4 + i] = Math.max(rgba[offset * 4 + i], v); + } + } + rgba[offset * 4 + 3] = 255; // alpha + offset += 1; + } + + return rgba; +} + +export function getMinMaxValues(chunk2d) { + const data = chunk2d.data; + let maxV = 0; + let minV = Infinity; + let length = chunk2d.data.length; + for (let y = 0; y < length; y++) { + let rawValue = data[y]; + maxV = Math.max(maxV, rawValue); + minV = Math.min(minV, rawValue); + } + return [minV, maxV]; +} + +export const MAX_CHANNELS = 4; +export const COLORS = { + cyan: "#00FFFF", + yellow: "#FFFF00", + magenta: "#FF00FF", + red: "#FF0000", + green: "#00FF00", + blue: "#0000FF", + white: "#FFFFFF", +}; +export const MAGENTA_GREEN = [COLORS.magenta, COLORS.green]; +export const RGB = [COLORS.red, COLORS.green, COLORS.blue]; +export const CYMRGB = Object.values(COLORS).slice(0, -2); + +export function getDefaultVisibilities(n) { + let visibilities; + if (n <= MAX_CHANNELS) { + // Default to all on if visibilities not specified and less than 6 channels. + visibilities = Array(n).fill(true); + } else { + // If more than MAX_CHANNELS, only make first set on by default. + visibilities = [ + ...Array(MAX_CHANNELS).fill(true), + ...Array(n - MAX_CHANNELS).fill(false), + ]; + } + return visibilities; +} + +export function getDefaultColors(n, visibilities) { + let colors = []; + if (n == 1) { + colors = [COLORS.white]; + } else if (n == 2) { + colors = MAGENTA_GREEN; + } else if (n === 3) { + colors = RGB; + } else if (n <= MAX_CHANNELS) { + colors = CYMRGB.slice(0, n); + } else { + // Default color for non-visible is white + colors = Array(n).fill(COLORS.white); + // Get visible indices + const visibleIndices = visibilities.flatMap((bool, i) => (bool ? i : [])); + // Set visible indices to CYMRGB colors. visibleIndices.length === MAX_CHANNELS from above. + for (const [i, visibleIndex] of visibleIndices.entries()) { + colors[visibleIndex] = CYMRGB[i]; + } + } + return colors.map(hexToRGB); +} + +export function hexToRGB(hex) { + if (hex.startsWith("#")) hex = hex.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return [r, g, b]; +} diff --git a/src/js/views/panel_view.js b/src/js/views/panel_view.js index 1cdd4c88e..ef64894ad 100644 --- a/src/js/views/panel_view.js +++ b/src/js/views/panel_view.js @@ -201,8 +201,8 @@ this.$img_panel.show(); }.bind(this)); - var src = this.model.get_img_src(); - this.$img_panel.attr('src', src); + this.model.get_img_src() + .then(src => this.$img_panel.attr('src', src)); // if a 'reasonable' dpi is set, we don't pixelate if (this.model.get('min_export_dpi') > 100) { From 6b45fa27a63785610cbbec5e7f5b81509a592b41 Mon Sep 17 00:00:00 2001 From: William Moore Date: Wed, 4 Dec 2024 15:01:17 +0000 Subject: [PATCH 05/42] Simple caching of zarr stores and data --- src/js/models/zarr_utils.js | 44 +++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index bb35cfc0f..9e89f8107 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -2,6 +2,8 @@ import * as zarr from "zarrita"; import { slice } from "@zarrita/indexing"; +const ZARRITA_ARRAY_CACHE = {}; +const ZARR_DATA_CACHE = {}; export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { let paths = attrs.multiscales[0].datasets.map((d) => d.path); @@ -28,9 +30,15 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { } console.log("Init zarr.FetchStore:", source + "/" + path); - const store = new zarr.FetchStore(source + "/" + path); - - const arr = await zarr.open(store, { kind: "array" }); + let storeArrayPath = source + "/" + path; + let arr; + if (ZARRITA_ARRAY_CACHE[storeArrayPath]) { + arr = ZARRITA_ARRAY_CACHE[storeArrayPath]; + } else { + let store = new zarr.FetchStore(source + "/" + path); + arr = await zarr.open(store, { kind: "array" }); + ZARRITA_ARRAY_CACHE[storeArrayPath] = arr; + } let chDim = axes.indexOf("c"); let shape = zarrays[path].shape; @@ -51,26 +59,45 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { console.log("minMaxValues", minMaxValues); let promises = activeChIndicies.map((chIndex) => { + let sliceKey = []; let slices = shape.map((dimSize, index) => { // channel - if (index == chDim) return chIndex; + if (index == chDim) { + sliceKey.push("" + chIndex); + return chIndex; + } // x and y if (index >= dims - 2) { + sliceKey.push(`${0}:${dimSize}`); return slice(0, dimSize); } // z if (axes[index] == "z") { - return parseInt(dimSize / 2 + ""); + sliceKey.push("" + theZ); + return theZ; } if (axes[index] == "t") { - return parseInt(dimSize / 2 + ""); + sliceKey.push("" + theT); + return theT; } return 0; }); - console.log("Zarr chIndex slices:", chIndex, slices); + let cacheKey = `${source}/${path}/${sliceKey.join(",")}`; + console.log("cacheKey", cacheKey); + console.log("Zarr chIndex slices:", chIndex, "" + slices); console.log("Zarr chIndex shape:", chIndex, shape); // TODO: add controller: { opts: { signal: controller.signal } } - return zarr.get(arr, slices); + // check cache! + if (ZARR_DATA_CACHE[cacheKey]) { + console.log("RETURN cache!", ZARR_DATA_CACHE[cacheKey]); + return ZARR_DATA_CACHE[cacheKey]; + } + + return zarr.get(arr, slices).then(data => { + console.log("populate cache..."); + ZARR_DATA_CACHE[cacheKey] = data; + return data; + }); }); let ndChunks = await Promise.all(promises); @@ -86,7 +113,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { const context = canvas.getContext("2d"); context.putImageData(new ImageData(rbgData, width, height), 0, 0); let dataUrl = canvas.toDataURL("image/png"); - console.log("Zarr dataUrl", dataUrl); return dataUrl; } From 3bbc15c400a3299231be5e46dcae1e6b46a00081 Mon Sep 17 00:00:00 2001 From: William Moore Date: Wed, 4 Dec 2024 16:47:20 +0000 Subject: [PATCH 06/42] Handle right panel setting of src async --- src/js/views/right_panel_view.js | 9 +++++++-- src/templates/viewport_inner.template.html | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/js/views/right_panel_view.js b/src/js/views/right_panel_view.js index 63fbf94af..e2c341f55 100644 --- a/src/js/views/right_panel_view.js +++ b/src/js/views/right_panel_view.js @@ -1118,9 +1118,14 @@ var frame_h = this.full_size / wh; } this.models.forEach(function(m){ - var src = m.get_img_src(); + var img_id = "image_" + Math.random(); + // src is async, so we set an id instead. When src returns we + // use the ID to find the element and set the src. + m.get_img_src().then(src => { + document.getElementById(img_id).src = src; + }); var img_css = m.get_vp_img_css(m.get('zoom'), frame_w, frame_h); - img_css.src = src; + img_css.id = img_id; // if a 'reasonable' dpi is set, we don't pixelate var dpiSet = m.get('min_export_dpi') > 100; img_css.pixelated = !dpiSet; diff --git a/src/templates/viewport_inner.template.html b/src/templates/viewport_inner.template.html index 30f50ad42..00c69d6c7 100644 --- a/src/templates/viewport_inner.template.html +++ b/src/templates/viewport_inner.template.html @@ -1,6 +1,7 @@ <% _.each(imgs_css, function(css, i) { %> - document.getElementById(random_id).src = src); var json = { + 'id': random_id, 'msg': msg, 'src': src, 'rect': rect, @@ -586,7 +589,8 @@ export const CropModalView = Backbone.View.extend({ this.m.set('zoom', 100); this.m.set('width', newW); this.m.set('height', newH); - var src = this.m.get_img_src(true); + this.m.get_img_src(true) + .then(src => this.$cropImg.attr('src', src)); var rotation = this.m.get('rotation') || 0; // Calculate bounding box for rotated image diff --git a/src/js/views/roi_modal_view.js b/src/js/views/roi_modal_view.js index 1ead692eb..d042bda27 100644 --- a/src/js/views/roi_modal_view.js +++ b/src/js/views/roi_modal_view.js @@ -551,48 +551,9 @@ export const RoiModalView = Backbone.View.extend({ $("#roiModalSidebar", this.$el).html(this.sidebar_template(json)); }, - // for rendering bounding-box viewports for shapes - getBboxJson: function(bbox, theZ, theT) { - var size = 50; // longest side - var orig_width = this.m.get('orig_width'), - orig_height = this.m.get('orig_height'); - // origT = this.m.get('theT'), - // origZ = this.m.get('theZ'); - // theT = (theT !== undefined ? theT : this.m.get('theT')) - var div_w, div_h; - // get src for image by temp setting Z & T - if (theT !== undefined) this.m.set('theT', bbox.theT, {'silent': true}); - if (theZ !== undefined) this.m.set('theZ', bbox.theZ, {'silent': true}); - var src = this.m.get_img_src(); - if (bbox.width > bbox.height) { - div_w = size; - div_h = (bbox.height/bbox.width) * div_w; - } else { - div_h = size; - div_w = (bbox.width/bbox.height) * div_h; - } - var zoom = div_w/bbox.width; - var img_w = orig_width * zoom; - var img_h = orig_height * zoom; - var top = -(zoom * bbox.y); - var left = -(zoom * bbox.x); - // bbox.theT = bbox.theT !== undefined ? bbox.theT : origT; - // bbox.theZ = bbox.theZ !== undefined ? bbox.theZ : origZ; - - return { - 'src': src, - 'w': div_w, - 'h': div_h, - 'top': top, - 'left': left, - 'img_w': img_w, - 'img_h': img_h - }; - }, - renderImagePlane: function() { - var src = this.m.get_img_src(); - this.$roiImg.attr('src', src); + this.m.get_img_src() + .then(src => this.$roiImg.attr('src', src)); var orig_model = this.model.getSelected().head(); var json = {'theZ': this.m.get('theZ'), diff --git a/src/templates/modal_dialogs/crop_modal_roi.template.html b/src/templates/modal_dialogs/crop_modal_roi.template.html index 53ecab399..1901cb504 100644 --- a/src/templates/modal_dialogs/crop_modal_roi.template.html +++ b/src/templates/modal_dialogs/crop_modal_roi.template.html @@ -2,7 +2,8 @@
- Date: Tue, 11 Feb 2025 13:25:31 +0000 Subject: [PATCH 08/42] Add APP_SERVED_BY_OMERO global js variable --- omero_figure/views.py | 3 +++ src/index.html | 2 ++ 2 files changed, 5 insertions(+) diff --git a/omero_figure/views.py b/omero_figure/views.py index 8ee2c27f4..a7a157ae3 100644 --- a/omero_figure/views.py +++ b/omero_figure/views.py @@ -113,6 +113,8 @@ def index(request, file_id=None, conn=None, **kwargs): # Load the template html and replace OMEROWEB_INDEX template = loader.get_template("omero_figure/index.html") html = template.render({}, request) + html = html.replace('const APP_SERVED_BY_OMERO = false;', + 'const APP_SERVED_BY_OMERO = true;') omeroweb_index = reverse("index") figure_index = reverse("figure_index") ping_url = reverse("keepalive_ping") @@ -120,6 +122,7 @@ def index(request, file_id=None, conn=None, **kwargs): 'const BASE_OMEROWEB_URL = "%s";' % omeroweb_index) html = html.replace('const APP_ROOT_URL = "";', 'const APP_ROOT_URL = "%s";' % figure_index) + # Replace various other placeholder values with OMERO data/configs html = html.replace('const USER_ID = 0;', 'const USER_ID = %s' % user.id) html = html.replace('const PING_URL = "";', 'const PING_URL = "%s";' % ping_url) diff --git a/src/index.html b/src/index.html index 3d1ff3471..808e7f6d2 100644 --- a/src/index.html +++ b/src/index.html @@ -8,6 +8,8 @@ // For dev, use this server to load/save figures (needs CORS enabled) const dev_omeroweb_url = "http://localhost:4080/"; // These are updated by views.py when serving this page... + // Flag for knowing if app is served from OMERO (or standalone) + const APP_SERVED_BY_OMERO = false; const BASE_OMEROWEB_URL = dev_omeroweb_url; const EXPORT_ENABLED = false; const SCRIPT_VERSION = ""; From f606f960ac23ab785ee069a3be46021952b99a4c Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 14 Feb 2025 09:47:12 +0000 Subject: [PATCH 09/42] Handle missing 'omero' metadata from zarr --- src/js/models/figure_model.js | 128 +---------------- src/js/models/zarr_utils.js | 260 ++++++++++++++++++++++++++++++++-- 2 files changed, 255 insertions(+), 133 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index cf79e31b8..4b595739a 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -9,6 +9,7 @@ figureConfirmDialog, getJson, saveFigureToStorage} from "../views/util"; + import { loadZarrForPanel } 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.) @@ -542,135 +543,12 @@ let self = this; this.set('loading_count', this.get('loading_count') + 1); - let zattrs = await fetch(zarrUrl + "/.zattrs").then(rsp => rsp.json()); - let zarrName = zarrUrl.split("/").pop(); - let multiscales = zattrs?.multiscales; - console.log("zarr zattrs", zattrs); - if (!multiscales) { - alert(`Image loading from ${imgDataUrl} included an Error: ${message}`); - return; - } + let panel_json = await loadZarrForPanel(zarrUrl); - // if we got multiscales, load first dataset... - // TODO: handle bioformats2raw.layout - let dsPath = multiscales[0]?.datasets[0].path; - let imgName = multiscales[0].name || zarrName; - let axes = multiscales[0].axes; - let axesNames = axes.map(axis => axis.name); - - let datasets = multiscales[0].datasets; - let zarrays = {}; - // 'consolidate' the metadata for all arrays - for (let ds of datasets) { - let path = ds.path; - let zarray = await fetch(`${zarrUrl}/${path}/.zarray`).then(rsp => rsp.json()); - zarrays[path] = zarray; - } - // store under 'arrays' key - zattrs['arrays'] = zarrays; - - let zarray = zarrays[0]; - console.log("zarray", zarray); - - // e.g. " "uint8" - let dtypeToPixelsType = (dtype) => { - let dt = ""; - if (dtype.includes("u")) { - dt += "uint"; - } else if (dtype.includes("i")) { - dt += "int"; - } else if (dtype.includes("f")) { - dt += "float"; - } - if (dtype.includes("8")) { - dt += "64"; - } else if (dtype.includes("4")) { - dt += "32"; - } else if (dtype.includes("2")) { - dt += "16"; - } else if (dtype.includes("1")) { - dt += "8"; - } - return dt; - } - let dtype = zarray.dtype; - let shape = zarray.shape; - let dims = shape.length; - let sizeX = shape[dims - 1]; - let sizeY = shape[dims - 2]; - let sizeZ = 1; - let sizeT = 1; - if (axesNames.includes('z')) { - sizeZ = shape[axesNames.indexOf('z')] - } - let defaultZ = parseInt(sizeZ / 2); - if (axesNames.includes('t')) { - sizeT = shape[axesNames.indexOf('t')] - } - let defaultT = parseInt(sizeT / 2); self.set('loading_count', self.get('loading_count') - 1); - - // channels... - // TODO: if no omero data, need to construct channels! - let channels = zattrs.omero?.channels || []; - - coords.spacer = coords.spacer || sizeX/20; - var full_width = (coords.colCount * (sizeX + coords.spacer)) - coords.spacer, - full_height = (coords.rowCount * (sizeY + coords.spacer)) - coords.spacer; - coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); - coords.scale = Math.min(coords.scale, 1); // only scale down - // For the FIRST IMAGE ONLY (coords.px etc undefined), we - // need to work out where to start (px,py) now that we know size of panel - // (assume all panels are same size) - coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; - coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; - - // calculate panel coordinates from index... - var row = parseInt(index / coords.colCount, 10); - var col = index % coords.colCount; - var panelX = coords.px + ((sizeX + coords.spacer) * coords.scale * col); - var panelY = coords.py + ((sizeY + coords.spacer) * coords.scale * row); - - // ****** This is the Data Model ****** - //------------------------------------- - // Any changes here will create a new version - // of the model and will also have to be applied - // to the 'version_transform()' function so that - // older files can be brought up to date. - // Also check 'previewSetId()' for changes. - var n = { - 'imageId': zarrUrl, - 'name': imgName, - 'width': sizeX * coords.scale, - 'height': sizeY * coords.scale, - 'sizeZ': sizeZ, - 'theZ': defaultZ, - 'sizeT': sizeT, - 'theT': defaultT, - 'rdefs': {'model': "-"}, - 'channels': channels, - 'orig_width': sizeX, - 'orig_height': sizeY, - 'x': panelX, - 'y': panelY, - // 'datasetName': data.meta.datasetName, - // 'datasetId': data.meta.datasetId, - // 'pixel_size_x': data.pixel_size.valueX, - // 'pixel_size_y': data.pixel_size.valueY, - // 'pixel_size_z': data.pixel_size.valueZ, - // 'pixel_size_x_symbol': data.pixel_size.symbolX, - // 'pixel_size_z_symbol': data.pixel_size.symbolZ, - // 'pixel_size_x_unit': data.pixel_size.unitX, - // 'pixel_size_z_unit': data.pixel_size.unitZ, - // 'deltaT': data.deltaT, - 'pixelsType': dtypeToPixelsType(dtype), - // 'pixel_range': data.pixel_range, - // let's dump the zarr data into the panel - 'zarr': zattrs, - }; // create Panel (and select it) // We do some additional processing in Panel.parse() - self.panels.create(n, {'parse': true}).set('selected', true); + self.panels.create(panel_json, {'parse': true}).set('selected', true); self.notifySelectionChange(); }, diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 9e89f8107..379e1a403 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -1,13 +1,194 @@ - import * as zarr from "zarrita"; import { slice } from "@zarrita/indexing"; +import _ from 'underscore'; const ZARRITA_ARRAY_CACHE = {}; const ZARR_DATA_CACHE = {}; +export async function loadZarrForPanel(zarrUrl) { + let store = new zarr.FetchStore(zarrUrl); + + // we load smallest array. Only need it for min/max values if not in 'omero' metadata + let datasetIndex = -1; + const {arr, shapes, multiscale, omero, scales, zarr_version} = await omezarr.getMultiscaleWithArray(store, datasetIndex); + console.log({arr, shapes, multiscale, omero, scales, zarr_version}) + let zarrName = zarrUrl.split("/").pop(); + console.log("multiscale", multiscale); + if (!multiscale) { + alert(`Failed to load multiscale from ${zarrUrl}`); + return; + } + + let imgName = multiscale.name || zarrName; + let axes = multiscale.axes; + let axesNames = axes?.map((axis) => axis.name) || ["t", "c", "z", "y", "x"]; + + let datasets = multiscale.datasets; + let zarrays = {}; + // 'consolidate' the metadata for all arrays + for (let ds of datasets) { + let path = ds.path; + let zarray = await fetch(`${zarrUrl}/${path}/.zarray`).then((rsp) => + rsp.json() + ); + zarrays[path] = zarray; + } + // store under 'arrays' key + let zarr_attrs = { + multiscales: [multiscale], + } + zarr_attrs["arrays"] = zarrays; + + let zarray = zarrays[0]; + console.log("zarray", zarray); + + let dtype = zarray.dtype; + let shape = zarray.shape; + let dims = shape.length; + let sizeX = shape[dims - 1]; + let sizeY = shape[dims - 2]; + let sizeZ = 1; + let sizeT = 1; + let sizeC = 1; + if (axesNames.includes("z")) { + sizeZ = shape[axesNames.indexOf("z")]; + } + let defaultZ = parseInt(sizeZ / 2); + if (axesNames.includes("t")) { + sizeT = shape[axesNames.indexOf("t")]; + } + let defaultT = parseInt(sizeT / 2); + if (axesNames.includes("c")) { + sizeC = shape[axesNames.indexOf("c")]; + } + + // channels... + // if no omero data, need to construct channels! + let default_colors = [ + "FF0000", + "00FF00", + "0000FF", + "FFFF00", + "00FFFF", + "FFFFFF", + ]; + let channels = omero?.channels; + if (!channels) { + // load smallest array to get min/max values for every channel + let slices = getSlices(_.range(sizeC), shape, axesNames, {}); + let promises = slices.map((chSlice) => zarr.get(arr, chSlice)); + let ndChunks = await Promise.all(promises); + + channels = _.range(sizeC).map((idx) => { + let mm = getMinMaxValues(ndChunks[idx]); + return { + label: "Ch" + idx, + active: true, + color: default_colors[idx], + window: { + min: mm[0], + max: mm[1], + start: mm[0], + end: mm[1], + }, + }; + }); + } + + // coords.spacer = coords.spacer || sizeX / 20; + // var full_width = coords.colCount * (sizeX + coords.spacer) - coords.spacer, + // full_height = coords.rowCount * (sizeY + coords.spacer) - coords.spacer; + // coords.scale = coords.paper_width / (full_width + 2 * coords.spacer); + // coords.scale = Math.min(coords.scale, 1); // only scale down + // // For the FIRST IMAGE ONLY (coords.px etc undefined), we + // // need to work out where to start (px,py) now that we know size of panel + // // (assume all panels are same size) + // coords.px = coords.px || coords.c.x - (full_width * coords.scale) / 2; + // coords.py = coords.py || coords.c.y - (full_height * coords.scale) / 2; + + // // calculate panel coordinates from index... + // var row = parseInt(index / coords.colCount, 10); + // var col = index % coords.colCount; + // var panelX = coords.px + (sizeX + coords.spacer) * coords.scale * col; + // var panelY = coords.py + (sizeY + coords.spacer) * coords.scale * row; + + let panelX = 0; + let panelY = 0; + let coords = {scale: 1}; + + // ****** This is the Data Model ****** + //------------------------------------- + // Any changes here will create a new version + // of the model and will also have to be applied + // to the 'version_transform()' function so that + // older files can be brought up to date. + // Also check 'previewSetId()' for changes. + var n = { + imageId: zarrUrl, + name: imgName, + width: sizeX * coords.scale, + height: sizeY * coords.scale, + sizeZ: sizeZ, + theZ: defaultZ, + sizeT: sizeT, + theT: defaultT, + rdefs: { model: "-" }, + channels: channels, + orig_width: sizeX, + orig_height: sizeY, + x: panelX, + y: panelY, + // 'datasetName': data.meta.datasetName, + // 'datasetId': data.meta.datasetId, + // 'pixel_size_x': data.pixel_size.valueX, + // 'pixel_size_y': data.pixel_size.valueY, + // 'pixel_size_z': data.pixel_size.valueZ, + // 'pixel_size_x_symbol': data.pixel_size.symbolX, + // 'pixel_size_z_symbol': data.pixel_size.symbolZ, + // 'pixel_size_x_unit': data.pixel_size.unitX, + // 'pixel_size_z_unit': data.pixel_size.unitZ, + // 'deltaT': data.deltaT, + pixelsType: dtypeToPixelsType(dtype), + // 'pixel_range': data.pixel_range, + // let's dump the zarr data into the panel + zarr: zarr_attrs, + }; + console.log("Zarr Panel Model", n); + + return n; +} + +// e.g. " "uint8" +function dtypeToPixelsType(dtype) { + let dt = ""; + if (dtype.includes("u")) { + dt += "uint"; + } else if (dtype.includes("i")) { + dt += "int"; + } else if (dtype.includes("f")) { + dt += "float"; + } + if (dtype.includes("8")) { + dt += "64"; + } else if (dtype.includes("4")) { + dt += "32"; + } else if (dtype.includes("2")) { + dt += "16"; + } else if (dtype.includes("1")) { + dt += "8"; + } + return dt; +}; + export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { let paths = attrs.multiscales[0].datasets.map((d) => d.path); - let axes = attrs.multiscales[0].axes.map((a) => a.name); + let axes = attrs.multiscales[0].axes?.map((a) => a.name) || [ + "t", + "c", + "z", + "y", + "x", + ]; let zarrays = attrs.arrays; // Pick first resolution that is below a max size... @@ -25,10 +206,12 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { } if (!path) { - console.error(`Lowest resolution too large for rendering: > ${MAX_SIZE} x ${MAX_SIZE}`); + console.error( + `Lowest resolution too large for rendering: > ${MAX_SIZE} x ${MAX_SIZE}` + ); return; } - + console.log("Init zarr.FetchStore:", source + "/" + path); let storeArrayPath = source + "/" + path; let arr; @@ -93,7 +276,7 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { return ZARR_DATA_CACHE[cacheKey]; } - return zarr.get(arr, slices).then(data => { + return zarr.get(arr, slices).then((data) => { console.log("populate cache..."); ZARR_DATA_CACHE[cacheKey] = data; return data; @@ -101,12 +284,25 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { }); let ndChunks = await Promise.all(promises); - // let minMaxValues = ndChunks.map((ch) => getMinMaxValues(ch)); - let rbgData = renderTo8bitArray(ndChunks, minMaxValues, colors); + console.log( + "renderTo8bitArray ndChunks, minMaxValues, colors, luts, inverteds", + ndChunks, + minMaxValues, + colors, + luts, + inverteds + ); + let rbgData = omezarr.renderTo8bitArray( + ndChunks, + minMaxValues, + colors, + luts, + inverteds + ); let width = shape.at(-1); let height = shape.at(-2); - + const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; @@ -229,3 +425,51 @@ export function hexToRGB(hex) { const b = parseInt(hex.slice(4, 6), 16); return [r, g, b]; } + + +// Copied from ome-zarr.js +export function getSlices(activeChannelIndices, shape, axesNames, indices) { + // For each active channel, get a multi-dimensional slice + let chSlices = activeChannelIndices.map((chIndex) => { + let chSlice = shape.map((dimSize, index) => { + let name = axesNames[index]; + // channel + if (name == "c") return chIndex; + + if (name in indices) { + let idx = indices[name]; + if (Array.isArray(idx)) { + return slice(idx[0], idx[1]); + } else if (Number.isInteger(idx)) { + return idx; + } + } + // no valid indices supplied, use defaults... + // x and y - we want full range + if (name == "x" || name == "y") { + return slice(0, dimSize); + } + // Use omero for z/t if available, otherwise use middle slice + if (name == "z" || name == "t") { + return parseInt(dimSize / 2 + ""); + } + return 0; + }); + return chSlice; + }); + return chSlices; +} + +// Copied from ome-zarr.js +export function getMinMaxValues(chunk2d) { + const data = chunk2d.data; + let maxV = 0; + let minV = Infinity; + let length = chunk2d.data.length; + for (let y = 0; y < length; y++) { + let rawValue = data[y]; + maxV = Math.max(maxV, rawValue); + minV = Math.min(minV, rawValue); + } + return [minV, maxV]; +} From f349124cd8821091ef9db876ab2abd0412ccae5e Mon Sep 17 00:00:00 2001 From: William Moore Date: Tue, 18 Feb 2025 14:50:59 +0000 Subject: [PATCH 10/42] Update to use ome-zarr.js 0.0.6 --- package-lock.json | 13 ++++++++++ package.json | 1 + src/js/models/zarr_utils.js | 52 ++----------------------------------- 3 files changed, 16 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8da7263a8..06ee26487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "jquery": "^3.6.0", "marked": "^4.2.12", "mousetrap": "^1.6.5", + "ome-zarr.js": "^0.0.6", "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", @@ -1106,6 +1107,18 @@ "fflate": "^0.8.0" } }, +<<<<<<< HEAD +======= + "node_modules/ome-zarr.js": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/ome-zarr.js/-/ome-zarr.js-0.0.6.tgz", + "integrity": "sha512-3on46ct8z2NFCernQTUzRRRItfb5wemg7gZlJHk6NpoX98LYtfmVosiwRPlvmtsDTWysaaoee12parV4qp7DRg==", + "license": "BSD", + "dependencies": { + "zarrita": "0.4.0-next.19" + } + }, +>>>>>>> 055cbba2 (Update to use ome-zarr.js 0.0.6) "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 701b89937..d6c025a26 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "jquery": "^3.6.0", "marked": "^4.2.12", "mousetrap": "^1.6.5", + "ome-zarr.js": "^0.0.6", "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 379e1a403..344649bfe 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -75,12 +75,12 @@ export async function loadZarrForPanel(zarrUrl) { let channels = omero?.channels; if (!channels) { // load smallest array to get min/max values for every channel - let slices = getSlices(_.range(sizeC), shape, axesNames, {}); + let slices = omezarr.getSlices(_.range(sizeC), shape, axesNames, {}); let promises = slices.map((chSlice) => zarr.get(arr, chSlice)); let ndChunks = await Promise.all(promises); channels = _.range(sizeC).map((idx) => { - let mm = getMinMaxValues(ndChunks[idx]); + let mm = omezarr.getMinMaxValues(ndChunks[idx]); return { label: "Ch" + idx, active: true, @@ -425,51 +425,3 @@ export function hexToRGB(hex) { const b = parseInt(hex.slice(4, 6), 16); return [r, g, b]; } - - -// Copied from ome-zarr.js -export function getSlices(activeChannelIndices, shape, axesNames, indices) { - // For each active channel, get a multi-dimensional slice - let chSlices = activeChannelIndices.map((chIndex) => { - let chSlice = shape.map((dimSize, index) => { - let name = axesNames[index]; - // channel - if (name == "c") return chIndex; - - if (name in indices) { - let idx = indices[name]; - if (Array.isArray(idx)) { - return slice(idx[0], idx[1]); - } else if (Number.isInteger(idx)) { - return idx; - } - } - // no valid indices supplied, use defaults... - // x and y - we want full range - if (name == "x" || name == "y") { - return slice(0, dimSize); - } - // Use omero for z/t if available, otherwise use middle slice - if (name == "z" || name == "t") { - return parseInt(dimSize / 2 + ""); - } - return 0; - }); - return chSlice; - }); - return chSlices; -} - -// Copied from ome-zarr.js -export function getMinMaxValues(chunk2d) { - const data = chunk2d.data; - let maxV = 0; - let minV = Infinity; - let length = chunk2d.data.length; - for (let y = 0; y < length; y++) { - let rawValue = data[y]; - maxV = Math.max(maxV, rawValue); - minV = Math.min(minV, rawValue); - } - return [minV, maxV]; -} From a0da4ea5bba6029a0b54cb9b5a5915a310763766 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 20 Feb 2025 16:08:37 +0000 Subject: [PATCH 11/42] Fix layout of multiple Zarr images when added together --- src/js/models/figure_model.js | 72 +++++++++++++++++++++++++---------- src/js/models/panel_model.js | 19 +++++++-- src/js/models/zarr_utils.js | 17 --------- 3 files changed, 68 insertions(+), 40 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 4b595739a..525b5d3b7 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -539,17 +539,45 @@ } }, + updateCoordsAndPanelCoords(panel_json, coords, index) { + console.log("importZarrImage coords.spacer coords:", coords.spacer, coords); + // update panel_json and coords + coords.spacer = coords.spacer || panel_json.orig_width/20; + var full_width = (coords.colCount * (panel_json.orig_width + coords.spacer)) - coords.spacer, + full_height = (coords.rowCount * (panel_json.orig_height + coords.spacer)) - coords.spacer; + coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); + coords.scale = Math.min(coords.scale, 1); // only scale down + // For the FIRST IMAGE ONLY (coords.px etc undefined), we + // need to work out where to start (px,py) now that we know size of panel + // (assume all panels are same size) + coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; + coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; + // calculate panel coordinates from index... + var row = parseInt(index / coords.colCount, 10); + var col = index % coords.colCount; + var panelX = coords.px + ((panel_json.orig_width + coords.spacer) * coords.scale * col); + var panelY = coords.py + ((panel_json.orig_height + coords.spacer) * coords.scale * row); + + // update panel_json + panel_json.x = panelX; + panel_json.y = panelY; + panel_json.width = panel_json.orig_width * coords.scale; + panel_json.height = panel_json.orig_height * coords.scale; + }, + importZarrImage: async function(zarrUrl, coords, index) { - let self = this; this.set('loading_count', this.get('loading_count') + 1); let panel_json = await loadZarrForPanel(zarrUrl); - self.set('loading_count', self.get('loading_count') - 1); + // coords (px, py etc) are incremented for each panel added + this.updateCoordsAndPanelCoords(panel_json, coords, index) + + this.set('loading_count', this.get('loading_count') - 1); // create Panel (and select it) // We do some additional processing in Panel.parse() - self.panels.create(panel_json, {'parse': true}).set('selected', true); - self.notifySelectionChange(); + this.panels.create(panel_json, {'parse': true}).set('selected', true); + this.notifySelectionChange(); }, importImage: function(imgDataUrl, coords, baseUrl, index) { @@ -584,22 +612,22 @@ return; } - coords.spacer = coords.spacer || data.size.width/20; - var full_width = (coords.colCount * (data.size.width + coords.spacer)) - coords.spacer, - full_height = (coords.rowCount * (data.size.height + coords.spacer)) - coords.spacer; - coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); - coords.scale = Math.min(coords.scale, 1); // only scale down - // For the FIRST IMAGE ONLY (coords.px etc undefined), we - // need to work out where to start (px,py) now that we know size of panel - // (assume all panels are same size) - coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; - coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; - - // calculate panel coordinates from index... - var row = parseInt(index / coords.colCount, 10); - var col = index % coords.colCount; - var panelX = coords.px + ((data.size.width + coords.spacer) * coords.scale * col); - var panelY = coords.py + ((data.size.height + coords.spacer) * coords.scale * row); + // coords.spacer = coords.spacer || data.size.width/20; + // var full_width = (coords.colCount * (data.size.width + coords.spacer)) - coords.spacer, + // full_height = (coords.rowCount * (data.size.height + coords.spacer)) - coords.spacer; + // coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); + // coords.scale = Math.min(coords.scale, 1); // only scale down + // // For the FIRST IMAGE ONLY (coords.px etc undefined), we + // // need to work out where to start (px,py) now that we know size of panel + // // (assume all panels are same size) + // coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; + // coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; + + // // calculate panel coordinates from index... + // var row = parseInt(index / coords.colCount, 10); + // var col = index % coords.colCount; + // var panelX = coords.px + ((data.size.width + coords.spacer) * coords.scale * col); + // var panelY = coords.py + ((data.size.height + coords.spacer) * coords.scale * row); // ****** This is the Data Model ****** //------------------------------------- @@ -639,6 +667,10 @@ if (baseUrl) { n.baseUrl = baseUrl; } + + // coords (px, py etc) are incremented for each panel added + self.updateCoordsAndPanelCoords(n, coords, index); + // create Panel (and select it) // We do some additional processing in Panel.parse() self.panels.create(n, {'parse': true}).set('selected', true); diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index 72534c06d..045b68c4d 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -977,13 +977,26 @@ return this.get('orig_width') * this.get('orig_height') > MAX_PLANE_SIZE; }, - get_zarr_img_src: async function() { - return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels')); + get_zarr_img_src: async function(force_no_padding) { + var rect; + if (this.is_big_image()) { + rect = this.getViewportAsRect(); + if (!force_no_padding) { + var length = Math.max(rect.width, rect.height) * 1.5; + rect.x = rect.x - ((length - rect.width) / 2); + rect.y = rect.y - ((length - rect.height) / 2); + rect.width = length; + rect.height = length; + } + } + console.log("------> get_zarr_img_src -force_no_padding, rect", force_no_padding, rect) + return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels'), rect); }, get_img_src: async function(force_no_padding) { + // async function since zarr src is the rendered image data if (this.get("zarr")) { - return this.get_zarr_img_src(); + return this.get_zarr_img_src(force_no_padding); } var chs = this.get('channels'); var cStrings = chs.map(function(c, i){ diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 344649bfe..31ad49fad 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -95,23 +95,6 @@ export async function loadZarrForPanel(zarrUrl) { }); } - // coords.spacer = coords.spacer || sizeX / 20; - // var full_width = coords.colCount * (sizeX + coords.spacer) - coords.spacer, - // full_height = coords.rowCount * (sizeY + coords.spacer) - coords.spacer; - // coords.scale = coords.paper_width / (full_width + 2 * coords.spacer); - // coords.scale = Math.min(coords.scale, 1); // only scale down - // // For the FIRST IMAGE ONLY (coords.px etc undefined), we - // // need to work out where to start (px,py) now that we know size of panel - // // (assume all panels are same size) - // coords.px = coords.px || coords.c.x - (full_width * coords.scale) / 2; - // coords.py = coords.py || coords.c.y - (full_height * coords.scale) / 2; - - // // calculate panel coordinates from index... - // var row = parseInt(index / coords.colCount, 10); - // var col = index % coords.colCount; - // var panelX = coords.px + (sizeX + coords.spacer) * coords.scale * col; - // var panelY = coords.py + (sizeY + coords.spacer) * coords.scale * row; - let panelX = 0; let panelY = 0; let coords = {scale: 1}; From f6a9df1e3a67fd38aa5c8da239c04fdd391dbc58 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 20 Feb 2025 16:09:29 +0000 Subject: [PATCH 12/42] Fix render_scaled_region behaviour for BIG zarr images --- src/js/models/zarr_utils.js | 121 +++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 30 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 31ad49fad..3cf429d17 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -163,7 +163,7 @@ function dtypeToPixelsType(dtype) { return dt; }; -export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { +export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) { let paths = attrs.multiscales[0].datasets.map((d) => d.path); let axes = attrs.multiscales[0].axes?.map((a) => a.name) || [ "t", @@ -175,18 +175,49 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { let zarrays = attrs.arrays; // Pick first resolution that is below a max size... - const MAX_SIZE = 2000; - console.log("Zarr pick size to render..."); + const MAX_SIZE = 500; + // use the arrays themselves to determine 'scale', since we might + // not have 'coordinateTransforms' for pre-v0.4 etc. + console.log("Zarr pick size to render...", rect); let path; + + // size of full-size image + let fullShape = zarrays[paths[0]].shape; + + let region_x = rect?.x || 0; + let region_y = rect?.y || 0; + let region_width = rect?.width || fullShape.at(-1); + let region_height = rect?.height || fullShape.at(-2); + + // crop region for the downscaled array + let array_rect; + for (let p of paths) { let arrayAttrs = zarrays[p]; - console.log(path, arrayAttrs); + console.log("checking path...", p, arrayAttrs.shape); let shape = arrayAttrs.shape; - if (shape.at(-1) * shape.at(-2) < MAX_SIZE * MAX_SIZE) { + // E.g. if dataset shape is 1/2 of fullShape then crop size will be half + let crop_w = region_width * shape.at(-1) / fullShape.at(-1); + let crop_h = region_height * shape.at(-2) / fullShape.at(-2); + + if (crop_w * crop_h < MAX_SIZE * MAX_SIZE) { + console.log("crop_w * crop_h < MAX_SIZE", crop_w * crop_h, MAX_SIZE * MAX_SIZE); + array_rect = { + x: Math.floor(region_x * shape.at(-1) / fullShape.at(-1)), + y: Math.floor(region_y * shape.at(-2) / fullShape.at(-2)), + width: Math.floor(crop_w), + height: Math.floor(crop_h), + } path = p; break; } } + console.log("Orign array_rect", array_rect); + + // We can create canvas of the size of the array_rect + const canvas = document.createElement("canvas"); + canvas.width = array_rect.width; + canvas.height = array_rect.height; if (!path) { console.error( @@ -220,9 +251,39 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { minMaxValues.push([ch.window.start, ch.window.end]); } }); - console.log("activeChIndicies", activeChIndicies); - console.log("colors", colors); - console.log("minMaxValues", minMaxValues); + // console.log("activeChIndicies", activeChIndicies); + // console.log("colors", colors); + // console.log("minMaxValues", minMaxValues); + + // Need same logic as https://github.com/ome/omero-figure/blob/9cc36cde05bde4def7b62b07c2e9ae5f66712e96/omero_figure/views.py#L310 + // if we have a crop region outside the bounds of the image + let array_shape = zarrays[path].shape; + let size_x = array_shape.at(-1); + let size_y = array_shape.at(-2); + let paste_x = 0; + let paste_y = 0; + // let {x, y, width, height} = array_rect; + if (array_rect.x < 0) { + paste_x = - array_rect.x + array_rect.width += array_rect.x; + array_rect.x = 0; + } + if (array_rect.y < 0) { + paste_y = - array_rect.y; + array_rect.height += array_rect.y; + array_rect.y = 0; + } + if (array_rect.x + array_rect.width > size_x) { + array_rect.width = size_x - array_rect.x; + } + if (array_rect.y + array_rect.height > size_y) { + array_rect.height = size_y - array_rect.y; + } + + console.log("array_shape", array_shape); + console.log("array_rect", array_rect); + console.log("paste_x", paste_x); + console.log("paste_y", paste_y); let promises = activeChIndicies.map((chIndex) => { let sliceKey = []; @@ -232,12 +293,16 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { sliceKey.push("" + chIndex); return chIndex; } - // x and y - if (index >= dims - 2) { - sliceKey.push(`${0}:${dimSize}`); - return slice(0, dimSize); + // x and y - crop to rect + if (axes[index] == "x") { + sliceKey.push(`${array_rect.x}:${array_rect.x + array_rect.width}`); + return slice(array_rect.x, array_rect.x + array_rect.width); + } + if (axes[index] == "y") { + sliceKey.push(`${array_rect.y}:${array_rect.y + array_rect.height}`); + return slice(array_rect.y, array_rect.y + array_rect.height); } - // z + // z: TODO: handle case where lower resolution is downsampled in Z if (axes[index] == "z") { sliceKey.push("" + theZ); return theZ; @@ -249,9 +314,9 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { return 0; }); let cacheKey = `${source}/${path}/${sliceKey.join(",")}`; - console.log("cacheKey", cacheKey); - console.log("Zarr chIndex slices:", chIndex, "" + slices); - console.log("Zarr chIndex shape:", chIndex, shape); + // console.log("cacheKey", cacheKey); + // console.log("Zarr chIndex slices:", chIndex, "" + slices); + // console.log("Zarr chIndex shape:", chIndex, shape); // TODO: add controller: { opts: { signal: controller.signal } } // check cache! if (ZARR_DATA_CACHE[cacheKey]) { @@ -267,14 +332,10 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { }); let ndChunks = await Promise.all(promises); - console.log( - "renderTo8bitArray ndChunks, minMaxValues, colors, luts, inverteds", - ndChunks, - minMaxValues, - colors, - luts, - inverteds - ); + console.log("ndChunk", ndChunks[0].shape); + console.log("array_rect", array_rect); + + let start = new Date().getTime(); let rbgData = omezarr.renderTo8bitArray( ndChunks, minMaxValues, @@ -282,15 +343,15 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels) { luts, inverteds ); + console.log("renderTo8bitArray took", new Date().getTime() - start, "ms"); - let width = shape.at(-1); - let height = shape.at(-2); + let chunk_width = ndChunks[0].shape.at(-1); + let chunk_height = ndChunks[0].shape.at(-2); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; const context = canvas.getContext("2d"); - context.putImageData(new ImageData(rbgData, width, height), 0, 0); + context.fillStyle = "#ddd"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.putImageData(new ImageData(rbgData, chunk_width, chunk_height), paste_x, paste_y); let dataUrl = canvas.toDataURL("image/png"); return dataUrl; } From 31352eb2b1052351f5fb0c0c55691180c944d966 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 6 Mar 2025 13:41:12 +0000 Subject: [PATCH 13/42] Fix saving of NGFF figures to OMERO --- omero_figure/views.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/omero_figure/views.py b/omero_figure/views.py index a7a157ae3..c0b65bb73 100644 --- a/omero_figure/views.py +++ b/omero_figure/views.py @@ -378,9 +378,13 @@ def save_web_figure(request, conn=None, **kwargs): try: json_data = json.loads(figure_json) for panel in json_data['panels']: - image_ids.append(panel['imageId']) + try: + image_ids.append(int(panel['imageId'])) + except ValueError: + # For NGFF images, the imageId is a string + pass if len(image_ids) > 0: - first_img_id = int(image_ids[0]) + first_img_id = image_ids[0] # remove duplicates image_ids = list(set(image_ids)) # pretty-print json @@ -417,7 +421,7 @@ def save_web_figure(request, conn=None, **kwargs): if file_id is None: # Create new file # Try to set Group context to the same as first image - curr_gid = conn.SERVICE_OPTS.getOmeroGroup() + curr_gid = conn.getEventContext().groupId i = None if first_img_id: i = conn.getObject("Image", first_img_id) From ae034d41deb92318bfc26bf3e4c2127f954c51f2 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 13 Mar 2025 22:00:12 +0000 Subject: [PATCH 14/42] isDark(color) handles invalid color --- src/js/views/channel_slider_view.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/views/channel_slider_view.js b/src/js/views/channel_slider_view.js index 505cc6701..a5253b9b6 100644 --- a/src/js/views/channel_slider_view.js +++ b/src/js/views/channel_slider_view.js @@ -137,6 +137,9 @@ var ChannelSliderView = Backbone.View.extend({ return false; } var c = this.hexToRgb(color); + if (!c) { + return false; + } var min, max, delta; var v, s, h; min = Math.min(c.r, c.g, c.b); From e3c969ab5d53e027785526ddcb9a61f4ff58b76f Mon Sep 17 00:00:00 2001 From: William Moore Date: Mon, 17 Mar 2025 12:52:22 +0000 Subject: [PATCH 15/42] Fix bug in adding OMERO images --- src/js/models/figure_model.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 525b5d3b7..eb6fbd77f 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -649,8 +649,8 @@ 'channels': data.channels, 'orig_width': data.size.width, 'orig_height': data.size.height, - 'x': panelX, - 'y': panelY, + 'x': 0, + 'y': 0, 'datasetName': data.meta.datasetName, 'datasetId': data.meta.datasetId, 'pixel_size_x': data.pixel_size.valueX, From 5de8480dff57ff4871aed4eed07dfef52cb0437f Mon Sep 17 00:00:00 2001 From: William Moore Date: Mon, 17 Mar 2025 12:59:33 +0000 Subject: [PATCH 16/42] Remove baseUrl support in Open with. Zarrs links to ngff-validatator --- src/js/views/info_panel_view.js | 37 +++++++++++++++++---------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/js/views/info_panel_view.js b/src/js/views/info_panel_view.js index ab5ab9e90..4b9c66387 100644 --- a/src/js/views/info_panel_view.js +++ b/src/js/views/info_panel_view.js @@ -164,22 +164,24 @@ var InfoPanelView = Backbone.View.extend({ this.$el.append(this.xywh_template(json)); }, - getImageLinks: function(remoteUrl, imageIds, imageNames) { + getImageLinks: function(imageIds, imageNames) { // Link if we have a single remote image, E.g. http://jcb-dataviewer.rupress.org/jcb/img_detail/625679/ + var selectedObjs = imageIds.map((id, index) => {return {id, name: imageNames[index], type: 'image'}}) + .filter(function(idWithName){ + return !isNaN(idWithName.id); + }); + var zarrIds = imageIds.filter(function(id){ + return typeof id === "string" && id.indexOf('zarr') > -1; + }); var imageLinks = []; - if (remoteUrl) { - if (imageIds.length == 1) { - imageLinks.push({'text': 'Image viewer', 'url': remoteUrl}); - } - // OR all the images are local... - } else { - imageLinks.push({'text': 'Webclient', 'url': WEBINDEX_URL + "?show=image-" + imageIds.join('|image-')}); + + // Handle Image ID links to OMERO... + if (selectedObjs.length > 0) { + var numberIds = selectedObjs.map(idWithName => idWithName.id); + imageLinks.push({'text': 'Webclient', 'url': WEBINDEX_URL + "?show=image-" + numberIds.join('|image-')}); // Handle other 'Open With' options OPEN_WITH.forEach(function(v){ - var selectedObjs = imageIds.map(function(id, i){ - return {'id': id, 'name': imageNames[i], 'type': 'image'}; - }); var enabled = false; if (typeof v.isEnabled === "function") { enabled = v.isEnabled(selectedObjs); @@ -204,6 +206,10 @@ var InfoPanelView = Backbone.View.extend({ imageLinks.push({'text': label, 'url': url}); }); } + // Handle Zarr links to ome-ngff-validator + if (zarrIds.length == 1) { + imageLinks.push({'text': "ome-ngff-validator", 'url': "https://ome.github.io/ome-ngff-validator/?source=" + zarrIds[0]}); + } return imageLinks; }, @@ -216,16 +222,11 @@ var InfoPanelView = Backbone.View.extend({ // Flag to ignore blur events caused by $el.html() below this.rendering = true; var json, - title = this.models.length + " Panels Selected...", - remoteUrl; + title = this.models.length + " Panels Selected..."; var imageIds = this.models.pluck('imageId'); var imageNames = this.models.pluck('name'); this.models.forEach(function(m) { - if (m.get('baseUrl')) { - // only used when a single image is selected - remoteUrl = m.get('baseUrl') + "/img_detail/" + m.get('imageId') + "/"; - } // start with json data from first Panel var this_json = m.toJSON(); // Format floating point values @@ -274,7 +275,7 @@ var InfoPanelView = Backbone.View.extend({ json.export_dpi = Math.max(json.export_dpi, json.min_export_dpi); } - json.imageLinks = this.getImageLinks(remoteUrl, imageIds, imageNames); + json.imageLinks = this.getImageLinks(imageIds, imageNames); // all setId if we have a single Id json.setImageId = _.uniq(imageIds).length == 1; From 7a072de56855ce0f61000abfebd57f0b257c9bd2 Mon Sep 17 00:00:00 2001 From: William Moore Date: Mon, 17 Mar 2025 13:45:53 +0000 Subject: [PATCH 17/42] Handle 'scale' to populate pixel sizes --- src/js/models/zarr_utils.js | 43 ++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 3cf429d17..5a75cd6f2 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -121,22 +121,45 @@ export async function loadZarrForPanel(zarrUrl) { orig_height: sizeY, x: panelX, y: panelY, - // 'datasetName': data.meta.datasetName, - // 'datasetId': data.meta.datasetId, - // 'pixel_size_x': data.pixel_size.valueX, - // 'pixel_size_y': data.pixel_size.valueY, - // 'pixel_size_z': data.pixel_size.valueZ, - // 'pixel_size_x_symbol': data.pixel_size.symbolX, - // 'pixel_size_z_symbol': data.pixel_size.symbolZ, - // 'pixel_size_x_unit': data.pixel_size.unitX, - // 'pixel_size_z_unit': data.pixel_size.unitZ, // 'deltaT': data.deltaT, pixelsType: dtypeToPixelsType(dtype), // 'pixel_range': data.pixel_range, // let's dump the zarr data into the panel zarr: zarr_attrs, }; - console.log("Zarr Panel Model", n); + + // handle pixel sizes if available + // Use 'scale' from first 'coordinateTransforms' if available + let datasetScale = multiscale.datasets[0].coordinateTransformations?.find( + (ct) => ct.type == "scale" + ); + let msScale = multiscale.coordinateTransformations?.find( + (ct) => ct.type == "scale" + ); + if (datasetScale) { + for (let dimName of ["x", "y", "z"]) { + axes.forEach((axis, idx) => { + if (axis.name == dimName) { + let dimScale = datasetScale.scale[idx]; + if (msScale) { + // also apply any 'scale' on multiscale.coordinateTransformations + dimScale = dimScale * msScale.scale[idx]; + } + // One OF: angstrom, attometer, centimeter, decimeter, exameter, femtometer, foot, gigameter, hectometer, inch, kilometer, megameter, meter, micrometer, mile, millimeter, nanometer, parsec, petameter, picometer, terameter, yard, yoctometer, yottameter, zeptometer, zettameter + if (axis.unit) { + let unitKey = axis.unit.toUpperCase(); + n['pixel_size_' + dimName] = dimScale; + n['pixel_size_' + dimName + '_unit'] = unitKey; + if (LENGTH_UNITS[unitKey]) { + n['pixel_size_' + dimName + '_symbol'] = LENGTH_UNITS[unitKey].symbol; + } + } + } + }); + } + } + + console.log("Zarr Panel Model...", n); return n; } From a36d7d25ba33239cabc461594e7e0e9898a165db Mon Sep 17 00:00:00 2001 From: William Moore Date: Mon, 17 Mar 2025 13:46:16 +0000 Subject: [PATCH 18/42] Remove some console.log --- src/js/models/panel_model.js | 1 - src/js/models/zarr_utils.js | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index 045b68c4d..f0d0809bf 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -989,7 +989,6 @@ rect.height = length; } } - console.log("------> get_zarr_img_src -force_no_padding, rect", force_no_padding, rect) return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels'), rect); }, diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 5a75cd6f2..1d6161dd9 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -201,7 +201,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) const MAX_SIZE = 500; // use the arrays themselves to determine 'scale', since we might // not have 'coordinateTransforms' for pre-v0.4 etc. - console.log("Zarr pick size to render...", rect); let path; // size of full-size image @@ -355,9 +354,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) }); let ndChunks = await Promise.all(promises); - console.log("ndChunk", ndChunks[0].shape); - console.log("array_rect", array_rect); - let start = new Date().getTime(); let rbgData = omezarr.renderTo8bitArray( ndChunks, From b9c1e21c1f04738f8b935a1cb1779b3193cba38a Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 21 Mar 2025 12:14:50 +0000 Subject: [PATCH 19/42] Fix LUT background on channel buttons and slider in OMERO --- src/css/figure.css | 1 + src/js/views/channel_slider_view.js | 13 ++++++++++--- src/js/views/lutpicker.js | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/css/figure.css b/src/css/figure.css index 96a58f15f..731717b08 100644 --- a/src/css/figure.css +++ b/src/css/figure.css @@ -1052,6 +1052,7 @@ /* NB: when updating png, consider using different name to avoid cache */ background-size: 100% var(--pngHeight); background-image: var(--lutPng); + background-position: var(--bgPos); background-repeat: no-repeat; image-rendering: pixelated; /* Universal support since 2021 */ } diff --git a/src/js/views/channel_slider_view.js b/src/js/views/channel_slider_view.js index a5253b9b6..ab7231a10 100644 --- a/src/js/views/channel_slider_view.js +++ b/src/js/views/channel_slider_view.js @@ -401,10 +401,17 @@ var ChannelSliderView = Backbone.View.extend({ var active = actives.reduce(allEqualFn, actives[0]); var style = {'background-position': '0 0'} var lutBgPos = FigureLutPicker.getLutBackgroundPosition(color); - if (color.endsWith('.lut')) { - style['background-position'] = lutBgPos; + var lutPng = FigureLutPicker.getLutPng(color); + console.log("lutPng", lutPng); + var lutBgCss = '--bgPos: 0 0;'; + if (color.endsWith('.lut') && lutPng) { + // lutPng means we have LUT from ome-zarr.js. Apply without offset + lutBgCss = `--bgPos: 0 0; --lutPng: url('${lutPng}'); --pngHeight: 100%;`; color = "ccc"; - } else if (color.toUpperCase() === "FFFFFF") { + } else { + lutBgCss = `--bgPos: ${lutBgPos};` + } + if (color.toUpperCase() === "FFFFFF") { color = "ccc"; // white slider would be invisible } if (color == "FFFFFF") color = "ccc"; // white slider would be invisible diff --git a/src/js/views/lutpicker.js b/src/js/views/lutpicker.js index 471fa730e..8045c9354 100644 --- a/src/js/views/lutpicker.js +++ b/src/js/views/lutpicker.js @@ -100,6 +100,14 @@ var LutPickerView = Backbone.View.extend({ return this.lutsPngUrl }, + getLutPng(lutName) { + // This will return undefined if the LUTs are loaded from OMERO + var lut = this.luts.find(lut => lut.name == lutName); + if (lut && lut.png) { + return lut.png; + } + }, + getLutBackgroundPosition: function(lutName) { var lutIndex = this.lut_names.indexOf(lutName); if (lutIndex > -1) { From f834d2b5de7253651065e7f4628c495fbfcd79b1 Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 28 Mar 2025 09:16:10 +0000 Subject: [PATCH 20/42] Handle Zarr v3. ONLY save array.shape and array.dtype to figure JSON --- src/js/models/zarr_utils.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 1d6161dd9..550001382 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -28,10 +28,8 @@ export async function loadZarrForPanel(zarrUrl) { // 'consolidate' the metadata for all arrays for (let ds of datasets) { let path = ds.path; - let zarray = await fetch(`${zarrUrl}/${path}/.zarray`).then((rsp) => - rsp.json() - ); - zarrays[path] = zarray; + let ds_array = await omezarr.getArray(store, path, zarr_version); + zarrays[path] = {shape: ds_array.shape, dtype: ds_array.dtype}; } // store under 'arrays' key let zarr_attrs = { From 39ab0b6b115592e7a4029f514a3b498ddcf121dd Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 28 Mar 2025 09:17:05 +0000 Subject: [PATCH 21/42] Handle opening of OME-Zarr v0.3 images --- src/js/models/zarr_utils.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 550001382..e9f758866 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -186,13 +186,10 @@ function dtypeToPixelsType(dtype) { export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) { let paths = attrs.multiscales[0].datasets.map((d) => d.path); - let axes = attrs.multiscales[0].axes?.map((a) => a.name) || [ - "t", - "c", - "z", - "y", - "x", - ]; + // for v0.3 each axes is a string, for v0.4+ it is an object + let axes = attrs.multiscales[0].axes?.map((a) => a.name || a); + // If no axes (v0.1, v0.2) it must be 5D + axes = axes || ["t", "c", "z", "y", "x"]; let zarrays = attrs.arrays; // Pick first resolution that is below a max size... From ff86010e6d1ea0861293a1c27859860c59bded18 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 09:11:33 +0000 Subject: [PATCH 22/42] Fix Luts display, update zarrita and ome-zarr.js --- package-lock.json | 70 ++++-------- package.json | 4 +- src/css/figure.css | 2 +- src/js/models/zarr_utils.js | 125 +-------------------- src/js/views/channel_slider_view.js | 2 +- src/js/views/lutpicker.js | 26 ++++- src/templates/channel_slider.template.html | 4 +- src/templates/lut_picker.template.html | 6 +- src/test.html | 25 +++++ 9 files changed, 87 insertions(+), 177 deletions(-) create mode 100644 src/test.html diff --git a/package-lock.json b/package-lock.json index 06ee26487..623b5bdbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,12 @@ "jquery": "^3.6.0", "marked": "^4.2.12", "mousetrap": "^1.6.5", - "ome-zarr.js": "^0.0.6", + "ome-zarr.js": "^0.0.17", "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", "vite-plugin-html-inject": "^1.1.2", - "zarrita": "^0.4.0-next.19" + "zarrita": "^0.5.4" }, "devDependencies": { "sass": "^1.54.1", @@ -758,40 +758,16 @@ "license": "MIT", "optional": true }, - "node_modules/@zarrita/core": { - "version": "0.1.0-next.17", - "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.1.0-next.17.tgz", - "integrity": "sha512-VTf1KWLz3vqX4IUdg1lJYBpHo/cT3NbpFQ47JOCEaVsmGv09So5yY7UkXPTQ6ef7oy9BFven7sD5EKSutiFn8A==", - "dependencies": { - "@zarrita/storage": "^0.1.0-next.8", - "@zarrita/typedarray": "^0.1.0-next.4", - "numcodecs": "^0.3.2" - } - }, - "node_modules/@zarrita/indexing": { - "version": "0.1.0-next.19", - "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.1.0-next.19.tgz", - "integrity": "sha512-GRu6CxtEeXnZUYR0Z/sEGFPcll7pG2Ek9muUkdpl5U5fUdPW7YaSj1tMBxDFhkgw9MNy87ksnN2VO4Tgjjl5fw==", - "dependencies": { - "@zarrita/core": "^0.1.0-next.17", - "@zarrita/storage": "^0.1.0-next.8", - "@zarrita/typedarray": "^0.1.0-next.4" - } - }, "node_modules/@zarrita/storage": { - "version": "0.1.0-next.8", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.0-next.8.tgz", - "integrity": "sha512-9d8bIaR2JuiG98gg5lF15Tpgc/+G/XOXlY53UtVP0LABwHbrIGFzpO2djzGB5bQe1jDj92VeXE8Z525Bs2vb6Q==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", + "integrity": "sha512-ZyCMYN3LuCNtKxro9876r/KyHyXV+ie2Bhk1qYsJR4Jp+sAjoVRRNNSJPsJxk64ZgFFezayO5S2hCu88/1Odwg==", + "license": "MIT", "dependencies": { "reference-spec-reader": "^0.2.0", "unzipit": "^1.4.3" } }, - "node_modules/@zarrita/typedarray": { - "version": "0.1.0-next.4", - "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.1.0-next.4.tgz", - "integrity": "sha512-sGqc5Ldh8nt/FE9gDA89OsL+FH37wgSxCF1Liv08O8SbZ4w3N48Wngv0EWAXvU1aSEdGhb2ABtSfErVDwnp45Q==" - }, "node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -953,7 +929,8 @@ "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", @@ -1103,22 +1080,20 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", "dependencies": { "fflate": "^0.8.0" } }, -<<<<<<< HEAD -======= "node_modules/ome-zarr.js": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/ome-zarr.js/-/ome-zarr.js-0.0.6.tgz", - "integrity": "sha512-3on46ct8z2NFCernQTUzRRRItfb5wemg7gZlJHk6NpoX98LYtfmVosiwRPlvmtsDTWysaaoee12parV4qp7DRg==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/ome-zarr.js/-/ome-zarr.js-0.0.17.tgz", + "integrity": "sha512-5t10obZtLX1Lwgo7kpqGdkPYJOAmRmTojCjhEiOlyDJ66zyyj7PCFN5CkUWsCQSvpWomZ9JwLsjEhv9rhJS4fQ==", "license": "BSD", "dependencies": { - "zarrita": "0.4.0-next.19" + "zarrita": "^0.5.0" } }, ->>>>>>> 055cbba2 (Update to use ome-zarr.js 0.0.6) "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1190,7 +1165,8 @@ "node_modules/reference-spec-reader": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", - "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==" + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" }, "node_modules/rollup": { "version": "4.39.0", @@ -1332,6 +1308,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "license": "MIT", "dependencies": { "uzip-module": "^1.0.2" }, @@ -1342,7 +1319,8 @@ "node_modules/uzip-module": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", - "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==" + "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", + "license": "MIT" }, "node_modules/vite": { "version": "6.4.1", @@ -1471,13 +1449,13 @@ } }, "node_modules/zarrita": { - "version": "0.4.0-next.19", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.4.0-next.19.tgz", - "integrity": "sha512-7/O3ph+5BGnZ36Bc+DjMym2M1C/xY/klMn4V4N0FpOXFlAsUpvNqFqgYID1p8SdjJNh+aF4QFmuOoxceyz6KKA==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.5.4.tgz", + "integrity": "sha512-i88iN2+HqIQ+uiCEWLfhjbYNXAJD7IrM4h3lFwFclfqEOOhxp10amRWtqmgN5jbuy3+h0LwdyLVVzk4y9rTLgg==", + "license": "MIT", "dependencies": { - "@zarrita/core": "^0.1.0-next.17", - "@zarrita/indexing": "^0.1.0-next.19", - "@zarrita/storage": "^0.1.0-next.8" + "@zarrita/storage": "^0.1.3", + "numcodecs": "^0.3.2" } } } diff --git a/package.json b/package.json index d6c025a26..168c3241b 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,11 @@ "jquery": "^3.6.0", "marked": "^4.2.12", "mousetrap": "^1.6.5", - "ome-zarr.js": "^0.0.6", + "ome-zarr.js": "^0.0.17", "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", "vite-plugin-html-inject": "^1.1.2", - "zarrita": "^0.4.0-next.19" + "zarrita": "^0.5.4" } } diff --git a/src/css/figure.css b/src/css/figure.css index 731717b08..dd9f6f4e2 100644 --- a/src/css/figure.css +++ b/src/css/figure.css @@ -1037,7 +1037,7 @@ text-align: left; } - .lutOption span { + .lutOption span, .lutOption img { width: 85px; display: inline-block; } diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index e9f758866..aa4743297 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -1,5 +1,6 @@ import * as zarr from "zarrita"; -import { slice } from "@zarrita/indexing"; +import * as omezarr from "ome-zarr.js"; +import { slice } from "zarrita"; import _ from 'underscore'; const ZARRITA_ARRAY_CACHE = {}; @@ -211,14 +212,12 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) for (let p of paths) { let arrayAttrs = zarrays[p]; - console.log("checking path...", p, arrayAttrs.shape); let shape = arrayAttrs.shape; // E.g. if dataset shape is 1/2 of fullShape then crop size will be half let crop_w = region_width * shape.at(-1) / fullShape.at(-1); let crop_h = region_height * shape.at(-2) / fullShape.at(-2); if (crop_w * crop_h < MAX_SIZE * MAX_SIZE) { - console.log("crop_w * crop_h < MAX_SIZE", crop_w * crop_h, MAX_SIZE * MAX_SIZE); array_rect = { x: Math.floor(region_x * shape.at(-1) / fullShape.at(-1)), y: Math.floor(region_y * shape.at(-2) / fullShape.at(-2)), @@ -229,7 +228,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) break; } } - console.log("Orign array_rect", array_rect); // We can create canvas of the size of the array_rect const canvas = document.createElement("canvas"); @@ -243,7 +241,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) return; } - console.log("Init zarr.FetchStore:", source + "/" + path); let storeArrayPath = source + "/" + path; let arr; if (ZARRITA_ARRAY_CACHE[storeArrayPath]) { @@ -261,16 +258,17 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) let activeChIndicies = []; let colors = []; let minMaxValues = []; + let luts = []; + let inverteds = []; channels.forEach((ch, index) => { if (ch.active) { activeChIndicies.push(index); colors.push(hexToRGB(ch.color)); minMaxValues.push([ch.window.start, ch.window.end]); + luts.push(ch.color.endsWith(".lut") ? ch.color : undefined); + inverteds.push(ch.reverseIntensity); } }); - // console.log("activeChIndicies", activeChIndicies); - // console.log("colors", colors); - // console.log("minMaxValues", minMaxValues); // Need same logic as https://github.com/ome/omero-figure/blob/9cc36cde05bde4def7b62b07c2e9ae5f66712e96/omero_figure/views.py#L310 // if we have a crop region outside the bounds of the image @@ -297,11 +295,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) array_rect.height = size_y - array_rect.y; } - console.log("array_shape", array_shape); - console.log("array_rect", array_rect); - console.log("paste_x", paste_x); - console.log("paste_y", paste_y); - let promises = activeChIndicies.map((chIndex) => { let sliceKey = []; let slices = shape.map((dimSize, index) => { @@ -370,112 +363,6 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) return dataUrl; } - -export function renderTo8bitArray(ndChunks, minMaxValues, colors) { - // Render chunks (array) into 2D 8-bit data for new ImageData(arr) - // ndChunks is list of zarr arrays - - // assume all chunks are same shape - const shape = ndChunks[0].shape; - const height = shape[0]; - const width = shape[1]; - const pixels = height * width; - - if (!minMaxValues) { - minMaxValues = ndChunks.map(getMinMaxValues); - } - - // let rgb = [255, 255, 255]; - - let rgba = new Uint8ClampedArray(4 * height * width).fill(0); - let offset = 0; - for (let y = 0; y < pixels; y++) { - for (let p = 0; p < ndChunks.length; p++) { - let rgb = colors[p]; - let data = ndChunks[p].data; - let range = minMaxValues[p]; - let rawValue = data[y]; - let fraction = (rawValue - range[0]) / (range[1] - range[0]); - // for red, green, blue, - for (let i = 0; i < 3; i++) { - // rgb[i] is 0-255... - let v = (fraction * rgb[i]) << 0; - // increase pixel intensity if value is higher - rgba[offset * 4 + i] = Math.max(rgba[offset * 4 + i], v); - } - } - rgba[offset * 4 + 3] = 255; // alpha - offset += 1; - } - - return rgba; -} - -export function getMinMaxValues(chunk2d) { - const data = chunk2d.data; - let maxV = 0; - let minV = Infinity; - let length = chunk2d.data.length; - for (let y = 0; y < length; y++) { - let rawValue = data[y]; - maxV = Math.max(maxV, rawValue); - minV = Math.min(minV, rawValue); - } - return [minV, maxV]; -} - -export const MAX_CHANNELS = 4; -export const COLORS = { - cyan: "#00FFFF", - yellow: "#FFFF00", - magenta: "#FF00FF", - red: "#FF0000", - green: "#00FF00", - blue: "#0000FF", - white: "#FFFFFF", -}; -export const MAGENTA_GREEN = [COLORS.magenta, COLORS.green]; -export const RGB = [COLORS.red, COLORS.green, COLORS.blue]; -export const CYMRGB = Object.values(COLORS).slice(0, -2); - -export function getDefaultVisibilities(n) { - let visibilities; - if (n <= MAX_CHANNELS) { - // Default to all on if visibilities not specified and less than 6 channels. - visibilities = Array(n).fill(true); - } else { - // If more than MAX_CHANNELS, only make first set on by default. - visibilities = [ - ...Array(MAX_CHANNELS).fill(true), - ...Array(n - MAX_CHANNELS).fill(false), - ]; - } - return visibilities; -} - -export function getDefaultColors(n, visibilities) { - let colors = []; - if (n == 1) { - colors = [COLORS.white]; - } else if (n == 2) { - colors = MAGENTA_GREEN; - } else if (n === 3) { - colors = RGB; - } else if (n <= MAX_CHANNELS) { - colors = CYMRGB.slice(0, n); - } else { - // Default color for non-visible is white - colors = Array(n).fill(COLORS.white); - // Get visible indices - const visibleIndices = visibilities.flatMap((bool, i) => (bool ? i : [])); - // Set visible indices to CYMRGB colors. visibleIndices.length === MAX_CHANNELS from above. - for (const [i, visibleIndex] of visibleIndices.entries()) { - colors[visibleIndex] = CYMRGB[i]; - } - } - return colors.map(hexToRGB); -} - export function hexToRGB(hex) { if (hex.startsWith("#")) hex = hex.slice(1); const r = parseInt(hex.slice(0, 2), 16); diff --git a/src/js/views/channel_slider_view.js b/src/js/views/channel_slider_view.js index ab7231a10..7aefeb056 100644 --- a/src/js/views/channel_slider_view.js +++ b/src/js/views/channel_slider_view.js @@ -430,7 +430,7 @@ var ChannelSliderView = Backbone.View.extend({ 'max': max, 'step': (max - min > SLIDER_INCR_CUTOFF) ? 1 : 0.01, 'active': active, - 'lutBgPos': lutBgPos, + 'lutBgCss': lutBgCss, 'reverse': reverse, 'color': color, 'isDark': this.isDark(color) diff --git a/src/js/views/lutpicker.js b/src/js/views/lutpicker.js index 8045c9354..7ca65cd06 100644 --- a/src/js/views/lutpicker.js +++ b/src/js/views/lutpicker.js @@ -21,6 +21,7 @@ import Backbone from "backbone"; import $ from "jquery"; import _ from 'underscore'; import * as bootstrap from "bootstrap" +import * as omezarr from "ome-zarr.js"; import lut_picker_template from '../../templates/lut_picker.template.html?raw'; import { showModal, getJson } from "./util"; @@ -38,6 +39,10 @@ var LutPickerView = Backbone.View.extend({ initialize:function () { this.lutModal = new bootstrap.Modal('#lutpickerModal'); this.lut_names = []; + + // These will be overwritten with LUTs from OMERO if served by OMERO + this.luts = omezarr.getLuts(); + this.loadLuts(); }, @@ -58,13 +63,24 @@ var LutPickerView = Backbone.View.extend({ this.pickedLut = lutName; // Update preview to show LUT - var bgPos = this.getLutBackgroundPosition(lutName); - $(".lutPreview", this.el).css({'background-position': bgPos, 'background-image': `url(${this.lutsPngUrl})`}); + var lut = this.luts.find(lut => lut.name == lutName); + if (lut.png) { + $(".lutPreview", this.el).css({'background-position': '0 0', 'background-image': "url('" + lut.png + "')", 'background-size': '100% 100%'}); + } else { + var bgPos = this.getLutBackgroundPosition(lutName); + $(".lutPreview", this.el).css({'background-position': bgPos, 'background-image': `url(${this.lutsPngUrl})`}); + } // Enable OK button $("button[type='submit']", this.el).removeAttr('disabled'); }, loadLuts: async function(force_reload = false) { + if (!APP_SERVED_BY_OMERO) { + $(":root").css({ + "--pngHeight": "100%" + }); + return; + } var url = WEBGATEWAYINDEX + 'luts/'; let cors_headers = { mode: 'cors', credentials: 'include' }; if (force_reload || this.lut_names === undefined || this.lutsPngUrl === undefined || this.luts === undefined) { @@ -133,8 +149,7 @@ var LutPickerView = Backbone.View.extend({ if (options.success) { this.success = options.success; } - - this.loadLuts().then(() => this.render()); + this.render(); }, render:function() { @@ -148,6 +163,7 @@ var LutPickerView = Backbone.View.extend({ // Add css background-position to each lut to offset luts_10.png return {'bgPos': this.getLutBackgroundPosition(lut.name), 'name': lut.name, + 'png': lut.png, // if LUT is from omezarr.js, we have png in hand 'displayName': this.formatLutName(lut.name)}; }.bind(this)); @@ -159,4 +175,4 @@ var LutPickerView = Backbone.View.extend({ const FigureLutPicker = new LutPickerView(); -export default FigureLutPicker +export default FigureLutPicker; diff --git a/src/templates/channel_slider.template.html b/src/templates/channel_slider.template.html index 9ec01ade3..9d71d1e23 100644 --- a/src/templates/channel_slider.template.html +++ b/src/templates/channel_slider.template.html @@ -7,7 +7,7 @@ title="<%= _.escape(label) %>" class="btn btn-default channel-btn lutBg <% if (isDark) { print('font-white') } %>" data-index="<%= idx %>" - style="background-color:#<%= color %>; background-position: <%= lutBgPos %>;"> + style="background-color:#<%= color %>; <%= lutBgCss %>"> <%= _.escape(label) %>
 
@@ -72,7 +72,7 @@ min="<%= min %>" max="<%= max %>" step="<%= step %>" type="range" value="<%= startAvg %>" style="background-color:#<%=color%>; - --bgPos: <%= lutBgPos %>; + <%= lutBgCss %> --scaleX: <%= reverse ? -1 : 1 %>"/> <% if (i === parseInt(luts.length/2)) { %> diff --git a/src/test.html b/src/test.html new file mode 100644 index 000000000..a0097a161 --- /dev/null +++ b/src/test.html @@ -0,0 +1,25 @@ + + + + + Test + \ No newline at end of file From 89c41f0cca9aac7f51ac48d54b88134e2ddb4fa0 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 11:09:41 +0000 Subject: [PATCH 23/42] Remove APP_SERVED_BY_OMERO and Luts display logic --- omero_figure/views.py | 2 -- src/css/figure.css | 3 +- src/index.html | 2 -- src/js/views/channel_slider_view.js | 15 +++------- src/js/views/lutpicker.js | 34 ++++------------------ src/templates/channel_slider.template.html | 4 +-- src/templates/lut_picker.template.html | 6 +--- 7 files changed, 13 insertions(+), 53 deletions(-) diff --git a/omero_figure/views.py b/omero_figure/views.py index c0b65bb73..c62992a49 100644 --- a/omero_figure/views.py +++ b/omero_figure/views.py @@ -113,8 +113,6 @@ def index(request, file_id=None, conn=None, **kwargs): # Load the template html and replace OMEROWEB_INDEX template = loader.get_template("omero_figure/index.html") html = template.render({}, request) - html = html.replace('const APP_SERVED_BY_OMERO = false;', - 'const APP_SERVED_BY_OMERO = true;') omeroweb_index = reverse("index") figure_index = reverse("figure_index") ping_url = reverse("keepalive_ping") diff --git a/src/css/figure.css b/src/css/figure.css index dd9f6f4e2..96a58f15f 100644 --- a/src/css/figure.css +++ b/src/css/figure.css @@ -1037,7 +1037,7 @@ text-align: left; } - .lutOption span, .lutOption img { + .lutOption span { width: 85px; display: inline-block; } @@ -1052,7 +1052,6 @@ /* NB: when updating png, consider using different name to avoid cache */ background-size: 100% var(--pngHeight); background-image: var(--lutPng); - background-position: var(--bgPos); background-repeat: no-repeat; image-rendering: pixelated; /* Universal support since 2021 */ } diff --git a/src/index.html b/src/index.html index 808e7f6d2..3d1ff3471 100644 --- a/src/index.html +++ b/src/index.html @@ -8,8 +8,6 @@ // For dev, use this server to load/save figures (needs CORS enabled) const dev_omeroweb_url = "http://localhost:4080/"; // These are updated by views.py when serving this page... - // Flag for knowing if app is served from OMERO (or standalone) - const APP_SERVED_BY_OMERO = false; const BASE_OMEROWEB_URL = dev_omeroweb_url; const EXPORT_ENABLED = false; const SCRIPT_VERSION = ""; diff --git a/src/js/views/channel_slider_view.js b/src/js/views/channel_slider_view.js index 7aefeb056..a5253b9b6 100644 --- a/src/js/views/channel_slider_view.js +++ b/src/js/views/channel_slider_view.js @@ -401,17 +401,10 @@ var ChannelSliderView = Backbone.View.extend({ var active = actives.reduce(allEqualFn, actives[0]); var style = {'background-position': '0 0'} var lutBgPos = FigureLutPicker.getLutBackgroundPosition(color); - var lutPng = FigureLutPicker.getLutPng(color); - console.log("lutPng", lutPng); - var lutBgCss = '--bgPos: 0 0;'; - if (color.endsWith('.lut') && lutPng) { - // lutPng means we have LUT from ome-zarr.js. Apply without offset - lutBgCss = `--bgPos: 0 0; --lutPng: url('${lutPng}'); --pngHeight: 100%;`; + if (color.endsWith('.lut')) { + style['background-position'] = lutBgPos; color = "ccc"; - } else { - lutBgCss = `--bgPos: ${lutBgPos};` - } - if (color.toUpperCase() === "FFFFFF") { + } else if (color.toUpperCase() === "FFFFFF") { color = "ccc"; // white slider would be invisible } if (color == "FFFFFF") color = "ccc"; // white slider would be invisible @@ -430,7 +423,7 @@ var ChannelSliderView = Backbone.View.extend({ 'max': max, 'step': (max - min > SLIDER_INCR_CUTOFF) ? 1 : 0.01, 'active': active, - 'lutBgCss': lutBgCss, + 'lutBgPos': lutBgPos, 'reverse': reverse, 'color': color, 'isDark': this.isDark(color) diff --git a/src/js/views/lutpicker.js b/src/js/views/lutpicker.js index 7ca65cd06..471fa730e 100644 --- a/src/js/views/lutpicker.js +++ b/src/js/views/lutpicker.js @@ -21,7 +21,6 @@ import Backbone from "backbone"; import $ from "jquery"; import _ from 'underscore'; import * as bootstrap from "bootstrap" -import * as omezarr from "ome-zarr.js"; import lut_picker_template from '../../templates/lut_picker.template.html?raw'; import { showModal, getJson } from "./util"; @@ -39,10 +38,6 @@ var LutPickerView = Backbone.View.extend({ initialize:function () { this.lutModal = new bootstrap.Modal('#lutpickerModal'); this.lut_names = []; - - // These will be overwritten with LUTs from OMERO if served by OMERO - this.luts = omezarr.getLuts(); - this.loadLuts(); }, @@ -63,24 +58,13 @@ var LutPickerView = Backbone.View.extend({ this.pickedLut = lutName; // Update preview to show LUT - var lut = this.luts.find(lut => lut.name == lutName); - if (lut.png) { - $(".lutPreview", this.el).css({'background-position': '0 0', 'background-image': "url('" + lut.png + "')", 'background-size': '100% 100%'}); - } else { - var bgPos = this.getLutBackgroundPosition(lutName); - $(".lutPreview", this.el).css({'background-position': bgPos, 'background-image': `url(${this.lutsPngUrl})`}); - } + var bgPos = this.getLutBackgroundPosition(lutName); + $(".lutPreview", this.el).css({'background-position': bgPos, 'background-image': `url(${this.lutsPngUrl})`}); // Enable OK button $("button[type='submit']", this.el).removeAttr('disabled'); }, loadLuts: async function(force_reload = false) { - if (!APP_SERVED_BY_OMERO) { - $(":root").css({ - "--pngHeight": "100%" - }); - return; - } var url = WEBGATEWAYINDEX + 'luts/'; let cors_headers = { mode: 'cors', credentials: 'include' }; if (force_reload || this.lut_names === undefined || this.lutsPngUrl === undefined || this.luts === undefined) { @@ -116,14 +100,6 @@ var LutPickerView = Backbone.View.extend({ return this.lutsPngUrl }, - getLutPng(lutName) { - // This will return undefined if the LUTs are loaded from OMERO - var lut = this.luts.find(lut => lut.name == lutName); - if (lut && lut.png) { - return lut.png; - } - }, - getLutBackgroundPosition: function(lutName) { var lutIndex = this.lut_names.indexOf(lutName); if (lutIndex > -1) { @@ -149,7 +125,8 @@ var LutPickerView = Backbone.View.extend({ if (options.success) { this.success = options.success; } - this.render(); + + this.loadLuts().then(() => this.render()); }, render:function() { @@ -163,7 +140,6 @@ var LutPickerView = Backbone.View.extend({ // Add css background-position to each lut to offset luts_10.png return {'bgPos': this.getLutBackgroundPosition(lut.name), 'name': lut.name, - 'png': lut.png, // if LUT is from omezarr.js, we have png in hand 'displayName': this.formatLutName(lut.name)}; }.bind(this)); @@ -175,4 +151,4 @@ var LutPickerView = Backbone.View.extend({ const FigureLutPicker = new LutPickerView(); -export default FigureLutPicker; +export default FigureLutPicker diff --git a/src/templates/channel_slider.template.html b/src/templates/channel_slider.template.html index 9d71d1e23..9ec01ade3 100644 --- a/src/templates/channel_slider.template.html +++ b/src/templates/channel_slider.template.html @@ -7,7 +7,7 @@ title="<%= _.escape(label) %>" class="btn btn-default channel-btn lutBg <% if (isDark) { print('font-white') } %>" data-index="<%= idx %>" - style="background-color:#<%= color %>; <%= lutBgCss %>"> + style="background-color:#<%= color %>; background-position: <%= lutBgPos %>;"> <%= _.escape(label) %>
 
@@ -72,7 +72,7 @@ min="<%= min %>" max="<%= max %>" step="<%= step %>" type="range" value="<%= startAvg %>" style="background-color:#<%=color%>; - <%= lutBgCss %> + --bgPos: <%= lutBgPos %>; --scaleX: <%= reverse ? -1 : 1 %>"/> <% if (i === parseInt(luts.length/2)) { %> From f6888ad218328a03e56e59e67eb61303e0298938 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 11:49:30 +0000 Subject: [PATCH 24/42] Remove unsupported importFromRemote() for adding public remote OMERO images --- src/js/views/modal_views.js | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/src/js/views/modal_views.js b/src/js/views/modal_views.js index bd431df84..7ba63695b 100644 --- a/src/js/views/modal_views.js +++ b/src/js/views/modal_views.js @@ -521,42 +521,13 @@ import { hideModal } from "./util"; if (!idInput || idInput.length === 0) return; // test for E.g: http://localhost:8000/webclient/?show=image-25|image-26|image-27 - if (idInput.indexOf('?') > 10) { + if (idInput.indexOf('?show') > 10) { iIds = idInput.split('image-').slice(1); - } else if (idInput.indexOf('img_detail') > 0) { - // url of image viewer... - this.importFromRemote(idInput); - return; } else { + // iIds could be Zarr URLs - NB: this will fail if a Zarr URL contains ',' iIds = idInput.split(','); } this.model.addImages(iIds); }, - - importFromRemote: function(img_detail_url) { - var iid = parseInt(img_detail_url.split('img_detail/')[1], 10), - baseUrl = img_detail_url.split('/img_detail')[0], - // http://jcb-dataviewer.rupress.org/jcb/imgData/25069/ - imgDataUrl = baseUrl + '/imgData/' + iid + "/"; - - var colCount = 1, - rowCount = 1, - paper_width = this.model.get('paper_width'), - c = this.figureView.getCentre(), - col = 0, - row = 0, - px, py, spacer, scale, - coords = {'px': px, - 'py': py, - 'c': c, - 'spacer': spacer, - 'colCount': colCount, - 'rowCount': rowCount, - 'col': col, - 'row': row, - 'paper_width': paper_width}; - - this.model.importImage(imgDataUrl, coords, baseUrl); - }, }); From d58c7a4a2184e30db9fde36c39a77833a806b4cf Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 12:12:45 +0000 Subject: [PATCH 25/42] Handle zarr urls that end in / --- src/js/models/figure_model.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index eb6fbd77f..5a167b520 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -520,7 +520,7 @@ var invalidIds = []; for (var i=0; i Date: Thu, 22 Jan 2026 12:14:18 +0000 Subject: [PATCH 26/42] Info panel for zarr imagess shows URL instead of Image ID and Edit ID button --- src/js/models/figure_model.js | 17 ----------------- src/templates/info_panel.template.html | 7 +++++++ 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 5a167b520..376c51d0b 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -615,23 +615,6 @@ return; } - // coords.spacer = coords.spacer || data.size.width/20; - // var full_width = (coords.colCount * (data.size.width + coords.spacer)) - coords.spacer, - // full_height = (coords.rowCount * (data.size.height + coords.spacer)) - coords.spacer; - // coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); - // coords.scale = Math.min(coords.scale, 1); // only scale down - // // For the FIRST IMAGE ONLY (coords.px etc undefined), we - // // need to work out where to start (px,py) now that we know size of panel - // // (assume all panels are same size) - // coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; - // coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; - - // // calculate panel coordinates from index... - // var row = parseInt(index / coords.colCount, 10); - // var col = index % coords.colCount; - // var panelX = coords.px + ((data.size.width + coords.spacer) * coords.scale * col); - // var panelY = coords.py + ((data.size.height + coords.spacer) * coords.scale * row); - // ****** This is the Data Model ****** //------------------------------------- // Any changes here will create a new version diff --git a/src/templates/info_panel.template.html b/src/templates/info_panel.template.html index 7d97cb8b0..5154d290b 100644 --- a/src/templates/info_panel.template.html +++ b/src/templates/info_panel.template.html @@ -14,6 +14,12 @@ <% }) %> + <% if (isNaN(imageId)) { %> +
URL:
+
+ <%= imageId %> +
+ <% } else { %>
Image ID:
<%= imageId %> @@ -24,6 +30,7 @@ Edit ID
+ <% } %>
Dimensions (XY):
<%= orig_width %> x <%= orig_height %>
From 9f9c52d794697f8a04fd6fc9d0d613277286bc03 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 15:22:21 +0000 Subject: [PATCH 27/42] Improve error handling when loading Zarr images --- src/js/models/figure_model.js | 5 +++++ src/js/models/zarr_utils.js | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 376c51d0b..fcfb6d2ad 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -572,6 +572,11 @@ this.set('loading_count', this.get('loading_count') + 1); let panel_json = await loadZarrForPanel(zarrUrl); + if (panel_json.Error) { + alert(`Error loading Zarr ${zarrUrl}: ${panel_json.Error}`); + this.set('loading_count', this.get('loading_count') - 1); + return; + } // coords (px, py etc) are incremented for each panel added this.updateCoordsAndPanelCoords(panel_json, coords, index) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index aa4743297..c7004471c 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -11,13 +11,18 @@ export async function loadZarrForPanel(zarrUrl) { // we load smallest array. Only need it for min/max values if not in 'omero' metadata let datasetIndex = -1; - const {arr, shapes, multiscale, omero, scales, zarr_version} = await omezarr.getMultiscaleWithArray(store, datasetIndex); - console.log({arr, shapes, multiscale, omero, scales, zarr_version}) + let msWithArray; + try { + msWithArray = await omezarr.getMultiscaleWithArray(store, datasetIndex); + } catch (error) { + console.error("Error loading Zarr:", error); + return {"Error": error.toString()}; + } + const {arr, shapes, multiscale, omero, scales, zarr_version} = msWithArray; let zarrName = zarrUrl.split("/").pop(); console.log("multiscale", multiscale); if (!multiscale) { - alert(`Failed to load multiscale from ${zarrUrl}`); - return; + return {"Error": `Failed to load multiscale`}; } let imgName = multiscale.name || zarrName; From 5974b665403a923ee5998dd820a0c5a6bd19d3d6 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 21:57:51 +0000 Subject: [PATCH 28/42] Store zarr_version in figure.json --- src/js/models/zarr_utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index c7004471c..a74e03d46 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -18,6 +18,7 @@ export async function loadZarrForPanel(zarrUrl) { console.error("Error loading Zarr:", error); return {"Error": error.toString()}; } + console.log("msWithArray", msWithArray); const {arr, shapes, multiscale, omero, scales, zarr_version} = msWithArray; let zarrName = zarrUrl.split("/").pop(); console.log("multiscale", multiscale); @@ -42,6 +43,7 @@ export async function loadZarrForPanel(zarrUrl) { multiscales: [multiscale], } zarr_attrs["arrays"] = zarrays; + zarr_attrs["zarr_version"] = zarr_version; let zarray = zarrays[0]; console.log("zarray", zarray); From fd5121acd236aaaeba564aad66b247587b781edc Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 22 Jan 2026 22:00:16 +0000 Subject: [PATCH 29/42] Avoid duplicate loading of chunks when rendering When we adjusst rendering settings, right panel and figure panel both render at the same time. We don't want to duplicate loading of chunks, so if the request is already pending, wait for it to complete the use the cached value --- src/js/models/zarr_utils.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index a74e03d46..0b033a0ba 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -331,16 +331,29 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) return 0; }); let cacheKey = `${source}/${path}/${sliceKey.join(",")}`; - // console.log("cacheKey", cacheKey); - // console.log("Zarr chIndex slices:", chIndex, "" + slices); - // console.log("Zarr chIndex shape:", chIndex, shape); - // TODO: add controller: { opts: { signal: controller.signal } } - // check cache! + // If we have requested slice in cache, return that instead of loading chunks... if (ZARR_DATA_CACHE[cacheKey]) { - console.log("RETURN cache!", ZARR_DATA_CACHE[cacheKey]); - return ZARR_DATA_CACHE[cacheKey]; + if (ZARR_DATA_CACHE[cacheKey] !== "pending") { + console.log("RETURN cache!", ZARR_DATA_CACHE[cacheKey]); + return ZARR_DATA_CACHE[cacheKey]; + } else { + // data is pending... + console.log("PENDING cache...", cacheKey); + // wait until data is populated, check every 100ms + return new Promise((resolve, reject) => { + let checkInterval = setInterval(() => { + if (ZARR_DATA_CACHE[cacheKey] && ZARR_DATA_CACHE[cacheKey] !== "pending") { + console.log("RESOLVE pending cache!", ZARR_DATA_CACHE[cacheKey]); + clearInterval(checkInterval); + resolve(ZARR_DATA_CACHE[cacheKey]); + } + }, 100); + }); + } } + // "pending" flag to avoid duplicate loads + ZARR_DATA_CACHE[cacheKey] = "pending"; return zarr.get(arr, slices).then((data) => { console.log("populate cache..."); ZARR_DATA_CACHE[cacheKey] = data; From 4d16cab9aa8d41fcff4afcd5270f44c4d318f034 Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 23 Jan 2026 12:21:13 +0000 Subject: [PATCH 30/42] Write OME-Zarr version to figure.json --- src/js/models/zarr_utils.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 0b033a0ba..49bba9929 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -45,6 +45,14 @@ export async function loadZarrForPanel(zarrUrl) { zarr_attrs["arrays"] = zarrays; zarr_attrs["zarr_version"] = zarr_version; + // TODO: look-up OME-Zarr version or get it from omezarr + // For now, if it's not in multiscale.version, assume v0.5 + if (multiscale.version) { + zarr_attrs["version"] = multiscale.version; + } else if (zarr_version == "3") { + zarr_attrs["version"] = "0.5"; + } + let zarray = zarrays[0]; console.log("zarray", zarray); From c5ee8f1310ae058b165b375e3346fd9c81bc4116 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 29 Jan 2026 11:46:39 +0000 Subject: [PATCH 31/42] renderZarrToSrc() picks closest to targetSize (2 x size on page) --- src/js/models/panel_model.js | 8 +++--- src/js/models/zarr_utils.js | 47 +++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index f0d0809bf..aebd0d5f7 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -977,7 +977,7 @@ return this.get('orig_width') * this.get('orig_height') > MAX_PLANE_SIZE; }, - get_zarr_img_src: async function(force_no_padding) { + get_zarr_img_src: async function(force_no_padding, targetSize) { var rect; if (this.is_big_image()) { rect = this.getViewportAsRect(); @@ -989,13 +989,15 @@ rect.height = length; } } - return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels'), rect); + return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels'), rect, targetSize); }, get_img_src: async function(force_no_padding) { // async function since zarr src is the rendered image data if (this.get("zarr")) { - return this.get_zarr_img_src(force_no_padding); + // use current size to choose resolution level... + const targetSize = 2 * Math.max(this.get('width'), this.get('height')); + return this.get_zarr_img_src(force_no_padding, targetSize); } var chs = this.get('channels'); var cStrings = chs.map(function(c, i){ diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 49bba9929..da86385b8 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -200,7 +200,7 @@ function dtypeToPixelsType(dtype) { return dt; }; -export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) { +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 let axes = attrs.multiscales[0].axes?.map((a) => a.name || a); @@ -208,8 +208,7 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) axes = axes || ["t", "c", "z", "y", "x"]; let zarrays = attrs.arrays; - // Pick first resolution that is below a max size... - const MAX_SIZE = 500; + // Pick resolution where crop size is closest to targetSize // use the arrays themselves to determine 'scale', since we might // not have 'coordinateTransforms' for pre-v0.4 etc. let path; @@ -221,28 +220,32 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) let region_y = rect?.y || 0; let region_width = rect?.width || fullShape.at(-1); let region_height = rect?.height || fullShape.at(-2); - - // crop region for the downscaled array - let array_rect; - - for (let p of paths) { - let arrayAttrs = zarrays[p]; - let shape = arrayAttrs.shape; - // E.g. if dataset shape is 1/2 of fullShape then crop size will be half - let crop_w = region_width * shape.at(-1) / fullShape.at(-1); - let crop_h = region_height * shape.at(-2) / fullShape.at(-2); - - if (crop_w * crop_h < MAX_SIZE * MAX_SIZE) { - array_rect = { - x: Math.floor(region_x * shape.at(-1) / fullShape.at(-1)), - y: Math.floor(region_y * shape.at(-2) / fullShape.at(-2)), - width: Math.floor(crop_w), - height: Math.floor(crop_h), + let region_max_size = Math.max(region_width, region_height); + + let dsScales = paths.map((p) => zarrays[p].shape.at(-1) / fullShape.at(-1)); + // E.g. if dataset shape is 1/2 of fullShape then crop size will be half + let cropSizes = dsScales.map((s) => region_max_size * s); + + // find the closest matching size... + let targetScale; + for (let i = 0; i < cropSizes.length; i++) { + if (cropSizes[i] <= targetSize) { + if (Math.abs(cropSizes[i] - targetSize) < Math.abs(cropSizes[Math.max(0, i - 1)] - targetSize)) { + path = paths[i]; + targetScale = dsScales[i]; + } else { + path = paths[Math.max(0, i - 1)]; + targetScale = dsScales[Math.max(0, i - 1)]; } - path = p; break; } } + let array_rect = { + x: Math.floor(region_x * targetScale), + y: Math.floor(region_y * targetScale), + width: Math.floor(region_width * targetScale), + height: Math.floor(region_height * targetScale), + }; // We can create canvas of the size of the array_rect const canvas = document.createElement("canvas"); @@ -251,7 +254,7 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect) if (!path) { console.error( - `Lowest resolution too large for rendering: > ${MAX_SIZE} x ${MAX_SIZE}` + `Lowest resolution too large for rendering: > ${targetSize} x ${targetSize}` ); return; } From f16ad188e1cbc3ee83e0ec1bd2e661a3d2c086e1 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 29 Jan 2026 13:37:52 +0000 Subject: [PATCH 32/42] Handle images with Z-downsampling, and with missing 'omero' metadata --- src/js/models/zarr_utils.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index da86385b8..f8d895c09 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -87,9 +87,13 @@ export async function loadZarrForPanel(zarrUrl) { "FFFFFF", ]; let channels = omero?.channels; + let indices = {}; + if (axesNames.includes("z")) { + indices["z"] = defaultZ; + } if (!channels) { // load smallest array to get min/max values for every channel - let slices = omezarr.getSlices(_.range(sizeC), shape, axesNames, {}); + let slices = omezarr.getSlices(_.range(sizeC), arr.shape, axesNames, indices, shapes[0]); let promises = slices.map((chSlice) => zarr.get(arr, chSlice)); let ndChunks = await Promise.all(promises); @@ -247,6 +251,11 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect, height: Math.floor(region_height * targetScale), }; + // Handle any Z-downsampling... + let sizeZ = zarrays[paths[0]].shape[axes.indexOf("z")]; + let cropZ = zarrays[path].shape[axes.indexOf("z")]; + theZ = Math.floor(theZ *cropZ / sizeZ); + // We can create canvas of the size of the array_rect const canvas = document.createElement("canvas"); canvas.width = array_rect.width; From 2373cd1ab1e9c5013b9f5fcb4cf4c55ae7552f09 Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 29 Jan 2026 16:18:49 +0000 Subject: [PATCH 33/42] Handle OME-Zarr 't' axes with units to populate deltaT timestamps --- src/js/models/zarr_utils.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index f8d895c09..3dd291e3f 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -113,6 +113,35 @@ export async function loadZarrForPanel(zarrUrl) { }); } + let deltaT = []; + if (axesNames.includes("t")) { + // if we have time units... + let timeAxis = axes.find((a) => a.name == "t"); + if (timeAxis && timeAxis.unit) { + let secsIncrement = 1; + let scaleTransform0 = multiscale.datasets[0].coordinateTransformations?.find( + (ct) => ct.type == "scale" + ); + if (scaleTransform0) { + secsIncrement = scaleTransform0.scale[axesNames.indexOf("t")]; + } + const LENGTH_UNITS = { + "nanosecond": 1e-9, + "microsecond": 1e-6, + "millisecond": 1e-3, + "second": 1, + "minute": 60, + "hour": 3600, + }; + if (LENGTH_UNITS[timeAxis.unit]) { + secsIncrement = secsIncrement * LENGTH_UNITS[timeAxis.unit]; + for(let t = 0; t < sizeT; t++) { + deltaT.push(t * secsIncrement); + } + } + } + } + let panelX = 0; let panelY = 0; let coords = {scale: 1}; @@ -146,6 +175,10 @@ export async function loadZarrForPanel(zarrUrl) { zarr: zarr_attrs, }; + if (deltaT.length > 0) { + n['deltaT'] = deltaT; + } + // handle pixel sizes if available // Use 'scale' from first 'coordinateTransforms' if available let datasetScale = multiscale.datasets[0].coordinateTransformations?.find( From b0c3d1407d74eb1659587cceb671d5c4b3d0b791 Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 30 Jan 2026 11:31:20 +0000 Subject: [PATCH 34/42] Show spinner on figure panels while images are loading --- src/js/views/panel_view.js | 5 ++++- src/js/views/right_panel_view.js | 2 ++ src/templates/figure_panel.template.html | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js/views/panel_view.js b/src/js/views/panel_view.js index ef64894ad..c5a8d5eea 100644 --- a/src/js/views/panel_view.js +++ b/src/js/views/panel_view.js @@ -194,9 +194,12 @@ // But we don't want the previous image showing while we wait... if (this.model.is_big_image()) { this.$img_panel.hide(); - $(".image_panel_spinner", this.$el).show(); } + let timeoutId = setTimeout(() => { + $(".image_panel_spinner", this.$el).show(); + }, 100); // Show spinner only if image load takes longer than 100ms this.$img_panel.one("load", function(){ + clearTimeout(timeoutId); $(".image_panel_spinner", this.$el).hide(); this.$img_panel.show(); }.bind(this)); diff --git a/src/js/views/right_panel_view.js b/src/js/views/right_panel_view.js index e2c341f55..f7d981bd7 100644 --- a/src/js/views/right_panel_view.js +++ b/src/js/views/right_panel_view.js @@ -1125,6 +1125,8 @@ document.getElementById(img_id).src = src; }); var img_css = m.get_vp_img_css(m.get('zoom'), frame_w, frame_h); + // placeholder transparent gif + img_css.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" img_css.id = img_id; // if a 'reasonable' dpi is set, we don't pixelate var dpiSet = m.get('min_export_dpi') > 100; diff --git a/src/templates/figure_panel.template.html b/src/templates/figure_panel.template.html index 65a808962..087acdcde 100644 --- a/src/templates/figure_panel.template.html +++ b/src/templates/figure_panel.template.html @@ -1,6 +1,6 @@
-
+
From 300d0fff8812696f0180037d20433a7ba0390759 Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 30 Jan 2026 12:04:35 +0000 Subject: [PATCH 35/42] Labels from KVP or Tags ignores zarr images --- src/js/models/panel_model.js | 6 +++++- src/js/views/labels_from_maps_modal.js | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index aebd0d5f7..bf007b30a 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -1466,7 +1466,11 @@ createLabelsFromTags: function(options) { // Loads Tags for selected images and creates labels - var image_ids = this.map(function(s){return s.get('imageId')}) + var image_ids = this.map(function(s){return s.get('imageId')}); + image_ids = _.uniq(image_ids).filter(id => !isNaN(id)); // ignore zarr images + if (image_ids.length === 0) { + return; + } image_ids = "image=" + image_ids.join("&image="); // TODO: Use /api/ when annotations is supported var url = WEBINDEX_URL + "api/annotations/?type=tag&parents=true&limit=1000&" + image_ids; diff --git a/src/js/views/labels_from_maps_modal.js b/src/js/views/labels_from_maps_modal.js index 74b9252e9..462b52ba6 100644 --- a/src/js/views/labels_from_maps_modal.js +++ b/src/js/views/labels_from_maps_modal.js @@ -39,6 +39,12 @@ export var LabelFromMapsModal = Backbone.View.extend({ */ loadMapAnns() { let imageIds = this.model.getSelected().map(function(m){return m.get('imageId')}); + imageIds = _.uniq(imageIds).filter(id => !isNaN(id)); // ignore zarr images + if (imageIds.length === 0) { + this.annotations = []; + this.render(); + return; + } this.isLoading = true; $('select', this.el).html(""); From 81d4a414d8ea04f86852081467c7a20d34bb15cb Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 30 Jan 2026 14:09:51 +0000 Subject: [PATCH 36/42] Rename utils.getJson() to getJsonWithCredentials() --- src/js/models/figure_model.js | 7 +++---- src/js/views/chgrp_modal_view.js | 10 +++++++--- src/js/views/crop_modal_view.js | 4 ++-- src/js/views/labels_from_maps_modal.js | 4 ++-- src/js/views/lutpicker.js | 2 +- src/js/views/roi_modal_view.js | 6 +++--- src/js/views/util.js | 3 ++- 7 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index fcfb6d2ad..8960d9af5 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -7,7 +7,7 @@ import { recoverFigureFromStorage, clearFigureFromStorage, figureConfirmDialog, - getJson, + getJsonWithCredentials, saveFigureToStorage} from "../views/util"; import { loadZarrForPanel } from "./zarr_utils"; @@ -66,7 +66,7 @@ var load_url = BASE_WEBFIGURE_URL + "load_web_figure/" + fileId + "/", self = this; - getJson(load_url).then(data => { + getJsonWithCredentials(load_url).then(data => { data.fileId = fileId; self.load_from_JSON(data); self.set('unsaved', false); @@ -296,7 +296,7 @@ if (iids.length > 0) { var ptUrl = BASE_WEBFIGURE_URL + 'pixels_type/'; ptUrl += '?image=' + iids.join('&image='); - getJson(ptUrl).then(data => { + getJsonWithCredentials(ptUrl).then(data => { // Update all panels // NB: By the time that this callback runs, the panels will have been created self.panels.forEach(function(p){ @@ -540,7 +540,6 @@ }, updateCoordsAndPanelCoords(panel_json, coords, index) { - console.log("importZarrImage coords.spacer coords:", coords.spacer, coords); // update panel_json and coords coords.spacer = coords.spacer || panel_json.orig_width/20; var full_width = (coords.colCount * (panel_json.orig_width + coords.spacer)) - coords.spacer, diff --git a/src/js/views/chgrp_modal_view.js b/src/js/views/chgrp_modal_view.js index eab664664..984a16a86 100644 --- a/src/js/views/chgrp_modal_view.js +++ b/src/js/views/chgrp_modal_view.js @@ -5,7 +5,11 @@ import _ from "underscore"; import FigureModel from "../models/figure_model"; -import {figureConfirmDialog, hideModal, getJson} from "./util"; +import { + figureConfirmDialog, + hideModal, + getJsonWithCredentials +} from "./util"; export const ChgrpModalView = Backbone.View.extend({ @@ -41,7 +45,7 @@ export const ChgrpModalView = Backbone.View.extend({ loadImageDetails: function() { var imgIds = this.model.panels.pluck('imageId'); var url = `${BASE_WEBFIGURE_URL}images_details/?image=${_.uniq(imgIds).join(',')}`; - getJson(url).then(data => { + getJsonWithCredentials(url).then(data => { // Sort images by Group this.imagesByGroup = data.data.reduce(function(prev, img){ if (!prev[img.group.id]) { @@ -56,7 +60,7 @@ export const ChgrpModalView = Backbone.View.extend({ loadGroups: function() { var url = `${API_BASE_URL_V0}m/experimenters/${USER_ID}/experimentergroups/`; - getJson(url).then(data => { + getJsonWithCredentials(url).then(data => { this.omeroGroups = data.data.map(group => {return {id: group['@id'], name: group.Name}}) .filter(group => group.name != 'user'); this.render(); diff --git a/src/js/views/crop_modal_view.js b/src/js/views/crop_modal_view.js index 710081521..d6d3e27bb 100644 --- a/src/js/views/crop_modal_view.js +++ b/src/js/views/crop_modal_view.js @@ -6,7 +6,7 @@ import Raphael from "raphael"; import FigureModel from "../models/figure_model"; import RectView from "./raphael-rect"; -import {figureConfirmDialog, hideModal, getJson, rotatePoint} from "./util"; +import {figureConfirmDialog, hideModal, getJsonWithCredentials, rotatePoint} from "./util"; import crop_modal_roi_template from '../../templates/modal_dialogs/crop_modal_roi.template.html?raw'; @@ -399,7 +399,7 @@ export const CropModalView = Backbone.View.extend({ iid = self.m.get('imageId'); var offset = this.roisPageSize * this.roisPage; var url = BASE_WEBFIGURE_URL + 'roiRectangles/' + iid + '/?limit=' + self.roisPageSize + '&offset=' + offset; - getJson(url).then(rsp => { + getJsonWithCredentials(url).then(rsp => { var data = rsp.data; self.roisLoaded += data.length; self.roisPage += 1; diff --git a/src/js/views/labels_from_maps_modal.js b/src/js/views/labels_from_maps_modal.js index 462b52ba6..857dc4bad 100644 --- a/src/js/views/labels_from_maps_modal.js +++ b/src/js/views/labels_from_maps_modal.js @@ -4,7 +4,7 @@ import $ from "jquery"; import _ from "underscore"; import FigureModel from "../models/figure_model"; -import { getJson, hideModal } from "./util"; +import { getJsonWithCredentials, hideModal } from "./util"; export var LabelFromMapsModal = Backbone.View.extend({ @@ -51,7 +51,7 @@ export var LabelFromMapsModal = Backbone.View.extend({ var url = WEBINDEX_URL + "api/annotations/?type=map&parents=true&image="; url += imageIds.join("&image="); - getJson(url).then(data => { + getJsonWithCredentials(url).then(data => { if (data.hasOwnProperty('parents')){ data.parents.annotations.forEach(function(ann) { let class_ = ann.link.parent.class; diff --git a/src/js/views/lutpicker.js b/src/js/views/lutpicker.js index 471fa730e..304eb137e 100644 --- a/src/js/views/lutpicker.js +++ b/src/js/views/lutpicker.js @@ -23,7 +23,7 @@ import _ from 'underscore'; import * as bootstrap from "bootstrap" import lut_picker_template from '../../templates/lut_picker.template.html?raw'; -import { showModal, getJson } from "./util"; +import { showModal } from "./util"; // Need to handle dev vv built (omero-web) paths import lutsPng from "../../images/luts_10.png"; diff --git a/src/js/views/roi_modal_view.js b/src/js/views/roi_modal_view.js index d042bda27..b66ac4d93 100644 --- a/src/js/views/roi_modal_view.js +++ b/src/js/views/roi_modal_view.js @@ -13,7 +13,7 @@ import shape_toolbar_template from '../../templates/shapes/shape_toolbar.templat import shape_sidebar_template from '../../templates/shapes/shape_sidebar.template.html?raw'; import roi_zt_buttons from '../../templates/modal_dialogs/roi_zt_buttons.template.html?raw'; import RoiLoaderView from './roi_loader_view'; -import { hideModal, getJson } from "./util"; +import { hideModal, getJsonWithCredentials } from "./util"; const TEXT_ANCHOR_ICONS = { "start": "bi-text-left", @@ -182,7 +182,7 @@ export const RoiModalView = Backbone.View.extend({ .attr({'disabled': 'disabled'}); $btn.parent().attr('title', 'Checking for ROIs...'); // title on parent div - still works if btn disabled - getJson(url).then((data) => { + getJsonWithCredentials(url).then((data) => { this.omeroRoiCount = data.roi; this.renderSidebar(); }); @@ -222,7 +222,7 @@ export const RoiModalView = Backbone.View.extend({ var iid = this.m.get('imageId'); let url = BASE_OMEROWEB_URL + 'api/v0/m/rois/'; var roiUrl = url + '?image=' + iid + '&limit=' + this.roisPageSize + '&offset=' + (this.roisPageSize * this.roisPage); - getJson(roiUrl).then((data) => { + getJsonWithCredentials(roiUrl).then((data) => { this.Rois.set(data.data); $("#loadRois").prop('disabled', false); $("#roiModalRoiList table").empty(); diff --git a/src/js/views/util.js b/src/js/views/util.js index 063be36f3..fda50841f 100644 --- a/src/js/views/util.js +++ b/src/js/views/util.js @@ -256,7 +256,8 @@ export function hideModal(modalId) { thisModal.hide(); } -export async function getJson (url) { +export async function getJsonWithCredentials (url) { + // $.getJSON() also works for OMERO.web requests let cors_headers = { mode: 'cors', credentials: 'include' }; return fetch(url, cors_headers).then(rsp => rsp.json()); } From 7522bb40845288f0037842646b9dc5a0ab1bc91e Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 30 Jan 2026 14:10:29 +0000 Subject: [PATCH 37/42] Use 'http' to identify zarr URLs rather than 'zarr' --- src/js/models/figure_model.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 8960d9af5..59d459fb7 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -520,7 +520,7 @@ var invalidIds = []; for (var i=0; i Date: Fri, 30 Jan 2026 15:10:40 +0000 Subject: [PATCH 38/42] Handle bf2raw, plate, CORS and File not found for Zarr --- src/js/models/figure_model.js | 21 +++++++++++++++- src/js/models/zarr_utils.js | 36 ++++++++++++++++++++++++---- src/js/views/util.js | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 59d459fb7..728f6c9a1 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -572,7 +572,26 @@ let panel_json = await loadZarrForPanel(zarrUrl); if (panel_json.Error) { - alert(`Error loading Zarr ${zarrUrl}: ${panel_json.Error}`); + let zarrErr = panel_json.Error; + for (let fmt of ["bioformats2raw.layout", "OME-Zarr Plates"]) { + if (zarrErr.includes(fmt)) { + zarrErr = `Error loading Zarr ${zarrUrl}: +

${fmt} not currently supported.

+

Please + open in the OME-NGFF Validator to choose a single Image url.

+ `; + } + } + if (zarrErr.includes("File not found")) { + zarrErr = `Error loading Zarr from
${zarrUrl}: +

File not found (No zarr.json or .zattrs)

`; + } + figureConfirmDialog( + "Zarr Load Error", + zarrErr, + ["OK"] + ); + // alert(`Error loading Zarr ${zarrUrl}: ${panel_json.Error}`); this.set('loading_count', this.get('loading_count') - 1); return; } diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 3dd291e3f..b3f1554cb 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -2,11 +2,40 @@ import * as zarr from "zarrita"; import * as omezarr from "ome-zarr.js"; import { slice } from "zarrita"; import _ from 'underscore'; +import { getJson, FILE_NOT_FOUND } from "../views/util.js"; const ZARRITA_ARRAY_CACHE = {}; const ZARR_DATA_CACHE = {}; export async function loadZarrForPanel(zarrUrl) { + + // first check if we have a zarr image... + let zarrJson; + try { + zarrJson = await getJson(zarrUrl + "/zarr.json"); + zarrJson = zarrJson.attributes?.ome; // zarr v3 + } catch (error) { + console.log("Error loading zarr.json:", error); + if (error.toString().includes(FILE_NOT_FOUND)) { + try { + zarrJson = await getJson(zarrUrl + "/.zattrs"); + } catch (error2) { + return {"Error": error2.toString()}; + } + } else { + return {"Error": error.toString()}; + } + } + if (!zarrJson) { + return {"Error": "Failed to load Zarr metadata"}; + } + if (zarrJson["bioformats2raw.layout"]) { + return {"Error": "bioformats2raw.layout is not currently supported"}; + } + if (zarrJson.plate) { + return {"Error": "OME-Zarr Plates are not currently supported"}; + } + let store = new zarr.FetchStore(zarrUrl); // we load smallest array. Only need it for min/max values if not in 'omero' metadata @@ -45,12 +74,10 @@ export async function loadZarrForPanel(zarrUrl) { zarr_attrs["arrays"] = zarrays; zarr_attrs["zarr_version"] = zarr_version; - // TODO: look-up OME-Zarr version or get it from omezarr - // For now, if it's not in multiscale.version, assume v0.5 if (multiscale.version) { zarr_attrs["version"] = multiscale.version; } else if (zarr_version == "3") { - zarr_attrs["version"] = "0.5"; + zarr_attrs["version"] = zarrJson.version; } let zarray = zarrays[0]; @@ -266,7 +293,8 @@ export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect, // find the closest matching size... let targetScale; for (let i = 0; i < cropSizes.length; i++) { - if (cropSizes[i] <= targetSize) { + // if we've gone small enough, or at last one... + if (cropSizes[i] <= targetSize || i == cropSizes.length - 1) { if (Math.abs(cropSizes[i] - targetSize) < Math.abs(cropSizes[Math.max(0, i - 1)] - targetSize)) { path = paths[i]; targetScale = dsScales[i]; diff --git a/src/js/views/util.js b/src/js/views/util.js index fda50841f..35ee9345c 100644 --- a/src/js/views/util.js +++ b/src/js/views/util.js @@ -262,6 +262,51 @@ export async function getJsonWithCredentials (url) { return fetch(url, cors_headers).then(rsp => rsp.json()); } +export const FILE_NOT_FOUND = "File not found"; +export async function getJson(url) { + // This will throw an Error if 404 or CORS fails etc + return fetchHandleError(url).then(rsp => rsp.json()); +} + +async function fetchHandleError(url) { + let msg = `Error Loading ${url}:`; + let rsp; + try { + rsp = await fetch(url).then(function (response) { + if (!response.ok) { + // make the promise be rejected if we didn't get a 2xx response + // NB. statusText could be "Not Found" or "File not found" depending on server + // Standardise based on response.status + if (response.status == 404) { + msg += ` ${FILE_NOT_FOUND}`; + } else { + msg += ` ${response.statusText}`; + } + } else { + return response; + } + }); + } catch (error) { + console.log("check for CORS..."); + console.log(error); + try { + let corsRsp = await fetch(url, { mode: "no-cors" }); + console.log("corsRsp", corsRsp); + // If the 'no-cors' mode allows this to return, then we + // likely failed due to CORS in the original request + msg += " Failed due to CORS issues."; + } catch (anotherError) { + console.log("Even `no-cors` request failed!", anotherError); + // return the original error (same as anotherError?) + msg += ` ${error}`; + } + } + if (rsp) { + return rsp; + } + throw Error(msg); +} + export const RANDOM_NUMBER_RANGE = 100000000; export function getRandomId() { From f012966b688029017c03fd06b8dfb392522384b8 Mon Sep 17 00:00:00 2001 From: William Moore Date: Wed, 4 Feb 2026 11:33:09 +0000 Subject: [PATCH 39/42] boost targetSize for zarr rendering to 4 x size of panel --- src/js/models/panel_model.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index bf007b30a..363b9cd86 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -996,7 +996,8 @@ // async function since zarr src is the rendered image data if (this.get("zarr")) { // use current size to choose resolution level... - const targetSize = 2 * Math.max(this.get('width'), this.get('height')); + let targetSize = 4 * Math.max(this.get('width'), this.get('height')); + targetSize = Math.max(targetSize, 500); return this.get_zarr_img_src(force_no_padding, targetSize); } var chs = this.get('channels'); From 1a0bf21adfbde776ff98e417c816b68e3137f4c3 Mon Sep 17 00:00:00 2001 From: William Moore Date: Wed, 4 Feb 2026 11:47:42 +0000 Subject: [PATCH 40/42] Use higher targetSize for non-big images --- src/js/models/panel_model.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index 363b9cd86..14a9887ca 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -988,6 +988,10 @@ rect.width = length; rect.height = length; } + } else { + // Since we render the whole plane every time (no 'rect' here), caching will prevent reloading on pan/zoom. + // as long as targetSize doesn't change. So we can afford to render at higher resolution... + targetSize = Math.max(targetSize, 2000); } return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels'), rect, targetSize); }, From 03062bc197e09e7cfcefd73bd86fdb8530cfa819 Mon Sep 17 00:00:00 2001 From: William Moore Date: Fri, 6 Feb 2026 11:16:23 +0000 Subject: [PATCH 41/42] Handle any missing values in omero.channels --- src/js/models/zarr_utils.js | 40 +++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index b3f1554cb..9b61947e7 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -113,33 +113,43 @@ export async function loadZarrForPanel(zarrUrl) { "00FFFF", "FFFFFF", ]; - let channels = omero?.channels; + let chs = omero?.channels; let indices = {}; if (axesNames.includes("z")) { indices["z"] = defaultZ; } - if (!channels) { + // placeholder minmax values + let minMaxs = _.range(sizeC).map((idx) => [0, 255]); + let minMaxProvided = chs && chs.every((ch) => ch.window && ch.window.min != undefined && ch.window.max != undefined); + if (!minMaxProvided) { // load smallest array to get min/max values for every channel let slices = omezarr.getSlices(_.range(sizeC), arr.shape, axesNames, indices, shapes[0]); let promises = slices.map((chSlice) => zarr.get(arr, chSlice)); let ndChunks = await Promise.all(promises); - channels = _.range(sizeC).map((idx) => { - let mm = omezarr.getMinMaxValues(ndChunks[idx]); - return { - label: "Ch" + idx, - active: true, - color: default_colors[idx], - window: { - min: mm[0], - max: mm[1], - start: mm[0], - end: mm[1], - }, - }; + minMaxs = _.range(sizeC).map((idx) => { + return omezarr.getMinMaxValues(ndChunks[idx]); }); } + // use channel metadata if provided, otherwise default values or values from smallest array + let channels = _.range(sizeC).map((idx) => { + let ch = chs ? chs[idx] : null; + let mm = [ch?.window?.min ?? minMaxs[idx][0], ch?.window?.max ?? minMaxs[idx][1]]; + + return { + label: ch?.label || "Ch" + idx, + active: ch?.active !== undefined ? ch.active : true, + color: ch?.color || default_colors[idx % default_colors.length], + window: { + min: mm[0], + max: mm[1], + start: ch?.window?.start ?? mm[0], + end: ch?.window?.end ?? mm[1], + }, + }; + }); + let deltaT = []; if (axesNames.includes("t")) { // if we have time units... From 815a82d8bde844994cb6e02e9501b067750982af Mon Sep 17 00:00:00 2001 From: William Moore Date: Thu, 26 Feb 2026 12:30:51 +0000 Subject: [PATCH 42/42] fixes for zarr v0.2 images --- src/js/models/zarr_utils.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js index 9b61947e7..520bf910c 100644 --- a/src/js/models/zarr_utils.js +++ b/src/js/models/zarr_utils.js @@ -48,7 +48,7 @@ export async function loadZarrForPanel(zarrUrl) { return {"Error": error.toString()}; } console.log("msWithArray", msWithArray); - const {arr, shapes, multiscale, omero, scales, zarr_version} = msWithArray; + const {arr, multiscale, omero, scales, zarr_version} = msWithArray; let zarrName = zarrUrl.split("/").pop(); console.log("multiscale", multiscale); if (!multiscale) { @@ -80,7 +80,7 @@ export async function loadZarrForPanel(zarrUrl) { zarr_attrs["version"] = zarrJson.version; } - let zarray = zarrays[0]; + let zarray = zarrays[datasets[0].path]; console.log("zarray", zarray); let dtype = zarray.dtype; @@ -123,7 +123,7 @@ export async function loadZarrForPanel(zarrUrl) { let minMaxProvided = chs && chs.every((ch) => ch.window && ch.window.min != undefined && ch.window.max != undefined); if (!minMaxProvided) { // load smallest array to get min/max values for every channel - let slices = omezarr.getSlices(_.range(sizeC), arr.shape, axesNames, indices, shapes[0]); + let slices = omezarr.getSlices(_.range(sizeC), arr.shape, axesNames, indices, shape); let promises = slices.map((chSlice) => zarr.get(arr, chSlice)); let ndChunks = await Promise.all(promises); @@ -153,7 +153,7 @@ export async function loadZarrForPanel(zarrUrl) { let deltaT = []; if (axesNames.includes("t")) { // if we have time units... - let timeAxis = axes.find((a) => a.name == "t"); + let timeAxis = axes?.find((a) => a.name == "t"); if (timeAxis && timeAxis.unit) { let secsIncrement = 1; let scaleTransform0 = multiscale.datasets[0].coordinateTransformations?.find(