diff --git a/package-lock.json b/package-lock.json index 67dcd8ad..16add4a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", - "zarrita": "^0.4.0-next.17" + "zarrita": "^0.4.0-next.19" }, "devDependencies": { "sass": "^1.54.1", @@ -620,38 +620,38 @@ "dev": 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", @@ -1204,13 +1204,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 5b7dabb3..1c07ebe4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,6 @@ "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.4", - "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 b6b9bace..0da2569a 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -549,11 +549,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 059516cb..7ce73513 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -2,6 +2,7 @@ import Backbone from "backbone"; import _ from "underscore"; import $ from "jquery"; + import {renderZarrToSrc} from "./zarr_utils"; // Corresponds to css - allows us to calculate size of labels var LINE_HEIGHT = 1.43; @@ -870,7 +871,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 00000000..bb35cfc0 --- /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 3a8b01a4..8181b6da 100644 --- a/src/js/views/panel_view.js +++ b/src/js/views/panel_view.js @@ -191,8 +191,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) {