From 3f51fe4cb20a8d09e6b329295433d4eef70c3472 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 11:16:21 -0300 Subject: [PATCH 01/57] Add viv loader --- craco.config.js | 13 ++ package.json | 28 ++++ src/App.tsx | 2 + src/AppConfig.d.ts | 24 +++ src/components/CaseViewer.tsx | 100 ++++++++---- src/viv/README.md | 11 ++ src/viv/VivSlideViewport.tsx | 174 +++++++++++++++++++++ src/viv/dicomLoader.ts | 281 ++++++++++++++++++++++++++++++++++ src/viv/index.ts | 7 + src/viv/vivDisplayDefaults.ts | 91 +++++++++++ 10 files changed, 699 insertions(+), 32 deletions(-) create mode 100644 src/viv/README.md create mode 100644 src/viv/VivSlideViewport.tsx create mode 100644 src/viv/dicomLoader.ts create mode 100644 src/viv/index.ts create mode 100644 src/viv/vivDisplayDefaults.ts diff --git a/craco.config.js b/craco.config.js index 67eadbf9..1aef8470 100644 --- a/craco.config.js +++ b/craco.config.js @@ -130,6 +130,19 @@ module.exports = { } } + /* Viv / Deck.gl / Luma ship modern JS; transpile for CRA 5 / webpack 5. */ + config.module.rules.push({ + test: /\.m?js$/, + include: /node_modules[\\/](@deck\.gl|@luma\.gl|@math\.gl|@probe\.gl|@hms-dbmi[\\/]viv)[\\/]/, + use: { + loader: require.resolve('babel-loader'), + options: { + presets: [require.resolve('babel-preset-react-app/dependencies')], + cacheDirectory: true + } + } + }) + return config } }, diff --git a/package.json b/package.json index 8ba01350..d9bf967c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,19 @@ ] }, "dependencies": { + "@deck.gl/core": "9.1.15", + "@deck.gl/extensions": "9.1.15", + "@deck.gl/geo-layers": "9.1.15", + "@deck.gl/layers": "9.1.15", + "@deck.gl/mesh-layers": "9.1.15", + "@deck.gl/react": "9.1.15", + "@deck.gl/widgets": "9.1.15", + "@hms-dbmi/viv": "~0.19.0", + "@luma.gl/constants": "~9.1.10", + "@luma.gl/core": "~9.1.10", + "@luma.gl/engine": "~9.1.10", + "@luma.gl/shadertools": "~9.1.10", + "@luma.gl/webgl": "~9.1.10", "antd": "^4.22.8", "classnames": "^2.2.6", "dcmjs": "^0.35.0", @@ -83,5 +96,20 @@ "react-test-renderer": "^18.2.0", "sonarqube-scanner": "^4.3.0", "typescript": "^4.7.4" + }, + "overrides": { + "@deck.gl/core": "9.1.15", + "@deck.gl/extensions": "9.1.15", + "@deck.gl/geo-layers": "9.1.15", + "@deck.gl/layers": "9.1.15", + "@deck.gl/mesh-layers": "9.1.15", + "@deck.gl/react": "9.1.15", + "@deck.gl/widgets": "9.1.15", + "nth-check": "2.0.1", + "wrap-ansi": "7.0.0", + "make-dir": "3.1.0", + "express": "4.21.0", + "rollup": "3.29.5", + "semver": "7.5.2" } } diff --git a/src/App.tsx b/src/App.tsx index 7131bdc1..cceb4cb1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,8 @@ function ParametrizedCaseViewer({ app={app} enableAnnotationTools={enableAnnotationTools} studyInstanceUID={studyInstanceUID} + useViv={config.useViv === true} + vivSettings={config.vivSettings} /> ) diff --git a/src/AppConfig.d.ts b/src/AppConfig.d.ts index 9e1d177c..4afdc906 100644 --- a/src/AppConfig.d.ts +++ b/src/AppConfig.d.ts @@ -98,6 +98,23 @@ export interface OidcSettings { endSessionEndpoint?: string } +export interface VivChannelSelection { + c: number + t?: number + z?: number +} + +export interface VivSettings { + selections?: VivChannelSelection[] + channelsVisible?: boolean[] + contrastLimits?: Array<[number, number]> + colors?: Array<[number, number, number]> + initialViewState?: { + target: [number, number, number] + zoom?: number + } +} + export default interface AppConfig { /** * Currently, only one server is supported. However, support for multiple @@ -128,4 +145,11 @@ export default interface AppConfig { enableInDevelopment?: boolean } enableMemoryMonitoring?: boolean + /** + * When true, the slide viewport uses Viv + Deck.gl (src/viv) + * instead of the default OpenLayers-based SlideViewer. Limited feature set. + */ + useViv?: boolean + /** Optional display overrides for the Viv viewer path (channels, contrast, etc.). */ + vivSettings?: VivSettings } diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index 52d89e27..01ca01ab 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -11,7 +11,7 @@ import { useParams, } from 'react-router-dom' -import type { AnnotationSettings } from '../AppConfig' +import type { AnnotationSettings, VivSettings } from '../AppConfig' import type { User } from '../auth' import type DicomWebManager from '../DicomWebManager' import type { Slide } from '../data/slides' @@ -30,6 +30,7 @@ import { RoutePaths, withSeriesInProjectPath, } from '../utils/routes' +import VivSlideViewport from '../viv/VivSlideViewport' import ClinicalTrial from './ClinicalTrial' import Patient from './Patient' import SlideList from './SlideList' @@ -74,6 +75,8 @@ function ParametrizedSlideViewer({ preload, enableAnnotationTools, annotations, + useViv, + vivSettings, }: { clients: { [key: string]: DicomWebManager } slides: Slide[] @@ -87,6 +90,8 @@ function ParametrizedSlideViewer({ preload: boolean enableAnnotationTools: boolean annotations: AnnotationSettings[] + useViv: boolean + vivSettings?: VivSettings }): JSX.Element | null { const { studyInstanceUID = '', seriesInstanceUID = '' } = useParams<{ studyInstanceUID: string @@ -228,21 +233,38 @@ function ParametrizedSlideViewer({ selectedSlide, seriesInstanceUID, ) - viewer = ( - - ) + if (useViv) { + const microscopyClient = + clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE] ?? + clients.default + if (microscopyClient === undefined) { + return null + } + viewer = ( + + ) + } else { + viewer = ( + + ) + } } return viewer } @@ -260,6 +282,8 @@ interface ViewerProps extends RouteComponentProps { enableAnnotationTools: boolean preload: boolean user?: User + useViv: boolean + vivSettings?: VivSettings } function Viewer(props: ViewerProps): JSX.Element | null { @@ -396,22 +420,34 @@ function Viewer(props: ViewerProps): JSX.Element | null { /> - - - } - /> - + + + + } + /> + + ) } diff --git a/src/viv/README.md b/src/viv/README.md new file mode 100644 index 00000000..c98ec4cc --- /dev/null +++ b/src/viv/README.md @@ -0,0 +1,11 @@ +# viv + +Isolated port of [viv-dicomweb-test](https://github.com/jmuhlich/viv-dicomweb-test) (DICOMweb + [Viv](https://github.com/hms-dbmi/viv) + [dicom-microscopy-viewer](https://github.com/imagingdatacommons/dicom-microscopy-viewer)). + +Enable with `useViv: true` in `public/config/*.js` (see `viv_example.js`). + +## Limitations (v1) + +- 16-bit SM images only; uses private `opticalPaths` access on `VolumeImageViewer`. +- No Slim annotations, SEG/SR, bulk annotations, optical-path sidebar, or presentation states. +- Intended as a rendering experiment alongside the default `SlideViewer`. diff --git a/src/viv/VivSlideViewport.tsx b/src/viv/VivSlideViewport.tsx new file mode 100644 index 00000000..302930c7 --- /dev/null +++ b/src/viv/VivSlideViewport.tsx @@ -0,0 +1,174 @@ +import type { Layer } from '@deck.gl/core' +import { OrthographicView } from '@deck.gl/core' +import DeckGL from '@deck.gl/react' +import { MultiscaleImageLayer } from '@hms-dbmi/viv' +import { message, Spin } from 'antd' +import type React from 'react' +import { useEffect, useRef, useState } from 'react' + +import type { VivSettings } from '../AppConfig' +import type DicomWebManager from '../DicomWebManager' +import { DicomLoader } from './dicomLoader' +import { buildVivDisplayOptions } from './vivDisplayDefaults' + +export interface VivSlideViewportProps { + client: DicomWebManager + studyInstanceUID: string + seriesInstanceUID: string + vivSettings?: VivSettings +} + +const orthographicView = new OrthographicView() + +/** + * Viv + Deck.gl viewport for DICOM SM (proof-of-concept). + * See src/viv/README.md for limitations. + */ +const VivSlideViewport: React.FC = ({ + client, + studyInstanceUID, + seriesInstanceUID, + vivSettings, +}) => { + const containerRef = useRef(null) + const [size, setSize] = useState({ width: 100, height: 100 }) + const [layers, setLayers] = useState([]) + const [loading, setLoading] = useState(true) + const [viewState, setViewState] = useState<{ + target: [number, number, number] + zoom: number + }>({ + target: [0, 0, 0], + zoom: -6, + }) + + useEffect(() => { + const el = containerRef.current + if (el === null) { + return + } + const ro = new ResizeObserver(() => { + setSize({ width: el.clientWidth, height: el.clientHeight }) + }) + ro.observe(el) + setSize({ width: el.clientWidth, height: el.clientHeight }) + return () => { + ro.disconnect() + } + }, []) + + useEffect(() => { + let cancelled = false + setLoading(true) + setLayers([]) + + const run = async (): Promise => { + try { + const dicomLoader = new DicomLoader(client, { + studyInstanceUID, + seriesInstanceUID, + }) + const sources = await dicomLoader.getSources() + if (cancelled) { + return + } + if (sources.length === 0) { + throw new Error('No pyramid levels returned for this series.') + } + const [, height, width] = sources[0].shape + const channelCount = sources[0].shape[0] + const display = buildVivDisplayOptions( + height, + width, + channelCount, + vivSettings, + ) + // Viv 0.19 MultiscaleImageLayer uses contrast/visibility only; channel + // colors come from defaults. `vivSettings.colors` is reserved for future use. + const layer = new MultiscaleImageLayer({ + id: 'slim-viv-multiscale', + loader: sources as never, + selections: display.selections, + channelsVisible: display.channelsVisible, + contrastLimits: display.contrastLimits, + dtype: 'Uint16', + }) + setLayers([layer as unknown as Layer]) + setViewState({ + target: display.initialViewState.target, + zoom: display.initialViewState.zoom, + }) + } catch (err) { + console.error(err) + if (!cancelled) { + const text = + err instanceof Error + ? err.message + : 'Failed to open slide in Viv viewer.' + void message.error(text) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void run() + return () => { + cancelled = true + } + }, [client, studyInstanceUID, seriesInstanceUID, vivSettings]) + + return ( +
+ {loading && ( +
+ +
+ )} + { + if ( + vs !== undefined && + typeof vs === 'object' && + 'target' in vs && + 'zoom' in vs + ) { + setViewState( + vs as { target: [number, number, number]; zoom: number }, + ) + } + }} + controller={true} + layers={layers} + width={size.width} + height={size.height} + /> +
+ ) +} + +export default VivSlideViewport diff --git a/src/viv/dicomLoader.ts b/src/viv/dicomLoader.ts new file mode 100644 index 00000000..1237d04d --- /dev/null +++ b/src/viv/dicomLoader.ts @@ -0,0 +1,281 @@ +// Ported from https://github.com/jmuhlich/viv-dicomweb-test (dicomweb.js). +// Adapts dicom-microscopy-viewer tile loaders to Viv PixelSource. + +// skipcq: JS-C1003 +import * as dmv from 'dicom-microscopy-viewer' +import type DicomWebManager from '../DicomWebManager' + +export interface DicomRetrieveOptions { + studyInstanceUID: string + seriesInstanceUID: string +} + +type OpticalPathEntry = { + pyramid: { + metadata: Array<{ + TotalPixelMatrixRows: number + TotalPixelMatrixColumns: number + }> + tileSizes: number[] + } + layer: { + getSource: () => { + loader_: (level: number, x: number, y: number) => Promise + } + } +} + +function getOpticalPathsMap(viewer: dmv.viewer.VolumeImageViewer): { + [key: string]: OpticalPathEntry +} { + const sym = Object.getOwnPropertySymbols(viewer).find( + (s) => s.description === 'opticalPaths', + ) + if (sym === undefined) { + throw new Error( + 'dicom-microscopy-viewer VolumeImageViewer: opticalPaths symbol not found', + ) + } + const raw = viewer[sym as unknown as keyof dmv.viewer.VolumeImageViewer] + return raw as unknown as { [key: string]: OpticalPathEntry } +} + +export class DicomLoader { + private readonly _client: DicomWebManager + + private readonly _retrieveOptions: DicomRetrieveOptions + + private _viewer?: dmv.viewer.VolumeImageViewer + + private _opticalPaths?: { [key: string]: OpticalPathEntry } + + private _tileSize?: number + + private _loaders?: { + [channel: string]: ( + level: number, + x: number, + y: number, + ) => Promise + } + + private _shapes?: Array<[number, number, number]> + + constructor(client: DicomWebManager, retrieveOptions: DicomRetrieveOptions) { + this._client = client + this._retrieveOptions = retrieveOptions + } + + private async _getViewer(): Promise { + if (this._viewer === undefined) { + const metadata = await this._client.retrieveSeriesMetadata( + this._retrieveOptions, + ) + const volumeImages: dmv.metadata.VLWholeSlideMicroscopyImage[] = [] + metadata.forEach((m) => { + const image = new dmv.metadata.VLWholeSlideMicroscopyImage({ + metadata: m as unknown as object, + }) + if (image.BitsAllocated !== 16) { + throw new Error('Viv example path: only 16-bit images are supported') + } + const imageFlavor = image.ImageType[2] + if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { + volumeImages.push(image) + } + }) + this._viewer = new dmv.viewer.VolumeImageViewer({ + client: this._client, + metadata: volumeImages, + controls: [], + }) + } + return this._viewer + } + + private async _getOpticalPaths(): Promise<{ + [key: string]: OpticalPathEntry + }> { + if (this._opticalPaths === undefined) { + const viewer = await this._getViewer() + this._opticalPaths = getOpticalPathsMap(viewer) + } + return this._opticalPaths + } + + private async _getTileSize(): Promise { + if (this._tileSize === undefined) { + const opticalPaths = await this._getOpticalPaths() + const tileSizes = Object.entries(opticalPaths) + .map(([, p]) => p.pyramid.tileSizes) + .flat(2) + if (tileSizes.length === 0) { + throw new Error('No tile sizes found in optical paths') + } + if (tileSizes.some((s) => s !== tileSizes[0])) { + throw new Error( + 'Inconsistent or non-square tile sizes are not supported', + ) + } + this._tileSize = tileSizes[0] + } + return this._tileSize + } + + private async _getLoader( + channel: string, + ): Promise<(level: number, x: number, y: number) => Promise> { + if (this._loaders === undefined) { + const viewer = await this._getViewer() + viewer.render({ container: document.createElement('div') }) + await new Promise((resolve) => { + requestAnimationFrame(() => { + resolve() + }) + }) + const opticalPaths = await this._getOpticalPaths() + this._loaders = Object.fromEntries( + Object.entries(opticalPaths).map(([c, p]) => { + const src = p.layer.getSource() as { + loader_: ( + level: number, + x: number, + y: number, + ) => Promise + } + return [c, src.loader_] + }), + ) + } + const loader = this._loaders[channel] + if (loader === undefined) { + throw new Error(`No tile loader for channel "${channel}"`) + } + return loader + } + + private async _getShapes(): Promise> { + if (this._shapes === undefined) { + const opticalPaths = await this._getOpticalPaths() + const first = + opticalPaths[0] ?? opticalPaths['0'] ?? Object.values(opticalPaths)[0] + if (first === undefined) { + throw new Error('No optical paths available for pyramid shapes') + } + const sizeC = Object.keys(opticalPaths).length + this._shapes = first.pyramid.metadata.map((m) => { + const sizeY = m.TotalPixelMatrixRows + const sizeX = m.TotalPixelMatrixColumns + return [sizeC, sizeY, sizeX] + }) + } + return this._shapes + } + + async getTile({ + level, + channel, + x, + y, + }: { + level: number + channel: string + x: number + y: number + }): Promise<{ data: Uint16Array; width: number; height: number }> { + const loader = await this._getLoader(channel) + const floatTile = await loader(level, x, y) + const data = new Uint16Array(floatTile) + const shape = (await this._getShapes())[level] + const ts = await this._getTileSize() + const cropX = Math.min(shape[2] - x * ts, ts) + const cropY = shape[1] - y * ts + let yy = 0 + for (; yy < ts; yy++) { + for (let xx = cropX; xx < ts; xx++) { + data[yy * ts + xx] = 0 + } + } + for (yy = cropY; yy < ts; yy++) { + for (let xx = 0; xx < cropX; xx++) { + data[yy * ts + xx] = 0 + } + } + return { data, width: ts, height: ts } + } + + async getSources(): Promise { + const levelShapes = await this._getShapes() + const tileSize = await this._getTileSize() + const sources = levelShapes.map( + (shape, i) => new DicomPixelSource(this, i, shape, 'Uint16', tileSize), + ) + sources.reverse() + return sources + } +} + +export class DicomPixelSource { + private readonly _loader: DicomLoader + + private readonly _level: number + + labels = ['c', 'y', 'x'] + + shape: [number, number, number] + + dtype: 'Uint16' + + tileSize: number + + meta: null = null + + constructor( + loader: DicomLoader, + level: number, + shape: [number, number, number], + dtype: 'Uint16', + tileSize: number, + ) { + this._loader = loader + this._level = level + this.shape = shape + this.dtype = dtype + this.tileSize = tileSize + } + + async getRaster({ + selection, + }: { + selection: { c: number; t: number; z: number } + signal?: AbortSignal + }): Promise<{ data: Uint16Array; width: number; height: number }> { + if (this.shape[1] > this.tileSize || this.shape[2] > this.tileSize) { + throw new Error('getRaster not supported for multi-tile pyramid levels') + } + return await this.getTile({ x: 0, y: 0, selection }) + } + + async getTile({ + x, + y, + selection, + }: { + x: number + y: number + selection: { c: number; t: number; z: number } + signal?: AbortSignal + }): Promise<{ data: Uint16Array; width: number; height: number }> { + const channel = String(selection.c) + return await this._loader.getTile({ + level: this._level, + channel, + x, + y, + }) + } + + onTileError(err: Error): void { + console.error(err) + } +} diff --git a/src/viv/index.ts b/src/viv/index.ts new file mode 100644 index 00000000..29362130 --- /dev/null +++ b/src/viv/index.ts @@ -0,0 +1,7 @@ +export { DicomLoader, DicomPixelSource } from './dicomLoader' +export type { VivSlideViewportProps } from './VivSlideViewport' +export { default as VivSlideViewport } from './VivSlideViewport' +export { + buildVivDisplayOptions, + IDC_CYCLIC_IF_VIV_SETTINGS, +} from './vivDisplayDefaults' diff --git a/src/viv/vivDisplayDefaults.ts b/src/viv/vivDisplayDefaults.ts new file mode 100644 index 00000000..2c0c8ed6 --- /dev/null +++ b/src/viv/vivDisplayDefaults.ts @@ -0,0 +1,91 @@ +import type { VivSettings } from '../AppConfig' + +const DEFAULT_COLORS: Array<[number, number, number]> = [ + [0, 0, 255], + [0, 255, 0], + [255, 255, 255], + [255, 0, 0], +] + +export function buildVivDisplayOptions( + fullHeight: number, + fullWidth: number, + channelCount: number, + overrides?: VivSettings, +): { + selections: Array<{ c: number; t: number; z: number }> + channelsVisible: boolean[] + contrastLimits: Array<[number, number]> + colors: Array<[number, number, number]> + initialViewState: { + target: [number, number, number] + zoom: number + } +} { + const n = + overrides?.selections?.length ?? Math.min(4, Math.max(1, channelCount)) + const selections = + overrides?.selections?.map((s) => ({ + c: s.c, + t: s.t ?? 0, + z: s.z ?? 0, + })) ?? + Array.from({ length: n }, (_, i) => ({ + c: Math.min(i, channelCount - 1), + t: 0, + z: 0, + })) + + const channelsVisible = + overrides?.channelsVisible ?? selections.map(() => true) + + const contrastLimits = + overrides?.contrastLimits ?? + selections.map(() => [1000, 50000] as [number, number]) + + const colors = + overrides?.colors ?? + selections.map((_, i) => DEFAULT_COLORS[i % DEFAULT_COLORS.length]) + + const initialViewState = { + target: + overrides?.initialViewState?.target ?? + ([fullWidth / 2, fullHeight / 2, 0] as [number, number, number]), + zoom: overrides?.initialViewState?.zoom ?? -6, + } + + return { + selections, + channelsVisible, + contrastLimits, + colors, + initialViewState, + } +} + +/** IDC cyclic IF demo (Lin et al.) — channels 8–11 per viv-dicomweb-test. */ +export const IDC_CYCLIC_IF_VIV_SETTINGS: VivSettings = { + selections: [ + { c: 8, t: 0, z: 0 }, + { c: 9, t: 0, z: 0 }, + { c: 10, t: 0, z: 0 }, + { c: 11, t: 0, z: 0 }, + ], + channelsVisible: [true, true, true, true], + contrastLimits: [ + [4000, 40000], + [3000, 30000], + [3000, 20000], + [5000, 50000], + ], + colors: [ + [0, 0, 255], + [0, 255, 0], + [255, 255, 255], + [255, 0, 0], + ], + initialViewState: { + target: [21000, 13000, 0], + zoom: -6, + }, +} From 4eac4c0eb319e9ec69a6b5aa1f6e8c0386cb4b4a Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 14:41:03 -0300 Subject: [PATCH 02/57] Improve viewport displaying --- src/components/CaseViewer.tsx | 45 +++++++++- src/viv/VivSlideViewport.tsx | 154 ++++++++++++++++++++-------------- src/viv/index.ts | 1 + src/viv/vivDisplayDefaults.ts | 19 +++++ 4 files changed, 156 insertions(+), 63 deletions(-) diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index 01ca01ab..eee2a768 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -67,6 +67,47 @@ const findSeriesSlide = ( seriesInstanceUID: string, ): Slide | undefined => findSlideBySeriesInstanceUID(slides, seriesInstanceUID) +/** Viv path: main viewport + slim right rail (classic viewer uses ~300px sider). */ +const vivChrome = (main: JSX.Element): JSX.Element => ( +
+
+ {main} +
+ +
+) + function ParametrizedSlideViewer({ clients, slides, @@ -240,13 +281,13 @@ function ParametrizedSlideViewer({ if (microscopyClient === undefined) { return null } - viewer = ( + viewer = vivChrome( + />, ) } else { viewer = ( diff --git a/src/viv/VivSlideViewport.tsx b/src/viv/VivSlideViewport.tsx index 302930c7..e3ee3fc0 100644 --- a/src/viv/VivSlideViewport.tsx +++ b/src/viv/VivSlideViewport.tsx @@ -4,12 +4,15 @@ import DeckGL from '@deck.gl/react' import { MultiscaleImageLayer } from '@hms-dbmi/viv' import { message, Spin } from 'antd' import type React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { VivSettings } from '../AppConfig' import type DicomWebManager from '../DicomWebManager' import { DicomLoader } from './dicomLoader' -import { buildVivDisplayOptions } from './vivDisplayDefaults' +import { + buildVivDisplayOptions, + computeOrthographicFitViewState, +} from './vivDisplayDefaults' export interface VivSlideViewportProps { client: DicomWebManager @@ -20,6 +23,8 @@ export interface VivSlideViewportProps { const orthographicView = new OrthographicView() +type ViewState = { target: [number, number, number]; zoom: number } + /** * Viv + Deck.gl viewport for DICOM SM (proof-of-concept). * See src/viv/README.md for limitations. @@ -30,35 +35,62 @@ const VivSlideViewport: React.FC = ({ seriesInstanceUID, vivSettings, }) => { - const containerRef = useRef(null) + const vivRef = useRef(vivSettings) + vivRef.current = vivSettings + + const slotRef = useRef(null) + const measureRef = useRef<(() => void) | null>(null) + const slideRef = useRef<{ w: number; h: number } | null>(null) + const fitDoneRef = useRef(false) + const [size, setSize] = useState({ width: 100, height: 100 }) const [layers, setLayers] = useState([]) const [loading, setLoading] = useState(true) - const [viewState, setViewState] = useState<{ - target: [number, number, number] - zoom: number - }>({ + const [viewState, setViewState] = useState({ target: [0, 0, 0], zoom: -6, }) - useEffect(() => { - const el = containerRef.current - if (el === null) { + useLayoutEffect(() => { + const el = slotRef.current + if (!el) { return } - const ro = new ResizeObserver(() => { - setSize({ width: el.clientWidth, height: el.clientHeight }) - }) + const tick = (): void => { + const w = Math.max(1, el.clientWidth) + const h = Math.max(1, el.clientHeight) + setSize({ width: w, height: h }) + const v = vivRef.current + const sp = slideRef.current + if (v?.initialViewState?.zoom != null || !sp || fitDoneRef.current) { + return + } + const fit = computeOrthographicFitViewState( + w, + h, + sp.w, + sp.h, + v?.initialViewState?.target, + ) + if (fit) { + setViewState(fit) + fitDoneRef.current = true + } + } + measureRef.current = tick + const ro = new ResizeObserver(tick) ro.observe(el) - setSize({ width: el.clientWidth, height: el.clientHeight }) + tick() return () => { ro.disconnect() + measureRef.current = null } }, []) useEffect(() => { let cancelled = false + fitDoneRef.current = false + slideRef.current = null setLoading(true) setLayers([]) @@ -75,37 +107,37 @@ const VivSlideViewport: React.FC = ({ if (sources.length === 0) { throw new Error('No pyramid levels returned for this series.') } - const [, height, width] = sources[0].shape - const channelCount = sources[0].shape[0] - const display = buildVivDisplayOptions( - height, - width, - channelCount, + const [, sh, sw] = sources[0].shape + const d = buildVivDisplayOptions( + sh, + sw, + sources[0].shape[0], vivSettings, ) - // Viv 0.19 MultiscaleImageLayer uses contrast/visibility only; channel - // colors come from defaults. `vivSettings.colors` is reserved for future use. const layer = new MultiscaleImageLayer({ id: 'slim-viv-multiscale', loader: sources as never, - selections: display.selections, - channelsVisible: display.channelsVisible, - contrastLimits: display.contrastLimits, + selections: d.selections, + channelsVisible: d.channelsVisible, + contrastLimits: d.contrastLimits, dtype: 'Uint16', }) setLayers([layer as unknown as Layer]) - setViewState({ - target: display.initialViewState.target, - zoom: display.initialViewState.zoom, + slideRef.current = { w: sw, h: sh } + setViewState(d.initialViewState) + requestAnimationFrame(() => { + if (!cancelled) { + measureRef.current?.() + } }) } catch (err) { console.error(err) if (!cancelled) { - const text = + void message.error( err instanceof Error ? err.message - : 'Failed to open slide in Viv viewer.' - void message.error(text) + : 'Failed to open slide in Viv viewer.', + ) } } finally { if (!cancelled) { @@ -122,51 +154,51 @@ const VivSlideViewport: React.FC = ({ return (
- {loading && ( + {loading ? (
- )} - { - if ( - vs !== undefined && - typeof vs === 'object' && - 'target' in vs && - 'zoom' in vs - ) { - setViewState( - vs as { target: [number, number, number]; zoom: number }, - ) - } - }} - controller={true} - layers={layers} - width={size.width} - height={size.height} - /> + ) : null} +
+ { + if ( + vs && + typeof vs === 'object' && + 'zoom' in vs && + 'target' in vs + ) { + setViewState(vs as ViewState) + } + }} + controller + layers={layers} + width={size.width} + height={size.height} + /> +
) } diff --git a/src/viv/index.ts b/src/viv/index.ts index 29362130..5c11dc36 100644 --- a/src/viv/index.ts +++ b/src/viv/index.ts @@ -3,5 +3,6 @@ export type { VivSlideViewportProps } from './VivSlideViewport' export { default as VivSlideViewport } from './VivSlideViewport' export { buildVivDisplayOptions, + computeOrthographicFitViewState, IDC_CYCLIC_IF_VIV_SETTINGS, } from './vivDisplayDefaults' diff --git a/src/viv/vivDisplayDefaults.ts b/src/viv/vivDisplayDefaults.ts index 2c0c8ed6..54c56c7c 100644 --- a/src/viv/vivDisplayDefaults.ts +++ b/src/viv/vivDisplayDefaults.ts @@ -63,6 +63,25 @@ export function buildVivDisplayOptions( } } +/** Deck orthographic viewState so the full slide fits the viewport (unless zoom is fixed in config). */ +export function computeOrthographicFitViewState( + vw: number, + vh: number, + slideW: number, + slideH: number, + pan?: [number, number, number], +): { target: [number, number, number]; zoom: number } | null { + const w = Math.max(1, Math.floor(vw)) + const h = Math.max(1, Math.floor(vh)) + if (w < 32 || h < 32) { + return null + } + return { + target: pan ? [pan[0], pan[1], pan[2] ?? 0] : [slideW / 2, slideH / 2, 0], + zoom: Math.log2(Math.min(w / slideW, h / slideH)), + } +} + /** IDC cyclic IF demo (Lin et al.) — channels 8–11 per viv-dicomweb-test. */ export const IDC_CYCLIC_IF_VIV_SETTINGS: VivSettings = { selections: [ From 3cb9955133fdd644e9ce4dd888d63c9f45ef4d90 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 16:05:30 -0300 Subject: [PATCH 03/57] Fixing 8-bit: Rendering images correctly (wrong position) --- src/viv/VivSlideViewport.tsx | 8 +- src/viv/dicomLoader.ts | 366 +++++++++++++++++++++++++++++----- src/viv/vivDisplayDefaults.ts | 6 +- 3 files changed, 332 insertions(+), 48 deletions(-) diff --git a/src/viv/VivSlideViewport.tsx b/src/viv/VivSlideViewport.tsx index e3ee3fc0..ab608d27 100644 --- a/src/viv/VivSlideViewport.tsx +++ b/src/viv/VivSlideViewport.tsx @@ -108,11 +108,13 @@ const VivSlideViewport: React.FC = ({ throw new Error('No pyramid levels returned for this series.') } const [, sh, sw] = sources[0].shape + const bitsAllocated = dicomLoader.bitsAllocated ?? 16 const d = buildVivDisplayOptions( sh, sw, sources[0].shape[0], vivSettings, + bitsAllocated, ) const layer = new MultiscaleImageLayer({ id: 'slim-viv-multiscale', @@ -120,7 +122,9 @@ const VivSlideViewport: React.FC = ({ selections: d.selections, channelsVisible: d.channelsVisible, contrastLimits: d.contrastLimits, - dtype: 'Uint16', + dtype: sources[0].dtype, + // Lowest pyramid level is often wider/taller than one tile; ImageLayer would call getRaster and fail. + excludeBackground: true, }) setLayers([layer as unknown as Layer]) slideRef.current = { w: sw, h: sh } @@ -176,7 +180,7 @@ const VivSlideViewport: React.FC = ({ background: 'rgba(255,255,255,0.6)', }} > - + ) : null}
diff --git a/src/viv/dicomLoader.ts b/src/viv/dicomLoader.ts index 1237d04d..9a0f6ac1 100644 --- a/src/viv/dicomLoader.ts +++ b/src/viv/dicomLoader.ts @@ -15,8 +15,11 @@ type OpticalPathEntry = { metadata: Array<{ TotalPixelMatrixRows: number TotalPixelMatrixColumns: number + Columns: number + Rows: number }> - tileSizes: number[] + /** Per pyramid level: [Columns, Rows] for that resolution. */ + tileSizes: Array<[number, number]> } layer: { getSource: () => { @@ -25,6 +28,57 @@ type OpticalPathEntry = { } } +/** OpenLayers DataTileSource sets this asynchronously inside VolumeImageViewer.render(). */ +function getDataTileLoader( + source: { loader_?: unknown } | null, +): ((z: number, y: number, x: number) => Promise) | undefined { + const fn = source?.loader_ + return typeof fn === 'function' + ? (fn as (z: number, y: number, x: number) => Promise) + : undefined +} + +/** + * `render()` triggers `forEach(async …)` ICC fetches and only then `setLoader`. + * A single rAF returns before loaders exist; wait until every optical path source is ready. + */ +async function waitForOpenLayersTileLoaders( + opticalPaths: { [key: string]: OpticalPathEntry }, + timeoutMs: number, +): Promise { + const paths = Object.values(opticalPaths) + if (paths.length === 0) { + return + } + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const ready = paths.every((p) => { + const loader = getDataTileLoader( + p.layer.getSource() as { loader_?: unknown } | null, + ) + return loader !== undefined + }) + if (ready) { + return + } + await new Promise((r) => { + setTimeout(r, 50) + }) + } + const missing = Object.entries(opticalPaths) + .filter(([, p]) => { + return ( + getDataTileLoader( + p.layer.getSource() as { loader_?: unknown } | null, + ) === undefined + ) + }) + .map(([id]) => id) + throw new Error( + `Timed out waiting for OpenLayers tile loaders (still missing: ${missing.join(', ') || 'unknown'}).`, + ) +} + function getOpticalPathsMap(viewer: dmv.viewer.VolumeImageViewer): { [key: string]: OpticalPathEntry } { @@ -59,7 +113,21 @@ export class DicomLoader { ) => Promise } - private _shapes?: Array<[number, number, number]> + private _shapes?: Array< + [number, number, number] | [number, number, number, number] + > + + /** + * Optical path ids from dicom-microscopy-viewer (DICOM OpticalPathIdentifier), + * sorted for stable Viv channel index { c: 0 .. n-1 }. + */ + private _orderedPathKeys?: string[] + + /** Set when the viewer is first built; 8- or 16-bit SM tiles both load as float then convert to Uint16 in getTile. */ + bitsAllocated?: 8 | 16 + + /** Samples per pixel for SM instances (1 = monochrome paths, 3 = RGB color slide). */ + samplesPerPixel?: number constructor(client: DicomWebManager, retrieveOptions: DicomRetrieveOptions) { this._client = client @@ -71,19 +139,68 @@ export class DicomLoader { const metadata = await this._client.retrieveSeriesMetadata( this._retrieveOptions, ) - const volumeImages: dmv.metadata.VLWholeSlideMicroscopyImage[] = [] + const candidates: dmv.metadata.VLWholeSlideMicroscopyImage[] = [] metadata.forEach((m) => { const image = new dmv.metadata.VLWholeSlideMicroscopyImage({ metadata: m as unknown as object, }) - if (image.BitsAllocated !== 16) { - throw new Error('Viv example path: only 16-bit images are supported') + const b = image.BitsAllocated + if (b !== 8 && b !== 16) { + throw new Error( + `Viv path: ${b}-bit pixel data is not supported (only 8 and 16).`, + ) } const imageFlavor = image.ImageType[2] if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { - volumeImages.push(image) + candidates.push(image) } }) + /* + * THUMBNAIL instances often use a different frame size than VOLUME pyramid + * tiles, so pyramid.tileSizes differ across levels. Viv/deck MultiscaleImageLayer + * expects one tile grid; mixing THUMBNAIL + VOLUME triggers + * "Inconsistent or non-square tile sizes". Prefer VOLUME only when present. + */ + const hasVolume = candidates.some((img) => img.ImageType[2] === 'VOLUME') + const volumeImages = candidates.filter((img) => + hasVolume + ? img.ImageType[2] === 'VOLUME' + : img.ImageType[2] === 'THUMBNAIL', + ) + let bitsAllocated: 8 | 16 | undefined + for (const image of volumeImages) { + const b = image.BitsAllocated as 8 | 16 + if (bitsAllocated === undefined) { + bitsAllocated = b + } else if (bitsAllocated !== b) { + throw new Error( + 'Viv path: mixed 8- and 16-bit instances in one series are not supported.', + ) + } + } + if (volumeImages.length === 0) { + throw new Error( + 'Viv path: no VOLUME or THUMBNAIL SM instances found for this series.', + ) + } + let spp: number | undefined + for (const image of volumeImages) { + const s = image.SamplesPerPixel + if (spp === undefined) { + spp = s + } else if (spp !== s) { + throw new Error( + 'Viv path: mixed SamplesPerPixel values in one series are not supported.', + ) + } + } + if (spp !== 1 && spp !== 3) { + throw new Error( + `Viv path: SamplesPerPixel=${String(spp)} is not supported (only 1 and 3).`, + ) + } + this.bitsAllocated = bitsAllocated + this.samplesPerPixel = spp this._viewer = new dmv.viewer.VolumeImageViewer({ client: this._client, metadata: volumeImages, @@ -106,44 +223,94 @@ export class DicomLoader { private async _getTileSize(): Promise { if (this._tileSize === undefined) { const opticalPaths = await this._getOpticalPaths() - const tileSizes = Object.entries(opticalPaths) - .map(([, p]) => p.pyramid.tileSizes) - .flat(2) - if (tileSizes.length === 0) { + const pathList = Object.values(opticalPaths) + if (pathList.length === 0) { throw new Error('No tile sizes found in optical paths') } - if (tileSizes.some((s) => s !== tileSizes[0])) { + const ref = pathList[0].pyramid.tileSizes + if (ref.length === 0) { + throw new Error('No tile sizes found in optical paths') + } + for (let p = 1; p < pathList.length; p++) { + const ts = pathList[p].pyramid.tileSizes + if (ts.length !== ref.length) { + throw new Error( + 'Viv path: optical paths have different pyramid level counts.', + ) + } + for (let L = 0; L < ref.length; L++) { + if (ts[L][0] !== ref[L][0] || ts[L][1] !== ref[L][1]) { + throw new Error( + 'Viv path: optical paths disagree on tile size at pyramid level ' + + `${L} ([${ref[L]}] vs [${ts[L]}]).`, + ) + } + } + } + const perLevel = ref.map(([cols, rows]) => { + if (cols !== rows) { + throw new Error( + 'Viv path: non-square tiles (Columns !== Rows) are not supported.', + ) + } + return cols + }) + if (perLevel.some((s) => s !== perLevel[0])) { throw new Error( - 'Inconsistent or non-square tile sizes are not supported', + 'Viv path: tile size varies by pyramid level; only a uniform tile ' + + 'grid is supported.', ) } - this._tileSize = tileSizes[0] + this._tileSize = perLevel[0] } return this._tileSize } + private async _ensureOrderedPathKeys(): Promise { + if (this._orderedPathKeys === undefined) { + const opticalPaths = await this._getOpticalPaths() + const keys = Object.keys(opticalPaths).sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ) + if (keys.length === 0) { + throw new Error('No optical paths in VolumeImageViewer') + } + this._orderedPathKeys = keys + } + return this._orderedPathKeys + } + + /** Map Viv / @vivjs 0-based channel index to dicom-microscopy-viewer optical path id. */ + async resolveOpticalPathId(vivChannelIndex: number): Promise { + const keys = await this._ensureOrderedPathKeys() + const id = keys[vivChannelIndex] + if (id === undefined) { + throw new Error( + `Viv channel index ${vivChannelIndex} is out of range (${keys.length} optical paths: ${keys.join(', ')})`, + ) + } + return id + } + private async _getLoader( channel: string, ): Promise<(level: number, x: number, y: number) => Promise> { if (this._loaders === undefined) { const viewer = await this._getViewer() viewer.render({ container: document.createElement('div') }) - await new Promise((resolve) => { - requestAnimationFrame(() => { - resolve() - }) - }) const opticalPaths = await this._getOpticalPaths() + await waitForOpenLayersTileLoaders(opticalPaths, 120_000) this._loaders = Object.fromEntries( Object.entries(opticalPaths).map(([c, p]) => { - const src = p.layer.getSource() as { - loader_: ( - level: number, - x: number, - y: number, - ) => Promise + const loader = getDataTileLoader( + p.layer.getSource() as { loader_?: unknown } | null, + ) + if (loader === undefined) { + throw new Error( + `OpenLayers tile loader missing for optical path "${c}" after render().`, + ) } - return [c, src.loader_] + return [c, loader] }), ) } @@ -154,24 +321,40 @@ export class DicomLoader { return loader } - private async _getShapes(): Promise> { + private async _getShapes(): Promise< + Array<[number, number, number] | [number, number, number, number]> + > { if (this._shapes === undefined) { const opticalPaths = await this._getOpticalPaths() - const first = - opticalPaths[0] ?? opticalPaths['0'] ?? Object.values(opticalPaths)[0] + const orderedKeys = await this._ensureOrderedPathKeys() + const first = opticalPaths[orderedKeys[0] ?? ''] if (first === undefined) { throw new Error('No optical paths available for pyramid shapes') } - const sizeC = Object.keys(opticalPaths).length + const spp = this.samplesPerPixel ?? 1 + const sizeC = spp === 3 ? 1 : orderedKeys.length this._shapes = first.pyramid.metadata.map((m) => { const sizeY = m.TotalPixelMatrixRows const sizeX = m.TotalPixelMatrixColumns - return [sizeC, sizeY, sizeX] + if (spp === 3) { + return [1, sizeY, sizeX, 3] as [number, number, number, number] + } + return [sizeC, sizeY, sizeX] as [number, number, number] }) } return this._shapes } + private async _frameLayout(level: number): Promise<{ columns: number }> { + const opticalPaths = await this._getOpticalPaths() + const orderedKeys = await this._ensureOrderedPathKeys() + const meta = opticalPaths[orderedKeys[0] ?? '']?.pyramid.metadata[level] + if (meta === undefined) { + throw new Error(`Viv path: missing pyramid metadata for level ${level}`) + } + return { columns: meta.Columns } + } + async getTile({ level, channel, @@ -182,14 +365,60 @@ export class DicomLoader { channel: string x: number y: number - }): Promise<{ data: Uint16Array; width: number; height: number }> { + }): Promise<{ + data: Uint8Array | Uint16Array + width: number + height: number + }> { const loader = await this._getLoader(channel) - const floatTile = await loader(level, x, y) - const data = new Uint16Array(floatTile) + const raw = await loader(level, x, y) const shape = (await this._getShapes())[level] const ts = await this._getTileSize() - const cropX = Math.min(shape[2] - x * ts, ts) - const cropY = shape[1] - y * ts + const { columns } = await this._frameLayout(level) + const validW = Math.min(ts, shape[2] - x * ts) + const validH = Math.min(ts, shape[1] - y * ts) + const spp = this.samplesPerPixel ?? 1 + const bits = this.bitsAllocated ?? 16 + + if (spp === 3 && bits === 8) { + const buf = new Uint8Array(ts * ts * 3) + buf.fill(255) + if (!(raw instanceof Uint8Array)) { + throw new Error( + 'Viv path: expected Uint8Array RGB tile from decoder (check SamplesPerPixel / BitsAllocated).', + ) + } + for (let row = 0; row < validH; row++) { + buf.set( + raw.subarray(row * columns * 3, row * columns * 3 + validW * 3), + row * ts * 3, + ) + } + return { data: buf, width: ts, height: ts } + } + + if (spp === 3 && bits === 16) { + const buf = new Uint16Array(ts * ts * 3) + buf.fill(65535) + const src = + raw instanceof Float32Array ? raw : new Float32Array(raw as ArrayBuffer) + for (let row = 0; row < validH; row++) { + for (let col = 0; col < validW; col++) { + const si = (row * columns + col) * 3 + const di = (row * ts + col) * 3 + buf[di] = clampU16(src[si]) + buf[di + 1] = clampU16(src[si + 1]) + buf[di + 2] = clampU16(src[si + 2]) + } + } + return { data: buf, width: ts, height: ts } + } + + const data = monoTileToUint16( + raw instanceof ArrayBuffer ? raw : (raw as ArrayBufferView), + ) + const cropX = validW + const cropY = validH let yy = 0 for (; yy < ts; yy++) { for (let xx = cropX; xx < ts; xx++) { @@ -207,14 +436,45 @@ export class DicomLoader { async getSources(): Promise { const levelShapes = await this._getShapes() const tileSize = await this._getTileSize() + const spp = this.samplesPerPixel ?? 1 + const bits = this.bitsAllocated ?? 16 + const dtype: 'Uint8' | 'Uint16' = + spp === 3 && bits === 8 ? 'Uint8' : 'Uint16' const sources = levelShapes.map( - (shape, i) => new DicomPixelSource(this, i, shape, 'Uint16', tileSize), + (shape, i) => new DicomPixelSource(this, i, shape, dtype, tileSize), ) sources.reverse() return sources } } +function clampU16(v: number): number { + if (!Number.isFinite(v)) { + return 0 + } + return Math.max(0, Math.min(65535, Math.round(v))) +} + +/** Same semantics as previous `new Uint16Array(floatTile)` for decoded mono tiles. */ +function monoTileToUint16(raw: ArrayBuffer | ArrayBufferView): Uint16Array { + if (raw instanceof ArrayBuffer) { + return new Uint16Array(new Float32Array(raw)) + } + if ( + raw instanceof Float32Array || + raw instanceof Uint16Array || + raw instanceof Uint8Array + ) { + return new Uint16Array(raw) + } + const f32 = new Float32Array( + raw.buffer, + raw.byteOffset, + raw.byteLength / Float32Array.BYTES_PER_ELEMENT, + ) + return new Uint16Array(f32) +} + export class DicomPixelSource { private readonly _loader: DicomLoader @@ -222,19 +482,23 @@ export class DicomPixelSource { labels = ['c', 'y', 'x'] - shape: [number, number, number] + shape: [number, number, number] | [number, number, number, number] - dtype: 'Uint16' + dtype: 'Uint8' | 'Uint16' tileSize: number - meta: null = null + /** + * Interleaved RGB tiles use BitmapLayer, which reads `photometricInterpretation` from `meta`. + * @vivjs defaults to 2 when unset on ImageLayer; MultiscaleImageLayer still destructures `meta` and requires a non-null object. + */ + meta: { photometricInterpretation: number } | null constructor( loader: DicomLoader, level: number, - shape: [number, number, number], - dtype: 'Uint16', + shape: [number, number, number] | [number, number, number, number], + dtype: 'Uint8' | 'Uint16', tileSize: number, ) { this._loader = loader @@ -242,6 +506,7 @@ export class DicomPixelSource { this.shape = shape this.dtype = dtype this.tileSize = tileSize + this.meta = shape.length === 4 ? { photometricInterpretation: 2 } : null } async getRaster({ @@ -249,7 +514,14 @@ export class DicomPixelSource { }: { selection: { c: number; t: number; z: number } signal?: AbortSignal - }): Promise<{ data: Uint16Array; width: number; height: number }> { + }): Promise<{ + data: Uint8Array | Uint16Array + width: number + height: number + }> { + if (this.shape.length === 4) { + throw new Error('getRaster not supported for interleaved RGB slides') + } if (this.shape[1] > this.tileSize || this.shape[2] > this.tileSize) { throw new Error('getRaster not supported for multi-tile pyramid levels') } @@ -265,11 +537,15 @@ export class DicomPixelSource { y: number selection: { c: number; t: number; z: number } signal?: AbortSignal - }): Promise<{ data: Uint16Array; width: number; height: number }> { - const channel = String(selection.c) + }): Promise<{ + data: Uint8Array | Uint16Array + width: number + height: number + }> { + const pathId = await this._loader.resolveOpticalPathId(selection.c) return await this._loader.getTile({ level: this._level, - channel, + channel: pathId, x, y, }) diff --git a/src/viv/vivDisplayDefaults.ts b/src/viv/vivDisplayDefaults.ts index 54c56c7c..d39b2300 100644 --- a/src/viv/vivDisplayDefaults.ts +++ b/src/viv/vivDisplayDefaults.ts @@ -12,6 +12,8 @@ export function buildVivDisplayOptions( fullWidth: number, channelCount: number, overrides?: VivSettings, + /** SM pixel bit depth from DICOM; selects sensible default contrast when overrides omit contrastLimits. */ + bitsAllocated: 8 | 16 = 16, ): { selections: Array<{ c: number; t: number; z: number }> channelsVisible: boolean[] @@ -39,9 +41,11 @@ export function buildVivDisplayOptions( const channelsVisible = overrides?.channelsVisible ?? selections.map(() => true) + const defaultContrast: [number, number] = + bitsAllocated === 8 ? [0, 255] : [1000, 50000] const contrastLimits = overrides?.contrastLimits ?? - selections.map(() => [1000, 50000] as [number, number]) + selections.map(() => [...defaultContrast] as [number, number]) const colors = overrides?.colors ?? From da8b18906c510bbbf8452ea4f4e74bd417f496c1 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 16:40:08 -0300 Subject: [PATCH 04/57] Fixing 8-bit: Rendering images correctly (correct position) --- src/viv/VivSlideViewport.tsx | 103 +++++++- src/viv/dicomLoader.ts | 438 +++++++++++++++++++++++++++++++++- src/viv/vivDisplayDefaults.ts | 25 ++ 3 files changed, 550 insertions(+), 16 deletions(-) diff --git a/src/viv/VivSlideViewport.tsx b/src/viv/VivSlideViewport.tsx index ab608d27..c42bf5db 100644 --- a/src/viv/VivSlideViewport.tsx +++ b/src/viv/VivSlideViewport.tsx @@ -12,6 +12,7 @@ import { DicomLoader } from './dicomLoader' import { buildVivDisplayOptions, computeOrthographicFitViewState, + orthographicZoomLimits, } from './vivDisplayDefaults' export interface VivSlideViewportProps { @@ -40,7 +41,11 @@ const VivSlideViewport: React.FC = ({ const slotRef = useRef(null) const measureRef = useRef<(() => void) | null>(null) - const slideRef = useRef<{ w: number; h: number } | null>(null) + const slideRef = useRef<{ + worldW: number + worldH: number + levelCount: number + } | null>(null) const fitDoneRef = useRef(false) const [size, setSize] = useState({ width: 100, height: 100 }) @@ -50,6 +55,8 @@ const VivSlideViewport: React.FC = ({ target: [0, 0, 0], zoom: -6, }) + const sizeRef = useRef(size) + sizeRef.current = size useLayoutEffect(() => { const el = slotRef.current @@ -65,12 +72,13 @@ const VivSlideViewport: React.FC = ({ if (v?.initialViewState?.zoom != null || !sp || fitDoneRef.current) { return } + const pan = v?.initialViewState?.target const fit = computeOrthographicFitViewState( w, h, - sp.w, - sp.h, - v?.initialViewState?.target, + sp.worldW, + sp.worldH, + pan, ) if (fit) { setViewState(fit) @@ -116,6 +124,12 @@ const VivSlideViewport: React.FC = ({ vivSettings, bitsAllocated, ) + /* + * Non-geospatial TileLayer already uses getScale(z, tileSize) = 2^z * (512 / tileSize), so + * 256px DICOM tiles get the right world step. Do not also pass modelMatrix + scaled viewState: + * MultiscaleImageLayer derives zoomOffset from modelMatrix, and scaling the camera to match + * double-corrects — coarse levels can look fine while the finest z slips vs the pyramid. + */ const layer = new MultiscaleImageLayer({ id: 'slim-viv-multiscale', loader: sources as never, @@ -125,10 +139,44 @@ const VivSlideViewport: React.FC = ({ dtype: sources[0].dtype, // Lowest pyramid level is often wider/taller than one tile; ImageLayer would call getRaster and fail. excludeBackground: true, + // Omit refinementStrategy → Viv uses best-available when opacity=1; smoother hand-off between pyramid levels than no-overlap. }) setLayers([layer as unknown as Layer]) - slideRef.current = { w: sw, h: sh } - setViewState(d.initialViewState) + slideRef.current = { + worldW: sw, + worldH: sh, + levelCount: sources.length, + } + + if (vivSettings?.initialViewState?.zoom != null) { + const el = slotRef.current + const vw = el ? Math.max(1, el.clientWidth) : 800 + const vh = el ? Math.max(1, el.clientHeight) : 600 + const lim = orthographicZoomLimits(vw, vh, sw, sh, sources.length) + const z0 = vivSettings.initialViewState.zoom + const z = Math.min(lim.maxZoom, Math.max(lim.minZoom, z0)) + setViewState({ + target: d.initialViewState.target, + zoom: Number(z.toFixed(5)), + }) + } else { + const el = slotRef.current + const vw = el ? Math.max(1, el.clientWidth) : 800 + const vh = el ? Math.max(1, el.clientHeight) : 600 + const fit = computeOrthographicFitViewState( + vw, + vh, + sw, + sh, + vivSettings?.initialViewState?.target, + ) + setViewState( + fit ?? { + target: [sw / 2, sh / 2, 0], + zoom: -6, + }, + ) + } requestAnimationFrame(() => { if (!cancelled) { measureRef.current?.() @@ -156,6 +204,18 @@ const VivSlideViewport: React.FC = ({ } }, [client, studyInstanceUID, seriesInstanceUID, vivSettings]) + const sp = slideRef.current + const orthoZoomClamp = + sp && !loading + ? orthographicZoomLimits( + size.width, + size.height, + sp.worldW, + sp.worldH, + sp.levelCount, + ) + : { minZoom: Number.NEGATIVE_INFINITY, maxZoom: Number.POSITIVE_INFINITY } + return (
= ({
{ if ( vs && @@ -194,10 +257,32 @@ const VivSlideViewport: React.FC = ({ 'zoom' in vs && 'target' in vs ) { - setViewState(vs as ViewState) + const sr = slideRef.current + if (!sr) { + return + } + const { width: cw, height: ch } = sizeRef.current + const lim = orthographicZoomLimits( + cw, + ch, + sr.worldW, + sr.worldH, + sr.levelCount, + ) + const rawZ = vs.zoom as number + const zClamped = Math.min( + lim.maxZoom, + Math.max(lim.minZoom, rawZ), + ) + const zq = Number(zClamped.toFixed(5)) + const t = vs.target as [number, number] | [number, number, number] + setViewState({ + target: [t[0], t[1], t[2] ?? 0], + zoom: zq, + }) } }} - controller + controller={{ inertia: false }} layers={layers} width={size.width} height={size.height} diff --git a/src/viv/dicomLoader.ts b/src/viv/dicomLoader.ts index 9a0f6ac1..795e2475 100644 --- a/src/viv/dicomLoader.ts +++ b/src/viv/dicomLoader.ts @@ -23,18 +23,33 @@ type OpticalPathEntry = { } layer: { getSource: () => { - loader_: (level: number, x: number, y: number) => Promise + loader_: ( + z: number, + requestX: number, + requestY: number, + ) => Promise } } } /** OpenLayers DataTileSource sets this asynchronously inside VolumeImageViewer.render(). */ +/** + * OpenLayers DataTile passes `(z, requestX, requestY)`. dicom-microscopy-viewer's + * `_createTileLoadFunction` names those `(z, y, x)` but builds the frame id as + * `${x+1}-${y+1}` → `(requestY+1)-(requestX+1)` in OL terms. + */ function getDataTileLoader( source: { loader_?: unknown } | null, -): ((z: number, y: number, x: number) => Promise) | undefined { +): + | ((z: number, requestX: number, requestY: number) => Promise) + | undefined { const fn = source?.loader_ return typeof fn === 'function' - ? (fn as (z: number, y: number, x: number) => Promise) + ? (fn as ( + z: number, + requestX: number, + requestY: number, + ) => Promise) : undefined } @@ -433,18 +448,69 @@ export class DicomLoader { return { data, width: ts, height: ts } } - async getSources(): Promise { + async getSources(): Promise< + Array + > { const levelShapes = await this._getShapes() const tileSize = await this._getTileSize() const spp = this.samplesPerPixel ?? 1 const bits = this.bitsAllocated ?? 16 const dtype: 'Uint8' | 'Uint16' = spp === 3 && bits === 8 ? 'Uint8' : 'Uint16' - const sources = levelShapes.map( + this._warnIfPyramidStepsMismatchDeckGrid() + const base = levelShapes.map( (shape, i) => new DicomPixelSource(this, i, shape, dtype, tileSize), ) - sources.reverse() - return sources + base.reverse() + return insertSyntheticDyadicLevels(base) + } + + /** + * Deck.gl Tile2D uses a 2× geometric step between each integer tile z. DICOM pyramids often use ~2×, + * but some (more common on certain 8-bit / RGB encodes) use 3×–4× or irregular factors — then Viv’s + * multiscale grid no longer lines up with OL tile coordinates and the view “creeps” when zooming. + */ + private _warnIfPyramidStepsMismatchDeckGrid(): void { + try { + const paths = this._opticalPaths + const keys = this._orderedPathKeys + if (paths === undefined || keys === undefined || keys.length === 0) { + return + } + const meta = paths[keys[0] ?? '']?.pyramid.metadata + if (meta === undefined || meta.length < 2) { + return + } + for (let i = 0; i < meta.length - 1; i++) { + const rw = + meta[i + 1].TotalPixelMatrixColumns / meta[i].TotalPixelMatrixColumns + const rh = + meta[i + 1].TotalPixelMatrixRows / meta[i].TotalPixelMatrixRows + if (Math.abs(rw - rh) > 0.02) { + console.warn( + '[Viv] Pyramid row/column ratios differ between levels; multiscale alignment may be wrong when zooming.', + ) + return + } + const r = rw + const near2 = Math.abs(r - 2) <= 0.12 + const near4 = Math.abs(r - 4) <= 0.2 + if (!near2 && !near4) { + console.warn( + `[Viv] Pyramid level step (~${r.toFixed(2)}×) is not ~2× between downsamplings. Deck.gl assumes 2× per zoom level; expect offset when switching resolutions.`, + ) + return + } + if (near4) { + console.warn( + '[Viv] ~4× pyramid steps detected; Deck.gl multiscale uses 2× between tile z levels. Zooming may shift the image until tiles match — consider using the OpenLayers viewer for these series.', + ) + return + } + } + } catch { + /* ignore */ + } } } @@ -475,11 +541,76 @@ function monoTileToUint16(raw: ArrayBuffer | ArrayBufferView): Uint16Array { return new Uint16Array(f32) } +/** Half-resolution shape aligned to deck’s 2× tile step (one dyadic level between finer and 4× coarser DICOM). */ +function halfShapeForDyadicStep( + shape: [number, number, number] | [number, number, number, number], +): [number, number, number] | [number, number, number, number] { + if (shape.length === 4) { + const [c, h, w] = shape + return [c, Math.ceil(h / 2), Math.ceil(w / 2), 3] + } + const [c, h, w] = shape + return [c, Math.ceil(h / 2), Math.ceil(w / 2)] +} + +/** + * Deck.gl multiscale assumes ~2× between each `z` step. DICOM often uses one 4× downsampling + * between instances; without an extra Viv “level” the tile grid shifts on deeper zoom. + */ +function insertSyntheticDyadicLevels( + finestFirst: DicomPixelSource[], +): Array { + if (finestFirst.length < 2) { + return finestFirst + } + const out: Array = [] + for (let i = 0; i < finestFirst.length; i++) { + out.push(finestFirst[i]) + if (i + 1 >= finestFirst.length) { + break + } + const wF = finestFirst[i].shape[2] + const wC = finestFirst[i + 1].shape[2] + const hF = finestFirst[i].shape[1] + const hC = finestFirst[i + 1].shape[1] + const rW = wF / wC + const rH = hF / hC + if (Math.abs(rW - rH) > 0.02) { + continue + } + const r = rW + if (r > 3.5 && r < 4.5) { + console.info( + '[Viv] Inserting synthetic half-resolution pyramid level (DICOM ~4× step) so deck.gl 2× tile alignment matches OpenLayers.', + ) + out.push( + new SyntheticDyadicPixelSource( + finestFirst[i].loader, + finestFirst[i].dicomLevel, + halfShapeForDyadicStep(finestFirst[i].shape), + finestFirst[i].dtype, + finestFirst[i].tileSize, + ), + ) + } + } + return out +} + export class DicomPixelSource { private readonly _loader: DicomLoader private readonly _level: number + /** OpenLayers / dicom-microscopy-viewer pyramid index (0 = coarsest, N-1 = finest). */ + get dicomLevel(): number { + return this._level + } + + get loader(): DicomLoader { + return this._loader + } + labels = ['c', 'y', 'x'] shape: [number, number, number] | [number, number, number, number] @@ -555,3 +686,296 @@ export class DicomPixelSource { console.error(err) } } + +/** + * Viv level whose pixel grid is ~2× coarser than `finerDicomLevel` but still uses that + * DICOM instance by merging 2×2 native tiles and box-downsampling to `tileSize`. + */ +export class SyntheticDyadicPixelSource { + private readonly _loader: DicomLoader + + private readonly _finerDicomLevel: number + + labels = ['c', 'y', 'x'] + + shape: [number, number, number] | [number, number, number, number] + + dtype: 'Uint8' | 'Uint16' + + tileSize: number + + meta: { photometricInterpretation: number } | null + + constructor( + loader: DicomLoader, + finerDicomLevel: number, + shape: [number, number, number] | [number, number, number, number], + dtype: 'Uint8' | 'Uint16', + tileSize: number, + ) { + this._loader = loader + this._finerDicomLevel = finerDicomLevel + this.shape = shape + this.dtype = dtype + this.tileSize = tileSize + this.meta = shape.length === 4 ? { photometricInterpretation: 2 } : null + } + + get loader(): DicomLoader { + return this._loader + } + + get dicomLevel(): number { + return this._finerDicomLevel + } + + async getRaster(): Promise { + throw new Error('getRaster not supported for synthetic pyramid levels') + } + + async getTile({ + x, + y, + selection, + }: { + x: number + y: number + selection: { c: number; t: number; z: number } + signal?: AbortSignal + }): Promise<{ + data: Uint8Array | Uint16Array + width: number + height: number + }> { + const pathId = await this._loader.resolveOpticalPathId(selection.c) + const lx = 2 * x + const ly = 2 * y + const tiles = await Promise.all([ + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx, + y: ly, + }), + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx + 1, + y: ly, + }), + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx, + y: ly + 1, + }), + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx + 1, + y: ly + 1, + }), + ]) + return downsampleFourQuadrants(tiles, this.tileSize, this.dtype, this.shape) + } + + onTileError(err: Error): void { + console.error(err) + } +} + +function downsampleFourQuadrants( + tiles: Array<{ + data: Uint8Array | Uint16Array + width: number + height: number + }>, + ts: number, + dtype: 'Uint8' | 'Uint16', + shape: [number, number, number] | [number, number, number, number], +): { + data: Uint8Array | Uint16Array + width: number + height: number +} { + const bigW = ts * 2 + const bigH = ts * 2 + const interleaved = shape.length === 4 + const ch = interleaved ? 3 : 1 + + if (dtype === 'Uint8' && interleaved) { + const big = new Uint8Array(bigW * bigH * 3) + big.fill(255) + blitTileQuadrant(big, bigW, tiles[0], 0, 0, ts, ch) + blitTileQuadrant(big, bigW, tiles[1], ts, 0, ts, ch) + blitTileQuadrant(big, bigW, tiles[2], 0, ts, ts, ch) + blitTileQuadrant(big, bigW, tiles[3], ts, ts, ts, ch) + const out = new Uint8Array(ts * ts * 3) + out.fill(255) + boxDownsampleRgb8(big, bigW, bigH, out, ts) + return { data: out, width: ts, height: ts } + } + + if (dtype === 'Uint16' && interleaved) { + const big = new Uint16Array(bigW * bigH * 3) + big.fill(65535) + blitTileQuadrantU16(big, bigW, tiles[0], 0, 0, ts, ch) + blitTileQuadrantU16(big, bigW, tiles[1], ts, 0, ts, ch) + blitTileQuadrantU16(big, bigW, tiles[2], 0, ts, ts, ch) + blitTileQuadrantU16(big, bigW, tiles[3], ts, ts, ts, ch) + const out = new Uint16Array(ts * ts * 3) + out.fill(65535) + boxDownsampleRgb16(big, bigW, bigH, out, ts) + return { data: out, width: ts, height: ts } + } + + const big = new Uint16Array(bigW * bigH) + big.fill(0) + blitMonoQuadrant(big, bigW, tiles[0], 0, 0, ts) + blitMonoQuadrant(big, bigW, tiles[1], ts, 0, ts) + blitMonoQuadrant(big, bigW, tiles[2], 0, ts, ts) + blitMonoQuadrant(big, bigW, tiles[3], ts, ts, ts) + const out = new Uint16Array(ts * ts) + boxDownsampleMono16(big, bigW, bigH, out, ts) + return { data: out, width: ts, height: ts } +} + +function blitTileQuadrant( + dst: Uint8Array, + dstStride: number, + tile: { data: Uint8Array | Uint16Array; width: number; height: number }, + ox: number, + oy: number, + ts: number, + ch: number, +): void { + const src = tile.data as Uint8Array + for (let row = 0; row < tile.height; row++) { + for (let col = 0; col < tile.width; col++) { + const si = (row * ts + col) * ch + const di = ((oy + row) * dstStride + (ox + col)) * ch + for (let k = 0; k < ch; k++) { + dst[di + k] = src[si + k] + } + } + } +} + +function blitTileQuadrantU16( + dst: Uint16Array, + dstStride: number, + tile: { data: Uint8Array | Uint16Array; width: number; height: number }, + ox: number, + oy: number, + ts: number, + ch: number, +): void { + const src = tile.data as Uint16Array + for (let row = 0; row < tile.height; row++) { + for (let col = 0; col < tile.width; col++) { + const si = (row * ts + col) * ch + const di = ((oy + row) * dstStride + (ox + col)) * ch + for (let k = 0; k < ch; k++) { + dst[di + k] = src[si + k] + } + } + } +} + +function blitMonoQuadrant( + dst: Uint16Array, + dstStride: number, + tile: { data: Uint8Array | Uint16Array; width: number; height: number }, + ox: number, + oy: number, + ts: number, +): void { + const src = tile.data as Uint16Array + for (let row = 0; row < tile.height; row++) { + for (let col = 0; col < tile.width; col++) { + dst[(oy + row) * dstStride + (ox + col)] = src[row * ts + col] + } + } +} + +function boxDownsampleRgb8( + src: Uint8Array, + srcW: number, + srcH: number, + dst: Uint8Array, + dstTs: number, +): void { + for (let dy = 0; dy < dstTs; dy++) { + for (let dx = 0; dx < dstTs; dx++) { + for (let k = 0; k < 3; k++) { + let sum = 0 + let n = 0 + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 2; i++) { + const sy = dy * 2 + j + const sx = dx * 2 + i + if (sy < srcH && sx < srcW) { + sum += src[(sy * srcW + sx) * 3 + k] + n++ + } + } + } + dst[(dy * dstTs + dx) * 3 + k] = n > 0 ? Math.round(sum / n) & 255 : 255 + } + } + } +} + +function boxDownsampleRgb16( + src: Uint16Array, + srcW: number, + srcH: number, + dst: Uint16Array, + dstTs: number, +): void { + for (let dy = 0; dy < dstTs; dy++) { + for (let dx = 0; dx < dstTs; dx++) { + for (let k = 0; k < 3; k++) { + let sum = 0 + let n = 0 + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 2; i++) { + const sy = dy * 2 + j + const sx = dx * 2 + i + if (sy < srcH && sx < srcW) { + sum += src[(sy * srcW + sx) * 3 + k] + n++ + } + } + } + dst[(dy * dstTs + dx) * 3 + k] = n > 0 ? clampU16(sum / n) : 65535 + } + } + } +} + +function boxDownsampleMono16( + src: Uint16Array, + srcW: number, + srcH: number, + dst: Uint16Array, + dstTs: number, +): void { + for (let dy = 0; dy < dstTs; dy++) { + for (let dx = 0; dx < dstTs; dx++) { + let sum = 0 + let n = 0 + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 2; i++) { + const sy = dy * 2 + j + const sx = dx * 2 + i + if (sy < srcH && sx < srcW) { + sum += src[sy * srcW + sx] + n++ + } + } + } + dst[dy * dstTs + dx] = n > 0 ? clampU16(sum / n) : 0 + } + } +} diff --git a/src/viv/vivDisplayDefaults.ts b/src/viv/vivDisplayDefaults.ts index d39b2300..e35945b5 100644 --- a/src/viv/vivDisplayDefaults.ts +++ b/src/viv/vivDisplayDefaults.ts @@ -86,6 +86,31 @@ export function computeOrthographicFitViewState( } } +/** + * OrthographicController defaults to unlimited zoom; Tile2D picks tile z using `ceil(viewport.zoom)` (+ offset). + * Tiny float drift or extreme zoom can bump tile z and load a different region. Clamp relative to the fit zoom + * and pyramid depth so the viewport stays aligned with MultiscaleImageLayer tile indexing. + */ +export function orthographicZoomLimits( + vw: number, + vh: number, + slideW: number, + slideH: number, + pyramidLevelCount: number, +): { minZoom: number; maxZoom: number } { + const w = Math.max(1, vw) + const h = Math.max(1, vh) + const sw = Math.max(1, slideW) + const sh = Math.max(1, slideH) + const fitZ = Math.log2(Math.min(w / sw, h / sh)) + const n = Math.max(1, pyramidLevelCount) + // ~one orthographic zoom step per pyramid level toward full-res, then room for magnifying past native tile z=0. + return { + minZoom: fitZ - 2, + maxZoom: fitZ + Math.max(0, n - 1) + 6, + } +} + /** IDC cyclic IF demo (Lin et al.) — channels 8–11 per viv-dicomweb-test. */ export const IDC_CYCLIC_IF_VIV_SETTINGS: VivSettings = { selections: [ From 1c8ed29bda6d8817620c432908479d1b1b1c2ed7 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 18:18:21 -0300 Subject: [PATCH 05/57] Add request abort --- package.json | 3 +- src/DicomWebManager.ts | 14 ++ src/viv/VivSlideViewport.tsx | 10 +- src/viv/dicomLoader.ts | 359 ++++++++++++++++++++++++++++--- types/dicomweb-client/index.d.ts | 3 + 5 files changed, 356 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index d9bf967c..3d6f6411 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "@deck.gl/mesh-layers": "9.1.15", "@deck.gl/react": "9.1.15", "@deck.gl/widgets": "9.1.15", - "@hms-dbmi/viv": "~0.19.0", + "@vivjs/layers": "^0.20.0", + "@vivjs/loaders": "^0.20.0", "@luma.gl/constants": "~9.1.10", "@luma.gl/core": "~9.1.10", "@luma.gl/engine": "~9.1.10", diff --git a/src/DicomWebManager.ts b/src/DicomWebManager.ts index c4168b99..8bda0886 100644 --- a/src/DicomWebManager.ts +++ b/src/DicomWebManager.ts @@ -549,6 +549,20 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { return this.stores[0].client.headers } + /** + * Run a callback on the primary {@link dwc.api.DICOMwebClient} (the store used for reads). + * Used e.g. to append Viv tile {@link dwc.api.DICOMwebClientOptions.requestHooks} that must + * see the same XHR pipeline as {@link dmv.viewer.VolumeImageViewer}. + */ + applyToPrimaryDicomwebClient( + fn: (client: dwc.api.DICOMwebClient) => void, + ): void { + if (this.stores.length === 0) { + return + } + fn(this.stores[0].client) + } + /** * Store new instances in the first writable configured store. Picking the * first writable (rather than always store[0]) keeps backwards compatibility diff --git a/src/viv/VivSlideViewport.tsx b/src/viv/VivSlideViewport.tsx index c42bf5db..8d02a277 100644 --- a/src/viv/VivSlideViewport.tsx +++ b/src/viv/VivSlideViewport.tsx @@ -1,14 +1,14 @@ import type { Layer } from '@deck.gl/core' import { OrthographicView } from '@deck.gl/core' import DeckGL from '@deck.gl/react' -import { MultiscaleImageLayer } from '@hms-dbmi/viv' +import { MultiscaleImageLayer } from '@vivjs/layers' import { message, Spin } from 'antd' import type React from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { VivSettings } from '../AppConfig' import type DicomWebManager from '../DicomWebManager' -import { DicomLoader } from './dicomLoader' +import { DicomLoader, isVivDicomTileNetworkCancellation } from './dicomLoader' import { buildVivDisplayOptions, computeOrthographicFitViewState, @@ -140,6 +140,12 @@ const VivSlideViewport: React.FC = ({ // Lowest pyramid level is often wider/taller than one tile; ImageLayer would call getRaster and fail. excludeBackground: true, // Omit refinementStrategy → Viv uses best-available when opacity=1; smoother hand-off between pyramid levels than no-overlap. + onTileError: (err: Error) => { + if (isVivDicomTileNetworkCancellation(err)) { + return + } + console.error(err) + }, }) setLayers([layer as unknown as Layer]) slideRef.current = { diff --git a/src/viv/dicomLoader.ts b/src/viv/dicomLoader.ts index 795e2475..e151802e 100644 --- a/src/viv/dicomLoader.ts +++ b/src/viv/dicomLoader.ts @@ -1,8 +1,11 @@ // Ported from https://github.com/jmuhlich/viv-dicomweb-test (dicomweb.js). // Adapts dicom-microscopy-viewer tile loaders to Viv PixelSource. +import { SIGNAL_ABORTED } from '@vivjs/loaders' // skipcq: JS-C1003 import * as dmv from 'dicom-microscopy-viewer' +// skipcq: JS-C1003 +import type * as dwc from 'dicomweb-client' import type DicomWebManager from '../DicomWebManager' export interface DicomRetrieveOptions { @@ -94,6 +97,150 @@ async function waitForOpenLayersTileLoaders( ) } +function isXhrLike(req: unknown): req is XMLHttpRequest { + if (req === null || typeof req !== 'object') { + return false + } + const r = req as { open?: unknown; abort?: unknown; readyState?: unknown } + return ( + typeof r.open === 'function' && + typeof r.abort === 'function' && + typeof r.readyState === 'number' + ) +} + +/** Walk `cause` / `errors` (AggregateError); avoid `instanceof XMLHttpRequest` (dev proxies break it). */ +function xhrFromDicomwebErrorDeep(e: unknown): XMLHttpRequest | undefined { + let cur: unknown = e + const seen = new Set() + while (cur !== null && typeof cur === 'object' && !seen.has(cur)) { + seen.add(cur) + const o = cur as { + request?: unknown + cause?: unknown + errors?: unknown[] + } + if (isXhrLike(o.request)) { + return o.request + } + if (Array.isArray(o.errors)) { + for (const sub of o.errors) { + const xhr = xhrFromDicomwebErrorDeep(sub) + if (xhr !== undefined) { + return xhr + } + } + } + cur = o.cause + } + return undefined +} + +/** dicomweb-client rejects with XHR status `0` and message `request failed` after `abort()`. */ +function dicomwebAbortedRequestErrorDeep(e: unknown): boolean { + let cur: unknown = e + const seen = new Set() + while (cur !== null && typeof cur === 'object' && !seen.has(cur)) { + seen.add(cur) + const o = cur as { + message?: string + status?: number + cause?: unknown + errors?: unknown[] + } + if (o.message === 'request failed' && o.status === 0) { + return true + } + if (Array.isArray(o.errors)) { + for (const sub of o.errors) { + if (dicomwebAbortedRequestErrorDeep(sub)) { + return true + } + } + } + cur = o.cause + } + return false +} + +/** + * dicom-microscopy-viewer wraps dicomweb failures in `new Error('Failed to load frames…', err)`; some + * engines omit `cause` or attach the inner error elsewhere — scan messages on `cause` / `errors`. + */ +function requestFailedMessageInErrorTree(e: unknown): boolean { + const stack: unknown[] = [e] + const seen = new Set() + while (stack.length > 0) { + const cur = stack.pop() + if (cur === null || cur === undefined || typeof cur !== 'object') { + continue + } + if (seen.has(cur)) { + continue + } + seen.add(cur) + const msg = (cur as Error).message + if (typeof msg === 'string' && msg.includes('request failed')) { + return true + } + const c = (cur as { cause?: unknown }).cause + if (c !== undefined) { + stack.push(c) + } + const errors = (cur as { errors?: unknown[] }).errors + if (Array.isArray(errors)) { + for (const sub of errors) { + stack.push(sub) + } + } + } + return false +} + +/** Bounded search for dicomweb’s `{ message: 'request failed', status: 0 }` on nested properties. */ +function objectGraphHasDicomwebTileAbort(root: unknown): boolean { + if (root === null || typeof root !== 'object') { + return false + } + const queue: unknown[] = [root] + const seen = new Set() + let nodes = 0 + const maxNodes = 48 + while (queue.length > 0 && nodes < maxNodes) { + const v = queue.shift() + if (v === null || typeof v !== 'object' || seen.has(v)) { + continue + } + seen.add(v) + nodes++ + const o = v as Record + const msg = o.message + const st = o.status + const msgStr = typeof msg === 'string' ? msg : '' + if ( + st === 0 && + (msg === 'request failed' || msgStr.includes('request failed')) + ) { + return true + } + for (const val of Object.values(o)) { + if (val !== null && typeof val === 'object') { + queue.push(val) + } + } + } + return false +} + +/** True when the OpenLayers→pyramid→dicomweb chain failed due to XHR abort / prune. */ +export function isVivDicomTileNetworkCancellation(e: unknown): boolean { + return ( + dicomwebAbortedRequestErrorDeep(e) || + requestFailedMessageInErrorTree(e) || + objectGraphHasDicomwebTileAbort(e) + ) +} + function getOpticalPathsMap(viewer: dmv.viewer.VolumeImageViewer): { [key: string]: OpticalPathEntry } { @@ -138,6 +285,24 @@ export class DicomLoader { */ private _orderedPathKeys?: string[] + private _vivAbortHooksInstalled = false + + /** + * Stashed only for the synchronous window before dicomweb-client runs piped + * {@link dwc.api.DICOMwebClientOptions.requestHooks} (see viv-dicomweb-test). + */ + private _currentSignal: AbortSignal | undefined + + /** + * XHR instances for which we forwarded deck.gl/Viv abort to dicomweb-client. + * `bridgedAbort` is set synchronously in the `abort` listener before `xhr.abort()`, so it is + * reliable even when `signal.aborted` in `getTile`'s catch is not (microtask ordering). + */ + private readonly _xhrTileAbort = new WeakMap< + XMLHttpRequest, + { bridgedAbort: boolean } + >() + /** Set when the viewer is first built; 8- or 16-bit SM tiles both load as float then convert to Uint16 in getTile. */ bitsAllocated?: 8 | 16 @@ -149,6 +314,87 @@ export class DicomLoader { this._retrieveOptions = retrieveOptions } + private _ensureVivAbortHooks(): void { + if (this._vivAbortHooksInstalled) { + return + } + this._client.applyToPrimaryDicomwebClient((inner) => { + const prevHooks = inner.requestHooks ?? [] + const vivHook: dwc.api.DICOMwebClientRequestHook = ( + request, + _metadata, + ) => { + const signal = this._currentSignal + if (signal !== undefined) { + if (signal.aborted) { + this._xhrTileAbort.set(request, { bridgedAbort: true }) + } else { + this._xhrTileAbort.set(request, { bridgedAbort: false }) + signal.addEventListener( + 'abort', + () => { + const meta = this._xhrTileAbort.get(request) + if (meta !== undefined) { + meta.bridgedAbort = true + } + request.abort() + }, + { once: true }, + ) + } + // dicomweb-client defaults verbose=true and logs console.error on any + // non-2xx XHR outcome, including status 0 after abort(). Silence only + // prune/cancellation paths we bridged from deck.gl's AbortSignal. + const prev = request.onreadystatechange + if (typeof prev === 'function') { + const tileAbortMap = this._xhrTileAbort + request.onreadystatechange = function ( + this: XMLHttpRequest, + ev: Event, + ) { + if ( + this.readyState === 4 && + this.status === 0 && + tileAbortMap.get(this)?.bridgedAbort === true + ) { + const ce = console.error + console.error = (): void => {} + try { + prev.call(this, ev) + } finally { + console.error = ce + } + return + } + prev.call(this, ev) + } + } + } + this._currentSignal = undefined + if (signal?.aborted === true) { + request.abort() + } + return request + } + inner.requestHooks = [...prevHooks, vivHook] + + const prevErr = inner.errorInterceptor + inner.errorInterceptor = (error: dwc.api.DICOMwebClientError) => { + const err = error as dwc.api.DICOMwebClientError & { cause?: unknown } + if (!Object.hasOwn(err, 'cause') || err.cause === undefined) { + err.cause = err + } + // dicomweb rejects with this shape after XHR.abort(); deck tile prune — skip app error UI. + const errMsg = (error as { message?: string }).message + if (error.status === 0 && errMsg === 'request failed') { + return + } + prevErr?.(error) + } + }) + this._vivAbortHooksInstalled = true + } + private async _getViewer(): Promise { if (this._viewer === undefined) { const metadata = await this._client.retrieveSeriesMetadata( @@ -310,6 +556,7 @@ export class DicomLoader { private async _getLoader( channel: string, ): Promise<(level: number, x: number, y: number) => Promise> { + this._ensureVivAbortHooks() if (this._loaders === undefined) { const viewer = await this._getViewer() viewer.render({ container: document.createElement('div') }) @@ -375,18 +622,46 @@ export class DicomLoader { channel, x, y, + signal, }: { level: number channel: string x: number y: number + signal?: AbortSignal }): Promise<{ data: Uint8Array | Uint16Array width: number height: number }> { const loader = await this._getLoader(channel) - const raw = await loader(level, x, y) + if (this._currentSignal !== undefined) { + throw new Error('Failure in tile request abort signal management') + } + this._currentSignal = signal + let raw: Awaited> + try { + raw = await loader(level, x, y) + } catch (e) { + const xhr = xhrFromDicomwebErrorDeep(e) + const tileMeta = + xhr !== undefined ? this._xhrTileAbort.get(xhr) : undefined + if (xhr !== undefined) { + this._xhrTileAbort.delete(xhr) + } + const bridged = tileMeta?.bridgedAbort === true + const signalPruned = signal?.aborted === true + const cancelled = + bridged || signalPruned || isVivDicomTileNetworkCancellation(e) + // MultiscaleImageLayer only treats __vivSignalAborted as cancellation; map all + // deck→dicomweb prune failures to that (see getTileData in @vivjs/layers). + if (cancelled) { + throw SIGNAL_ABORTED + } + throw e + } finally { + this._currentSignal = undefined + } const shape = (await this._getShapes())[level] const ts = await this._getTileSize() const { columns } = await this._frameLayout(level) @@ -642,6 +917,7 @@ export class DicomPixelSource { async getRaster({ selection, + signal, }: { selection: { c: number; t: number; z: number } signal?: AbortSignal @@ -656,13 +932,14 @@ export class DicomPixelSource { if (this.shape[1] > this.tileSize || this.shape[2] > this.tileSize) { throw new Error('getRaster not supported for multi-tile pyramid levels') } - return await this.getTile({ x: 0, y: 0, selection }) + return await this.getTile({ x: 0, y: 0, selection, signal }) } async getTile({ x, y, selection, + signal, }: { x: number y: number @@ -679,11 +956,12 @@ export class DicomPixelSource { channel: pathId, x, y, + signal, }) } onTileError(err: Error): void { - console.error(err) + console.error(`Tile error: ${err}`) } } @@ -737,6 +1015,7 @@ export class SyntheticDyadicPixelSource { x, y, selection, + signal, }: { x: number y: number @@ -750,37 +1029,57 @@ export class SyntheticDyadicPixelSource { const pathId = await this._loader.resolveOpticalPathId(selection.c) const lx = 2 * x const ly = 2 * y - const tiles = await Promise.all([ - this._loader.getTile({ - level: this._finerDicomLevel, - channel: pathId, - x: lx, - y: ly, - }), - this._loader.getTile({ - level: this._finerDicomLevel, - channel: pathId, - x: lx + 1, - y: ly, - }), - this._loader.getTile({ - level: this._finerDicomLevel, - channel: pathId, - x: lx, - y: ly + 1, - }), - this._loader.getTile({ - level: this._finerDicomLevel, - channel: pathId, - x: lx + 1, - y: ly + 1, - }), - ]) + let tiles: Array<{ + data: Uint8Array | Uint16Array + width: number + height: number + }> + try { + tiles = await Promise.all([ + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx, + y: ly, + signal, + }), + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx + 1, + y: ly, + signal, + }), + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx, + y: ly + 1, + signal, + }), + this._loader.getTile({ + level: this._finerDicomLevel, + channel: pathId, + x: lx + 1, + y: ly + 1, + signal, + }), + ]) + } catch (e) { + if ( + e === SIGNAL_ABORTED || + signal?.aborted === true || + isVivDicomTileNetworkCancellation(e) + ) { + throw SIGNAL_ABORTED + } + throw e + } return downsampleFourQuadrants(tiles, this.tileSize, this.dtype, this.shape) } onTileError(err: Error): void { - console.error(err) + console.error(`Tile error: ${err}`) } } diff --git a/types/dicomweb-client/index.d.ts b/types/dicomweb-client/index.d.ts index 53b424e3..f8ee861d 100644 --- a/types/dicomweb-client/index.d.ts +++ b/types/dicomweb-client/index.d.ts @@ -136,6 +136,9 @@ declare module 'dicomweb-client' { export interface DICOMwebClient { headers: { [key: string]: string } baseURL: string + /** Present on the concrete {@link DICOMwebClient} implementation; used for retries and Viv tile abort. */ + requestHooks?: DICOMwebClientRequestHook[] + errorInterceptor?: (error: DICOMwebClientError) => void // STOW-RS storeInstances(options: StoreInstancesOptions): Promise // QIDO-RS From a386f08396de4a4517cbbf241a0339ea651a93ab Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 21:12:24 -0300 Subject: [PATCH 06/57] Working: Bulk ann (polygons) --- src/components/CaseViewer.tsx | 346 ++++++------ src/viv/README.md | 6 +- src/viv/VivSlideViewport.tsx | 97 +++- src/viv/dicomLoader.ts | 80 +++ src/viv/loadBulkAnnotationLayers.ts | 647 +++++++++++++++++++++++ types/dicom-microscopy-viewer/index.d.ts | 67 +++ 6 files changed, 1043 insertions(+), 200 deletions(-) create mode 100644 src/viv/loadBulkAnnotationLayers.ts diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index eee2a768..3545c288 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -1,15 +1,8 @@ -import type { MenuProps } from 'antd' -import { Layout, Menu } from 'antd' +import { Layout, Menu, Switch } from 'antd' // skipcq: JS-C1003 import * as dcmjs from 'dcmjs' -import { useEffect, useState } from 'react' -import { - Route, - Routes, - useLocation, - useNavigate, - useParams, -} from 'react-router-dom' +import { useEffect, useState, type ReactNode } from 'react' +import { Route, Routes, useLocation, useParams } from 'react-router-dom' import type { AnnotationSettings, VivSettings } from '../AppConfig' import type { User } from '../auth' @@ -17,24 +10,11 @@ import type DicomWebManager from '../DicomWebManager' import type { Slide } from '../data/slides' import { StorageClasses } from '../data/uids' import { useSlides } from '../hooks/useSlides' -import { - findSlideBySeriesInstanceUID, - seriesUidFromSlide, -} from '../utils/recoverSeriesInstanceUID' import { type RouteComponentProps, withRouter } from '../utils/router' -import { - buildSeriesPath, - hasSeriesInPath, - isProjectsPath, - parseSeriesInstanceUID, - RoutePaths, - withSeriesInProjectPath, -} from '../utils/routes' import VivSlideViewport from '../viv/VivSlideViewport' import ClinicalTrial from './ClinicalTrial' import Patient from './Patient' import SlideList from './SlideList' -// skipcq: JS-W1028 - SlideViewer has a default export import SlideViewer from './SlideViewer' import Study from './Study' @@ -65,10 +45,16 @@ interface NaturalizedInstance { const findSeriesSlide = ( slides: Slide[], seriesInstanceUID: string, -): Slide | undefined => findSlideBySeriesInstanceUID(slides, seriesInstanceUID) +): Slide | undefined => { + return slides.find((slide: Slide) => { + return slide.seriesInstanceUIDs.find((uid: string) => { + return uid === seriesInstanceUID + }) + }) +} /** Viv path: main viewport + slim right rail (classic viewer uses ~300px sider). */ -const vivChrome = (main: JSX.Element): JSX.Element => ( +const vivChrome = (main: JSX.Element, rightPanel?: ReactNode): JSX.Element => (
( overflow: 'auto', }} > - Viv preview — classic slide tools are not wired here yet. + {rightPanel ?? ( + Viv preview — classic slide tools are not wired here yet. + )}
) @@ -139,20 +127,19 @@ function ParametrizedSlideViewer({ seriesInstanceUID: string }>() const location = useLocation() - const navigate = useNavigate() const [selectedSlide, setSelectedSlide] = useState( findSeriesSlide(slides, seriesInstanceUID), ) const [derivedDataset, setDerivedDataset] = useState(null) + const [loadVivBulkAnnotations, setLoadVivBulkAnnotations] = useState(false) useEffect(() => { const currentSlideMatchesSeries = - selectedSlide !== null && - selectedSlide !== undefined && - findSlideBySeriesInstanceUID([selectedSlide], seriesInstanceUID) === - selectedSlide + selectedSlide?.seriesInstanceUIDs.some( + (uid: string) => uid === seriesInstanceUID, + ) ?? false if ( selectedSlide === null || @@ -161,105 +148,68 @@ function ParametrizedSlideViewer({ ) { const imageSlide = findSeriesSlide(slides, seriesInstanceUID) if (imageSlide !== null && imageSlide !== undefined) { - const resolvedSeriesUID = seriesUidFromSlide( - imageSlide, - seriesInstanceUID, - ) setSelectedSlide(imageSlide) setDerivedDataset(null) - if (resolvedSeriesUID !== seriesInstanceUID) { - console.warn( - `Corrected mangled series UID in route: "${seriesInstanceUID}" → "${resolvedSeriesUID}"`, - ) - navigate( - { - pathname: location.pathname.replace( - `/series/${seriesInstanceUID}`, - `/series/${resolvedSeriesUID}`, - ), - search: location.search, - }, - { replace: true }, - ) - } return } const findReferencedSlide = async (): Promise => { - try { - const client = clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE] - const derivedSeriesMetadata = await client.retrieveSeriesMetadata({ - studyInstanceUID, - seriesInstanceUID, - }) - const naturalizedDerivedMetadata = naturalizeDataset( - derivedSeriesMetadata[0], - ) as NaturalizedInstance - if ( - naturalizedDerivedMetadata.ReferencedSeriesSequence != null && - naturalizedDerivedMetadata.ReferencedSeriesSequence.length > 0 - ) { - for (const referencedSeries of naturalizedDerivedMetadata.ReferencedSeriesSequence) { - const referencedImageSeriesUID = - referencedSeries.SeriesInstanceUID - const referencedSlide = slides.find((slide: Slide) => { - return slide.seriesInstanceUIDs.some( - (uid: string) => uid === referencedImageSeriesUID, - ) - }) - if (referencedSlide !== null && referencedSlide !== undefined) { - setSelectedSlide(referencedSlide) - setDerivedDataset(naturalizedDerivedMetadata) - return - } - } - } - const IMAGE_LIBRARY_CONCEPT_NAME_CODE = '111028' - const imageLibrary = naturalizedDerivedMetadata.ContentSequence?.find( - (contentItem) => - contentItem.ConceptNameCodeSequence[0].CodeValue === - IMAGE_LIBRARY_CONCEPT_NAME_CODE, - ) - if ( - imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] - ?.ReferencedSOPSequence?.[0] !== undefined && - imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] - ?.ReferencedSOPSequence?.[0] !== null - ) { - const referencedSOPInstanceUID = - imageLibrary.ContentSequence[0].ContentSequence[0] - .ReferencedSOPSequence[0].ReferencedSOPInstanceUID + const client = clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE] + const derivedSeriesMetadata = await client.retrieveSeriesMetadata({ + studyInstanceUID, + seriesInstanceUID, + }) + const naturalizedDerivedMetadata = naturalizeDataset( + derivedSeriesMetadata[0], + ) as NaturalizedInstance + if ( + naturalizedDerivedMetadata.ReferencedSeriesSequence != null && + naturalizedDerivedMetadata.ReferencedSeriesSequence.length > 0 + ) { + for (const referencedSeries of naturalizedDerivedMetadata.ReferencedSeriesSequence) { + const referencedImageSeriesUID = referencedSeries.SeriesInstanceUID const referencedSlide = slides.find((slide: Slide) => { - return slide.volumeImages.find( - (image: { SOPInstanceUID: string }) => { - return image.SOPInstanceUID === referencedSOPInstanceUID - }, + return slide.seriesInstanceUIDs.some( + (uid: string) => uid === referencedImageSeriesUID, ) }) - setSelectedSlide(referencedSlide) - setDerivedDataset(naturalizedDerivedMetadata) + if (referencedSlide !== null && referencedSlide !== undefined) { + setSelectedSlide(referencedSlide) + setDerivedDataset(naturalizedDerivedMetadata) + return + } } - } catch (error) { - console.warn( - `Failed to resolve referenced slide for series "${seriesInstanceUID}"`, - error, - ) + } + const IMAGE_LIBRARY_CONCEPT_NAME_CODE = '111028' + const imageLibrary = naturalizedDerivedMetadata.ContentSequence?.find( + (contentItem) => + contentItem.ConceptNameCodeSequence[0].CodeValue === + IMAGE_LIBRARY_CONCEPT_NAME_CODE, + ) + if ( + imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] + ?.ReferencedSOPSequence?.[0] !== undefined && + imageLibrary?.ContentSequence?.[0]?.ContentSequence?.[0] + ?.ReferencedSOPSequence?.[0] !== null + ) { + const referencedSOPInstanceUID = + imageLibrary.ContentSequence[0].ContentSequence[0] + .ReferencedSOPSequence[0].ReferencedSOPInstanceUID + const referencedSlide = slides.find((slide: Slide) => { + return slide.volumeImages.find( + (image: { SOPInstanceUID: string }) => { + return image.SOPInstanceUID === referencedSOPInstanceUID + }, + ) + }) + setSelectedSlide(referencedSlide) + setDerivedDataset(naturalizedDerivedMetadata) } } - // skipcq: JS-0098 - void operator intentionally discards the Promise void findReferencedSlide() } - }, [ - slides, - clients, - studyInstanceUID, - seriesInstanceUID, - selectedSlide, - navigate, - location.pathname, - location.search, - ]) + }, [slides, clients, studyInstanceUID, seriesInstanceUID, selectedSlide]) const searchParams = new URLSearchParams(location.search) let presentationStateUID: string | undefined @@ -270,10 +220,6 @@ function ParametrizedSlideViewer({ let viewer = null if (selectedSlide != null && selectedSlide !== undefined) { - const resolvedSeriesInstanceUID = seriesUidFromSlide( - selectedSlide, - seriesInstanceUID, - ) if (useViv) { const microscopyClient = clients[StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE] ?? @@ -281,20 +227,61 @@ function ParametrizedSlideViewer({ if (microscopyClient === undefined) { return null } + const bulkAnnotationClient = + clients[StorageClasses.MICROSCOPY_BULK_SIMPLE_ANNOTATION] ?? + microscopyClient viewer = vivChrome( , +
+
+ Viv preview +
+

+ Classic slide tools are not wired here yet. +

+
+ Bulk annotations + +
+

+ Off by default. Turn on to fetch ANN series and draw overlays after + the pyramid loads. Use the browser console filter{' '} + [Viv bulk ANN] for diagnostics. +

+
, ) } else { viewer = ( { console.info(`switch to series "${seriesInstanceUID}"`) - let urlPath = buildSeriesPath(studyInstanceUID, seriesInstanceUID) + let urlPath = `/studies/${studyInstanceUID}/series/${seriesInstanceUID}` - if (isProjectsPath(location.pathname)) { - urlPath = withSeriesInProjectPath(location.pathname, seriesInstanceUID) + if (location.pathname.includes('/projects/')) { + urlPath = location.pathname + if (!location.pathname.includes('/series/')) { + urlPath += `/series/${seriesInstanceUID}` + } else { + urlPath = urlPath.replace( + /\/series\/[^/]+/, + `/series/${seriesInstanceUID}`, + ) + } } if ( - hasSeriesInPath(location.pathname) && + location.pathname.includes('/series/') && location.search !== null && location.search !== undefined ) { @@ -374,91 +369,58 @@ function Viewer(props: ViewerProps): JSX.Element | null { * the first slide contained in the study. */ let selectedSeriesInstanceUID: string - const seriesFromPath = parseSeriesInstanceUID(location.pathname) - if (seriesFromPath !== '') { - const slideForPath = findSeriesSlide(slides, seriesFromPath) - selectedSeriesInstanceUID = - slideForPath !== undefined - ? seriesUidFromSlide(slideForPath, seriesFromPath) - : seriesFromPath + if (location.pathname.includes('series/')) { + const seriesFragment = location.pathname.split('series/')[1] + selectedSeriesInstanceUID = seriesFragment.includes('/') + ? seriesFragment.split('/')[0] + : seriesFragment } else { selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID } - const siderMenuItems: MenuProps['items'] = [ - { - key: 'patient', - label: 'Patient', - children: [ - { - key: 'patient-info', - style: { cursor: 'default', height: 'auto' }, - label: , - }, - ], - }, - { - key: 'study', - label: 'Study', - children: [ - { - key: 'study-info', - style: { cursor: 'default', height: 'auto' }, - label: , - }, - ], - }, - ...(refImage.ClinicalTrialSponsorName != null - ? [ - { - key: 'clinical-trial', - label: 'Clinical Trial', - children: [ - { - key: 'clinical-trial-info', - style: { cursor: 'default', height: 'auto' }, - label: , - }, - ], - }, - ] - : []), - ] + let clinicalTrialMenu: React.ReactNode + if (refImage.ClinicalTrialSponsorName != null) { + clinicalTrialMenu = ( + + + + ) + } return ( - + -
- Slides -
- + + + + + + + {clinicalTrialMenu} + + + +
= ({ client, + bulkAnnotationClient, + loadBulkAnnotations = false, studyInstanceUID, seriesInstanceUID, vivSettings, @@ -47,9 +55,15 @@ const VivSlideViewport: React.FC = ({ levelCount: number } | null>(null) const fitDoneRef = useRef(false) + /** Same loader instance that built `baseLayer` (for geometry + ANN bulk fetches only). */ + const dicomLoaderRef = useRef(null) const [size, setSize] = useState({ width: 100, height: 100 }) - const [layers, setLayers] = useState([]) + const [baseLayer, setBaseLayer] = useState(null) + const [annLayers, setAnnLayers] = useState([]) + const layers = useMemo((): Layer[] => { + return baseLayer !== null ? [baseLayer, ...annLayers] : [] + }, [baseLayer, annLayers]) const [loading, setLoading] = useState(true) const [viewState, setViewState] = useState({ target: [0, 0, 0], @@ -100,7 +114,9 @@ const VivSlideViewport: React.FC = ({ fitDoneRef.current = false slideRef.current = null setLoading(true) - setLayers([]) + setBaseLayer(null) + setAnnLayers([]) + dicomLoaderRef.current = null const run = async (): Promise => { try { @@ -108,6 +124,7 @@ const VivSlideViewport: React.FC = ({ studyInstanceUID, seriesInstanceUID, }) + dicomLoaderRef.current = dicomLoader const sources = await dicomLoader.getSources() if (cancelled) { return @@ -147,7 +164,12 @@ const VivSlideViewport: React.FC = ({ console.error(err) }, }) - setLayers([layer as unknown as Layer]) + + if (cancelled) { + return + } + + setBaseLayer(layer as unknown as Layer) slideRef.current = { worldW: sw, worldH: sh, @@ -184,9 +206,7 @@ const VivSlideViewport: React.FC = ({ ) } requestAnimationFrame(() => { - if (!cancelled) { - measureRef.current?.() - } + measureRef.current?.() }) } catch (err) { console.error(err) @@ -207,9 +227,72 @@ const VivSlideViewport: React.FC = ({ void run() return () => { cancelled = true + dicomLoaderRef.current = null } }, [client, studyInstanceUID, seriesInstanceUID, vivSettings]) + useEffect(() => { + if (!loadBulkAnnotations) { + setAnnLayers([]) + return + } + if (baseLayer === null) { + setAnnLayers([]) + return + } + const dicomLoader = dicomLoaderRef.current + if (dicomLoader === null) { + console.warn( + '[Viv bulk ANN] viewport: no DicomLoader ref — wait for slide to finish loading', + ) + return + } + + let cancelled = false + console.info('[Viv bulk ANN] viewport: loading overlays (image layer unchanged)…', { + studyInstanceUID, + seriesInstanceUID, + usesDedicatedAnnClient: + bulkAnnotationClient != null && bulkAnnotationClient !== client, + }) + + const run = async (): Promise => { + try { + const geometry = await dicomLoader.getBulkAnnotationGeometryContext() + const loaded = await loadBulkAnnotationDeckLayers({ + geometry, + studyInstanceUID, + imageSeriesInstanceUID: seriesInstanceUID, + annotationClient: bulkAnnotationClient ?? client, + fetchClient: client, + }) + if (!cancelled) { + setAnnLayers(loaded) + console.info('[Viv bulk ANN] viewport: deck overlay layers', { + count: loaded.length, + }) + } + } catch (e) { + console.warn('[Viv bulk ANN] viewport: overlay load failed', e) + if (!cancelled) { + setAnnLayers([]) + } + } + } + + void run() + return () => { + cancelled = true + } + }, [ + loadBulkAnnotations, + baseLayer, + bulkAnnotationClient, + client, + studyInstanceUID, + seriesInstanceUID, + ]) + const sp = slideRef.current const orthoZoomClamp = sp && !loading diff --git a/src/viv/dicomLoader.ts b/src/viv/dicomLoader.ts index e151802e..090436ac 100644 --- a/src/viv/dicomLoader.ts +++ b/src/viv/dicomLoader.ts @@ -241,6 +241,71 @@ export function isVivDicomTileNetworkCancellation(e: unknown): boolean { ) } +/** Same slide/map space as OpenLayers VolumeImageViewer (finest pyramid, affine). */ +export interface BulkAnnotationGeometryContext { + pyramid: dmv.metadata.VLWholeSlideMicroscopyImage[] + affine: number[][] + affineInverse: number[][] + /** OL map extent [minX, minY, maxX, maxY] for bulk-annotation viewport helpers. */ + extent: number[] +} + +function readVolumeImageViewerAffine( + viewer: dmv.viewer.VolumeImageViewer, +): number[][] { + const v = viewer as unknown as Record + const m = v[Symbol.for('affine')] + if (!Array.isArray(m)) { + throw new Error('VolumeImageViewer: affine transform not available') + } + return m as number[][] +} + +function readVolumeImageViewerAffineInverse( + viewer: dmv.viewer.VolumeImageViewer, +): number[][] { + const sym = Object.getOwnPropertySymbols(viewer).find( + (s) => s.description === 'affineInverse', + ) + if (sym === undefined) { + throw new Error('VolumeImageViewer: affineInverse symbol not found') + } + const v = viewer as unknown as Record + const m = v[sym] + if (!Array.isArray(m)) { + throw new Error('VolumeImageViewer: affine inverse not available') + } + return m as number[][] +} + +function readVolumeImageViewerPyramid(viewer: dmv.viewer.VolumeImageViewer): { + metadata: dmv.metadata.VLWholeSlideMicroscopyImage[] + extent: number[] +} { + const sym = Object.getOwnPropertySymbols(viewer).find( + (s) => s.description === 'pyramid', + ) + if (sym === undefined) { + throw new Error('VolumeImageViewer: pyramid symbol not found') + } + const raw = ( + viewer as unknown as Record + )[sym] + if ( + raw === null || + typeof raw !== 'object' || + !Array.isArray((raw as { metadata?: unknown }).metadata) || + !Array.isArray((raw as { extent?: unknown }).extent) + ) { + throw new Error('VolumeImageViewer: invalid pyramid object') + } + return { + metadata: (raw as { metadata: dmv.metadata.VLWholeSlideMicroscopyImage[] }) + .metadata, + extent: (raw as { extent: number[] }).extent, + } +} + function getOpticalPathsMap(viewer: dmv.viewer.VolumeImageViewer): { [key: string]: OpticalPathEntry } { @@ -740,6 +805,21 @@ export class DicomLoader { return insertSyntheticDyadicLevels(base) } + /** + * Pyramid metadata and affine transforms for Microscopy Bulk Simple Annotations, + * matching {@link dmv.viewer.VolumeImageViewer} / OpenLayers geometry space. + */ + async getBulkAnnotationGeometryContext(): Promise { + const viewer = await this._getViewer() + const { metadata, extent } = readVolumeImageViewerPyramid(viewer) + return { + pyramid: metadata, + affine: readVolumeImageViewerAffine(viewer), + affineInverse: readVolumeImageViewerAffineInverse(viewer), + extent, + } + } + /** * Deck.gl Tile2D uses a 2× geometric step between each integer tile z. DICOM pyramids often use ~2×, * but some (more common on certain 8-bit / RGB encodes) use 3×–4× or irregular factors — then Viv’s diff --git a/src/viv/loadBulkAnnotationLayers.ts b/src/viv/loadBulkAnnotationLayers.ts new file mode 100644 index 00000000..40bb2a08 --- /dev/null +++ b/src/viv/loadBulkAnnotationLayers.ts @@ -0,0 +1,647 @@ +// skipcq: JS-C1003 +import type { Layer, Position } from '@deck.gl/core' +import { PathLayer, ScatterplotLayer } from '@deck.gl/layers' +import * as dcmjs from 'dcmjs' +// skipcq: JS-C1003 +import dmvDefault from 'dicom-microscopy-viewer' +import * as dmvNamespace from 'dicom-microscopy-viewer' + +import type DicomWebManager from '../DicomWebManager' +import type { BulkAnnotationGeometryContext } from './dicomLoader' + +/** Console filter: `Viv bulk ANN` */ +const VIV_BULK = '[Viv bulk ANN]' + +type PathRow = { path: Position[]; closed: boolean } + +type FeatureBuilder = (opts: Record) => unknown + +/** API surface of DMV `bulkSimpleAnnotations` namespace (added in 0.48.21 bundle). */ +type BulkSimpleAnnotationsApi = { + getFeaturesFromBulkAnnotations: (opts: Record) => unknown[] + getPointFeature: FeatureBuilder + getPolygonFeature: FeatureBuilder + getRectangleFeature: FeatureBuilder + getEllipseFeature: FeatureBuilder + getCircleFeature: FeatureBuilder +} + +function isBulkSimpleAnnotationsApi(x: unknown): x is BulkSimpleAnnotationsApi { + if (x === null || typeof x !== 'object') { + return false + } + const o = x as BulkSimpleAnnotationsApi + return ( + typeof o.getPolygonFeature === 'function' && + typeof o.getFeaturesFromBulkAnnotations === 'function' + ) +} + +/** + * DMV publishes a UMD `dist/dicomMicroscopyViewer.bundle.min.js`. Webpack’s + * `import * as ns` often wraps `module.exports` as `ns.default`, while + * `import dmv from` resolves to the same object as Node’s `module.exports`. + * Prefer whichever binding actually attaches `bulkSimpleAnnotations` (0.48.21+). + */ +function dmvModule(): typeof dmvNamespace { + if (isBulkSimpleAnnotationsApi(dmvNamespace.bulkSimpleAnnotations)) { + return dmvNamespace + } + const fromDefault = dmvDefault as typeof dmvNamespace + if (isBulkSimpleAnnotationsApi(fromDefault.bulkSimpleAnnotations)) { + return fromDefault + } + return dmvNamespace +} + +const dmv = dmvModule() + +function resolveBulkSimpleAnnotationsApi(): BulkSimpleAnnotationsApi { + const ns = dmv.bulkSimpleAnnotations + if (isBulkSimpleAnnotationsApi(ns)) { + return ns + } + const root = dmv as unknown as Record + console.error(`${VIV_BULK} missing bulkSimpleAnnotations on resolved DMV module`, { + dmvTopKeys: Object.keys(root), + }) + const msg = + 'dicom-microscopy-viewer: no `bulkSimpleAnnotations` in the loaded bundle. ' + + 'Rebuild the linked package: `cd ../dicom-microscopy-viewer && bun run build`, then restart Slim dev.' + console.error(`${VIV_BULK} ${msg}`) + throw new Error(msg) +} + +function pickStr(obj: Record, ...keys: string[]): string | undefined { + for (const k of keys) { + const v = obj[k] + if (v != null && String(v).length > 0) { + return String(v) + } + } + return undefined +} + +/** First item of a Code Sequence (raw / naturalized metadata shapes vary by server). */ +function firstCodedSequenceItem(seq: unknown): Record | null { + if (seq == null) { + return null + } + const first = Array.isArray(seq) ? seq[0] : seq + if (first == null || typeof first !== 'object') { + return null + } + return first as Record +} + +/** + * Build {@link dcmjs.sr.coding.CodedConcept} when standard tags exist; otherwise + * pass the sequence item through like `viewer.js` so AnnotationGroup still + * matches the OpenLayers path (strict parsing had skipped every group for some + * QIDO / naturalized payloads). + */ +function annotationPropertyCodeFromSequence( + seq: unknown, +): dcmjs.sr.coding.CodedConcept { + const raw = firstCodedSequenceItem(seq) + if (raw == null) { + return new dcmjs.sr.coding.CodedConcept({ + value: '99SLIM', + meaning: 'Missing code sequence', + schemeDesignator: '99SLIM', + }) + } + const value = pickStr(raw, 'CodeValue', 'codeValue') + const meaning = pickStr(raw, 'CodeMeaning', 'codeMeaning') + const scheme = pickStr( + raw, + 'CodingSchemeDesignator', + 'codingSchemeDesignator', + ) + const version = pickStr( + raw, + 'CodingSchemeVersion', + 'codingSchemeVersion', + ) + if ( + value !== undefined && + meaning !== undefined && + scheme !== undefined + ) { + try { + return new dcmjs.sr.coding.CodedConcept({ + value, + meaning, + schemeDesignator: scheme, + schemeVersion: version, + }) + } catch { + /* use raw object below */ + } + } + return raw as unknown as dcmjs.sr.coding.CodedConcept +} + +/** + * OpenLayers pyramid extent uses flipped row axis: Y in [-(rows+1), -1] (see + * dicom-microscopy-viewer pyramid.js). Viv MultiscaleImageLayer / BitmapLayer + * use finest-level pixel space with y = 0 at the top row and y increasing down. + */ +function openLayersMapYToVivWorldY(mapY: number): number { + return -mapY - 1 +} + +function rgbFromLabItem( + item: { RecommendedDisplayCIELabValue?: number[] }, + fallback: [number, number, number], +): [number, number, number] { + const lab = item.RecommendedDisplayCIELabValue + if (Array.isArray(lab) && lab.length >= 3) { + try { + const rgb = dcmjs.data.Colors.dicomlab2RGB(lab) + return [ + Math.max(0, Math.min(255, Math.round(rgb[0] * 255))), + Math.max(0, Math.min(255, Math.round(rgb[1] * 255))), + Math.max(0, Math.min(255, Math.round(rgb[2] * 255))), + ] + } catch { + /* use fallback */ + } + } + return fallback +} + +function appendOlGeometry( + geom: unknown, + pathRows: PathRow[], + points: [number, number][], +): void { + if (geom === null || typeof geom !== 'object') { + return + } + const g = geom as { + getType?: () => string + getCoordinates?: () => unknown + getCenter?: () => number[] + getRadius?: () => number + } + const t = g.getType?.() + if (t === 'Point') { + const c = g.getCoordinates?.() as number[] + if (c?.length >= 2) { + points.push([c[0], openLayersMapYToVivWorldY(c[1])]) + } + return + } + if (t === 'LineString') { + const c = g.getCoordinates?.() as number[][] + if (c?.length) { + pathRows.push({ + path: c.map( + (p): Position => [p[0], openLayersMapYToVivWorldY(p[1])], + ), + closed: false, + }) + } + return + } + if (t === 'Polygon') { + const rings = g.getCoordinates?.() as number[][][] + if (rings) { + for (const ring of rings) { + pathRows.push({ + path: ring.map( + (p): Position => [p[0], openLayersMapYToVivWorldY(p[1])], + ), + closed: true, + }) + } + } + return + } + if (t === 'Circle') { + const center = g.getCenter?.() + const r = g.getRadius?.() + if ( + center != null && + center.length >= 2 && + typeof r === 'number' && + r > 0 + ) { + const n = 48 + const path: Position[] = [] + const cx = center[0] + const cy = center[1] + for (let i = 0; i <= n; i++) { + const a = (i / n) * Math.PI * 2 + path.push([ + cx + r * Math.cos(a), + openLayersMapYToVivWorldY(cy + r * Math.sin(a)), + ]) + } + pathRows.push({ path, closed: true }) + } + } +} + +function featuresToDeckLayers( + features: unknown[], + color: [number, number, number], + idPrefix: string, + /** When set, log if OL features did not translate to any deck layer. */ + logContext?: { groupUID: string }, +): Layer[] { + const pathRows: PathRow[] = [] + const points: [number, number][] = [] + let unknownGeom = 0 + for (const f of features) { + const feat = f as { getGeometry?: () => unknown } + const geom = feat.getGeometry?.() + if (geom == null || typeof geom !== 'object') { + unknownGeom++ + continue + } + const g = geom as { getType?: () => string } + const t = g.getType?.() + if ( + t !== 'Point' && + t !== 'LineString' && + t !== 'Polygon' && + t !== 'Circle' + ) { + unknownGeom++ + } + appendOlGeometry(geom, pathRows, points) + } + const layers: Layer[] = [] + const rgba: [number, number, number, number] = [ + color[0], + color[1], + color[2], + 220, + ] + if (pathRows.length > 0) { + layers.push( + new PathLayer({ + id: `${idPrefix}-paths`, + data: pathRows, + getPath: (d) => d.path, + getColor: () => rgba, + getWidth: () => 2, + widthUnits: 'pixels', + capRounded: true, + jointRounded: true, + }) as unknown as Layer, + ) + } + if (points.length > 0) { + layers.push( + new ScatterplotLayer<[number, number]>({ + id: `${idPrefix}-pts`, + data: points, + getPosition: (d) => d, + getFillColor: () => rgba, + getRadius: () => 4, + radiusUnits: 'pixels', + }) as unknown as Layer, + ) + } + if ( + logContext != null && + features.length > 0 && + layers.length === 0 + ) { + console.warn( + `${VIV_BULK} group ${logContext.groupUID}: ${features.length} OL feature(s) but 0 Deck layers`, + { + pathRowCandidates: pathRows.length, + pointCandidates: points.length, + skippedNoOrUnsupportedGeom: unknownGeom, + }, + ) + } + return layers +} + +function featureFnForGraphicType(graphicType: string): FeatureBuilder | null { + const b = resolveBulkSimpleAnnotationsApi() + switch (graphicType) { + case 'POINT': + return b.getPointFeature as FeatureBuilder + case 'POLYGON': + case 'POLYLINE': + return b.getPolygonFeature as FeatureBuilder + case 'RECTANGLE': + return b.getRectangleFeature as FeatureBuilder + case 'ELLIPSE': + return b.getEllipseFeature as FeatureBuilder + case 'CIRCLE': + return b.getCircleFeature as FeatureBuilder + default: + return null + } +} + +/** + * QIDO ANN series, load bulk simple annotation groups that reference + * `imageSeriesInstanceUID`, decode with DMV, return Deck.gl overlay layers. + */ +export async function loadBulkAnnotationDeckLayers(options: { + geometry: BulkAnnotationGeometryContext + studyInstanceUID: string + imageSeriesInstanceUID: string + /** Store used for ANN search + metadata (may differ from SM). */ + annotationClient: DicomWebManager + /** Bulk byte fetches must use the same client as VolumeImageViewer / SM tiles. */ + fetchClient: DicomWebManager +}): Promise { + const { + geometry, + studyInstanceUID, + imageSeriesInstanceUID, + annotationClient, + fetchClient, + } = options + + const { pyramid, affine, affineInverse, extent } = geometry + const refImage = pyramid[0] + if (refImage === undefined) { + console.warn(`${VIV_BULK} pyramid[0] missing — cannot align annotations`) + return [] + } + + const refMeta = refImage as { + SOPInstanceUID?: string + TotalPixelMatrixColumns?: number + TotalPixelMatrixRows?: number + } + console.info(`${VIV_BULK} start`, { + studyInstanceUID, + imageSeriesInstanceUID, + refSOPInstanceUID: refMeta.SOPInstanceUID, + finestColumns: refMeta.TotalPixelMatrixColumns, + finestRows: refMeta.TotalPixelMatrixRows, + extent, + }) + + let matched = await annotationClient.searchForSeries({ + studyInstanceUID, + queryParams: { + Modality: 'ANN', + }, + }) + if (matched === null || matched === undefined) { + matched = [] + } + + const annSeriesSummaries = matched.map((raw) => { + try { + const { dataset } = dmv.metadata.formatMetadata(raw) + const d = dataset as { + SeriesInstanceUID?: string + Modality?: string + } + return { + SeriesInstanceUID: d.SeriesInstanceUID, + Modality: d.Modality, + } + } catch { + return { SeriesInstanceUID: '(formatMetadata failed)', Modality: '?' } + } + }) + console.info(`${VIV_BULK} QIDO Modality=ANN → ${matched.length} series`, { + series: annSeriesSummaries, + }) + + if (matched.length === 0) { + console.warn( + `${VIV_BULK} no ANN series from this store for study — check store / QIDO / Modality`, + ) + return [] + } + + const viewMock = { + calculateExtent: (): number[] => [...extent], + } + + const allLayers: Layer[] = [] + + for (const s of matched) { + const { dataset } = dmv.metadata.formatMetadata(s) + const series = dataset as { SeriesInstanceUID: string } + let retrieved: object[] + try { + retrieved = await annotationClient.retrieveSeriesMetadata({ + studyInstanceUID, + seriesInstanceUID: series.SeriesInstanceUID, + }) + } catch (e) { + console.warn( + `${VIV_BULK} retrieveSeriesMetadata failed`, + { seriesInstanceUID: series.SeriesInstanceUID }, + e, + ) + continue + } + + console.info(`${VIV_BULK} retrieved`, { + annSeriesInstanceUID: series.SeriesInstanceUID, + numInstances: retrieved.length, + }) + + const annotations = retrieved.map( + (metadata) => + new dmv.metadata.MicroscopyBulkSimpleAnnotations({ + metadata: metadata as object, + }), + ) + + for (const ann of annotations) { + const refSer = ann.ReferencedSeriesSequence?.[0] + const refImg = ann.ReferencedImageSequence?.[0] + if (refSer === undefined || refImg === undefined) { + console.info(`${VIV_BULK} skip instance: missing ReferencedSeries/Image`, { + annSOP: ann.SOPInstanceUID, + }) + continue + } + if (refSer.SeriesInstanceUID !== imageSeriesInstanceUID) { + console.info(`${VIV_BULK} skip instance: references other SM series`, { + annSOP: ann.SOPInstanceUID, + referencedSeries: refSer.SeriesInstanceUID, + activeSlideSeries: imageSeriesInstanceUID, + }) + continue + } + + console.info(`${VIV_BULK} instance matches slide`, { + annSOP: ann.SOPInstanceUID, + annSeries: ann.SeriesInstanceUID, + groups: (ann.AnnotationGroupSequence ?? []).length, + }) + + const bulkRoot = ann.bulkdataReferences as { + AnnotationGroupSequence?: object[] | null + } + + for (const item of ann.AnnotationGroupSequence ?? []) { + const annotationGroupUID = item.AnnotationGroupUID as string + const color = rgbFromLabItem(item, [220, 60, 60]) + const annotationGroupIndex = Number(item.AnnotationGroupNumber) - 1 + const metadataItem = + ann.AnnotationGroupSequence[annotationGroupIndex] ?? item + + const propertyCategory = annotationPropertyCodeFromSequence( + item.AnnotationPropertyCategoryCodeSequence, + ) + const propertyType = annotationPropertyCodeFromSequence( + item.AnnotationPropertyTypeCodeSequence, + ) + + let bulkdataItem: object | undefined + if (bulkRoot.AnnotationGroupSequence != null) { + bulkdataItem = bulkRoot.AnnotationGroupSequence[ + annotationGroupIndex + ] as object + } + + const annotationGroupWrapper = { + annotationGroup: new dmv.annotation.AnnotationGroup({ + uid: annotationGroupUID, + number: item.AnnotationGroupNumber, + label: item.AnnotationGroupLabel, + algorithmType: item.AnnotationGroupGenerationType ?? '', + algorithmName: + item.AnnotationGroupAlgorithmIdentificationSequence?.[0] + ?.AlgorithmName ?? '', + propertyCategory, + propertyType, + studyInstanceUID: ann.StudyInstanceUID, + seriesInstanceUID: ann.SeriesInstanceUID, + sopInstanceUIDs: [ann.SOPInstanceUID], + referencedSeriesInstanceUID: refSer.SeriesInstanceUID, + referencedSOPInstanceUID: refImg.ReferencedSOPInstanceUID, + }), + style: { opacity: 1, color }, + defaultStyle: { opacity: 1, color }, + metadata: ann, + } + + const numberOfAnnotations = Number(metadataItem.NumberOfAnnotations) + const graphicType = metadataItem.GraphicType as string + const featureFn = featureFnForGraphicType(graphicType) + if (featureFn === null || numberOfAnnotations <= 0) { + console.info(`${VIV_BULK} skip group: graphic type / count`, { + annotationGroupUID, + graphicType, + numberOfAnnotations, + hasFeatureFn: featureFn != null, + }) + continue + } + + const coordinateDimensionality = + dmv.annotation.getCoordinateDimensionality( + metadataItem, + ann.AnnotationCoordinateType, + ) + if ( + coordinateDimensionality === 3 && + refImage.FrameOfReferenceUID !== ann.FrameOfReferenceUID + ) { + console.warn( + `[Viv] skip bulk group ${annotationGroupUID}: frame of reference mismatch`, + ) + continue + } + + const commonZCoordinate = + dmv.annotation.getCommonZCoordinate(metadataItem) + + let graphicData: Int32Array | Float32Array + let graphicIndex: Int32Array | null + try { + ;[graphicData, graphicIndex] = await Promise.all([ + dmv.annotation.fetchGraphicData({ + metadataItem: metadataItem as object, + bulkdataItem, + annotationGroupIndex, + metadata: ann as unknown as object, + client: fetchClient, + }), + dmv.annotation.fetchGraphicIndex({ + metadataItem: metadataItem as object, + bulkdataItem, + annotationGroupIndex, + metadata: ann as unknown as object, + client: fetchClient, + }), + ]) + } catch (e) { + console.warn( + `${VIV_BULK} fetchGraphicData/Index failed`, + { annotationGroupUID, graphicType }, + e, + ) + continue + } + + if ( + (graphicType === 'POLYGON' || graphicType === 'POLYLINE') && + (graphicIndex === null || graphicIndex === undefined) + ) { + console.warn( + `[Viv] skip bulk group ${annotationGroupUID}: missing LongPrimitivePointIndexList`, + ) + continue + } + + let features: unknown[] + try { + features = resolveBulkSimpleAnnotationsApi().getFeaturesFromBulkAnnotations({ + graphicType, + graphicData, + graphicIndex, + measurements: [], + commonZCoordinate, + coordinateDimensionality, + numberOfAnnotations, + annotationGroupUID, + annotationGroup: annotationGroupWrapper, + pyramid, + affine, + affineInverse, + view: viewMock, + featureFunction: featureFn, + /* Load all features; Deck + Slim can add viewport filtering later. */ + isHighResolution: () => false, + }) + } catch (e) { + console.warn( + `${VIV_BULK} getFeaturesFromBulkAnnotations failed`, + { annotationGroupUID, graphicType }, + e, + ) + continue + } + + const deckSlices = featuresToDeckLayers( + features, + color, + `viv-bulk-${annotationGroupUID}`, + { groupUID: annotationGroupUID }, + ) + console.info(`${VIV_BULK} group → deck`, { + annotationGroupUID, + graphicType, + olFeatures: features.length, + deckLayers: deckSlices.length, + }) + allLayers.push(...deckSlices) + } + } + } + + console.info(`${VIV_BULK} done: total deck layers`, allLayers.length) + return allLayers +} diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 540d47c9..8987e480 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -592,6 +592,12 @@ declare module 'dicom-microscopy-viewer' { } export interface MicroscopyBulkSimpleAnnotations extends SOPClass { + ReferencedSeriesSequence?: Array<{ + SeriesInstanceUID: string + }> + ReferencedImageSequence?: Array<{ + ReferencedSOPInstanceUID: string + }> AnnotationCoordinateType: string // Frame of Reference module FrameOfReferenceUID: string @@ -622,6 +628,11 @@ declare module 'dicom-microscopy-viewer' { CodingSchemeDesignator: string CodingSchemeVersion?: string }> + RecommendedDisplayCIELabValue?: number[] + AnnotationGroupGenerationType?: string + AnnotationGroupAlgorithmIdentificationSequence?: Array<{ + AlgorithmName: string + }> GraphicType: string NumberOfAnnotations: number CommonZCoordinateValue?: number @@ -757,6 +768,8 @@ declare module 'dicom-microscopy-viewer' { studyInstanceUID: string seriesInstanceUID: string sopInstanceUIDs: string[] + referencedSeriesInstanceUID: string + referencedSOPInstanceUID: string } export class AnnotationGroup { @@ -771,8 +784,62 @@ declare module 'dicom-microscopy-viewer' { get studyInstanceUID (): string get seriesInstanceUID (): string get sopInstanceUIDs (): string[] + get referencedSeriesInstanceUID (): string + get referencedSOPInstanceUID (): string + } + + export function fetchGraphicData (options: { + metadataItem: object + bulkdataItem: object | null | undefined + annotationGroupIndex: number + metadata: object + client: dwc.api.DICOMwebClient + }): Promise + + export function fetchGraphicIndex (options: { + metadataItem: object + bulkdataItem: object | null | undefined + annotationGroupIndex: number + metadata: object + client: dwc.api.DICOMwebClient + }): Promise + + export function getCommonZCoordinate (metadataItem: object): number + + export function getCoordinateDimensionality ( + metadataItem: object, + annotationCoordinateType: string + ): number + + } + + declare namespace bulkSimpleAnnotations { + + export function getFeaturesFromBulkAnnotations ( + options: Record + ): unknown[] + + export function getPointFeature (options: Record): unknown + + export function getPolygonFeature (options: Record): unknown + + export function getCircleFeature (options: Record): unknown + + export function getEllipseFeature (options: Record): unknown + + export function getRectangleFeature (options: Record): unknown + + export function getViewportBoundingBox (options: Record): { + topLeft: number[] + bottomRight: number[] } + export function isCoordinateInsideBoundingBox ( + coordinate: number[], + topLeft: number[], + bottomRight: number[] + ): boolean + } declare namespace mapping { From 476e6b12dfaf75e497de77959eac42de916cef25 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Tue, 7 Apr 2026 21:30:26 -0300 Subject: [PATCH 07/57] Working: Bulk ann (polygons) --- src/components/CaseViewer.tsx | 261 +++++++++++++++++-- src/viv/VivSlideViewport.tsx | 221 ++++++++++++++-- src/viv/loadBulkAnnotationLayers.ts | 388 +++++++++++++++++++--------- 3 files changed, 704 insertions(+), 166 deletions(-) diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index 3545c288..a8e3c626 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -1,7 +1,9 @@ -import { Layout, Menu, Switch } from 'antd' +import { Layout, Menu, Select } from 'antd' // skipcq: JS-C1003 import * as dcmjs from 'dcmjs' -import { useEffect, useState, type ReactNode } from 'react' +// skipcq: JS-C1003 +import type * as dmv from 'dicom-microscopy-viewer' +import { type ReactNode, useCallback, useEffect, useState } from 'react' import { Route, Routes, useLocation, useParams } from 'react-router-dom' import type { AnnotationSettings, VivSettings } from '../AppConfig' @@ -10,8 +12,11 @@ import type DicomWebManager from '../DicomWebManager' import type { Slide } from '../data/slides' import { StorageClasses } from '../data/uids' import { useSlides } from '../hooks/useSlides' +import DicomMetadataStore from '../services/DICOMMetadataStore' import { type RouteComponentProps, withRouter } from '../utils/router' +import type { VivBulkAnnotationCatalogPayload } from '../viv/loadBulkAnnotationLayers' import VivSlideViewport from '../viv/VivSlideViewport' +import AnnotationGroupList from './AnnotationGroupList' import ClinicalTrial from './ClinicalTrial' import Patient from './Patient' import SlideList from './SlideList' @@ -133,7 +138,112 @@ function ParametrizedSlideViewer({ ) const [derivedDataset, setDerivedDataset] = useState(null) - const [loadVivBulkAnnotations, setLoadVivBulkAnnotations] = useState(false) + const [vivBulkCatalog, setVivBulkCatalog] = + useState(null) + const [vivVisibleAnnotationGroupUIDs, setVivVisibleAnnotationGroupUIDs] = + useState>(new Set()) + const [vivAnnotationGroupStyles, setVivAnnotationGroupStyles] = useState< + Record + >({}) + const [vivAnnGroupSeriesSelection, setVivAnnGroupSeriesSelection] = + useState('all') + + const getVivSeriesDescription = (seriesInstanceUID: string): string => { + const study = DicomMetadataStore.getStudy(studyInstanceUID) + if (study?.series != null && study !== undefined) { + const series = study.series.find( + (s) => s.SeriesInstanceUID === seriesInstanceUID, + ) + if ( + series?.SeriesDescription !== undefined && + series.SeriesDescription !== '' + ) { + return series.SeriesDescription + } + } + return `Series ${seriesInstanceUID.slice(0, 8)}…` + } + + const handleVivBulkCatalogChange = useCallback( + (c: VivBulkAnnotationCatalogPayload | null) => { + setVivBulkCatalog(c) + if (c != null && c.annotationGroups.length > 0) { + setVivAnnGroupSeriesSelection('all') + setVivVisibleAnnotationGroupUIDs(new Set()) + setVivAnnotationGroupStyles({ ...c.defaultStylesByGroupUID }) + } else if (c != null) { + setVivVisibleAnnotationGroupUIDs(new Set()) + setVivAnnotationGroupStyles({}) + } else { + setVivVisibleAnnotationGroupUIDs(new Set()) + setVivAnnotationGroupStyles({}) + setVivAnnGroupSeriesSelection('all') + } + }, + [], + ) + + const handleVivAnnotationGroupVisibilityChange = useCallback( + ({ + annotationGroupUID, + isVisible, + }: { + annotationGroupUID: string + isVisible: boolean + }) => { + setVivVisibleAnnotationGroupUIDs((prev) => { + const next = new Set(prev) + if (isVisible) { + next.add(annotationGroupUID) + } else { + next.delete(annotationGroupUID) + } + return next + }) + }, + [], + ) + + const handleVivAnnotationGroupStyleChange = useCallback( + ({ + uid, + styleOptions, + }: { + uid: string + styleOptions: { + opacity?: number + color?: number[] + measurement?: dcmjs.sr.coding.CodedConcept + limitValues?: number[] + } + }) => { + setVivAnnotationGroupStyles((prev) => { + const base = + prev[uid] ?? + vivBulkCatalog?.defaultStylesByGroupUID[uid] ?? { + opacity: 1, + color: [220, 60, 60], + } + return { + ...prev, + [uid]: { + opacity: styleOptions.opacity ?? base.opacity, + color: styleOptions.color ?? base.color, + }, + } + }) + }, + [vivBulkCatalog], + ) + + const handleVivAnnotationGroupSeriesSelect = useCallback((value: string) => { + setVivVisibleAnnotationGroupUIDs(new Set()) + setVivAnnGroupSeriesSelection(value) + }, []) + + const handleVivAnnotationGroupClick = useCallback((_uid: string) => { + /* Viv preview: no VolumeImageViewer.zoomToROI equivalent yet */ + }, []) useEffect(() => { const currentSlideMatchesSeries = @@ -230,11 +340,129 @@ function ParametrizedSlideViewer({ const bulkAnnotationClient = clients[StorageClasses.MICROSCOPY_BULK_SIMPLE_ANNOTATION] ?? microscopyClient + + let vivAnnotationGroupListSection: ReactNode = null + if ( + vivBulkCatalog != null && + vivBulkCatalog.annotationGroups.length > 0 + ) { + const annotationGroups = vivBulkCatalog.annotationGroups + const annotationGroupsBySeries: { + [seriesInstanceUID: string]: dmv.annotation.AnnotationGroup[] + } = {} + for (const ag of annotationGroups) { + const seriesUID = ag.seriesInstanceUID + if (!(seriesUID in annotationGroupsBySeries)) { + annotationGroupsBySeries[seriesUID] = [] + } + annotationGroupsBySeries[seriesUID]?.push(ag) + } + const dropdownOptions = [ + { value: 'all', label: 'All' }, + ...Object.keys(annotationGroupsBySeries).map((seriesUID) => ({ + value: seriesUID, + label: `${getVivSeriesDescription(seriesUID)} (${annotationGroupsBySeries[seriesUID]?.length ?? 0} groups)`, + })), + ] + const selectedSeriesAnnotationGroups = + vivAnnGroupSeriesSelection === 'all' + ? annotationGroups + : (annotationGroupsBySeries[vivAnnGroupSeriesSelection] ?? []) + + vivAnnotationGroupListSection = ( + <> +
+