@vitessce/spatial-utils 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -15,4 +15,21 @@ export {
15
15
  getNgffAxes,
16
16
  getNgffAxesForTiff,
17
17
  coordinateTransformationsToMatrix,
18
+ physicalSizeToMatrix,
19
+ hexToRgb,
20
+ getStatsForResolution,
21
+ canLoadResolution,
22
+ normalizeCoordinateTransformations,
18
23
  } from './spatial.js';
24
+ export {
25
+ toRgbUIString,
26
+ getSingleSelectionStats,
27
+ getMultiSelectionStats,
28
+ getPhysicalSizeScalingMatrix,
29
+ getBoundingCube,
30
+ abbreviateNumber,
31
+ filterSelection,
32
+ } from './layer-controller.js';
33
+ export {
34
+ DOMAINS,
35
+ } from './constants.js';
@@ -1,9 +1,37 @@
1
- import { Matrix4 } from 'math.gl';
2
1
  import { viv } from '@vitessce/gl';
2
+ import { fromEntries } from '@vitessce/utils';
3
+ import { Matrix4 } from 'math.gl';
4
+
5
+ // Returns an rgb string for display, and changes the color (arr)
6
+ // to use a grey for light theme + white color or if the colormap is on.
7
+ export const toRgbUIString = (on, arr, theme) => {
8
+ const color = on || (theme === 'light' && arr?.every(i => i === 255))
9
+ ? [220, 220, 220]
10
+ : arr;
11
+ return `rgb(${color})`;
12
+ };
13
+
14
+ /**
15
+ * Ensure that the channel selection object does not have
16
+ * extra dimension keys, as this will cause Viv functions
17
+ * to throw errors about not being able to access the data.
18
+ * @param {*} loader
19
+ * @param {object} selection Mapping from dimension label to slice index.
20
+ * @returns {object} The filtered selection.
21
+ */
22
+ export function filterSelection(loader, selection) {
23
+ if (!selection) {
24
+ return selection;
25
+ }
26
+ const data = Array.isArray(loader) ? loader[loader.length - 1] : loader;
27
+ const { labels } = data;
28
+ return fromEntries(Object.entries(selection).filter(([key]) => labels.includes(key)));
29
+ }
3
30
 
