Skip to content

Commit cd1c44d

Browse files
authored
Merge pull request #29 from BioNGFF/feat/scalebar
add scalebar for single source
2 parents de8b126 + 91a1b1b commit cd1c44d

File tree

3 files changed

+173
-87
lines changed

3 files changed

+173
-87
lines changed

viewer/src/components/Viewer.jsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React, {
66
useMemo,
77
} from 'react';
88

9-
import { ImageLayer, MultiscaleImageLayer } from '@hms-dbmi/viv';
9+
import { ImageLayer, MultiscaleImageLayer, ScaleBarLayer } from '@hms-dbmi/viv';
1010
import { initLayerStateFromSource } from '@hms-dbmi/vizarr/src/io';
1111
import { GridLayer } from '@hms-dbmi/vizarr/src/layers/grid-layer';
1212
import {
@@ -141,16 +141,35 @@ export const Viewer = ({
141141
.flat();
142142
}, [isLabel, layerStates]);
143143

144+
const deckLayers = useMemo(() => {
145+
if (sourceData.length > 1 || !layers.length || !viewState) {
146+
return layers;
147+
}
148+
if (layers[0].props.loader?.[0]?.meta?.physicalSizes?.x) {
149+
const { size, unit } = layers[0].props.loader[0].meta.physicalSizes.x;
150+
const scalebar = new ScaleBarLayer({
151+
id: 'scalebar',
152+
size: size / layers[0].props.modelMatrix[0],
153+
unit: unit,
154+
viewState: viewState,
155+
});
156+
return [...layers, scalebar];
157+
}
158+
return layers;
159+
}, [layers, sourceData.length, viewState]);
160+
144161
const resetViewState = useCallback(() => {
145162
const { deck } = deckRef.current;
146-
setViewState(
147-
fitImageToViewport({
163+
setViewState({
164+
...fitImageToViewport({
148165
image: getLayerSize(layers?.[0]),
149166
viewport: deck,
150167
padding: deck.width < 400 ? 10 : deck.width < 600 ? 30 : 50,
151168
matrix: layers?.[0]?.props.modelMatrix,
152169
}),
153-
);
170+
width: deck.width,
171+
height: deck.height,
172+
});
154173
}, [layers]);
155174

156175
useEffect(() => {
@@ -342,7 +361,7 @@ export const Viewer = ({
342361
/>
343362
<DeckGL
344363
ref={deckRef}
345-
layers={layers}
364+
layers={deckLayers}
346365
viewState={viewState && { ortho: viewState }}
347366
onViewStateChange={(e) => setViewState(e.viewState)}
348367
views={[

viewer/src/hooks.js

Lines changed: 101 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import {
55
isBioformats2rawlayout,
66
guessZarrVersion,
77
isOmePlate,
8+
isMultiscales,
9+
coordinateTransformationsToMatrix,
810
} from '@hms-dbmi/vizarr/src/utils';
911
import { FetchStore, open } from 'zarrita';
1012

1113
import {
1214
findSeries,
15+
getNgffAxes,
1316
getXmlDom,
1417
getZarrJson,
1518
getZarrMetadata,
@@ -34,99 +37,123 @@ const fetchSourceData = async (config) => {
3437
const zarrJson = zarrVersion === 3 ? await getZarrJson(base) : null;
3538
let ome = zarrJson?.attributes?.ome || node.attrs?.OME || null;
3639

40+
let sourceData;
3741
if (
3842
!isBioformats2rawlayout(ome || node.attrs) ||
3943
isOmePlate(ome || node.attrs) // if plate is present it takes precedence (https://ngff.openmicroscopy.org/0.4/#bf2raw-attributes)
4044
) {
4145
// use Vizarr's createSourceData with source as is
4246

43-
if (ome?.version === "0.5") {
44-
const sourceData = await createSourceData(config);
47+
if (ome?.version === '0.5') {
48+
sourceData = await createSourceData(config);
4549
const labels = await resolveOmeLabelsFromMultiscales(node);
4650
sourceData.labels = await Promise.all(
4751
labels.map((name) => loadOmeImageLabel(node.resolve('labels'), name)),
4852
);
49-
return sourceData;
53+
} else {
54+
sourceData = await createSourceData(config);
55+
}
56+
} else {
57+
// load bioformats2raw.layout
58+
// https://ngff.openmicroscopy.org/0.4/#bf2raw
59+
60+
// get b2f metadata from ome key in metadata or node attributes
61+
const b2fl =
62+
ome?.['bioformats2raw.layout'] || node.attrs?.['bioformats2raw.layout'];
63+
if (b2fl !== 3) {
64+
throw new Error('Unsupported bioformats2raw layout');
5065
}
51-
return await createSourceData(config);
52-
}
53-
54-
// load bioformats2raw.layout
55-
// https://ngff.openmicroscopy.org/0.4/#bf2raw
56-
57-
// get b2f metadata from ome key in metadata or node attributes
58-
const b2fl =
59-
ome?.['bioformats2raw.layout'] || node.attrs?.['bioformats2raw.layout'];
60-
if (b2fl !== 3) {
61-
throw new Error('Unsupported bioformats2raw layout');
62-
}
6366

64-
// Try to load .zmetadata if present
65-
const metadata = await getZarrMetadata(base);
67+
// Try to load .zmetadata if present
68+
const metadata = await getZarrMetadata(base);
6669

67-
// Try to load OME group at root if present and not in v3 zarr metadata
68-
if (!ome) {
69-
try {
70-
ome = await open(node.resolve('OME'), { kind: 'group' });
71-
} catch {}
72-
}
70+
// Try to load OME group at root if present and not in v3 zarr metadata
71+
if (!ome) {
72+
try {
73+
ome = await open(node.resolve('OME'), { kind: 'group' });
74+
} catch {}
75+
}
7376

74-
// Try to load OME XML file if present
75-
const omeXmlDom = await getXmlDom(base);
76-
let omeXml = omeXmlDom ? parseXml(omeXmlDom) : null;
77+
// Try to load OME XML file if present
78+
const omeXmlDom = await getXmlDom(base);
79+
let omeXml = omeXmlDom ? parseXml(omeXmlDom) : null;
7780

78-
let series;
79-
if (ome?.series) {
80-
series = ome.series;
81-
} else if (ome?.attrs?.series) {
82-
series = ome.attrs.series;
83-
} else {
84-
// https://ngff.openmicroscopy.org/0.4/#bf2raw-details
85-
if (metadata) {
86-
const multiscaleKeys = Object.keys(metadata).filter(
87-
(key) => key.endsWith('/.zattrs') && 'multiscales' in metadata[key],
88-
);
89-
series = multiscaleKeys.map((key) => key.split('/')[0]);
90-
} else if (omeXml) {
91-
series = omeXml.images.map((image) => image.path);
81+
let series;
82+
if (ome?.series) {
83+
series = ome.series;
84+
} else if (ome?.attrs?.series) {
85+
series = ome.attrs.series;
9286
} else {
93-
console.warn(
94-
'No OME group, .zmetadata or xml file. Attempting to find series.',
95-
);
96-
series = await findSeries(base, node, zarrVersion);
87+
// https://ngff.openmicroscopy.org/0.4/#bf2raw-details
88+
if (metadata) {
89+
const multiscaleKeys = Object.keys(metadata).filter(
90+
(key) => key.endsWith('/.zattrs') && 'multiscales' in metadata[key],
91+
);
92+
series = multiscaleKeys.map((key) => key.split('/')[0]);
93+
} else if (omeXml) {
94+
series = omeXml.images.map((image) => image.path);
95+
} else {
96+
console.warn(
97+
'No OME group, .zmetadata or xml file. Attempting to find series.',
98+
);
99+
series = await findSeries(base, node, zarrVersion);
100+
}
97101
}
102+
103+
const seriesMd = await Promise.all(
104+
series?.map(async (s, index) => {
105+
const seriesNode = await open(node.resolve(s), {
106+
kind: 'group',
107+
});
108+
if (!seriesNode.attrs.multiscales?.[0].axes && omeXml) {
109+
// get axes from xml if not in metadata
110+
// "The specified dimension order is then reversed when creating Zarr arrays, e.g. XYCZT would become TZCYX in Zarr." (https://github.com/glencoesoftware/bioformats2raw/blob/85ef84db26ce1239dd71ef482b4f38f67e605491/README.md?plain=1#L293)
111+
// though multiscales metadata MUST have axes (https://ngff.openmicroscopy.org/0.4/#multiscale-md)
112+
const dimensionOrder = omeXml.images[index].dimensionOrder;
113+
return dimensionOrder
114+
? {
115+
channel_axis:
116+
dimensionOrder?.length - dimensionOrder?.indexOf('C') - 1,
117+
}
118+
: {};
119+
}
120+
return {};
121+
}),
122+
);
123+
124+
// @TODO: return all series
125+
const sIndex = 0;
126+
127+
const seriesUrl = `${base.replace(/\/?$/, '/')}${series?.[sIndex] || ''}`;
128+
sourceData = await createSourceData({
129+
...config,
130+
source: seriesUrl,
131+
...seriesMd[sIndex],
132+
});
98133
}
99134

100-
const seriesMd = await Promise.all(
101-
series?.map(async (s, index) => {
102-
const seriesNode = await open(node.resolve(s), {
103-
kind: 'group',
104-
});
105-
if (!seriesNode.attrs.multiscales?.[0].axes && omeXml) {
106-
// get axes from xml if not in metadata
107-
// "The specified dimension order is then reversed when creating Zarr arrays, e.g. XYCZT would become TZCYX in Zarr." (https://github.com/glencoesoftware/bioformats2raw/blob/85ef84db26ce1239dd71ef482b4f38f67e605491/README.md?plain=1#L293)
108-
// though multiscales metadata MUST have axes (https://ngff.openmicroscopy.org/0.4/#multiscale-md)
109-
const dimensionOrder = omeXml.images[index].dimensionOrder;
110-
return dimensionOrder
111-
? {
112-
channel_axis:
113-
dimensionOrder?.length - dimensionOrder?.indexOf('C') - 1,
114-
}
115-
: {};
116-
}
117-
return {};
118-
}),
119-
);
120-
121-
// @TODO: return all series
122-
const sIndex = 0;
123-
124-
const seriesUrl = `${base.replace(/\/?$/, '/')}${series?.[sIndex] || ''}`;
125-
return await createSourceData({
126-
...config,
127-
source: seriesUrl,
128-
...seriesMd[sIndex],
129-
});
135+
// @TODO: implement this in createSourceData
136+
// Get physical sizes and add them to loader.meta (or as another prop?)
137+
const attrs = ome || node.attrs;
138+
if (isMultiscales(attrs)) {
139+
const axes = getNgffAxes(attrs.multiscales);
140+
const ct = coordinateTransformationsToMatrix(attrs.multiscales);
141+
const matrixIndices = {
142+
x: 0,
143+
y: 5,
144+
z: 10,
145+
};
146+
const physicalSizes = axes
147+
.filter((a) => a.type === 'space')
148+
.reduce((acc, a) => {
149+
acc[a.name] = { size: ct[matrixIndices[a.name]], unit: a.unit };
150+
return acc;
151+
}, {});
152+
// @TODO: get t size from multiscales.coordinateTransformations if axis is present
153+
sourceData.loader[0].meta = { physicalSizes };
154+
}
155+
156+
return sourceData;
130157
} catch (err) {
131158
throw err;
132159
}

viewer/src/utils.js

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { open, NodeNotFoundError } from 'zarrita';
21
import * as utils from '@hms-dbmi/vizarr/src/utils';
32
import { ZarrPixelSource } from '@hms-dbmi/vizarr/src/ZarrPixelSource';
3+
import { open, NodeNotFoundError } from 'zarrita';
44

55
export async function getZarrJson(location, path = '') {
66
const jsonUrl = `${location.replace(/\/?$/, '/')}${path ? path.replace(/\/?$/, '/') : ''}zarr.json`;
@@ -120,31 +120,71 @@ export function transformBox(bbox, modelMatrix) {
120120
}
121121

122122
// From vizarr utils
123-
// Workaround for verson 0.5
123+
// Workaround for version 0.5
124124
export async function resolveOmeLabelsFromMultiscales(grp) {
125-
return open(grp.resolve("labels"), { kind: "group" })
126-
.then(({ attrs }) => (utils.resolveAttrs(attrs).labels ?? [])) // use resolveAttrs to use ome if present
125+
return open(grp.resolve('labels'), { kind: 'group' })
126+
.then(({ attrs }) => utils.resolveAttrs(attrs).labels ?? []) // use resolveAttrs to use ome if present
127127
.catch((e) => {
128128
utils.rethrowUnless(e, NodeNotFoundError);
129129
return [];
130130
});
131131
}
132132

133133
export async function loadOmeImageLabel(root, name) {
134-
const grp = await open(root.resolve(name), { kind: "group" });
134+
const grp = await open(root.resolve(name), { kind: 'group' });
135135
const attrs = utils.resolveAttrs(grp.attrs);
136136
utils.assert(utils.isOmeImageLabel(attrs), "No 'image-label' metadata.");
137137
const data = await utils.loadMultiscales(grp, attrs.multiscales);
138138
const baseResolution = data.at(0);
139-
utils.assert(baseResolution, "No base resolution found for multiscale labels.");
139+
utils.assert(
140+
baseResolution,
141+
'No base resolution found for multiscale labels.',
142+
);
140143
const tileSize = utils.guessTileSize(baseResolution);
141144
const axes = utils.getNgffAxes(attrs.multiscales);
142145
const labels = utils.getNgffAxisLabels(axes);
143-
const colors = (attrs["image-label"].colors ?? []).map((d) => ({ labelValue: d["label-value"], rgba: d.rgba }));
146+
const colors = (attrs['image-label'].colors ?? []).map((d) => ({
147+
labelValue: d['label-value'],
148+
rgba: d.rgba,
149+
}));
144150
return {
145151
name,
146152
modelMatrix: utils.coordinateTransformationsToMatrix(attrs.multiscales),
147153
loader: data.map((arr) => new ZarrPixelSource(arr, { labels, tileSize })),
148154
colors: colors.length > 0 ? colors : undefined,
149155
};
150-
}
156+
}
157+
158+
export function getNgffAxes(multiscales) {
159+
// Returns axes in the latest v0.4+ format.
160+
// defaults for v0.1 & v0.2
161+
const default_axes = [
162+
{ type: 'time', name: 't' },
163+
{ type: 'channel', name: 'c' },
164+
{ type: 'space', name: 'z' },
165+
{ type: 'space', name: 'y' },
166+
{ type: 'space', name: 'x' },
167+
];
168+
function getDefaultType(name) {
169+
if (name === 't') return 'time';
170+
if (name === 'c') return 'channel';
171+
return 'space';
172+
}
173+
let axes = default_axes;
174+
// v0.3 & v0.4+
175+
if (multiscales[0].axes) {
176+
axes = multiscales[0].axes.map((axis) => {
177+
// axis may be string 'x' (v0.3) or object
178+
if (typeof axis === 'string') {
179+
return { name: axis, type: getDefaultType(axis) };
180+
}
181+
const { name, type, unit } = axis; // add unit
182+
return {
183+
name,
184+
type: type ?? getDefaultType(name),
185+
unit,
186+
};
187+
});
188+
}
189+
return axes;
190+
}

0 commit comments

Comments
 (0)