diff --git a/README.md b/README.md index b11f5634..8de794dc 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ _Slim_ also supports interactive visualization of image annotations and analysis **Raster graphics:** - [DICOM Segmentation](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.51.html) instances that contain binary or fractional segmentation masks +- [DICOM Labelmap Segmentation](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.89.html) instances (Supplement 243) that contain multi-class label maps where each pixel value corresponds to a distinct segment - [DICOM Parametric Map](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.75.html) instances that contain saliency maps, attention maps, class activation maps, and similar derived images Fractional segmentations and parametric maps show an in-viewport color legend when at least one overlay is visible. The legend is collapsible and its per-item visibility toggles stay in sync with the switches in the right-hand panel. diff --git a/src/App.tsx b/src/App.tsx index 7131bdc1..b361ace4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -359,6 +359,7 @@ class App extends React.Component { StorageClasses.COMPREHENSIVE_SR, StorageClasses.COMPREHENSIVE_3D_SR, StorageClasses.SEGMENTATION, + StorageClasses.LABELMAP_SEGMENTATION, StorageClasses.MICROSCOPY_BULK_SIMPLE_ANNOTATION, StorageClasses.PARAMETRIC_MAP, StorageClasses.ADVANCED_BLENDING_PRESENTATION_STATE, diff --git a/src/DicomWebManager.ts b/src/DicomWebManager.ts index c4168b99..212a4c5c 100644 --- a/src/DicomWebManager.ts +++ b/src/DicomWebManager.ts @@ -206,6 +206,72 @@ const retrieveWithFallback = async ( : new Error(String(lastError as unknown)) } +/** + * Cache mapping series UIDs to the store that successfully served them. + * Keyed by "studyInstanceUID/seriesInstanceUID". + */ +const seriesStoreCache = new Map() + +/** + * Build the cache key for a series. + */ +const buildSeriesCacheKey = ( + studyInstanceUID: string, + seriesInstanceUID: string, +): string => `${studyInstanceUID}/${seriesInstanceUID}` + +/** + * Try stores in an optimized order: if a cached store exists for the given + * series, try it first to avoid 404s on other stores. Falls back to the + * standard order if the cached store fails or no cache exists. + * + * On success, caches the store for subsequent requests to the same series. + */ +const retrieveWithCachedFallback = async ( + stores: Store[], + call: (store: Store) => Promise, + cacheKey?: string, +): Promise => { + const readable = stores.filter((s) => s.read) + if (readable.length === 0) { + throw new CustomError( + errorTypes.COMMUNICATION, + 'No readable DICOMweb store is configured.', + ) + } + + /** Reorder stores to try the cached one first if available. */ + let orderedStores = readable + const cachedStore = cacheKey != null ? seriesStoreCache.get(cacheKey) : null + if (cachedStore != null && readable.includes(cachedStore)) { + orderedStores = [cachedStore, ...readable.filter((s) => s !== cachedStore)] + } + + let lastError: unknown + for (const store of orderedStores) { + try { + const result = await call(store) + /** Cache the successful store for future requests to this series. */ + if (cacheKey != null) { + seriesStoreCache.set(cacheKey, store) + } + return result + } catch (error: unknown) { + lastError = error + if (process.env.NODE_ENV === 'development') { + console.debug( + `retrieve against store "${store.id}" failed; ` + + 'falling back to the next configured store', + error, + ) + } + } + } + throw lastError instanceof Error + ? lastError + : new Error(String(lastError as unknown)) +} + export default class DicomWebManager implements dwc.api.DICOMwebClient { private readonly stores: Store[] = [] @@ -641,13 +707,18 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { retrieveSeriesMetadata = async ( options: dwc.api.RetrieveSeriesMetadataOptions, ): Promise => { - const seriesSummaryMetadata = await retrieveWithFallback( + const cacheKey = buildSeriesCacheKey( + options.studyInstanceUID, + options.seriesInstanceUID, + ) + const seriesSummaryMetadata = await retrieveWithCachedFallback( this.stores, async (store) => await this.callStore( store, async (client) => await client.retrieveSeriesMetadata(options), ), + cacheKey, ) const naturalized = seriesSummaryMetadata.map(naturalizeDataset) DicomMetadataStore.addSeriesMetadata( @@ -660,26 +731,36 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { retrieveInstanceMetadata = async ( options: dwc.api.RetrieveInstanceMetadataOptions, ): Promise => { - return await retrieveWithFallback( + const cacheKey = buildSeriesCacheKey( + options.studyInstanceUID, + options.seriesInstanceUID, + ) + return await retrieveWithCachedFallback( this.stores, async (store) => await this.callStore( store, async (client) => await client.retrieveInstanceMetadata(options), ), + cacheKey, ) } retrieveInstance = async ( options: dwc.api.RetrieveInstanceOptions, ): Promise => { - const instance = await retrieveWithFallback( + const cacheKey = buildSeriesCacheKey( + options.studyInstanceUID, + options.seriesInstanceUID, + ) + const instance = await retrieveWithCachedFallback( this.stores, async (store) => await this.callStore( store, async (client) => await client.retrieveInstance(options), ), + cacheKey, ) const data = dcmjs.data.DicomMessage.readFile(instance) const { dataset } = dmv.metadata.formatMetadata(data.dict) @@ -690,33 +771,47 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { retrieveInstanceFrames = async ( options: dwc.api.RetrieveInstanceFramesOptions, ): Promise => { - return await retrieveWithFallback( + const cacheKey = buildSeriesCacheKey( + options.studyInstanceUID, + options.seriesInstanceUID, + ) + return await retrieveWithCachedFallback( this.stores, async (store) => await this.callStore( store, async (client) => await client.retrieveInstanceFrames(options), ), + cacheKey, ) } retrieveInstanceRendered = async ( options: dwc.api.RetrieveInstanceRenderedOptions, ): Promise => { - return await retrieveWithFallback( + const cacheKey = buildSeriesCacheKey( + options.studyInstanceUID, + options.seriesInstanceUID, + ) + return await retrieveWithCachedFallback( this.stores, async (store) => await this.callStore( store, async (client) => await client.retrieveInstanceRendered(options), ), + cacheKey, ) } retrieveInstanceFramesRendered = async ( options: dwc.api.RetrieveInstanceFramesRenderedOptions, ): Promise => { - return await retrieveWithFallback( + const cacheKey = buildSeriesCacheKey( + options.studyInstanceUID, + options.seriesInstanceUID, + ) + return await retrieveWithCachedFallback( this.stores, async (store) => await this.callStore( @@ -724,6 +819,7 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { async (client) => await client.retrieveInstanceFramesRendered(options), ), + cacheKey, ) } diff --git a/src/components/SegmentItem.tsx b/src/components/SegmentItem.tsx index 81331f86..c56fdecd 100644 --- a/src/components/SegmentItem.tsx +++ b/src/components/SegmentItem.tsx @@ -34,6 +34,7 @@ interface SegmentItemProps { color?: number[] } }) => void + onClick: (segmentUID: string) => void } interface SegmentItemState { @@ -111,6 +112,10 @@ class SegmentItem extends React.Component { } } + handleClick = (): void => { + this.props.onClick(this.props.segment.uid) + } + render(): React.ReactNode { const attributes: Array<{ name: string; value: string }> = [ { @@ -175,6 +180,7 @@ class SegmentItem extends React.Component { metadata, onVisibilityChange, onStyleChange, + onClick: _onClick, ...otherProps } = this.props return ( @@ -223,14 +229,26 @@ class SegmentItem extends React.Component { )} -
+
+ ) diff --git a/src/components/SegmentList.tsx b/src/components/SegmentList.tsx index 212723f9..a1e55820 100644 --- a/src/components/SegmentList.tsx +++ b/src/components/SegmentList.tsx @@ -35,6 +35,7 @@ interface SegmentListProps { color?: number[] } }) => void + onSegmentClick: (segmentUID: string) => void } /** @@ -75,6 +76,7 @@ class SegmentList extends React.Component< defaultStyle={this.props.defaultSegmentStyles[uid]} onVisibilityChange={this.props.onSegmentVisibilityChange} onStyleChange={this.props.onSegmentStyleChange} + onClick={this.props.onSegmentClick} /> ) }) diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index a95b4b87..70bf1a99 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -734,6 +734,7 @@ class SlideViewer extends React.Component { const MicroscopyBulkSimpleAnnotation = StorageClasses.MICROSCOPY_BULK_SIMPLE_ANNOTATION const Segmentation = StorageClasses.SEGMENTATION + const LabelmapSegmentation = StorageClasses.LABELMAP_SEGMENTATION const ParametricMap = StorageClasses.PARAMETRIC_MAP const OpticalPath = StorageClasses.OPTICAL_PATH const AdvancedBlendingPresentationState = @@ -818,7 +819,10 @@ class SlideViewer extends React.Component { } logger.debug('Loading Microscopy Bulk Simple Annotation') } else if ( - (derivedDataset as { SOPClassUID: string }).SOPClassUID === Segmentation + (derivedDataset as { SOPClassUID: string }).SOPClassUID === + Segmentation || + (derivedDataset as { SOPClassUID: string }).SOPClassUID === + LabelmapSegmentation ) { const allSegments = this.volumeViewer.getAllSegments() const derivedSeriesInstanceUID = ( @@ -838,9 +842,19 @@ class SlideViewer extends React.Component { * showSegment on any single segment does not abort the forEach and * leave subsequent segments hidden. We batch the state update at * the end with all successfully-shown UIDs. + * + * Skip background segments - they are identified by PixelPaddingValue + * or Segmented Property Type (DCM, 125040, "Background"). Background + * segments remain in the panel but are not auto-shown. */ const shownSegmentUIDs: string[] = [] matchingSegments.forEach((segment) => { + if (segment.isBackground === true) { + logger.debug( + `skipping auto-show for background segment "${segment.uid}"`, + ) + return + } try { this.volumeViewer.showSegment(segment.uid) shownSegmentUIDs.push(segment.uid) @@ -3102,6 +3116,10 @@ class SlideViewer extends React.Component { } } + handleSegmentClick = (segmentUID: string): void => { + this.volumeViewer.zoomToSegment(segmentUID) + } + /** * Handle change of segment style. */ @@ -4288,6 +4306,7 @@ class SlideViewer extends React.Component { visibleSegmentUIDs={this.state.visibleSegmentUIDs} onSegmentVisibilityChange={this.handleSegmentVisibilityChange} onSegmentStyleChange={this.handleSegmentStyleChange} + onSegmentClick={this.handleSegmentClick} /> )} diff --git a/src/data/uids.tsx b/src/data/uids.tsx index 2ed451a6..c11c4fae 100644 --- a/src/data/uids.tsx +++ b/src/data/uids.tsx @@ -3,6 +3,14 @@ export enum StorageClasses { COMPREHENSIVE_SR = '1.2.840.10008.5.1.4.1.1.88.33', COMPREHENSIVE_3D_SR = '1.2.840.10008.5.1.4.1.1.88.34', SEGMENTATION = '1.2.840.10008.5.1.4.1.1.66.4', + /** + * DICOM Labelmap Segmentation Storage (Supplement 243). + * + * TODO: When implementing LABELMAP creation in Slim, ensure PixelPaddingValue + * (0028,0120) is populated to identify the background segment. This allows + * viewers to distinguish background from foreground segments. + */ + LABELMAP_SEGMENTATION = '1.2.840.10008.5.1.4.1.1.66.7', MICROSCOPY_BULK_SIMPLE_ANNOTATION = '1.2.840.10008.5.1.4.1.1.91.1', PARAMETRIC_MAP = '1.2.840.10008.5.1.4.1.1.30', ADVANCED_BLENDING_PRESENTATION_STATE = '1.2.840.10008.5.1.4.1.1.11.8', diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 540d47c9..be81f8ec 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -156,9 +156,11 @@ declare module 'dicom-microscopy-viewer' { segmentUID: string, styleOptions?: { opacity?: number - } + }, + shouldZoomIn?: boolean ): void hideSegment (segmentUID: string): void + zoomToSegment (segmentUID: string): void setSegmentStyle ( segmentUID: string, styleOptions: {