From 7599ec9421129dcade94e6fa6ec7908424ab3134 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 15 Aug 2024 14:36:53 -0400 Subject: [PATCH] feat: Add CS3D WSI and Video Viewports and add annotation navigation for MPR (#4182) --- .circleci/config.yml | 2 +- .vscode/extensions.json | 1 + .vscode/settings.json | 102 +++++++++-- .webpack/webpack.base.js | 5 +- extensions/cornerstone-dicom-sr/package.json | 6 +- .../cornerstone-dynamic-volume/package.json | 6 +- extensions/cornerstone/package.json | 10 +- .../src/Viewport/OHIFCornerstoneViewport.tsx | 145 +++------------ .../Overlays/ViewportImageScrollbar.tsx | 67 ++----- .../src/getSopClassHandlerModule.js} | 55 +++++- extensions/cornerstone/src/index.tsx | 2 + .../CornerstoneCacheService.ts | 47 +++-- .../CornerstoneViewportService.ts | 172 +++++++++++------- .../src/services/ViewportService/Viewport.ts | 39 ++-- .../src/types/CornerstoneCacheService.ts | 1 - .../cornerstone/src/types/Presentation.ts | 11 +- .../src/utils/getCornerstoneViewportType.ts | 22 ++- .../utils/measurementServiceMappings/Angle.ts | 2 +- .../ArrowAnnotate.ts | 2 +- .../Bidirectional.ts | 2 +- .../measurementServiceMappings/CircleROI.ts | 2 +- .../measurementServiceMappings/CobbAngle.ts | 2 +- .../EllipticalROI.ts | 2 +- .../measurementServiceMappings/Length.ts | 2 +- .../LivewireContour.ts | 2 +- .../PlanarFreehandROI.ts | 2 +- .../utils/measurementServiceMappings/Probe.ts | 2 +- .../RectangleROI.ts | 2 +- .../measurementServiceMappings/SplineROI.ts | 2 +- .../UltrasoundDirectional.ts | 2 +- .../default/src/DicomWebDataSource/index.js | 108 ++++++----- .../utils/cleanDenaturalizedDataset.ts | 78 ++++++++ .../utils/fixBulkDataURI.ts | 5 +- .../utils/fixMultiValueKeys.ts | 12 ++ .../src/DicomWebDataSource/utils/index.ts | 8 +- .../default/src/getSopClassHandlerModule.js | 12 +- extensions/default/src/index.ts | 2 + .../src/DicomMicroscopyViewport.tsx | 2 +- extensions/dicom-microscopy/src/index.tsx | 14 +- .../src/utils/cleanDenaturalizedDataset.ts | 66 ------- .../src/getSopClassHandlerModule.js | 11 +- .../PanelMeasurementTableTracking/index.tsx | 2 +- modes/basic-test-mode/src/index.ts | 19 +- modes/basic-test-mode/src/initToolGroups.ts | 2 + modes/longitudinal/src/index.ts | 21 ++- modes/microscopy/src/index.tsx | 9 +- platform/app/.webpack/webpack.pwa.js | 2 + .../OHIFMeasurementPanel.spec.js | 4 + platform/app/cypress/support/commands.js | 9 +- platform/app/public/config/default.js | 1 + platform/app/public/config/e2e.js | 2 + platform/app/public/config/netlify.js | 1 + platform/app/src/components/ViewportGrid.tsx | 41 ++--- platform/core/src/classes/MetadataProvider.ts | 1 + .../{IDisplaySet.ts => DisplaySet.ts} | 7 +- .../DisplaySetService/DisplaySetService.ts | 2 +- .../HangingProtocolService.ts | 9 +- platform/core/src/types/index.ts | 2 + platform/core/src/utils/index.test.js | 1 + platform/core/src/utils/index.ts | 2 + platform/core/src/utils/sortStudy.ts | 2 + .../migration-guide/from-3p8-to-3p9-beta.md | 40 ++++ .../components/StudyBrowser/StudyBrowser.tsx | 14 +- .../ui/src/components/Thumbnail/Thumbnail.tsx | 16 +- yarn.lock | 48 ++++- 65 files changed, 781 insertions(+), 513 deletions(-) rename extensions/{dicom-microscopy/src/DicomMicroscopySopClassHandler.js => cornerstone/src/getSopClassHandlerModule.js} (61%) create mode 100644 extensions/default/src/DicomWebDataSource/utils/cleanDenaturalizedDataset.ts create mode 100644 extensions/default/src/DicomWebDataSource/utils/fixMultiValueKeys.ts delete mode 100644 extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts rename platform/core/src/services/DisplaySetService/{IDisplaySet.ts => DisplaySet.ts} (57%) diff --git a/.circleci/config.yml b/.circleci/config.yml index 699a9d30a..6b896c54c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -385,7 +385,7 @@ jobs: - yarn-berry type: enum parallelism: - default: 1 + default: 4 description: | Number of Circle machines to use for load balancing, min 1 (requires `parallel` and `record` flags in your `cypress-command`) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7189097a7..1e74a79aa 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "esbenp.prettier-vscode", + "streetsidesoftware.code-spell-checker", "dbaeumer.vscode-eslint", "mikestead.dotenv", "bungcip.better-toml", diff --git a/.vscode/settings.json b/.vscode/settings.json index 84cc76bc2..3bd5e24c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.rulers": [80, 120], // === // Spacing @@ -14,23 +15,100 @@ // Event Triggers // === "editor.formatOnSave": true, - "eslint.autoFixOnSave": true, "eslint.run": "onSave", - "eslint.validate": [ - { - "language": "javascript", - "autoFix": true - }, - { - "language": "javascriptreact", - "autoFix": true - } - ], "jest.autoRun": "off", "prettier.disableLanguages": ["html"], "prettier.endOfLine": "lf", "workbench.colorCustomizations": {}, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" - } + }, + "cSpell.userWords": [ + "aabb", + "architectured", + "attrname", + "Barksy", + "browserslist", + "bulkdata", + "Cacheable", + "cfun", + "clonedeep", + "Colormap", + "Colormaps", + "Comlink", + "cornerstonejs", + "Crosshairs", + "datasource", + "dcmjs", + "decache", + "decached", + "decaching", + "deepmerge", + "Dicom", + "dicomweb", + "DISPLAYSETS", + "glwindow", + "grababble", + "grabbable", + "Hounsfield", + "Interactable", + "Interactor", + "istyle", + "kitware", + "labelmap", + "labelmaps", + "livewire", + "Mergeable", + "multiframe", + "nifti", + "ofun", + "OHIF", + "polylines", + "POLYSEG", + "prapogation", + "precisionmetrics", + "prefetch", + "Prescaled", + "pydicom", + "Radiopharmaceutical", + "rasterizing", + "reconstructable", + "Rehydratable", + "renderable", + "resampler", + "resemblejs", + "reslice", + "resliced", + "Reslices", + "roadmap", + "ROADMAPS", + "Segmentations", + "semibold", + "sitk", + "SUBRESOLUTION", + "suvbsa", + "suvbw", + "suvlbm", + "textbox", + "thresholded", + "thresholding", + "timepoint", + "timepoints", + "TMTV", + "TOOLGROUP", + "tqdm", + "transferables", + "typedoc", + "unsubscriptions", + "uuidv", + "viewplane", + "viewports", + "Voxel", + "Voxels", + "Vtkjs", + "wado", + "wadors", + "wadouri", + "workerpool" + ] } diff --git a/.webpack/webpack.base.js b/.webpack/webpack.base.js index 08f18c5de..e7ad8127b 100644 --- a/.webpack/webpack.base.js +++ b/.webpack/webpack.base.js @@ -14,11 +14,12 @@ const TerserJSPlugin = require('terser-webpack-plugin'); // const vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core // .rules; // ~~ RULES -const loadShadersRule = require('./rules/loadShaders.js'); +// const loadShadersRule = require('./rules/loadShaders.js'); const loadWebWorkersRule = require('./rules/loadWebWorkers.js'); const transpileJavaScriptRule = require('./rules/transpileJavaScript.js'); const cssToJavaScript = require('./rules/cssToJavaScript.js'); -const stylusToJavaScript = require('./rules/stylusToJavaScript.js'); +// Only uncomment for old v2 stylus +// const stylusToJavaScript = require('./rules/stylusToJavaScript.js'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 1122a5817..df4959107 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -46,9 +46,9 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^1.82.0", - "@cornerstonejs/core": "^1.82.0", - "@cornerstonejs/tools": "^1.82.0", + "@cornerstonejs/adapters": "^1.82.4", + "@cornerstonejs/core": "^1.82.4", + "@cornerstonejs/tools": "^1.82.4", "classnames": "^2.3.2" } } diff --git a/extensions/cornerstone-dynamic-volume/package.json b/extensions/cornerstone-dynamic-volume/package.json index 769893bd9..d3a177b28 100644 --- a/extensions/cornerstone-dynamic-volume/package.json +++ b/extensions/cornerstone-dynamic-volume/package.json @@ -42,9 +42,9 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/core": "^1.82.0", - "@cornerstonejs/streaming-image-volume-loader": "^1.82.0", - "@cornerstonejs/tools": "^1.82.0", + "@cornerstonejs/core": "^1.82.4", + "@cornerstonejs/streaming-image-volume-loader": "^1.82.4", + "@cornerstonejs/tools": "^1.82.4", "classnames": "^2.3.2" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 53d7a534f..7c473bba3 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -38,7 +38,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", "@cornerstonejs/codec-openjph": "^2.4.2", - "@cornerstonejs/dicom-image-loader": "^1.82.0", + "@cornerstonejs/dicom-image-loader": "^1.82.4", "@icr/polyseg-wasm": "^0.4.0", "@ohif/core": "3.9.0-beta.77", "@ohif/ui": "3.9.0-beta.77", @@ -55,10 +55,10 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^1.82.0", - "@cornerstonejs/core": "^1.82.0", - "@cornerstonejs/streaming-image-volume-loader": "^1.82.0", - "@cornerstonejs/tools": "^1.82.0", + "@cornerstonejs/adapters": "^1.82.4", + "@cornerstonejs/core": "^1.82.4", + "@cornerstonejs/streaming-image-volume-loader": "^1.82.4", + "@cornerstonejs/tools": "^1.82.4", "@icr/polyseg-wasm": "^0.4.0", "@kitware/vtk.js": "30.4.1", "html2canvas": "^1.4.1", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index ba91619a0..ebcd9bfc8 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -2,24 +2,17 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; import { useResizeDetector } from 'react-resize-detector'; import PropTypes from 'prop-types'; import * as cs3DTools from '@cornerstonejs/tools'; -import { - Enums, - eventTarget, - getEnabledElement, - StackViewport, - utilities as csUtils, -} from '@cornerstonejs/core'; +import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core'; import { MeasurementService } from '@ohif/core'; import { Notification, useViewportDialog, AllInOneMenu } from '@ohif/ui'; -import { IStackViewport, IVolumeViewport } from '@cornerstonejs/core/dist/esm/types'; +import type { Types as csTypes } from '@cornerstonejs/core'; import { setEnabledElement } from '../state'; import './OHIFCornerstoneViewport.css'; import CornerstoneOverlays from './Overlays/CornerstoneOverlays'; -import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; import CinePlayer from '../components/CinePlayer'; -import { Types } from '@ohif/core'; +import type { Types } from '@ohif/core'; import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners'; import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu'; @@ -106,6 +99,7 @@ const OHIFCornerstoneViewport = React.memo((props: withAppTypes) => { viewportOptions, displaySetOptions, servicesManager, + commandsManager, onElementEnabled, // eslint-disable-next-line react/prop-types onElementDisabled, @@ -149,14 +143,12 @@ const OHIFCornerstoneViewport = React.memo((props: withAppTypes) => { const [appConfig] = useAppConfig(); const { - measurementService, displaySetService, toolbarService, toolGroupService, syncGroupService, cornerstoneViewportService, cornerstoneCacheService, - viewportGridService, stateSyncService, viewportActionCornersService, } = servicesManager.services; @@ -370,24 +362,12 @@ const OHIFCornerstoneViewport = React.memo((props: withAppTypes) => { } const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents( - measurementService, - displaySetService, elementRef, viewportId, - displaySets, - viewportGridService, - cornerstoneViewportService + servicesManager ); - _checkForCachedJumpToMeasurementEvents( - measurementService, - displaySetService, - elementRef, - viewportId, - displaySets, - viewportGridService, - cornerstoneViewportService - ); + _checkForCachedJumpToMeasurementEvents(elementRef, viewportId, displaySets, servicesManager); return () => { unsubscribeFromJumpToMeasurementEvents(); @@ -471,15 +451,9 @@ const OHIFCornerstoneViewport = React.memo((props: withAppTypes) => { ); }, areEqual); -function _subscribeToJumpToMeasurementEvents( - measurementService, - displaySetService, - elementRef, - viewportId, - displaySets, - viewportGridService, - cornerstoneViewportService -) { +function _subscribeToJumpToMeasurementEvents(elementRef, viewportId, servicesManager) { + const { measurementService, cornerstoneViewportService } = servicesManager.services; + const { unsubscribe } = measurementService.subscribe( MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, props => { @@ -491,27 +465,17 @@ function _subscribeToJumpToMeasurementEvents( if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) { // Decide on which viewport should handle this cacheJumpToMeasurementEvent.cornerstoneViewport = - cornerstoneViewportService.getViewportIdToJump( - jumpId, - measurement.displaySetInstanceUID, - { - referencedImageId: - measurement.referencedImageId || measurement.metadata?.referencedImageId, - } - ); + cornerstoneViewportService.getViewportIdToJump(jumpId, { + displaySetInstanceUID: measurement.displaySetInstanceUID, + ...measurement.metadata, + referencedImageId: + measurement.referencedImageId || measurement.metadata?.referencedImageId, + }); } if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) { return; } - _jumpToMeasurement( - measurement, - elementRef, - viewportId, - measurementService, - displaySetService, - viewportGridService, - cornerstoneViewportService - ); + _jumpToMeasurement(measurement, elementRef, viewportId, servicesManager); } ); @@ -520,13 +484,10 @@ function _subscribeToJumpToMeasurementEvents( // Check if there is a queued jumpToMeasurement event function _checkForCachedJumpToMeasurementEvents( - measurementService, - displaySetService, elementRef, viewportId, displaySets, - viewportGridService, - cornerstoneViewportService + servicesManager ) { if (!cacheJumpToMeasurementEvent) { return; @@ -544,37 +505,15 @@ function _checkForCachedJumpToMeasurementEvents( const { measurement } = cacheJumpToMeasurementEvent; if (measurement && elementRef) { if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) { - _jumpToMeasurement( - measurement, - elementRef, - viewportId, - measurementService, - displaySetService, - viewportGridService, - cornerstoneViewportService - ); + _jumpToMeasurement(measurement, elementRef, viewportId, servicesManager); } } } -function _jumpToMeasurement( - measurement, - targetElementRef, - viewportId, - measurementService, - displaySetService, - viewportGridService, - cornerstoneViewportService -) { +function _jumpToMeasurement(measurement, targetElementRef, viewportId, servicesManager) { + const { viewportGridService } = servicesManager.services; + const targetElement = targetElementRef.current; - const { displaySetInstanceUID, SOPInstanceUID, frameNumber } = measurement; - - if (!SOPInstanceUID) { - console.warn('cannot jump in a non-acquisition plane measurements yet'); - return; - } - - const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); // Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager // to set it properly @@ -586,47 +525,15 @@ function _jumpToMeasurement( if (enabledElement) { // See how the jumpToSlice() of Cornerstone3D deals with imageIdx param. - const viewport = enabledElement.viewport as IStackViewport | IVolumeViewport; + const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport; - let imageIdIndex = 0; - let viewportCameraDirectionMatch = true; - - if (viewport instanceof StackViewport) { - const imageIds = viewport.getImageIds(); - imageIdIndex = imageIds.findIndex(imageId => { - const { SOPInstanceUID: aSOPInstanceUID, frameNumber: aFrameNumber } = - getSOPInstanceAttributes(imageId); - return aSOPInstanceUID === SOPInstanceUID && (!frameNumber || frameNumber === aFrameNumber); - }); - } else { - // for volume viewport we can't rely on the imageIdIndex since it can be - // a reconstructed view that doesn't match the original slice numbers etc. - const { viewPlaneNormal: measurementViewPlane } = measurement.metadata; - imageIdIndex = referencedDisplaySet.images.findIndex( - i => i.SOPInstanceUID === SOPInstanceUID - ); - - // the index is reversed in the volume viewport - // imageIdIndex = referencedDisplaySet.images.length - 1 - imageIdIndex; - - const { viewPlaneNormal: viewportViewPlane } = viewport.getCamera(); - - // should compare abs for both planes since the direction can be flipped - if ( - measurementViewPlane && - !csUtils.isEqual(measurementViewPlane.map(Math.abs), viewportViewPlane.map(Math.abs)) - ) { - viewportCameraDirectionMatch = false; - } - } - - if (!viewportCameraDirectionMatch || imageIdIndex === -1) { + const { metadata } = measurement; + if (!viewport.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true })) { + console.log("Reference isn't viewable, postponing until updated"); return; } - cs3DTools.utilities.jumpToSlice(targetElement, { - imageIndex: imageIdIndex, - }); + viewport.setViewReference(metadata); cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid); // Jump to measurement consumed, remove. diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx index 4de754552..6e467181b 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx @@ -43,67 +43,40 @@ function CornerstoneImageScrollbar({ return; } - if (viewportData.viewportType === Enums.ViewportType.STACK) { - const imageIndex = viewport.getCurrentImageIdIndex(); + const imageIndex = viewport.getCurrentImageIdIndex(); + const numberOfSlices = viewport.getNumberOfSlices(); - setImageSliceData({ - imageIndex: imageIndex, - numberOfSlices: viewportData.data[0].imageIds.length, - }); - - return; - } - - if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) { - const sliceData = utilities.getImageSliceDataForVolumeViewport( - viewport as Types.IVolumeViewport - ); - - if (!sliceData) { - return; - } - - const { imageIndex, numberOfSlices } = sliceData; - setImageSliceData({ imageIndex, numberOfSlices }); - } + setImageSliceData({ + imageIndex: imageIndex, + numberOfSlices, + }); }, [viewportId, viewportData]); useEffect(() => { - if (viewportData?.viewportType !== Enums.ViewportType.STACK) { + if (!viewportData) { return; } + const { viewportType } = viewportData; + const eventId = + (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_VIEWPORT_SCROLL) || + (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) || + Enums.Events.IMAGE_RENDERED; - const updateStackIndex = event => { - const { newImageIdIndex } = event.detail; + const updateIndex = event => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const { imageIndex, newImageIdIndex = imageIndex } = event.detail; + const numberOfSlices = viewport.getNumberOfSlices(); // find the index of imageId in the imageIds setImageSliceData({ imageIndex: newImageIdIndex, - numberOfSlices: viewportData.data[0].imageIds.length, + numberOfSlices, }); }; - element.addEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, updateStackIndex); + element.addEventListener(eventId, updateIndex); return () => { - element.removeEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, updateStackIndex); - }; - }, [viewportData, element]); - - useEffect(() => { - if (viewportData?.viewportType !== Enums.ViewportType.ORTHOGRAPHIC) { - return; - } - - const updateVolumeIndex = event => { - const { imageIndex, numberOfSlices } = event.detail; - // find the index of imageId in the imageIds - setImageSliceData({ imageIndex, numberOfSlices }); - }; - - element.addEventListener(Enums.Events.VOLUME_NEW_IMAGE, updateVolumeIndex); - - return () => { - element.removeEventListener(Enums.Events.VOLUME_NEW_IMAGE, updateVolumeIndex); + element.removeEventListener(eventId, updateIndex); }; }, [viewportData, element]); @@ -112,7 +85,7 @@ function CornerstoneImageScrollbar({ onChange={evt => onImageScrollbarChange(evt, viewportId)} max={imageSliceData.numberOfSlices ? imageSliceData.numberOfSlices - 1 : 0} height={scrollbarHeight} - value={imageSliceData.imageIndex} + value={imageSliceData.imageIndex || 0} /> ); } diff --git a/extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js b/extensions/cornerstone/src/getSopClassHandlerModule.js similarity index 61% rename from extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js rename to extensions/cornerstone/src/getSopClassHandlerModule.js index 36e33fda3..e3b5fae21 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js +++ b/extensions/cornerstone/src/getSopClassHandlerModule.js @@ -1,13 +1,19 @@ import OHIF from '@ohif/core'; +import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core'; +import dcmjs from 'dcmjs'; +import { dicomWebUtils } from '@ohif/extension-default'; +const { MetadataModules } = csEnums; const { utils } = OHIF; +const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary; +const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils; const SOP_CLASS_UIDS = { VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6', }; const SOPClassHandlerId = - '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler'; + '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler'; function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) { // If the series has no instances, stop here @@ -27,6 +33,7 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) } } let imageIdForThumbnail = null; + const dataSource = extensionManager.getActiveDataSource()[0]; if (singleFrameInstance) { if (currentFrames == 1) { // Not all DICOM server implementations support thumbnail service, @@ -35,7 +42,6 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) } if (!imageIdForThumbnail) { // use the thumbnail service provided by DICOM server - const dataSource = extensionManager.getActiveDataSource()[0]; imageIdForThumbnail = dataSource.getImageIdsForInstance({ instance: singleFrameInstance, thumbnail: true, @@ -83,6 +89,7 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) const displaySet = { plugin: 'microscopy', Modality: 'SM', + viewportType: csEnums.ViewportType.WholeSlide, altImageText: 'Microscopy', displaySetInstanceUID: utils.guid(), SOPInstanceUID, @@ -102,13 +109,51 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) numInstances: 1, imageIdForThumbnail, // thumbnail image others: instances, // all other level instances in the image Pyramid + instances, othersFrameOfReferenceUID, + imageIds: instances.map(instance => instance.imageId), }; + // The microscopy viewer directly accesses the metadata already loaded, and + // uses the DICOMweb client library directly for loading, so it has to be + // provided here. + const dicomWebClient = dataSource.retrieve.getWadoDicomWebClient?.(); + const instanceMap = new Map(); + instances.forEach(instance => instanceMap.set(instance.imageId, instance)); + if (dicomWebClient) { + const webClient = Object.create(dicomWebClient); + // This replaces just the dicom web metadata call with one which retrieves + // internally. + webClient.getDICOMwebMetadata = getDICOMwebMetadata.bind(webClient, instanceMap); + + csUtils.genericMetadataProvider.addRaw(displaySet.imageIds[0], { + type: MetadataModules.WADO_WEB_CLIENT, + metadata: webClient, + }); + } else { + // Might have some other way of getting the data in the future or internally? + // throw new Error('Unable to provide a DICOMWeb client library, microscopy will fail to view'); + } return [displaySet]; } -export default function getDicomMicroscopySopClassHandler({ servicesManager, extensionManager }) { +/** + * This method provides access to the internal DICOMweb metadata, used to avoid + * refetching the DICOMweb data. It gets assigned as a member function to the + * dicom web client. + */ +function getDICOMwebMetadata(instanceMap, imageId) { + const instance = instanceMap.get(imageId); + if (!instance) { + console.warn('Metadata not already found for', imageId, 'in', instanceMap); + return this.super.getDICOMwebMetadata(imageId); + } + return transferDenaturalizedDataset( + denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId))) + ); +} + +export function getDicomMicroscopySopClassHandler({ servicesManager, extensionManager }) { const getDisplaySetsFromSeries = instances => { return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager); }; @@ -119,3 +164,7 @@ export default function getDicomMicroscopySopClassHandler({ servicesManager, ext getDisplaySetsFromSeries, }; } + +export function getSopClassHandlerModule(params) { + return [getDicomMicroscopySopClassHandler(params)]; +} diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 934f3de52..e74a0de9f 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -39,6 +39,7 @@ import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel'; import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; import { findNearbyToolData } from './utils/findNearbyToolData'; import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer'; +import { getSopClassHandlerModule } from './getSopClassHandlerModule'; const { helpers: volumeLoaderHelpers } = csStreamingImageVolumeLoader; const { getDynamicVolumeInfo } = volumeLoaderHelpers ?? {}; @@ -218,6 +219,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { }, ]; }, + getSopClassHandlerModule, }; export type { PublicViewportOptions }; diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 09c0d8c2b..c428e8043 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -32,7 +32,7 @@ class CornerstoneCacheService { } public async createViewportData( - displaySets: unknown[], + displaySets: Types.DisplaySet[], viewportOptions: Record, dataSource: unknown, initialImageIndex?: number @@ -55,23 +55,29 @@ class CornerstoneCacheService { viewportOptions.viewportType = viewportType; } - const cs3DViewportType = getCornerstoneViewportType(viewportType); + const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets); let viewportData: StackViewportData | VolumeViewportData; - if (cs3DViewportType === Enums.ViewportType.STACK) { - viewportData = await this._getStackViewportData( - dataSource, - displaySets, - initialImageIndex, - cs3DViewportType - ); - } - if ( cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC || cs3DViewportType === Enums.ViewportType.VOLUME_3D ) { viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType); + } else if (cs3DViewportType === Enums.ViewportType.STACK) { + // Everything else looks like a stack + viewportData = await this._getStackViewportData( + dataSource, + displaySets, + initialImageIndex, + cs3DViewportType + ); + } else { + viewportData = await this._getOtherViewportData( + dataSource, + displaySets, + initialImageIndex, + cs3DViewportType + ); } viewportData.viewportType = cs3DViewportType; @@ -143,6 +149,25 @@ class CornerstoneCacheService { return newViewportData; } + private async _getOtherViewportData( + dataSource, + displaySets, + _initialImageIndex, + viewportType: Enums.ViewportType + ): Promise { + // TODO - handle overlays and secondary display sets, but for now assume + // the 1st display set is the one of interest + const [displaySet] = displaySets; + if (!displaySet.imageIds) { + displaySet.imagesIds = this._getCornerstoneStackImageIds(displaySet, dataSource); + } + const { imageIds: data, viewportType: dsViewportType } = displaySet; + return { + viewportType: dsViewportType || viewportType, + data: displaySets, + }; + } + private async _getStackViewportData( dataSource, displaySets, diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 6dd645bce..7fdaf65db 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -65,6 +65,14 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi this.viewportGridResizeObserver = null; this.servicesManager = servicesManager; } + hangingProtocolService: unknown; + viewportsInfo: unknown; + sceneVolumeInputs: unknown; + viewportDivElements: unknown; + ViewportPropertiesMap: unknown; + volumeUIDs: unknown; + displaySetsNeedRerendering: unknown; + viewportDisplaySets: unknown; /** * Adds the HTML element to the viewportService @@ -166,17 +174,18 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi * * @param viewportId - The ID of the viewport. * @param presentations - The presentations to apply to the viewport. + * @param viewportInfo - Contains a view reference for immediate application */ - public setPresentations(viewportId: string, presentations?: Presentations): void { + public setPresentations( + viewportId: string, + presentations?: Presentations, + viewportInfo?: ViewportInfo + ): void { const viewport = this.getCornerstoneViewport(viewportId) as | Types.IStackViewport | Types.IVolumeViewport; - if (!viewport) { - return; - } - - if (!presentations) { + if (!viewport || !presentations) { return; } @@ -196,17 +205,12 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi } } - if (positionPresentation) { - const { viewPlaneNormal, viewUp, zoom, pan } = positionPresentation.presentation; - viewport.setCamera({ viewPlaneNormal, viewUp }); - - if (zoom !== undefined) { - viewport.setZoom(zoom); - } - - if (pan !== undefined) { - viewport.setPan(pan); - } + const viewRef = viewportInfo?.getViewReference() || positionPresentation?.viewReference; + if (viewRef) { + viewport.setViewReference(viewRef); + } + if (positionPresentation?.position) { + viewport.setViewPresentation(positionPresentation.position); } } @@ -235,21 +239,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi return; } - const { viewPlaneNormal, viewUp } = csViewport.getCamera(); - const initialImageIndex = csViewport.getCurrentImageIdIndex() || 0; - const zoom = csViewport.getZoom(); - const pan = csViewport.getPan(); - return { id: positionPresentationId, viewportType: viewportInfo.getViewportType(), - presentation: { - initialImageIndex, - viewUp, - viewPlaneNormal, - zoom, - pan, - }, + viewReference: csViewport.getViewReference(), + position: csViewport.getViewPresentation({ pan: true, zoom: true }), }; } @@ -428,7 +422,14 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi // override the viewportOptions and displaySetOptions with the public ones // since those are the newly set ones, we set them here so that it handles defaults const displaySetOptions = viewportInfo.setPublicDisplaySetOptions(publicDisplaySetOptions); - const viewportOptions = viewportInfo.setPublicViewportOptions(publicViewportOptions); + // Specify an over-ride for the viewport type, even though it is in the public + // viewport options, because the one in the public viewport options is a suggestion + // for initial view, whereas the one in viewportData is a requirement based on the + // type of data being displayed. + const viewportOptions = viewportInfo.setPublicViewportOptions( + publicViewportOptions, + viewportData.viewportType + ); const element = viewportInfo.getElement(); const type = viewportInfo.getViewportType(); @@ -518,35 +519,80 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi /** * Looks through the viewports to see if the specified measurement can be - * displayed in one of the viewports. + * displayed in one of the viewports. This function tries to get a "best fit" + * viewport to display the image in where it matches, in order: + * * Active viewport that can be navigated to the given image without orientation change + * * Other viewport that can be navigated to the given image without orientation change + * * Active viewport that can change orientation to display the image + * * Other viewport that can change orientation to display the image * - * @param measurement - * The measurement that is desired to view. + * It returns `null` otherwise, indicating that a viewport needs display set/type + * changes in order to display the image. + * + * Notes: + * * If the display set is displayed in multiple viewports all needing orientation change, + * then the active one or first one listed will be modified. This can create unexpected + * behaviour for MPR views. + * * If the image is contained in multiple display sets, then the first one + * found will be navigated (active first, followed by first found) + * + * @param measurement - The measurement that is desired to view. * @param activeViewportId - the index that was active at the time the jump * was initiated. * @return the viewportId that the measurement should be displayed in. */ - public getViewportIdToJump( - activeViewportId: string, - displaySetInstanceUID: string, - cameraProps: unknown - ): string { - const viewportInfo = this.getViewportInfo(activeViewportId); - - if (viewportInfo.getViewportType() === csEnums.ViewportType.VOLUME_3D) { - return null; - } - - const { referencedImageId } = cameraProps; - if (viewportInfo?.contains(displaySetInstanceUID, referencedImageId)) { + public getViewportIdToJump(activeViewportId: string, metadata): string { + // First check if the active viewport can just be navigated to show the given item + const activeViewport = this.getCornerstoneViewport(activeViewportId); + if (activeViewport.isReferenceViewable(metadata, { withNavigation: true })) { return activeViewportId; } - return ( - [...this.viewportsById.values()].find(viewportInfo => - viewportInfo.contains(displaySetInstanceUID, referencedImageId) - )?.viewportId ?? null - ); + // Next, see if any viewport could be navigated to show the given item, + // without considering orientation changes. + for (const id of this.viewportsById.keys()) { + const viewport = this.getCornerstoneViewport(id); + if (viewport?.isReferenceViewable(metadata, { withNavigation: true })) { + return id; + } + } + + // No viewport is in the right display set/orientation to show this, so see if + // the active viewport could change orientations to show this + if ( + activeViewport.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true }) + ) { + return activeViewportId; + } + + // See if any viewport could show this with an orientation change + for (const id of this.viewportsById.keys()) { + const viewport = this.getCornerstoneViewport(id); + if ( + viewport?.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true }) + ) { + return id; + } + } + + // No luck, need to update the viewport itself + return null; + } + + /** + * Sets the image data for the given viewport. + */ + private async _setOtherViewport( + viewport: Types.IStackViewport, + viewportData: StackViewportData, + viewportInfo: ViewportInfo, + _presentations: Presentations = {} + ): Promise { + const [displaySet] = viewportData.data; + return viewport.setDataIds(displaySet.imageIds, { + groupId: displaySet.displaySetInstanceUID, + viewReference: viewportInfo.getViewReference(), + }); } private async _setStackViewport( @@ -566,6 +612,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const { initialImageIndex, imageIds } = viewportData.data[0]; + // Use the slice index from any provided view reference, as the view reference + // is being used to navigate to the initial view position for measurement + // navigation and other navigation forcing specific views. let initialImageIndexToUse = presentations?.positionPresentation?.initialImageIndex ?? initialImageIndex; @@ -586,20 +635,15 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi properties.voiRange = { lower, upper }; } - if (voiInverted !== undefined) { - properties.invert = voiInverted; - } - - if (colormap !== undefined) { - properties.colormap = colormap; - } + properties.invert = voiInverted ?? properties.invert; + properties.colormap = colormap ?? properties.colormap; } this._handleOverlays(viewport); return viewport.setStack(imageIds, initialImageIndexToUse).then(() => { viewport.setProperties({ ...properties }); - this.setPresentations(viewport.id, presentations); + this.setPresentations(viewport.id, presentations, viewportInfo); if (displayArea) { viewport.setDisplayArea(displayArea); } @@ -750,7 +794,6 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewport.id); const displaySet = displaySetService.getDisplaySetByUID(displaySetUIDs[0]); const displaySetModality = displaySet?.Modality; - // Todo: use presentations states const volumesProperties = volumeInputArray.map((volumeInput, index) => { const { volumeId } = volumeInput; @@ -786,7 +829,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi viewport.setProperties(properties, volumeId); }); - this.setPresentations(viewport.id, presentations); + this.setPresentations(viewport.id, presentations, viewportInfo); this._handleOverlays(viewport); @@ -976,7 +1019,12 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi ); } - throw new Error('Unknown viewport type'); + return this._setOtherViewport( + viewport as Types.IViewport, + viewportData as StackViewportData, + viewportInfo, + presentations + ); } /** diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index 6245c69ff..9281ccf5c 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -31,6 +31,9 @@ export type ViewportOptions = { displayArea?: Types.DisplayArea; syncGroups?: SyncGroup[]; initialImageOptions?: InitialImageOptions; + rotation?: number; + flipHorizontal?: boolean; + viewReference?: Types.ViewReference; customViewportProps?: Record; /* * Allows drag and drop of display sets not matching viewport options, but @@ -49,6 +52,8 @@ export type PublicViewportOptions = { background?: Types.Point3; displayArea?: Types.DisplayArea; syncGroups?: SyncGroup[]; + rotation?: number; + flipHorizontal?: boolean; initialImageOptions?: InitialImageOptions; customViewportProps?: Record; allowUnmatchedView?: boolean; @@ -130,6 +135,7 @@ class ViewportInfo { private displaySetOptions: Array; private viewportData: StackViewportData | VolumeViewportData; private renderingEngineId: string; + private viewReference: Types.ViewReference; constructor(viewportId: string) { this.viewportId = viewportId; @@ -201,6 +207,10 @@ class ViewportInfo { return this.viewportId; } + public getViewReference(): Types.ViewReference { + return this.viewportOptions?.viewReference; + } + public setPublicDisplaySetOptions( publicDisplaySetOptions: PublicDisplaySetOptions[] | DisplaySetSelector[] ): Array { @@ -232,21 +242,22 @@ class ViewportInfo { return viewportData.data.displaySetInstanceUID === displaySetInstanceUID; } - public setPublicViewportOptions(viewportOptionsEntry: PublicViewportOptions): ViewportOptions { - let viewportType = viewportOptionsEntry.viewportType; - const { toolGroupId = DEFAULT_TOOLGROUP_ID, presentationIds } = viewportOptionsEntry; - let orientation; + /** + * + * @param viewportOptionsEntry - the base values for the options + * @param viewportTypeDisplaySet - allows overriding the viewport type + */ + public setPublicViewportOptions( + viewportOptionsEntry: PublicViewportOptions, + viewportTypeDisplaySet?: string + ): ViewportOptions { + const ohifViewportType = viewportTypeDisplaySet || viewportOptionsEntry.viewportType || STACK; + const { presentationIds } = viewportOptionsEntry; + let { toolGroupId = DEFAULT_TOOLGROUP_ID } = viewportOptionsEntry; + // Just assign the orientation for any viewport type and let the viewport deal with it + const orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation); - if (!viewportType) { - viewportType = getCornerstoneViewportType(STACK); - } else { - viewportType = getCornerstoneViewportType(viewportOptionsEntry.viewportType); - } - - // map SAGITTAL, AXIAL, CORONAL orientation to be used by cornerstone - if (viewportOptionsEntry.viewportType?.toLowerCase() !== STACK) { - orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation); - } + const viewportType = getCornerstoneViewportType(ohifViewportType); if (!toolGroupId) { toolGroupId = DEFAULT_TOOLGROUP_ID; diff --git a/extensions/cornerstone/src/types/CornerstoneCacheService.ts b/extensions/cornerstone/src/types/CornerstoneCacheService.ts index 3fef228b2..261d1b848 100644 --- a/extensions/cornerstone/src/types/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/types/CornerstoneCacheService.ts @@ -9,7 +9,6 @@ type StackData = { isCompositeStack?: boolean; imageIds: string[]; frameRate?: number; - isClip?: boolean; initialImageIndex?: number | string | null; }; diff --git a/extensions/cornerstone/src/types/Presentation.ts b/extensions/cornerstone/src/types/Presentation.ts index 57737cc44..1fd332f82 100644 --- a/extensions/cornerstone/src/types/Presentation.ts +++ b/extensions/cornerstone/src/types/Presentation.ts @@ -7,13 +7,10 @@ import type { Types } from '@cornerstonejs/core'; export type PositionPresentation = { id: string; viewportType: string; - presentation: { - initialImageIndex: number; - viewUp: Types.Point3; - viewPlaneNormal: Types.Point3; - zoom?: number; - pan?: Types.Point2; - }; + // The view reference has the basic information as to what image orientation/slice is shown + viewReference: Types.ViewReference; + // The position information has the zoom/pan and possibly other related information, but not LUT + position: Types.ViewPresentation; }; /** diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index 9a5dd71de..2d44dd983 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -1,16 +1,30 @@ +import type { Types } from '@ohif/core'; import { Enums } from '@cornerstonejs/core'; const STACK = 'stack'; const VOLUME = 'volume'; const ORTHOGRAPHIC = 'orthographic'; const VOLUME_3D = 'volume3d'; +const VIDEO = 'video'; +const WHOLESLIDE = 'wholeslide'; -export default function getCornerstoneViewportType(viewportType: string): Enums.ViewportType { - const lowerViewportType = viewportType.toLowerCase(); +export default function getCornerstoneViewportType( + viewportType: string, + displaySets?: Types.DisplaySet[] +): Enums.ViewportType { + const lowerViewportType = + displaySets?.[0]?.viewportType?.toLowerCase() || viewportType.toLowerCase(); if (lowerViewportType === STACK) { return Enums.ViewportType.STACK; } + if (lowerViewportType === VIDEO) { + return Enums.ViewportType.VIDEO; + } + if (lowerViewportType === WHOLESLIDE) { + return Enums.ViewportType.WholeSlide; + } + if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) { return Enums.ViewportType.ORTHOGRAPHIC; } @@ -19,5 +33,7 @@ export default function getCornerstoneViewportType(viewportType: string): Enums. return Enums.ViewportType.VOLUME_3D; } - throw new Error(`Invalid viewport type: ${viewportType}. Valid types are: stack, volume`); + throw new Error( + `Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide` + ); } diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Angle.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/Angle.ts index 1ef0ecf49..022b70cab 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Angle.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Angle.ts @@ -168,7 +168,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { angle, unit, SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.ts index 18aee0c23..c91239144 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.ts @@ -117,7 +117,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.ts index d2b1f81f9..fceed7e48 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.ts @@ -165,7 +165,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { const roundedLength = utils.roundNumber(length, 2); const roundedWidth = utils.roundNumber(width, 2); - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts index 1e2e85a3f..d6dba8762 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts @@ -171,7 +171,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { area, SOPInstanceUID, frameNumber, areaUnit } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/CobbAngle.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/CobbAngle.ts index 91f2c1576..717fcaf0a 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/CobbAngle.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/CobbAngle.ts @@ -168,7 +168,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { angle, unit, SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.ts index 54f156abb..d9ccd35e5 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.ts @@ -171,7 +171,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { area, SOPInstanceUID, frameNumber, areaUnit } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts index 9ebad2b48..71abc6148 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts @@ -168,7 +168,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { length, SeriesNumber, SOPInstanceUID, frameNumber, unit } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts index 4c2d3f56f..4d4a98736 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts @@ -132,7 +132,7 @@ function getDisplayText(annotation, displaySet, customizationService) { const displayText = []; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { InstanceNumber = instance.InstanceNumber; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/PlanarFreehandROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/PlanarFreehandROI.ts index bad6062b9..d8720bfab 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/PlanarFreehandROI.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/PlanarFreehandROI.ts @@ -139,7 +139,7 @@ function getDisplayText(annotation, displaySet, customizationService, displaySet const displayTextArray = []; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { InstanceNumber = instance.InstanceNumber; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Probe.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/Probe.ts index 7d0c89c29..9ecb426f2 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Probe.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Probe.ts @@ -163,7 +163,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { const { value, unit, SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts index 61b2e1c47..ade974f91 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts @@ -172,7 +172,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { // Area is the same for all series const { area, SOPInstanceUID, frameNumber, areaUnit } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/SplineROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/SplineROI.ts index b1bac9f54..7c719260d 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/SplineROI.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/SplineROI.ts @@ -138,7 +138,7 @@ function getDisplayText(annotation, displaySet, customizationService) { const displayTextArray = []; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { InstanceNumber = instance.InstanceNumber; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/UltrasoundDirectional.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/UltrasoundDirectional.ts index f88973ae9..b6997f3f5 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/UltrasoundDirectional.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/UltrasoundDirectional.ts @@ -176,7 +176,7 @@ function getDisplayText(mappedAnnotations, displaySet, customizationService) { const { xValues, yValues, units, isUnitless, SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0]; - const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; if (instance) { diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 5e9a35bfb..8f5f9ae86 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -190,6 +190,15 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { params ); }, + /** + * Provide direct access to the dicom web client for certain use cases + * where the dicom web client is used by an external library such as the + * microscopy viewer. + * Note this instance only needs to support the wado queries, and may not + * support any QIDO or STOW operations. + */ + getWadoDicomWebClient: () => wadoDicomWebClient, + bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => { qidoDicomWebClient.headers = getAuthrorizationHeader(); const options = { @@ -373,6 +382,32 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { dicomWebConfig ); + /** + * Adds the retrieve bulkdata function to naturalized DICOM data. + * This is done recursively, for sub-sequences. + */ + const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => { + for (const key of Object.keys(naturalized)) { + const value = naturalized[key]; + + if (Array.isArray(value) && typeof value[0] === 'object') { + // Fix recursive values + value.forEach(child => addRetrieveBulkDataNaturalized(child, instance)); + continue; + } + + // The value.Value will be set with the bulkdata read value + // in which case it isn't necessary to re-read this. + if (value && value.BulkDataURI && !value.Value) { + // handle the scenarios where bulkDataURI is relative path + fixBulkDataURI(value, instance, dicomWebConfig); + // Provide a method to fetch bulkdata + value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value); + } + } + return naturalized; + }; + /** * naturalizes the dataset, and adds a retrieve bulkdata method * to any values containing BulkDataURI. @@ -387,48 +422,7 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { return naturalized; } - Object.keys(naturalized).forEach(key => { - const value = naturalized[key]; - - // The value.Value will be set with the bulkdata read value - // in which case it isn't necessary to re-read this. - if (value && value.BulkDataURI && !value.Value) { - // handle the scenarios where bulkDataURI is relative path - fixBulkDataURI(value, naturalized, dicomWebConfig); - // Provide a method to fetch bulkdata - value.retrieveBulkData = (options = {}) => { - - const { mediaType } = options; - const useOptions = { - // The bulkdata fetches work with either multipart or - // singlepart, so set multipart to false to let the server - // decide which type to respond with. - multipart: false, - BulkDataURI: value.BulkDataURI, - // The study instance UID is required if the bulkdata uri - // is relative - that isn't disallowed by DICOMweb, but - // isn't well specified in the standard, but is needed in - // any implementation that stores static copies of the metadata - StudyInstanceUID: naturalized.StudyInstanceUID, - mediaTypes: mediaType - ? [{ mediaType }, { mediaType: 'application/octet-stream' }] - : undefined, - ...options, - }; - return qidoDicomWebClient.retrieveBulkData(useOptions).then(val => { - // There are DICOM PDF cases where the first ArrayBuffer in the array is - // the bulk data and DICOM video cases where the second ArrayBuffer is - // the bulk data. Here we play it safe and do a find. - const ret = - (val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || - undefined; - value.Value = ret; - return ret; - }); - }; - } - }); - return naturalized; + return addRetrieveBulkDataNaturalized(naturalized); }; // Async load series, store as retrieved @@ -558,4 +552,34 @@ function createDicomWebApi(dicomWebConfig, servicesManager) { return IWebApiDataSource.create(implementation); } +/** + * A bindable function that retrieves the bulk data against this as the + * dicomweb client, and on the given value element. + * + * @param value - a bind value that stores the retrieve value to short circuit the + * next retrieve instance. + * @param options - to allow specifying the content type. + */ +function retrieveBulkData(value, options = {}) { + const { mediaType } = options; + const useOptions = { + // The bulkdata fetches work with either multipart or + // singlepart, so set multipart to false to let the server + // decide which type to respond with. + multipart: false, + BulkDataURI: value.BulkDataURI, + mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined, + ...options, + }; + return this.retrieveBulkData(useOptions).then(val => { + // There are DICOM PDF cases where the first ArrayBuffer in the array is + // the bulk data and DICOM video cases where the second ArrayBuffer is + // the bulk data. Here we play it safe and do a find. + const ret = + (val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || undefined; + value.Value = ret; + return ret; + }); +} + export { createDicomWebApi }; diff --git a/extensions/default/src/DicomWebDataSource/utils/cleanDenaturalizedDataset.ts b/extensions/default/src/DicomWebDataSource/utils/cleanDenaturalizedDataset.ts new file mode 100644 index 000000000..61227990c --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/utils/cleanDenaturalizedDataset.ts @@ -0,0 +1,78 @@ +import { fixBulkDataURI } from './fixBulkDataURI'; + +function isPrimitive(v: any) { + return !(typeof v == 'object' || Array.isArray(v)); +} + +const vrNumerics = new Set([ + 'DS', + 'FL', + 'FD', + 'IS', + 'OD', + 'OF', + 'OL', + 'OV', + 'SL', + 'SS', + 'SV', + 'UL', + 'US', + 'UV', +]); + +/** + * Specialized for DICOM JSON format dataset cleaning. + * @param obj + * @returns + */ +export function cleanDenaturalizedDataset( + obj: any, + options?: { + StudyInstanceUID: string; + SeriesInstanceUID: string; + dataSourceConfig: unknown; + } +): any { + if (Array.isArray(obj)) { + const newAry = obj.map(o => (isPrimitive(o) ? o : cleanDenaturalizedDataset(o, options))); + return newAry; + } + if (isPrimitive(obj)) { + return obj; + } + Object.keys(obj).forEach(key => { + if (obj[key].Value === null && obj[key].vr) { + delete obj[key].Value; + } else if (Array.isArray(obj[key].Value) && obj[key].vr) { + if (obj[key].Value.length === 1 && obj[key].Value[0].BulkDataURI) { + if (options?.dataSourceConfig) { + // Not needed unless data source is directly used for loading data. + fixBulkDataURI(obj[key].Value[0], options, options.dataSourceConfig); + } + + obj[key].BulkDataURI = obj[key].Value[0].BulkDataURI; + + // prevent mixed-content blockage + if (window.location.protocol === 'https:' && obj[key].BulkDataURI.startsWith('http:')) { + obj[key].BulkDataURI = obj[key].BulkDataURI.replace('http:', 'https:'); + } + delete obj[key].Value; + } else if (vrNumerics.has(obj[key].vr)) { + obj[key].Value = obj[key].Value.map(v => +v); + } else { + obj[key].Value = obj[key].Value.map(entry => cleanDenaturalizedDataset(entry, options)); + } + } + }); + return obj; +} + +/** + * This is required to make the denaturalized data transferrable when it has + * added proxy values. + */ +export function transferDenaturalizedDataset(dataset) { + const noNull = cleanDenaturalizedDataset(dataset); + return JSON.parse(JSON.stringify(noNull)); +} diff --git a/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts b/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts index 49c622916..68dab96dc 100644 --- a/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts +++ b/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts @@ -23,6 +23,8 @@ function fixBulkDataURI(value, instance, dicomWebConfig) { let { BulkDataURI } = value; const { bulkDataURI: uriConfig = {} } = dicomWebConfig; + BulkDataURI = uriConfig.transform?.(BulkDataURI) || BulkDataURI; + // Handle incorrectly prefixed origins const { startsWith, prefixWith = '' } = uriConfig; if (startsWith && BulkDataURI.startsWith(startsWith)) { @@ -36,7 +38,7 @@ function fixBulkDataURI(value, instance, dicomWebConfig) { if ( BulkDataURI.startsWith('series/') || BulkDataURI.startsWith('bulkdata/') || - (uriConfig.relativeResolution === 'studies' && isInstanceStart) + (uriConfig.relativeResolution === 'studies' && !isInstanceStart) ) { value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/${BulkDataURI}`; } else if ( @@ -49,7 +51,6 @@ function fixBulkDataURI(value, instance, dicomWebConfig) { return; } - // in case it is relative path but starts at the server (e.g., /bulk/1e, note the missing http // in the beginning and the first character is /) There are two scenarios, whether the wado root // is absolute or relative. In case of absolute, we need to prepend the wado root to the bulkdata diff --git a/extensions/default/src/DicomWebDataSource/utils/fixMultiValueKeys.ts b/extensions/default/src/DicomWebDataSource/utils/fixMultiValueKeys.ts new file mode 100644 index 000000000..c0e462e1c --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/utils/fixMultiValueKeys.ts @@ -0,0 +1,12 @@ +/** + * Fix multi-valued keys so that those which are strings split by + * a backslash are returned as arrays. + */ +export function fixMultiValueKeys(naturalData, keys = ['ImageType']) { + for (const key of keys) { + if (typeof naturalData[key] === 'string') { + naturalData[key] = naturalData[key].split('\\'); + } + } + return naturalData; +} diff --git a/extensions/default/src/DicomWebDataSource/utils/index.ts b/extensions/default/src/DicomWebDataSource/utils/index.ts index 2132253fe..3c34ea1a4 100644 --- a/extensions/default/src/DicomWebDataSource/utils/index.ts +++ b/extensions/default/src/DicomWebDataSource/utils/index.ts @@ -1,3 +1,9 @@ import { fixBulkDataURI } from './fixBulkDataURI'; +import { + cleanDenaturalizedDataset, + transferDenaturalizedDataset, +} from './cleanDenaturalizedDataset'; -export { fixBulkDataURI }; +export { fixMultiValueKeys } from './fixMultiValueKeys'; + +export { fixBulkDataURI, cleanDenaturalizedDataset, transferDenaturalizedDataset }; diff --git a/extensions/default/src/getSopClassHandlerModule.js b/extensions/default/src/getSopClassHandlerModule.js index 28a677ff7..74f8cea5a 100644 --- a/extensions/default/src/getSopClassHandlerModule.js +++ b/extensions/default/src/getSopClassHandlerModule.js @@ -1,12 +1,12 @@ -import { isImage } from '@ohif/core/src/utils/isImage'; -import sopClassDictionary from '@ohif/core/src/utils/sopClassDictionary'; -import ImageSet from '@ohif/core/src/classes/ImageSet'; -import isDisplaySetReconstructable from '@ohif/core/src/utils/isDisplaySetReconstructable'; +import { utils, classes } from '@ohif/core'; import { id } from './id'; import getDisplaySetMessages from './getDisplaySetMessages'; import getDisplaySetsFromUnsupportedSeries from './getDisplaySetsFromUnsupportedSeries'; import { chartHandler } from './SOPClassHandlers/chartSOPClassHandler'; +const { isImage, sopClassDictionary, isDisplaySetReconstructable } = utils; +const { ImageSet } = classes; + const DEFAULT_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume'; const DYNAMIC_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingDynamicImageVolume'; const sopClassHandlerName = 'stack'; @@ -178,7 +178,6 @@ function getDisplaySetsFromSeries(instances) { displaySet.setAttributes({ sopClassUids, - isClip: true, numImageFrames: instance.NumberOfFrames, instanceNumber: instance.InstanceNumber, acquisitionDatetime: instance.AcquisitionDateTime, @@ -254,7 +253,8 @@ const sopClassUids = [ sopClassDictionary.OphthalmicPhotography8BitImageStorage, sopClassDictionary.OphthalmicPhotography16BitImageStorage, sopClassDictionary.OphthalmicTomographyImageStorage, - sopClassDictionary.VLWholeSlideMicroscopyImageStorage, + // Handled by another sop class module + // sopClassDictionary.VLWholeSlideMicroscopyImageStorage, sopClassDictionary.PositronEmissionTomographyImageStorage, sopClassDictionary.EnhancedPETImageStorage, sopClassDictionary.LegacyConvertedEnhancedPETImageStorage, diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index 3d4c9ac48..2c2524181 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -17,6 +17,7 @@ import * as dicomWebUtils from './DicomWebDataSource/utils'; import { createReportDialogPrompt } from './Panels'; import createReportAsync from './Actions/createReportAsync'; import StaticWadoClient from './DicomWebDataSource/utils/StaticWadoClient'; +import { cleanDenaturalizedDataset } from './DicomWebDataSource/utils'; const defaultExtension: Types.Extensions.Extension = { /** @@ -56,4 +57,5 @@ export { createReportDialogPrompt, createReportAsync, StaticWadoClient, + cleanDenaturalizedDataset, }; diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx index 3ebe0c603..2fcd0ca66 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -1,12 +1,12 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { LoadingIndicatorProgress } from '@ohif/ui'; +import { cleanDenaturalizedDataset } from '@ohif/extension-default'; import './DicomMicroscopyViewport.css'; import ViewportOverlay from './components/ViewportOverlay'; import getDicomWebClient from './utils/dicomWebClient'; import dcmjs from 'dcmjs'; -import cleanDenaturalizedDataset from './utils/cleanDenaturalizedDataset'; import MicroscopyService from './services/MicroscopyService'; class DicomMicroscopyViewport extends Component { diff --git a/extensions/dicom-microscopy/src/index.tsx b/extensions/dicom-microscopy/src/index.tsx index ce8768745..3491b9a5c 100644 --- a/extensions/dicom-microscopy/src/index.tsx +++ b/extensions/dicom-microscopy/src/index.tsx @@ -5,7 +5,6 @@ import getCommandsModule from './getCommandsModule'; import { Types } from '@ohif/core'; import { useViewportGrid } from '@ohif/ui'; -import getDicomMicroscopySopClassHandler from './DicomMicroscopySopClassHandler'; import getDicomMicroscopySRSopClassHandler from './DicomMicroscopySRSopClassHandler'; import MicroscopyService from './services/MicroscopyService'; import { useResizeDetector } from 'react-resize-detector'; @@ -153,17 +152,8 @@ const extension: Types.Extensions.Extension = { * Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}. * Examples include the default sop class handler provided by the default extension */ - getSopClassHandlerModule({ servicesManager, commandsManager, extensionManager }) { - return [ - getDicomMicroscopySopClassHandler({ - servicesManager, - extensionManager, - }), - getDicomMicroscopySRSopClassHandler({ - servicesManager, - extensionManager, - }), - ]; + getSopClassHandlerModule(params) { + return [getDicomMicroscopySRSopClassHandler(params)]; }, getPanelModule, diff --git a/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts b/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts deleted file mode 100644 index 93acfb143..000000000 --- a/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { dicomWebUtils } from '@ohif/extension-default'; - -function isPrimitive(v: any) { - return !(typeof v == 'object' || Array.isArray(v)); -} - -const vrNumerics = [ - 'DS', - 'FL', - 'FD', - 'IS', - 'OD', - 'OF', - 'OL', - 'OV', - 'SL', - 'SS', - 'SV', - 'UL', - 'US', - 'UV', -]; - -/** - * Specialized for DICOM JSON format dataset cleaning. - * @param obj - * @returns - */ -export default function cleanDenaturalizedDataset( - obj: any, - options: { - StudyInstanceUID: string; - SeriesInstanceUID: string; - dataSourceConfig: unknown; - } -): any { - if (Array.isArray(obj)) { - const newAry = obj.map(o => (isPrimitive(o) ? o : cleanDenaturalizedDataset(o, options))); - return newAry; - } else if (isPrimitive(obj)) { - return obj; - } else { - Object.keys(obj).forEach(key => { - if (obj[key].Value === null && obj[key].vr) { - delete obj[key].Value; - } else if (Array.isArray(obj[key].Value) && obj[key].vr) { - if (obj[key].Value.length === 1 && obj[key].Value[0].BulkDataURI) { - dicomWebUtils.fixBulkDataURI(obj[key].Value[0], options, options.dataSourceConfig); - - obj[key].BulkDataURI = obj[key].Value[0].BulkDataURI; - - // prevent mixed-content blockage - if (window.location.protocol === 'https:' && obj[key].BulkDataURI.startsWith('http:')) { - obj[key].BulkDataURI = obj[key].BulkDataURI.replace('http:', 'https:'); - } - delete obj[key].Value; - } else if (vrNumerics.includes(obj[key].vr)) { - obj[key].Value = obj[key].Value.map(v => +v); - } else { - obj[key].Value = obj[key].Value.map(entry => cleanDenaturalizedDataset(entry, options)); - } - } - }); - return obj; - } -} diff --git a/extensions/dicom-video/src/getSopClassHandlerModule.js b/extensions/dicom-video/src/getSopClassHandlerModule.js index e2db17632..a49512775 100644 --- a/extensions/dicom-video/src/getSopClassHandlerModule.js +++ b/extensions/dicom-video/src/getSopClassHandlerModule.js @@ -1,5 +1,6 @@ import { SOPClassHandlerId } from './id'; import { utils } from '@ohif/core'; +import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core'; const SOP_CLASS_UIDS = { VIDEO_MICROSCOPIC_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.2.1', @@ -50,7 +51,7 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) ); }) .map(instance => { - const { Modality, SOPInstanceUID, SeriesDescription = 'VIDEO' } = instance; + const { Modality, SOPInstanceUID, SeriesDescription = 'VIDEO', imageId } = instance; const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, NumberOfFrames, url } = instance; const videoUrl = dataSource.retrieve.directURL({ @@ -72,7 +73,10 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) SOPClassHandlerId, referencedImages: null, measurements: null, + viewportType: csEnums.ViewportType.VIDEO, + // The videoUrl is deprecated, the preferred URL is renderedUrl videoUrl, + renderedUrl: videoUrl, instances: [instance], thumbnailSrc: dataSource.retrieve.directURL({ instance, @@ -80,12 +84,17 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) defaultType: 'image/jpeg', tag: 'Absent', }), + imageIds: [imageId], isDerivedDisplaySet: true, isLoaded: false, sopClassUids, numImageFrames: NumberOfFrames, instance, }; + csUtils.genericMetadataProvider.add(imageId, { + type: 'imageUrlModule', + metadata: { rendered: videoUrl }, + }); return displaySet; }); }; diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx index 6ab9b4134..182a9e63d 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx @@ -267,7 +267,7 @@ function _mapMeasurementToDisplay(measurement, types, displaySetService) { const displaySets = displaySetService.getDisplaySetsForSeries(referenceSeriesUID); - if (!displaySets[0] || !displaySets[0].images) { + if (!displaySets[0]?.instances) { throw new Error('The tracked measurements panel should only be tracking "stack" displaySets.'); } diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index 909898977..6f7622297 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -7,11 +7,13 @@ import i18n from 'i18next'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. -const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG']; +const NON_IMAGE_MODALITIES = ['ECG', 'SR', 'SEG', 'RTSTRUCT']; const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', + wsiSopClassHandler: + '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler', thumbnailList: '@ohif/extension-default.panelModule.seriesList', measurements: '@ohif/extension-default.panelModule.measurements', }; @@ -150,16 +152,20 @@ function modeFactory() { viewports: [ { namespace: tracked.viewport, - displaySetsToDisplay: [ohif.sopClassHandler], + displaySetsToDisplay: [ + ohif.sopClassHandler, + dicomvideo.sopClassHandler, + ohif.wsiSopClassHandler, + ], }, { namespace: dicomsr.viewport, displaySetsToDisplay: [dicomsr.sopClassHandler], }, - { - namespace: dicomvideo.viewport, - displaySetsToDisplay: [dicomvideo.sopClassHandler], - }, + // { + // namespace: dicomvideo.viewport, + // displaySetsToDisplay: [dicomvideo.sopClassHandler], + // }, { namespace: dicompdf.viewport, displaySetsToDisplay: [dicompdf.sopClassHandler], @@ -188,6 +194,7 @@ function modeFactory() { sopClassHandlers: [ dicomvideo.sopClassHandler, dicomSeg.sopClassHandler, + ohif.wsiSopClassHandler, ohif.sopClassHandler, dicompdf.sopClassHandler, dicomsr.sopClassHandler, diff --git a/modes/basic-test-mode/src/initToolGroups.ts b/modes/basic-test-mode/src/initToolGroups.ts index 7c183d520..b313d8e0b 100644 --- a/modes/basic-test-mode/src/initToolGroups.ts +++ b/modes/basic-test-mode/src/initToolGroups.ts @@ -196,6 +196,8 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: toolNames.Angle }, { toolName: toolNames.SegmentationDisplay }, { toolName: toolNames.WindowLevelRegion }, + { toolName: toolNames.PlanarFreehandROI }, + { toolName: toolNames.SplineROI }, ], disabled: [ { diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts index 4ad67f50d..00fda7976 100644 --- a/modes/longitudinal/src/index.ts +++ b/modes/longitudinal/src/index.ts @@ -7,12 +7,15 @@ import moreTools from './moreTools'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. -const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SEG', 'RTSTRUCT']; +const NON_IMAGE_MODALITIES = ['ECG', 'SEG', 'RTSTRUCT', 'RTPLAN', 'PR']; const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', thumbnailList: '@ohif/extension-default.panelModule.seriesList', + wsiSopClassHandler: + '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler', + measurements: '@ohif/extension-default.panelModule.measurements', }; const tracked = { @@ -96,7 +99,6 @@ function modeFactory({ modeConfiguration }) { // }, // ]); - // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager, this.labelConfig); @@ -194,16 +196,20 @@ function modeFactory({ modeConfiguration }) { viewports: [ { namespace: tracked.viewport, - displaySetsToDisplay: [ohif.sopClassHandler], + displaySetsToDisplay: [ + ohif.sopClassHandler, + dicomvideo.sopClassHandler, + ohif.wsiSopClassHandler, + ], }, { namespace: dicomsr.viewport, displaySetsToDisplay: [dicomsr.sopClassHandler], }, - { - namespace: dicomvideo.viewport, - displaySetsToDisplay: [dicomvideo.sopClassHandler], - }, + // { + // namespace: dicomvideo.viewport, + // displaySetsToDisplay: [dicomvideo.sopClassHandler], + // }, { namespace: dicompdf.viewport, displaySetsToDisplay: [dicompdf.sopClassHandler], @@ -238,6 +244,7 @@ function modeFactory({ modeConfiguration }) { dicomSeg.sopClassHandler, dicomPmap.sopClassHandler, ohif.sopClassHandler, + ohif.wsiSopClassHandler, dicompdf.sopClassHandler, dicomsr.sopClassHandler, dicomRT.sopClassHandler, diff --git a/modes/microscopy/src/index.tsx b/modes/microscopy/src/index.tsx index 50b19e766..9ad808bd2 100644 --- a/modes/microscopy/src/index.tsx +++ b/modes/microscopy/src/index.tsx @@ -94,7 +94,8 @@ function modeFactory({ modeConfiguration }) { { namespace: '@ohif/extension-dicom-microscopy.viewportModule.microscopy-dicom', displaySetsToDisplay: [ - '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler', + // Share the sop class handler with cornerstone version of it + '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler', '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler', ], }, @@ -115,12 +116,8 @@ function modeFactory({ modeConfiguration }) { extensions: extensionDependencies, hangingProtocol: ['default'], - // Order is important in sop class handlers when two handlers both use - // the same sop class under different situations. In that case, the more - // general handler needs to come last. For this case, the dicomvideo must - // come first to remove video transfer syntax before ohif uses images sopClassHandlers: [ - '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler', + '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler', '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler', dicomvideo.sopClassHandler, dicompdf.sopClassHandler, diff --git a/platform/app/.webpack/webpack.pwa.js b/platform/app/.webpack/webpack.pwa.js index 69cc1e260..6a90e73b4 100644 --- a/platform/app/.webpack/webpack.pwa.js +++ b/platform/app/.webpack/webpack.pwa.js @@ -35,6 +35,8 @@ const setHeaders = (res, path) => { } if (path.indexOf('.pdf') !== -1) { res.setHeader('Content-Type', 'application/pdf'); + } else if (path.indexOf('mp4') !== -1) { + res.setHeader('Content-Type', 'video/mp4'); } else if (path.indexOf('frames') !== -1) { res.setHeader('Content-Type', 'multipart/related'); } else { diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js index 944f562a6..bb5c1716e 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js @@ -42,6 +42,10 @@ describe('OHIF Measurement Panel', function () { }); it('checks if image would jump when clicked on a measurement item', function () { + cy.get('[data-cy="study-browser-thumbnail"][data-series="1"]').dblclick(); + cy.wait(250); + cy.scrollToIndex(0); + // Add length measurement cy.addLengthMeasurement().wait(250); cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('yesBtn').click(); diff --git a/platform/app/cypress/support/commands.js b/platform/app/cypress/support/commands.js index f8bd42915..5df25e108 100644 --- a/platform/app/cypress/support/commands.js +++ b/platform/app/cypress/support/commands.js @@ -36,9 +36,9 @@ Cypress.Commands.add('openStudy', PatientName => { cy.openStudyList(); cy.get('#filter-patientNameOrId').type(PatientName); // cy.get('@getStudies').then(() => { - cy.waitQueryList(); + // cy.waitQueryList(); - cy.get('[data-cy="study-list-results"]', { timeout: 5000 }) + cy.get('[data-cy="study-list-results"]', { timeout: 15000 }) .contains(PatientName) .first() .click({ force: true }); @@ -79,8 +79,9 @@ Cypress.Commands.add( ); Cypress.Commands.add('waitQueryList', () => { - cy.get('[data-querying="false"]'); + cy.get('[data-querying="false"]', { timeout: 15000 }); }); + /** * Command to search for a Modality and open the study. * @@ -109,7 +110,7 @@ Cypress.Commands.add('openStudyList', () => { cy.visit('/', { timeout: 15000 }); // For some reason cypress 12.x does not like to stub the network request - // so we just wait here for 1 second + // so we just wait here for querying to be done. // cy.wait('@getStudies'); cy.waitQueryList(); }); diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 197ebee57..3fec05a88 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -59,6 +59,7 @@ window.config = { bulkDataURI: { enabled: true, relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/rendered'), }, omitQuotationForMultipartRequest: true, }, diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index ed7dcdc75..e0e2912fc 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -44,6 +44,7 @@ window.config = { bulkDataURI: { enabled: true, relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/index.mp4'), }, }, }, @@ -115,6 +116,7 @@ window.config = { bulkDataURI: { enabled: true, relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/rendered'), }, }, }, diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js index ce5ec9e3a..6746ff0f7 100644 --- a/platform/app/public/config/netlify.js +++ b/platform/app/public/config/netlify.js @@ -38,6 +38,7 @@ window.config = { bulkDataURI: { enabled: true, relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/rendered'), }, omitQuotationForMultipartRequest: true, }, diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index 807b2072d..56a150611 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -168,33 +168,31 @@ function ViewerViewportGrid(props: withAppTypes) { const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement; const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID); - // Arbitrarily assign the viewport to element 0 - const viewport = updatedViewports?.[0]; - - if (!viewport) { + if (!updatedViewports[0]) { console.warn( 'ViewportGrid::Unable to navigate to viewport containing', referencedDisplaySetInstanceUID ); return; } + // Arbitrarily assign the viewport to element 0 + // TODO - this should perform a search to find the most suitable viewport. + updatedViewports[0] = { ...updatedViewports[0] }; + const [viewport] = updatedViewports; - viewport.viewportOptions ||= {}; - viewport.viewportOptions.orientation = 'acquisition'; + // Copy the viewport options to prevent modifying the internal data + viewport.viewportOptions = { + ...viewport.viewportOptions, + orientation: 'acquisition', + // The preferred way to jump to the measurement view is to set the + // view reference, as this can hold information such as the orientation + // or zoom level required to display an annotation. The metadata attribute + // of the measurement is a viewReference, so use it to show the measurement. + // Longer term this should clear the view reference data + viewReference: measurement.metadata, + viewportType: measurement.metadata.volumeId ? 'volume' : null, + }; - const displaySet = displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID); - // jump straight to the initial image index if we can - if (displaySet.images && measurement.SOPInstanceUID) { - for (let index = 0; index < displaySet.images.length; index++) { - const image = displaySet.images[index]; - if (image.SOPInstanceUID === measurement.SOPInstanceUID) { - viewport.viewportOptions.initialImageOptions = { - index, - }; - break; - } - } - } viewportGridService.setDisplaySetsForViewports(updatedViewports); } ); @@ -338,11 +336,6 @@ function ViewerViewportGrid(props: withAppTypes) { ); } -ViewerViewportGrid.propTypes = { - viewportComponents: PropTypes.array.isRequired, - servicesManager: PropTypes.instanceOf(Object).isRequired, -}; - function _getViewportComponent(displaySets, viewportComponents, uiNotificationService) { if (!displaySets || !displaySets.length) { return EmptyViewport; diff --git a/platform/core/src/classes/MetadataProvider.ts b/platform/core/src/classes/MetadataProvider.ts index a92fb5daa..a2ea77e82 100644 --- a/platform/core/src/classes/MetadataProvider.ts +++ b/platform/core/src/classes/MetadataProvider.ts @@ -389,6 +389,7 @@ class MetadataProvider { case WADO_IMAGE_LOADER_TAGS.CINE_MODULE: metadata = { frameTime: instance.FrameTime, + numberOfFrames: instance.NumberOfFrames ? Number(instance.NumberOfFrames) : 1, }; break; diff --git a/platform/core/src/services/DisplaySetService/IDisplaySet.ts b/platform/core/src/services/DisplaySetService/DisplaySet.ts similarity index 57% rename from platform/core/src/services/DisplaySetService/IDisplaySet.ts rename to platform/core/src/services/DisplaySetService/DisplaySet.ts index f193e3222..88e5111cd 100644 --- a/platform/core/src/services/DisplaySetService/IDisplaySet.ts +++ b/platform/core/src/services/DisplaySetService/DisplaySet.ts @@ -1,9 +1,12 @@ -interface IDisplaySet { +type DisplaySet { displaySetInstanceUID: string; StudyInstanceUID: string; SeriesInstanceUID?: string; SeriesNumber?: string; unsupported?: boolean; + viewportType?: string; + instances: any[]; + instance?: any; } -export default IDisplaySet; +export default DisplaySet; diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.ts b/platform/core/src/services/DisplaySetService/DisplaySetService.ts index ac9922484..7d72738d4 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.ts +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.ts @@ -131,7 +131,7 @@ export default class DisplaySetService extends PubSubService { : [...this.getDisplaySetCache().values()]; const displaySet = displaySets.find(ds => { - return ds.images && ds.images.some(i => i.SOPInstanceUID === sopInstanceUID); + return ds.instances?.some(i => i.SOPInstanceUID === sopInstanceUID); }); return displaySet; diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index f40b27324..f6be07e27 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -4,7 +4,7 @@ import { PubSubService } from '../_shared/pubSubServiceInterface'; import sortBy from '../../utils/sortBy'; import ProtocolEngine from './ProtocolEngine'; import { StudyMetadata } from '../../types/StudyMetadata'; -import IDisplaySet from '../DisplaySetService/IDisplaySet'; +import DisplaySet from '../DisplaySetService/DisplaySet'; import { CommandsManager } from '../../classes'; import * as HangingProtocol from '../../types/HangingProtocol'; import { isDisplaySetFromUrl, sopInstanceLocation } from './custom-attribute/isDisplaySetFromUrl'; @@ -62,7 +62,7 @@ export default class HangingProtocolService extends PubSubService { _servicesManager: AppTypes.ServicesManager; protocolEngine: ProtocolEngine; customViewportSettings = []; - displaySets: IDisplaySet[] = []; + displaySets: DisplaySet[] = []; activeStudy: StudyMetadata; debugLogging: false; @@ -1126,10 +1126,7 @@ export default class HangingProtocolService extends PubSubService { * to match display sets to viewports. * @returns a display set sort function */ - public getDisplaySetSortFunction(): ( - displaySetA: IDisplaySet, - displaySetB: IDisplaySet - ) => number { + public getDisplaySetSortFunction(): (displaySetA: DisplaySet, displaySetB: DisplaySet) => number { return (displaySetA, displaySetB) => { const seriesA = this._getSeriesSortInfoForDisplaySetSort(displaySetA); const seriesB = this._getSeriesSortInfoForDisplaySetSort(displaySetB); diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index e01e64f71..3e1608790 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -7,6 +7,7 @@ import type { BaseDataSourceConfigurationAPI, BaseDataSourceConfigurationAPIItem, } from './DataSourceConfigurationAPI'; +import type DisplaySet from '../services/DisplaySetService/DisplaySet'; export type * from '../services/ViewportGridService'; export type * from '../services/CustomizationService/types'; @@ -31,4 +32,5 @@ export { DataSourceDefinition, BaseDataSourceConfigurationAPI, BaseDataSourceConfigurationAPIItem, + DisplaySet, }; diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index f72714b47..22ae507ae 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -22,6 +22,7 @@ describe('Top level exports', () => { 'imageIdToURI', 'roundNumber', 'b64toBlob', + 'sopClassDictionary', 'createStudyBrowserTabs', 'formatDate', 'formatTime', diff --git a/platform/core/src/utils/index.ts b/platform/core/src/utils/index.ts index 63518961c..e5725f748 100644 --- a/platform/core/src/utils/index.ts +++ b/platform/core/src/utils/index.ts @@ -39,6 +39,7 @@ import { import { subscribeToNextViewportGridChange } from './subscribeToNextViewportGridChange'; import { splitComma, getSplitParam } from './splitComma'; import { createStudyBrowserTabs } from './createStudyBrowserTabs'; +import { sopClassDictionary } from './sopClassDictionary'; // Commented out unused functionality. // Need to implement new mechanism for derived displaySets using the displaySetManager. @@ -69,6 +70,7 @@ const utils = { Queue, isDicomUid, isEqualWithin, + sopClassDictionary, addAccessors, resolveObjectPath, hierarchicalListUtils, diff --git a/platform/core/src/utils/sortStudy.ts b/platform/core/src/utils/sortStudy.ts index 8928a2f34..24fe90e75 100644 --- a/platform/core/src/utils/sortStudy.ts +++ b/platform/core/src/utils/sortStudy.ts @@ -26,6 +26,8 @@ function seriesInfoSortingCriteria(firstSeries, secondSeries) { const bLowPriority = isLowPriorityModality(secondSeries.Modality ?? secondSeries.modality); if (aLowPriority) { + // Use the reverse sort order for low priority modalities so that the + // most recent one comes up first as usually that is the one of interest. return bLowPriority ? defaultSeriesSort(secondSeries, firstSeries) : 1; } else if (bLowPriority) { return -1; diff --git a/platform/docs/docs/migration-guide/from-3p8-to-3p9-beta.md b/platform/docs/docs/migration-guide/from-3p8-to-3p9-beta.md index af8fca41f..d430f06a1 100644 --- a/platform/docs/docs/migration-guide/from-3p8-to-3p9-beta.md +++ b/platform/docs/docs/migration-guide/from-3p8-to-3p9-beta.md @@ -267,3 +267,43 @@ We have removed the Polyfill.io script from the Viewer. If you require polyfills ## Dynamic Modules TBD + +## Renaming some interfaces +A few interfaces are being renamed to simple types to reflect the fact that +they don't contain methods and are thus more properly simple types. + +* IDisplaySet renamed to DisplaySet + * Adding some field declarations to agree with actual usage + + +## Use of ViewReference for navigation +When navigating to measurements and storing/remembering navigation positions, +the `viewport.getViewReference` is used to get a position, and `viewport.isReferenceViewable` +used to check if a reference can be applied, and finally `viewport.setViewReference` to +navigate to a view. Note that this changes the behaviour of navigation between +MPR and Stack viewports, and also enables navigation of video and microscopy +viewports in CS3D. This can cause some unexpected behaviour depending on how the +frame of reference values are configured to allow for navigation. + +The isReferenceViewable is used to determine when a view or measurement can be +shown on a given view. For stack versus volume viewports, this can cause unexpected +behaviour to be seen depending on how the view reference was fetched. + +### `getViewReference` with `forFrameOfReference` +When a view reference is fetched with the for frame of reference flag set to true, +a reference will be returned which can be displayed on any viewport containing +the same frame of reference and encompassing the given FOR and able to display the required +orientation. Without this flag, a view reference is returned which will be +displayed on a stack with the given image id, or a volume containing said image id +or the specified volume. + +### `isReferenceViewable` with navigation and/or orientation +The is reference viewable will return false unless the given reference is directly +viewable in the viewport as is. However, it can be passed various flags to determine +whether the reference could be displayed if the viewport was modified in various ways, +for example, by changing the position or orientation of the viewport. This allows +checking for degrees of closeness so that the correct viewport can be chosen. + +Note that this may result in displaying a measurement from one viewport on a completely +different viewport, for example, showing a Probe tool from the stack viewport on +an MPR view. diff --git a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx index 91fd6b0e4..842f9dac7 100644 --- a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx +++ b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; +import { utils } from '@ohif/core'; import StudyItem from '../StudyItem'; import LegacyButtonGroup from '../LegacyButtonGroup'; @@ -9,6 +10,8 @@ import ThumbnailList from '../ThumbnailList'; import { StringNumber } from '../../types'; import StudyBrowserSort from '../StudyBrowserSort'; +const { sortStudySeries } = utils; + const getTrackedSeries = displaySets => { let trackedSeries = 0; displaySets.forEach(displaySet => { @@ -20,7 +23,7 @@ const getTrackedSeries = displaySets => { return trackedSeries; }; -const noop = () => {}; +const noop = () => { }; const StudyBrowser = ({ tabs, @@ -36,11 +39,14 @@ const StudyBrowser = ({ }: withAppTypes) => { const { t } = useTranslation('StudyBrowser'); const { customizationService } = servicesManager?.services || {}; - + const { experimentalStudyBrowserSort } = window.config; const getTabContent = () => { const tabData = tabs.find(tab => tab.name === activeTabName); return tabData.studies.map( ({ studyInstanceUid, date, description, numInstances, modalities, displaySets }) => { + if (!experimentalStudyBrowserSort) { + sortStudySeries(displaySets); + } const isExpanded = expandedStudyInstanceUIDs.includes(studyInstanceUid); return ( @@ -112,9 +118,7 @@ const StudyBrowser = ({ ); })} - {window.config.experimentalStudyBrowserSort && ( - - )} + {experimentalStudyBrowserSort && }
{getTabContent()} diff --git a/platform/ui/src/components/Thumbnail/Thumbnail.tsx b/platform/ui/src/components/Thumbnail/Thumbnail.tsx index b904a91e6..b392bc3ea 100644 --- a/platform/ui/src/components/Thumbnail/Thumbnail.tsx +++ b/platform/ui/src/components/Thumbnail/Thumbnail.tsx @@ -101,14 +101,14 @@ const Thumbnail = ({ {loadingProgress && loadingProgress === 1 && ( - )} -
- + className="w-3" + /> + )} + +
{description}
diff --git a/yarn.lock b/yarn.lock index 43098d8e1..a397f1c38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1505,7 +1505,7 @@ lodash.clonedeep "^4.5.0" ndarray "^1.0.19" -"@cornerstonejs/adapters@^1.81.6", "@cornerstonejs/adapters@^1.82.0": +"@cornerstonejs/adapters@^1.81.6": version "1.82.0" resolved "https://registry.yarnpkg.com/@cornerstonejs/adapters/-/adapters-1.82.0.tgz#4c7ae6341ad7bcf5cf0213e5252fbac009da23b6" integrity sha512-hcLmba3b7jC1wEorCzaS4OSFBuHz5V5I3IJW1PHbwIAWNHiuKMcC4j6ndMeQhVXHtmy56HUQP+yKAPez59k8Yw== @@ -1518,6 +1518,19 @@ lodash.clonedeep "^4.5.0" ndarray "^1.0.19" +"@cornerstonejs/adapters@^1.82.4": + version "1.83.1" + resolved "https://registry.yarnpkg.com/@cornerstonejs/adapters/-/adapters-1.83.1.tgz#69c6bd33dd2ef3f392d4adafac89c0f50776ebf9" + integrity sha512-AQlgSMpxNfWEKHthZp050aEI1BEhgzSbc2MmX2NAvHdQglg7YYDusFxZMKXUBB3jr1X9eTP+4VcYwzp4hPyM0Q== + dependencies: + "@babel/runtime-corejs2" "^7.17.8" + "@cornerstonejs/tools" "^1.83.1" + buffer "^6.0.3" + dcmjs "^0.29.8" + gl-matrix "^3.4.3" + lodash.clonedeep "^4.5.0" + ndarray "^1.0.19" + "@cornerstonejs/calculate-suv@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@cornerstonejs/calculate-suv/-/calculate-suv-1.1.0.tgz#35c8049d3eea03611225a8398d398cd81bf0031a" @@ -1576,6 +1589,17 @@ gl-matrix "^3.4.3" lodash.clonedeep "4.5.0" +"@cornerstonejs/core@^1.82.4", "@cornerstonejs/core@^1.83.1": + version "1.83.1" + resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-1.83.1.tgz#626df83e896a5e8328012c681343f1158840dfc1" + integrity sha512-VuLjcBZ+qPeUh+8RDrma7SvDGMEA/kXb1nsf+1yQTJQsqJmNiSyV+Pb7q/bn4X270KtGrEiyCJSNkb7b2zvfhQ== + dependencies: + "@kitware/vtk.js" "30.4.1" + comlink "^4.4.1" + detect-gpu "^5.0.22" + gl-matrix "^3.4.3" + lodash.clonedeep "4.5.0" + "@cornerstonejs/dicom-image-loader@^1.77.6": version "1.81.3" resolved "https://registry.yarnpkg.com/@cornerstonejs/dicom-image-loader/-/dicom-image-loader-1.81.3.tgz#8d9165b351954999bbc566d811a9eedd2363d55c" @@ -1590,12 +1614,12 @@ pako "^2.0.4" uuid "^9.0.0" -"@cornerstonejs/streaming-image-volume-loader@^1.82.0": - version "1.82.0" - resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-1.82.0.tgz#a513ca57236f13af0045f2118352e1919120db1c" - integrity sha512-BW/ncOf0ouP82o8jQkj2hhWdjqlA23mNls1gYrpsWBUnyYQOBeto2XZ2LgIOfIgvCrtbhXlE03/nkwxdvTgp3w== +"@cornerstonejs/streaming-image-volume-loader@^1.82.4": + version "1.83.1" + resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-1.83.1.tgz#3939d754777490b48a319f4026ec5c230e646161" + integrity sha512-LcrrS1v5OOF2CkhcITAMKcoV/W3jQcCMzOl4oQHGtw6fJSSWpbQZjsIU94YnSUyhr/I0St3uTedNk5sNNzY66A== dependencies: - "@cornerstonejs/core" "^1.82.0" + "@cornerstonejs/core" "^1.83.1" comlink "^4.4.1" "@cornerstonejs/tools@^1.82.0": @@ -1610,6 +1634,18 @@ lodash.clonedeep "4.5.0" lodash.get "^4.4.2" +"@cornerstonejs/tools@^1.82.4", "@cornerstonejs/tools@^1.83.1": + version "1.83.1" + resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-1.83.1.tgz#56761fe339bd575b6636a4b4e12ac702ceb2a246" + integrity sha512-KbMIFgXhuMp2zl5ORLkbJY6SwioyGlDw91cU3Hu02WR2KNTbm2b8oIzTY09F4/UzI0n19064mmP5TsPMRPsjMw== + dependencies: + "@cornerstonejs/core" "^1.83.1" + "@icr/polyseg-wasm" "0.4.0" + "@types/offscreencanvas" "2019.7.3" + comlink "^4.4.1" + lodash.clonedeep "4.5.0" + lodash.get "^4.4.2" + "@cornerstonejs/tools@^1.83.0": version "1.83.0" resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-1.83.0.tgz#6148e72972d826abfa640924f5c34f0d4acd75a3"