Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[wip] Experiment with multiscale grid #80

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 12 additions & 78 deletions src/gridLayer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { CompositeLayer } from '@deck.gl/core';
import { SolidPolygonLayer, TextLayer } from '@deck.gl/layers';
import type { CompositeLayerProps } from '@deck.gl/core/lib/composite-layer';
import pMap from 'p-map';

import { XRLayer } from '@hms-dbmi/viv';
import { Matrix4 } from '@math.gl/core/dist/esm';
import { MultiscaleImageLayer } from '@hms-dbmi/viv';
import type { GridLoader } from './state';

export interface GridLayerProps extends CompositeLayerProps<any> {
Expand All @@ -21,90 +20,27 @@ export interface GridLayerProps extends CompositeLayerProps<any> {
}

const defaultProps = {
...XRLayer.defaultProps,
...MultiscaleImageLayer.defaultProps,
// Special grid props
loaders: { type: 'array', value: [], compare: true },
spacer: { type: 'number', value: 5, compare: true },
rows: { type: 'number', value: 0, compare: true },
columns: { type: 'number', value: 0, compare: true },
concurrency: { type: 'number', value: 10, compare: false }, // set concurrency for queue
text: { type: 'boolean', value: false, compare: true },
text: { type: 'boolean', value: true, compare: true },
// Deck.gl
onClick: { type: 'function', value: null, compare: true },
onHover: { type: 'function', value: null, compare: true },
};

function scaleBounds(width: number, height: number, translate = [0, 0], scale = 1) {
const [left, top] = translate;
const right = width * scale + left;
const bottom = height * scale + top;
return [left, bottom, right, top];
}

function validateWidthHeight(d: { data: { width: number; height: number } }[]) {
const [first] = d;
// Return early if no grid data. Maybe throw an error?
const { width, height } = first.data;
// Verify that all grid data is same shape (ignoring undefined)
d.forEach(({ data }) => {
if (data?.width !== width || data?.height !== height) {
throw new Error('Grid data is not same shape.');
}
});
return { width, height };
}

function refreshGridData(props: { loaders: GridLoader[]; concurrency?: number; loaderSelection: number[][] }) {
const { loaders, loaderSelection = [] } = props;
let { concurrency } = props;
if (concurrency && loaderSelection.length > 0) {
// There are `loaderSelection.length` requests per loader. This block scales
// the provided concurrency to map to the number of actual requests.
concurrency = Math.ceil(concurrency / loaderSelection.length);
}
const mapper = async (d: GridLoader) => {
const promises = loaderSelection.map((selection) => d.loader.getRaster({ selection }));
const tiles = await Promise.all(promises);
return {
...d,
data: {
data: tiles.map((d) => d.data),
width: tiles[0].width,
height: tiles[0].height,
},
};
};
return pMap(loaders, mapper, { concurrency });
}

export default class GridLayer<P extends GridLayerProps = GridLayerProps> extends CompositeLayer<any, P> {
initializeState() {
this.state = { gridData: [], width: 0, height: 0 };
refreshGridData(this.props).then((gridData) => {
const { width, height } = validateWidthHeight(gridData);
this.setState({ gridData, width, height });
});
}

updateState({ props, oldProps, changeFlags }: { props: GridLayerProps; oldProps: GridLayerProps; changeFlags: any }) {
const { propsChanged } = changeFlags;
const loaderChanged = typeof propsChanged === 'string' && propsChanged.includes('props.loaders');
const loaderSelectionChanged = props.loaderSelection !== oldProps.loaderSelection;
if (loaderChanged || loaderSelectionChanged) {
// Only fetch new data to render if loader has changed
refreshGridData(this.props).then((gridData) => {
this.setState({ gridData });
});
}
}

getPickingInfo({ info }: { info: any }) {
// provide Grid row and column info for mouse events (hover & click)
if (!info.coordinate) {
return info;
}
const spacer = this.props.spacer || 0;
const { width, height } = this.state;
const [height, width] = this.props.loaders[0].loader[0].shape.slice(-2);
const [x, y] = info.coordinate;
const row = Math.floor(y / (height + spacer));
const column = Math.floor(x / (width + spacer));
Expand All @@ -113,21 +49,19 @@ export default class GridLayer<P extends GridLayerProps = GridLayerProps> extend
}

renderLayers() {
const { gridData, width, height } = this.state;
if (width === 0 || height === 0) return null; // early return if no data

if (this.props.loaders.length === 0) return null;
const [height, width] = this.props.loaders[0].loader[0].shape.slice(-2);
const { rows, columns, spacer = 0, id = '' } = this.props;
const layers = gridData.map((d: any) => {
const layers = this.props.loaders.map((d: any) => {
const y = d.row * (height + spacer);
const x = d.col * (width + spacer);
const layerProps = {
channelData: d.data, // coerce to null if no data
bounds: scaleBounds(width, height, [x, y]),
loader: d.loader,
modelMatrix: new Matrix4().translate([x, y, 0]),
id: `${id}-GridLayer-${d.row}-${d.col}`,
dtype: d.loader.dtype || 'Uint16', // fallback if missing,
pickable: false,
};
return new (XRLayer as any)({ ...this.props, ...layerProps });
return new (MultiscaleImageLayer as any)({ ...this.props, ...layerProps });
});

if (this.props.pickable) {
Expand Down Expand Up @@ -155,7 +89,7 @@ export default class GridLayer<P extends GridLayerProps = GridLayerProps> extend
if (this.props.text) {
const textLayer = new TextLayer({
id: `${id}-GridLayer-text`,
data: gridData,
data: this.props.loaders,
getPosition: (d: any) => [d.col * (width + spacer), d.row * (height + spacer)],
getText: (d: any) => d.name,
getColor: [255, 255, 255, 255],
Expand Down
51 changes: 27 additions & 24 deletions src/ome.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { ZarrPixelSource } from '@hms-dbmi/viv';
import pMap from 'p-map';
import { Group as ZarrGroup, HTTPStore, openGroup, ZarrArray } from 'zarr';
import { Group as ZarrGroup, HTTPStore, openGroup, ZarrArray, openArray } from 'zarr';
import type { ImageLayerConfig, SourceData } from './state';
import { join, loadMultiscales, guessTileSize, range, parseMatrix } from './utils';
import { join, loadMultiscales, guessTileSize, parseMatrix } from './utils';

export async function loadWell(config: ImageLayerConfig, grp: ZarrGroup, wellAttrs: Ome.Well): Promise<SourceData> {
// Can filter Well fields by URL query ?acquisition=ID
Expand Down Expand Up @@ -42,28 +41,31 @@ export async function loadWell(config: ImageLayerConfig, grp: ZarrGroup, wellAtt
const rows = Math.ceil(imgPaths.length / cols);

// Use first image for rendering settings, resolutions etc.
const imgAttrs = (await grp.getItem(imgPaths[0]).then((g) => g.attrs.asObject())) as Ome.Attrs;
const img = await grp.getItem(imgPaths[0]);
const imgAttrs = (await img.attrs.asObject()) as Ome.Attrs;
if (!('omero' in imgAttrs)) {
throw Error('Path for image is not valid.');
}
let resolution = imgAttrs.multiscales[0].datasets[0].path;
const { datasets } = imgAttrs.multiscales[0];
const resolutions = datasets.map((d) => d.path);

// Create loader for every Image.
const promises = imgPaths.map((p) => grp.getItem(join(p, resolution)));
const pyramid = resolutions.map((p) => grp.getItem(join(imgPaths[0], p)));
const meta = parseOmeroMeta(imgAttrs.omero);
const data = (await Promise.all(promises)) as ZarrArray[];
const data = (await Promise.all(pyramid)) as ZarrArray[];
const tileSize = guessTileSize(data[0]);
const loaders = range(rows).flatMap((row) => {
return range(cols).map((col) => {
const offset = col + row * cols;
return { name: String(offset), row, col, loader: new ZarrPixelSource(data[offset], meta.axis_labels, tileSize) };
const loaders = imgPaths.map((p, i) => {
const loader = resolutions.map((res, level) => {
const arr: ZarrArray = new (ZarrArray as any)(grp.store, join(grp.path, p, res), data[level].meta);
return new ZarrPixelSource(arr, meta.axis_labels, tileSize);
});
return { name: String(i), row: Math.floor(i / cols), col: i % cols, loader };
});

const sourceData: SourceData = {
loaders,
...meta,
loader: [loaders[0].loader],
loader: loaders[0].loader,
model_matrix: parseMatrix(config.model_matrix),
defaults: {
selection: meta.defaultSelection,
Expand Down Expand Up @@ -128,33 +130,34 @@ export async function loadPlate(config: ImageLayerConfig, grp: ZarrGroup, plateA
}
// Lowest resolution is the 'path' of the last 'dataset' from the first multiscales
const { datasets } = imgAttrs.multiscales[0];
const resolution = datasets[datasets.length - 1].path;
const resolutions = datasets.map((d) => d.path);

// Create loader for every Well. Some loaders may be undefined if Wells are missing.
const mapper = ([key, path]: string[]) => grp.getItem(path).then((arr) => [key, arr]) as Promise<[string, ZarrArray]>;
const promises = await pMap(
wellPaths.map((p) => [p, join(p, imgPath, resolution)]),
mapper,
{ concurrency: 10 }
const promises = resolutions.map((res) =>
openArray({ store: grp.store, path: join(grp.path, wellPaths[0], imgPath, res) })
);
const meta = parseOmeroMeta(imgAttrs.omero);
const data = await Promise.all(promises);
const tileSize = guessTileSize(data[0][1]);
const loaders = data.map((d) => {
const [row, col] = d[0].split('/');
const meta = parseOmeroMeta(imgAttrs.omero);
const tileSize = guessTileSize(data[0]);
const loaders = wellPaths.map((d) => {
const [row, col] = d.split('/');
const loader = resolutions.map((res, i) => {
const arr = new (ZarrArray as any)(grp.store, join(grp.path, d, imgPath, res), data[i].meta);
return new ZarrPixelSource(arr, meta.axis_labels, tileSize);
});
return {
name: `${row}${col}`,
row: rows.indexOf(row),
col: columns.indexOf(col),
loader: new ZarrPixelSource(d[1], meta.axis_labels, tileSize),
loader: loader,
};
});

// Load Image to use for channel names, rendering settings, sizeZ, sizeT etc.
const sourceData: SourceData = {
loaders,
...meta,
loader: [loaders[0].loader],
loader: loaders[0].loader,
model_matrix: parseMatrix(config.model_matrix),
defaults: {
selection: meta.defaultSelection,
Expand Down
2 changes: 1 addition & 1 deletion src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface SingleChannelConfig extends BaseConfig {
export type ImageLayerConfig = MultichannelConfig | SingleChannelConfig;

export interface GridLoader {
loader: ZarrPixelSource<string[]>;
loader: ZarrPixelSource<string[]>[];
row: number;
col: number;
name: string;
Expand Down