diff --git a/commit.txt b/commit.txt index 12eb8b3051c..2836f3b77a6 100644 --- a/commit.txt +++ b/commit.txt @@ -1 +1 @@ -f5f086cd953bfcb783276fbb8d687e05fa118725 \ No newline at end of file +f5f086cd953bfcb783276fbb8d687e05fa118725 diff --git a/extensions/idc/README.md b/extensions/idc/README.md index fec18391f08..9a1ec902b3f 100644 --- a/extensions/idc/README.md +++ b/extensions/idc/README.md @@ -13,3 +13,32 @@ MIT ```bash yarn add @ohif/extension-idc ``` + +## Features + +### Instance level SR qualitative annotations (TID 1500 / TID 1501) + +Implements [OHIF/Viewers#3358](https://github.com/OHIF/Viewers/issues/3358). + +When a DICOM SR (TID 1500 Imaging Measurement Report) is present in a study and +contains a Measurement Group (TID 1501) with one or more `IMAGE` content items +and one or more `CODE` content items — but no geometric coordinates (`SCOORD` / +`SCOORD3D`) — the coded values are rendered as `concept: value` labels in the +bottom-right of the viewport that shows the referenced image instance (e.g. +`Target Region: Neck`). Hovering a label shows the code value / +coding scheme designator and any modifiers (subordinate codes). + +The feature is enabled automatically by the extension's `onModeEnter`. It can be +configured (or disabled) via the app config `instanceAnnotations` key: + +```js +window.config = { + // ... + instanceAnnotations: { + enabled: true, // master switch + maxLabels: 10, // collapse extra labels into a "+N more" indicator + showColor: true, // render a colored dot before each label + colors: ['#5acce6', '#fcfa6b', '#7ee37e', '#f7a35c', '#e67ee6', '#ff7f7f'], + }, +}; +``` diff --git a/extensions/idc/jest.config.js b/extensions/idc/jest.config.js new file mode 100644 index 00000000000..cc302bb15a2 --- /dev/null +++ b/extensions/idc/jest.config.js @@ -0,0 +1,10 @@ +const base = require('../../jest.config.base.js'); + +module.exports = { + ...base, + moduleNameMapper: { + ...base.moduleNameMapper, + '@ohif/(.*)': '/../../platform/$1/src', + '^@cornerstonejs/(.*)$': '/../../node_modules/@cornerstonejs/$1/dist/esm', + }, +}; diff --git a/extensions/idc/src/index.tsx b/extensions/idc/src/index.tsx index d5406200a7c..db900e145d3 100644 --- a/extensions/idc/src/index.tsx +++ b/extensions/idc/src/index.tsx @@ -2,6 +2,7 @@ import { Icons } from '@ohif/ui-next'; import DownloadStudySeriesDialog from './DownloadStudySeriesDialog'; import { ReactComponent as downloadIcon } from '../assets/icons/download.svg'; +import { registerInstanceAnnotations } from './instanceAnnotations'; import { id } from './id'; @@ -10,10 +11,16 @@ const extension = { preRegistration: () => { Icons.addIcon('download', downloadIcon); }, - onModeEnter: ({ servicesManager }) => { + onModeEnter: ({ servicesManager, extensionManager }) => { const { toolbarService, UIModalService, viewportGridService, displaySetService } = servicesManager.services; + const appConfig = extensionManager?.appConfig ?? {}; + registerInstanceAnnotations({ + servicesManager, + config: appConfig.instanceAnnotations, + }); + const moreTools = [ { id: 'DownloadStudySeries', diff --git a/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx b/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx new file mode 100644 index 00000000000..6081574d470 --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@ohif/ui-next'; + +import instanceAnnotationStore from './instanceAnnotationStore'; +import type { InstanceAnnotation } from './extractInstanceAnnotations'; + +export interface InstanceAnnotationsConfig { + /** Master switch for the feature. */ + enabled?: boolean; + /** Maximum number of labels rendered before collapsing into a "+N" indicator. */ + maxLabels?: number; + /** Whether to render the colored dot before each label. */ + showColor?: boolean; + /** Palette used to assign a stable color per concept label. */ + colors?: string[]; +} + +const DEFAULT_CONFIG: Required = { + enabled: true, + maxLabels: 10, + showColor: true, + colors: ['#5acce6', '#fcfa6b', '#7ee37e', '#f7a35c', '#e67ee6', '#ff7f7f'], +}; + +/** Deterministically maps a string to a color from the palette. */ +function colorForKey(key: string, colors: string[]): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = (hash * 31 + key.charCodeAt(i)) | 0; + } + const index = Math.abs(hash) % colors.length; + return colors[index]; +} + +/** + * Picks a stable color for an annotation based on its coded value (so different + * findings such as "Head" and "Neck" get different colors), falling back to the + * value text and finally the concept label. + */ +function colorForAnnotation(annotation: InstanceAnnotation, colors: string[]): string { + const key = + (annotation.codeValue && + `${annotation.codingSchemeDesignator ?? ''}:${annotation.codeValue}`) || + annotation.value || + annotation.label; + return colorForKey(key, colors); +} + +function buildTooltip(annotation: InstanceAnnotation): string { + const lines: string[] = []; + if (annotation.codeValue || annotation.codingSchemeDesignator) { + const scheme = annotation.codingSchemeDesignator ?? ''; + const code = annotation.codeValue ?? ''; + lines.push(`${annotation.value} (${scheme}${scheme && code ? ': ' : ''}${code})`); + } + annotation.modifiers?.forEach(modifier => { + lines.push(`${modifier.label}: ${modifier.value}`); + }); + if (annotation.trackingIdentifier) { + lines.push(`Group: ${annotation.trackingIdentifier}`); + } + return lines.join('\n'); +} + +function AnnotationLabel({ + annotation, + config, +}: { + annotation: InstanceAnnotation; + config: Required; +}) { + const tooltip = buildTooltip(annotation); + const modifierSuffix = annotation.modifiers?.length + ? ` (${annotation.modifiers.map(m => m.value).join(', ')})` + : ''; + const color = config.showColor ? colorForAnnotation(annotation, config.colors) : undefined; + + const content = ( +
+ {config.showColor && ( + + )} + {annotation.label}: + + {annotation.value} + {modifierSuffix} + +
+ ); + + if (!tooltip) { + return content; + } + + return ( + + + +
{content}
+
+ +
{tooltip}
+
+
+
+ ); +} + +/** + * Viewport overlay item that renders the instance level qualitative annotations + * (TID 1500 / TID 1501) associated with the image currently shown in a viewport. + * + * It is registered as a `viewportOverlay.bottomRight` customization item and + * receives the resolved overlay props (current `instance`, `imageSliceData`, ...). + */ +export default function InstanceAnnotationsOverlay(props): React.ReactNode { + const { instance, imageSliceData, customization } = props ?? {}; + const sopInstanceUID = instance?.SOPInstanceUID; + + // Re-render when SR display sets are parsed after the overlay has mounted. + const [, forceUpdate] = useState(0); + useEffect(() => { + return instanceAnnotationStore.subscribe(() => forceUpdate(value => value + 1)); + }, []); + + const config = useMemo>( + () => ({ ...DEFAULT_CONFIG, ...(customization?.config ?? {}) }), + [customization] + ); + + const annotations = useMemo(() => { + if (!config.enabled || !sopInstanceUID) { + return []; + } + const frameNumber = + imageSliceData?.imageIndex !== undefined ? imageSliceData.imageIndex + 1 : undefined; + return instanceAnnotationStore.getAnnotations(sopInstanceUID, frameNumber); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config, sopInstanceUID, imageSliceData?.imageIndex, imageSliceData?.numberOfSlices]); + + if (!annotations.length) { + return null; + } + + const visible = annotations.slice(0, config.maxLabels); + const hiddenCount = annotations.length - visible.length; + + return ( +
+ {visible.map((annotation, index) => ( + + ))} + {hiddenCount > 0 &&
+{hiddenCount} more
} +
+ ); +} diff --git a/extensions/idc/src/instanceAnnotations/constants.ts b/extensions/idc/src/instanceAnnotations/constants.ts new file mode 100644 index 00000000000..7001e7e6172 --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/constants.ts @@ -0,0 +1,37 @@ +/** + * DICOM SR SOP Class UIDs that may contain TID 1500 Imaging Measurement Reports. + */ +export const SR_SOP_CLASS_UIDS = [ + '1.2.840.10008.5.1.4.1.1.88.11', // Basic Text SR + '1.2.840.10008.5.1.4.1.1.88.22', // Enhanced SR + '1.2.840.10008.5.1.4.1.1.88.33', // Comprehensive SR + '1.2.840.10008.5.1.4.1.1.88.34', // Comprehensive 3D SR +]; + +/** + * Concept name CodeValues used while traversing a TID 1500 report tree to reach + * the TID 1501 Measurement Groups holding instance level qualitative codes. + */ +export const CodeNameCodeSequenceValues = { + ImagingMeasurementReport: '126000', + ImagingMeasurements: '126010', + MeasurementGroup: '125007', + TrackingIdentifier: '112039', + TrackingUniqueIdentifier: '112040', +}; + +/** + * SR content item value types relevant for instance level qualitative annotations. + */ +export const ValueTypes = { + CODE: 'CODE', + IMAGE: 'IMAGE', + SCOORD: 'SCOORD', + SCOORD3D: 'SCOORD3D', + NUM: 'NUM', +}; + +/** + * Relationship types used to identify modifiers attached to a qualitative code. + */ +export const ModifierRelationshipTypes = ['HAS CONCEPT MOD', 'HAS PROPERTIES']; diff --git a/extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.test.ts b/extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.test.ts new file mode 100644 index 00000000000..9de88a08e84 --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.test.ts @@ -0,0 +1,167 @@ +import { extractInstanceAnnotations } from './extractInstanceAnnotations'; + +const REFERENCED_SOP_INSTANCE_UID = '1.2.3.4.5.6.7.8.9'; + +/** + * Builds the root ContentSequence of a TID 1500 Imaging Measurement Report whose + * single Measurement Group carries instance level qualitative codes, mirroring + * the example in https://github.com/OHIF/Viewers/issues/3358. + */ +function buildReportContentSequence(measurementGroupItems) { + return [ + { + RelationshipType: 'HAS CONCEPT MOD', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '121049', CodeMeaning: 'Language of Content Item' }, + }, + { + RelationshipType: 'CONTAINS', + ValueType: 'CONTAINER', + ConceptNameCodeSequence: { CodeValue: '126010', CodeMeaning: 'Imaging Measurements' }, + ContentSequence: [ + { + RelationshipType: 'CONTAINS', + ValueType: 'CONTAINER', + ConceptNameCodeSequence: { CodeValue: '125007', CodeMeaning: 'Measurement Group' }, + ContentSequence: measurementGroupItems, + }, + ], + }, + ]; +} + +const trackingItems = [ + { + RelationshipType: 'HAS OBS CONTEXT', + ValueType: 'TEXT', + ConceptNameCodeSequence: { CodeValue: '112039', CodeMeaning: 'Tracking Identifier' }, + TextValue: 'Annotations group x', + }, + { + RelationshipType: 'HAS OBS CONTEXT', + ValueType: 'UIDREF', + ConceptNameCodeSequence: { CodeValue: '112040', CodeMeaning: 'Tracking Unique Identifier' }, + UID: '1.2.826.0.1.3680043.8.498.11346640510041906666146760516895890504', + }, +]; + +const imageItem = { + RelationshipType: 'CONTAINS', + ValueType: 'IMAGE', + ConceptNameCodeSequence: { CodeValue: '121112', CodeMeaning: 'Source of Measurement' }, + ReferencedSOPSequence: { + ReferencedSOPClassUID: '1.2.840.10008.5.1.4.1.1.2', + ReferencedSOPInstanceUID: REFERENCED_SOP_INSTANCE_UID, + }, +}; + +describe('extractInstanceAnnotations', () => { + it('extracts qualitative CODE items referencing an image instance', () => { + const contentSequence = buildReportContentSequence([ + ...trackingItems, + { + RelationshipType: 'CONTAINS', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '123014', CodeMeaning: 'Target Region' }, + ConceptCodeSequence: { + CodeValue: '69536005', + CodingSchemeDesignator: 'SCT', + CodeMeaning: 'Head', + }, + }, + { + RelationshipType: 'CONTAINS', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '123014', CodeMeaning: 'Target Region' }, + ConceptCodeSequence: { + CodeValue: '45048000', + CodingSchemeDesignator: 'SCT', + CodeMeaning: 'Neck', + }, + }, + imageItem, + ]); + + const annotations = extractInstanceAnnotations(contentSequence); + + expect(annotations).toHaveLength(2); + expect(annotations[0]).toMatchObject({ + label: 'Target Region', + value: 'Head', + codeValue: '69536005', + codingSchemeDesignator: 'SCT', + referencedSOPInstanceUID: REFERENCED_SOP_INSTANCE_UID, + trackingIdentifier: 'Annotations group x', + }); + expect(annotations[1]).toMatchObject({ label: 'Target Region', value: 'Neck' }); + }); + + it('captures modifiers attached to a qualitative code', () => { + const contentSequence = buildReportContentSequence([ + ...trackingItems, + { + RelationshipType: 'CONTAINS', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '123014', CodeMeaning: 'Finding' }, + ConceptCodeSequence: { CodeValue: '108369006', CodingSchemeDesignator: 'SCT', CodeMeaning: 'Tumor' }, + ContentSequence: [ + { + RelationshipType: 'HAS CONCEPT MOD', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '106233006', CodeMeaning: 'Topographical modifier' }, + ConceptCodeSequence: { CodeValue: '26216008', CodingSchemeDesignator: 'SCT', CodeMeaning: 'Center' }, + }, + ], + }, + imageItem, + ]); + + const annotations = extractInstanceAnnotations(contentSequence); + + expect(annotations).toHaveLength(1); + expect(annotations[0].modifiers).toEqual([ + { label: 'Topographical modifier', value: 'Center' }, + ]); + }); + + it('ignores geometric (SCOORD) measurement groups', () => { + const contentSequence = buildReportContentSequence([ + ...trackingItems, + { + RelationshipType: 'CONTAINS', + ValueType: 'SCOORD', + ConceptNameCodeSequence: { CodeValue: '111030', CodeMeaning: 'Image Region' }, + GraphicType: 'POLYLINE', + GraphicData: [0, 0, 1, 1], + }, + { + RelationshipType: 'CONTAINS', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '123014', CodeMeaning: 'Target Region' }, + ConceptCodeSequence: { CodeValue: '45048000', CodingSchemeDesignator: 'SCT', CodeMeaning: 'Neck' }, + }, + imageItem, + ]); + + expect(extractInstanceAnnotations(contentSequence)).toHaveLength(0); + }); + + it('ignores groups without an IMAGE reference', () => { + const contentSequence = buildReportContentSequence([ + ...trackingItems, + { + RelationshipType: 'CONTAINS', + ValueType: 'CODE', + ConceptNameCodeSequence: { CodeValue: '123014', CodeMeaning: 'Target Region' }, + ConceptCodeSequence: { CodeValue: '45048000', CodingSchemeDesignator: 'SCT', CodeMeaning: 'Neck' }, + }, + ]); + + expect(extractInstanceAnnotations(contentSequence)).toHaveLength(0); + }); + + it('returns an empty list when there are no imaging measurements', () => { + expect(extractInstanceAnnotations([])).toEqual([]); + expect(extractInstanceAnnotations(undefined)).toEqual([]); + }); +}); diff --git a/extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.ts b/extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.ts new file mode 100644 index 00000000000..f63460d62da --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.ts @@ -0,0 +1,203 @@ +import { + CodeNameCodeSequenceValues, + ModifierRelationshipTypes, + ValueTypes, +} from './constants'; + +/** + * A single qualitative annotation (a TID 1501 CODE content item) describing a + * property of an image instance, e.g. `{ label: 'Target Region', value: 'Neck' }`. + */ +export interface InstanceAnnotation { + /** Human readable concept name, e.g. "Target Region". */ + label: string; + /** Human readable concept value, e.g. "Neck". */ + value: string; + /** Coded value of the concept value, e.g. "45048000". */ + codeValue?: string; + /** Coding scheme designator of the concept value, e.g. "SCT". */ + codingSchemeDesignator?: string; + /** Optional modifiers (subordinate codes), e.g. "Topographical modifier: Center". */ + modifiers?: Array<{ label: string; value: string }>; + /** Tracking identifier of the owning measurement group, if any. */ + trackingIdentifier?: string; + /** SOP Instance UID(s) referenced by the owning measurement group. */ + referencedSOPInstanceUID: string; + /** Referenced frame number when the source is a multiframe image. */ + referencedFrameNumber?: number; +} + +const toArray = value => (Array.isArray(value) ? value : value ? [value] : []); + +const first = value => (Array.isArray(value) ? value[0] : value); + +const getConceptName = item => first(item?.ConceptNameCodeSequence); + +const getConceptCode = item => first(item?.ConceptCodeSequence); + +/** + * Extracts the modifiers (subordinate coded concepts) attached to a CODE + * content item via a HAS CONCEPT MOD / HAS PROPERTIES relationship. + */ +function extractModifiers(codeItem): Array<{ label: string; value: string }> { + const modifiers = []; + + toArray(codeItem?.ContentSequence).forEach(child => { + if (child?.ValueType !== ValueTypes.CODE) { + return; + } + + if (!ModifierRelationshipTypes.includes(child?.RelationshipType)) { + return; + } + + const conceptName = getConceptName(child); + const conceptCode = getConceptCode(child); + + if (!conceptCode) { + return; + } + + modifiers.push({ + label: conceptName?.CodeMeaning ?? '', + value: conceptCode?.CodeMeaning ?? '', + }); + }); + + return modifiers; +} + +/** + * Collects the SOP Instance UIDs (and optional frame numbers) referenced by the + * IMAGE content items of a TID 1501 measurement group. + */ +function getReferencedInstances( + imageItems +): Array<{ ReferencedSOPInstanceUID: string; ReferencedFrameNumber?: number }> { + const referenced = []; + + imageItems.forEach(imageItem => { + toArray(imageItem?.ReferencedSOPSequence).forEach(ref => { + if (!ref?.ReferencedSOPInstanceUID) { + return; + } + + const frames = toArray(ref.ReferencedFrameNumber); + if (frames.length) { + frames.forEach(frame => + referenced.push({ + ReferencedSOPInstanceUID: ref.ReferencedSOPInstanceUID, + ReferencedFrameNumber: Number(frame), + }) + ); + } else { + referenced.push({ ReferencedSOPInstanceUID: ref.ReferencedSOPInstanceUID }); + } + }); + }); + + return referenced; +} + +/** + * Parses a single TID 1501 style Measurement Group and returns the qualitative + * annotations it carries, keyed nowhere yet (callers index by SOP Instance UID). + * + * A group is considered an instance level qualitative annotation group when it + * contains one or more IMAGE content items and one or more CODE content items, + * and is NOT a geometric measurement (i.e. has no SCOORD / SCOORD3D). + */ +function processMeasurementGroup(group): InstanceAnnotation[] { + const items = toArray(group?.ContentSequence); + + // Skip geometric measurement groups (handled by the SR extension itself). + const hasGeometry = items.some( + item => item?.ValueType === ValueTypes.SCOORD || item?.ValueType === ValueTypes.SCOORD3D + ); + if (hasGeometry) { + return []; + } + + const imageItems = items.filter(item => item?.ValueType === ValueTypes.IMAGE); + if (!imageItems.length) { + return []; + } + + const codeItems = items.filter(item => item?.ValueType === ValueTypes.CODE); + if (!codeItems.length) { + return []; + } + + const trackingIdentifierItem = items.find( + item => getConceptName(item)?.CodeValue === CodeNameCodeSequenceValues.TrackingIdentifier + ); + const trackingIdentifier = trackingIdentifierItem?.TextValue; + + const referencedInstances = getReferencedInstances(imageItems); + if (!referencedInstances.length) { + return []; + } + + const annotations: InstanceAnnotation[] = []; + + referencedInstances.forEach(({ ReferencedSOPInstanceUID, ReferencedFrameNumber }) => { + codeItems.forEach(codeItem => { + const conceptName = getConceptName(codeItem); + const conceptCode = getConceptCode(codeItem); + + if (!conceptName || !conceptCode) { + return; + } + + const modifiers = extractModifiers(codeItem); + + annotations.push({ + label: conceptName.CodeMeaning ?? '', + value: conceptCode.CodeMeaning ?? '', + codeValue: conceptCode.CodeValue, + codingSchemeDesignator: conceptCode.CodingSchemeDesignator, + modifiers: modifiers.length ? modifiers : undefined, + trackingIdentifier, + referencedSOPInstanceUID: ReferencedSOPInstanceUID, + referencedFrameNumber: ReferencedFrameNumber, + }); + }); + }); + + return annotations; +} + +/** + * Extracts all instance level qualitative annotations (TID 1500 / TID 1501) from + * the root content sequence of a DICOM SR instance. + * + * @param contentSequence - The root container ContentSequence of the SR instance + * (i.e. `srInstance.ContentSequence`). + * @returns A flat list of {@link InstanceAnnotation}s referencing image instances. + */ +export function extractInstanceAnnotations(contentSequence): InstanceAnnotation[] { + const rootItems = toArray(contentSequence); + if (!rootItems.length) { + return []; + } + + const imagingMeasurements = rootItems.find( + item => getConceptName(item)?.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurements + ); + + if (!imagingMeasurements) { + return []; + } + + const measurementGroups = toArray(imagingMeasurements.ContentSequence).filter( + item => getConceptName(item)?.CodeValue === CodeNameCodeSequenceValues.MeasurementGroup + ); + + if (!measurementGroups.length) { + return []; + } + + return measurementGroups.flatMap(processMeasurementGroup); +} + +export default extractInstanceAnnotations; diff --git a/extensions/idc/src/instanceAnnotations/index.ts b/extensions/idc/src/instanceAnnotations/index.ts new file mode 100644 index 00000000000..39652eae5d8 --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/index.ts @@ -0,0 +1,6 @@ +export { registerInstanceAnnotations } from './registerInstanceAnnotations'; +export { extractInstanceAnnotations } from './extractInstanceAnnotations'; +export { instanceAnnotationStore } from './instanceAnnotationStore'; +export { default as InstanceAnnotationsOverlay } from './InstanceAnnotationsOverlay'; +export type { InstanceAnnotation } from './extractInstanceAnnotations'; +export type { InstanceAnnotationsConfig } from './InstanceAnnotationsOverlay'; diff --git a/extensions/idc/src/instanceAnnotations/instanceAnnotationStore.ts b/extensions/idc/src/instanceAnnotations/instanceAnnotationStore.ts new file mode 100644 index 00000000000..12ad96479f9 --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/instanceAnnotationStore.ts @@ -0,0 +1,86 @@ +import type { InstanceAnnotation } from './extractInstanceAnnotations'; + +type Listener = () => void; + +/** + * In-memory store mapping a referenced image SOP Instance UID to the list of + * instance level qualitative annotations that should be rendered over it. + * + * The store is intentionally framework agnostic: the viewport overlay component + * subscribes to change notifications so it can re-render once SR display sets + * finish loading (which can happen after the overlay has mounted). + */ +class InstanceAnnotationStore { + private annotationsBySOPInstanceUID = new Map(); + /** Tracks which SR display sets have already been parsed to avoid duplicates. */ + private processedDisplaySetUIDs = new Set(); + private listeners = new Set(); + + public addAnnotations(displaySetInstanceUID: string, annotations: InstanceAnnotation[]): void { + if (this.processedDisplaySetUIDs.has(displaySetInstanceUID)) { + return; + } + this.processedDisplaySetUIDs.add(displaySetInstanceUID); + + if (!annotations.length) { + return; + } + + annotations.forEach(annotation => { + const key = annotation.referencedSOPInstanceUID; + const existing = this.annotationsBySOPInstanceUID.get(key) ?? []; + existing.push(annotation); + this.annotationsBySOPInstanceUID.set(key, existing); + }); + + this.notify(); + } + + public getAnnotations( + sopInstanceUID: string, + frameNumber?: number + ): InstanceAnnotation[] { + if (!sopInstanceUID) { + return []; + } + + const annotations = this.annotationsBySOPInstanceUID.get(sopInstanceUID) ?? []; + + if (frameNumber === undefined) { + return annotations; + } + + // Keep annotations that either target this specific frame or are frame agnostic. + return annotations.filter( + annotation => + annotation.referencedFrameNumber === undefined || + annotation.referencedFrameNumber === frameNumber + ); + } + + public hasAnnotations(sopInstanceUID: string): boolean { + return this.annotationsBySOPInstanceUID.has(sopInstanceUID); + } + + public clear(): void { + this.annotationsBySOPInstanceUID.clear(); + this.processedDisplaySetUIDs.clear(); + this.notify(); + } + + public subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify(): void { + this.listeners.forEach(listener => listener()); + } +} + +/** Shared singleton used by both the registration logic and the overlay UI. */ +export const instanceAnnotationStore = new InstanceAnnotationStore(); + +export default instanceAnnotationStore; diff --git a/extensions/idc/src/instanceAnnotations/registerInstanceAnnotations.tsx b/extensions/idc/src/instanceAnnotations/registerInstanceAnnotations.tsx new file mode 100644 index 00000000000..af23462a947 --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/registerInstanceAnnotations.tsx @@ -0,0 +1,104 @@ +import React from 'react'; + +import { SR_SOP_CLASS_UIDS } from './constants'; +import { extractInstanceAnnotations } from './extractInstanceAnnotations'; +import instanceAnnotationStore from './instanceAnnotationStore'; +import InstanceAnnotationsOverlay, { + InstanceAnnotationsConfig, +} from './InstanceAnnotationsOverlay'; + +const OVERLAY_ITEM_ID = 'instanceQualitativeAnnotations'; + +let displaySetsSubscription: { unsubscribe: () => void } | null = null; + +function isStructuredReport(displaySet): boolean { + if (!displaySet) { + return false; + } + if (displaySet.Modality === 'SR') { + return true; + } + const sopClassUID = displaySet.SOPClassUID ?? displaySet.instance?.SOPClassUID; + return SR_SOP_CLASS_UIDS.includes(sopClassUID); +} + +/** + * Parses a single SR display set for instance level qualitative annotations and + * adds the result to the shared store. The CODE/IMAGE content items required by + * this feature live directly in the instance metadata, so no bulk-data load is + * needed before parsing. + */ +function processDisplaySet(displaySet): void { + if (!isStructuredReport(displaySet)) { + return; + } + + const instance = displaySet.instance ?? displaySet.instances?.[displaySet.instances.length - 1]; + const contentSequence = instance?.ContentSequence; + if (!contentSequence) { + return; + } + + const annotations = extractInstanceAnnotations(contentSequence); + instanceAnnotationStore.addAnnotations(displaySet.displaySetInstanceUID, annotations); +} + +/** + * Registers the instance level qualitative annotation feature: + * 1. Parses already loaded and future SR display sets into the shared store. + * 2. Appends an overlay item that renders the annotations over the referenced + * image instances (bottom-right of the viewport). + * + * Safe to call on every `onModeEnter`; previous subscriptions are torn down and + * the store is reset so annotations do not leak across studies/modes. + */ +export function registerInstanceAnnotations({ + servicesManager, + config, +}: { + servicesManager: AppTypes.ServicesManager; + config?: InstanceAnnotationsConfig; +}): void { + const { displaySetService, customizationService } = servicesManager.services; + + if (config?.enabled === false) { + return; + } + + // Reset state from any previous mode/study. + displaySetsSubscription?.unsubscribe(); + instanceAnnotationStore.clear(); + + // Parse SR display sets that were already added before we subscribed. + displaySetService.getActiveDisplaySets?.().forEach(processDisplaySet); + + displaySetsSubscription = displaySetService.subscribe( + displaySetService.EVENTS.DISPLAY_SETS_ADDED, + ({ displaySetsAdded }) => { + displaySetsAdded?.forEach(processDisplaySet); + } + ); + + // Append the overlay item to the bottom-right viewport overlay region so it + // does not collide with the bottom-left text overlays. + const overlayItem = { + id: OVERLAY_ITEM_ID, + config, + contentF: overlayProps => , + }; + + const existing = customizationService.getCustomization('viewportOverlay.bottomRight'); + + // Avoid registering the overlay item more than once for the same mode entry. + if (Array.isArray(existing) && existing.some(item => item?.id === OVERLAY_ITEM_ID)) { + return; + } + + customizationService.setCustomizations({ + 'viewportOverlay.bottomRight': Array.isArray(existing) + ? { $push: [overlayItem] } + : { $set: [overlayItem] }, + }); +} + +export default registerInstanceAnnotations; diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 1cbf65e9868..12576b68c6f 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -323,6 +323,13 @@ window.config = { ); }, }, + defaultDataSourceName: 'idc-dicomweb', + instanceAnnotations: { + enabled: true, // master switch + maxLabels: 10, // collapse extra labels into a "+N more" indicator + showColor: true, // render a colored dot before each label + // colors: ['#5acce6', '#fcfa6b', '#7ee37e', '#f7a35c', '#e67ee6', '#ff7f7f'], + }, idcDownloadCommandsDialog: { description: 'Follow the instructions below to download the study or series:', instructions: [ @@ -341,7 +348,6 @@ window.config = { ], }, disableConfirmationPrompts: true, - defaultDataSourceName: 'ohif', dataSources: [ { friendlyName: 'dcmjs DICOMWeb Server', diff --git a/version.json b/version.json index 121d6d2c3ea..6f3540ce767 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { "version": "3.12.5", "commit": "f5f086cd953bfcb783276fbb8d687e05fa118725" -} \ No newline at end of file +} diff --git a/version.txt b/version.txt index f46612b4ca0..d9506ceba51 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.12.5 \ No newline at end of file +3.12.5