From 4df1cbf3ff34a7b68c74423bc1387bafd04ad2c7 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 22 Jun 2026 19:56:44 -0300 Subject: [PATCH 1/3] feat(idc): instance-level SR qualitative annotations (TID 1500/1501) Implements OHIF/Viewers#3358 in the IDC extension. When a TID 1500 Imaging Measurement Report contains a Measurement Group (TID 1501) with IMAGE content items and CODE content items but no geometric coordinates (SCOORD/SCOORD3D), the coded values are rendered as "concept: value" labels in the bottom-right viewport overlay over the referenced image instance (e.g. "Target Region: Neck"). A tooltip shows the code value / coding scheme designator and any modifiers. - extractInstanceAnnotations: pure TID 1500/1501 parser (+ unit tests) - instanceAnnotationStore: SOPInstanceUID -> annotations store with pub/sub - InstanceAnnotationsOverlay: viewport overlay item with tooltips - registerInstanceAnnotations: parses SR display sets and registers the viewportOverlay.bottomRight customization - wired into the extension onModeEnter; configurable via app config `instanceAnnotations` (enabled/maxLabels/showColor/colors) --- extensions/idc/README.md | 29 +++ extensions/idc/jest.config.js | 10 + extensions/idc/src/index.tsx | 11 +- .../InstanceAnnotationsOverlay.tsx | 151 +++++++++++++ .../idc/src/instanceAnnotations/constants.ts | 37 ++++ .../extractInstanceAnnotations.test.ts | 167 ++++++++++++++ .../extractInstanceAnnotations.ts | 203 ++++++++++++++++++ .../idc/src/instanceAnnotations/index.ts | 6 + .../instanceAnnotationStore.ts | 86 ++++++++ .../registerInstanceAnnotations.tsx | 104 +++++++++ 10 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 extensions/idc/jest.config.js create mode 100644 extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx create mode 100644 extensions/idc/src/instanceAnnotations/constants.ts create mode 100644 extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.test.ts create mode 100644 extensions/idc/src/instanceAnnotations/extractInstanceAnnotations.ts create mode 100644 extensions/idc/src/instanceAnnotations/index.ts create mode 100644 extensions/idc/src/instanceAnnotations/instanceAnnotationStore.ts create mode 100644 extensions/idc/src/instanceAnnotations/registerInstanceAnnotations.tsx 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..5be5eefaa6f 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,18 @@ const extension = { preRegistration: () => { Icons.addIcon('download', downloadIcon); }, - onModeEnter: ({ servicesManager }) => { + onModeEnter: ({ servicesManager, extensionManager }) => { const { toolbarService, UIModalService, viewportGridService, displaySetService } = servicesManager.services; + // Instance level SR qualitative annotations (TID 1500 / TID 1501). + // See https://github.com/OHIF/Viewers/issues/3358 + 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..0bdd9f6c76e --- /dev/null +++ b/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx @@ -0,0 +1,151 @@ +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 concept label to a color from the palette. */ +function colorForLabel(label: string, colors: string[]): string { + let hash = 0; + for (let i = 0; i < label.length; i++) { + hash = (hash * 31 + label.charCodeAt(i)) | 0; + } + const index = Math.abs(hash) % colors.length; + return colors[index]; +} + +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 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; From 18312e070d188040cfc075c9dbe27fafedfd91d6 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 22 Jun 2026 21:50:01 -0300 Subject: [PATCH 2/3] fix(idc): make qualitative annotation colors distinct per value Color was derived from the concept name, so annotations sharing a concept (e.g. two "Finding Site" items) rendered with an identical dot and looked like "no colors". Derive the color from the coded value instead (falling back to value text, then concept label) so distinct findings get distinct colors, color the value text as well, and slightly enlarge the dot for visibility. --- .../InstanceAnnotationsOverlay.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx b/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx index 0bdd9f6c76e..6081574d470 100644 --- a/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx +++ b/extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx @@ -22,16 +22,30 @@ const DEFAULT_CONFIG: Required = { colors: ['#5acce6', '#fcfa6b', '#7ee37e', '#f7a35c', '#e67ee6', '#ff7f7f'], }; -/** Deterministically maps a concept label to a color from the palette. */ -function colorForLabel(label: string, colors: string[]): string { +/** 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 < label.length; i++) { - hash = (hash * 31 + label.charCodeAt(i)) | 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) { @@ -59,17 +73,21 @@ function AnnotationLabel({ 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} From aec74e4fc6d3b5ff82689876ddac4f4a6c83a1e8 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Mon, 22 Jun 2026 21:50:35 -0300 Subject: [PATCH 3/3] Update config --- extensions/idc/src/index.tsx | 2 -- platform/app/public/config/default.js | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/extensions/idc/src/index.tsx b/extensions/idc/src/index.tsx index 5be5eefaa6f..db900e145d3 100644 --- a/extensions/idc/src/index.tsx +++ b/extensions/idc/src/index.tsx @@ -15,8 +15,6 @@ const extension = { const { toolbarService, UIModalService, viewportGridService, displaySetService } = servicesManager.services; - // Instance level SR qualitative annotations (TID 1500 / TID 1501). - // See https://github.com/OHIF/Viewers/issues/3358 const appConfig = extensionManager?.appConfig ?? {}; registerInstanceAnnotations({ servicesManager, diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 56f4962841e..e59aa2a31ab 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',