diff --git a/packages/core/examples/displaySets/index.ts b/packages/core/examples/displaySets/index.ts new file mode 100644 index 0000000000..44227dc388 --- /dev/null +++ b/packages/core/examples/displaySets/index.ts @@ -0,0 +1,391 @@ +import type { Types } from '@cornerstonejs/core'; +import { RenderingEngine, Enums, utilities } from '@cornerstonejs/core'; +import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; +import { api } from 'dicomweb-client'; +import { + initDemo, + setTitleAndDescription, + createImageIdsAndCacheMetaData, + splitDisplaySetsFromImageIds, + setCtTransferFunctionForVolumeActor, + getLocalUrl, +} from '../../../../utils/demo/helpers'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { ViewportType } = Enums; +const { wadors } = dicomImageLoader; + +const renderingEngineId = 'displaySetsRenderingEngine'; +const wadoRsRoot = + getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb'; + +type DisplaySet = ReturnType[number]; + +// Maps a display set's viewport-type hint to the cornerstone viewport type to +// enable for it. +const HINT_TO_VIEWPORT_TYPE: Record = { + stack: ViewportType.STACK, + volume: ViewportType.ORTHOGRAPHIC, + volume3d: ViewportType.VOLUME_3D, + video: ViewportType.VIDEO, + wholeslide: ViewportType.WHOLE_SLIDE, + ecg: ViewportType.ECG, +}; + +const BACKGROUND_BY_HINT: Record = { + stack: [0.1, 0.1, 0.1], + volume: [0.1, 0.1, 0.1], + volume3d: [0, 0, 0], + video: [0, 0.1, 0], + wholeslide: [0, 0.1, 0], + ecg: [0.1, 0, 0.1], +}; + +setTitleAndDescription( + 'Display Sets — one viewport per display set', + 'Each source series is split into display sets, and every display set gets ' + + 'its own viewport with the associated information to its right (so the ' + + 'mixed US video series shows two viewports - one for the still images and ' + + 'one for the video). The per-display-set dropdown switches that display ' + + 'set between its allowed viewport types; only the volumetric (CT) series ' + + 'permits more than one. Each viewport is driven by viewport.setDisplaySets().' +); + +const content = document.getElementById('content'); + +// A vertical stack of rows; each row is [viewport | info + type dropdown]. +const rowsContainer = document.createElement('div'); +rowsContainer.style.display = 'flex'; +rowsContainer.style.flexDirection = 'column'; +rowsContainer.style.gap = '16px'; +content.appendChild(rowsContainer); + +// ======== Source series (one or more display sets each) ======== // + +type SourceSeries = { + label: string; + StudyInstanceUID: string; + SeriesInstanceUID: string; + /** WSI needs a DICOMweb client and non-converted multiframe metadata. */ + isWsi?: boolean; +}; + +const SOURCE_SERIES: SourceSeries[] = [ + { + label: 'US — mixed still images + video', + StudyInstanceUID: '2.25.96975534054447904995905761963464388233', + SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', + }, + { + label: 'CT — volumetric', + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + }, + { + label: 'ECG — 12-lead waveform', + StudyInstanceUID: '1.3.76.13.65829.2.20130125082826.1072139.2', + SeriesInstanceUID: '1.3.6.1.4.1.20029.40.20130125105919.5407.1', + }, + { + label: 'SM — whole slide microscopy', + StudyInstanceUID: '2.25.269859997690759739055099378767846712697', + SeriesInstanceUID: '2.25.274641717059635090989922952756233538416', + isWsi: true, + }, +]; + +// ======== Per-display-set row state ======== // + +type Row = { + displaySetId: string; + viewportId: string; + displaySet: DisplaySet; + /** The currently selected viewport-type hint. */ + hint: string; + viewportElement: HTMLDivElement; + detailsElement: HTMLDivElement; + /** DICOMweb client, only needed for whole-slide display sets. */ + client?: unknown; +}; + +let renderingEngine: RenderingEngine; + +function instanceField(displaySet: DisplaySet, key: string): string { + const instance = displaySet.instances[0] as + | Record + | undefined; + const value = instance?.[key]; + return value === undefined || value === null ? '—' : String(value); +} + +function registerDisplaySetData(row: Row) { + const { displaySetId, displaySet, hint, client } = row; + const imageIds = [...displaySet.imageIds]; + const provider = utilities.genericViewportDisplaySetMetadataProvider; + + switch (hint) { + case 'video': + provider.add(displaySetId, { kind: 'video', sourceDataId: imageIds[0] }); + break; + case 'ecg': + provider.add(displaySetId, { kind: 'ecg', sourceDataId: imageIds[0] }); + break; + case 'wholeslide': + provider.add(displaySetId, { + imageIds, + kind: 'wsi', + options: { webClient: client }, + }); + break; + case 'stack': + provider.add(displaySetId, { + imageIds, + kind: 'planar', + initialImageIdIndex: Math.floor(imageIds.length / 2), + }); + break; + default: + // volume / volume3d + provider.add(displaySetId, { + imageIds, + volumeId: `cornerstoneStreamingImageVolume:${displaySetId}`, + }); + } +} + +function renderDetails(row: Row, viewport: Types.IViewport, status: string) { + const ds = row.displaySet; + const recorded = viewport + .getDisplaySets() + .map((entry) => entry.displaySetId) + .join(', '); + + const lines: Array<[string, string]> = [ + ['Series', instanceField(ds, 'SeriesDescription')], + ['Modality', instanceField(ds, 'Modality')], + ['SOP class', instanceField(ds, 'SOPClassUID')], + ['Instances', String(ds.instances.length)], + ['Image ids', String(ds.imageIds.length)], + ['Preferred', ds.preferredViewportType], + ['Allowed', ds.viewportTypes.join(', ')], + ['Mounted as', row.hint], + ['getDisplaySets()', `[${recorded}]`], + ]; + + row.detailsElement.replaceChildren(); + for (const [label, value] of lines) { + const line = document.createElement('div'); + const strong = document.createElement('strong'); + strong.textContent = `${label}:`; + line.appendChild(strong); + line.append(` ${value}`); + row.detailsElement.appendChild(line); + } + if (status) { + const statusLine = document.createElement('div'); + statusLine.style.color = '#f1c40f'; + statusLine.innerText = status; + row.detailsElement.appendChild(statusLine); + } +} + +async function mountRow(row: Row) { + const viewportType = HINT_TO_VIEWPORT_TYPE[row.hint] ?? ViewportType.STACK; + + try { + registerDisplaySetData(row); + + // Each viewport type needs its own viewport class, so (re)create it. + if (renderingEngine.getViewport(row.viewportId)) { + renderingEngine.disableElement(row.viewportId); + } + renderingEngine.enableElement({ + viewportId: row.viewportId, + type: viewportType, + element: row.viewportElement, + defaultOptions: { + background: BACKGROUND_BY_HINT[row.hint] ?? [0.1, 0.1, 0.1], + }, + }); + + const viewport = renderingEngine.getViewport(row.viewportId); + + const options = + row.hint === 'volume' + ? { callback: setCtTransferFunctionForVolumeActor } + : undefined; + + // The unified entry point: resolves the displaySetId through the registry, + // loads the type-specific data, and records the mounted display set. + await viewport.setDisplaySets({ displaySetId: row.displaySetId, options }); + + if (row.hint === 'volume3d') { + // 3D needs a preset to be visible; setDisplaySets already loaded the volume. + (viewport as Types.IVolumeViewport).setProperties({ preset: 'CT-Bone' }); + } + if (row.hint === 'video') { + (viewport as Types.IVideoViewport).play(); + } + + viewport.render(); + renderDetails(row, viewport, ''); + } catch (err) { + const viewport = renderingEngine.getViewport(row.viewportId); + const message = err instanceof Error ? err.message : String(err); + if (viewport) { + renderDetails(row, viewport, `Error: ${message}`); + } + console.error(`[displaySets] Failed to mount ${row.displaySetId}:`, err); + } +} + +function buildRow( + displaySet: DisplaySet, + client: unknown, + seriesIndex: number, + dsIndex: number +): Row { + // The viewport/registry `displaySetId` is the display set's instance UID; the + // split rules keep it unique per series (e.g. the DWI mixed-b-value split). + const displaySetId = displaySet.displaySetId; + const viewportId = `displaySets-vp-${seriesIndex}-${dsIndex}`; + + const rowElement = document.createElement('div'); + rowElement.style.display = 'flex'; + rowElement.style.gap = '12px'; + rowElement.style.alignItems = 'flex-start'; + + const viewportElement = document.createElement('div'); + viewportElement.id = viewportId; + viewportElement.style.width = '320px'; + viewportElement.style.height = '320px'; + viewportElement.style.flex = '0 0 auto'; + viewportElement.oncontextmenu = (e) => e.preventDefault(); + + const infoElement = document.createElement('div'); + infoElement.style.fontFamily = 'monospace'; + infoElement.style.fontSize = '12px'; + + const detailsElement = document.createElement('div'); + infoElement.appendChild(detailsElement); + + const row: Row = { + displaySetId, + viewportId, + displaySet, + hint: displaySet.preferredViewportType, + viewportElement, + detailsElement, + client, + }; + + // Per-display-set dropdown to switch among the allowed viewport types. + const selectLabel = document.createElement('label'); + selectLabel.style.display = 'block'; + selectLabel.style.marginTop = '8px'; + selectLabel.innerText = 'Viewport type: '; + + const select = document.createElement('select'); + for (const hint of displaySet.viewportTypes) { + const option = document.createElement('option'); + option.value = hint; + option.text = hint; + option.selected = hint === displaySet.preferredViewportType; + select.appendChild(option); + } + select.disabled = displaySet.viewportTypes.length <= 1; + select.onchange = () => { + row.hint = select.value; + mountRow(row); + }; + selectLabel.appendChild(select); + infoElement.appendChild(selectLabel); + + rowElement.appendChild(viewportElement); + rowElement.appendChild(infoElement); + rowsContainer.appendChild(rowElement); + + return row; +} + +async function loadSeries(series: SourceSeries) { + let client: unknown; + let imageIds: string[]; + + if (series.isWsi) { + const webClient = new api.DICOMwebClient({ url: wadoRsRoot }); + imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: series.StudyInstanceUID, + SeriesInstanceUID: series.SeriesInstanceUID, + client: webClient, + wadoRsRoot, + convertMultiframe: false, + }); + webClient.getDICOMwebMetadata = (imageId) => + wadors.metaDataManager.get(imageId); + client = webClient; + } else { + imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: series.StudyInstanceUID, + SeriesInstanceUID: series.SeriesInstanceUID, + wadoRsRoot, + }); + } + + const displaySets = splitDisplaySetsFromImageIds(imageIds); + return { displaySets, client }; +} + +async function run() { + await initDemo(); + + renderingEngine = new RenderingEngine(renderingEngineId); + + for (let seriesIndex = 0; seriesIndex < SOURCE_SERIES.length; seriesIndex++) { + const series = SOURCE_SERIES[seriesIndex]; + + // Section header so the display sets are grouped by their source series. + const header = document.createElement('h3'); + header.innerText = series.label; + header.style.margin = '4px 0'; + rowsContainer.appendChild(header); + + try { + const { displaySets, client } = await loadSeries(series); + + if (!displaySets.length) { + const empty = document.createElement('div'); + empty.innerText = 'No display sets found in series.'; + rowsContainer.appendChild(empty); + continue; + } + + for (let dsIndex = 0; dsIndex < displaySets.length; dsIndex++) { + const row = buildRow( + displaySets[dsIndex], + client, + seriesIndex, + dsIndex + ); + // Mount sequentially so heavy loads (volume/WSI) don't all contend. + await mountRow(row); + } + } catch (err) { + const errorEl = document.createElement('div'); + errorEl.style.color = '#e74c3c'; + errorEl.innerText = `Failed to load series: ${ + err instanceof Error ? err.message : String(err) + }`; + rowsContainer.appendChild(errorEl); + console.error(`[displaySets] Failed to load ${series.label}:`, err); + } + } +} + +run(); diff --git a/packages/core/examples/dynamicVolume/index.ts b/packages/core/examples/dynamicVolume/index.ts index 659f9cfef8..85c7dc272b 100644 --- a/packages/core/examples/dynamicVolume/index.ts +++ b/packages/core/examples/dynamicVolume/index.ts @@ -8,6 +8,7 @@ import { import { initDemo, createImageIdsAndCacheMetaData, + get4DVolumeImageIds, setTitleAndDescription, addSliderToToolbar, addDropdownToToolbar, @@ -82,11 +83,12 @@ async function run() { // Init Cornerstone and related libraries await initDemo(); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const seriesImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '2.25.79767489559005369769092179787138169587', SeriesInstanceUID: '2.25.87977716979310885152986847054790859463', wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); + const imageIds = get4DVolumeImageIds(seriesImageIds); // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); diff --git a/packages/core/examples/genericEcg/index.ts b/packages/core/examples/genericEcg/index.ts index 19831d1ad2..cd75c56c94 100644 --- a/packages/core/examples/genericEcg/index.ts +++ b/packages/core/examples/genericEcg/index.ts @@ -118,7 +118,7 @@ async function run() { const viewport = renderingEngine.getViewport(viewportId); - utilities.genericViewportDataSetMetadataProvider.add(ecgDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(ecgDataId, { kind: 'ecg', sourceDataId: ecgImageId, }); diff --git a/packages/core/examples/genericMultiVolumeAPI/index.ts b/packages/core/examples/genericMultiVolumeAPI/index.ts index 74dd3279d1..0d8536c493 100644 --- a/packages/core/examples/genericMultiVolumeAPI/index.ts +++ b/packages/core/examples/genericMultiVolumeAPI/index.ts @@ -292,13 +292,13 @@ async function run() { addColormapEventListener(); - utilities.genericViewportDataSetMetadataProvider.add(ctDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(ctDataId, { imageIds: ctImageIds, kind: 'planar', initialImageIdIndex: Math.floor(ctImageIds.length / 2), volumeId: ctVolumeId, }); - utilities.genericViewportDataSetMetadataProvider.add(ptDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(ptDataId, { imageIds: ptImageIds, kind: 'planar', initialImageIdIndex: Math.floor(ptImageIds.length / 2), diff --git a/packages/core/examples/genericStackAPI/index.ts b/packages/core/examples/genericStackAPI/index.ts index 0aaaecea01..edcfd913ec 100644 --- a/packages/core/examples/genericStackAPI/index.ts +++ b/packages/core/examples/genericStackAPI/index.ts @@ -277,7 +277,7 @@ async function run() { const viewport = renderingEngine.getViewport(viewportId); const stack = [imageIds[0], imageIds[1], imageIds[2]]; - utilities.genericViewportDataSetMetadataProvider.add(stackDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { imageIds: stack, kind: 'planar', initialImageIdIndex: 0, diff --git a/packages/core/examples/genericStackPosition/index.ts b/packages/core/examples/genericStackPosition/index.ts index 4a70e9d54c..88dc43392a 100644 --- a/packages/core/examples/genericStackPosition/index.ts +++ b/packages/core/examples/genericStackPosition/index.ts @@ -269,7 +269,7 @@ async function run() { viewport = renderingEngine.getViewport(viewportId); - utilities.genericViewportDataSetMetadataProvider.add(stackDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { imageIds, kind: 'planar', initialImageIdIndex: 0, diff --git a/packages/core/examples/genericVideo/index.ts b/packages/core/examples/genericVideo/index.ts index 87e9d86a20..8fa4e08efe 100644 --- a/packages/core/examples/genericVideo/index.ts +++ b/packages/core/examples/genericVideo/index.ts @@ -173,7 +173,7 @@ async function run() { const viewport = renderingEngine.getViewport(viewportId); - utilities.genericViewportDataSetMetadataProvider.add(videoDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(videoDataId, { kind: 'video', sourceDataId: videoId, }); diff --git a/packages/core/examples/genericViewportScale/index.ts b/packages/core/examples/genericViewportScale/index.ts index c9b1ced6d9..00d21adc40 100644 --- a/packages/core/examples/genericViewportScale/index.ts +++ b/packages/core/examples/genericViewportScale/index.ts @@ -422,12 +422,12 @@ async function run() { const stackViewport = getViewport(stackViewportId); const volumeViewport = getViewport(volumeViewportId); - utilities.genericViewportDataSetMetadataProvider.add(stackDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { imageIds, kind: 'planar', initialImageIdIndex: middleImageIndex, }); - utilities.genericViewportDataSetMetadataProvider.add(volumeDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(volumeDataId, { imageIds, kind: 'planar', initialImageIdIndex: middleImageIndex, diff --git a/packages/core/examples/genericWsi/index.ts b/packages/core/examples/genericWsi/index.ts index 01859960b6..0116f82f02 100644 --- a/packages/core/examples/genericWsi/index.ts +++ b/packages/core/examples/genericWsi/index.ts @@ -104,7 +104,7 @@ async function run() { client.getDICOMwebMetadata = (imageId) => wadors.metaDataManager.get(imageId); - utilities.genericViewportDataSetMetadataProvider.add(wsiDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(wsiDataId, { imageIds, kind: 'wsi', options: { webClient: client }, diff --git a/packages/core/examples/video/index.ts b/packages/core/examples/video/index.ts index cf40ec2c0a..d7d8f3c2ce 100644 --- a/packages/core/examples/video/index.ts +++ b/packages/core/examples/video/index.ts @@ -8,8 +8,9 @@ import { initDemo, setTitleAndDescription, addButtonToToolbar, - createImageIdsAndCacheMetaData, + createDisplaySets, getLocalUrl, + getViewportTypeForDisplaySet, } from '../../../../utils/demo/helpers'; // This is for debugging purposes @@ -17,7 +18,7 @@ console.warn( 'Click on index.ts to open source code for this example --------->' ); -const { ViewportType, Events } = Enums; +const { Events } = Enums; // ======== Constants ======= // const renderingEngineId = 'myRenderingEngine'; const viewportId = 'videoViewport'; @@ -108,27 +109,47 @@ async function run() { // Init Cornerstone and related libraries await initDemo(); - // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + // Fetch the series metadata and split it into display sets using the default + // split rules. + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find((it) => - it.includes('2.25.179478223177027022014772769075050874231') + if (!displaySets.length) { + throw new Error('No display set found in series'); + } + + // This is a mixed series (single-frame secondary captures + one multi-frame + // video), so pick the display set whose split rule flagged it as video rather + // than blindly taking the first one. + const displaySet = displaySets.find( + (ds) => getViewportTypeForDisplaySet(ds) === Enums.ViewportType.VIDEO ); + // This example only supports video series, so bail clearly if the series did + // not contain a video display set. + if (!displaySet) { + const resolved = displaySets + .map((ds) => ds.preferredViewportType) + .join(', '); + throw new Error( + `No video display set found in series (resolved types: ${resolved}). ` + + 'This example only supports video series.' + ); + } + + const viewportType = Enums.ViewportType.VIDEO; + // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); - // Create a stack viewport - + // Create the viewport using the display set's preferred viewport type. const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: viewportType, element, defaultOptions: { background: [0.2, 0, 0.2] as Types.Point3, @@ -137,13 +158,15 @@ async function run() { renderingEngine.enableElement(viewportInput); - // Get the stack viewport that was created + // Get the viewport that was created const viewport = renderingEngine.getViewport( viewportId ) as Types.IVideoViewport; - // Set the video on the viewport - await viewport.setDisplaySets({ displaySetId: videoId }); + // Drive the viewport from the display set (displaySetId is the source imageId). + await viewport.setDisplaySets({ + displaySetId: displaySet.instances[0].imageId, + }); // Set the VOI of the stack // viewport.setProperties({ voiRange: ctVoiRange }); diff --git a/packages/core/examples/viewportProjection/index.ts b/packages/core/examples/viewportProjection/index.ts index f0ec84738c..55214d221c 100644 --- a/packages/core/examples/viewportProjection/index.ts +++ b/packages/core/examples/viewportProjection/index.ts @@ -424,12 +424,12 @@ async function run() { const planarViewport = getPlanarViewport(); const volumeViewport = getVolumeViewport(); - utilities.genericViewportDataSetMetadataProvider.add(planarDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(planarDataId, { imageIds: planarImageIds, kind: 'planar', initialImageIdIndex: middleImageIndex, }); - utilities.genericViewportDataSetMetadataProvider.add(volumeDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(volumeDataId, { imageIds: volumeImageIds, volumeId, }); diff --git a/packages/core/examples/wsi/index.ts b/packages/core/examples/wsi/index.ts index 23b78a431d..69168326f5 100644 --- a/packages/core/examples/wsi/index.ts +++ b/packages/core/examples/wsi/index.ts @@ -128,7 +128,7 @@ async function run() { // Register WSI data and set it on the viewport const dataId = `wsi:${imageIds[0]}`; - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { imageIds, options: { webClient: client }, }); diff --git a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts index 64fe895e83..95196dba1e 100644 --- a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts +++ b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts @@ -61,6 +61,9 @@ import { import type { TransferFunctionNodes } from '../types/ITransferFunctionNode'; import type vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import { createAndCacheVolume } from '../loaders/volumeLoader'; +import resolveViewportVolumeId from './helpers/resolveViewportVolumeId'; +import { getGenericViewportImageDisplaySet } from './GenericViewport/genericViewportDisplaySetAccess'; import createVolumeActor from './helpers/createVolumeActor'; import volumeNewImageEventDispatcher, { resetVolumeNewImageState, @@ -1437,6 +1440,10 @@ abstract class BaseVolumeViewport extends Viewport { immediate = false, suppressEvents = false ): Promise { + // Setting raw volumes directly resets any display-set bookkeeping; the + // setDisplaySets override re-records after calling this. + this.clearDisplaySets(); + const volumeId = volumeInputArray[0].volumeId; const firstImageVolume = cache.getVolume(volumeId); @@ -1499,6 +1506,49 @@ abstract class BaseVolumeViewport extends Viewport { } } + /** + * Mounts display sets on the viewport, mirroring the GenericViewport + * `setDisplaySets` API. The `displaySetId` is resolved through the registered + * generic-viewport dataset metadata (see `genericViewportDisplaySetMetadataProvider`) + * to its `imageIds`; a volume is created/cached from them (if not already + * present) and loaded via `setVolumes`. Per-entry `options` (e.g. `callback`, + * `blendMode`, `slabThickness`) are forwarded to the volume input. Resolution + * and loading run inside {@link mountDisplaySets}, which records the mounted + * entries after `setVolumes` so {@link getDisplaySets} reports them. + * + * @param entries - display set entries to mount; the first provides the volume. + */ + public async setDisplaySets( + ...entries: Array<{ displaySetId: string; options?: unknown }> + ): Promise { + await this.mountDisplaySets(entries, async (entry) => { + const dataSet = getGenericViewportImageDisplaySet(entry.displaySetId); + if (!dataSet?.imageIds?.length) { + throw new Error( + `[VolumeViewport] No registered imageIds for display set ${entry.displaySetId}` + ); + } + + const volumeId = resolveViewportVolumeId( + (dataSet.volumeId as string) ?? entry.displaySetId + ); + + if (!cache.getVolume(volumeId)) { + const volume = await createAndCacheVolume(volumeId, { + imageIds: dataSet.imageIds, + }); + volume.load(); + } + + const volumeInput = { + volumeId, + ...((entry.options as Record) ?? {}), + } as IVolumeInput; + + await this.setVolumes([volumeInput]); + }); + } + /** * Creates and adds volume actors for all volumes defined in the `volumeInputArray`. * For each entry, if a `callback` is supplied, it will be called with the new volume actor as input. diff --git a/packages/core/src/RenderingEngine/ECGViewport.ts b/packages/core/src/RenderingEngine/ECGViewport.ts index c4544603b7..cb2dc8c360 100644 --- a/packages/core/src/RenderingEngine/ECGViewport.ts +++ b/packages/core/src/RenderingEngine/ECGViewport.ts @@ -15,6 +15,7 @@ import { Transform } from './helpers/cpuFallback/rendering/transform'; import triggerEvent from '../utilities/triggerEvent'; import Viewport from './Viewport'; import { getOrCreateCanvas } from './helpers'; +import { getGenericViewportSourceDataId } from './GenericViewport/genericViewportDisplaySetAccess'; import { ECG_CHANNEL_SPACING, computeECGChannelLayouts, @@ -109,6 +110,9 @@ class ECGViewport extends Viewport { * @param imageId - A DICOM image ID whose metadata includes waveform data */ public async setEcg(imageId: string): Promise { + // Setting ECG data directly resets any display-set bookkeeping; the + // setDisplaySets override re-records after calling this. + this.clearDisplaySets(); this.imageId = imageId; const { waveform, calibration } = await loadECGWaveform(imageId); @@ -135,6 +139,25 @@ class ECGViewport extends Viewport { this.renderFrame(); } + /** + * Mounts display sets on the viewport, mirroring the GenericViewport + * `setDisplaySets` API. The `displaySetId` is resolved through the registered + * generic-viewport dataset metadata (see `genericViewportDisplaySetMetadataProvider`) + * to its source ECG imageId, which is loaded via `setEcg`. Resolution and + * loading run inside {@link mountDisplaySets}, which records the mounted + * entries after `setEcg` so {@link getDisplaySets} reports them. + * + * @param entries - display set entries to mount; the first is used as the ECG source. + */ + public async setDisplaySets( + ...entries: Array<{ displaySetId: string; options?: unknown }> + ): Promise { + await this.mountDisplaySets(entries, async (entry) => { + const sourceDataId = getGenericViewportSourceDataId(entry.displaySetId); + await this.setEcg(sourceDataId); + }); + } + /** * Toggle visibility of a specific channel. */ diff --git a/packages/core/src/RenderingEngine/GenericViewport/ECG/DefaultECGDataProvider.ts b/packages/core/src/RenderingEngine/GenericViewport/ECG/DefaultECGDataProvider.ts index 6b8bfdf931..028f58f18c 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ECG/DefaultECGDataProvider.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ECG/DefaultECGDataProvider.ts @@ -1,7 +1,7 @@ import type { DataProvider, LoadedData } from '../ViewportArchitectureTypes'; import type { ECGWaveformPayload } from './ECGViewportTypes'; import { loadECGWaveform } from '../../../utilities/ECGUtilities'; -import { getGenericViewportSourceDataId } from '../genericViewportDataSetAccess'; +import { getGenericViewportSourceDataId } from '../genericViewportDisplaySetAccess'; export class DefaultECGDataProvider implements DataProvider { async load(dataId: string): Promise> { diff --git a/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts index 68941ac243..09a21e7972 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts @@ -8,7 +8,7 @@ import type { BaseViewportRenderContext, BindingRole, DataAddOptions, - DataId, + DisplaySetId, DataProvider, LoadedData, ViewportDataBinding, @@ -73,10 +73,10 @@ abstract class GenericViewport< protected renderContext: TContext; protected bindings = new Map< - DataId, + DisplaySetId, ViewportDataBinding >(); - protected dataPresentation = new Map(); + protected dataPresentation = new Map(); protected viewState!: TViewState; protected isDestroyed = false; @@ -101,7 +101,7 @@ abstract class GenericViewport< * to the overlay role unless they specify one explicitly. */ async setDisplaySets( - ...entries: Array<{ displaySetId: DataId; options?: unknown }> + ...entries: Array<{ displaySetId: DisplaySetId; options?: unknown }> ): Promise { this.removeAllData(); @@ -126,7 +126,7 @@ abstract class GenericViewport< * through the render-path resolver. */ async addDisplaySet( - displaySetId: DataId, + displaySetId: DisplaySetId, options: DataAddOptions ): Promise { if (this.isDestroyed) { @@ -137,11 +137,26 @@ abstract class GenericViewport< await this.addLoadedData(displaySetId, data, options); } + /** + * Returns the display sets currently mounted on the viewport, in mount order + * (source binding first, then overlays). Derived from the live bindings, so + * it always reflects what is actually rendered - including overlays and any + * `removeData` calls. The per-entry `options` carry the binding `role`. + */ + getDisplaySets(): Array<{ displaySetId: DisplaySetId; options?: unknown }> { + return Array.from(this.bindings.entries()).map( + ([displaySetId, binding]) => ({ + displaySetId, + options: { role: binding.role }, + }) + ); + } + /** * Removes a dataset binding and its stored presentation state, then * triggers a re-render so the viewport reflects the removal. */ - removeData(displaySetId: DataId): void { + removeData(displaySetId: DisplaySetId): void { const binding = this.bindings.get(displaySetId); if (!binding) { @@ -165,11 +180,11 @@ abstract class GenericViewport< */ setDisplaySetPresentation(props: Partial): void; setDisplaySetPresentation( - displaySetId: DataId, + displaySetId: DisplaySetId, props: Partial ): void; setDisplaySetPresentation( - displaySetIdOrProps: DataId | Partial, + displaySetIdOrProps: DisplaySetId | Partial, maybeProps?: Partial ): void { if (typeof displaySetIdOrProps === 'string') { @@ -190,7 +205,7 @@ abstract class GenericViewport< * Returns the stored presentation state for a specific dataset. */ getDisplaySetPresentation( - displaySetId: DataId + displaySetId: DisplaySetId ): TDataPresentation | undefined { return this.getDataPresentationState(displaySetId); } @@ -475,7 +490,7 @@ abstract class GenericViewport< * to the correct render-path runtime. */ protected async addLoadedData( - displaySetId: DataId, + displaySetId: DisplaySetId, data: LoadedData, options: DataAddOptions, shouldIgnore?: () => boolean @@ -565,7 +580,7 @@ abstract class GenericViewport< * that dataset is already added. */ protected setDataPresentationState( - displaySetId: DataId, + displaySetId: DisplaySetId, props: TDataPresentation ): void { if (this.isDestroyed) { @@ -588,7 +603,7 @@ abstract class GenericViewport< * display set is not currently mounted. */ protected getDataPresentationState( - displaySetId: DataId + displaySetId: DisplaySetId ): TDataPresentation | undefined { return this.dataPresentation.get(displaySetId); } @@ -598,7 +613,7 @@ abstract class GenericViewport< * already tracked for that display set. */ protected setDefaultDataPresentation( - displaySetId: DataId, + displaySetId: DisplaySetId, defaults: TDataPresentation ): TDataPresentation { const nextPresentation = { @@ -619,7 +634,7 @@ abstract class GenericViewport< * forwards the result immediately when mounted. */ protected mergeDataPresentation( - displaySetId: DataId, + displaySetId: DisplaySetId, props: Partial ): TDataPresentation { const nextPresentation = { @@ -715,7 +730,7 @@ abstract class GenericViewport< * Looks up a binding by dataset identifier. */ protected getBinding( - displaySetId: DataId + displaySetId: DisplaySetId ): ViewportDataBinding | undefined { return this.bindings.get(displaySetId); } @@ -724,7 +739,9 @@ abstract class GenericViewport< * Internal helper: returns the mounted render mode for a specific dataset * when present. */ - protected getDisplaySetRenderMode(displaySetId: DataId): string | undefined { + protected getDisplaySetRenderMode( + displaySetId: DisplaySetId + ): string | undefined { return this.getBinding(displaySetId)?.rendering.renderMode; } @@ -732,7 +749,9 @@ abstract class GenericViewport< * Internal helper: returns the binding role for a mounted dataset when * present. */ - protected getDisplaySetRole(displaySetId: DataId): BindingRole | undefined { + protected getDisplaySetRole( + displaySetId: DisplaySetId + ): BindingRole | undefined { return this.getBinding(displaySetId)?.role; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/DefaultPlanarDataProvider.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/DefaultPlanarDataProvider.ts index 62033b82c7..9d2544c4e7 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/DefaultPlanarDataProvider.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/DefaultPlanarDataProvider.ts @@ -3,7 +3,7 @@ import { createAndCacheVolume } from '../../../loaders/volumeLoader'; import { ActorRenderMode } from '../../../types'; import resolveViewportVolumeId from '../../helpers/resolveViewportVolumeId'; import type { LoadedData } from '../ViewportArchitectureTypes'; -import { getGenericViewportPlanarDataSet } from '../genericViewportDataSetAccess'; +import { getGenericViewportPlanarDisplaySet } from '../genericViewportDisplaySetAccess'; import type { PlanarDataProvider, PlanarDataLoadOptions, @@ -89,7 +89,7 @@ export class DefaultPlanarDataProvider implements PlanarDataProvider { } private getDataSet(dataId: string): PlanarRegisteredDataSet | undefined { - const dataSet = getGenericViewportPlanarDataSet(dataId); + const dataSet = getGenericViewportPlanarDisplaySet(dataId); if (!isPlanarRegisteredDataSet(dataSet)) { return; diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarLegacyCompatibilityController.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarLegacyCompatibilityController.ts index 577cfbfeee..6673467865 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarLegacyCompatibilityController.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarLegacyCompatibilityController.ts @@ -10,7 +10,7 @@ import { getThresholdValue, } from '../../../utilities/colormap'; import { getTransferFunctionNodes } from '../../../utilities/transferFunctionUtils'; -import genericViewportDataSetMetadataProvider from '../../../utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../../../utilities/genericViewportDisplaySetMetadataProvider'; import type { PlanarRendering } from './planarRuntimeTypes'; import { clonePlanarLegacyProperties, @@ -454,7 +454,7 @@ class PlanarLegacyCompatibilityController { dataId: string, dataSet: PlanarRegisteredDataSet ): void { - genericViewportDataSetMetadataProvider.add(dataId, dataSet); + genericViewportDisplaySetMetadataProvider.add(dataId, dataSet); this.managedDataIds.add(dataId); } @@ -868,7 +868,7 @@ class PlanarLegacyCompatibilityController { private unregisterDataId(dataId: string): void { try { - genericViewportDataSetMetadataProvider.remove(dataId); + genericViewportDisplaySetMetadataProvider.remove(dataId); } finally { this.properties.delete(dataId); this.globalDefaultProperties.delete(dataId); diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts index 8979b536d7..4bc55873d0 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts @@ -25,7 +25,7 @@ import type ViewportInputOptions from '../../../types/ViewportInputOptions'; import { deepClone } from '../../../utilities/deepClone'; import imageIdToURI from '../../../utilities/imageIdToURI'; import { getImageDataMetadata } from '../../../utilities/getImageDataMetadata'; -import genericViewportDataSetMetadataProvider from '../../../utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../../../utilities/genericViewportDisplaySetMetadataProvider'; import triggerEvent from '../../../utilities/triggerEvent'; import getMinMax from '../../../utilities/getMinMax'; import renderingEngineCache from '../../renderingEngineCache'; @@ -37,9 +37,9 @@ import type { import GenericViewport from '../GenericViewport'; import type { GenericViewportReferenceContext } from '../genericViewportReferenceCompatibility'; import { - getGenericViewportImageDataSet, - isGenericViewportImageDataSet, -} from '../genericViewportDataSetAccess'; + getGenericViewportImageDisplaySet, + isGenericViewportImageDisplaySet, +} from '../genericViewportDisplaySetAccess'; import { DefaultPlanarDataProvider } from './DefaultPlanarDataProvider'; import { createPlanarRenderPathResolver } from './PlanarRenderPathResolver'; import { @@ -445,13 +445,13 @@ class PlanarViewport extends GenericViewport< this.renderImageObjectDataId && this.renderImageObjectDataId !== dataId ) { - genericViewportDataSetMetadataProvider.remove( + genericViewportDisplaySetMetadataProvider.remove( this.renderImageObjectDataId ); } this.renderImageObjectDataId = dataId; - genericViewportDataSetMetadataProvider.add(dataId, { + genericViewportDisplaySetMetadataProvider.add(dataId, { image, imageIds: [image.imageId], initialImageIdIndex: 0, @@ -505,7 +505,7 @@ class PlanarViewport extends GenericViewport< const reference = this.resolveOverlayReference(stackInput, image); const dataId = this.resolveOverlayDataId(stackInput, image, reference); - genericViewportDataSetMetadataProvider.add(dataId, { + genericViewportDisplaySetMetadataProvider.add(dataId, { image, imageData: stackInput.imageData, imageIds: [stackInput.imageId], @@ -1247,7 +1247,7 @@ class PlanarViewport extends GenericViewport< protected override onDestroy(): void { this.clearResolvedViewCache(); if (this.renderImageObjectDataId) { - genericViewportDataSetMetadataProvider.remove( + genericViewportDisplaySetMetadataProvider.remove( this.renderImageObjectDataId ); this.renderImageObjectDataId = undefined; @@ -1445,7 +1445,7 @@ class PlanarViewport extends GenericViewport< } private getDataSet(dataId: string): PlanarRegisteredDataSet | undefined { - const dataSet = getGenericViewportImageDataSet(dataId); + const dataSet = getGenericViewportImageDisplaySet(dataId); if (!isPlanarRegisteredDataSet(dataSet)) { return; @@ -2045,7 +2045,7 @@ function createPlanarImageFromVTKImageData( function isPlanarRegisteredDataSet( value: unknown ): value is PlanarRegisteredDataSet { - if (!isGenericViewportImageDataSet(value) || value.imageIds.length === 0) { + if (!isGenericViewportImageDisplaySet(value) || value.imageIds.length === 0) { return false; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportLegacyAdapter.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportLegacyAdapter.ts index 520cc3992c..dbf16e75bf 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportLegacyAdapter.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportLegacyAdapter.ts @@ -12,7 +12,7 @@ import type { import type BlendModes from '../../../enums/BlendModes'; import clonePoint3 from '../../../utilities/clonePoint3'; import hasOwn from '../../../utilities/hasOwn'; -import genericViewportDataSetMetadataProvider from '../../../utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../../../utilities/genericViewportDisplaySetMetadataProvider'; import { viewportProjection } from '../viewportProjection'; import type { PlanarLegacyViewportProperties } from './planarLegacyCompatibility'; import PlanarLegacyCompatibilityController from './PlanarLegacyCompatibilityController'; @@ -357,7 +357,7 @@ class PlanarViewportLegacyAdapter extends PlanarViewport { if (bindingDataId) { if (this.getDisplaySetRole(bindingDataId) === 'overlay') { - genericViewportDataSetMetadataProvider.remove(bindingDataId); + genericViewportDisplaySetMetadataProvider.remove(bindingDataId); } this.removeData(bindingDataId); didRemoveActor = true; diff --git a/packages/core/src/RenderingEngine/GenericViewport/Video/DefaultVideoDataProvider.ts b/packages/core/src/RenderingEngine/GenericViewport/Video/DefaultVideoDataProvider.ts index fb9c3bf8f0..4c405dc99a 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Video/DefaultVideoDataProvider.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Video/DefaultVideoDataProvider.ts @@ -1,6 +1,6 @@ import type { DataProvider, LoadedData } from '../ViewportArchitectureTypes'; import { loadVideoStreamMetadata } from '../../../utilities/VideoUtilities'; -import { getGenericViewportSourceDataId } from '../genericViewportDataSetAccess'; +import { getGenericViewportSourceDataId } from '../genericViewportDisplaySetAccess'; import type { VideoStreamPayload } from './VideoViewportTypes'; export class DefaultVideoDataProvider implements DataProvider { diff --git a/packages/core/src/RenderingEngine/GenericViewport/Video/VideoViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/Video/VideoViewport.ts index ad828a0795..efe9a9d76a 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Video/VideoViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Video/VideoViewport.ts @@ -15,7 +15,7 @@ import type { LoadedData, ViewportDataBinding, } from '../ViewportArchitectureTypes'; -import { getGenericViewportSourceDataId } from '../genericViewportDataSetAccess'; +import { getGenericViewportSourceDataId } from '../genericViewportDisplaySetAccess'; import type { GenericViewportReferenceContext } from '../genericViewportReferenceCompatibility'; import type { VideoViewState, diff --git a/packages/core/src/RenderingEngine/GenericViewport/ViewportArchitectureTypes.ts b/packages/core/src/RenderingEngine/GenericViewport/ViewportArchitectureTypes.ts index b8b4cd3568..d382da4844 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/ViewportArchitectureTypes.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/ViewportArchitectureTypes.ts @@ -22,7 +22,7 @@ import type ResolvedViewportView from './ResolvedViewportView'; */ export type ViewportId = string; -export type DataId = string; +export type DisplaySetId = string; export type KnownViewportRenderContext = | 'planar' | 'video' @@ -58,7 +58,7 @@ export interface BasePresentationProps { } export type LoadedData = { - id: DataId; + id: DisplaySetId; type: LogicalDataType; } & TData; @@ -130,7 +130,7 @@ export interface DataProvider< TData extends object = object, TOptions = unknown, > { - load(dataId: DataId, options?: TOptions): Promise>; + load(dataId: DisplaySetId, options?: TOptions): Promise>; } export interface ViewportDataBinding @@ -146,11 +146,21 @@ export interface ViewportController< readonly id: ViewportId; readonly type: ViewportType; - addDisplaySet(displaySetId: DataId, options: DataAddOptions): Promise; + addDisplaySet( + displaySetId: DisplaySetId, + options: DataAddOptions + ): Promise; setDisplaySets( - ...entries: Array<{ displaySetId: DataId; options?: unknown }> + ...entries: Array<{ displaySetId: DisplaySetId; options?: unknown }> ): Promise; - removeData(dataId: DataId): void; + /** + * Returns the display sets currently mounted on the viewport, in mount order + * (the source binding first, then overlays). Mirrors the legacy + * `Viewport.getDisplaySets()` shape so code can read mounted display sets + * uniformly across the legacy and generic viewport hierarchies. + */ + getDisplaySets(): Array<{ displaySetId: DisplaySetId; options?: unknown }>; + removeData(displaySetId: DisplaySetId): void; setViewState(viewState: Partial): void; getViewState(): TViewState; @@ -165,10 +175,12 @@ export interface ViewportController< resetViewState(options?: unknown): boolean; setDisplaySetPresentation(props: Partial): void; setDisplaySetPresentation( - displaySetId: DataId, + displaySetId: DisplaySetId, props: Partial ): void; - getDisplaySetPresentation(dataId: DataId): TDataPresentation | undefined; + getDisplaySetPresentation( + displaySetId: DisplaySetId + ): TDataPresentation | undefined; setViewReference(viewReference: ViewReference): void; getViewReference(specifier?: ViewReferenceSpecifier): ViewReference; getViewReferenceId(specifier?: ViewReferenceSpecifier): string; diff --git a/packages/core/src/RenderingEngine/GenericViewport/Volume3D/DefaultVolume3DDataProvider.ts b/packages/core/src/RenderingEngine/GenericViewport/Volume3D/DefaultVolume3DDataProvider.ts index 8372de9b98..f5749c2f1c 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Volume3D/DefaultVolume3DDataProvider.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Volume3D/DefaultVolume3DDataProvider.ts @@ -4,9 +4,9 @@ import { createAndCacheVolume } from '../../../loaders/volumeLoader'; import resolveViewportVolumeId from '../../helpers/resolveViewportVolumeId'; import type { LoadedData } from '../ViewportArchitectureTypes'; import { - getGenericViewportImageDataSet, - isGenericViewportImageDataSet, -} from '../genericViewportDataSetAccess'; + getGenericViewportImageDisplaySet, + isGenericViewportImageDisplaySet, +} from '../genericViewportDisplaySetAccess'; import type { Volume3DDataProvider, Volume3DGeometryPayload, @@ -67,7 +67,7 @@ export class DefaultVolume3DDataProvider implements Volume3DDataProvider { } private getDataSet(dataId: string): Volume3DRegisteredDataSet | undefined { - const dataSet = getGenericViewportImageDataSet(dataId); + const dataSet = getGenericViewportImageDisplaySet(dataId); if (!isVolume3DRegisteredDataSet(dataSet)) { return; @@ -80,7 +80,7 @@ export class DefaultVolume3DDataProvider implements Volume3DDataProvider { function isVolume3DRegisteredDataSet( value: unknown ): value is Volume3DRegisteredDataSet { - if (!isGenericViewportImageDataSet(value)) { + if (!isGenericViewportImageDisplaySet(value)) { return false; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Volume3D/VolumeViewport3DLegacyAdapter.ts b/packages/core/src/RenderingEngine/GenericViewport/Volume3D/VolumeViewport3DLegacyAdapter.ts index 3afa2cbdb0..2f66cf535d 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Volume3D/VolumeViewport3DLegacyAdapter.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Volume3D/VolumeViewport3DLegacyAdapter.ts @@ -11,7 +11,7 @@ import type { } from '../../../types'; import applyPreset from '../../../utilities/applyPreset'; import triggerEvent from '../../../utilities/triggerEvent'; -import genericViewportDataSetMetadataProvider from '../../../utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../../../utilities/genericViewportDisplaySetMetadataProvider'; import { viewportProjection } from '../viewportProjection'; import VolumeViewport3D from './viewport3D'; import type { @@ -254,7 +254,7 @@ class VolumeViewport3DLegacyAdapter extends VolumeViewport3D { volumeId: volumeInput.volumeId, }; - genericViewportDataSetMetadataProvider.add(dataId, dataSet); + genericViewportDisplaySetMetadataProvider.add(dataId, dataSet); this.managedDataIds.add(dataId); this.volumeDataIds.set(volumeInput.volumeId, dataId); dataIds.push(dataId); @@ -422,7 +422,7 @@ class VolumeViewport3DLegacyAdapter extends VolumeViewport3D { return; } - genericViewportDataSetMetadataProvider.remove(dataId); + genericViewportDisplaySetMetadataProvider.remove(dataId); this.legacyProperties.delete(dataId); this.defaultLegacyProperties.delete(dataId); @@ -435,7 +435,7 @@ class VolumeViewport3DLegacyAdapter extends VolumeViewport3D { protected override onDestroy(): void { for (const dataId of Array.from(this.managedDataIds)) { - genericViewportDataSetMetadataProvider.remove(dataId); + genericViewportDisplaySetMetadataProvider.remove(dataId); } this.managedDataIds.clear(); diff --git a/packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts b/packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts index def8dc0bbe..83ea86347d 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts @@ -21,9 +21,9 @@ import { type GenericViewportReferenceContext, } from '../genericViewportReferenceCompatibility'; import { - getGenericViewportImageDataSet, - isGenericViewportImageDataSet, -} from '../genericViewportDataSetAccess'; + getGenericViewportImageDisplaySet, + isGenericViewportImageDisplaySet, +} from '../genericViewportDisplaySetAccess'; import { DefaultVolume3DDataProvider } from './DefaultVolume3DDataProvider'; import { createVolume3DRenderPathResolver } from './Volume3DRenderPathResolver'; import Volume3DResolvedView from './Volume3DResolvedView'; @@ -593,7 +593,7 @@ class VolumeViewport3D extends GenericViewport< } private getDataSet(dataId: string): Volume3DRegisteredDataSet | undefined { - const dataSet = getGenericViewportImageDataSet(dataId); + const dataSet = getGenericViewportImageDisplaySet(dataId); if (!isVolume3DRegisteredDataSet(dataSet)) { return; @@ -699,7 +699,7 @@ function isVolume3DRendering(rendering: { function isVolume3DRegisteredDataSet( value: unknown ): value is Volume3DRegisteredDataSet { - if (!isGenericViewportImageDataSet(value)) { + if (!isGenericViewportImageDisplaySet(value)) { return false; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/WSI/DefaultWSIDataProvider.ts b/packages/core/src/RenderingEngine/GenericViewport/WSI/DefaultWSIDataProvider.ts index 5638e45db5..c6c1b12a8b 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/WSI/DefaultWSIDataProvider.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/WSI/DefaultWSIDataProvider.ts @@ -3,7 +3,7 @@ import { loadWSIData, } from '../../../utilities/WSIUtilities'; import type { LoadedData } from '../ViewportArchitectureTypes'; -import { getGenericViewportWSIDataSet } from '../genericViewportDataSetAccess'; +import { getGenericViewportWSIDisplaySet } from '../genericViewportDisplaySetAccess'; import type { WSIDataProvider, WSIPayload, @@ -42,7 +42,7 @@ export class DefaultWSIDataProvider implements WSIDataProvider { } private getDataSet(dataId: string): WSIRegisteredDataSet | undefined { - const registered = getGenericViewportWSIDataSet(dataId); + const registered = getGenericViewportWSIDisplaySet(dataId); if (!registered) { return; diff --git a/packages/core/src/RenderingEngine/GenericViewport/WSI/WSIViewportLegacyAdapter.ts b/packages/core/src/RenderingEngine/GenericViewport/WSI/WSIViewportLegacyAdapter.ts index bea48f8c2a..e06dfaa03b 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/WSI/WSIViewportLegacyAdapter.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/WSI/WSIViewportLegacyAdapter.ts @@ -10,7 +10,7 @@ import type { } from '../../../types'; import { MetadataModules } from '../../../enums'; import * as metaData from '../../../metaData'; -import genericViewportDataSetMetadataProvider from '../../../utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../../../utilities/genericViewportDisplaySetMetadataProvider'; import type { WSIClientLike } from '../../../utilities/WSIUtilities'; import { viewportProjection } from '../viewportProjection'; import { canvasToIndexForWSI } from './wsiTransformUtils'; @@ -100,7 +100,7 @@ class WSIViewportLegacyAdapter extends WSIViewport { ); } - genericViewportDataSetMetadataProvider.add(dataId, { + genericViewportDisplaySetMetadataProvider.add(dataId, { imageIds, kind: 'wsi', options: { diff --git a/packages/core/src/RenderingEngine/GenericViewport/genericViewportDataSetAccess.ts b/packages/core/src/RenderingEngine/GenericViewport/genericViewportDisplaySetAccess.ts similarity index 53% rename from packages/core/src/RenderingEngine/GenericViewport/genericViewportDataSetAccess.ts rename to packages/core/src/RenderingEngine/GenericViewport/genericViewportDisplaySetAccess.ts index 26509e6ebf..1267fb4424 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/genericViewportDataSetAccess.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/genericViewportDisplaySetAccess.ts @@ -1,15 +1,15 @@ import type { IImage } from '../../types'; import type { WSIClientLike } from '../../utilities/WSIUtilities'; -import genericViewportDataSetMetadataProvider from '../../utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../../utilities/genericViewportDisplaySetMetadataProvider'; import type { ViewportDataReference } from './ViewportArchitectureTypes'; -export interface GenericViewportImageDataSet { +export interface GenericViewportImageDisplaySet { imageIds: string[]; [key: string]: unknown; } -export interface GenericViewportPlanarDataSet - extends GenericViewportImageDataSet { +export interface GenericViewportPlanarDisplaySet + extends GenericViewportImageDisplaySet { kind?: 'planar'; initialImageIdIndex?: number; volumeId?: string; @@ -17,12 +17,12 @@ export interface GenericViewportPlanarDataSet reference?: ViewportDataReference; } -export interface GenericViewportSourceAliasDataSet { +export interface GenericViewportSourceAliasDisplaySet { kind: 'video' | 'ecg'; sourceDataId: string; } -export interface GenericViewportWSIDataSet { +export interface GenericViewportWSIDisplaySet { kind: 'wsi'; imageIds: string[]; options: { @@ -31,26 +31,26 @@ export interface GenericViewportWSIDataSet { }; } -export type GenericViewportRegisteredData = +export type GenericViewportRegisteredDisplaySet = | string | string[] - | GenericViewportPlanarDataSet - | GenericViewportSourceAliasDataSet - | GenericViewportWSIDataSet; - -export function getGenericViewportRegisteredData( - dataId: string -): GenericViewportRegisteredData | undefined { - return genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, - dataId - ) as GenericViewportRegisteredData | undefined; + | GenericViewportPlanarDisplaySet + | GenericViewportSourceAliasDisplaySet + | GenericViewportWSIDisplaySet; + +export function getGenericViewportRegisteredDisplaySet( + displaySetId: string +): GenericViewportRegisteredDisplaySet | undefined { + return genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, + displaySetId + ) as GenericViewportRegisteredDisplaySet | undefined; } -export function getGenericViewportImageDataSet( - dataId: string -): GenericViewportImageDataSet | undefined { - const registered = getGenericViewportRegisteredData(dataId); +export function getGenericViewportImageDisplaySet( + displaySetId: string +): GenericViewportImageDisplaySet | undefined { + const registered = getGenericViewportRegisteredDisplaySet(displaySetId); if (isStringArray(registered)) { return { @@ -58,17 +58,17 @@ export function getGenericViewportImageDataSet( }; } - if (!isGenericViewportImageDataSet(registered)) { + if (!isGenericViewportImageDisplaySet(registered)) { return; } return registered; } -export function getGenericViewportPlanarDataSet( - dataId: string -): GenericViewportPlanarDataSet | undefined { - const registered = getGenericViewportRegisteredData(dataId); +export function getGenericViewportPlanarDisplaySet( + displaySetId: string +): GenericViewportPlanarDisplaySet | undefined { + const registered = getGenericViewportRegisteredDisplaySet(displaySetId); if (isStringArray(registered)) { return { @@ -76,7 +76,7 @@ export function getGenericViewportPlanarDataSet( }; } - if (!isGenericViewportImageDataSet(registered)) { + if (!isGenericViewportImageDisplaySet(registered)) { return; } @@ -84,15 +84,15 @@ export function getGenericViewportPlanarDataSet( return; } - return registered as GenericViewportPlanarDataSet; + return registered as GenericViewportPlanarDisplaySet; } -export function getGenericViewportWSIDataSet( - dataId: string -): GenericViewportWSIDataSet | undefined { - const registered = getGenericViewportRegisteredData(dataId); +export function getGenericViewportWSIDisplaySet( + displaySetId: string +): GenericViewportWSIDisplaySet | undefined { + const registered = getGenericViewportRegisteredDisplaySet(displaySetId); - if (isGenericViewportWSIDataSet(registered)) { + if (isGenericViewportWSIDisplaySet(registered)) { return registered; } @@ -115,8 +115,8 @@ export function getGenericViewportWSIDataSet( } } -export function getGenericViewportSourceDataId(dataId: string): string { - const registered = getGenericViewportRegisteredData(dataId); +export function getGenericViewportSourceDataId(displaySetId: string): string { + const registered = getGenericViewportRegisteredDisplaySet(displaySetId); if (typeof registered === 'string') { return registered; @@ -126,22 +126,22 @@ export function getGenericViewportSourceDataId(dataId: string): string { return registered[0]; } - if (isGenericViewportSourceAliasDataSet(registered)) { + if (isGenericViewportSourceAliasDisplaySet(registered)) { return registered.sourceDataId; } - return dataId; + return displaySetId; } -export function isGenericViewportImageDataSet( +export function isGenericViewportImageDisplaySet( value: unknown -): value is GenericViewportImageDataSet { +): value is GenericViewportImageDisplaySet { return isRecord(value) && isStringArray(value.imageIds); } -export function isGenericViewportWSIDataSet( +export function isGenericViewportWSIDisplaySet( value: unknown -): value is GenericViewportWSIDataSet { +): value is GenericViewportWSIDisplaySet { return ( isRecord(value) && value.kind === 'wsi' && @@ -151,9 +151,9 @@ export function isGenericViewportWSIDataSet( ); } -export function isGenericViewportSourceAliasDataSet( +export function isGenericViewportSourceAliasDisplaySet( value: unknown -): value is GenericViewportSourceAliasDataSet { +): value is GenericViewportSourceAliasDisplaySet { return ( isRecord(value) && (value.kind === 'video' || value.kind === 'ecg') && diff --git a/packages/core/src/RenderingEngine/GenericViewport/index.ts b/packages/core/src/RenderingEngine/GenericViewport/index.ts index cd2ff6dba6..e0403a0ced 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/index.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/index.ts @@ -44,7 +44,7 @@ export type { BaseViewportRenderContext, BasePresentationProps, DataAddOptions, - DataId, + DisplaySetId, DataProvider, ICanvasWorldViewport, IFrameOfReferenceViewport, diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index f78c129f27..2d6021985a 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -9,6 +9,7 @@ import eventTarget from '../eventTarget'; import * as metaData from '../metaData'; import { getImageDataMetadata as getImageDataMetadataUtil } from '../utilities/getImageDataMetadata'; import { coreLog } from '../utilities/logger'; +import { getGenericViewportImageDisplaySet } from './GenericViewport/genericViewportDisplaySetAccess'; import type { ActorEntry, @@ -1930,6 +1931,10 @@ class StackViewport extends Viewport { ): Promise { this._throwIfDestroyed(); + // Setting a raw stack directly resets any display-set bookkeeping; the + // setDisplaySets override re-records after calling this. + this.clearDisplaySets(); + this.imageIds = imageIds; if (currentImageIdIndex > imageIds.length) { @@ -1987,6 +1992,36 @@ class StackViewport extends Viewport { return imageId; } + /** + * Mounts display sets on the viewport, mirroring the GenericViewport + * `setDisplaySets` API. Each `displaySetId` is resolved through the registered + * generic-viewport dataset metadata (see `genericViewportDisplaySetMetadataProvider`) + * to its `imageIds`; the first entry is loaded as the stack. Resolution and + * loading run inside {@link mountDisplaySets}, which records the mounted + * entries after `setStack` so {@link getDisplaySets} reports them. + * + * @param entries - display set entries to mount; the first is used as the stack source. + */ + public async setDisplaySets( + ...entries: Array<{ displaySetId: string; options?: unknown }> + ): Promise { + await this.mountDisplaySets(entries, async (entry) => { + const dataSet = getGenericViewportImageDisplaySet(entry.displaySetId); + if (!dataSet?.imageIds?.length) { + throw new Error( + `[StackViewport] No registered imageIds for display set ${entry.displaySetId}` + ); + } + + const initialImageIdIndex = + typeof dataSet.initialImageIdIndex === 'number' + ? dataSet.initialImageIdIndex + : 0; + + await this.setStack(dataSet.imageIds, initialImageIdIndex); + }); + } + /** * Throws an error if you are using a destroyed instance of the stack viewport */ diff --git a/packages/core/src/RenderingEngine/VideoViewport.ts b/packages/core/src/RenderingEngine/VideoViewport.ts index 0d139ef2e3..6fe2943e57 100644 --- a/packages/core/src/RenderingEngine/VideoViewport.ts +++ b/packages/core/src/RenderingEngine/VideoViewport.ts @@ -1,5 +1,7 @@ import type { mat4 } from 'gl-matrix'; import { Events as EVENTS, VideoEnums as VideoViewportEnum } from '../enums'; +import type { DisplaySetId } from './GenericViewport/ViewportArchitectureTypes'; +import { getGenericViewportSourceDataId } from './GenericViewport/genericViewportDisplaySetAccess'; import type { VideoViewportProperties, Point3, @@ -196,12 +198,37 @@ class VideoViewport extends Viewport { return this.setVideo(entries[0].dataId); } + /** + * Mounts display sets on the viewport, mirroring the GenericViewport + * `setDisplaySets` API. The `displaySetId` is the video imageId (callers + * typically pass `displaySet.instances[0].imageId`); the first entry is loaded + * as the video source. Resolution and loading run inside + * {@link mountDisplaySets}, which records the mounted entries after `setVideo` + * so {@link getDisplaySets} reports them. + * + * @param entries - display set entries to mount; the first is used as the source. + */ + public async setDisplaySets( + ...entries: Array<{ displaySetId: DisplaySetId; options?: unknown }> + ): Promise { + await this.mountDisplaySets(entries, async (entry) => { + // Resolve the display set to its source video imageId. When the id is not + // registered in the generic-viewport dataset metadata it is returned + // as-is, so callers passing the video imageId directly keep working. + const sourceDataId = getGenericViewportSourceDataId(entry.displaySetId); + await this.setVideo(sourceDataId); + }); + } + /** * Sets the video image id to show and hte frame number. * Requirements are to have the imageUrlModule in the metadata * with the rendered endpoint being the raw video in video/mp4 format. */ public setVideo(imageId: string, frameNumber?: number): Promise { + // Setting a raw video directly resets any display-set bookkeeping; the + // setDisplaySets override re-records after calling this. + this.clearDisplaySets(); this.imageId = Array.isArray(imageId) ? imageId[0] : imageId; const stream = loadVideoStreamMetadata(imageId); diff --git a/packages/core/src/RenderingEngine/Viewport.ts b/packages/core/src/RenderingEngine/Viewport.ts index da0bc87e44..938e7f0fdd 100644 --- a/packages/core/src/RenderingEngine/Viewport.ts +++ b/packages/core/src/RenderingEngine/Viewport.ts @@ -133,6 +133,14 @@ class Viewport { sHeight: number; /** a Map containing the actor uid and actors */ _actors: Map; + /** + * Records the display sets currently mounted on the viewport. Populated by the + * type-specific {@link setDisplaySets} overrides (via {@link mountDisplaySets}) + * and cleared whenever raw data is set directly (e.g. `setStack`), so that + * {@link getDisplaySets} reflects only display-set-driven content. + */ + protected _displaySets: Array<{ displaySetId: string; options?: unknown }> = + []; /** Default options for the viewport which includes orientation, viewPlaneNormal and backgroundColor */ readonly defaultOptions: ViewportInputOptions; /** options for the viewport which includes orientation axis, backgroundColor and displayArea */ @@ -2409,6 +2417,98 @@ class Viewport { ) { throw new Error('Unsupported operation setDataList'); } + + /** + * Records the display sets currently shown on the viewport. The base + * implementation only stores the entries so that {@link getDisplaySets} can + * report them; type-specific viewports override this to actually load the data + * (via their native setter such as `setStack`/`setVolumes`/`setVideo`), + * delegating to {@link mountDisplaySets} so the clear-then-record ordering is + * applied consistently. + * + * Because the native data setters clear the recorded list (see + * {@link clearDisplaySets}), driving a viewport with a raw data setter - + * without going through `setDisplaySets` - leaves `getDisplaySets()` empty, + * correctly reflecting that the viewport is not display-set driven. + * + * @param entries - the display set entries that were mounted; the first entry + * is the source and any subsequent entries are overlays. + */ + public setDisplaySets( + ...entries: Array<{ displaySetId: string; options?: unknown }> + ): void | Promise { + this._displaySets = entries; + } + + /** + * Shared template for the type-specific {@link setDisplaySets} overrides. It + * keeps the clear-then-record ordering invariant in exactly one place instead + * of being copy-pasted per viewport: + * + * 1. Validate that a source display set (the first entry) was provided. + * 2. Run `load`, which resolves the entry to renderable data and hands it to + * the viewport's native data setter (`setStack` / `setVolumes` / + * `setVideo` / `setWSI` / `setEcg`). That native setter calls + * {@link clearDisplaySets}, wiping any previously recorded entries. + * 3. Record the mounted entries - only after `load` resolves - so + * {@link getDisplaySets} reflects exactly what was mounted. + * + * Because recording always happens last, a new viewport cannot accidentally + * record before loading (which the native setter would then wipe) or forget + * to record at all; both failure modes the previous copy-pasted sequence was + * prone to are handled here once. + * + * Overlays: the variadic signature already accepts overlay entries (index 1+), + * matching the generic viewport `setDisplaySets`. Legacy viewports do not yet + * render overlays, so only the source (first) entry is loaded and recorded - + * {@link getDisplaySets} stays honest about what is actually shown. Adding + * overlay support later is purely additive: load the extra entries here and + * record them, with no change to the public signature. + * + * @param entries - the display set entries passed to `setDisplaySets`; the + * first is the source and any subsequent entries are overlays. + * @param load - resolves the source entry to renderable data and loads it via + * the viewport's native data setter. Receives the validated source entry. + */ + protected async mountDisplaySets< + TEntry extends { displaySetId: string; options?: unknown }, + >( + entries: TEntry[], + load: (sourceEntry: TEntry) => void | Promise + ): Promise { + const [entry] = entries; + if (!entry?.displaySetId) { + throw new Error( + `${this.constructor.name}: setDisplaySets requires a displaySetId` + ); + } + + // `load` invokes the native data setter, which clears the recorded display + // sets (see clearDisplaySets); record the mounted source only afterwards. + // Overlays (entries[1+]) are not rendered by legacy viewports yet, so they + // are intentionally not recorded - see the overlay note above. + await load(entry); + this._displaySets = [entry]; + } + + /** + * Returns the display sets currently recorded as mounted on the viewport. + * Empty when the viewport was driven by a raw data setter instead of + * {@link setDisplaySets}. + */ + public getDisplaySets(): Array<{ displaySetId: string; options?: unknown }> { + return this._displaySets; + } + + /** + * Clears the recorded display sets. Native data setters call this so that + * setting raw data without {@link setDisplaySets} resets the stored list. + * {@link mountDisplaySets} relies on this ordering: it runs the native data + * setter first (which clears) and then records the mounted entries. + */ + protected clearDisplaySets(): void { + this._displaySets = []; + } } /** diff --git a/packages/core/src/RenderingEngine/WSIViewport.ts b/packages/core/src/RenderingEngine/WSIViewport.ts index 942dc304f5..43bcd706a0 100644 --- a/packages/core/src/RenderingEngine/WSIViewport.ts +++ b/packages/core/src/RenderingEngine/WSIViewport.ts @@ -19,6 +19,7 @@ import triggerEvent from '../utilities/triggerEvent'; import type { DataSetOptions } from '../types/IViewport'; import eventTarget from '../eventTarget'; import imageIdToURI from '../utilities/imageIdToURI'; +import { getGenericViewportWSIDisplaySet } from './GenericViewport/genericViewportDisplaySetAccess'; import { addWSIMiniNavigationOverlayCss, type WSIClientLike, @@ -543,7 +544,7 @@ class WSIViewport extends Viewport { return; } const { dataId } = entries[0]; - const dataSet = metaData.get('genericViewportDataSet', dataId) as + const dataSet = metaData.get('genericViewportDisplaySet', dataId) as | { imageIds?: string[]; options?: { webClient?: WSIClientLike } } | undefined; @@ -583,6 +584,9 @@ class WSIViewport extends Viewport { } public async setWSI(imageIds: string[], client: WSIClientLike) { + // Setting WSI data directly resets any display-set bookkeeping; the + // setDisplaySets override re-records after calling this. + this.clearDisplaySets(); this.microscopyElement.style.background = 'black'; this.microscopyElement.innerText = 'Loading'; this.imageIds = imageIds; @@ -626,6 +630,31 @@ class WSIViewport extends Viewport { }); } + /** + * Mounts display sets on the viewport, mirroring the GenericViewport + * `setDisplaySets` API. The `displaySetId` is resolved through the registered + * generic-viewport dataset metadata (see `genericViewportDisplaySetMetadataProvider`) + * to its WSI `imageIds` and `webClient`, which are loaded via `setWSI`. + * Resolution and loading run inside {@link mountDisplaySets}, which records + * the mounted entries after `setWSI` so {@link getDisplaySets} reports them. + * + * @param entries - display set entries to mount; the first is used as the WSI source. + */ + public async setDisplaySets( + ...entries: Array<{ displaySetId: string; options?: unknown }> + ): Promise { + await this.mountDisplaySets(entries, async (entry) => { + const dataSet = getGenericViewportWSIDisplaySet(entry.displaySetId); + if (!dataSet?.imageIds?.length || !dataSet.options?.webClient) { + throw new Error( + `[WSIViewport] No registered WSI dataset (imageIds + webClient) for display set ${entry.displaySetId}` + ); + } + + await this.setWSI(dataSet.imageIds, dataSet.options.webClient); + }); + } + public postrender = () => { this.refreshRenderValues(); triggerEvent(this.element, EVENTS.IMAGE_RENDERED, { diff --git a/packages/core/src/utilities/genericViewportDataSetMetadataProvider.ts b/packages/core/src/utilities/genericViewportDataSetMetadataProvider.ts deleted file mode 100644 index 96c5f1ed99..0000000000 --- a/packages/core/src/utilities/genericViewportDataSetMetadataProvider.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { addProvider } from '../metaData'; - -const VIEWPORT_V2_DATA_SET = 'genericViewportDataSet'; - -let state: Record = {}; - -const genericViewportDataSetMetadataProvider = { - VIEWPORT_V2_DATA_SET, - - add(dataId: string, dataSet: unknown): void { - state[dataId] = dataSet; - }, - - get(type: string, dataId: string): unknown { - if (type !== VIEWPORT_V2_DATA_SET) { - return; - } - - return state[dataId]; - }, - - remove(dataId: string): void { - delete state[dataId]; - }, - - clear(): void { - state = {}; - }, -}; - -addProvider(genericViewportDataSetMetadataProvider.get); - -export default genericViewportDataSetMetadataProvider; diff --git a/packages/core/src/utilities/genericViewportDisplaySetMetadataProvider.ts b/packages/core/src/utilities/genericViewportDisplaySetMetadataProvider.ts new file mode 100644 index 0000000000..93eb6718bb --- /dev/null +++ b/packages/core/src/utilities/genericViewportDisplaySetMetadataProvider.ts @@ -0,0 +1,33 @@ +import { addProvider } from '../metaData'; + +const VIEWPORT_V2_DISPLAY_SET = 'genericViewportDisplaySet'; + +let state: Record = {}; + +const genericViewportDisplaySetMetadataProvider = { + VIEWPORT_V2_DISPLAY_SET, + + add(displaySetId: string, dataSet: unknown): void { + state[displaySetId] = dataSet; + }, + + get(type: string, displaySetId: string): unknown { + if (type !== VIEWPORT_V2_DISPLAY_SET) { + return; + } + + return state[displaySetId]; + }, + + remove(displaySetId: string): void { + delete state[displaySetId]; + }, + + clear(): void { + state = {}; + }, +}; + +addProvider(genericViewportDisplaySetMetadataProvider.get); + +export default genericViewportDisplaySetMetadataProvider; diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 2fe7b163ed..24f40271db 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -59,7 +59,7 @@ import { updateVTKImageDataWithCornerstoneImage } from './updateVTKImageDataWith import ProgressiveIterator from './ProgressiveIterator'; import decimate from './decimate'; import imageRetrieveMetadataProvider from './imageRetrieveMetadataProvider'; -import genericViewportDataSetMetadataProvider from './genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from './genericViewportDisplaySetMetadataProvider'; import isVideoTransferSyntax from './isVideoTransferSyntax'; import { getBufferConfiguration } from './getBufferConfiguration'; import { generateVolumePropsFromImageIds } from './generateVolumePropsFromImageIds'; @@ -194,7 +194,7 @@ export { ProgressiveIterator, decimate, imageRetrieveMetadataProvider, - genericViewportDataSetMetadataProvider, + genericViewportDisplaySetMetadataProvider, transferFunctionUtils, updateVTKImageDataWithCornerstoneImage, sortImageIdsAndGetSpacing, diff --git a/packages/core/test/PlanarLegacyCompatibilityController.jest.js b/packages/core/test/PlanarLegacyCompatibilityController.jest.js index 469d622c64..97d4b8e0c7 100644 --- a/packages/core/test/PlanarLegacyCompatibilityController.jest.js +++ b/packages/core/test/PlanarLegacyCompatibilityController.jest.js @@ -5,7 +5,7 @@ jest.mock('../src/cache/cache', () => ({ }, })); -jest.mock('../src/utilities/genericViewportDataSetMetadataProvider', () => ({ +jest.mock('../src/utilities/genericViewportDisplaySetMetadataProvider', () => ({ __esModule: true, default: { add: jest.fn(), @@ -24,7 +24,7 @@ jest.mock('../src/utilities/transferFunctionUtils', () => ({ })); import cache from '../src/cache/cache'; -import metadataProvider from '../src/utilities/genericViewportDataSetMetadataProvider'; +import metadataProvider from '../src/utilities/genericViewportDisplaySetMetadataProvider'; import Events from '../src/enums/Events'; import PlanarLegacyCompatibilityController from '../src/RenderingEngine/GenericViewport/Planar/PlanarLegacyCompatibilityController'; diff --git a/packages/core/test/planarOrchestration.jest.js b/packages/core/test/planarOrchestration.jest.js index bb71cec885..10a08a6baa 100644 --- a/packages/core/test/planarOrchestration.jest.js +++ b/packages/core/test/planarOrchestration.jest.js @@ -5,7 +5,7 @@ import PlanarViewReferenceController from '../src/RenderingEngine/GenericViewpor import PlanarLegacyCompatibilityController, { PLANAR_LEGACY_PER_IMAGE_DEFAULT_PROPERTIES_LIMIT, } from '../src/RenderingEngine/GenericViewport/Planar/PlanarLegacyCompatibilityController'; -import genericViewportDataSetMetadataProvider from '../src/utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../src/utilities/genericViewportDisplaySetMetadataProvider'; function createBinding({ actorUID, @@ -60,8 +60,8 @@ function createLegacyStackHarness({ deferSetData = false } = {}) { let currentImageIds = []; function finishSetData(requestedDataId) { - const registered = genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + const registered = genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, requestedDataId ); @@ -138,7 +138,7 @@ function createLegacyStackHarness({ deferSetData = false } = {}) { describe('Planar legacy stack compatibility', () => { afterEach(() => { - genericViewportDataSetMetadataProvider.clear(); + genericViewportDisplaySetMetadataProvider.clear(); }); it('replaces the legacy stack without unregistering replacement metadata', async () => { @@ -151,8 +151,8 @@ describe('Planar legacy stack compatibility', () => { expect(host.setDisplaySets).toHaveBeenCalledTimes(2); expect(getCurrentImageIds()).toEqual(['image:new']); expect( - genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, dataId ) ).toEqual({ diff --git a/packages/core/test/planarViewportState.jest.js b/packages/core/test/planarViewportState.jest.js index 92a60fe3bd..29e779e2dc 100644 --- a/packages/core/test/planarViewportState.jest.js +++ b/packages/core/test/planarViewportState.jest.js @@ -13,7 +13,7 @@ import { } from '../src/RenderingEngine/GenericViewport'; import PlanarViewport from '../src/RenderingEngine/GenericViewport/Planar/PlanarViewport'; import renderingEngineCache from '../src/RenderingEngine/renderingEngineCache'; -import genericViewportDataSetMetadataProvider from '../src/utilities/genericViewportDataSetMetadataProvider'; +import genericViewportDisplaySetMetadataProvider from '../src/utilities/genericViewportDisplaySetMetadataProvider'; import imageIdToURI from '../src/utilities/imageIdToURI'; let viewportCounter = 0; @@ -165,7 +165,7 @@ describe('PlanarViewport view state', () => { metaData.removeProvider(provider); } metadataProviders = []; - genericViewportDataSetMetadataProvider.clear(); + genericViewportDisplaySetMetadataProvider.clear(); jest.clearAllMocks(); }); @@ -643,20 +643,20 @@ describe('PlanarViewport view state', () => { options: { orientation: expect.anything() }, }); expect( - genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, firstDataId ).image ).toBe(firstImage); expect( - genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, secondDataId ).image ).toBe(secondImage); expect( - genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, imageId ) ).toBeUndefined(); @@ -679,14 +679,14 @@ describe('PlanarViewport view state', () => { await viewport.renderImageObject(secondImage); expect( - genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, firstDataId ) ).toBeUndefined(); expect( - genericViewportDataSetMetadataProvider.get( - genericViewportDataSetMetadataProvider.VIEWPORT_V2_DATA_SET, + genericViewportDisplaySetMetadataProvider.get( + genericViewportDisplaySetMetadataProvider.VIEWPORT_V2_DISPLAY_SET, secondDataId ).image ).toBe(secondImage); diff --git a/packages/docs/docs/concepts/cornerstone-core/generic-viewport/api.md b/packages/docs/docs/concepts/cornerstone-core/generic-viewport/api.md index 0f31a9b67f..908c9b5d80 100644 --- a/packages/docs/docs/concepts/cornerstone-core/generic-viewport/api.md +++ b/packages/docs/docs/concepts/cornerstone-core/generic-viewport/api.md @@ -53,7 +53,7 @@ Register stack-like data with the metadata provider, then mount it with ```ts const stackDisplaySetId = 'ct-stack'; -utilities.genericViewportDataSetMetadataProvider.add(stackDisplaySetId, { +utilities.genericViewportDisplaySetMetadataProvider.add(stackDisplaySetId, { kind: 'planar', imageIds, initialImageIdIndex: 0, @@ -86,7 +86,7 @@ Volume slice data uses the same viewport API. The registered data includes a ```ts const ctDataId = 'ct-volume-source'; -utilities.genericViewportDataSetMetadataProvider.add(ctDataId, { +utilities.genericViewportDisplaySetMetadataProvider.add(ctDataId, { kind: 'planar', imageIds: ctImageIds, initialImageIdIndex: Math.floor(ctImageIds.length / 2), @@ -113,7 +113,7 @@ the same viewport view state as the source but keep their own data presentation. ```ts const ptDataId = 'pt-volume-overlay'; -utilities.genericViewportDataSetMetadataProvider.add(ptDataId, { +utilities.genericViewportDisplaySetMetadataProvider.add(ptDataId, { kind: 'planar', imageIds: ptImageIds, initialImageIdIndex: Math.floor(ptImageIds.length / 2), @@ -162,7 +162,7 @@ their `displaySetId`, `imageIds`, and optional `volumeId` are already the public identity. ```ts -utilities.genericViewportDataSetMetadataProvider.add(labelmapDataId, { +utilities.genericViewportDisplaySetMetadataProvider.add(labelmapDataId, { kind: 'planar', imageIds: labelmapImageIds, reference: { diff --git a/packages/docs/docs/concepts/cornerstone-core/generic-viewport/data-bindings-and-loading.md b/packages/docs/docs/concepts/cornerstone-core/generic-viewport/data-bindings-and-loading.md index ac87be7ec8..371caec335 100644 --- a/packages/docs/docs/concepts/cornerstone-core/generic-viewport/data-bindings-and-loading.md +++ b/packages/docs/docs/concepts/cornerstone-core/generic-viewport/data-bindings-and-loading.md @@ -50,7 +50,7 @@ segmentation labelmap, volume, image, geometry, or another data id. It is not an actor id and it is not used as runtime actor identity. ```ts -utilities.genericViewportDataSetMetadataProvider.add(labelmapDataId, { +utilities.genericViewportDisplaySetMetadataProvider.add(labelmapDataId, { kind: 'planar', imageIds: labelmapImageIds, reference: { diff --git a/packages/docs/docs/concepts/cornerstone-core/generic-viewport/migration.md b/packages/docs/docs/concepts/cornerstone-core/generic-viewport/migration.md index d8c3decbc9..cf3ff6faa7 100644 --- a/packages/docs/docs/concepts/cornerstone-core/generic-viewport/migration.md +++ b/packages/docs/docs/concepts/cornerstone-core/generic-viewport/migration.md @@ -72,7 +72,7 @@ Now: ```ts const displaySetId = 'ct-stack'; -utilities.genericViewportDataSetMetadataProvider.add(displaySetId, { +utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { kind: 'planar', imageIds, initialImageIdIndex: 0, @@ -108,7 +108,7 @@ Now: ```ts const displaySetId = 'ct-volume'; -utilities.genericViewportDataSetMetadataProvider.add(displaySetId, { +utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), @@ -188,7 +188,7 @@ viewport.addImages([{ imageId }]); Now, prefer registering overlay data and using data presentation: ```ts -utilities.genericViewportDataSetMetadataProvider.add(overlayDataId, { +utilities.genericViewportDisplaySetMetadataProvider.add(overlayDataId, { kind: 'planar', imageIds: [imageId], initialImageIdIndex: 0, diff --git a/packages/docs/docs/concepts/cornerstone-metadata/display-sets.md b/packages/docs/docs/concepts/cornerstone-metadata/display-sets.md new file mode 100644 index 0000000000..88a4aa6f4e --- /dev/null +++ b/packages/docs/docs/concepts/cornerstone-metadata/display-sets.md @@ -0,0 +1,295 @@ +--- +id: display-sets +title: Display Sets +summary: Framework-agnostic display-set splitting in @cornerstonejs/metadata — the split/create/consume pipeline, split rules, and the IDisplaySet data model +--- + +# Display Sets + +A **display set** is the unit a viewport renders. It groups the instances of a +series that should be shown together and records which viewport type(s) can +render them. This mirrors the OHIF "display set" concept, but lives in +`@cornerstonejs/metadata` as a framework-agnostic, **data-shaped** object +(`IDisplaySet`) so any application — not just OHIF — can reuse it. + +A series does not always map to a single display set. The classic case is the +fix this module was extracted for: a **diffusion MR (DWI)** series that mixes +4D b-value frames with trailing frames that have no b-value. Those undefined +b-value frames are not part of the 4D data set, so rendering them as one volume +applies the wrong window/level. The `mixedDimensionalityBValue` split rule +separates them into their own display set (see [Split rules](#split-rules)). + +## The split → create → consume pipeline + +The end-to-end flow has three stages: + +1. **Split** a series' image ids into instance groups with + `splitImageIdsBySplitRules` using a set of split rules. +2. **Create** an `IDisplaySet` for each group with `createDisplaySetFromGroup`. +3. **Consume** each display set — render it on a viewport, and/or cache it in the + metadata layer so downstream code can resolve it by image id. + +For examples, the demo helper `splitDisplaySetsFromImageIds(imageIds)` performs +stages 1–2 for you (it normalizes frame image ids to their base form, dedupes to +one instance per SOP, and re-attaches the frame-level image ids). Under the hood +it is just: + +```ts +import { + splitImageIdsBySplitRules, + createDisplaySetFromGroup, + defaultDisplaySetSplitRules, + metaData, + type IDisplaySet, + type NaturalizedInstance, +} from '@cornerstonejs/metadata'; + +// Resolve one (base) imageId to its naturalized DICOM instance. In a real app +// this reads the metadata cache, e.g. metaData.get('instance', imageId), with +// the imageId normalized to its base (frame 1) form. +function getNaturalizedInstance( + imageId: string +): NaturalizedInstance | undefined { + return metaData.get('instance', imageId) as NaturalizedInstance | undefined; +} + +const groups = splitImageIdsBySplitRules(seriesImageIds, { + getNaturalizedInstance, + splitRules: defaultDisplaySetSplitRules, +}); + +const displaySets: IDisplaySet[] = groups.map((group) => + createDisplaySetFromGroup(group) +); +``` + +## Driving a viewport from a display set + +Each display set exposes the viewport type(s) it can be shown in +(`viewportTypes`, with `preferredViewportType` being the first). A viewport's +`setDisplaySets({ displaySetId })` is the single entry point that loads a display +set: it resolves `displaySetId` to renderable data, calls the viewport's native +setter (`setStack` / `setVolumes` / `setVideo` / `setWSI` / `setEcg`), and +records the mounted entry so `getDisplaySets()` reflects it. + +The viewport/registry `displaySetId` is the same value as the display set's +`displaySetId` field — there is one identifier for a display set, used on both +the metadata object and the viewport API. + +For the legacy viewports, `setDisplaySets` resolves `displaySetId` through the +**generic-viewport display-set provider**, so you register the renderable data +there first. The registered shape depends on the viewport family: + +```ts +import { Enums, utilities } from '@cornerstonejs/core'; + +const { ViewportType } = Enums; + +const HINT_TO_VIEWPORT_TYPE: Record = { + stack: ViewportType.STACK, + volume: ViewportType.ORTHOGRAPHIC, + volume3d: ViewportType.VOLUME_3D, + video: ViewportType.VIDEO, + wholeslide: ViewportType.WHOLE_SLIDE, + ecg: ViewportType.ECG, +}; + +const displaySetId = displaySet.displaySetId; + +// 1. Register the renderable data so the viewport can resolve `displaySetId`. +// stack/volume use { imageIds }; video/ecg use { kind, sourceDataId }; +// wsi uses { kind: 'wsi', imageIds, options: { webClient } }. +utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { + imageIds: [...displaySet.imageIds], +}); + +// 2. Enable a viewport of the display set's preferred type, then mount it. +const viewportType = + HINT_TO_VIEWPORT_TYPE[displaySet.preferredViewportType] ?? ViewportType.STACK; +renderingEngine.enableElement({ viewportId, type: viewportType, element }); + +const viewport = renderingEngine.getViewport(viewportId); +await viewport.setDisplaySets({ displaySetId }); + +viewport.getDisplaySets(); // [{ displaySetId }] — reflects what was mounted +``` + +`getDisplaySets()` is available on both the legacy `Viewport` and the generic +viewport, so mounted display sets can be read uniformly across either hierarchy. + +The runnable end-to-end version (all five viewport families, plus a dropdown to +switch a display set among its allowed viewport types) is the **Display Sets** +example under `packages/core/examples/displaySets`. + +## Caching display sets in the metadata layer + +Independently of rendering, a display set can be stored in the typed metadata +cache so any consumer (tools, measurements, custom UI) can resolve it from any +of its image ids: + +```ts +import { + registerDisplaySetProviders, + registerDisplaySetMetadata, + Enums, + metaData, +} from '@cornerstonejs/metadata'; + +// Once at app init (after registerDefaultProviders): +registerDisplaySetProviders(); + +// After creating a display set, cache it keyed by its (underlying) image ids: +registerDisplaySetMetadata(seriesImageIds, displaySet); + +// Anywhere downstream, resolve the display set from one of its image ids: +const ds = metaData.getTyped(Enums.MetadataModules.DISPLAY_SET, imageId); +ds?.instances; // the full IDisplaySet — including instances and split-rule +ds?.numImageFrames; // attributes such as isClip / numImageFrames / splitNumber +``` + +`getTyped(MetadataModules.DISPLAY_SET, …)` returns the full `IDisplaySet` that +was registered, not a narrowed projection, so the cached shape and the typed +read never drift apart. + +## Split rules + +Split rules decide how a series' instances are grouped into display sets and +which viewport types each group supports. `defaultDisplaySetSplitRules` covers +the common DICOM cases (video, ECG, whole-slide, single-image modalities, +multi-frame clips, mixed-b-value DWI, volumetric series, and a fallback image +rule). Rules are evaluated **in order, first match wins per instance**. + +A `SplitRule` has up to five parts: + +| Field | Purpose | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `matches` | Returns true if an instance belongs to this rule. Omit to match everything. | +| `groupBy` | Keys (tag names or functions) that partition matched instances into separate display sets. | +| `series` | Optional. Runs once per rule per split and **returns** that rule's derived facts; `matches`/`groupBy` read them via `series`. | +| `viewportTypes` | Allowed viewport types for the produced display sets; index `0` is preferred. | +| `customAttributes` | Returns extra attributes spread flat onto the display set (e.g. `isClip`, `numImageFrames`). | + +Most rules only need `matches` and `groupBy`: + +```ts +{ + matches: (instance) => isVideoInstance(instance), + groupBy: ['SOPInstanceUID'], +} +``` + +Reach for `series` only when a rule needs a value computed from the **whole +series** and reused by `matches` or `groupBy`. It is optional, runs **once per +rule per split operation**, and returns derived facts for that rule — it should +**not** mutate shared state. The DWI fix is the worked example: `series` decides +whether the series mixes b-value and non-b-value frames, and `groupBy` then +separates them into two display sets: + +```ts +import type { SplitRule } from '@cornerstonejs/metadata'; + +const mixedDimensionalityBValue: SplitRule = { + id: 'mixedDimensionalityBValue', + viewportTypes: ['volume', 'volume3d', 'stack'], + // Computed once over the whole series; returned, not mutated onto shared state. + series: ({ instances }) => ({ + mixedBValue: + instances[0]?.Modality === 'MR' && + instances.some((i) => i.DiffusionBValue !== undefined) && + instances.some((i) => i.DiffusionBValue === undefined), + }), + // Reads this rule's own derived facts. + matches: (_instance, { series }) => series.mixedBValue, + // Two display sets: undefined-b-value frames split off from the rest. + groupBy: [ + 'SeriesInstanceUID', + (instance) => instance.DiffusionBValue === undefined, + ], +}; +``` + +To customize splitting, prepend your own rules to (or replace) the defaults and +pass the result as `splitRules`. `customAttributes` may set any attribute, but +the resolved data fields a display set is built from — `imageIds`, +`underlyingImageIds`, `instances`, and `displaySetId` — are reserved and cannot +be overwritten, so the underlying-vs-frame image id invariant the viewports rely +on always holds. + +A few engine guarantees worth knowing when writing rules: + +- **Buckets are namespaced by rule.** Two different rules can never merge into + one display set even if their `groupBy` values coincide. +- **Group order is deterministic.** Groups come back sorted by a stable, + rule-namespaced key, so a series' display sets — and any id derived from their + position — are stable regardless of the order the image ids were passed in. +- **`series` samples `instances[0]`** for some facts (e.g. multi-frame, + volumetric), so those rules assume a homogeneous series. A heterogeneous series + needs a dedicated rule (as `mixedDimensionalityBValue` does for DWI) to + separate it. +- **`series` is scoped to its own rule.** A rule only ever sees the facts its own + `series` hook returned; it cannot read another rule's facts, and it must not + mutate shared state. +- **Unmatched instances are dropped.** An instance that matches no rule (e.g. a + non-image SOP) produces no display set; pass `onUnmatchedInstance` to + `splitImageIdsBySplitRules` to observe them. +- **`buildSeriesInfo` is safe on an empty instance list** — it returns zeroed + counts. It aggregates series statistics only and is independent of split rules. + +### Instance classifiers + +The default rules rely on small SOP-class/modality heuristics that are also +exported for reuse, so you can detect a series' kind without re-hardcoding UID +lists: + +- `isImageInstance(instance)` — the SOP class carries renderable pixel data. +- `isVideoInstance(instance)` — video transfer syntax (reusing the shared + `videoUIDs` list), a video SOP class, or a long multi-frame secondary capture. +- `isEcgInstance(instance)` — an ECG / waveform SOP class. +- `isWsiInstance(instance)` — VL Whole Slide Microscopy storage, or modality `SM`. + +## Display set attributes (`IDisplaySet`) + +A display set implements `IDisplaySet`, which declares the **common attributes** +read from a display set as plain data — not accessor methods — so it behaves +like the OHIF display set object: + +```ts +const displaySet = createDisplaySetFromGroup(group); + +displaySet.displaySetId; +displaySet.viewportTypes; // readonly ViewportTypeHint[] +displaySet.preferredViewportType; // viewportTypes[0] +displaySet.instances; // readonly NaturalizedInstance[] +displaySet.imageIds; // frame-level, renderable image ids +displaySet.underlyingImageIds; // SOP-level image ids (one per instance) +``` + +### Adding new display set attributes + +- **Shared / common attributes** belong on `IDisplaySet` directly. Declare them + optional unless every display set populates them. Many are produced by a split + rule's `customAttributes` callback and spread flat onto the display set in + `createDisplaySetFromGroup` (for example `isMultiFrame`, `isClip`, + `numImageFrames`, `splitNumber`). +- **App- or extension-specific attributes** that are not part of the common model + should be added through **TypeScript module augmentation**, so they stay + type-checked without widening the shared surface: + + ```ts + // my-extension.ts — in an extension or the consuming app + import '@cornerstonejs/metadata'; + + declare module '@cornerstonejs/metadata' { + interface IDisplaySet { + /** Whether this display set supports window/level. */ + supportsWindowLevel?: boolean; + } + } + ``` + +Keep augmented attributes optional — not all display set types define them. + +## Related docs + +- [Cornerstone Metadata](./index.md) +- [Metadata Providers](../cornerstone-core/metadataProvider.md) diff --git a/packages/docs/docs/concepts/cornerstone-metadata/index.md b/packages/docs/docs/concepts/cornerstone-metadata/index.md index 5d3e069c17..8537695ab0 100644 --- a/packages/docs/docs/concepts/cornerstone-metadata/index.md +++ b/packages/docs/docs/concepts/cornerstone-metadata/index.md @@ -65,6 +65,19 @@ flow, required providers must be re-registered after init. This is especially important during migration from older code paths where provider registration happened once and relied on persistent global state. +## Display sets + +The metadata layer also organizes a series' instances into **display sets** — +the unit a viewport renders — via framework-agnostic split rules, exposed as the +data-shaped `IDisplaySet`. Because this is a large topic (the split pipeline, +driving a viewport, caching, the split-rule model, and the data model), it has +its own page: + +- **[Display sets](./display-sets.md)** — `splitImageIdsBySplitRules` → + `createDisplaySetFromGroup` → driving a viewport via `setDisplaySets` / caching + via `registerDisplaySetMetadata`, the split-rule model (with the DWI worked + example), and the `IDisplaySet` attributes + module-augmentation pattern. + ## Package boundaries - `@cornerstonejs/metadata`: metadata ingestion, provider chains, normalized diff --git a/packages/docs/docs/migration-guides/5x/2-generic-viewport.md b/packages/docs/docs/migration-guides/5x/2-generic-viewport.md index 1adc122010..088428f222 100644 --- a/packages/docs/docs/migration-guides/5x/2-generic-viewport.md +++ b/packages/docs/docs/migration-guides/5x/2-generic-viewport.md @@ -297,7 +297,7 @@ import { Enums, utilities, type PlanarViewport } from '@cornerstonejs/core'; const viewport = renderingEngine.getViewport(viewportId); const displaySetId = 'ct-stack'; -utilities.genericViewportDataSetMetadataProvider.add(displaySetId, { +utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { kind: 'planar', imageIds, initialImageIdIndex: 0, @@ -315,7 +315,7 @@ For a volume-backed planar slice, include the `volumeId` in the registered display set: ```ts -utilities.genericViewportDataSetMetadataProvider.add(displaySetId, { +utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/docs/sidebars.js b/packages/docs/sidebars.js index b9e18dfc64..6fb1a234d5 100644 --- a/packages/docs/sidebars.js +++ b/packages/docs/sidebars.js @@ -169,7 +169,10 @@ module.exports = { label: 'Metadata', link: { type: 'doc', id: 'concepts/cornerstone-metadata/index' }, collapsed: true, - items: ['concepts/cornerstone-metadata/index'], + items: [ + 'concepts/cornerstone-metadata/index', + 'concepts/cornerstone-metadata/display-sets', + ], }, { type: 'category', diff --git a/packages/metadata/jest.config.js b/packages/metadata/jest.config.js index f6dd31be8c..f9560bfe13 100644 --- a/packages/metadata/jest.config.js +++ b/packages/metadata/jest.config.js @@ -5,7 +5,11 @@ const path = require('path'); module.exports = { ...base, displayName: 'metadata', - transformIgnorePatterns: ['/node_modules/(?!(@kitware|dcmjs)/.*)'], + testMatch: [ + ...base.testMatch, + '/src/**/*.test.ts', + '/src/**/*.spec.ts', + ], moduleNameMapper: { ...base.moduleNameMapper, '^@cornerstonejs/(\\w+)/(.+)$': path.resolve(__dirname, '../$1/src/$2'), diff --git a/packages/metadata/src/displayset/BaseDisplaySet.ts b/packages/metadata/src/displayset/BaseDisplaySet.ts new file mode 100644 index 0000000000..b3a1695829 --- /dev/null +++ b/packages/metadata/src/displayset/BaseDisplaySet.ts @@ -0,0 +1,36 @@ +import type { IDisplaySet } from './IDisplaySet'; +import type { NaturalizedInstance, ViewportTypeHint } from './types'; +import { getPreferredViewportType } from './viewportTypes'; + +export type BaseDisplaySetOptions = { + displaySetId: string; + viewportTypes?: readonly ViewportTypeHint[]; + instances?: NaturalizedInstance[]; + imageIds?: Iterable; + underlyingImageIds?: Iterable; +}; + +/** + * Base display set metadata exposing the common {@link IDisplaySet} attributes. + * Attributes are computed once at construction (see IDisplaySet for the + * data-attribute rationale and the extension pattern for additional attributes). + */ +export class BaseDisplaySet implements IDisplaySet { + displaySetId: string; + viewportTypes: readonly ViewportTypeHint[]; + preferredViewportType: ViewportTypeHint; + readonly instances: readonly NaturalizedInstance[]; + readonly imageIds: readonly string[]; + readonly underlyingImageIds: readonly string[]; + + constructor(options: BaseDisplaySetOptions) { + this.displaySetId = options.displaySetId; + this.viewportTypes = options.viewportTypes?.length + ? [...options.viewportTypes] + : ['stack']; + this.preferredViewportType = getPreferredViewportType(this.viewportTypes); + this.instances = [...(options.instances ?? [])]; + this.imageIds = [...new Set(options.imageIds ?? [])]; + this.underlyingImageIds = [...new Set(options.underlyingImageIds ?? [])]; + } +} diff --git a/packages/metadata/src/displayset/IDisplaySet.ts b/packages/metadata/src/displayset/IDisplaySet.ts new file mode 100644 index 0000000000..3dfe017236 --- /dev/null +++ b/packages/metadata/src/displayset/IDisplaySet.ts @@ -0,0 +1,62 @@ +import type { NaturalizedInstance, ViewportTypeHint } from './types'; + +/** + * Framework-agnostic display set metadata stored in the Cornerstone metadata cache. + * + * `IDisplaySet` declares the **common attributes** read from a display set, + * matching the OHIF display set shape. They are plain data attributes — not + * accessor methods — so a display set behaves like a data object that can be + * destructured, spread, and serialized. + * + * Attributes that are shared across different display-set uses belong here, even + * when only some display-set types populate them (they are declared optional). + * Split rules produce many of these via their `customAttributes` callback; the + * values are spread flat onto the display set in `createDisplaySetFromGroup`. + * + * ## Adding new attributes + * + * - Shared / common attributes: add them to this interface (optional unless every + * display set sets them). + * - App- or extension-specific attributes that are not part of the common model: + * declare them with TypeScript module augmentation so they stay type-checked + * without widening this shared surface: + * + * ```ts + * // my-extension.ts + * import '@cornerstonejs/metadata'; + * + * declare module '@cornerstonejs/metadata' { + * interface IDisplaySet { + * myAppSpecificAttribute?: string; + * } + * } + * ``` + */ +export interface IDisplaySet { + /** Unique identifier for this display set. */ + displaySetId: string; + /** + * Allowed viewport types for this display set. + * `viewportTypes[0]` is the preferred viewport type. + */ + viewportTypes: readonly ViewportTypeHint[]; + /** Preferred viewport type (equivalent to `viewportTypes[0]`). */ + preferredViewportType: ViewportTypeHint; + /** Naturalized instances grouped into this display set, in input order. */ + instances: readonly NaturalizedInstance[]; + /** Frame-level, renderable image ids for this display set. */ + imageIds: readonly string[]; + /** Underlying (SOP-level) image ids, one per instance. */ + underlyingImageIds: readonly string[]; + + // ── Shared attributes (populated by split rules / specific types) ────────── + + /** True when this display set is a multi-frame (clip) image stack. */ + isMultiFrame?: boolean; + /** True for multi-frame clip display sets (OHIF parity). */ + isClip?: boolean; + /** Number of image frames for a clip / multi-frame display set. */ + numImageFrames?: number; + /** 0-based index of this display set among the series' split groups. */ + splitNumber?: number; +} diff --git a/packages/metadata/src/displayset/ImageStackDisplaySet.ts b/packages/metadata/src/displayset/ImageStackDisplaySet.ts new file mode 100644 index 0000000000..48e15a618d --- /dev/null +++ b/packages/metadata/src/displayset/ImageStackDisplaySet.ts @@ -0,0 +1,106 @@ +import { BaseDisplaySet, type BaseDisplaySetOptions } from './BaseDisplaySet'; +import type { NaturalizedInstance, ViewportTypeHint } from './types'; + +export type ImageStackDisplaySetOptions = Omit< + BaseDisplaySetOptions, + 'imageIds' | 'underlyingImageIds' +> & { + instances?: NaturalizedInstance[]; + imageIds?: Iterable; + underlyingImageIds?: Iterable; +}; + +function collectUnderlyingImageIds(instances: NaturalizedInstance[]): string[] { + const ids: string[] = []; + for (const instance of instances) { + if (instance.imageId) { + ids.push(instance.imageId); + } + } + return ids; +} + +function collectImageIds( + instances: NaturalizedInstance[], + underlyingImageIds: string[] +): string[] { + const imageIds: string[] = []; + for (const instance of instances) { + if (instance.imageId) { + imageIds.push(instance.imageId); + } + } + if (imageIds.length === 0) { + return [...underlyingImageIds]; + } + return imageIds; +} + +/** + * Image/stack display set metadata with underlying vs frame image id semantics. + */ +export class ImageStackDisplaySet extends BaseDisplaySet { + isMultiFrame: boolean; + + constructor(options: ImageStackDisplaySetOptions) { + const instances = options.instances ?? []; + const underlyingImageIds = + options.underlyingImageIds ?? collectUnderlyingImageIds(instances); + const underlyingList = [...underlyingImageIds]; + const imageIds = + options.imageIds ?? collectImageIds(instances, underlyingList); + + super({ + ...options, + instances, + imageIds, + underlyingImageIds: underlyingList, + }); + this.isMultiFrame = instances.some( + (instance) => Number(instance.NumberOfFrames) > 1 + ); + } + + static fromInstances( + instances: NaturalizedInstance[], + options?: { + displaySetId?: string; + viewportTypes?: readonly ViewportTypeHint[]; + imageIds?: Iterable; + } + ): ImageStackDisplaySet { + const displaySetId = + options?.displaySetId ?? + instances[0]?.SeriesInstanceUID ?? + `display-set-${instances[0]?.imageId ?? 'unknown'}`; + + return new ImageStackDisplaySet({ + displaySetId, + viewportTypes: options?.viewportTypes ?? ['stack', 'volume', 'volume3d'], + instances, + imageIds: options?.imageIds, + }); + } + + static fromImageIds( + imageIds: string[], + getNaturalizedInstance: ( + imageId: string + ) => NaturalizedInstance | undefined, + options?: { + displaySetId?: string; + viewportTypes?: readonly ViewportTypeHint[]; + } + ): ImageStackDisplaySet { + const instances = imageIds + .map((imageId) => getNaturalizedInstance(imageId)) + .filter( + (instance): instance is NaturalizedInstance => instance !== undefined + ); + + return ImageStackDisplaySet.fromInstances(instances, { + ...options, + imageIds, + }); + } +} diff --git a/packages/metadata/src/displayset/buildSeriesInfo.ts b/packages/metadata/src/displayset/buildSeriesInfo.ts new file mode 100644 index 0000000000..ac053922e2 --- /dev/null +++ b/packages/metadata/src/displayset/buildSeriesInfo.ts @@ -0,0 +1,43 @@ +import type { NaturalizedInstance, SeriesInfo } from './types'; + +/** + * Aggregates series-level statistics over a series' naturalized instances. + * + * Independent of split rules: a rule derives its own facts through its `series` + * hook (see {@link RuleContext}), so this helper only counts and is safe to call + * with an empty `instances` list (it returns zeroed counts). + * + * @param instances - naturalized instances for the series. + * @returns the aggregated series statistics. + */ +export function buildSeriesInfo(instances: NaturalizedInstance[]): SeriesInfo { + const NumberOfSeriesRelatedInstances = instances.length; + let numberOfFrames = 0; + let numberOfNonImageObjects = 0; + let numberOfSOPInstanceUIDsPerSeries = 0; + + for (const instance of instances) { + if (instance.NumberOfFrames) { + numberOfFrames += Number(instance.NumberOfFrames); + } else if (instance.Rows) { + numberOfFrames += 1; + } else { + numberOfNonImageObjects += 1; + } + + if (instance.SOPInstanceUID) { + numberOfSOPInstanceUIDsPerSeries += 1; + } + } + + return { + NumberOfSeriesRelatedInstances, + numberOfFrames, + // `numImageFrames` mirrors `numberOfFrames` here purely for OHIF parity (the + // display-set shape exposes `numImageFrames`); they intentionally hold the + // same series-level frame count. + numImageFrames: numberOfFrames, + numberOfNonImageObjects, + numberOfSOPInstanceUIDsPerSeries, + }; +} diff --git a/packages/metadata/src/displayset/createDisplaySetFromGroup.ts b/packages/metadata/src/displayset/createDisplaySetFromGroup.ts new file mode 100644 index 0000000000..db2f30e454 --- /dev/null +++ b/packages/metadata/src/displayset/createDisplaySetFromGroup.ts @@ -0,0 +1,159 @@ +import { BaseDisplaySet } from './BaseDisplaySet'; +import { ImageStackDisplaySet } from './ImageStackDisplaySet'; +import { isEcgInstance } from './isEcgInstance'; +import { isVideoInstance } from './isVideoInstance'; +import { isWsiInstance } from './isWsiInstance'; +import type { IDisplaySet } from './IDisplaySet'; +import type { InstanceGroup, ViewportTypeHint } from './types'; +import { + getPreferredViewportType, + getViewportTypesForGroup, +} from './viewportTypes'; + +export type CreateDisplaySetFromGroupOptions = { + displaySetId?: string; + imageIds?: Iterable; + /** 0-based index of this group among the series' split groups. */ + splitNumber?: number; + descriptionName?: string; +}; + +/** + * Resolved data fields that custom attributes must never overwrite. They are + * declared `readonly` on the display set, but `readonly` is erased at runtime, + * so the constructor-assigned fields stay writable - a consumer split rule + * returning e.g. `{ imageIds: [...] }` would otherwise clobber the resolved ids + * and break the underlying-vs-frame invariant the viewports rely on. + */ +const RESERVED_ATTRIBUTE_KEYS = new Set([ + 'imageIds', + 'underlyingImageIds', + 'instances', + 'displaySetId', +]); + +/** + * Returns true unless `key` resolves to a read-only accessor (getter without a + * setter) somewhere on the display set's prototype chain, so custom attributes + * never clobber a computed getter. + */ +function isAssignable(target: object, key: string): boolean { + let obj: object | null = target; + while (obj) { + const descriptor = Object.getOwnPropertyDescriptor(obj, key); + if (descriptor) { + if (descriptor.get || descriptor.set) { + return typeof descriptor.set === 'function'; + } + return descriptor.writable !== false; + } + obj = Object.getPrototypeOf(obj); + } + return true; +} + +/** + * Runs the matched rule's `customAttributes` (if any) and spreads the returned + * attributes flat onto the display set (shared attributes are declared on + * IDisplaySet). A `viewportTypes` key in the returned attributes overrides the + * rule's default viewport types; `preferredViewportType` is kept in sync + * afterwards. Reserved data fields (see {@link RESERVED_ATTRIBUTE_KEYS}) and + * keys backed by a read-only accessor on the display set are skipped rather than + * overridden. + */ +function applyCustomAttributes( + displaySet: IDisplaySet, + group: InstanceGroup, + viewportTypes: readonly ViewportTypeHint[], + options: CreateDisplaySetFromGroupOptions +): void { + const { instances, matchedRule } = group; + const first = instances[0]; + if (!matchedRule.customAttributes || !first) { + return; + } + + const sopClassUids = [ + ...new Set(instances.map((i) => i.SOPClassUID).filter(Boolean)), + ]; + const isMultiFrame = Number(first.NumberOfFrames) > 1; + + const attributes = matchedRule.customAttributes( + { instance: first, isMultiFrame, sopClassUids, viewportTypes }, + { + instances, + splitNumber: options.splitNumber, + descriptionName: options.descriptionName, + } + ); + + if (!attributes) { + return; + } + + for (const [key, value] of Object.entries(attributes)) { + if (RESERVED_ATTRIBUTE_KEYS.has(key)) { + continue; + } + if (isAssignable(displaySet, key)) { + (displaySet as unknown as Record)[key] = value; + } + } + + // Keep the preferred viewport attribute consistent if customAttributes + // overrode the allowed viewport types. + displaySet.preferredViewportType = getPreferredViewportType( + displaySet.viewportTypes + ); +} + +/** + * Builds cornerstone display set metadata for an instance group. + */ +export function createDisplaySetFromGroup( + group: InstanceGroup, + options: CreateDisplaySetFromGroupOptions = {} +): IDisplaySet { + const viewportTypes = getViewportTypesForGroup(group); + const { instances } = group; + // A single series can split into multiple display sets (e.g. the DWI + // mixed-b-value split), so the default id folds in the 0-based `splitNumber` + // to stay unique within a series rather than collapsing every split to the + // bare SeriesInstanceUID. This is the same value callers pass to a viewport as + // `displaySetId` - the metadata id and the viewport/registry id are one. + const baseDisplaySetId = + instances[0]?.SeriesInstanceUID ?? + `display-set-${instances[0]?.imageId ?? 'unknown'}`; + const displaySetId = + options.displaySetId ?? + (options.splitNumber + ? `${baseDisplaySetId}:${options.splitNumber}` + : baseDisplaySetId); + + const first = instances[0]; + let displaySet: IDisplaySet; + + if ( + first && + (isVideoInstance(first) || isEcgInstance(first) || isWsiInstance(first)) + ) { + const imageIds = instances.map((i) => i.imageId).filter(Boolean); + displaySet = new BaseDisplaySet({ + displaySetId, + viewportTypes, + instances, + imageIds: options.imageIds ?? imageIds, + underlyingImageIds: imageIds, + }); + } else { + displaySet = ImageStackDisplaySet.fromInstances(instances, { + displaySetId, + viewportTypes, + imageIds: options.imageIds, + }); + } + + applyCustomAttributes(displaySet, group, viewportTypes, options); + + return displaySet; +} diff --git a/packages/metadata/src/displayset/defaultDisplaySetSplitRules.ts b/packages/metadata/src/displayset/defaultDisplaySetSplitRules.ts new file mode 100644 index 0000000000..7ce24a4d32 --- /dev/null +++ b/packages/metadata/src/displayset/defaultDisplaySetSplitRules.ts @@ -0,0 +1,144 @@ +import { isEcgInstance } from './isEcgInstance'; +import { isImageInstance } from './isImageInstance'; +import { isVideoInstance } from './isVideoInstance'; +import { isWsiInstance } from './isWsiInstance'; +import type { SplitRule } from './types'; + +const VOLUME_MODALITIES = new Set(['CT', 'MR', 'PT', 'NM']); + +/** + * Default display-set split rules (OHIF PR parity + video, ECG, volume3d). + * Rules are evaluated in order; the first match wins. Each rule's `viewportTypes` + * (index 0 = preferred) is applied to the resulting display set, so only rules + * that add *other* attributes need a `customAttributes` callback. + */ +export const defaultDisplaySetSplitRules: SplitRule[] = [ + { + id: 'video', + viewportTypes: ['video'], + matches: (instance) => isVideoInstance(instance), + groupBy: ['SOPInstanceUID'], + }, + + { + id: 'ecg', + viewportTypes: ['ecg'], + matches: (instance) => isEcgInstance(instance), + groupBy: ['SOPInstanceUID'], + }, + + { + id: 'wholeslide', + viewportTypes: ['wholeslide'], + // All microscopy levels of a series form a single whole-slide display set. + matches: (instance) => isWsiInstance(instance), + groupBy: ['SeriesInstanceUID'], + }, + + { + id: 'singleImageModality', + viewportTypes: ['stack'], + matches: (instance) => + ['CR', 'DX', 'MG'].includes(instance.Modality ?? '') && + isImageInstance(instance) && + !!instance.Rows, + // Split within the series by a coarse size bucket so differently-sized + // images (e.g. MG views) become separate stacks. `SeriesInstanceUID` keeps + // the bucket series-scoped (the entry point is per-series, but this stays + // correct if ever fed multiple series). The `/64` rounding is a deliberately + // fuzzy bucket and can straddle a boundary (480 -> 8, 544 -> 9). + groupBy: [ + 'SeriesInstanceUID', + (instance) => + `rows=${Math.round(Number(instance.Rows) / 64)}&cols=${Math.round(Number(instance.Columns) / 64)}`, + ], + }, + + { + id: 'multiFrame', + viewportTypes: ['stack'], + // Assumes a homogeneous series: samples instances[0] for NumberOfFrames / + // SliceLocation. The `SliceLocation !== undefined` guard mirrors OHIF - a + // multi-frame object without a slice location is not treated as a clip here + // and falls through to the volume/stack rules below. + series: ({ instances }) => { + const first = instances[0]; + return { + isMultiFrame: + Number(first?.NumberOfFrames) > 1 && + first?.SliceLocation !== undefined, + }; + }, + matches: (_instance, { series }) => !!series.isMultiFrame, + groupBy: ['SeriesInstanceUID', 'InstanceNumber'], + customAttributes: ({ isMultiFrame }, options) => { + // NumberOfFrames is frequently naturalized as a string (e.g. '30'); coerce + // it so numImageFrames matches its declared `number` type. + const numberOfFrames = options.instances[0]?.NumberOfFrames; + return { + isClip: true, + numImageFrames: + numberOfFrames === undefined ? undefined : Number(numberOfFrames), + splitNumber: options.splitNumber, + isMultiFrame, + }; + }, + }, + + /** + * This rule splits off images containing an undefined bValue from the + * 4d b-value containing images, since the undefined versions are not + * part of the 4d data set. That prevents applying incorrect 4d rendering + * to the 3d portion. + */ + { + id: 'mixedDimensionalityBValue', + // Both subgroups are multi-slice MR; default them to MPR (volume) like any + // volumetric MR series. This rule matches before `volume3d`, so listing + // stack first here would regress the defined-b-value subgroup to a stack. + viewportTypes: ['volume', 'volume3d', 'stack'], + // Gates on instances[0].Modality (assumes a homogeneous-modality series), + // then scans all instances for the mix of defined/undefined b-values. + series: ({ instances }) => { + const [instance] = instances; + if (!instance || instance.Modality !== 'MR') { + return { mixedBValue: false }; + } + const hasBValue = instances.some((i) => i.DiffusionBValue !== undefined); + const missingBValue = instances.some( + (i) => i.DiffusionBValue === undefined + ); + return { mixedBValue: hasBValue && missingBValue }; + }, + matches: (_instance, { series }) => !!series.mixedBValue, + groupBy: [ + 'SeriesInstanceUID', + (instance) => instance.DiffusionBValue === undefined, + ], + }, + + { + id: 'volume3d', + // Default volumetric series to MPR (volume); 3D is an extra allowed type. + viewportTypes: ['volume', 'volume3d', 'stack'], + // Assumes a homogeneous series: samples instances[0].Modality. A + // heterogeneous series (e.g. a localizer first, then a volume) can be + // misflagged - add a dedicated split rule (as `mixedDimensionalityBValue` + // does for DWI) when a specific mix must be separated. + series: ({ instances }) => { + const modality = instances[0]?.Modality; + return { + supportsVolume3d: + !!modality && VOLUME_MODALITIES.has(modality) && instances.length > 1, + }; + }, + matches: (_instance, { series }) => !!series.supportsVolume3d, + groupBy: ['SeriesInstanceUID'], + }, + + { + id: 'defaultImageRule', + viewportTypes: ['stack', 'volume', 'volume3d'], + matches: (instance) => isImageInstance(instance) && !!instance.Rows, + }, +]; diff --git a/packages/metadata/src/displayset/displaySetProvider.ts b/packages/metadata/src/displayset/displaySetProvider.ts new file mode 100644 index 0000000000..4016eea30a --- /dev/null +++ b/packages/metadata/src/displayset/displaySetProvider.ts @@ -0,0 +1,21 @@ +import { MetadataModules } from '../enums'; +import { addAddProvider } from '../metaData'; +import { addWritableCacheForType } from '../utilities/metadataProvider/cacheData'; + +function displaySetAddProvider(next, query: string, _data, options) { + const displaySet = options?.displaySet; + if (displaySet) { + return displaySet; + } + return next(query, _data, options); +} + +/** + * Registers read/add typed providers for display set metadata. + */ +export function registerDisplaySetProviders() { + addWritableCacheForType(MetadataModules.DISPLAY_SET); + addAddProvider(MetadataModules.DISPLAY_SET, displaySetAddProvider, { + priority: 40_000, + }); +} diff --git a/packages/metadata/src/displayset/displayset.test.ts b/packages/metadata/src/displayset/displayset.test.ts new file mode 100644 index 0000000000..e7a139e59d --- /dev/null +++ b/packages/metadata/src/displayset/displayset.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from '@jest/globals'; +import { buildSeriesInfo } from './buildSeriesInfo'; +import { createDisplaySetFromGroup } from './createDisplaySetFromGroup'; +import { defaultDisplaySetSplitRules } from './defaultDisplaySetSplitRules'; +import { groupInstancesBySplitRules } from './groupInstancesBySplitRules'; +import { ImageStackDisplaySet } from './ImageStackDisplaySet'; +import { isVideoInstance } from './isVideoInstance'; +import { resolveInstances } from './resolveInstances'; +import { splitImageIdsBySplitRules } from './splitImageIdsBySplitRules'; +import type { InstanceGroup, NaturalizedInstance, SplitRule } from './types'; +import { getPreferredViewportType } from './viewportTypes'; + +describe('displayset split utilities', () => { + const instances: NaturalizedInstance[] = [ + { + imageId: 'wadors:1', + Modality: 'CT', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.2', + Rows: 512, + Columns: 512, + SeriesInstanceUID: '1.2.3', + InstanceNumber: 1, + }, + { + imageId: 'wadors:2', + Modality: 'CT', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.2', + Rows: 512, + Columns: 512, + SeriesInstanceUID: '1.2.3', + InstanceNumber: 2, + }, + ]; + + const getNaturalizedInstance = (imageId: string) => + instances.find((instance) => instance.imageId === imageId); + + it('resolveInstances preserves order and skips missing ids', () => { + const resolved = resolveInstances( + ['wadors:2', 'wadors:missing', 'wadors:1'], + getNaturalizedInstance + ); + expect(resolved.map((i) => i.imageId)).toEqual(['wadors:2', 'wadors:1']); + }); + + it('default rules group multi-slice CT as volume (MPR) preferred', () => { + const groups = splitImageIdsBySplitRules( + instances.map((i) => i.imageId!), + { + getNaturalizedInstance: (id) => instances.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + } + ); + expect(groups).toHaveLength(1); + expect(groups[0].matchedRule.id).toBe('volume3d'); + const displaySet = createDisplaySetFromGroup(groups[0]); + // The volume3d rule defaults volumetric series to MPR (volume); volume3d + // remains an allowed-but-not-preferred viewport type. + expect(displaySet.viewportTypes[0]).toBe('volume'); + expect(displaySet.viewportTypes).toContain('volume3d'); + expect(displaySet.preferredViewportType).toBe('volume'); + }); + + it('video rule uses video viewportTypes', () => { + const videoInstance: NaturalizedInstance = { + imageId: 'wadors:video', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.77.1.4.1', + Modality: 'US', + }; + const groups = splitImageIdsBySplitRules(['wadors:video'], { + getNaturalizedInstance: () => videoInstance, + splitRules: defaultDisplaySetSplitRules, + }); + expect(groups[0].matchedRule.id).toBe('video'); + const displaySet = createDisplaySetFromGroup(groups[0]); + expect(displaySet.viewportTypes).toEqual(['video']); + expect(getPreferredViewportType(displaySet.viewportTypes)).toBe('video'); + // The video display set exposes its instances so consumers (e.g. the video + // viewport's setDisplaySets) can resolve the source imageId directly. + expect(displaySet.instances[0]?.imageId).toBe('wadors:video'); + }); + + it('ecg rule uses ecg viewportTypes', () => { + const ecgInstance: NaturalizedInstance = { + imageId: 'wadors:ecg', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.9.1.1', + Modality: 'ECG', + }; + const groups = splitImageIdsBySplitRules(['wadors:ecg'], { + getNaturalizedInstance: () => ecgInstance, + splitRules: defaultDisplaySetSplitRules, + }); + expect(groups[0].matchedRule.id).toBe('ecg'); + expect(createDisplaySetFromGroup(groups[0]).viewportTypes[0]).toBe('ecg'); + }); + + it('splits MR mixed B-value series', () => { + const mrInstances: NaturalizedInstance[] = [ + { + imageId: 'wadors:a', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'series-mr', + DiffusionBValue: 800, + }, + { + imageId: 'wadors:b', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'series-mr', + }, + ]; + const groups = splitImageIdsBySplitRules( + mrInstances.map((i) => i.imageId!), + { + getNaturalizedInstance: (id) => + mrInstances.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + } + ); + expect(groups).toHaveLength(2); + }); + + it('ImageStackDisplaySet exposes underlying and frame ids', () => { + const displaySet = ImageStackDisplaySet.fromInstances(instances, { + displaySetId: 'uid-1', + viewportTypes: ['stack', 'volume', 'volume3d'], + }); + expect(displaySet.underlyingImageIds.length).toBe(2); + expect(displaySet.viewportTypes[0]).toBe('stack'); + expect(displaySet.preferredViewportType).toBe('stack'); + }); + + it('groups by default image rule into a single group', () => { + const singleInstance = [instances[0]]; + const rules: SplitRule[] = [ + { + id: 'defaultImageRule', + viewportTypes: ['stack'], + matches: (instance) => + instance.SOPClassUID === '1.2.840.10008.5.1.4.1.1.2' && + !!instance.Rows, + }, + ]; + const groups = groupInstancesBySplitRules(singleInstance, rules); + expect(groups).toHaveLength(1); + expect(groups[0].instances).toHaveLength(1); + }); + + it('spreads matched-rule customAttributes flat onto the display set', () => { + const multiFrameInstances: NaturalizedInstance[] = [ + { + imageId: 'wadors:mf', + Modality: 'XA', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.12.1', + Rows: 512, + NumberOfFrames: 30, + SliceLocation: 0, + SeriesInstanceUID: 'series-mf', + InstanceNumber: 1, + }, + ]; + const groups = splitImageIdsBySplitRules(['wadors:mf'], { + getNaturalizedInstance: () => multiFrameInstances[0], + splitRules: defaultDisplaySetSplitRules, + }); + expect(groups[0].matchedRule.id).toBe('multiFrame'); + + const displaySet = createDisplaySetFromGroup(groups[0], { splitNumber: 2 }); + // customAttributes for the multiFrame rule are spread flat onto the display + // set; the keys are type-declared via the IDisplaySet extension. + expect(displaySet.isClip).toBe(true); + expect(displaySet.numImageFrames).toBe(30); + expect(displaySet.splitNumber).toBe(2); + expect(displaySet.viewportTypes).toEqual(['stack']); + }); + + it('coerces a string NumberOfFrames to a numeric numImageFrames', () => { + const multiFrameInstances: NaturalizedInstance[] = [ + { + imageId: 'wadors:mf-str', + Modality: 'XA', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.12.1', + Rows: 512, + // Naturalized DICOM frequently yields NumberOfFrames as a string. + NumberOfFrames: '30' as unknown as number, + SliceLocation: 0, + SeriesInstanceUID: 'series-mf-str', + InstanceNumber: 1, + }, + ]; + const groups = splitImageIdsBySplitRules(['wadors:mf-str'], { + getNaturalizedInstance: () => multiFrameInstances[0], + splitRules: defaultDisplaySetSplitRules, + }); + expect(groups[0].matchedRule.id).toBe('multiFrame'); + + const displaySet = createDisplaySetFromGroup(groups[0]); + expect(displaySet.numImageFrames).toBe(30); + expect(typeof displaySet.numImageFrames).toBe('number'); + }); + + it('classifies an MPEG2 transfer syntax instance as video', () => { + // MPEG2 Main Profile @ Main Level - in the shared videoUIDs list but absent + // from the previously hard-coded subset, so this guards against regressing + // back to a second drifting list. + const mpeg2Instance: NaturalizedInstance = { + imageId: 'wadors:mpeg2', + // A non-video image SOP class so only the transfer syntax can match. + SOPClassUID: '1.2.840.10008.5.1.4.1.1.7', + TransferSyntaxUID: '1.2.840.10008.1.2.4.100', + Modality: 'OT', + }; + expect(isVideoInstance(mpeg2Instance)).toBe(true); + }); + + it('buildSeriesInfo and grouping are safe on an empty instance list', () => { + // buildSeriesInfo only aggregates counts; grouping derives each rule's + // `series` facts up front. Both must be safe when given no instances. + expect(() => buildSeriesInfo([])).not.toThrow(); + + const seriesInfo = buildSeriesInfo([]); + expect(seriesInfo.NumberOfSeriesRelatedInstances).toBe(0); + expect(groupInstancesBySplitRules([], defaultDisplaySetSplitRules)).toEqual( + [] + ); + }); + + it('does not let customAttributes clobber resolved data fields', () => { + const stackInstances: NaturalizedInstance[] = [ + { + imageId: 'wadors:reserved', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.2', + Rows: 512, + SeriesInstanceUID: 'series-reserved', + InstanceNumber: 1, + }, + ]; + const group: InstanceGroup = { + instances: stackInstances, + matchedRule: { + id: 'reserved-clobber', + viewportTypes: ['stack'], + customAttributes: () => ({ + // Reserved data fields must be ignored ... + imageIds: ['evil-frame'], + underlyingImageIds: ['evil-underlying'], + instances: [], + displaySetId: 'evil-uid', + // ... while non-reserved custom attributes are still applied. + customFlag: true, + }), + }, + }; + + const displaySet = createDisplaySetFromGroup(group, { + displaySetId: 'good-uid', + }); + + expect(displaySet.imageIds).toEqual(['wadors:reserved']); + expect(displaySet.underlyingImageIds).toEqual(['wadors:reserved']); + expect(displaySet.instances).toHaveLength(1); + expect(displaySet.displaySetId).toBe('good-uid'); + expect((displaySet as unknown as Record).customFlag).toBe( + true + ); + }); + + it('derives unique displaySetIds for splits of one series', () => { + const seriesUID = 'series-split'; + const makeGroup = (imageId: string): InstanceGroup => ({ + instances: [ + { + imageId, + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: seriesUID, + }, + ], + matchedRule: { id: 'split', viewportTypes: ['stack'] }, + }); + + // A series can split into multiple display sets (the DWI case); the split + // index keeps their displaySetIds - used as the viewport id - unique + // instead of all collapsing to the bare SeriesInstanceUID. + const ds0 = createDisplaySetFromGroup(makeGroup('wadors:s0'), { + splitNumber: 0, + }); + const ds1 = createDisplaySetFromGroup(makeGroup('wadors:s1'), { + splitNumber: 1, + }); + + expect(ds0.displaySetId).toBe(seriesUID); + expect(ds1.displaySetId).toBe(`${seriesUID}:1`); + expect(ds0.displaySetId).not.toBe(ds1.displaySetId); + }); + + it('namespaces buckets by rule so identical split keys do not merge', () => { + const insts: NaturalizedInstance[] = [ + { imageId: 'a', Modality: 'XA' }, + { imageId: 'b', Modality: 'NM' }, + ]; + // Two different rules whose groupBy functions return the same string. + const rules: SplitRule[] = [ + { + id: 'ruleA', + matches: (i) => i.Modality === 'XA', + groupBy: [() => 'same'], + }, + { + id: 'ruleB', + matches: (i) => i.Modality === 'NM', + groupBy: [() => 'same'], + }, + ]; + const groups = groupInstancesBySplitRules(insts, rules); + + // Without rule-namespaced keys these would collapse into one bucket. + expect(groups).toHaveLength(2); + expect(new Set(groups.map((g) => g.matchedRule.id))).toEqual( + new Set(['ruleA', 'ruleB']) + ); + }); + + it('returns groups in a deterministic order regardless of input order', () => { + const mr: NaturalizedInstance[] = [ + { + imageId: 'a', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 's', + DiffusionBValue: 800, + }, + { + imageId: 'b', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 's', + }, + ]; + const options = { + getNaturalizedInstance: (id: string) => mr.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + }; + + const forward = splitImageIdsBySplitRules(['a', 'b'], options); + const reverse = splitImageIdsBySplitRules(['b', 'a'], options); + + expect(forward.map((g) => g.splitKey)).toEqual( + reverse.map((g) => g.splitKey) + ); + }); + + it('series hook splits a mixed-b-value DWI series into two display sets', () => { + const mixed: NaturalizedInstance[] = [ + { + imageId: 'b800', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'dwi', + DiffusionBValue: 800, + }, + { + imageId: 'noB', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'dwi', + }, + ]; + const groups = splitImageIdsBySplitRules(['b800', 'noB'], { + getNaturalizedInstance: (id) => mixed.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + }); + + expect(groups).toHaveLength(2); + expect( + groups.every((g) => g.matchedRule.id === 'mixedDimensionalityBValue') + ).toBe(true); + }); + + it('series hook leaves a non-mixed DWI series as one volume display set', () => { + // Every frame has a b-value, so the mixed-b-value rule must not fire; the + // series falls through to the volume3d rule as a single display set. + const allBValue: NaturalizedInstance[] = [ + { + imageId: 'b0', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'dwi-uniform', + DiffusionBValue: 0, + }, + { + imageId: 'b1000', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'dwi-uniform', + DiffusionBValue: 1000, + }, + ]; + const groups = splitImageIdsBySplitRules(['b0', 'b1000'], { + getNaturalizedInstance: (id) => allBValue.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + }); + + expect(groups).toHaveLength(1); + expect(groups[0].matchedRule.id).toBe('volume3d'); + }); + + it('reports instances that match no rule via onUnmatched', () => { + const insts: NaturalizedInstance[] = [ + { imageId: 'a', Modality: 'CT' }, + { imageId: 'b', Modality: 'SR' }, + ]; + const rules: SplitRule[] = [ + { + id: 'ct', + matches: (i) => i.Modality === 'CT', + groupBy: ['imageId'], + }, + ]; + const unmatched: string[] = []; + + const groups = groupInstancesBySplitRules(insts, rules, (i) => + unmatched.push(i.imageId!) + ); + + expect(unmatched).toEqual(['b']); + expect(groups).toHaveLength(1); + }); +}); diff --git a/packages/metadata/src/displayset/groupInstancesBySplitRules.ts b/packages/metadata/src/displayset/groupInstancesBySplitRules.ts new file mode 100644 index 0000000000..c27d834aa8 --- /dev/null +++ b/packages/metadata/src/displayset/groupInstancesBySplitRules.ts @@ -0,0 +1,100 @@ +import type { + InstanceGroup, + NaturalizedInstance, + RuleContext, + SplitRule, +} from './types'; + +/** + * Builds the bucket key an instance is grouped under for a given rule. + * + * The key is **namespaced by the rule** (via `ruleDiscriminator`) so two + * different rules can never share a bucket even if their split values coincide, + * and it is **JSON-encoded** so the parts can't collide through a separator - + * e.g. an `&` inside a tag value, or `undefined` vs `''` vs a missing tag, which + * a plain string join would alias together. + */ +function buildSplitKey( + instance: NaturalizedInstance, + context: RuleContext, + splitRule: SplitRule, + ruleDiscriminator: string | number +): string { + const groupBy = splitRule.groupBy ?? ['SeriesInstanceUID']; + const parts = groupBy.map((key) => + typeof key === 'function' ? key(instance, context) : instance[key] + ); + return JSON.stringify([ruleDiscriminator, ...parts]); +} + +/** + * Groups instances into instance groups using the first matching split rule per + * instance (rules are evaluated in order; first match wins). + * + * Each rule's optional `series` hook runs **once** here (per rule, per call) to + * derive that rule's series-level facts; those facts are passed to the rule's + * `matches` and `groupBy` via the {@link RuleContext}. A rule only ever sees its + * own derived facts. + * + * Groups are returned in a **deterministic order** (sorted by their bucket key), + * so a series' display sets - and any identity derived from their position - + * are stable regardless of the order the imageIds were passed in. + * + * @param onUnmatched - called for each instance that matches no rule and is + * therefore placed in no group (e.g. a non-image SOP such as an SR or + * presentation state). Lets callers observe what was dropped instead of it + * disappearing silently. + */ +export function groupInstancesBySplitRules( + instances: NaturalizedInstance[], + splitRules: SplitRule[], + onUnmatched?: (instance: NaturalizedInstance) => void +): InstanceGroup[] { + if (!instances.length) { + return []; + } + + // Derive each rule's series-level facts once for this split operation, so the + // per-instance `matches`/`groupBy` only read an already-computed value. + const ruleContexts: RuleContext[] = splitRules.map((rule) => ({ + series: rule.series?.({ instances }) ?? {}, + })); + + const instancesMap = new Map(); + + for (const instance of instances) { + let matched = false; + + for (let ruleIndex = 0; ruleIndex < splitRules.length; ruleIndex++) { + const splitRule = splitRules[ruleIndex]; + const context = ruleContexts[ruleIndex]; + if (splitRule.matches && !splitRule.matches(instance, context)) { + continue; + } + + matched = true; + const key = buildSplitKey( + instance, + context, + splitRule, + splitRule.id ?? ruleIndex + ); + + let group = instancesMap.get(key); + if (!group) { + group = { instances: [], matchedRule: splitRule, splitKey: key }; + instancesMap.set(key, group); + } + group.instances.push(instance); + break; + } + + if (!matched) { + onUnmatched?.(instance); + } + } + + return Array.from(instancesMap.values()).sort((a, b) => + (a.splitKey ?? '').localeCompare(b.splitKey ?? '') + ); +} diff --git a/packages/metadata/src/displayset/index.ts b/packages/metadata/src/displayset/index.ts new file mode 100644 index 0000000000..63199f3d6e --- /dev/null +++ b/packages/metadata/src/displayset/index.ts @@ -0,0 +1,41 @@ +export type { IDisplaySet } from './IDisplaySet'; +export { BaseDisplaySet } from './BaseDisplaySet'; +export type { BaseDisplaySetOptions } from './BaseDisplaySet'; +export { ImageStackDisplaySet } from './ImageStackDisplaySet'; +export type { ImageStackDisplaySetOptions } from './ImageStackDisplaySet'; +export { resolveInstances } from './resolveInstances'; +export type { ResolveInstancesOptions } from './resolveInstances'; +export { buildSeriesInfo } from './buildSeriesInfo'; +export { groupInstancesBySplitRules } from './groupInstancesBySplitRules'; +export { splitImageIdsBySplitRules } from './splitImageIdsBySplitRules'; +export type { SplitImageIdsBySplitRulesOptions } from './splitImageIdsBySplitRules'; +export { + registerDisplaySetMetadata, + type RegisterDisplaySetMetadataOptions, +} from './registerDisplaySetMetadata'; +export { registerDisplaySetProviders } from './displaySetProvider'; +export { defaultDisplaySetSplitRules } from './defaultDisplaySetSplitRules'; +export { createDisplaySetFromGroup } from './createDisplaySetFromGroup'; +export type { CreateDisplaySetFromGroupOptions } from './createDisplaySetFromGroup'; +export { isImageInstance } from './isImageInstance'; +export { isVideoInstance } from './isVideoInstance'; +export { isEcgInstance } from './isEcgInstance'; +export { isWsiInstance } from './isWsiInstance'; +export { + getViewportTypesForRule, + getPreferredViewportType, + getViewportTypesForGroup, +} from './viewportTypes'; +export type { + NaturalizedInstance, + SeriesInfo, + SeriesFacts, + SeriesContext, + RuleContext, + SplitRule, + SplitContext, + SplitRuleOptions, + SplitRuleCustomAttributesContext, + InstanceGroup, + ViewportTypeHint, +} from './types'; diff --git a/packages/metadata/src/displayset/isEcgInstance.ts b/packages/metadata/src/displayset/isEcgInstance.ts new file mode 100644 index 0000000000..c4469ce620 --- /dev/null +++ b/packages/metadata/src/displayset/isEcgInstance.ts @@ -0,0 +1,21 @@ +import type { NaturalizedInstance } from './types'; + +const ECG_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.9.1.1', + '1.2.840.10008.5.1.4.1.1.9.1.2', + '1.2.840.10008.5.1.4.1.1.9.1.3', + '1.2.840.10008.5.1.4.1.1.9.2.1', + '1.2.840.10008.5.1.4.1.1.9.3.1', +]); + +/** + * Returns true when the instance is an ECG / waveform SOP class (12-lead, + * general, ambulatory, hemodynamic, or basic voice/audio waveform), so the + * default split rules route it to an ECG viewport rather than an image stack. + * + * @param instance - the naturalized DICOM instance to classify. + * @returns true if the instance is a waveform/ECG SOP class. + */ +export function isEcgInstance(instance: NaturalizedInstance): boolean { + return ECG_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? ''); +} diff --git a/packages/metadata/src/displayset/isImageInstance.ts b/packages/metadata/src/displayset/isImageInstance.ts new file mode 100644 index 0000000000..b859b47648 --- /dev/null +++ b/packages/metadata/src/displayset/isImageInstance.ts @@ -0,0 +1,58 @@ +import type { NaturalizedInstance } from './types'; + +/** SOP Class UIDs that represent image storage (aligned with OHIF isImage). */ +const IMAGE_STORAGE_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.1', + '1.2.840.10008.5.1.4.1.1.1.1', + '1.2.840.10008.5.1.4.1.1.1.1.1', + '1.2.840.10008.5.1.4.1.1.2', + '1.2.840.10008.5.1.4.1.1.2.1', + '1.2.840.10008.5.1.4.1.1.2.2', + '1.2.840.10008.5.1.4.1.1.4', + '1.2.840.10008.5.1.4.1.1.4.1', + '1.2.840.10008.5.1.4.1.1.4.2', + '1.2.840.10008.5.1.4.1.1.4.3', + '1.2.840.10008.5.1.4.1.1.4.4', + '1.2.840.10008.5.1.4.1.1.7', + '1.2.840.10008.5.1.4.1.1.7.1', + '1.2.840.10008.5.1.4.1.1.7.2', + '1.2.840.10008.5.1.4.1.1.7.3', + '1.2.840.10008.5.1.4.1.1.7.4', + '1.2.840.10008.5.1.4.1.1.12.1', + '1.2.840.10008.5.1.4.1.1.12.1.1', + '1.2.840.10008.5.1.4.1.1.12.2', + '1.2.840.10008.5.1.4.1.1.12.2.1', + '1.2.840.10008.5.1.4.1.1.13.1.1', + '1.2.840.10008.5.1.4.1.1.13.1.2', + '1.2.840.10008.5.1.4.1.1.13.1.3', + '1.2.840.10008.5.1.4.1.1.13.1.4', + '1.2.840.10008.5.1.4.1.1.13.1.5', + '1.2.840.10008.5.1.4.1.1.13.1.6', + '1.2.840.10008.5.1.4.1.1.128', + '1.2.840.10008.5.1.4.1.1.77.1.1', + '1.2.840.10008.5.1.4.1.1.77.1.1.1', + '1.2.840.10008.5.1.4.1.1.77.1.2', + '1.2.840.10008.5.1.4.1.1.77.1.2.1', + '1.2.840.10008.5.1.4.1.1.77.1.3', + '1.2.840.10008.5.1.4.1.1.77.1.4', + '1.2.840.10008.5.1.4.1.1.77.1.4.1', + '1.2.840.10008.5.1.4.1.1.128.1', + '1.2.840.10008.5.1.4.1.1.128.2', + '1.2.840.10008.5.1.4.1.1.128.3', + '1.2.840.10008.5.1.4.1.1.128.4', + '1.2.840.10008.5.1.4.1.1.128.5', + '1.2.840.10008.5.1.4.1.1.77.1.6', +]); + +/** + * Returns true when the instance is an image storage SOP class (the set OHIF's + * `isImage` recognizes), i.e. an instance that carries pixel data and can be + * rendered. Used by the default split rules to keep non-image objects (e.g. + * presentation states, structured reports) out of image-oriented display sets. + * + * @param instance - the naturalized DICOM instance to classify. + * @returns true when the instance's SOP class is a known image storage class. + */ +export function isImageInstance(instance: NaturalizedInstance): boolean { + return IMAGE_STORAGE_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? ''); +} diff --git a/packages/metadata/src/displayset/isVideoInstance.ts b/packages/metadata/src/displayset/isVideoInstance.ts new file mode 100644 index 0000000000..0dfdd7df6e --- /dev/null +++ b/packages/metadata/src/displayset/isVideoInstance.ts @@ -0,0 +1,56 @@ +import { videoUIDs } from '../utilities/isVideoTransferSyntax'; +import type { NaturalizedInstance } from './types'; + +const VIDEO_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.77.1.2.1', + '1.2.840.10008.5.1.4.1.1.77.1.4.1', + '1.2.840.10008.5.1.4.1.1.77.1.1.1', +]); + +const SECONDARY_CAPTURE_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.7', + '1.2.840.10008.5.1.4.1.1.7.4', +]); + +function getTransferSyntaxUids(instance: NaturalizedInstance): string[] { + const tsuid = + instance.AvailableTransferSyntaxUID || + instance.TransferSyntaxUID || + instance['00083002']; + return (Array.isArray(tsuid) ? tsuid : [tsuid]).filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ); +} + +/** + * Heuristic aligned with OHIF's dicom-video SOP class handler. An instance is + * treated as video when it is encoded with a video transfer syntax (the shared + * {@link videoUIDs} list, e.g. the MPEG2/MPEG4/HEVC families), declares a + * dedicated video SOP class, or is a long multi-frame secondary capture. + * + * The transfer-syntax check reuses {@link videoUIDs} so it stays in sync with + * `isVideoTransferSyntax` rather than maintaining a second, drifting list. + * + * @param instance - the naturalized DICOM instance to classify. + * @returns true when the instance should be rendered as video. + */ +export function isVideoInstance(instance: NaturalizedInstance): boolean { + const tsuids = getTransferSyntaxUids(instance); + if (tsuids.some((tsuid) => videoUIDs.has(tsuid))) { + return true; + } + + if (instance.SOPClassUID === '1.2.840.10008.5.1.4.1.1.77.1.4.1') { + return true; + } + + if (VIDEO_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? '')) { + return true; + } + + const numberOfFrames = Number(instance.NumberOfFrames) || 0; + return ( + SECONDARY_CAPTURE_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? '') && + numberOfFrames >= 90 + ); +} diff --git a/packages/metadata/src/displayset/isWsiInstance.ts b/packages/metadata/src/displayset/isWsiInstance.ts new file mode 100644 index 0000000000..4a6cf18c4f --- /dev/null +++ b/packages/metadata/src/displayset/isWsiInstance.ts @@ -0,0 +1,22 @@ +import type { NaturalizedInstance } from './types'; + +// VL Whole Slide Microscopy Image Storage. +const WSI_SOP_CLASS_UIDS = new Set(['1.2.840.10008.5.1.4.1.1.77.1.6']); + +/** + * Returns true when the instance is whole-slide microscopy imaging - either the + * VL Whole Slide Microscopy Image Storage SOP class or modality `SM`. Heuristic + * aligned with OHIF's whole-slide microscopy SOP class handler; the default + * split rules use it to group all microscopy levels of a series into a single + * whole-slide display set. + * + * @param instance - the naturalized DICOM instance to classify. + * @returns true if the instance is a whole-slide microscopy image. + */ +export function isWsiInstance(instance: NaturalizedInstance): boolean { + if (WSI_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? '')) { + return true; + } + + return instance.Modality === 'SM'; +} diff --git a/packages/metadata/src/displayset/registerDisplaySetMetadata.ts b/packages/metadata/src/displayset/registerDisplaySetMetadata.ts new file mode 100644 index 0000000000..b2c8592f06 --- /dev/null +++ b/packages/metadata/src/displayset/registerDisplaySetMetadata.ts @@ -0,0 +1,36 @@ +import { MetadataModules } from '../enums'; +import { addTyped } from '../metaData'; +import type { IDisplaySet } from './IDisplaySet'; + +export type RegisterDisplaySetMetadataOptions = { + /** Register on frame-level imageIds in addition to underlying ids. */ + includeImageIds?: boolean; +}; + +/** + * Stores display set metadata in the typed metadata cache via addTyped. + */ +export function registerDisplaySetMetadata( + imageIds: string[], + displaySet: IDisplaySet, + options: RegisterDisplaySetMetadataOptions = {} +): void { + const idsToRegister = new Set(imageIds); + + if (options.includeImageIds) { + for (const frameId of displaySet.imageIds) { + idsToRegister.add(frameId); + } + } + + for (const underlyingId of displaySet.underlyingImageIds) { + idsToRegister.add(underlyingId); + } + + for (const imageId of idsToRegister) { + if (!imageId) { + continue; + } + addTyped(MetadataModules.DISPLAY_SET, imageId, { displaySet }); + } +} diff --git a/packages/metadata/src/displayset/resolveInstances.ts b/packages/metadata/src/displayset/resolveInstances.ts new file mode 100644 index 0000000000..b89d769669 --- /dev/null +++ b/packages/metadata/src/displayset/resolveInstances.ts @@ -0,0 +1,33 @@ +import type { NaturalizedInstance } from './types'; + +export type ResolveInstancesOptions = { + /** When true, skip missing ids silently; otherwise they are omitted with optional warn. */ + skipMissing?: boolean; + onMissing?: (imageId: string) => void; +}; + +/** + * Resolves imageIds to naturalized instances in input order. + */ +export function resolveInstances( + imageIds: string[], + getNaturalizedInstance: (imageId: string) => NaturalizedInstance | undefined, + options: ResolveInstancesOptions = {} +): NaturalizedInstance[] { + const { skipMissing = true, onMissing } = options; + const instances: NaturalizedInstance[] = []; + + for (const imageId of imageIds) { + const instance = getNaturalizedInstance(imageId); + if (!instance) { + if (!skipMissing) { + throw new Error(`No naturalized instance for imageId: ${imageId}`); + } + onMissing?.(imageId); + continue; + } + instances.push(instance); + } + + return instances; +} diff --git a/packages/metadata/src/displayset/splitImageIdsBySplitRules.ts b/packages/metadata/src/displayset/splitImageIdsBySplitRules.ts new file mode 100644 index 0000000000..08ad07e180 --- /dev/null +++ b/packages/metadata/src/displayset/splitImageIdsBySplitRules.ts @@ -0,0 +1,43 @@ +import { groupInstancesBySplitRules } from './groupInstancesBySplitRules'; +import { resolveInstances } from './resolveInstances'; +import type { + InstanceGroup, + NaturalizedInstance, + SplitContext, + SplitRule, +} from './types'; + +export type SplitImageIdsBySplitRulesOptions = SplitContext & { + splitRules: SplitRule[]; + onMissingImageId?: (imageId: string) => void; + /** + * Called for each resolved instance that matches no split rule (and so + * produces no display set), e.g. a non-image SOP. Surfaces silent drops. + */ + onUnmatchedInstance?: (instance: NaturalizedInstance) => void; +}; + +/** + * Primary entrypoint: splits a series represented by metadata imageIds into instance groups. + */ +export function splitImageIdsBySplitRules( + imageIds: string[], + options: SplitImageIdsBySplitRulesOptions +): InstanceGroup[] { + const { + getNaturalizedInstance, + splitRules, + onMissingImageId, + onUnmatchedInstance, + } = options; + + const instances = resolveInstances(imageIds, getNaturalizedInstance, { + onMissing: onMissingImageId, + }); + + if (!instances.length) { + return []; + } + + return groupInstancesBySplitRules(instances, splitRules, onUnmatchedInstance); +} diff --git a/packages/metadata/src/displayset/types.ts b/packages/metadata/src/displayset/types.ts new file mode 100644 index 0000000000..d615da5599 --- /dev/null +++ b/packages/metadata/src/displayset/types.ts @@ -0,0 +1,132 @@ +/** + * Naturalized DICOM instance used by display-set split rules and metadata. + * OHIF naturalized instances satisfy this type; additional tags are allowed. + */ +export type NaturalizedInstance = { + imageId?: string; + Modality?: string; + SOPClassUID?: string; + Rows?: number; + Columns?: number; + NumberOfFrames?: number; + SliceLocation?: number; + SeriesInstanceUID?: string; + InstanceNumber?: number; + DiffusionBValue?: number; + TransferSyntaxUID?: string; + AvailableTransferSyntaxUID?: string; + [key: string]: unknown; +}; + +export type ViewportTypeHint = + | 'stack' + | 'volume' + | 'volume3d' + | 'video' + | 'wholeslide' + | 'ecg' + | string; + +/** + * Series-level statistics aggregated once over a series' instances (see + * {@link buildSeriesInfo}). Independent of split rules - a rule derives its own + * facts through its `series` hook (see {@link RuleContext}), not here. + */ +export type SeriesInfo = { + NumberOfSeriesRelatedInstances: number; + numberOfFrames: number; + numImageFrames: number; + numberOfNonImageObjects: number; + numberOfSOPInstanceUIDsPerSeries: number; + [key: string]: unknown; +}; + +/** + * Derived series-level facts a rule's `series` hook returns, keyed by name and + * read back by that same rule's `matches`/`groupBy` via {@link RuleContext}. + */ +export type SeriesFacts = Record; + +/** + * Argument to a rule's `series` hook: the whole resolved series. + */ +export type SeriesContext = { + instances: NaturalizedInstance[]; +}; + +/** + * Argument to a rule's `matches` predicate and to its `groupBy` extractor + * functions: the facts this rule's `series` hook derived (an empty object when + * the rule has no `series` hook). Scoped per rule - a rule never sees another + * rule's derived facts. + */ +export type RuleContext = { + series: SeriesFacts; +}; + +export type SplitRuleCustomAttributesContext = { + instance: NaturalizedInstance; + isMultiFrame?: boolean; + sopClassUids?: string[]; + viewportTypes?: readonly ViewportTypeHint[]; + [key: string]: unknown; +}; + +export type SplitRuleOptions = { + instances: NaturalizedInstance[]; + splitNumber?: number; + descriptionName?: string; +}; + +export type SplitRule = { + id?: string; + /** Allowed viewport types; index 0 is the preferred viewport type. */ + viewportTypes?: readonly ViewportTypeHint[]; + /** + * Optional. Runs once per rule per split operation, before matching, and + * returns derived facts for THIS rule - read back by `matches`/`groupBy` + * through `context.series`. Use it only when a rule needs a value computed + * from the whole series (e.g. "does this series mix b-value and non-b-value + * frames?"). Must be pure: return facts, do not mutate shared state. + */ + series?: (context: SeriesContext) => SeriesFacts; + /** + * Predicate deciding whether this rule claims a given instance. Omit to match + * every instance (a catch-all rule). Evaluated in rule order; first match wins. + * The second argument carries this rule's derived `series` facts. + */ + matches?: (instance: NaturalizedInstance, context: RuleContext) => boolean; + /** + * Recipe for the bucket an instance is grouped under once this rule claims it: + * an ordered list of tag names and/or extractor functions. Instances whose + * parts are all equal land in the same group (one group -> one display set). + * Defaults to `['SeriesInstanceUID']` (one group per series). Extractor + * functions receive this rule's derived `series` facts as their second + * argument. The computed result is stored on the produced + * {@link InstanceGroup} as `splitKey`. + */ + groupBy?: ( + | string + | ((instance: NaturalizedInstance, context: RuleContext) => unknown) + )[]; + customAttributes?: ( + attributes: SplitRuleCustomAttributesContext, + options: SplitRuleOptions + ) => Record; +}; + +export type SplitContext = { + getNaturalizedInstance: (imageId: string) => NaturalizedInstance | undefined; +}; + +export type InstanceGroup = { + instances: NaturalizedInstance[]; + matchedRule: SplitRule; + /** + * Deterministic, rule-namespaced bucket key this group was collected under. + * Stable for a given set of instances regardless of input order, so it can + * seed a stable display set identity. Set by `groupInstancesBySplitRules`; + * optional so hand-built groups don't need it. + */ + splitKey?: string; +}; diff --git a/packages/metadata/src/displayset/viewportTypes.ts b/packages/metadata/src/displayset/viewportTypes.ts new file mode 100644 index 0000000000..1e20be7496 --- /dev/null +++ b/packages/metadata/src/displayset/viewportTypes.ts @@ -0,0 +1,48 @@ +import type { InstanceGroup, SplitRule, ViewportTypeHint } from './types'; + +const DEFAULT_VIEWPORT_TYPES: readonly ViewportTypeHint[] = ['stack']; + +/** + * Resolves the allowed viewport types for a matched split rule, falling back to + * a stack viewport when the rule declares none. `viewportTypes[0]` is the + * preferred viewport type. + * + * @param rule - the split rule that matched the group. + * @returns the rule's `viewportTypes`, or `['stack']` when unset. + */ +export function getViewportTypesForRule( + rule: SplitRule +): readonly ViewportTypeHint[] { + if (rule.viewportTypes?.length) { + return rule.viewportTypes; + } + return DEFAULT_VIEWPORT_TYPES; +} + +/** + * Returns the preferred viewport type for a list of allowed viewport types, + * which is its first entry (`'stack'` when the list is empty). Use this when + * deciding which single viewport type to create for a display set. + * + * @param viewportTypes - the display set's allowed viewport types. + * @returns the preferred (first) viewport type, defaulting to `'stack'`. + */ +export function getPreferredViewportType( + viewportTypes: readonly ViewportTypeHint[] +): ViewportTypeHint { + return viewportTypes[0] ?? 'stack'; +} + +/** + * Resolves the allowed viewport types for an instance group from the rule that + * produced it. Convenience wrapper around {@link getViewportTypesForRule} for an + * {@link InstanceGroup}. + * + * @param group - the instance group (carries its `matchedRule`). + * @returns the group's allowed viewport types; index 0 is preferred. + */ +export function getViewportTypesForGroup( + group: InstanceGroup +): readonly ViewportTypeHint[] { + return getViewportTypesForRule(group.matchedRule); +} diff --git a/packages/metadata/src/enums/MetadataModules.ts b/packages/metadata/src/enums/MetadataModules.ts index d2d07c800e..c04e2465f9 100644 --- a/packages/metadata/src/enums/MetadataModules.ts +++ b/packages/metadata/src/enums/MetadataModules.ts @@ -210,6 +210,12 @@ enum MetadataModules { * The per-frame data object is generated from the natural value object - see INSTANCE */ NATURALIZED = 'naturalized', + + /** + * Display set metadata for a series group (frame and underlying image ids, + * viewport hints). Registered per imageId via addTyped / registerDisplaySetMetadata. + */ + DISPLAY_SET = 'displaySetModule', } export const ADD_MODULE_TYPE_SUFFIX = 'Add'; diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index b636d9614a..3d642f1a75 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -4,6 +4,46 @@ export * as Enums from './enums'; export { version } from './version'; export * as metaData from './metaData'; export * as utilities from './utilities'; +export * as displaySet from './displayset'; +export type { + IDisplaySet, + BaseDisplaySetOptions, + ImageStackDisplaySetOptions, + ResolveInstancesOptions, + SplitImageIdsBySplitRulesOptions, + RegisterDisplaySetMetadataOptions, + NaturalizedInstance, + SeriesInfo, + SeriesFacts, + SeriesContext, + RuleContext, + SplitRule, + SplitContext, + SplitRuleOptions, + SplitRuleCustomAttributesContext, + InstanceGroup, + ViewportTypeHint, +} from './displayset'; +export { + BaseDisplaySet, + ImageStackDisplaySet, + resolveInstances, + buildSeriesInfo, + groupInstancesBySplitRules, + splitImageIdsBySplitRules, + registerDisplaySetMetadata, + registerDisplaySetProviders, + defaultDisplaySetSplitRules, + createDisplaySetFromGroup, + isImageInstance, + isVideoInstance, + isEcgInstance, + isWsiInstance, + getViewportTypesForRule, + getPreferredViewportType, + getViewportTypesForGroup, +} from './displayset'; +export type { CreateDisplaySetFromGroupOptions } from './displayset'; export * as logging from './utilities/logging'; export { registerDefaultProviders } from './registerDefaultProviders'; export type * from './types'; diff --git a/packages/metadata/src/registerDefaultProviders.ts b/packages/metadata/src/registerDefaultProviders.ts index 2c9791359b..0fd75e48d8 100644 --- a/packages/metadata/src/registerDefaultProviders.ts +++ b/packages/metadata/src/registerDefaultProviders.ts @@ -18,6 +18,7 @@ import { registerCompressedFrameDataProvider } from './utilities/metadataProvide import { registerScalingFromInstanceProvider } from './utilities/metadataProvider/scalingFromInstance'; import { registerNaturalizedHandlers } from './utilities/metadataProvider/naturalizedHandlers'; import { registerImageIdProviders } from './utilities/metadataProvider/imageIdsProviders'; +import { registerDisplaySetProviders } from './displayset/displaySetProvider'; const TYPED_PROVIDER_BRIDGE_PRIORITY = -1000; @@ -63,6 +64,9 @@ export function registerDefaultProviders() { // Register imageId provider pipeline with front-end cache registerImageIdProviders(); + // Display set metadata (OHIF display sets registered per imageId) + registerDisplaySetProviders(); + // Register data lookup providers registerDataLookup(); diff --git a/packages/metadata/src/types/MetadataModuleTypes.ts b/packages/metadata/src/types/MetadataModuleTypes.ts index 3814ef0de9..6832bf0413 100644 --- a/packages/metadata/src/types/MetadataModuleTypes.ts +++ b/packages/metadata/src/types/MetadataModuleTypes.ts @@ -1,3 +1,5 @@ +import type { IDisplaySet } from '../displayset/IDisplaySet'; + export interface DicomDateObject { year: number; month: number; @@ -119,4 +121,12 @@ export interface MetadataModuleType { frameModule: FrameMetadata; transferSyntax: TransferSyntaxMetadata; compressedFrameData: CompressedFrameDataMetadata; + /** + * The display set stored by `registerDisplaySetMetadata` and resolved by the + * display set provider. This is the full {@link IDisplaySet} (including + * `instances` and any split-rule attributes such as `isClip`, + * `numImageFrames`, `splitNumber`), not a narrowed projection, so a typed + * `getTyped(MetadataModules.DISPLAY_SET, imageId)` read matches what is cached. + */ + displaySetModule: IDisplaySet; } diff --git a/packages/metadata/src/utilities/index.ts b/packages/metadata/src/utilities/index.ts index 038f688b3e..ec0e1bbc69 100644 --- a/packages/metadata/src/utilities/index.ts +++ b/packages/metadata/src/utilities/index.ts @@ -24,3 +24,4 @@ export * as DicomStream from './dicomStream'; export * from './logging'; export * from './metadataProvider'; export * as typedMetadataProviders from './metadataProvider'; +export * from '../displayset'; diff --git a/packages/tools/examples/dynamicCINETool/index.ts b/packages/tools/examples/dynamicCINETool/index.ts index de5c09ed36..a8cb0783ec 100644 --- a/packages/tools/examples/dynamicCINETool/index.ts +++ b/packages/tools/examples/dynamicCINETool/index.ts @@ -1,4 +1,3 @@ -import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; import type { Types } from '@cornerstonejs/core'; import { RenderingEngine, @@ -10,6 +9,7 @@ import { import { initDemo, createImageIdsAndCacheMetaData, + get4DVolumeImageIds, setTitleAndDescription, setPetTransferFunctionForVolumeActor, } from '../../../../utils/demo/helpers'; @@ -229,33 +229,20 @@ function initViewports(volume, elements) { } async function createVolume(numDimensionGroups: number): Promise { - const { metaDataManager } = cornerstoneDICOMImageLoader.wadors; - if (numDimensionGroups < 1 || numDimensionGroups > MAX_NUM_DIMENSION_GROUPS) { throw new Error('Number of dimension groups is out of range'); } - let imageIds = await createImageIdsAndCacheMetaData({ + const seriesImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '2.25.232704420736447710317909004159492840763', SeriesInstanceUID: '2.25.16992883200578135914239363565496792012', wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - const NUM_IMAGES_PER_DIMENSION_GROUP = 235; - const TOTAL_NUM_IMAGES = - MAX_NUM_DIMENSION_GROUPS * NUM_IMAGES_PER_DIMENSION_GROUP; - const numImagesToLoad = numDimensionGroups * NUM_IMAGES_PER_DIMENSION_GROUP; - - // Load the last N dimension groups because they have a better image quality - // and first ones are white or contains only a few black pixels - const firstInstanceNumber = TOTAL_NUM_IMAGES - numImagesToLoad + 1; - - imageIds = imageIds.filter((imageId) => { - const instanceMetaData = metaDataManager.get(imageId); - const instanceTag = instanceMetaData['00200013']; - const instanceNumber = parseInt(instanceTag.Value[0]); - - return instanceNumber >= firstInstanceNumber; + // Load the last N dimension groups because they have better image quality + // than the first ones (often blank or sparse). + const imageIds = get4DVolumeImageIds(seriesImageIds, { + lastCount: numDimensionGroups, }); // Define a unique id for the volume diff --git a/packages/tools/examples/generateImageFromTimeData/index.ts b/packages/tools/examples/generateImageFromTimeData/index.ts index 1003b3ee29..d0dd644de4 100644 --- a/packages/tools/examples/generateImageFromTimeData/index.ts +++ b/packages/tools/examples/generateImageFromTimeData/index.ts @@ -13,9 +13,9 @@ import { addSliderToToolbar, addDropdownToToolbar, addButtonToToolbar, + get4DVolumeImageIds, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; -import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; const { utilities: csToolsUtilities, @@ -226,32 +226,15 @@ async function run() { ], }); - const { metaDataManager } = cornerstoneDICOMImageLoader.wadors; - - // Get Cornerstone imageIds and fetch metadata into RAM - let imageIds = await createImageIdsAndCacheMetaData({ + const seriesImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '2.25.79767489559005369769092179787138169587', SeriesInstanceUID: '2.25.87977716979310885152986847054790859463', wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - const firstDimensionGroup = 10; - const lastDimensionGroup = 14; - const NUM_IMAGES_PER_DIMENSION_GROUP = 235; - const firstInstanceNumber = - (firstDimensionGroup - 1) * NUM_IMAGES_PER_DIMENSION_GROUP + 1; - const lastInstanceNumber = - lastDimensionGroup * NUM_IMAGES_PER_DIMENSION_GROUP; - - imageIds = imageIds.filter((imageId) => { - const instanceMetaData = metaDataManager.get(imageId); - const instanceTag = instanceMetaData['00200013']; - const instanceNumber = parseInt(instanceTag.Value[0]); - - return ( - instanceNumber >= firstInstanceNumber && - instanceNumber <= lastInstanceNumber - ); + const imageIds = get4DVolumeImageIds(seriesImageIds, { + fromGroup: 10, + toGroup: 14, }); // Instantiate a rendering engine diff --git a/packages/tools/examples/genericLabelmapOverlapPlayground/index.ts b/packages/tools/examples/genericLabelmapOverlapPlayground/index.ts index 0bb277f089..0ffae194bc 100644 --- a/packages/tools/examples/genericLabelmapOverlapPlayground/index.ts +++ b/packages/tools/examples/genericLabelmapOverlapPlayground/index.ts @@ -370,12 +370,12 @@ async function run() { toolGroup.addViewport(viewportId, renderingEngineId); }); - utilities.genericViewportDataSetMetadataProvider.add(stackDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), }); - utilities.genericViewportDataSetMetadataProvider.add(volumeDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(volumeDataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/tools/examples/genericLabelmapRendering/index.ts b/packages/tools/examples/genericLabelmapRendering/index.ts index f492d09e98..2429c1e5af 100644 --- a/packages/tools/examples/genericLabelmapRendering/index.ts +++ b/packages/tools/examples/genericLabelmapRendering/index.ts @@ -126,7 +126,7 @@ async function run() { volume.load(); - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/tools/examples/genericLabelmapSegmentationTools/index.ts b/packages/tools/examples/genericLabelmapSegmentationTools/index.ts index a9bf35435b..c2b41528e4 100644 --- a/packages/tools/examples/genericLabelmapSegmentationTools/index.ts +++ b/packages/tools/examples/genericLabelmapSegmentationTools/index.ts @@ -440,7 +440,7 @@ async function run() { toolGroup.addViewport(viewportId, renderingEngineId); }); - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/tools/examples/genericLabelmapSliceRendering/index.ts b/packages/tools/examples/genericLabelmapSliceRendering/index.ts index 91890138be..49f6e9376e 100644 --- a/packages/tools/examples/genericLabelmapSliceRendering/index.ts +++ b/packages/tools/examples/genericLabelmapSliceRendering/index.ts @@ -119,7 +119,7 @@ async function run() { volume.load(); - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/tools/examples/genericLabelmapSliceRenderingTools/index.ts b/packages/tools/examples/genericLabelmapSliceRenderingTools/index.ts index d55f9b13c1..067e4996ac 100644 --- a/packages/tools/examples/genericLabelmapSliceRenderingTools/index.ts +++ b/packages/tools/examples/genericLabelmapSliceRenderingTools/index.ts @@ -243,7 +243,7 @@ async function run() { toolGroup.addViewport(viewportId, renderingEngineId); }); - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/tools/examples/genericStackLabelmapSegmentation/index.ts b/packages/tools/examples/genericStackLabelmapSegmentation/index.ts index d0851d7416..f9fe6db26f 100644 --- a/packages/tools/examples/genericStackLabelmapSegmentation/index.ts +++ b/packages/tools/examples/genericStackLabelmapSegmentation/index.ts @@ -468,12 +468,12 @@ async function run() { await imageLoader.createAndCacheDerivedLabelmapImages(mgImageIds); const mgStackImageIds = [...mgImageIds, ctImageIds[2]]; - utilities.genericViewportDataSetMetadataProvider.add(ctDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(ctDataId, { kind: 'planar', imageIds: ctImageIds, initialImageIdIndex: 0, }); - utilities.genericViewportDataSetMetadataProvider.add(mgDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(mgDataId, { kind: 'planar', imageIds: mgStackImageIds, initialImageIdIndex: 0, diff --git a/packages/tools/examples/genericStackManipulationTools/index.ts b/packages/tools/examples/genericStackManipulationTools/index.ts index a25bec4716..1b543043a9 100644 --- a/packages/tools/examples/genericStackManipulationTools/index.ts +++ b/packages/tools/examples/genericStackManipulationTools/index.ts @@ -205,7 +205,7 @@ async function run() { const viewport = renderingEngine.getViewport(viewportId); - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds, }); diff --git a/packages/tools/examples/genericVolumeAnnotationTools/index.ts b/packages/tools/examples/genericVolumeAnnotationTools/index.ts index 3f0f71ce37..916322e2b0 100644 --- a/packages/tools/examples/genericVolumeAnnotationTools/index.ts +++ b/packages/tools/examples/genericVolumeAnnotationTools/index.ts @@ -186,7 +186,7 @@ async function run() { volume.load(); - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds, initialImageIdIndex: Math.floor(imageIds.length / 2), diff --git a/packages/tools/examples/localAdvanced/index.ts b/packages/tools/examples/localAdvanced/index.ts index 82d52fa273..e95514ba95 100644 --- a/packages/tools/examples/localAdvanced/index.ts +++ b/packages/tools/examples/localAdvanced/index.ts @@ -18,6 +18,7 @@ import { addDropdownToToolbar, annotationTools, createImageIdsAndCacheMetaData, + getPrimaryStackFrameImageIds, imageIds, setImageIds, handleFileSelect, @@ -175,8 +176,8 @@ addDropdownToToolbar({ if (!data?.wadoRsRoot) { return; } - setImageIds(await createImageIdsAndCacheMetaData({ ...data })); - loadAndViewImages(imageIds); + const seriesImageIds = await createImageIdsAndCacheMetaData({ ...data }); + loadAndViewImages(getPrimaryStackFrameImageIds(seriesImageIds)); }, }); diff --git a/packages/tools/examples/videoColor/index.ts b/packages/tools/examples/videoColor/index.ts index 3f0c34a2ba..655c0532e7 100644 --- a/packages/tools/examples/videoColor/index.ts +++ b/packages/tools/examples/videoColor/index.ts @@ -5,8 +5,9 @@ import { addDropdownToToolbar, initDemo, setTitleAndDescription, - createImageIdsAndCacheMetaData, + createDisplaySets, getLocalUrl, + getViewportTypeForDisplaySet, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -24,7 +25,6 @@ const { Enums: csToolsEnums, } = cornerstoneTools; -const { ViewportType } = Enums; const { MouseBindings, KeyboardBindings } = csToolsEnums; const toolGroupId = 'VIDEO_TOOL_GROUP_ID'; @@ -182,17 +182,20 @@ async function run() { await initDemo(); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; // Add tools to Cornerstone3D cornerstoneTools.addTool(PanTool); @@ -253,7 +256,7 @@ async function run() { const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element, defaultOptions: { background: [0.2, 0, 0.2], @@ -270,7 +273,7 @@ async function run() { // Set the video on the viewport // Will be `/studies//series//instances//rendered?accept=video/mp4` // on a compliant DICOMweb endpoint - await viewport.setVideo(videoId, 25); + await viewport.setDisplaySets({ displaySetId: videoId }); viewport.play(); diff --git a/packages/tools/examples/videoContourSegmentation/index.ts b/packages/tools/examples/videoContourSegmentation/index.ts index 483088b1f2..f6c5b1494b 100644 --- a/packages/tools/examples/videoContourSegmentation/index.ts +++ b/packages/tools/examples/videoContourSegmentation/index.ts @@ -6,7 +6,8 @@ import { addSliderToToolbar, addDropdownToToolbar, addToggleButtonToToolbar, - createImageIdsAndCacheMetaData, + createDisplaySets, + getViewportTypeForDisplaySet, createInfoSection, initDemo, setTitleAndDescription, @@ -39,7 +40,6 @@ const { Enums: csToolsEnums, segmentation, } = cornerstoneTools; -const { ViewportType } = Enums; // Define various constants for the tool definition const toolGroupId = 'DEFAULT_TOOLGROUP_ID'; @@ -250,17 +250,20 @@ async function run() { addManipulationBindings(toolGroup, { toolMap }); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; // Instantiate a rendering engine const renderingEngineId = 'myRenderingEngine'; @@ -270,7 +273,7 @@ async function run() { const viewportInputArray = [ { viewportId: viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element: element, defaultOptions: { background: [0.2, 0, 0.2], @@ -286,7 +289,7 @@ async function run() { addVideoTime(viewportGrid, viewport); // Set the stack on the viewport - await viewport.setVideo(videoId, 1); + await viewport.setDisplaySets({ displaySetId: videoId }); // Render the image renderingEngine.render(); diff --git a/packages/tools/examples/videoGroup/index.ts b/packages/tools/examples/videoGroup/index.ts index d2e02900e3..8ed31d04de 100644 --- a/packages/tools/examples/videoGroup/index.ts +++ b/packages/tools/examples/videoGroup/index.ts @@ -4,8 +4,9 @@ import { addButtonToToolbar, initDemo, setTitleAndDescription, - createImageIdsAndCacheMetaData, + createDisplaySets, getLocalUrl, + getViewportTypeForDisplaySet, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -38,7 +39,6 @@ const { const { AnnotationMultiSlice } = cornerstoneTools.utilities; -const { ViewportType } = Enums; const { MouseBindings, KeyboardBindings, Events: toolsEvents } = csToolsEnums; const toolGroupId = 'VIDEO_TOOL_GROUP_ID'; @@ -324,7 +324,7 @@ async function run() { await initDemo(); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: @@ -332,9 +332,13 @@ async function run() { }); // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; addAnnotationListeners(); @@ -452,7 +456,7 @@ async function run() { const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element, defaultOptions: { background: [0.2, 0, 0.2], @@ -469,7 +473,7 @@ async function run() { // Set the video on the viewport // Will be `/studies//series//instances//rendered?accept=video/mp4` // on a compliant DICOMweb endpoint - await viewport.setVideo(videoId, 25); + await viewport.setDisplaySets({ displaySetId: videoId }); viewport.play(); diff --git a/packages/tools/examples/videoNavigation/index.ts b/packages/tools/examples/videoNavigation/index.ts index 2d838c6b61..410a3c1175 100644 --- a/packages/tools/examples/videoNavigation/index.ts +++ b/packages/tools/examples/videoNavigation/index.ts @@ -5,8 +5,9 @@ import { addDropdownToToolbar, initDemo, setTitleAndDescription, - createImageIdsAndCacheMetaData, + createDisplaySets, getLocalUrl, + getViewportTypeForDisplaySet, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -24,7 +25,6 @@ const { Enums: csToolsEnums, } = cornerstoneTools; -const { ViewportType } = Enums; const { MouseBindings, KeyboardBindings } = csToolsEnums; const toolGroupId = 'VIDEO_TOOL_GROUP_ID'; @@ -167,22 +167,21 @@ async function run() { // Init Cornerstone and related libraries await initDemo(); - // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + // Fetch the series metadata and split it into display sets using the default + // split rules. + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // The default DICOMweb loader splits up the video into one image id per frame, - // but the video viewport needs a single combined reference, so find the first - // reference and use that one. - // Also, the series has more than one object in it, and the video viewport - // can only display a single video at a time. - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } // Add tools to Cornerstone3D cornerstoneTools.addTool(PanTool); @@ -238,11 +237,11 @@ async function run() { // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); - // Create a stack viewport - + // Create the viewport using the display set's preferred viewport type + // instead of hard-coding ViewportType.VIDEO. const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element, defaultOptions: { background: [0.2, 0, 0.2], @@ -256,10 +255,12 @@ async function run() { toolGroup.addViewport(viewport.id, renderingEngineId); - // Set the video on the viewport - // Will be `/studies//series//instances//rendered?accept=video/mp4` - // on a compliant DICOMweb endpoint - await viewport.setVideo(videoId, 25); + // Drive the viewport from the display set, mirroring the GenericViewport + // setDisplaySets API. The displaySetId is the video instance's imageId + // (equivalent to the previous viewport.setVideo(videoId) call). + await viewport.setDisplaySets({ + displaySetId: displaySet.instances[0].imageId, + }); viewport.play(); diff --git a/packages/tools/examples/videoRange/index.ts b/packages/tools/examples/videoRange/index.ts index 14aeddec62..40551f4ec3 100644 --- a/packages/tools/examples/videoRange/index.ts +++ b/packages/tools/examples/videoRange/index.ts @@ -6,7 +6,8 @@ import { addDropdownToToolbar, initDemo, setTitleAndDescription, - createImageIdsAndCacheMetaData, + createDisplaySets, + getViewportTypeForDisplaySet, getLocalUrl, addManipulationBindings, addVideoTime, @@ -28,7 +29,6 @@ const { const { AnnotationMultiSlice } = cornerstoneTools.utilities; -const { ViewportType } = Enums; const { MouseBindings, KeyboardBindings, Events: toolsEvents } = csToolsEnums; const toolGroupId = 'VIDEO_TOOL_GROUP_ID'; @@ -300,17 +300,20 @@ async function run() { await initDemo(); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; addAnnotationListeners(); // Add annotation tools to Cornerstone3D @@ -353,7 +356,7 @@ async function run() { const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element, defaultOptions: { background: [0.2, 0, 0.2], @@ -370,7 +373,7 @@ async function run() { // Set the video on the viewport // Will be `/studies//series//instances//rendered?accept=video/mp4` // on a compliant DICOMweb endpoint - await viewport.setVideo(videoId, 1); + await viewport.setDisplaySets({ displaySetId: videoId }); addVideoTime(element, viewport); } diff --git a/packages/tools/examples/videoSegmentation/index.ts b/packages/tools/examples/videoSegmentation/index.ts index f45c88e16d..cff17dee4c 100644 --- a/packages/tools/examples/videoSegmentation/index.ts +++ b/packages/tools/examples/videoSegmentation/index.ts @@ -7,7 +7,8 @@ import { import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import { - createImageIdsAndCacheMetaData, + createDisplaySets, + getViewportTypeForDisplaySet, initDemo, addDropdownToToolbar, setTitleAndDescription, @@ -33,7 +34,6 @@ const { } = cornerstoneTools; const { MouseBindings } = csToolsEnums; -const { ViewportType } = Enums; // Define a unique id for the volume let renderingEngine; @@ -190,17 +190,20 @@ async function run() { const toolGroup = setupTools(toolGroupId); - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; // Instantiate a rendering engine renderingEngine = new RenderingEngine(renderingEngineId); @@ -209,7 +212,7 @@ async function run() { const viewportInputArray = [ { viewportId: viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element: element1, }, ]; @@ -219,7 +222,7 @@ async function run() { const imageIdsArray = [videoId]; - await viewport.setVideo(videoId, 1); + await viewport.setDisplaySets({ displaySetId: videoId }); addVideoTime(viewportGrid, viewport); // We need the map on all image ids const allImageIds = viewport.getImageIds(); diff --git a/packages/tools/examples/videoSplineROITools/index.ts b/packages/tools/examples/videoSplineROITools/index.ts index e1787fc581..4f908354ec 100644 --- a/packages/tools/examples/videoSplineROITools/index.ts +++ b/packages/tools/examples/videoSplineROITools/index.ts @@ -2,7 +2,8 @@ import type { Types } from '@cornerstonejs/core'; import { RenderingEngine, Enums } from '@cornerstonejs/core'; import { initDemo, - createImageIdsAndCacheMetaData, + createDisplaySets, + getViewportTypeForDisplaySet, setTitleAndDescription, addDropdownToToolbar, addSliderToToolbar, @@ -27,7 +28,6 @@ const { Enums: csToolsEnums, } = cornerstoneTools; -const { ViewportType } = Enums; const { MouseBindings } = csToolsEnums; const renderingEngineId = 'myRenderingEngine'; const viewportId = 'VIDEO_STACK'; @@ -276,17 +276,20 @@ async function run() { addManipulationBindings(toolGroup); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); @@ -294,7 +297,7 @@ async function run() { // Create a stack viewport const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element, defaultOptions: { background: [0.2, 0, 0.2], @@ -312,7 +315,7 @@ async function run() { ); // Set the stack on the viewport - await viewport.setVideo(videoId, 25); + await viewport.setDisplaySets({ displaySetId: videoId }); viewport.play(); } diff --git a/packages/tools/examples/videoTools/index.ts b/packages/tools/examples/videoTools/index.ts index f51a8897f3..c5604baef4 100644 --- a/packages/tools/examples/videoTools/index.ts +++ b/packages/tools/examples/videoTools/index.ts @@ -6,7 +6,8 @@ import { addDropdownToToolbar, initDemo, setTitleAndDescription, - createImageIdsAndCacheMetaData, + createDisplaySets, + getViewportTypeForDisplaySet, getLocalUrl, addManipulationBindings, addVideoTime, @@ -21,7 +22,6 @@ console.warn( const { ToolGroupManager, Enums: csToolsEnums } = cornerstoneTools; -const { ViewportType } = Enums; const { MouseBindings, KeyboardBindings, Events: toolsEvents } = csToolsEnums; const toolGroupId = 'VIDEO_TOOL_GROUP_ID'; @@ -169,17 +169,20 @@ async function run() { await initDemo(); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const displaySets = await createDisplaySets({ StudyInstanceUID: '2.25.96975534054447904995905761963464388233', SeriesInstanceUID: '2.25.15054212212536476297201250326674987992', wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const displaySet = + displaySets.find((ds) => ds.preferredViewportType === 'video') ?? + displaySets[0]; + if (!displaySet) { + throw new Error('No display set found in series'); + } + const videoId = displaySet.instances[0].imageId; addAnnotationListeners(); @@ -207,7 +210,7 @@ async function run() { // Create a stack viewport const viewportInput = { viewportId, - type: ViewportType.VIDEO, + type: getViewportTypeForDisplaySet(displaySet), element, defaultOptions: { background: [0.2, 0, 0.2], @@ -224,7 +227,7 @@ async function run() { // Set the video on the viewport // Will be `/studies//series//instances//rendered?accept=video/mp4` // on a compliant DICOMweb endpoint - await viewport.setVideo(videoId, 1); + await viewport.setDisplaySets({ displaySetId: videoId }); addVideoTime(element, viewport); } diff --git a/packages/tools/examples/viewportProjectionSynchronizer/index.ts b/packages/tools/examples/viewportProjectionSynchronizer/index.ts index efb25fdf4b..f44fcd485e 100644 --- a/packages/tools/examples/viewportProjectionSynchronizer/index.ts +++ b/packages/tools/examples/viewportProjectionSynchronizer/index.ts @@ -412,12 +412,12 @@ async function run() { toolGroup.addViewport(viewportId, renderingEngineId); }); - utilities.genericViewportDataSetMetadataProvider.add(leftDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(leftDataId, { imageIds, kind: 'planar', initialImageIdIndex: middleImageIndex, }); - utilities.genericViewportDataSetMetadataProvider.add(rightDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(rightDataId, { imageIds, kind: 'planar', initialImageIdIndex: middleImageIndex, diff --git a/packages/tools/examples/wsiAnnotationTools/index.ts b/packages/tools/examples/wsiAnnotationTools/index.ts index fe3fb6b95f..60f49ccd33 100644 --- a/packages/tools/examples/wsiAnnotationTools/index.ts +++ b/packages/tools/examples/wsiAnnotationTools/index.ts @@ -188,7 +188,7 @@ async function run() { // Register WSI data and set it on the viewport const dataId = `wsi:${imageIds[0]}`; - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { imageIds, options: { webClient: client }, }); diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan.spec.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan.spec.ts index 5ded8055c1..7b3385dacf 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan.spec.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan.spec.ts @@ -19,7 +19,7 @@ jest.mock('@cornerstonejs/core', () => ({ }, utilities: { uuidv4: jest.fn(() => 'generated-volume-id'), - genericViewportDataSetMetadataProvider: { + genericViewportDisplaySetMetadataProvider: { add: jest.fn(), remove: jest.fn(), }, diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts index 9b30a52c6d..084134e70f 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts @@ -109,7 +109,7 @@ async function addLabelmapToPlanarGenericViewport(args: { }); const dataId = representationUID; - utilities.genericViewportDataSetMetadataProvider.add(dataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(dataId, { kind: 'planar', imageIds: volume.imageIds, initialImageIdIndex: Math.min( diff --git a/packages/tools/src/tools/displayTools/Labelmap/removeLabelmapRepresentationData.ts b/packages/tools/src/tools/displayTools/Labelmap/removeLabelmapRepresentationData.ts index 2345559329..dd3e17316e 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/removeLabelmapRepresentationData.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/removeLabelmapRepresentationData.ts @@ -22,7 +22,7 @@ function removeLabelmapRepresentationData( return false; } - utilities.genericViewportDataSetMetadataProvider.remove(representationUID); + utilities.genericViewportDisplaySetMetadataProvider.remove(representationUID); dataViewport.removeData(representationUID); return true; diff --git a/tests/vitest-browser/genericStackApi.browser.test.ts b/tests/vitest-browser/genericStackApi.browser.test.ts index 48c0d859c2..1a0e239680 100644 --- a/tests/vitest-browser/genericStackApi.browser.test.ts +++ b/tests/vitest-browser/genericStackApi.browser.test.ts @@ -218,7 +218,7 @@ async function renderNextStackViewport() { const viewport = renderingEngine.getViewport(viewportId); const imageId = encodeImageIdInfo(imageInfo); - utilities.genericViewportDataSetMetadataProvider.add(stackDataId, { + utilities.genericViewportDisplaySetMetadataProvider.add(stackDataId, { imageIds: [imageId], kind: 'planar', initialImageIdIndex: 0, @@ -248,7 +248,7 @@ afterEach(() => { cache.purgeCache(); metaData.removeProvider(fakeMetaDataProvider); imageLoader.unregisterAllImageLoaders(); - utilities.genericViewportDataSetMetadataProvider.clear?.(); + utilities.genericViewportDisplaySetMetadataProvider.clear?.(); if (previousUseGenericViewport !== undefined) { getConfiguration().rendering.useGenericViewport = previousUseGenericViewport; diff --git a/utils/ExampleRunner/example-runner-cli.js b/utils/ExampleRunner/example-runner-cli.js index 1b94b66f71..d9492f317b 100755 --- a/utils/ExampleRunner/example-runner-cli.js +++ b/utils/ExampleRunner/example-runner-cli.js @@ -301,7 +301,22 @@ function run() { '--config', webpackConfigPath, ]; - spawnSync(rspackBin, rspackArgs, { stdio: 'inherit', shell: false }); + // On Windows the rspack binary is a `.cmd` shim. Since Node's + // CVE-2024-27980 hardening, spawning a `.cmd`/`.bat` with `shell: false` + // fails with EINVAL, so run it through a shell on win32. + const isWin = process.platform === 'win32'; + const result = spawnSync(rspackBin, rspackArgs, { + stdio: 'inherit', + shell: isWin, + }); + + if (result.error) { + console.error(`\n=> Failed to start rspack: ${result.error.message}`); + process.exit(1); + } + if (typeof result.status === 'number' && result.status !== 0) { + process.exit(result.status); + } } else { console.log('=> To run an example:'); console.log(' $ npm run example -- PUT_YOUR_EXAMPLE_NAME_HERE\n'); diff --git a/utils/demo/helpers/index.js b/utils/demo/helpers/index.js index 257d911858..de07d8163d 100644 --- a/utils/demo/helpers/index.js +++ b/utils/demo/helpers/index.js @@ -17,6 +17,17 @@ import contourSegmentationToolBindings from './contourSegmentationToolBindings'; import contourTools from './contourTools'; import createElement from './createElement'; import createImageIdsAndCacheMetaData from './createImageIdsAndCacheMetaData'; +export { + splitDisplaySetsFromImageIds, + createDisplaySets, + getVideoImageIdFromImageIds, + getViewportTypeForDisplaySet, + getPrimaryStackFrameImageIds, + getVolumeFrameImageIds, + get4DDimensionGroupImageIds, + get4DVolumeImageIds, + getNaturalizedInstanceForDisplaySetSplit, +} from './splitDisplaySetsFromImageIds'; import createInfoSection from './createInfoSection'; import downloadSurfacesData from './downloadSurfacesData'; import getLocalUrl from './getLocalUrl'; diff --git a/utils/demo/helpers/splitDisplaySetsFromImageIds.ts b/utils/demo/helpers/splitDisplaySetsFromImageIds.ts new file mode 100644 index 0000000000..746586287d --- /dev/null +++ b/utils/demo/helpers/splitDisplaySetsFromImageIds.ts @@ -0,0 +1,265 @@ +import { Enums, metaData } from '@cornerstonejs/core'; +import { + createDisplaySetFromGroup, + defaultDisplaySetSplitRules, + splitImageIdsBySplitRules, + utilities as metadataUtilities, + type IDisplaySet, + type NaturalizedInstance, +} from '@cornerstonejs/metadata'; +import createImageIdsAndCacheMetaData from './createImageIdsAndCacheMetaData'; + +export type CreateDisplaySetsOptions = { + StudyInstanceUID: string; + SeriesInstanceUID: string; + SOPInstanceUID?: string; + wadoRsRoot: string; + client?: unknown; + convertMultiframe?: boolean; + useLegacyWadoRs?: boolean; +}; + +const { splitImageIdsBy4DTags } = metadataUtilities; +const { ViewportType } = Enums; + +/** Maps a display set's preferred viewport type hint to a cornerstone ViewportType. */ +const VIEWPORT_TYPE_BY_PREFERRED_HINT: Record = { + stack: ViewportType.STACK, + volume: ViewportType.ORTHOGRAPHIC, + volume3d: ViewportType.VOLUME_3D, + video: ViewportType.VIDEO, + wholeslide: ViewportType.WHOLE_SLIDE, + ecg: ViewportType.ECG, +}; + +/** + * Resolves the cornerstone {@link Enums.ViewportType} to enable for a display + * set, based on its preferred viewport type. Falls back to a stack viewport. + */ +export function getViewportTypeForDisplaySet( + displaySet: IDisplaySet +): Enums.ViewportType { + return ( + VIEWPORT_TYPE_BY_PREFERRED_HINT[displaySet.preferredViewportType] ?? + ViewportType.STACK + ); +} + +export type Select4DDimensionGroupsOptions = { + /** 1-based inclusive start dimension group (default 1) */ + fromGroup?: number; + /** 1-based inclusive end dimension group (default last group) */ + toGroup?: number; + /** Take the last N dimension groups */ + lastCount?: number; +}; + +// Normalizes a frame-level imageId to its base (frame 1) form. Mirrors the +// frame patterns handled by `generateFrameImageId` in splitImageIdsBy4DTags so +// both wadors (`/frames/N`) and wadouri (`?frame=N` or `&frame=N`) ids collapse +// to frame 1; the regexes preserve any trailing path/query and multi-digit +// frame numbers instead of slicing off everything after the frame. +function toBaseImageId(imageId: string): string { + const wadorsFramePattern = /\/frames\/\d+/; + if (wadorsFramePattern.test(imageId)) { + return imageId.replace(wadorsFramePattern, '/frames/1'); + } + + const queryFramePattern = /([?&])frame=\d+/; + if (queryFramePattern.test(imageId)) { + return imageId.replace( + queryFramePattern, + (_match, separator) => `${separator}frame=1` + ); + } + + return imageId; +} + +/** + * Naturalized instance for display-set splitting (one row per SOP, base imageId). + */ +export function getNaturalizedInstanceForDisplaySetSplit( + imageId: string +): NaturalizedInstance | undefined { + const instance = metaData.get( + 'instance', + imageId + ) as NaturalizedInstance | undefined; + + if (!instance) { + return undefined; + } + + return { + ...instance, + imageId: toBaseImageId(imageId), + }; +} + +function getInstanceLevelImageIds(imageIds: string[]): string[] { + const bySop = new Map(); + + for (const imageId of imageIds) { + const instance = getNaturalizedInstanceForDisplaySetSplit(imageId); + if (!instance) { + continue; + } + + const sopUid = instance.SOPInstanceUID as string | undefined; + const key = sopUid ?? imageId; + if (!bySop.has(key)) { + bySop.set(key, instance.imageId ?? toBaseImageId(imageId)); + } + } + + return [...bySop.values()]; +} + +function collectFrameImageIdsForGroup( + seriesImageIds: string[], + groupInstances: NaturalizedInstance[] +): string[] { + const sopUids = new Set( + groupInstances + .map(instance => instance.SOPInstanceUID) + .filter(Boolean) as string[] + ); + + if (!sopUids.size) { + return seriesImageIds; + } + + return seriesImageIds.filter(imageId => { + const instance = getNaturalizedInstanceForDisplaySetSplit(imageId); + return instance?.SOPInstanceUID && sopUids.has(instance.SOPInstanceUID); + }); +} + +/** + * Splits a loaded series' imageIds using {@link defaultDisplaySetSplitRules}. + */ +export function splitDisplaySetsFromImageIds( + seriesImageIds: string[] +): IDisplaySet[] { + const instanceLevelImageIds = getInstanceLevelImageIds(seriesImageIds); + + const groups = splitImageIdsBySplitRules(instanceLevelImageIds, { + getNaturalizedInstance: getNaturalizedInstanceForDisplaySetSplit, + splitRules: defaultDisplaySetSplitRules, + }); + + return groups.map((group, splitNumber) => + createDisplaySetFromGroup(group, { + splitNumber, + imageIds: collectFrameImageIdsForGroup(seriesImageIds, group.instances), + }) + ); +} + +/** + * Fetches a series' imageIds (caching its metadata) and splits them into + * display sets using the default split rules. This combines + * {@link createImageIdsAndCacheMetaData} with {@link splitDisplaySetsFromImageIds} + * so examples can go straight from a series query to display sets. + */ +export async function createDisplaySets( + options: CreateDisplaySetsOptions +): Promise { + const seriesImageIds = await createImageIdsAndCacheMetaData(options); + return splitDisplaySetsFromImageIds(seriesImageIds); +} + +/** + * Resolves the underlying imageId for a video display set (replaces hard-coded SOP lookup). + */ +export function getVideoImageIdFromImageIds( + seriesImageIds: string[] +): string | undefined { + const displaySets = splitDisplaySetsFromImageIds(seriesImageIds); + const videoDisplaySet = displaySets.find( + displaySet => displaySet.preferredViewportType === 'video' + ); + + if (!videoDisplaySet) { + return undefined; + } + + return videoDisplaySet.underlyingImageIds[0]; +} + +/** + * Frame-level imageIds for the primary stack-oriented display set in a series. + */ +export function getPrimaryStackFrameImageIds( + seriesImageIds: string[] +): string[] { + const displaySets = splitDisplaySetsFromImageIds(seriesImageIds); + + const primaryDisplaySet = + displaySets.find(displaySet => { + const preferred = displaySet.preferredViewportType; + return preferred === 'stack' || preferred === 'volume3d'; + }) ?? displaySets[0]; + + if (!primaryDisplaySet) { + return seriesImageIds; + } + + const frameImageIds = [...primaryDisplaySet.imageIds]; + return frameImageIds.length ? frameImageIds : seriesImageIds; +} + +/** + * Frame-level imageIds for the primary volume-oriented display set (volume3d/volume). + */ +export function getVolumeFrameImageIds(seriesImageIds: string[]): string[] { + const displaySets = splitDisplaySetsFromImageIds(seriesImageIds); + + const volumeDisplaySet = + displaySets.find( + displaySet => displaySet.preferredViewportType === 'volume3d' + ) ?? + displaySets.find( + displaySet => displaySet.preferredViewportType === 'volume' + ) ?? + displaySets[0]; + + if (!volumeDisplaySet) { + return seriesImageIds; + } + + const frameImageIds = [...volumeDisplaySet.imageIds]; + return frameImageIds.length ? frameImageIds : seriesImageIds; +} + +/** + * Splits a series into 4D dimension groups using DICOM 4D tags + * ({@link splitImageIdsBy4DTags}) after applying default display-set rules. + */ +export function get4DDimensionGroupImageIds(seriesImageIds: string[]): string[][] { + const volumeFrameImageIds = getVolumeFrameImageIds(seriesImageIds); + const { imageIdGroups } = splitImageIdsBy4DTags(volumeFrameImageIds); + return imageIdGroups; +} + +/** + * ImageIds for a dynamic (4D) volume, optionally restricted to dimension groups. + * Group indices are 1-based in `fromGroup` / `toGroup`. + */ +export function get4DVolumeImageIds( + seriesImageIds: string[], + options: Select4DDimensionGroupsOptions = {} +): string[] { + let groups = get4DDimensionGroupImageIds(seriesImageIds); + + if (options.lastCount !== undefined) { + groups = groups.slice(-options.lastCount); + } else if (options.fromGroup !== undefined || options.toGroup !== undefined) { + const fromIndex = Math.max(0, (options.fromGroup ?? 1) - 1); + const toIndex = options.toGroup ?? groups.length; + groups = groups.slice(fromIndex, toIndex); + } + + return groups.flat(); +}