Skip to content

Commit

Permalink
clean up dzi viewer
Browse files Browse the repository at this point in the history
  • Loading branch information
TheMooseman committed Jan 6, 2025
1 parent 1163339 commit f55e099
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 63 deletions.
5 changes: 2 additions & 3 deletions examples/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router';
import { Home } from './home';
import { TwoClientsPOC } from './dzi/double';
import { OmezarrDemo } from './omezarr/omezarr';
import { DziViewerPair } from './dzi/double';

export function App() {
console.log('app');
return (
<BrowserRouter>
<Routes>
Expand All @@ -15,7 +14,7 @@ export function App() {
/>
<Route
path="/dzi"
element={<TwoClientsPOC />}
element={<DziViewerPair />}
/>
<Route
path="/omezarr"
Expand Down
60 changes: 33 additions & 27 deletions examples/src/dzi/double.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { RenderServerProvider } from '../common/react/render-server-provider';
import React from 'react';
import { DziView } from './dziView';
import type { DziImage, DziRenderSettings } from '@alleninstitute/vis-dzi';
import { Box2D, Vec2, type box2D } from '@alleninstitute/vis-geometry';
import { Box2D, Vec2, type box2D, type vec2 } from '@alleninstitute/vis-geometry';
import { DziViewer } from './dzi-viewer';

const example: DziImage = {
// We know the sizes and formats ahead of time for these examples,
// if you'd like to see how to get this data from an endpoint with a dzi file check out use-dzi-image.ts
const exampleA: DziImage = {
format: 'jpeg',
imagesUrl:
'https://idk-etl-prod-download-bucket.s3.amazonaws.com/idf-23-10-pathology-images/pat_images_HPW332DMO29NC92JPWA/H20.33.029-A12-I6-primary/H20.33.029-A12-I6-primary_files/',
Expand All @@ -16,7 +18,8 @@ const example: DziImage = {
},
tileSize: 512,
};
const exampleDzi: DziImage = {

const exampleB: DziImage = {
imagesUrl: 'https://openseadragon.github.io/example-images/highsmith/highsmith_files/',
format: 'jpg',
overlap: 2,
Expand All @@ -26,12 +29,11 @@ const exampleDzi: DziImage = {
},
tileSize: 256,
};
const exampleSettings: DziRenderSettings = {
camera: {
screenSize: [500, 500],
view: Box2D.create([0, 0], [1, 1]),
},
};

const screenSize: vec2 = [500, 500];

const images = [exampleA, exampleB];

/**
* HEY!!!
* this is an example React Component for rendering two DZI images which share a camera.
Expand All @@ -42,40 +44,44 @@ const exampleSettings: DziRenderSettings = {
* SVG overlays, etc may all be different!
*
*/
export function TwoClientsPOC() {
export function DziViewerPair() {
// the DZI renderer expects a "relative" camera - that means a box, from 0 to 1. 0 is the bottom or left of the image,
// and 1 is the top or right of the image, regardless of the aspect ratio of that image.
const [view, setView] = useState<box2D>(Box2D.create([0, 0], [1, 1]));
const zoom = (e: React.WheelEvent<HTMLCanvasElement>) => {
const zoom = (e: WheelEvent) => {
e.preventDefault();
const scale = e.deltaY > 0 ? 1.1 : 0.9;
const m = Box2D.midpoint(view);
const v = Box2D.translate(Box2D.scale(Box2D.translate(view, Vec2.scale(m, -1)), [scale, scale]), m);
setView(v);
};
const overlay = useRef<HTMLImageElement>(new Image());

const camera: DziRenderSettings['camera'] = useMemo(() => ({ screenSize, view }), [view]);

useEffect(() => {
overlay.current.onload = () => {
console.log('loaded svg!');
};
overlay.current.src =
'https://idk-etl-prod-download-bucket.s3.amazonaws.com/idf-22-07-pathology-image-move/pat_images_JGCXWER774NLNWX2NNR/7179-A6-I6-MTG-classified/annotation.svg';
}, []);

return (
<RenderServerProvider>
<DziView
id="left"
svgOverlay={overlay.current}
dzi={example}
camera={{ ...exampleSettings.camera, view }}
wheel={zoom}
/>
<DziView
id="right"
dzi={exampleDzi}
svgOverlay={overlay.current}
camera={{ ...exampleSettings.camera, view }}
wheel={zoom}
/>
<div style={{ display: 'flex', flexDirection: 'row' }}>
{images.map((v) => (
<div style={{ width: screenSize[0], height: screenSize[1] }}>
<DziViewer
id={v.imagesUrl}
dzi={v}
camera={camera}
svgOverlay={overlay.current}
onWheel={zoom}
/>
</div>
))}
</div>
</RenderServerProvider>
);
}
60 changes: 38 additions & 22 deletions examples/src/dzi/dziView.tsx → examples/src/dzi/dzi-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { useContext, useEffect, useRef } from 'react';
import {
buildDziRenderer,
type DziImage,
type DziRenderSettings,
type DziTile,
type GpuProps as CachedPixels,
buildAsyncDziRenderer,
} from '@alleninstitute/vis-dzi';
import React from 'react';
import { buildAsyncRenderer, type RenderFrameFn } from '@alleninstitute/vis-scatterbrain';
import { isEqual } from 'lodash';
import { renderServerContext } from '../common/react/render-server-provider';
import { Vec2, type vec2 } from '@alleninstitute/vis-geometry';
import { renderServerContext } from '~/common/react/render-server-provider';
import React from 'react';

type Props = {
id: string;
dzi: DziImage;
svgOverlay: HTMLImageElement;
wheel: (e: React.WheelEvent<HTMLCanvasElement>) => void;
onWheel?: (e: WheelEvent) => void;
onMouseDown?: (e: React.MouseEvent<HTMLCanvasElement>) => void;
onMouseUp?: (e: React.MouseEvent<HTMLCanvasElement>) => void;
onMouseMove?: (e: React.MouseEvent<HTMLCanvasElement>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLCanvasElement>) => void;
} & DziRenderSettings;

function buildCompositor(svg: HTMLImageElement, settings: DziRenderSettings) {
Expand All @@ -36,20 +38,11 @@ function buildCompositor(svg: HTMLImageElement, settings: DziRenderSettings) {
};
}

export function DziView(props: Props) {
const { svgOverlay, camera, dzi, wheel, id } = props;
export function DziViewer(props: Props) {
const { svgOverlay, camera, dzi, onWheel, id, onMouseDown, onMouseUp, onMouseMove, onMouseLeave } = props;
const server = useContext(renderServerContext);
const cnvs = useRef<HTMLCanvasElement>(null);

// this is a demo, so rather than work hard to have a referentially stable camera,
// we just memoize it like so to prevent over-rendering
const [cam, setCam] = useState(camera);
useEffect(() => {
if (!isEqual(cam, camera)) {
setCam(camera);
}
}, [camera]);

// the renderer needs WebGL for us to create it, and WebGL needs a canvas to exist, and that canvas needs to be the same canvas forever
// hence the awkwardness of refs + an effect to initialize the whole hting
const renderer =
Expand All @@ -63,6 +56,7 @@ export function DziView(props: Props) {
}
return () => {
if (cnvs.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
server?.destroyClient(cnvs.current);
}
};
Expand All @@ -73,11 +67,11 @@ export function DziView(props: Props) {
const renderMyData: RenderFrameFn<DziImage, DziTile> = (target, cache, callback) => {
if (renderer.current) {
// erase the frame before we start drawing on it
return renderer.current(dzi, { camera: cam }, callback, target, cache);
return renderer.current(dzi, { camera }, callback, target, cache);
}
return null;
};
const compose = buildCompositor(svgOverlay, { camera: cam });
const compose = buildCompositor(svgOverlay, { camera });
server.beginRendering(
renderMyData,
(e) => {
Expand All @@ -91,20 +85,42 @@ export function DziView(props: Props) {
break;
case 'finished': {
e.server.copyToClient(compose);
break;
}
default:
break;
}
},
cnvs.current
);
}
}, [server, renderer.current, cnvs.current, cam]);
}, [server, svgOverlay, dzi, camera]);

// we have to add the listener this way because onWheel is a passive listener by default
// that means we can't preventDefault to stop scrolling
useEffect(() => {
const handleWheel = (e: WheelEvent) => onWheel?.(e);
const canvas = cnvs;
if (canvas?.current) {
canvas.current.addEventListener('wheel', handleWheel, { passive: false });
}
return () => {
if (canvas?.current) {
canvas.current.removeEventListener('wheel', handleWheel);
}
};
}, [onWheel]);

return (
<canvas
id={id}
ref={cnvs}
onWheel={wheel}
width={camera.screenSize[0]}
height={camera.screenSize[1]}
></canvas>
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
/>
);
}
5 changes: 0 additions & 5 deletions examples/src/dzi/dzi.ts

This file was deleted.

6 changes: 0 additions & 6 deletions examples/src/dzi/dzi.tsx

This file was deleted.

80 changes: 80 additions & 0 deletions examples/src/dzi/use-dzi-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { DziImage } from '@alleninstitute/vis-dzi';
import type { vec2 } from '@alleninstitute/vis-geometry';
import { useEffect, useState } from 'react';

interface useDziImageProps {
imageUrls: string[];
onComplete?: (imgSize: vec2) => void;
}

function decodeDzi(s: string, url: string): DziImage | undefined {
const parser = new DOMParser();
const doc = parser.parseFromString(s, 'text/xml');
const err = doc.querySelector('Error');
if (err) return undefined;

if (doc) {
const img = doc.getElementsByTagName('Image')[0];
const size = doc.getElementsByTagName('Size')?.[0];
const [format, overlap, tileSize] = [
img.getAttribute('Format'),
img.getAttribute('Overlap'),
img.getAttribute('TileSize'),
];
if (size && format && overlap && tileSize) {
const width = size.getAttribute('Width');
const height = size.getAttribute('Height');
const splits = url.split('.dzi');
if (width && height && splits) {
return {
imagesUrl: `${splits?.[0]}_files/`,
format: format as 'jpeg' | 'png' | 'jpg' | 'JPG' | 'PNG',
overlap: Number.parseInt(overlap, 10),
tileSize: Number.parseInt(tileSize, 10),
size: {
width: Number.parseInt(width, 10),
height: Number.parseInt(height, 10),
},
};
}
}
}
return undefined;
}

async function getImages(urls: string[], callback: (images: Array<DziImage | undefined>) => void) {
const images = await Promise.all(
urls.map(async (v) =>
fetch(v)
.then((res) => {
console.log(res);
return res.text();
})
.then((s) => decodeDzi(s, v))
)
);

callback(images);
}

export function useDziImages({ imageUrls, onComplete }: useDziImageProps) {
const [loading, setLoading] = useState(false);
const [images, setImages] = useState<DziImage[]>([]);

useEffect(() => {
setImages([]);
setLoading(true);
const updateImages = (data: Array<DziImage | undefined>) => {
setImages(data.filter((v) => v !== undefined));
setLoading(false);

// only fire the on complete if we have an image to give a size for
if (data[0]?.size) {
onComplete?.([data[0].size.width, data[0].size.height]);
}
};
getImages(imageUrls, updateImages);
}, [imageUrls, onComplete]);

return { images, loading };
}

0 comments on commit f55e099

Please sign in to comment.