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

Collection thumbs #125

Closed
wants to merge 11 commits into from
24 changes: 24 additions & 0 deletions src/components/Collection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { useAtomValue } from 'jotai/utils';

import { collectionAtom } from '../state';
import ImageThumbnail from './NgffCollection/ImageThumbnail';

function Collection() {
const ngffCollection = useAtomValue(collectionAtom);

console.log('ngffCollection', ngffCollection);
if (!ngffCollection?.images.length) {
return null;
}
return (
<ul style={{ color: 'white' }}>
Collection
{ngffCollection.images.map((img) => (
<ImageThumbnail key={img.path} imgPath={img.path} zarrGroup={img.group}></ImageThumbnail>
))}
</ul>
);
}

export default Collection;
135 changes: 135 additions & 0 deletions src/components/NgffCollection/ImageThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { useEffect, useRef, useState } from 'react';
import DeckGL from 'deck.gl';
import type { Group, ZarrArray } from 'zarr';
import { OrthographicView } from '@deck.gl/core';
import { ImageLayer, ZarrPixelSource } from '@hms-dbmi/viv';

import { fitBounds, guessTileSize, getNgffAxes } from '../../utils';
// import { Attrs } from '../../../types/ome';

const THUMB = { WIDTH: 100, HEIGHT: 75 };
const thumbStyle = {
position: 'relative',
width: THUMB.WIDTH,
height: THUMB.HEIGHT,
border: 'solid grey 1px',
};

type ThumbProps = {
imgPath: string;
zarrGroup: Group;
};

type ThumbState = {
z_arr: ZarrArray;
imgAttrs: Ome.Attrs;
};

function ImageThumbnail({ imgPath, zarrGroup }: ThumbProps) {
console.log('imgPath', imgPath, 'zarrGroup', zarrGroup);

const [imgState, setImgState] = useState<ThumbState | undefined>(undefined);

async function loadThumb() {
const imgAttrs = (await zarrGroup.getItem(imgPath).then((g) => g.attrs.asObject())) as Ome.Attrs;
// Lowest resolution is the 'path' of the last 'dataset' from the first multiscales
console.log('imgAttrs', imgAttrs);
if ('multiscales' in imgAttrs) {
const { datasets } = imgAttrs.multiscales[0];
const resolution = datasets[datasets.length - 1].path;
console.log('resolution', resolution);
const arrayUrl = `${imgPath}/${resolution}`;
const z_arr = (await zarrGroup.getItem(arrayUrl)) as ZarrArray;
console.log('ImageThumbnail z_arr', z_arr);
setImgState({ imgAttrs, z_arr });
}
}

useEffect(() => {
loadThumb();
}, []);

let viewer = null;

function getRgb(color: string, greyscale: boolean) {
// return [true, true, true] for white, or [true, false, false] for red
return [
color[0] == 'F' || greyscale ? 255 : 0,
color[2] == 'F' || greyscale ? 255 : 0,
color[4] == 'F' || greyscale ? 255 : 0,
];
}

if (imgState) {
const { imgAttrs, z_arr } = imgState;

if (imgAttrs && 'omero' in imgAttrs) {
const { channels } = imgAttrs.omero;
const greyscale = imgAttrs.omero?.rdefs?.model == 'greyscale';

const activeIndx = channels.flatMap((ch, idx) => (ch.active ? [idx] : []));
const activeChs = channels.filter((ch) => ch.active);

let contrastLimits: [number, number][] = activeChs.map((ch) => [ch.window.start, ch.window.end]);
let rgbColors = activeChs.map((ch) => getRgb(ch.color, greyscale));

const dims = z_arr.shape.length;
const width = z_arr.shape[dims - 1];
const height = z_arr.shape[dims - 2];

// fetch 2D array for each channel
// chunk is [t, c, z] (if we have those dimensions)
const axes = getNgffAxes(imgAttrs.multiscales);

const defaultSelection = Object.fromEntries(
axes.map((axis, index) => {
// thumbnail is T=0 and Zx=midpoint
if (axis.name === 'z') return [axis.name, Math.floor(z_arr.shape[index] / 2)];
return [axis.name, 0];
})
);

const selections = activeIndx.map((idx) => {
let chk = { ...defaultSelection };
chk['c'] = idx;
return chk;
});

console.log('guessTileSize(z_arr)', guessTileSize(z_arr));

const loader = new ZarrPixelSource(
z_arr,
axes.map((a) => a.name) as [...string[], 'y', 'x'],
guessTileSize(z_arr)
);
const imageLayer = new ImageLayer({
loader,
selections,
contrastLimits,
channelsVisible: Array(activeIndx.length).fill(true),
// @ts-expect-error this is a bug with viv. It shouldn't be a type errror
colors: rgbColors,
});

const { zoom } = fitBounds([width, height], [THUMB.WIDTH, THUMB.HEIGHT], 1, 0);

viewer = (
<DeckGL
layers={[imageLayer]}
style={thumbStyle}
viewState={{ zoom }}
views={[new OrthographicView({ id: 'ortho', controller: true })]}
/>
);
}
}

return (
<li>
{imgPath}
{viewer}
</li>
);
}

