Skip to content

Commit 798405f

Browse files
committed
Add initial image label renderer
1 parent 039df3b commit 798405f

File tree

11 files changed

+300
-40
lines changed

11 files changed

+300
-40
lines changed

main.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ async function main() {
2020

2121
// Add event listener to sync viewState as query param.
2222
// Debounce to limit how quickly we are pushing to browser history
23-
viewer.on(
24-
"viewStateChange",
25-
debounce((update: vizarr.ViewState) => {
26-
const url = new URL(window.location.href);
27-
url.searchParams.set("viewState", JSON.stringify(update));
28-
window.history.pushState({}, "", decodeURIComponent(url.href));
29-
}, 200),
30-
);
23+
// viewer.on(
24+
// "viewStateChange",
25+
// debounce((update: vizarr.ViewState) => {
26+
// const url = new URL(window.location.href);
27+
// url.searchParams.set("viewState", JSON.stringify(update));
28+
// window.history.pushState({}, "", decodeURIComponent(url.href));
29+
// }, 200),
30+
// );
3131

3232
// parse image config
3333
// @ts-expect-error - TODO: validate config

src/components/LayerController/Content.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import AcquisitionController from "./AcquisitionController";
66
import AddChannelButton from "./AddChannelButton";
77
import AxisSliders from "./AxisSliders";
88
import ChannelController from "./ChannelController";
9+
import Labels from "./Labels";
910
import OpacitySlider from "./OpacitySlider";
1011

1112
import { useLayerState } from "../../hooks";
@@ -22,6 +23,7 @@ const Details = withStyles({
2223
function Content() {
2324
const [layer] = useLayerState();
2425
const nChannels = layer.layerProps.selections.length;
26+
const nLabels = layer.imageLabel?.layerProps.length ?? 0;
2527
return (
2628
<Details>
2729
<Grid container direction="column">
@@ -51,6 +53,14 @@ function Content() {
5153
<ChannelController channelIndex={i} key={i} />
5254
))}
5355
</Grid>
56+
{nLabels > 0 && (
57+
<Grid>
58+
<Typography variant="caption">labels:</Typography>
59+
{range(nLabels).map((i) => (
60+
<Labels labelIndex={i} key={i} />
61+
))}
62+
</Grid>
63+
)}
5464
</Grid>
5565
</Details>
5666
);
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Divider, Typography } from "@material-ui/core";
2+
import { Slider } from "@material-ui/core";
3+
import { withStyles } from "@material-ui/styles";
4+
import React from "react";
5+
6+
import { useLayerState, useSourceData } from "../../hooks";
7+
import { assert } from "../../utils";
8+
9+
const DenseSlider = withStyles({
10+
root: {
11+
color: "white",
12+
padding: "10px 0px 5px 0px",
13+
marginRight: "5px",
14+
},
15+
active: {
16+
boxshadow: "0px 0px 0px 8px rgba(158, 158, 158, 0.16)",
17+
},
18+
})(Slider);
19+
20+
export default function Labels({ labelIndex }: { labelIndex: number }) {
21+
const [source] = useSourceData();
22+
const [layer, setLayer] = useLayerState();
23+
assert(source.labels && layer.imageLabel, "Missing image labels");
24+
25+
const handleOpacityChange = (_: unknown, value: number | number[]) => {
26+
setLayer((prev) => {
27+
assert(prev.imageLabel, "Missing image labels");
28+
return {
29+
...prev,
30+
imageLabel: {
31+
...prev.imageLabel,
32+
layerProps: prev.imageLabel.layerProps.with(labelIndex, {
33+
...prev.imageLabel.layerProps[labelIndex],
34+
opacity: value as number,
35+
}),
36+
},
37+
};
38+
});
39+
};
40+
41+
const { name } = source.labels[labelIndex];
42+
const { opacity } = layer.imageLabel.layerProps[labelIndex];
43+
return (
44+
<>
45+
<Divider />
46+
<Typography variant="caption">{name}</Typography>
47+
<DenseSlider value={opacity} onChange={handleOpacityChange} min={0} max={1} step={0.01} />
48+
</>
49+
);
50+
}

src/components/Viewer.tsx

+25-8
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ function WrappedViewStateDeck(props: {
4343
const { width, height, maxZoom } = getLayerSize(firstLayerProps);
4444
const padding = deck.width < 400 ? 10 : deck.width < 600 ? 30 : 50; // Adjust depending on viewport width.
4545
const bounds = fitBounds([width, height], [deck.width, deck.height], maxZoom, padding);
46-
setViewState(bounds);
46+
// FIXME: For some reason deck.width & deck.height when there is no view
47+
// state, resulting in a `NaN` zoom.
48+
// This prevents us from setting the state until we have a valid state
49+
if (!Number.isNaN(bounds.zoom)) {
50+
setViewState(bounds);
51+
}
4752
}
4853

4954
// Enables screenshots of the canvas: https://github.com/visgl/deck.gl/issues/2200
@@ -66,13 +71,25 @@ function WrappedViewStateDeck(props: {
6671
);
6772
}
6873

69-
function Viewer({ viewStateAtom }: { viewStateAtom: WritableAtom<ViewState | undefined, ViewState> }) {
70-
const layerConstructors = useAtomValue(layerAtoms);
71-
// @ts-expect-error - Viv types are giving up an issue
72-
const layers: Array<VizarrLayer | null> = layerConstructors.map((layer) => {
73-
return !layer.on ? null : new layer.Layer(layer.layerProps);
74-
});
75-
return <WrappedViewStateDeck viewStateAtom={viewStateAtom} layers={layers} />;
74+
function Viewer(props: { viewStateAtom: WritableAtom<ViewState | undefined, ViewState> }) {
75+
const layers: Array<VizarrLayer> = [];
76+
for (const { Layer, layerProps, on, imageLabel } of useAtomValue(layerAtoms)) {
77+
if (on) {
78+
const layer = new Layer(layerProps);
79+
// @ts-expect-error - Viv types are giving up an issue
80+
layers.push(layer);
81+
}
82+
if (imageLabel?.on) {
83+
// Should be same as source except differnt channel
84+
const selection = layerProps.selections[0].with(imageLabel.channelIndex, 0);
85+
for (let layerProps of imageLabel.layerProps) {
86+
const layer = new imageLabel.Layer({ ...layerProps, selection });
87+
// @ts-expect-error - Viv types are giving up an issue
88+
layers.push(layer);
89+
}
90+
}
91+
return <WrappedViewStateDeck viewStateAtom={props.viewStateAtom} layers={layers} />;
92+
}
7693
}
7794

7895
export default Viewer;

src/io.ts

+32-5
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { ImageLayer, MultiscaleImageLayer } from "@hms-dbmi/viv";
22
import * as zarr from "zarrita";
33

44
import { ZarrPixelSource } from "./ZarrPixelSource";
5-
import GridLayer from "./gridLayer";
6-
import { loadOmeroMultiscales, loadPlate, loadWell } from "./ome";
5+
import GridLayer from "./layers/grid-layer";
6+
import ImageLabelLayer from "./layers/image-label-layer";
7+
import { loadOmeroMultiscales as loadOmeMultiscales, loadPlate, loadWell } from "./ome";
78
import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from "./state";
89
import * as utils from "./utils";
910

@@ -92,8 +93,8 @@ export async function createSourceData(config: ImageLayerConfig): Promise<Source
9293
return loadWell(config, node, attrs.well);
9394
}
9495

95-
if (utils.isOmeroMultiscales(attrs)) {
96-
return loadOmeroMultiscales(config, node, attrs);
96+
if (utils.isOmeMultiscales(attrs)) {
97+
return loadOmeMultiscales(config, node, attrs);
9798
}
9899

99100
if (Object.keys(attrs).length === 0 && node.path) {
@@ -223,12 +224,38 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L
223224
};
224225
}
225226

226-
return {
227+
const state: LayerState<"multiscale"> = {
227228
Layer: MultiscaleImageLayer,
228229
layerProps: {
229230
...layerProps,
230231
loader: source.loader,
231232
},
232233
on: true,
233234
};
235+
236+
if (source.labels && source.labels.length > 0) {
237+
// Representative axis labels
238+
for (const label of source.labels) {
239+
utils.assert(
240+
source.loader[0].labels.every((name, i) => name === label.loader[0].labels[i]),
241+
"Labels and source must share axes.",
242+
);
243+
}
244+
const channelIndex = source.labels[0].loader[0].labels.indexOf("c");
245+
utils.assert(channelIndex >= 0, "No channel index found in labels");
246+
state.imageLabel = {
247+
Layer: ImageLabelLayer,
248+
layerProps: source.labels.map(({ loader }, i) => ({
249+
id: `${source.id}_${i}`,
250+
loader: loader,
251+
modelMatrix: source.model_matrix,
252+
selection: selection.with(channelIndex, 0),
253+
opacity: 1,
254+
})),
255+
channelIndex,
256+
on: true,
257+
};
258+
}
259+
260+
return state;
234261
}

src/gridLayer.ts src/layers/grid-layer.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import pMap from "p-map";
44
import { ColorPaletteExtension, XRLayer } from "@hms-dbmi/viv";
55
import type { SupportedTypedArray } from "@vivjs/types";
66
import type { CompositeLayerProps, PickingInfo, SolidPolygonLayerProps, TextLayerProps } from "deck.gl";
7-
import type { ZarrPixelSource } from "./ZarrPixelSource";
8-
import type { BaseLayerProps } from "./state";
9-
import { assert } from "./utils";
7+
import type { ZarrPixelSource } from "../ZarrPixelSource";
8+
import type { BaseLayerProps } from "../state";
9+
import { assert } from "../utils";
1010

1111
export interface GridLoader {
1212
loader: ZarrPixelSource<string[]>;

src/layers/image-label-layer.ts

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { getImageSize, MultiscaleImageLayer } from "@hms-dbmi/viv";
2+
import { clamp } from "math.gl";
3+
import { TileLayer } from "deck.gl";
4+
import * as utils from "../utils";
5+
6+
import type { Matrix4 } from "math.gl";
7+
import type { ZarrPixelSource } from "../ZarrPixelSource";
8+
import type { PixelData } from "@vivjs/types";
9+
import { BitmapLayer } from "deck.gl";
10+
11+
export type ImageLabelLayerProps = {
12+
id: string;
13+
loader: Array<ZarrPixelSource>;
14+
selection: Array<number>;
15+
opacity: number;
16+
visible?: boolean;
17+
modelMatrix?: Matrix4;
18+
color?: [r: number, g: number, b: number];
19+
};
20+
21+
// just subclass viv for now
22+
export class _ImageLabelLayer extends MultiscaleImageLayer<string[]> {
23+
constructor(props: ImageLabelLayerProps) {
24+
super({
25+
id: `labels-${props.id}`,
26+
loader: props.loader,
27+
opacity: props.opacity,
28+
// @ts-expect-error - Viv types aren't great
29+
selections: [props.selection],
30+
colors: [props.color ?? [255, 255, 255]],
31+
channelsVisible: [true],
32+
contrastLimits: [[0, 1]],
33+
modelMatrix: props.modelMatrix,
34+
});
35+
}
36+
}
37+
38+
export default class ImageLabelLayer extends TileLayer<PixelData> {
39+
constructor(props: ImageLabelLayerProps) {
40+
const resolutions = props.loader;
41+
const dimensions = getImageSize(resolutions[0]);
42+
super({
43+
// Base props
44+
id: `labels-${props.id}`,
45+
visible: props.visible,
46+
opacity: props.opacity,
47+
modelMatrix: props.modelMatrix,
48+
tileSize: getTileSizeForResolutions(resolutions),
49+
extent: [0, 0, dimensions.width, dimensions.height],
50+
minZoom: Math.round(-(resolutions.length - 1)),
51+
maxZoom: 0,
52+
async getTileData(props: any) {
53+
// types don't match
54+
const { x, y, z } = props.index;
55+
const resolution = resolutions[Math.round(-z)];
56+
const { data, width, height } = await resolution.getTile({
57+
x,
58+
y,
59+
signal,
60+
selection: props.selection,
61+
});
62+
return [{ data, width, height }];
63+
},
64+
renderSubLayers: (props: any) => {
65+
console.log(props);
66+
const [[left, bottom], [right, top]] = props.tile.boundingBox;
67+
const { width, height } = dimensions;
68+
const { data, ...otherProps } = props;
69+
console.log(props.data);
70+
return new BitmapLayer(otherProps, {
71+
image: props.data,
72+
bounds: [clamp(left, 0, width), clamp(top, 0, height), clamp(right, 0, width), clamp(bottom, 0, height)],
73+
});
74+
},
75+
});
76+
}
77+
}
78+
79+
function getTileSizeForResolutions(resolutions: Array<ZarrPixelSource>): number {
80+
const tileSize = resolutions[0].tileSize;
81+
utils.assert(
82+
resolutions.every((resolution) => resolution.tileSize === tileSize),
83+
"resolutions must all have the same tile size",
84+
);
85+
return tileSize;
86+
}

src/ome.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export async function loadWell(
7676
});
7777

7878
let meta: Meta;
79-
if (utils.isOmeroMultiscales(imgAttrs)) {
79+
if (utils.isOmeMultiscales(imgAttrs)) {
8080
meta = parseOmeroMeta(imgAttrs.omero, axes);
8181
} else {
8282
meta = await defaultMeta(loaders[0].loader, axis_labels);
@@ -255,6 +255,7 @@ export async function loadOmeroMultiscales(
255255
const tileSize = utils.guessTileSize(data[0]);
256256

257257
const loader = data.map((arr) => new ZarrPixelSource(arr, { labels: axis_labels, tileSize }));
258+
const labels = await resolveOmeLabelsFromMultiscales(grp);
258259
return {
259260
loader: loader,
260261
axis_labels,
@@ -266,9 +267,43 @@ export async function loadOmeroMultiscales(
266267
},
267268
...meta,
268269
name: meta.name ?? name,
270+
labels: await Promise.all(
271+
labels.map((name) => loadOmeImageLabel(grp.resolve("labels"), name, { axisLabels: axis_labels, tileSize })),
272+
),
269273
};
270274
}
271275

276+
async function loadOmeImageLabel(
277+
root: zarr.Location<zarr.Readable>,
278+
name: string,
279+
options: {
280+
axisLabels: ReturnType<typeof utils.getNgffAxisLabels>;
281+
tileSize: number;
282+
},
283+
): Promise<{
284+
name: string;
285+
loader: Array<ZarrPixelSource>;
286+
}> {
287+
const grp = await zarr.open(root.resolve(name), { kind: "group" });
288+
const attrs = utils.resolveAttrs(grp.attrs);
289+
utils.assert(utils.isOmeImageLabel(attrs), "No 'image-label' metadata.");
290+
const labels = await utils.loadMultiscales(grp, attrs.multiscales);
291+
return {
292+
name,
293+
loader: labels.map((arr) => new ZarrPixelSource(arr, { labels: options.axisLabels, tileSize: options.tileSize })),
294+
};
295+
}
296+
297+
async function resolveOmeLabelsFromMultiscales(grp: zarr.Group<zarr.Readable>): Promise<Array<string>> {
298+
return zarr
299+
.open(grp.resolve("labels"), { kind: "group" })
300+
.then(({ attrs }) => (attrs.labels ?? []) as Array<string>)
301+
.catch((e) => {
302+
utils.rethrowUnless(e, zarr.NodeNotFoundError);
303+
return [];
304+
});
305+
}
306+
272307
type Meta = {
273308
name: string | undefined;
274309
names: Array<string>;

0 commit comments

Comments
 (0)