Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ class App extends React.Component<AppProps, AppState> {
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,
Expand Down
108 changes: 102 additions & 6 deletions src/DicomWebManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,72 @@ const retrieveWithFallback = async <T>(
: 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<string, Store>()

/**
* 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 <T>(
stores: Store[],
call: (store: Store) => Promise<T>,
cacheKey?: string,
): Promise<T> => {
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[] = []

Expand Down Expand Up @@ -641,13 +707,18 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient {
retrieveSeriesMetadata = async (
options: dwc.api.RetrieveSeriesMetadataOptions,
): Promise<dwc.api.Metadata[]> => {
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(
Expand All @@ -660,26 +731,36 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient {
retrieveInstanceMetadata = async (
options: dwc.api.RetrieveInstanceMetadataOptions,
): Promise<dwc.api.Metadata[]> => {
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<dwc.api.Dataset> => {
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)
Expand All @@ -690,40 +771,55 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient {
retrieveInstanceFrames = async (
options: dwc.api.RetrieveInstanceFramesOptions,
): Promise<dwc.api.Pixeldata[]> => {
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<dwc.api.Pixeldata> => {
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<dwc.api.Pixeldata> => {
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.retrieveInstanceFramesRendered(options),
),
cacheKey,
)
}

Expand Down
22 changes: 20 additions & 2 deletions src/components/SegmentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface SegmentItemProps {
color?: number[]
}
}) => void
onClick: (segmentUID: string) => void
}

interface SegmentItemState {
Expand Down Expand Up @@ -111,6 +112,10 @@ class SegmentItem extends React.Component<SegmentItemProps, SegmentItemState> {
}
}

handleClick = (): void => {
this.props.onClick(this.props.segment.uid)
}

render(): React.ReactNode {
const attributes: Array<{ name: string; value: string }> = [
{
Expand Down Expand Up @@ -175,6 +180,7 @@ class SegmentItem extends React.Component<SegmentItemProps, SegmentItemState> {
metadata,
onVisibilityChange,
onStyleChange,
onClick: _onClick,
...otherProps
} = this.props
return (
Expand Down Expand Up @@ -223,14 +229,26 @@ class SegmentItem extends React.Component<SegmentItemProps, SegmentItemState> {
)}
</Space>
</div>
<div style={{ flex: 1 }}>
<button
type="button"
style={{
flex: 1,
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
textAlign: 'left',
}}
onClick={this.handleClick}
title="Click to zoom to segment"
>
<Description
header={this.props.segment.label}
attributes={attributes}
selectable
hasLongValues
/>
</div>
</button>
</Space>
</Menu.Item>
)
Expand Down
2 changes: 2 additions & 0 deletions src/components/SegmentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface SegmentListProps {
color?: number[]
}
}) => void
onSegmentClick: (segmentUID: string) => void
}

/**
Expand Down Expand Up @@ -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}
/>
)
})
Expand Down
21 changes: 20 additions & 1 deletion src/components/SlideViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ class SlideViewer extends React.Component<SlideViewerProps, SlideViewerState> {
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 =
Expand Down Expand Up @@ -818,7 +819,10 @@ class SlideViewer extends React.Component<SlideViewerProps, SlideViewerState> {
}
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 = (
Expand All @@ -838,9 +842,19 @@ class SlideViewer extends React.Component<SlideViewerProps, SlideViewerState> {
* 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)
Expand Down Expand Up @@ -3102,6 +3116,10 @@ class SlideViewer extends React.Component<SlideViewerProps, SlideViewerState> {
}
}

handleSegmentClick = (segmentUID: string): void => {
this.volumeViewer.zoomToSegment(segmentUID)
}

/**
* Handle change of segment style.
*/
Expand Down Expand Up @@ -4288,6 +4306,7 @@ class SlideViewer extends React.Component<SlideViewerProps, SlideViewerState> {
visibleSegmentUIDs={this.state.visibleSegmentUIDs}
onSegmentVisibilityChange={this.handleSegmentVisibilityChange}
onSegmentStyleChange={this.handleSegmentStyleChange}
onSegmentClick={this.handleSegmentClick}
/>
)}
</Menu.SubMenu>
Expand Down
8 changes: 8 additions & 0 deletions src/data/uids.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
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

Check warning on line 9 in src/data/uids.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=ImagingDataCommons_slim&issues=AaB8KqljXpBoSwV2FkCd&open=AaB8KqljXpBoSwV2FkCd&pullRequest=430
* (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',
Expand Down
4 changes: 3 additions & 1 deletion types/dicom-microscopy-viewer/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down