export default ImageThumbnail;
5 changes: 4 additions & 1 deletion src/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function getLayerSize(props: LayerState['layerProps']) {
height = (height + spacer) * props.rows;
width = (width + spacer) * props.columns;
}
return { height, width, maxZoom };
return { height, width, maxZoom: 1 };
}

function WrappedViewStateDeck({ layers }: { layers: Layer<any, any>[] }) {
Expand All @@ -33,13 +33,16 @@ function WrappedViewStateDeck({ layers }: { layers: Layer<any, any>[] }) {
if (deckRef.current && viewState?.default && layers[0]?.props?.loader) {
const { deck } = deckRef.current;
const { width, height, maxZoom } = getLayerSize(layers[0].props);
console.log('Viewer', { width, height, maxZoom });
const padding = deck.width < 400 ? 10 : deck.width < 600 ? 30 : 50; // Adjust depending on viewport width.
const { zoom, target } = fitBounds([width, height], [deck.width, deck.height], maxZoom, padding);
console.log('FIT', { zoom, target });
setViewState({ zoom, target });
}

return (
<DeckGL
style={{ visibility: layers.length == 0 ? 'hidden' : 'visible' }}
ref={deckRef}
layers={layers}
viewState={viewState}
Expand Down
35 changes: 20 additions & 15 deletions src/io.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { ImageLayer, MultiscaleImageLayer, ZarrPixelSource } from '@hms-dbmi/viv';
import { Group as ZarrGroup, openGroup, ZarrArray } from 'zarr';
import GridLayer from './gridLayer';
import { loadOmeroMultiscales, loadPlate, loadWell } from './ome';
import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from './state';
import { loadCollection, loadOmeroMultiscales, loadWell, getImagePaths } from './ome';
import type {
CollectionData,
ImageLayerConfig,
LayerState,
MultichannelConfig,
SingleChannelConfig,
SourceData,
} from './state';
import {
COLORS,
getDefaultColors,
Expand Down Expand Up @@ -82,7 +89,7 @@ async function loadMultiChannel(
};
}

export async function createSourceData(config: ImageLayerConfig): Promise<SourceData> {
export async function createSourceData(config: ImageLayerConfig): Promise<SourceData | CollectionData> {
const node = await open(config.source);
let data: ZarrArray[];
let axes: Ome.Axis[] | undefined;
Expand All @@ -91,7 +98,16 @@ export async function createSourceData(config: ImageLayerConfig): Promise<Source
const attrs = (await node.attrs.asObject()) as Ome.Attrs;

if ('plate' in attrs) {
return loadPlate(config, node, attrs.plate);
return loadCollection(config, node, attrs);
}

if ('collection' in attrs) {
const imagePaths = await getImagePaths(node, attrs);
return {
images: imagePaths.map((path) => {
return { path, group: node };
}),
} as CollectionData;
}

if ('well' in attrs) {
Expand All @@ -102,17 +118,6 @@ export async function createSourceData(config: ImageLayerConfig): Promise<Source
return loadOmeroMultiscales(config, node, attrs);
}

if (Object.keys(attrs).length === 0 && node.path) {
// No rootAttrs in this group.
// if url is to a plate/acquisition/ check parent dir for 'plate' zattrs
const parentPath = node.path.slice(0, node.path.lastIndexOf('/'));
const parent = await openGroup(node.store, parentPath);
const parentAttrs = (await parent.attrs.asObject()) as Ome.Attrs;
if ('plate' in parentAttrs) {
return loadPlate(config, parent, parentAttrs.plate);
}
}

if (!('multiscales' in attrs)) {
throw Error('Group is missing multiscales specification.');
}
Expand Down
101 changes: 73 additions & 28 deletions src/ome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,42 +126,87 @@ export async function loadWell(config: ImageLayerConfig, grp: ZarrGroup, wellAtt
return sourceData;
}

export async function loadPlate(config: ImageLayerConfig, grp: ZarrGroup, plateAttrs: Ome.Plate): Promise<SourceData> {
if (!('columns' in plateAttrs) || !('rows' in plateAttrs)) {
throw Error(`Plate .zattrs missing columns or rows`);
export async function getImagePaths(grp: ZarrGroup, omeAttrs: Ome.Attrs): Promise<string[]> {
if ('collection' in omeAttrs) {
return Object.keys(omeAttrs.collection.images);
} else if ('plate' in omeAttrs) {
// Load each Well to get a path/to/image/
const wellPaths = omeAttrs.plate.wells.map((well) => well.path);
async function getImgPath(wellPath: string) {
const wellAttrs = await getAttrsOnly<{ well: Ome.Well }>(grp, wellPath);
// Fields are by index and we assume at least 1 per Well
return join(wellPath, wellAttrs.well.images[0].path);
}
const imgPaths = await Promise.all(wellPaths.map(getImgPath));
return imgPaths;
} else {
return [];
}
}

const rows = plateAttrs.rows.map((row) => row.name);
const columns = plateAttrs.columns.map((row) => row.name);
export async function loadCollection(
config: ImageLayerConfig,
grp: ZarrGroup,
omeAttrs: Ome.Attrs
): Promise<SourceData> {
const imagePaths = await getImagePaths(grp, omeAttrs);

let displayName = 'Collection';
let rows: string[];
let columns: string[];
let colCount: number;
let rowCount: number;
if ('plate' in omeAttrs) {
const plateAttrs = omeAttrs.plate;
if (!('columns' in plateAttrs) || !('rows' in plateAttrs)) {
throw Error(`Plate .zattrs missing columns or rows`);
}
rows = plateAttrs.rows.map((row) => row.name);
columns = plateAttrs.columns.map((row) => row.name);
displayName = plateAttrs.name || 'Collection';
colCount = columns.length;
rowCount = rows.length;
} else {
throw Error('No plate data');
}

// Fields are by index and we assume at least 1 per Well
const wellPaths = plateAttrs.wells.map((well) => well.path);
function getImgSource(source: string, row: number, column: number) {
if (rows && columns) {
return join(source, rows[row], columns[column]);
} else {
return join(source, imagePaths[row * colCount + column]);
}
}

// Use first image as proxy for others.
const wellAttrs = await getAttrsOnly<{ well: Ome.Well }>(grp, wellPaths[0]);
if (!('well' in wellAttrs)) {
throw Error('Path for image is not valid, not a well.');
function getGridCoord(imgPath: string) {
let row, col, name;
if (rows && columns) {
const [rowName, colName] = imgPath.split('/');
row = rows.indexOf(rowName);
col = columns.indexOf(colName);
name = `${rowName}${colName}`;
} else {
const imgIndex = imagePaths?.indexOf(imgPath);
row = Math.floor(imgIndex / colCount);
col = imgIndex - row * colCount;
name = imgPath;
}
return { row, col, name };
}

const imgPath = wellAttrs.well.images[0].path;
const imgAttrs = (await grp.getItem(join(wellPaths[0], imgPath)).then((g) => g.attrs.asObject())) as Ome.Attrs;
const imgPath = imagePaths[0];
const imgAttrs = (await grp.getItem(imgPath).then((g) => g.attrs.asObject())) as Ome.Attrs;
if (!('multiscales' in imgAttrs)) {
throw Error('Path for image is not valid.');
}
// 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;

async function getImgPath(wellPath: string) {
const wellAttrs = await getAttrsOnly<{ well: Ome.Well }>(grp, wellPath);
return join(wellPath, wellAttrs.well.images[0].path);
}
const wellImagePaths = await Promise.all(wellPaths.map(getImgPath));

// 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(
wellImagePaths.map((p) => [p, join(p, resolution)]),
imagePaths.map((p) => [p, join(p, resolution)]),
mapper,
{ concurrency: 10 }
);
Expand All @@ -170,11 +215,11 @@ export async function loadPlate(config: ImageLayerConfig, grp: ZarrGroup, plateA
const axis_labels = getNgffAxisLabels(axes);
const tileSize = guessTileSize(data[0][1]);
const loaders = data.map((d) => {
const [row, col] = d[0].split('/');
const coord = getGridCoord(d[0]);
return {
name: `${row}${col}`,
row: rows.indexOf(row),
col: columns.indexOf(col),
name: coord.name,
row: coord.row,
col: coord.col,
loader: new ZarrPixelSource(d[1], axis_labels, tileSize),
};
});
Expand All @@ -197,9 +242,9 @@ export async function loadPlate(config: ImageLayerConfig, grp: ZarrGroup, plateA
colormap: config.colormap ?? '',
opacity: config.opacity ?? 1,
},
name: plateAttrs.name || 'Plate',
rows: rows.length,
columns: columns.length,
name: displayName,
rows: rowCount,
columns: colCount,
};
// Us onClick from image config or Open Well in new window
sourceData.onClick = (info: any) => {
Expand All @@ -210,7 +255,7 @@ export async function loadPlate(config: ImageLayerConfig, grp: ZarrGroup, plateA
const { row, column } = gridCoord;
let imgSource = undefined;
if (typeof config.source === 'string' && grp.path && !isNaN(row) && !isNaN(column)) {
imgSource = join(config.source, rows[row], columns[column]);
imgSource = getImgSource(config.source, row, column);
}
if (config.onClick) {
delete info.layer;
Expand Down
Loading