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
2 changes: 1 addition & 1 deletion commit.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
f5f086cd953bfcb783276fbb8d687e05fa118725
f5f086cd953bfcb783276fbb8d687e05fa118725
29 changes: 29 additions & 0 deletions extensions/idc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
```
10 changes: 10 additions & 0 deletions extensions/idc/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const base = require('../../jest.config.base.js');

module.exports = {
...base,
moduleNameMapper: {
...base.moduleNameMapper,
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
'^@cornerstonejs/(.*)$': '<rootDir>/../../node_modules/@cornerstonejs/$1/dist/esm',
},
};
9 changes: 8 additions & 1 deletion extensions/idc/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down
169 changes: 169 additions & 0 deletions extensions/idc/src/instanceAnnotations/InstanceAnnotationsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<InstanceAnnotationsConfig> = {
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<InstanceAnnotationsConfig>;
}) {
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 = (
<div className="overlay-item flex flex-row items-center">
{config.showColor && (
<span
className="mr-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="mr-1 shrink-0 opacity-[0.70]">{annotation.label}:</span>
<span
className="shrink-0 font-bold"
style={{ color }}
>
{annotation.value}
{modifierSuffix}
</span>
</div>
);

if (!tooltip) {
return content;
}

return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="pointer-events-auto">{content}</div>
</TooltipTrigger>
<TooltipContent side="left">
<div className="whitespace-pre-line text-left">{tooltip}</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

/**
* 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<Required<InstanceAnnotationsConfig>>(
() => ({ ...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 (
<div
data-cy="instance-annotations-overlay"
className="flex flex-col items-end"
>
{visible.map((annotation, index) => (
<AnnotationLabel
key={`${annotation.label}-${annotation.value}-${index}`}
annotation={annotation}
config={config}
/>
))}
{hiddenCount > 0 && <div className="overlay-item opacity-[0.70]">+{hiddenCount} more</div>}
</div>
);
}
37 changes: 37 additions & 0 deletions extensions/idc/src/instanceAnnotations/constants.ts
Original file line number Diff line number Diff line change
@@ -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'];
Loading
Loading