From 09362031eea25308ac4caa47169cf93927ef6b9a Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Fri, 28 Aug 2026 13:59:47 -0300 Subject: [PATCH 1/5] feat: add click-to-zoom for segments Add onClick handler to SegmentItem to zoom to segment's bounding box when the segment label is clicked. This is consistent with the bulk annotation zoom behavior and provides a better UX than auto-zooming on visibility toggle. Changes: - Add onClick prop to SegmentItem and SegmentList components - Add handleSegmentClick method in SlideViewer - Update dicom-microscopy-viewer types with zoomToSegment method --- src/components/SegmentItem.tsx | 22 ++++++++++++++++++++-- src/components/SegmentList.tsx | 2 ++ src/components/SlideViewer.tsx | 5 +++++ types/dicom-microscopy-viewer/index.d.ts | 4 +++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/SegmentItem.tsx b/src/components/SegmentItem.tsx index 81331f86..31d0c26a 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, ...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..1077453f 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -3102,6 +3102,10 @@ class SlideViewer extends React.Component { } } + handleSegmentClick = (segmentUID: string): void => { + this.volumeViewer.zoomToSegment(segmentUID) + } + /** * Handle change of segment style. */ @@ -4288,6 +4292,7 @@ class SlideViewer extends React.Component { visibleSegmentUIDs={this.state.visibleSegmentUIDs} onSegmentVisibilityChange={this.handleSegmentVisibilityChange} onSegmentStyleChange={this.handleSegmentStyleChange} + onSegmentClick={this.handleSegmentClick} /> )} 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: { From e873071d88336a570d1473a35df6b51cfebdac31 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Sat, 29 Aug 2026 10:28:02 -0300 Subject: [PATCH 2/5] feat: add support for DICOM Labelmap Segmentation Storage Add support for Labelmap Segmentation (SOP Class UID 1.2.840.10008.5.1.4.1.1.66.7) as defined in DICOM Supplement 243. Key changes: - Add LABELMAP_SEGMENTATION to StorageClasses enum - Include LABELMAP_SEGMENTATION in GCP secondary store's storage classes - Update SlideViewer.loadDerivedDataset to handle both Segmentation and LabelmapSegmentation SOP Classes - Add series store caching in DicomWebManager to reduce 404 noise when using multi-store fallback (e.g., GCP query parameter for secondary stores) The series store caching remembers which store successfully served each series and tries that store first on subsequent requests, avoiding unnecessary 404 errors when loading frames from the correct store. Closes #271 --- src/App.tsx | 1 + src/DicomWebManager.ts | 108 +++++++++++++++++++++++++++++++-- src/components/SlideViewer.tsx | 6 +- src/data/uids.tsx | 1 + 4 files changed, 109 insertions(+), 7 deletions(-) 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/SlideViewer.tsx b/src/components/SlideViewer.tsx index 1077453f..b139e6ab 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 = ( diff --git a/src/data/uids.tsx b/src/data/uids.tsx index 2ed451a6..7c6b0a6b 100644 --- a/src/data/uids.tsx +++ b/src/data/uids.tsx @@ -3,6 +3,7 @@ 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', + 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', From 52b785a3e85df6c382a250337c66cba723742d51 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Sun, 30 Aug 2026 08:12:49 -0300 Subject: [PATCH 3/5] fix: address DeepSource issue Prefix unused onClick destructure with underscore in SegmentItem.tsx (JS-0356) --- src/components/SegmentItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SegmentItem.tsx b/src/components/SegmentItem.tsx index 31d0c26a..c56fdecd 100644 --- a/src/components/SegmentItem.tsx +++ b/src/components/SegmentItem.tsx @@ -180,7 +180,7 @@ class SegmentItem extends React.Component { metadata, onVisibilityChange, onStyleChange, - onClick, + onClick: _onClick, ...otherProps } = this.props return ( From b011a348062efc9e555a3f65d553747002c642f6 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Sun, 30 Aug 2026 21:47:58 -0300 Subject: [PATCH 4/5] docs: add Labelmap Segmentation to supported formats --- README.md | 1 + 1 file changed, 1 insertion(+) 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. From 71710171011eeeee779e8357133be4a470763751 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 7 Sep 2026 10:57:52 -0300 Subject: [PATCH 5/5] fix: skip auto-show for background segments in LABELMAP - Background segments (identified by PixelPaddingValue or DCM 125040 property type) remain in the panel but are not auto-shown - Add TODO for populating PixelPaddingValue when creating LABELMAP Requires: ImagingDataCommons/dicom-microscopy-viewer#281 --- src/components/SlideViewer.tsx | 10 ++++++++++ src/data/uids.tsx | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index b139e6ab..70bf1a99 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -842,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) diff --git a/src/data/uids.tsx b/src/data/uids.tsx index 7c6b0a6b..c11c4fae 100644 --- a/src/data/uids.tsx +++ b/src/data/uids.tsx @@ -3,6 +3,13 @@ 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',