UNPKG

7.27 kBJavaScriptView Raw
1import { BoundsCheckError } from 'zarr';
2
3import { guessRgb, padTileWithZeros, byteSwapInplace } from './utils';
4import { DTYPE_VALUES } from '../constants';
5import HTTPStore from './httpStore';
6
7/**
8 * This class serves as a wrapper for fetching zarr data from a file server.
9 * */
10export default class ZarrLoader {
11 constructor({
12 data,
13 dimensions,
14 isRgb,
15 scale = 1,
16 translate = { x: 0, y: 0 }
17 }) {
18 let base;
19 if (Array.isArray(data)) {
20 [base] = data;
21 this.numLevels = data.length;
22 } else {
23 base = data;
24 this.numLevels = 1;
25 }
26 this.type = 'zarr';
27 this.scale = scale;
28 this.translate = translate;
29 this.isRgb = isRgb || guessRgb(base.shape);
30 this.dimensions = dimensions;
31
32 this._data = data;
33 this._dimIndices = new Map();
34 dimensions.forEach(({ field }, i) => this._dimIndices.set(field, i));
35
36 const { dtype, chunks } = base;
37 /* TODO: Use better dtype convention in DTYPE_LOOKUP.
38 * https://github.com/hms-dbmi/viv/issues/203
39 *
40 * This convension should probably _not_ describe endianness,
41 * since endianness is resolved when decoding the source arrayBuffers
42 * into TypedArrays. The dtype of the zarr array describes the dtype of the
43 * source but this is different from how the bytes end up being represented in
44 * memory client-side.
45 */
46 this.dtype = `<${dtype.slice(1)}`;
47 this.tileSize = chunks[this._dimIndices.get('x')];
48 }
49
50 get isPyramid() {
51 return Array.isArray(this._data);
52 }
53
54 get base() {
55 return this.isPyramid ? this._data[0] : this._data;
56 }
57
58 /**
59 * Returns image tiles at tile-position (x, y) at pyramidal level z.
60 * @param {number} x positive integer
61 * @param {number} y positive integer
62 * @param {number} z positive integer (0 === highest zoom level)
63 * @param {Array} loaderSelection, Array of valid dimension selections
64 * @returns {Object} data: TypedArray[], width: number (tileSize), height: number (tileSize)
65 */
66 async getTile({ x, y, z, loaderSelection = [], signal }) {
67 const { TypedArray } = DTYPE_VALUES[this.dtype];
68 const source = this._getSource(z);
69 const [xIndex, yIndex] = ['x', 'y'].map(k => this._dimIndices.get(k));
70
71 const dataRequests = loaderSelection.map(async sel => {
72 const chunkKey = this._serializeSelection(sel);
73 chunkKey[yIndex] = y;
74 chunkKey[xIndex] = x;
75
76 const key = source.keyPrefix + chunkKey.join('.');
77 let buffer;
78 try {
79 buffer = await source.store.getItem(key, { signal });
80 } catch (err) {
81 if (err.name !== 'AbortError') {
82 throw err;
83 }
84 return null;
85 }
86 let bytes = new Uint8Array(buffer);
87 if (source.compressor) {
88 bytes = await source.compressor.decode(bytes);
89 }
90 const data = new TypedArray(bytes.buffer);
91 if (source.dtype[0] === '>') {
92 // big endian
93 byteSwapInplace(data);
94 }
95 const width = source.chunks[xIndex];
96 const height = source.chunks[yIndex];
97 if (height < this.tileSize || width < this.tileSize) {
98 return padTileWithZeros(
99 { data, width, height },
100 this.tileSize,
101 this.tileSize
102 );
103 }
104 return data;
105 });
106 const data = await Promise.all(dataRequests);
107 if (source.store instanceof HTTPStore && signal?.aborted) return null;
108 return {
109 data,
110 width: this.tileSize,
111 height: this.tileSize
112 };
113 }
114
115 /**
116 * Returns full image panes (at level z if pyramid)
117 * @param {number} z positive integer (0 === highest zoom level)
118 * @param {Array} loaderSelection, Array of valid dimension selections
119 * @returns {Object} data: TypedArray[], width: number, height: number
120 */
121 async getRaster({ z, loaderSelection = [] }) {
122 const source = this._getSource(z);
123 const [xIndex, yIndex] = ['x', 'y'].map(k => this._dimIndices.get(k));
124
125 const dataRequests = loaderSelection.map(async sel => {
126 const chunkKey = this._serializeSelection(sel);
127 chunkKey[yIndex] = null;
128 chunkKey[xIndex] = null;
129 if (this.isRgb) {
130 chunkKey[chunkKey.length - 1] = null;
131 }
132 const { data } = await source.getRaw(chunkKey);
133 return data;
134 });
135
136 const data = await Promise.all(dataRequests);
137 const { shape } = source;
138 const width = shape[xIndex];
139 const height = shape[yIndex];
140 return { data, width, height };
141 }
142
143 /**
144 * Handles `onTileError` within deck.gl
145 * @param {Error} err Error thrown in tile layer
146 */
147 // eslint-disable-next-line class-methods-use-this
148 onTileError(err) {
149 if (!(err instanceof BoundsCheckError)) {
150 // Rethrow error if something other than tile being requested is out of bounds.
151 throw err;
152 }
153 }
154
155 /**
156 * Returns image width and height (at pyramid level z) without fetching data
157 * @param {number} z positive integer (0 === highest zoom level)
158 * @returns {Object} width: number, height: number
159 */
160 getRasterSize({ z }) {
161 const { shape } = this._getSource(z);
162 const [height, width] = ['y', 'x'].map(k => shape[this._dimIndices.get(k)]);
163 return { height, width };
164 }
165
166 /**
167 * Get the metadata associated with a Zarr image layer, in a human-readable format.
168 * @returns {Object} Metadata keys mapped to values.
169 */
170 // eslint-disable-next-line class-methods-use-this
171 getMetadata() {
172 return {};
173 }
174
175 _getSource(z) {
176 return typeof z === 'number' && this.isPyramid ? this._data[z] : this._data;
177 }
178
179 /**
180 * Returns valid zarr.js selection for ZarrArray.getRaw or ZarrArray.getRawChunk
181 * @param {Object} selection valid dimension selection
182 * @returns {Array} Array of indicies
183 *
184 * Valid dimension selections include:
185 * - Direct zarr.js selection: [1, 0, 0, 0]
186 * - Named selection object: { channel: 0, time: 2 } or { channel: "DAPI", time: 2 }
187 */
188 _serializeSelection(selection) {
189 // Just return copy if array-like zarr.js selection
190 if (Array.isArray(selection)) return [...selection];
191
192 const serialized = Array(this.dimensions.length).fill(0);
193 Object.entries(selection).forEach(([dimName, value]) => {
194 if (!this._dimIndices.has(dimName)) {
195 throw Error(
196 `Dimension "${dimName}" does not exist on loader.
197 Must be one of "${this.dimensions.map(d => d.field)}."`
198 );
199 }
200 const dimIndex = this._dimIndices.get(dimName);
201 switch (typeof value) {
202 case 'number': {
203 // ex. { channel: 0 }
204 serialized[dimIndex] = value;
205 break;
206 }
207 case 'string': {
208 const { values, type } = this.dimensions[dimIndex];
209 if (type === 'nominal' || type === 'ordinal') {
210 // ex. { channel: 'DAPI' }
211 serialized[dimIndex] = values.indexOf(value);
212 break;
213 } else {
214 // { z: 'DAPI' }
215 throw Error(
216 `Cannot use selection "${value}" for dimension "${dimName}" with type "${type}".`
217 );
218 }
219 }
220 default: {
221 throw Error(
222 `Named selection must be a string or number. Got ${value} for ${dimName}.`
223 );
224 }
225 }
226 });
227 return serialized;
228 }
229}