Skip to content

Commit

Permalink
feat(vis) layered rendering demo (#22)
Browse files Browse the repository at this point in the history
* first step to layered rendering: one layer!

* bufferPair gets its own file in common/

* technically functional rendering of layers of ome-zarr slices and scatterplot points.

* dig up the old ccf average-template data... todo ask scott for better chunk-sizes!

* I can load a slide-view slide and the average_template ccf (ask scott for a better one) and show them togeather, although their dimensions suggest that they are radically different sizes

* get the dang things to line up by fixing all my goofy voxel math

* aaaaaaah what a wild bug

* a little less sloppy

* fun idea but breaks visibility determination

* generalize layers, build up convenience around frames, more formal types to be rendered

* a working (but somewhat confusing) generic layer (impl for scatterplots first)

* both layer types working

* delete half-baked approach

* pull out common stuff (target) from generic types to make things less confusing

* add a super basic annotation (just lines) renderer, wrap it in a layer thingy

* fix a lil slop

* minor changes as I prepare to add some sort of UI

* working but very strange imgui implementation... lets see if its nice

* add some UI - not in love withit

* draw after loading

* start to merge the two different zarr rendering methods

* less hacky way of picking an appropriate resolution, although its still a bit iffy...

* thinking about skipping ui and just having json state...

* super basic optional transforms for layers. next up a grid of these things

* add a volume grid layer, mild cleanup elsewhere

* draw data as it arrives - and prepend frames with low-res data to cover the pan case

* refactor various types and loaders to make it easier to configure an instance with a list of simple config payloads. separate data types from the code that would render them. updates to demo to use new loaders

* lets serve the app with parcel

* Get layers app Parcelized

* Noah/layers pt2 (#23)

* delete ye olde build script

* add some react for UI - think about if I like it or it will make my life nice

* fix a perf bug in which we consider rendering every slice in a grid regardless of zoom level.
some (better?) smoke and mirrors trickery to try to obscure redraw flickering when panning/zooming

* working but messy impl of a worker pool that handles decoding zarr chunks for us

# Conflicts:
#	apps/layers/package.json

* play around with WW pool size, and fix a bug in which the optional render-working budget parameters were being ignored (for the slice renderer specifically)

* move a shocking amount of code in to render slide-view style annotations

# Conflicts:
#	apps/layers/package.json

* good enough for now

# Conflicts:
#	apps/layers/src/demo.ts

* respect queue params

* enable screenshots, with a default output resolution of roughly 85MP

# Conflicts:
#	apps/layers/package.json
#	apps/layers/src/demo.ts
#	pnpm-lock.yaml

* start thinking about upside down data...

* its all upside down now  great

* minor tidy, plus a rough attempt at a less flickery stand-in algorithm

* tools to add layers during demotime

# Conflicts:
#	apps/layers/src/demo.ts

* add a versa layer

* add scatterplot-slideview real quick

# Conflicts:
#	apps/layers/src/demo.ts

* start some cleanup so I can merge this...

# Conflicts:
#	apps/layers/src/demo.ts

* Merge branch 'noah/layered-demo' into noah/layered-with-react-whynot

# Conflicts:
#	apps/layers/src/demo.ts

* quickly change the underlying cache type for scatterplots for much better perf (gpu buffer not client-buffer)

* try out sds components for quick hacky ui fun - delete old ui code

* add a bunch of per-layer ui elements

* prev/next layer buttons

* take a snapshot button

* quickly re-enable drawing layers

* a bit hacky, but non-flickering drawings are worth it for a demo

* change moduleResolution in the apps tsconfig to make the zarr library that we use extensively get resolved correctly. this is an issue on their end: gzuidhof/zarr.js#152

* cleanup some increasingly scary cherrypicks, and finally tidy up those last little demo ts errors.

* clean up a bunch of low hanging fruit

* fix up the scatterplot (standalone) demo

* readme and demo script

* a little more

* copy in the latest and greatest annotation stuff in

* minor cleanups

* fix wrongly named example

---------

Co-authored-by: Lane Sawyer <[email protected]>
  • Loading branch information
froyo-np and lanesawyer authored May 13, 2024
1 parent 6fedb9e commit e10b436
Show file tree
Hide file tree
Showing 49 changed files with 3,713 additions and 406 deletions.
9 changes: 9 additions & 0 deletions apps/common/src/bufferPair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

export type BufferPair<T> = {
writeTo: T;
readFrom: T;
}
export function swapBuffers<T>(doubleBuffer: BufferPair<T>) {
const { readFrom, writeTo } = doubleBuffer;
return { readFrom: writeTo, writeTo: readFrom };
}
30 changes: 30 additions & 0 deletions apps/common/src/loaders/ome-zarr/fetchSlice.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

// a web-worker which fetches slices of data, decodes them, and returns the result as a flat float32 array, using transferables
import type { NestedArray, TypedArray } from 'zarr'
import { getSlice, type ZarrDataset, type ZarrRequest } from "./zarr-data";

const ctx = self;
type ZarrSliceRequest = {
id: string;
type: 'ZarrSliceRequest'
metadata: ZarrDataset
req: ZarrRequest,
layerIndex: number
}
function isSliceRequest(payload: any): payload is ZarrSliceRequest {
return typeof payload === 'object' && payload['type'] === 'ZarrSliceRequest';
}
ctx.onmessage = (msg: MessageEvent<unknown>) => {
const { data } = msg;
if (isSliceRequest(data)) {
const { metadata, req, layerIndex, id } = data;
getSlice(metadata, req, layerIndex).then((result: {
shape: number[],
buffer: NestedArray<TypedArray>
}) => {
const { shape, buffer } = result;
const R = new Float32Array(buffer.flatten());
ctx.postMessage({ type: 'slice', id, shape, data: R }, { transfer: [R.buffer] })
})
}
}
75 changes: 75 additions & 0 deletions apps/common/src/loaders/ome-zarr/sliceWorkerPool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { uniqueId } from "lodash";
import type { ZarrDataset, ZarrRequest } from "./zarr-data";

type PromisifiedMessage = {
requestCacheKey: string;
resolve: (t: Slice) => void;
reject: (reason: unknown) => void;
promise?: Promise<Slice> | undefined;
};
type ExpectedResultSlice = {
type: 'slice',
id: string;
} & Slice;
type Slice = {
data: Float32Array;
shape: number[]
}
function isExpectedResult(obj: any): obj is ExpectedResultSlice {
return (typeof obj === 'object' && 'type' in obj && obj.type === 'slice')
}
export class SliceWorkerPool {
private workers: Worker[];
private promises: Record<string, PromisifiedMessage>;
private which: number;
constructor(size: number) {
this.workers = new Array(size);
for (let i = 0; i < size; i++) {
this.workers[i] = new Worker(new URL('./fetchSlice.worker.ts', import.meta.url), { type: 'module' });
this.workers[i].onmessage = (msg) => this.handleResponse(msg)
}
this.promises = {};
this.which = 0;
}

handleResponse(msg: MessageEvent<unknown>) {
const { data: payload } = msg;
if (isExpectedResult(payload)) {
const prom = this.promises[payload.id];
if (prom) {
const { data, shape } = payload
prom.resolve({ data, shape });
delete this.promises[payload.id]
}
}
}
private roundRobin() {
this.which = (this.which + 1) % this.workers.length
}
requestSlice(dataset: ZarrDataset, req: ZarrRequest, layerIndex: number) {
const reqId = uniqueId('rq');
const cacheKey = JSON.stringify({ url: dataset.url, req, layerIndex });
// TODO caching I guess...
const eventually = new Promise<Slice>((resolve, reject) => {
this.promises[reqId] = {
requestCacheKey: cacheKey,
resolve,
reject,
promise: undefined, // ill get added to the map once I am fully defined!
};
this.workers[this.which].postMessage({ id: reqId, type: 'ZarrSliceRequest', metadata: dataset, req, layerIndex });
this.roundRobin();
});
this.promises[reqId].promise = eventually;
return eventually;
}
}

// a singleton...
let slicePool: SliceWorkerPool;
export function getSlicePool() {
if (!slicePool) {
slicePool = new SliceWorkerPool(16);
}
return slicePool;
}
123 changes: 45 additions & 78 deletions apps/common/src/loaders/ome-zarr/zarr-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { HTTPStore, NestedArray, type TypedArray, openArray, openGroup, slice } from "zarr";
import { some } from "lodash";
import { Box2D, type Interval, Vec2, type box2D, limit, type vec2 } from "@alleninstitute/vis-geometry";
import type { AxisAlignedPlane } from "../../../../omezarr-viewer/src/versa-renderer";
// documentation for ome-zarr datasets (from which these types are built)
// can be found here:
// https://ngff.openmicroscopy.org/latest/#multiscale-md
Expand Down Expand Up @@ -41,47 +42,6 @@ type ZarrAttrs = {
multiscales: ReadonlyArray<ZarrAttr>;
};

// function getSpatialDimensionShape(dataset: DatasetWithShape, axes: readonly AxisDesc[]) {
// const dims = axes.reduce(
// (shape, ax, i) => (ax.type === "spatial" ? { ...shape, [ax.name]: dataset.shape[i] } : shape),
// {} as Record<string, number>
// );
// return dims;
// }
// function getSpatialOrdering
// function getBoundsInMillimeters(data: ZarrDataset) {
// if (data.multiscales.length !== 1) {
// throw new Error("cant support multi-scene zarr file...");
// }
// const scene = data.multiscales[0];
// const { axes, datasets } = scene;
// if (datasets.length < 1) {
// throw new Error("malformed dataset - no voxels!");
// }
// const dataset = datasets[0];
// const spatialResolution = getSpatialDimensionShape(dataset, axes);
// // apply transforms
// dataset.coordinateTransformations.forEach((trn) => {});
// const dimensions = getNumVoxelsInXYZ(getXYZIndexing(axes), dataset.shape);

// let bounds: box3D = Box3D.create([0, 0, 0], dimensions);
// dataset.coordinateTransformations.forEach((trn) => {
// // specification for coordinate transforms given here: https://ngff.openmicroscopy.org/latest/#trafo-md
// // from the above doc, its not super clear if the given transformation is in the order of the axes metadata (https://ngff.openmicroscopy.org/latest/#axes-md)
// // or some other order
// // all files I've seen so far have both in xyz order, so its a bit ambiguous.
// if (isScaleTransform(trn) && trn.scale.length >= 3) {
// bounds = applyScaleToXYZBounds(bounds, trn, axes);
// } else {
// throw new Error(`unsupported coordinate transformation type - please implement`);
// }
// });
// // finally - convert whatever the axes units are to millimeters, or risk crashing into mars
// // get the units of each axis in xyz order...

// return Box3D.map(bounds, (corner) => unitsToMillimeters(corner, axes));
// }

async function getRawInfo(store: HTTPStore) {
const group = await openGroup(store);
// TODO HACK ALERT: I am once again doing the thing that I hate, in which I promise to my friend Typescript that
Expand All @@ -108,19 +68,22 @@ async function loadMetadata(store: HTTPStore, attrs: ZarrAttrs) {
}

type OmeDimension = "x" | "y" | "z" | "t" | "c";

// function sizeOnScreen(full: box2D, relativeView: box2D, screen: vec2) {
// const pxView = Box2D.scale(relativeView, Box2D.size(full));
// const onScreen = Box2D.intersection(pxView, full);
// if (!onScreen) return [0, 0];

// const effective = Box2D.size(onScreen);
// // as a parameter, how much is on screen?
// const p = Vec2.div(effective, Box2D.size(full));
// const limit = Vec2.mul(p, screen);

// return limit[0] * limit[1] < effective[0] * effective[1] ? limit : effective
// }
const uvTable = {
xy: { u: "x", v: "y" },
xz: { u: "x", v: "z" },
yz: { u: "y", v: "z" },
} as const;
const sliceDimension = {
xy: "z",
xz: "y",
yz: "x",
} as const;
export function uvForPlane(plane: AxisAlignedPlane) {
return uvTable[plane];
}
export function sliceDimensionForPlane(plane: AxisAlignedPlane) {
return sliceDimension[plane];
}
export type ZarrRequest = Record<OmeDimension, number | Interval | null>;
export function pickBestScale(
dataset: ZarrDataset,
Expand All @@ -134,15 +97,25 @@ export function pickBestScale(
) {
const datasets = dataset.multiscales[0].datasets;
const axes = dataset.multiscales[0].axes;
const vxlPitch = (size: vec2) => Vec2.div([1, 1], size);
const realSize = sizeInUnits(plane, axes, datasets[0])!

const vxlPitch = (size: vec2) => Vec2.div(realSize, size);
// size, in dataspace, of a pixel 1/res
const pxPitch = Vec2.div(Box2D.size(relativeView), displayResolution);
const dstToDesired = (a: vec2, goal: vec2) => Vec2.length(Vec2.sub(a, goal));
const dstToDesired = (a: vec2, goal: vec2) => {
const diff = Vec2.sub(a, goal);
if (diff[0] * diff[1] > 0) {
// the res (a) is higher than our goal -
// weight this heavily to prefer smaller than the goal
return 1000 * Vec2.length(Vec2.sub(a, goal));
}
return Vec2.length(Vec2.sub(a, goal));
}
// we assume the datasets are ordered... hmmm TODO
const choice = datasets.reduce(
(bestSoFar, cur) =>
dstToDesired(vxlPitch(sizeInVoxels(plane, axes, bestSoFar)!), pxPitch) >
dstToDesired(vxlPitch(sizeInVoxels(plane, axes, cur)!), pxPitch)
dstToDesired(vxlPitch(planeSizeInVoxels(plane, axes, bestSoFar)!), pxPitch) >
dstToDesired(vxlPitch(planeSizeInVoxels(plane, axes, cur)!), pxPitch)
? cur
: bestSoFar,
datasets[0]
Expand All @@ -152,15 +125,17 @@ export function pickBestScale(
function indexFor(dim: OmeDimension, axes: readonly AxisDesc[]) {
return axes.findIndex((axe) => axe.name === dim);
}

export function sizeInUnits(
plane: {
plane: AxisAlignedPlane | {
u: OmeDimension;
v: OmeDimension;
},
axes: readonly AxisDesc[],
dataset: DatasetWithShape
): vec2 | undefined {
const vxls = sizeInVoxels(plane, axes, dataset);
plane = typeof plane === 'string' ? uvForPlane(plane) : plane
const vxls = planeSizeInVoxels(plane, axes, dataset);

if (vxls === undefined) return undefined;
let size: vec2 = vxls;
Expand All @@ -177,6 +152,16 @@ export function sizeInUnits(
return size;
}
export function sizeInVoxels(
dim: OmeDimension,
axes: readonly AxisDesc[],
dataset: DatasetWithShape
) {
const uI = indexFor(dim, axes);
if (uI === -1) return undefined

return dataset.shape[uI]
}
export function planeSizeInVoxels(
plane: {
u: OmeDimension;
v: OmeDimension;
Expand Down Expand Up @@ -247,24 +232,6 @@ export async function getSlice(metadata: ZarrDataset, r: ZarrRequest, layerIndex
buffer: result,
};
}
// export async function getRGBSlice(metadata: ZarrDataset, r: ZarrRequest, layerIndex: number) {
// dieIfMalformed(r);
// // put the request in native order
// const store = new HTTPStore(metadata.url);
// const scene = metadata.multiscales[0];
// const { axes } = scene;
// const level = scene.datasets[layerIndex] ?? scene.datasets[scene.datasets.length - 1];
// const arr = await openArray({ store, path: level.path, mode: "r" });
// const result = await arr.get(buildQuery(r, axes, level.shape));
// if (typeof result == "number" || result.shape.length !== 2) {
// throw new Error("oh noes, slice came back all weird");
// }
// return {
// shape: result.shape as unknown as vec2,
// buffer: result.flatten(),
// };
// }

export async function load(url: string) {
const store = new HTTPStore(url);
return loadMetadata(store, await getRawInfo(store));
Expand Down
51 changes: 38 additions & 13 deletions apps/common/src/loaders/scatterplot/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,63 @@
// todo rename this file

import { Box2D, visitBFS, type box2D, type vec2 } from "@alleninstitute/vis-geometry";
import { fetchColumn, type ColumnarTree, type loadDataset } from "./scatterbrain-loader";

import { fetchColumn, type ColumnData, type ColumnRequest, type ColumnarNode, type ColumnarTree, type SlideViewDataset, type loadDataset } from "./scatterbrain-loader";
import REGL from 'regl'
export type Dataset = ReturnType<typeof loadDataset>
export type RenderSettings = {
dataset: Dataset;
view: box2D;
colorBy: ColumnRequest;
pointSize: number;
target: REGL.Framebuffer2D | null;
regl: REGL.Regl;
}

function isVisible(view: box2D, sizeLimit: number, tree: ColumnarTree<vec2>) {
const { bounds } = tree.content;
return Box2D.size(bounds)[0] > sizeLimit && !!Box2D.intersection(view, tree.content.bounds);
}
export function getVisibleItems(dataset: Dataset, view: box2D, sizeLimit: number) {
const hits: ColumnarTree<vec2>[] = []
visitBFS(dataset.tree,
let tree = 'slides' in dataset ? Object.values(dataset.slides)[0].tree : dataset.tree;
visitBFS(tree,
(t: ColumnarTree<vec2>) => t.children,
(tree) => { hits.push(tree) },
(tree) => isVisible(view, sizeLimit, tree));
return hits;
}
export function getVisibleItemsInSlide(dataset: SlideViewDataset, slide: string, view: box2D, sizeLimit: number) {
const theSlide = dataset.slides[slide];
if (!theSlide) {
console.log('nope', Object.keys(dataset.slides))
return []
}

const hits: ColumnarTree<vec2>[] = []
const tree = theSlide.tree;
visitBFS(tree,
(t: ColumnarTree<vec2>) => t.children,
(tree) => { hits.push(tree) },
(tree) => isVisible(view, sizeLimit, tree));
return hits;
}
function toReglBuffer(c: ColumnData, regl: REGL.Regl) {
return {
type: 'vbo',
data: regl.buffer(c)
} as const;
}
function fetchAndUpload(settings: { dataset: Dataset, regl: REGL.Regl }, node: ColumnarNode<vec2>, req: ColumnRequest, signal?: AbortSignal | undefined) {
const { dataset, regl } = settings;
return fetchColumn(node, dataset, req, signal).then(cd => toReglBuffer(cd, regl))
}
export function fetchItem(item: ColumnarTree<vec2>, settings: RenderSettings, signal?: AbortSignal) {
const { dataset } = settings;
const position = () => fetchColumn(item.content, settings.dataset, {
name: dataset.spatialColumn,
type: 'METADATA',
}, signal);
const color = () => fetchColumn(item.content, settings.dataset, {
type: 'QUANTITATIVE',
name: '2097'
}, signal);
const { dataset, colorBy } = settings;
const position = () => fetchAndUpload(settings, item.content, { type: 'METADATA', name: dataset.spatialColumn }, signal);
const color = () => fetchAndUpload(settings, item.content, colorBy, signal)
return {
position,
color
}
} as const
}

Loading

0 comments on commit e10b436

Please sign in to comment.