4
31
  async function getSingleSelectionStats2D({ loader, selection }) {
5
32
  const data = Array.isArray(loader) ? loader[loader.length - 1] : loader;
6
- const raster = await data.getRaster({ selection });
33
+ const filteredSelection = filterSelection(loader, selection);
34
+ const raster = await data.getRaster({ selection: filteredSelection });
7
35
  const selectionStats = viv.getChannelStats(raster.data);
8
36
  const { domain, contrastLimits: slider } = selectionStats;
9
37
  return { domain, slider };
@@ -11,17 +39,18 @@ async function getSingleSelectionStats2D({ loader, selection }) {
11
39
 
12
40
  async function getSingleSelectionStats3D({ loader, selection }) {
13
41
  const lowResSource = loader[loader.length - 1];
42
+ const filteredSelection = filterSelection(lowResSource, selection);
14
43
  const { shape, labels } = lowResSource;
15
44
  // eslint-disable-next-line no-bitwise
16
45
  const sizeZ = shape[labels.indexOf('z')] >> (loader.length - 1);
17
46
  const raster0 = await lowResSource.getRaster({
18
- selection: { ...selection, z: 0 },
47
+ selection: { ...filteredSelection, z: 0 },
19
48
  });
20
49
  const rasterMid = await lowResSource.getRaster({
21
- selection: { ...selection, z: Math.floor(sizeZ / 2) },
50
+ selection: { ...filteredSelection, z: Math.floor(sizeZ / 2) },
22
51
  });
23
52
  const rasterTop = await lowResSource.getRaster({
24
- selection: { ...selection, z: Math.max(0, sizeZ - 1) },
53
+ selection: { ...filteredSelection, z: Math.max(0, sizeZ - 1) },
25
54
  });
26
55
  const stats0 = viv.getChannelStats(raster0.data);
27
56
  const statsMid = viv.getChannelStats(rasterMid.data);
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { toRgbUIString } from './layer-controller.js';
3
+
4
+ const GREY = [220, 220, 220];
5
+
6
+ describe('layer-controller/RasterChannelController.js', () => {
7
+ describe('toRgbUIString()', () => {
8
+ it('Maps color value to itself when colormap off and not white with dark theme', () => {
9
+ expect(toRgbUIString(false, [200, 200, 200], 'dark')).toEqual(`rgb(${[200, 200, 200]})`);
10
+ });
11
+ it('Maps color value to grey when colormap on with dark theme', () => {
12
+ expect(toRgbUIString(true, [200, 200, 200], 'dark')).toEqual(`rgb(${GREY})`);
13
+ });
14
+ it('Maps color value to grey when colormap on with light theme', () => {
15
+ expect(toRgbUIString(true, [200, 200, 200], 'light')).toEqual(`rgb(${GREY})`);
16
+ });
17
+ it('Maps color value to grey when colormap off and white with light theme', () => {
18
+ expect(toRgbUIString(false, [255, 255, 255], 'light')).toEqual(`rgb(${GREY})`);
19
+ });
20
+ it('Maps color value to grey when colormap off and white with light theme', () => {
21
+ expect(toRgbUIString(false, [200, 200, 200], 'light')).toEqual(`rgb(${[200, 200, 200]})`);
22
+ });
23
+ });
24
+ });
package/src/spatial.js CHANGED
@@ -53,6 +53,40 @@ export function getSourceFromLoader(loader, level) {
53
53
  return source;
54
54
  }
55
55
 
56
+ export const getStatsForResolution = (loader, resolution) => {
57
+ const { shape, labels } = loader[resolution];
58
+ const height = shape[labels.indexOf('y')];
59
+ const width = shape[labels.indexOf('x')];
60
+ const depth = shape[labels.indexOf('z')];
61
+ // eslint-disable-next-line no-bitwise
62
+ const depthDownsampled = Math.max(1, depth >> resolution);
63
+ // Check memory allocation limits for Float32Array (used in XR3DLayer for rendering)
64
+ const totalBytes = 4 * height * width * depthDownsampled;
65
+ return {
66
+ height, width, depthDownsampled, totalBytes,
67
+ };
68
+ };
69
+
70
+ export const canLoadResolution = (loader, resolution) => {
71
+ const {
72
+ totalBytes, height, width, depthDownsampled,
73
+ } = getStatsForResolution(
74
+ loader,
75
+ resolution,
76
+ );
77
+ const maxHeapSize = window.performance?.memory
78
+ && window.performance?.memory?.jsHeapSizeLimit / 2;
79
+ const maxSize = maxHeapSize || (2 ** 31) - 1;
80
+ // 2048 is a normal texture size limit although some browsers go larger.
81
+ return (
82
+ totalBytes < maxSize
83
+ && height <= 2048
84
+ && depthDownsampled <= 2048
85
+ && width <= 2048
86
+ && depthDownsampled > 1
87
+ );
88
+ };
89
+
56
90
  /**
57
91
  * Helper method to determine whether pixel data is interleaved and rgb or not.
58
92
  * @param {object} loader
@@ -77,6 +111,42 @@ export function isRgb(loader, channels) {
77
111
 
78
112
  // From spatial/utils.js
79
113
 
114
+ export function physicalSizeToMatrix(xSize, ySize, zSize, xUnit, yUnit, zUnit) {
115
+ const hasZPhyscialSize = Boolean(zSize) && Boolean(zUnit);
116
+ const hasYPhyscialSize = Boolean(ySize) && Boolean(yUnit);
117
+ const hasXPhyscialSize = Boolean(xSize) && Boolean(xUnit);
118
+ if (!hasXPhyscialSize || !hasYPhyscialSize) {
119
+ return (new Matrix4()).identity();
120
+ }
121
+ const sizes = [
122
+ unit(`${xSize} ${xUnit}`.replace('µ', 'u')),
123
+ unit(`${ySize} ${yUnit}`.replace('µ', 'u')),
124
+ ];
125
+ if (hasZPhyscialSize) {
126
+ sizes.push(unit(`${zSize} ${zUnit}`.replace('µ', 'u')));
127
+ }
128
+ // Find the ratio of the sizes to get the scaling factor.
129
+ const scale = sizes.map(i => divide(i, unit('1 um')));
130
+
131
+ // TODO: is this still needed
132
+ // sizes are special objects with own equals method - see `unit` in declaration
133
+ if (!sizes[0].equals(sizes[1])) {
134
+ // Handle scaling in the Y direction for non-square pixels
135
+ scale[1] = divide(sizes[1], sizes[0]);
136
+ }
137
+ // END TODO: is this still needed
138
+
139
+ // Add in z dimension needed for Matrix4 scale API.
140
+ if (!scale[2]) {
141
+ scale[2] = 1;
142
+ }
143
+ // no need to store/use identity scaling
144
+ if (isEqual(scale, [1, 1, 1])) {
145
+ return (new Matrix4()).identity();
146
+ }
147
+ return new Matrix4().scale([...scale]);
148
+ }
149
+
80
150
  function getMetaWithTransformMatrices(imageMeta, imageLoaders) {
81
151
  // Do not fill in transformation matrices if any of the layers specify one.
82
152
  const sources = imageLoaders.map(loader => getSourceFromLoader(loader));
@@ -367,11 +437,11 @@ export function getNgffAxesForTiff(dimOrder) {
367
437
  /**
368
438
  * Convert an array of coordinateTransformations objects to a 16-element
369
439
  * plain JS array using Matrix4 linear algebra transformation functions.
370
- * @param {object[]} coordinateTransformations List of objects matching the
440
+ * @param {object[]|undefined} coordinateTransformations List of objects matching the
371
441
  * OME-NGFF v0.4 coordinateTransformations spec.
372
- * @param {object[]} axes Axes in OME-NGFF v0.4 format, objects
442
+ * @param {object[]|undefined} axes Axes in OME-NGFF v0.4 format, objects
373
443
  * with { type, name }.
374
- * @returns {Array<number>} Array of 16 numbers representing the Matrix4.
444
+ * @returns {Matrix4} Array of 16 numbers representing the Matrix4.
375
445
  */
376
446
  export function coordinateTransformationsToMatrix(coordinateTransformations, axes) {
377
447
  let mat = (new Matrix4()).identity();
@@ -416,3 +486,71 @@ export function coordinateTransformationsToMatrix(coordinateTransformations, axe
416
486
  }
417
487
  return mat;
418
488
  }
489
+
490
+ export function hexToRgb(hex) {
491
+ const result = /^#?([A-F\d]{2})([A-F\d]{2})([A-F\d]{2})$/i.exec(hex);
492
+ return [
493
+ parseInt(result[1].toLowerCase(), 16),
494
+ parseInt(result[2].toLowerCase(), 16),
495
+ parseInt(result[3].toLowerCase(), 16),
496
+ ];
497
+ }
498
+
499
+ /**
500
+ * Normalize coordinate transformations to the OME-NGFF v0.4 format,
501
+ * despite potentially being in the new format proposed in
502
+ * https://github.com/ome/ngff/pull/138 (As of 2023-09-02).
503
+ * @param {object[]|undefined} coordinateTransformations Value of
504
+ * multiscales[0].coordinateTransformations in either OME-NGFF v0.4 format
505
+ * or that proposed in https://github.com/ome/ngff/pull/138.
506
+ * @param {object[]} datasets Value of multiscales[0].datasets in OME-NGFF v0.4 format.
507
+ * @returns {object[]} Array of coordinateTransformations in OME-NGFF v0.4 format.
508
+ */
509
+ export function normalizeCoordinateTransformations(coordinateTransformations, datasets) {
510
+ // "The transformations in the list are applied sequentially and in order."
511
+ // Reference: https://ngff.openmicroscopy.org/0.4/index.html#trafo-md
512
+ let result = [];
513
+
514
+ if (Array.isArray(coordinateTransformations)) {
515
+ result = coordinateTransformations.map((transform) => {
516
+ if (transform.input && transform.output) {
517
+ // This is a new-style coordinate transformation.
518
+ // (As proposed in https://github.com/ome/ngff/pull/138)
519
+ const { type } = transform;
520
+ if (type === 'translation') {
521
+ return {
522
+ type,
523
+ translation: transform.translation,
524
+ };
525
+ }
526
+ if (type === 'scale') {
527
+ return {
528
+ type,
529
+ scale: transform.scale,
530
+ };
531
+ }
532
+ if (type === 'identity') {
533
+ return { type };
534
+ }
535
+ console.warn(`Coordinate transformation type "${type}" is not supported.`);
536
+ }
537
+ // Assume it was already an old-style (NGFF v0.4) coordinate transformation.
538
+ return transform;
539
+ });
540
+ }
541
+
542
+ if (Array.isArray(datasets?.[0]?.coordinateTransformations)) {
543
+ // "Datasets SHOULD define a transformation from array space
544
+ // to their "native physical space."
545
+ // This transformation SHOULD describe physical pixel spacing
546
+ // and origin only, and therefore SHOULD consist of
547
+ // `scale` and/or `translation` types only.""
548
+ // Reference: https://github.com/ome/ngff/blob/b92f540dc95440f2d6b7012185b09c2b862aa744/latest/transform-details.bs#L99
549
+
550
+ result = [
551
+ ...datasets[0].coordinateTransformations,
552
+ ...result,
553
+ ];
554
+ }
555
+ return result;
556
+ }
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { square, coordinateTransformationsToMatrix } from './spatial.js';
2
+ import {
3
+ square,
4
+ coordinateTransformationsToMatrix,
5
+ normalizeCoordinateTransformations,
6
+ } from './spatial.js';
3
7
 
4
8
  const defaultAxes = [
5
9
  { type: 'time', name: 't' },
@@ -43,4 +47,159 @@ describe('Spatial.js', () => {
43
47
  ]);
44
48
  });
45
49
  });
