fix(SR): Added support for spline and live wire SR items. (#5870)
* fix(SR): Added support for spline and live wire SR items. * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Add a script to checkout a worktree for test builds * fix: Allow download for testing sr validator * Remove script that wasn't intended to be included * Bump CS3D version. * PR comments - simplify code and use single codepath for download * Allow both download and save buttons for SEG and RTSTRUCT --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
parent
69d8ad63f3
commit
1d4802c2a3
5
.gitignore
vendored
5
.gitignore
vendored
@ -66,3 +66,8 @@ libs/
|
|||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# cornerstone3D local linking
|
||||||
|
libs/
|
||||||
|
link-cs3d.js
|
||||||
|
unlink-cs3d.js
|
||||||
|
|||||||
@ -2,9 +2,8 @@ import dcmjs from 'dcmjs';
|
|||||||
import { classes, Types, utils } from '@ohif/core';
|
import { classes, Types, utils } from '@ohif/core';
|
||||||
import { cache, metaData } from '@cornerstonejs/core';
|
import { cache, metaData } from '@cornerstonejs/core';
|
||||||
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
|
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
|
||||||
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
|
import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
|
||||||
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
||||||
import { DicomMetadataStore } from '@ohif/core';
|
|
||||||
|
|
||||||
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
||||||
|
|
||||||
@ -29,11 +28,11 @@ const {
|
|||||||
},
|
},
|
||||||
} = adaptersRT;
|
} = adaptersRT;
|
||||||
|
|
||||||
const { downloadDICOMData } = helpers;
|
|
||||||
|
|
||||||
const commandsModule = ({
|
const commandsModule = ({
|
||||||
servicesManager,
|
servicesManager,
|
||||||
extensionManager,
|
extensionManager,
|
||||||
|
commandsManager,
|
||||||
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
||||||
const { segmentationService, displaySetService, viewportGridService } =
|
const { segmentationService, displaySetService, viewportGridService } =
|
||||||
servicesManager.services as AppTypes.Services;
|
servicesManager.services as AppTypes.Services;
|
||||||
@ -194,8 +193,11 @@ const commandsModule = ({
|
|||||||
const generatedSegmentation = actions.generateSegmentation({
|
const generatedSegmentation = actions.generateSegmentation({
|
||||||
segmentationId,
|
segmentationId,
|
||||||
});
|
});
|
||||||
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||||
downloadDICOMData(generatedSegmentation.dataset, `${segmentationInOHIF.label}`);
|
dataSource: 'download',
|
||||||
|
defaultFileName: `${segmentationInOHIF.label}.dcm`,
|
||||||
|
});
|
||||||
|
storeFn(generatedSegmentation.dataset);
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Stores a segmentation based on the provided segmentationId into a specified data source.
|
* Stores a segmentation based on the provided segmentationId into a specified data source.
|
||||||
@ -217,11 +219,10 @@ const commandsModule = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { label, predecessorImageId } = segmentation;
|
const { label, predecessorImageId } = segmentation;
|
||||||
const defaultDataSource = dataSource ?? extensionManager.getActiveDataSource()[0];
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: reportName,
|
value: reportName,
|
||||||
dataSourceName: selectedDataSource,
|
dataSourceName,
|
||||||
series,
|
series,
|
||||||
priorSeriesNumber,
|
priorSeriesNumber,
|
||||||
action,
|
action,
|
||||||
@ -231,50 +232,58 @@ const commandsModule = ({
|
|||||||
predecessorImageId,
|
predecessorImageId,
|
||||||
title: 'Store Segmentation',
|
title: 'Store Segmentation',
|
||||||
modality,
|
modality,
|
||||||
|
enableDownload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
|
if (action !== PROMPT_RESPONSES.CREATE_REPORT) {
|
||||||
try {
|
return;
|
||||||
const selectedDataSourceConfig = selectedDataSource
|
}
|
||||||
? extensionManager.getDataSources(selectedDataSource)[0]
|
|
||||||
: defaultDataSource;
|
|
||||||
|
|
||||||
const args = {
|
const defaultFileName =
|
||||||
segmentationId,
|
modality === 'RTSTRUCT'
|
||||||
options: {
|
? `rtss-${segmentationId}.dcm`
|
||||||
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
|
: `${label || 'segmentation'}.dcm`;
|
||||||
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
|
|
||||||
predecessorImageId: series,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const generatedDataAsync =
|
|
||||||
(modality === 'SEG' && actions.generateSegmentation(args)) ||
|
|
||||||
(modality === 'RTSTRUCT' && actions.generateContour(args));
|
|
||||||
const generatedData = await generatedDataAsync;
|
|
||||||
|
|
||||||
if (!generatedData || !generatedData.dataset) {
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||||
throw new Error('Error during segmentation generation');
|
dataSource: dataSourceName,
|
||||||
}
|
defaultFileName,
|
||||||
|
});
|
||||||
|
|
||||||
const { dataset: naturalizedReport } = generatedData;
|
if (!storeFn) {
|
||||||
|
throw new Error(`No valid store for dataSource: ${dataSourceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
|
try {
|
||||||
if (naturalizedReport.StudyID === 'No Study ID') {
|
const args = {
|
||||||
naturalizedReport.StudyID = '';
|
segmentationId,
|
||||||
}
|
options: {
|
||||||
|
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
|
||||||
|
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
|
||||||
|
predecessorImageId: series,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const generatedDataAsync =
|
||||||
|
(modality === 'SEG' && actions.generateSegmentation(args)) ||
|
||||||
|
(modality === 'RTSTRUCT' && actions.generateContour(args));
|
||||||
|
const generatedData = await generatedDataAsync;
|
||||||
|
|
||||||
await selectedDataSourceConfig.store.dicom(naturalizedReport);
|
if (!generatedData?.dataset) {
|
||||||
|
throw new Error('Error during segmentation generation');
|
||||||
// add the information for where we stored it to the instance as well
|
|
||||||
naturalizedReport.wadoRoot = selectedDataSourceConfig.getConfig().wadoRoot;
|
|
||||||
|
|
||||||
DicomMetadataStore.addInstances([naturalizedReport], true);
|
|
||||||
|
|
||||||
return naturalizedReport;
|
|
||||||
} catch (error) {
|
|
||||||
console.debug('Error storing segmentation:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { dataset: naturalizedReport } = generatedData;
|
||||||
|
|
||||||
|
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
|
||||||
|
if (naturalizedReport.StudyID === 'No Study ID') {
|
||||||
|
naturalizedReport.StudyID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeFn(naturalizedReport, {});
|
||||||
|
|
||||||
|
return naturalizedReport;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Error storing segmentation:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -307,14 +316,11 @@ const commandsModule = ({
|
|||||||
downloadRTSS: async args => {
|
downloadRTSS: async args => {
|
||||||
const { dataset } = await actions.generateContour(args);
|
const { dataset } = await actions.generateContour(args);
|
||||||
const { InstanceNumber: instanceNumber = 1, SeriesInstanceUID: seriesUID } = dataset;
|
const { InstanceNumber: instanceNumber = 1, SeriesInstanceUID: seriesUID } = dataset;
|
||||||
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||||
try {
|
dataSource: 'download',
|
||||||
//Create a URL for the binary.
|
defaultFileName: `rtss-${seriesUID}-${instanceNumber}.dcm`,
|
||||||
const filename = `rtss-${seriesUID}-${instanceNumber}.dcm`;
|
});
|
||||||
downloadDICOMData(dataset, filename);
|
await storeFn(dataset);
|
||||||
} catch (e) {
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {
|
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { metaData } from '@cornerstonejs/core';
|
import { metaData } from '@cornerstonejs/core';
|
||||||
|
|
||||||
import OHIF, { DicomMetadataStore, utils } from '@ohif/core';
|
import OHIF from '@ohif/core';
|
||||||
import dcmjs from 'dcmjs';
|
|
||||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||||
|
|
||||||
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
|
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
|
||||||
import hydrateStructuredReport from './utils/hydrateStructuredReport';
|
import hydrateStructuredReport from './utils/hydrateStructuredReport';
|
||||||
|
|
||||||
const { downloadBlob } = utils;
|
|
||||||
|
|
||||||
const { MeasurementReport } = adaptersSR.Cornerstone3D;
|
const { MeasurementReport } = adaptersSR.Cornerstone3D;
|
||||||
const { log } = OHIF;
|
const { log } = OHIF;
|
||||||
|
|
||||||
@ -75,24 +72,8 @@ const commandsModule = (props: withAppTypes) => {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param measurementData An array of measurements from the measurements service
|
* @param measurementData An array of measurements from the measurements service
|
||||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
|
||||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
|
||||||
* as opposed to Finding Sites.
|
|
||||||
* that you wish to serialize.
|
* that you wish to serialize.
|
||||||
*/
|
* @param dataSource The data source name ('download', 'copyToClipboard', or a named data source).
|
||||||
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
|
|
||||||
const srDataset = _generateReport(measurementData, additionalFindingTypes, options);
|
|
||||||
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
|
|
||||||
|
|
||||||
//Create a URL for the binary.
|
|
||||||
downloadBlob(reportBlob, { filename: 'dicom-sr.dcm' });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param measurementData An array of measurements from the measurements service
|
|
||||||
* that you wish to serialize.
|
|
||||||
* @param dataSource The dataSource that you wish to use to persist the data.
|
|
||||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||||
* @return The naturalized report
|
* @return The naturalized report
|
||||||
@ -103,23 +84,30 @@ const commandsModule = (props: withAppTypes) => {
|
|||||||
additionalFindingTypes,
|
additionalFindingTypes,
|
||||||
options = {},
|
options = {},
|
||||||
}) => {
|
}) => {
|
||||||
// Use the @cornerstonejs adapter for converting to/from DICOM
|
|
||||||
// But it is good enough for now whilst we only have cornerstone as a datasource.
|
|
||||||
log.info('[DICOMSR] storeMeasurements');
|
log.info('[DICOMSR] storeMeasurements');
|
||||||
|
|
||||||
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||||
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
|
dataSource,
|
||||||
|
defaultFileName: 'dicom-sr.dcm',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!storeFn) {
|
||||||
|
log.error('[DICOMSR] No valid store for dataSource:', dataSource);
|
||||||
return Promise.reject({});
|
return Promise.reject({});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, options);
|
const naturalizedReport = _generateReport(
|
||||||
|
measurementData,
|
||||||
|
additionalFindingTypes,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
|
const { ContentSequence } = naturalizedReport;
|
||||||
// The content sequence has 5 or more elements, of which
|
// The content sequence has 5 or more elements, of which
|
||||||
// the `[4]` element contains the annotation data, so this is
|
// the `[4]` element contains the annotation data, so this is
|
||||||
// checking that there is some annotation data present.
|
// checking that there is some annotation data present.
|
||||||
if (!ContentSequence?.[4].ContentSequence?.length) {
|
if (!ContentSequence?.[4]?.ContentSequence?.length) {
|
||||||
console.log('naturalizedReport missing imaging content', naturalizedReport);
|
console.log('naturalizedReport missing imaging content', naturalizedReport);
|
||||||
throw new Error('Invalid report, no content');
|
throw new Error('Invalid report, no content');
|
||||||
}
|
}
|
||||||
@ -128,22 +116,12 @@ const commandsModule = (props: withAppTypes) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
|
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
|
||||||
|
|
||||||
let dicomDict;
|
let dicomDict;
|
||||||
if (typeof onBeforeDicomStore === 'function') {
|
if (typeof onBeforeDicomStore === 'function') {
|
||||||
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
|
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
|
||||||
}
|
}
|
||||||
|
|
||||||
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
|
await storeFn(naturalizedReport, { measurementData, dicomDict });
|
||||||
|
|
||||||
if (StudyInstanceUID) {
|
|
||||||
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "Mode" route listens for DicomMetadataStore changes
|
|
||||||
// When a new instance is added, it listens and
|
|
||||||
// automatically calls makeDisplaySets
|
|
||||||
DicomMetadataStore.addInstances([naturalizedReport], true);
|
|
||||||
|
|
||||||
return naturalizedReport;
|
return naturalizedReport;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -166,7 +144,6 @@ const commandsModule = (props: withAppTypes) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const definitions = {
|
const definitions = {
|
||||||
downloadReport: actions.downloadReport,
|
|
||||||
storeMeasurements: actions.storeMeasurements,
|
storeMeasurements: actions.storeMeasurements,
|
||||||
hydrateStructuredReport: actions.hydrateStructuredReport,
|
hydrateStructuredReport: actions.hydrateStructuredReport,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -637,16 +637,35 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
|||||||
NUMContentItems.forEach(item => {
|
NUMContentItems.forEach(item => {
|
||||||
const { ConceptNameCodeSequence, ContentSequence, MeasuredValueSequence } = item;
|
const { ConceptNameCodeSequence, ContentSequence, MeasuredValueSequence } = item;
|
||||||
|
|
||||||
// Handle spatial reference ONLY if ContentSequence exists
|
// Handle spatial reference ONLY if ContentSequence exists.
|
||||||
|
// ContentSequence may be a scalar SCOORD or an array when additional named
|
||||||
|
// SCOORDs (e.g. control points) are nested alongside the primary geometry.
|
||||||
|
// Pick the primary geometry entry: prefer the SCOORD without a
|
||||||
|
// ConceptNameCodeSequence (plain polyline), falling back to the first SCOORD.
|
||||||
if (ContentSequence) {
|
if (ContentSequence) {
|
||||||
const { ValueType } = ContentSequence;
|
const scoordItem = Array.isArray(ContentSequence)
|
||||||
|
? (ContentSequence.find(
|
||||||
|
cs =>
|
||||||
|
(cs.ValueType === 'SCOORD' || cs.ValueType === 'SCOORD3D') &&
|
||||||
|
!cs.ConceptNameCodeSequence
|
||||||
|
) ?? ContentSequence.find(cs => cs.ValueType === 'SCOORD' || cs.ValueType === 'SCOORD3D'))
|
||||||
|
: ContentSequence;
|
||||||
|
|
||||||
|
if (!scoordItem) {
|
||||||
|
console.warn(
|
||||||
|
'ContentSequence array contains no SCOORD or SCOORD3D entry, skipping annotation.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ValueType } = scoordItem;
|
||||||
|
|
||||||
if (ValueType !== 'SCOORD' && ValueType !== 'SCOORD3D') {
|
if (ValueType !== 'SCOORD' && ValueType !== 'SCOORD3D') {
|
||||||
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
|
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = _getCoordsFromSCOORDOrSCOORD3D(ContentSequence);
|
const coords = _getCoordsFromSCOORDOrSCOORD3D(scoordItem);
|
||||||
|
|
||||||
if (coords) {
|
if (coords) {
|
||||||
measurement.coords.push(coords);
|
measurement.coords.push(coords);
|
||||||
|
|||||||
@ -5,9 +5,7 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
Icons,
|
Icons,
|
||||||
} from '@ohif/ui-next';
|
} from '@ohif/ui-next';
|
||||||
|
|
||||||
@ -19,8 +17,6 @@ interface ExportSegmentationSubMenuItemProps {
|
|||||||
allowExport: boolean;
|
allowExport: boolean;
|
||||||
actions: {
|
actions: {
|
||||||
storeSegmentation: (segmentationId: string, modality?: string) => Promise<unknown>;
|
storeSegmentation: (segmentationId: string, modality?: string) => Promise<unknown>;
|
||||||
onSegmentationDownloadRTSS: (segmentationId: string) => void;
|
|
||||||
onSegmentationDownload: (segmentationId: string) => void;
|
|
||||||
downloadCSVSegmentationReport: (segmentationId: string) => void;
|
downloadCSVSegmentationReport: (segmentationId: string) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -37,14 +33,10 @@ export const ExportSegmentationSubMenuItem: React.FC<ExportSegmentationSubMenuIt
|
|||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger className="pl-1">
|
<DropdownMenuSubTrigger className="pl-1">
|
||||||
<Icons.Export className="text-foreground" />
|
<Icons.Export className="text-foreground" />
|
||||||
<span className="pl-2">{t('Download & Export')}</span>
|
<span className="pl-2">{t('Export')}</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuLabel className="flex items-center pl-0">
|
|
||||||
<Icons.Download className="h-5 w-5" />
|
|
||||||
<span className="pl-1">{t('Download')}</span>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
|
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@ -56,31 +48,6 @@ export const ExportSegmentationSubMenuItem: React.FC<ExportSegmentationSubMenuIt
|
|||||||
{t('CSV Report')}
|
{t('CSV Report')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
actions.onSegmentationDownload(segmentationId);
|
|
||||||
}}
|
|
||||||
disabled={!allowExport}
|
|
||||||
>
|
|
||||||
{t('DICOM SEG')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
actions.onSegmentationDownloadRTSS(segmentationId);
|
|
||||||
}}
|
|
||||||
disabled={!allowExport}
|
|
||||||
>
|
|
||||||
{t('DICOM RTSS')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuLabel className="flex items-center pl-0">
|
|
||||||
<Icons.Export className="h-5 w-5" />
|
|
||||||
<span className="pl-1 pt-1">{t('Export')}</span>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
|
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|||||||
@ -64,12 +64,6 @@ export const CustomDropdownMenuContent = () => {
|
|||||||
context: 'CORNERSTONE',
|
context: 'CORNERSTONE',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSegmentationDownloadRTSS: segmentationId => {
|
|
||||||
commandsManager.run('downloadRTSS', { segmentationId });
|
|
||||||
},
|
|
||||||
onSegmentationDownload: segmentationId => {
|
|
||||||
commandsManager.run('downloadSegmentation', { segmentationId });
|
|
||||||
},
|
|
||||||
downloadCSVSegmentationReport: segmentationId => {
|
downloadCSVSegmentationReport: segmentationId => {
|
||||||
commandsManager.run('downloadCSVSegmentationReport', { segmentationId });
|
commandsManager.run('downloadCSVSegmentationReport', { segmentationId });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { DicomMetadataStore } from '@ohif/core';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} servicesManager
|
* @param {*} servicesManager
|
||||||
@ -7,7 +5,8 @@ import { DicomMetadataStore } from '@ohif/core';
|
|||||||
async function createReportAsync({
|
async function createReportAsync({
|
||||||
servicesManager,
|
servicesManager,
|
||||||
getReport,
|
getReport,
|
||||||
reportType = 'measurement',
|
reportType = 'Measurements',
|
||||||
|
successMessage,
|
||||||
}: withAppTypes) {
|
}: withAppTypes) {
|
||||||
const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services;
|
const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services;
|
||||||
|
|
||||||
@ -18,18 +17,15 @@ async function createReportAsync({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "Mode" route listens for DicomMetadataStore changes
|
// addInstances is called by the store command (storeMeasurements/storeSegmentation),
|
||||||
// When a new instance is added, it listens and
|
// so the display set should already exist at this point.
|
||||||
// automatically calls makeDisplaySets
|
|
||||||
DicomMetadataStore.addInstances([naturalizedReport], true);
|
|
||||||
|
|
||||||
const displaySet = displaySetService.getMostRecentDisplaySet();
|
const displaySet = displaySetService.getMostRecentDisplaySet();
|
||||||
|
|
||||||
const displaySetInstanceUID = displaySet.displaySetInstanceUID;
|
const displaySetInstanceUID = displaySet.displaySetInstanceUID;
|
||||||
|
|
||||||
uiNotificationService.show({
|
uiNotificationService.show({
|
||||||
title: 'Create Report',
|
title: 'Create Report',
|
||||||
message: `${reportType} saved successfully`,
|
message: successMessage ?? `${reportType} saved successfully`,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export default function CreateReportDialogPrompt({
|
|||||||
predecessorImageId,
|
predecessorImageId,
|
||||||
extensionManager,
|
extensionManager,
|
||||||
servicesManager,
|
servicesManager,
|
||||||
|
enableDownload = false,
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
value: string;
|
value: string;
|
||||||
dataSourceName: string;
|
dataSourceName: string;
|
||||||
@ -59,6 +60,7 @@ export default function CreateReportDialogPrompt({
|
|||||||
predecessorImageId,
|
predecessorImageId,
|
||||||
minSeriesNumber,
|
minSeriesNumber,
|
||||||
modality,
|
modality,
|
||||||
|
enableDownload,
|
||||||
onSave: async ({
|
onSave: async ({
|
||||||
reportName,
|
reportName,
|
||||||
dataSource: selectedDataSource,
|
dataSource: selectedDataSource,
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { Types, DicomMetadataStore } from '@ohif/core';
|
import { Types, DicomMetadataStore, utils } from '@ohif/core';
|
||||||
|
import dcmjs from 'dcmjs';
|
||||||
|
|
||||||
|
const { downloadBlob } = utils;
|
||||||
|
|
||||||
import { ContextMenuController } from './CustomizableContextMenu';
|
import { ContextMenuController } from './CustomizableContextMenu';
|
||||||
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
|
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
|
||||||
@ -768,6 +771,67 @@ const commandsModule = ({
|
|||||||
|
|
||||||
setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0);
|
setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a store function based on the data source type.
|
||||||
|
* @param dataSource - 'download', 'copyToClipboard', or a named data source
|
||||||
|
* @param defaultFileName - Default filename for download/clipboard
|
||||||
|
* @param defaultContentType - Default content type for clipboard
|
||||||
|
* @returns A store function, or null if no valid store exists
|
||||||
|
*/
|
||||||
|
createStoreFunction: ({ dataSource, defaultFileName, defaultContentType }) => {
|
||||||
|
if (dataSource === 'download') {
|
||||||
|
return async dicom => {
|
||||||
|
const instances = Array.isArray(dicom) ? dicom : [dicom];
|
||||||
|
DicomMetadataStore.addInstances(instances, true);
|
||||||
|
if (instances.length !== 1) {
|
||||||
|
throw new Error('Download only supports a single DICOM instance');
|
||||||
|
}
|
||||||
|
const reportBlob = dcmjs.data.datasetToBlob(instances[0]);
|
||||||
|
downloadBlob(reportBlob, { filename: defaultFileName || 'dicom.dcm' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataSource === 'copyToClipboard') {
|
||||||
|
return async dicom => {
|
||||||
|
const instances = Array.isArray(dicom) ? dicom : [dicom];
|
||||||
|
DicomMetadataStore.addInstances(instances, true);
|
||||||
|
if (instances.length !== 1) {
|
||||||
|
throw new Error('Copy to clipboard only supports a single DICOM instance');
|
||||||
|
}
|
||||||
|
const reportBlob = dcmjs.data.datasetToBlob(instances[0]);
|
||||||
|
const type = defaultContentType || 'application/dicom';
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ [type]: new Blob([reportBlob], { type }) }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// DICOM STOW path — resolve the named data source
|
||||||
|
const dataSources = extensionManager.getDataSources(dataSource);
|
||||||
|
const resolvedDataSource = dataSources?.[0];
|
||||||
|
if (!resolvedDataSource?.store?.dicom) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (dicom, { dicomDict } = {}) => {
|
||||||
|
const instances = Array.isArray(dicom) ? dicom : [dicom];
|
||||||
|
const config = resolvedDataSource.getConfig?.();
|
||||||
|
if (config?.wadoRoot) {
|
||||||
|
instances.forEach(instance => {
|
||||||
|
instance.wadoRoot = config.wadoRoot;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DicomMetadataStore.addInstances(instances, true);
|
||||||
|
for (const instance of instances) {
|
||||||
|
await resolvedDataSource.store.dicom(instance, null, dicomDict);
|
||||||
|
}
|
||||||
|
const studyUIDs = new Set(instances.map(i => i.StudyInstanceUID).filter(Boolean));
|
||||||
|
for (const uid of studyUIDs) {
|
||||||
|
resolvedDataSource.deleteStudyMetadataPromise(uid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const definitions = {
|
const definitions = {
|
||||||
@ -796,6 +860,7 @@ const commandsModule = ({
|
|||||||
scrollActiveThumbnailIntoView: actions.scrollActiveThumbnailIntoView,
|
scrollActiveThumbnailIntoView: actions.scrollActiveThumbnailIntoView,
|
||||||
addDisplaySetAsLayer: actions.addDisplaySetAsLayer,
|
addDisplaySetAsLayer: actions.addDisplaySetAsLayer,
|
||||||
removeDisplaySetLayer: actions.removeDisplaySetLayer,
|
removeDisplaySetLayer: actions.removeDisplaySetLayer,
|
||||||
|
createStoreFunction: actions.createStoreFunction,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { InputDialog } from '@ohif/ui-next';
|
import { InputDialog } from '@ohif/ui-next';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next';
|
||||||
import { useSystem } from '@ohif/core';
|
import { useSystem } from '@ohif/core';
|
||||||
@ -21,6 +22,7 @@ type ReportDialogProps = {
|
|||||||
priorSeriesNumber: number;
|
priorSeriesNumber: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
enableDownload?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ReportDialog({
|
function ReportDialog({
|
||||||
@ -31,8 +33,11 @@ function ReportDialog({
|
|||||||
hide,
|
hide,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
enableDownload = false,
|
||||||
}: ReportDialogProps) {
|
}: ReportDialogProps) {
|
||||||
|
const { t } = useTranslation('Buttons');
|
||||||
const { servicesManager } = useSystem();
|
const { servicesManager } = useSystem();
|
||||||
|
const actionTakenRef = useRef(false);
|
||||||
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(
|
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(
|
||||||
dataSources?.[0]?.value ?? null
|
dataSources?.[0]?.value ?? null
|
||||||
);
|
);
|
||||||
@ -72,6 +77,7 @@ function ReportDialog({
|
|||||||
}, [selectedSeries, seriesOptions]);
|
}, [selectedSeries, seriesOptions]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
|
actionTakenRef.current = true;
|
||||||
onSave({
|
onSave({
|
||||||
reportName,
|
reportName,
|
||||||
dataSource: selectedDataSource,
|
dataSource: selectedDataSource,
|
||||||
@ -82,11 +88,33 @@ function ReportDialog({
|
|||||||
}, [selectedDataSource, selectedSeries, reportName, hide, onSave]);
|
}, [selectedDataSource, selectedSeries, reportName, hide, onSave]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
actionTakenRef.current = true;
|
||||||
onCancel();
|
onCancel();
|
||||||
hide();
|
hide();
|
||||||
}, [onCancel, hide]);
|
}, [onCancel, hide]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
actionTakenRef.current = true;
|
||||||
|
onSave({
|
||||||
|
reportName,
|
||||||
|
dataSource: 'download',
|
||||||
|
priorSeriesNumber: Math.max(...seriesOptions.map(it => it.seriesNumber)),
|
||||||
|
series: selectedSeries,
|
||||||
|
});
|
||||||
|
hide();
|
||||||
|
}, [selectedDataSource, selectedSeries, reportName, hide, onSave]);
|
||||||
|
|
||||||
|
// Handles the close dialog button/external close as a cancel
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!actionTakenRef.current) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
const showDataSourceSelect = dataSources?.length > 1;
|
const showDataSourceSelect = dataSources?.length > 1;
|
||||||
|
const showDownloadButton = enableDownload;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-foreground flex min-w-[400px] max-w-md flex-col">
|
<div className="text-foreground flex min-w-[400px] max-w-md flex-col">
|
||||||
@ -181,9 +209,11 @@ function ReportDialog({
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<InputDialog>
|
<InputDialog>
|
||||||
<InputDialog.Actions>
|
<InputDialog.Actions>
|
||||||
<InputDialog.ActionsSecondary onClick={handleCancel}>
|
{showDownloadButton && (
|
||||||
Cancel
|
<InputDialog.ActionsSecondary onClick={handleDownload}>
|
||||||
</InputDialog.ActionsSecondary>
|
{t('Download')}
|
||||||
|
</InputDialog.ActionsSecondary>
|
||||||
|
)}
|
||||||
<InputDialog.ActionsPrimary onClick={handleSave}>Save</InputDialog.ActionsPrimary>
|
<InputDialog.ActionsPrimary onClick={handleSave}>Save</InputDialog.ActionsPrimary>
|
||||||
</InputDialog.Actions>
|
</InputDialog.Actions>
|
||||||
</InputDialog>
|
</InputDialog>
|
||||||
|
|||||||
@ -35,21 +35,19 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
|
|||||||
minSeriesNumber: 3000,
|
minSeriesNumber: 3000,
|
||||||
extensionManager,
|
extensionManager,
|
||||||
servicesManager,
|
servicesManager,
|
||||||
|
enableDownload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) {
|
if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) {
|
||||||
const dataSources = extensionManager.getDataSources(promptResult.dataSourceName);
|
const { series, priorSeriesNumber, value: reportName, dataSourceName } = promptResult;
|
||||||
const dataSource = dataSources[0];
|
|
||||||
|
|
||||||
const { series, priorSeriesNumber, value: reportName } = promptResult;
|
|
||||||
const SeriesDescription = reportName || defaultSaveTitle;
|
const SeriesDescription = reportName || defaultSaveTitle;
|
||||||
|
|
||||||
const getReport = async () => {
|
const getReport = async () =>
|
||||||
return commandsManager.runCommand(
|
commandsManager.runCommand(
|
||||||
'storeMeasurements',
|
'storeMeasurements',
|
||||||
{
|
{
|
||||||
measurementData,
|
measurementData,
|
||||||
dataSource,
|
dataSource: dataSourceName,
|
||||||
additionalFindingTypes: ['ArrowAnnotate'],
|
additionalFindingTypes: ['ArrowAnnotate'],
|
||||||
options: {
|
options: {
|
||||||
SeriesDescription,
|
SeriesDescription,
|
||||||
@ -59,12 +57,12 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
|
|||||||
},
|
},
|
||||||
'CORNERSTONE_STRUCTURED_REPORT'
|
'CORNERSTONE_STRUCTURED_REPORT'
|
||||||
);
|
);
|
||||||
};
|
|
||||||
displaySetInstanceUIDs = await createReportAsync({
|
displaySetInstanceUIDs = await createReportAsync({
|
||||||
servicesManager,
|
servicesManager,
|
||||||
getReport,
|
getReport,
|
||||||
});
|
});
|
||||||
} else if (promptResult.action === RESPONSE.CANCEL) {
|
} else if (promptResult.action === PROMPT_RESPONSES.CANCEL) {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -137,8 +137,9 @@ function TrackedMeasurementsContextProvider(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => {
|
showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => {
|
||||||
if (evt.data.createdDisplaySetInstanceUIDs.length > 0) {
|
const uids = evt.data?.createdDisplaySetInstanceUIDs;
|
||||||
const StructuredReportDisplaySetInstanceUID = evt.data.createdDisplaySetInstanceUIDs[0];
|
if (uids?.length > 0) {
|
||||||
|
const StructuredReportDisplaySetInstanceUID = uids[0];
|
||||||
|
|
||||||
viewportGridService.setDisplaySetsForViewport({
|
viewportGridService.setDisplaySetsForViewport({
|
||||||
viewportId: evt.data.viewportId,
|
viewportId: evt.data.viewportId,
|
||||||
|
|||||||
2
testdata
2
testdata
@ -1 +1 @@
|
|||||||
Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45
|
Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d
|
||||||
Loading…
Reference in New Issue
Block a user