50
+ describe('normalizeCoordinateTransformations', () => {
51
+ it('does nothing for OME-NGFF v0.4 input', () => {
52
+ const datasets = [
53
+ {
54
+ coordinateTransformations: [
55
+ {
56
+ scale: [
57
+ 1,
58
+ 0.5002025531914894,
59
+ 0.3603981534640209,
60
+ 0.3603981534640209,
61
+ ],
62
+ type: 'scale',
63
+ },
64
+ ],
65
+ path: '0',
66
+ },
67
+ {
68
+ coordinateTransformations: [
69
+ {
70
+ scale: [
71
+ 1,
72
+ 0.5002025531914894,
73
+ 0.7207963069280418,
74
+ 0.7207963069280418,
75
+ ],
76
+ type: 'scale',
77
+ },
78
+ ],
79
+ path: '1',
80
+ },
81
+ {
82
+ coordinateTransformations: [
83
+ {
84
+ scale: [
85
+ 1,
86
+ 0.5002025531914894,
87
+ 1.4415926138560835,
88
+ 1.4415926138560835,
89
+ ],
90
+ type: 'scale',
91
+ },
92
+ ],
93
+ path: '2',
94
+ },
95
+ ];
96
+ expect(normalizeCoordinateTransformations(undefined, datasets)).toEqual([
97
+ // Here, we expect only the first dataset to be used.
98
+ // However, we should eventually support transforms
99
+ // specified for each dataset individually,
100
+ // since there could in theory be irregular ways of downsampling.
101
+ {
102
+ scale: [
103
+ 1,
104
+ 0.5002025531914894,
105
+ 0.3603981534640209,
106
+ 0.3603981534640209,
107
+ ],
108
+ type: 'scale',
109
+ },
110
+ ]);
111
+ });
112
+ it('transforms SpatialData input', () => {
113
+ const newCoordinateTransformations = [
114
+ {
115
+ input: {
116
+ axes: [
117
+ {
118
+ name: 'c',
119
+ type: 'channel',
120
+ },
121
+ {
122
+ name: 'y',
123
+ type: 'space',
124
+ unit: 'unit',
125
+ },
126
+ {
127
+ name: 'x',
128
+ type: 'space',
129
+ unit: 'unit',
130
+ },
131
+ ],
132
+ name: 'cyx',
133
+ },
134
+ output: {
135
+ axes: [
136
+ {
137
+ name: 'c',
138
+ type: 'channel',
139
+ },
140
+ {
141
+ name: 'y',
142
+ type: 'space',
143
+ unit: 'unit',
144
+ },
145
+ {
146
+ name: 'x',
147
+ type: 'space',
148
+ unit: 'unit',
149
+ },
150
+ ],
151
+ name: 'ST8059048',
152
+ },
153
+ scale: [
154
+ 1.0,
155
+ 8.670500183814605,
156
+ 8.670500183814605,
157
+ ],
158
+ type: 'scale',
159
+ },
160
+ ];
161
+ const datasets = [
162
+ {
163
+ coordinateTransformations: [
164
+ {
165
+ scale: [
166
+ 1.0,
167
+ 1.0,
168
+ 1.0,
169
+ ],
170
+ type: 'scale',
171
+ },
172
+ ],
173
+ path: '0',
174
+ },
175
+ ];
176
+ expect(normalizeCoordinateTransformations(newCoordinateTransformations, datasets)).toEqual([
177
+ // Here, we expect only the first dataset to be used.
178
+ // However, we should eventually support transforms
179
+ // specified for each dataset individually,
180
+ // since there could in theory be irregular ways of downsampling.
181
+ {
182
+ scale: [
183
+ 1.0,
184
+ 1.0,
185
+ 1.0,
186
+ ],
187
+ type: 'scale',
188
+ },
189
+ // We expect the dataset transform to be prepended
190
+ // to the newCoordinateTransformations items.
191
+ // We do not check the input/output coordinate systems, but we should eventually allow
192
+ // the user to specify this somehow and use that information to filter which transforms
193
+ // are included here.
194
+ {
195
+ scale: [
196
+ 1.0,
197
+ 8.670500183814605,
198
+ 8.670500183814605,
199
+ ],
200
+ type: 'scale',
201
+ },
202
+ ]);
203
+ });
204
+ });
46
205
  });