feat: allow saving to same SR series (#4889)

This commit is contained in:
Pedro Köhler 2025-04-07 11:05:22 -03:00 committed by GitHub
parent 7b40963997
commit 62e5a62066
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 167 additions and 63 deletions

View File

@ -10,6 +10,15 @@ import hydrateStructuredReport from './utils/hydrateStructuredReport';
const { MeasurementReport } = adaptersSR.Cornerstone3D; const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF; const { log } = OHIF;
interface Options {
SeriesDescription?: string;
SeriesInstanceUID?: string;
SeriesNumber?: number;
InstanceNumber?: number;
SeriesDate?: string;
SeriesTime?: string;
}
/** /**
* @param measurementData An array of measurements from the measurements service * @param measurementData An array of measurements from the measurements service
* that you wish to serialize. * that you wish to serialize.
@ -17,7 +26,7 @@ const { log } = OHIF;
* @param options Naturalized DICOM JSON headers to merge into the displaySet. * @param options Naturalized DICOM JSON headers to merge into the displaySet.
* *
*/ */
const _generateReport = (measurementData, additionalFindingTypes, options = {}) => { const _generateReport = (measurementData, additionalFindingTypes, options: Options = {}) => {
const filteredToolState = getFilteredCornerstoneToolState( const filteredToolState = getFilteredCornerstoneToolState(
measurementData, measurementData,
additionalFindingTypes additionalFindingTypes
@ -37,6 +46,9 @@ const _generateReport = (measurementData, additionalFindingTypes, options = {})
if (typeof dataset.SpecificCharacterSet === 'undefined') { if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192'; dataset.SpecificCharacterSet = 'ISO_IR 192';
} }
dataset.InstanceNumber = options.InstanceNumber ?? 1;
return dataset; return dataset;
}; };

View File

@ -90,6 +90,7 @@ function _getDisplaySetsFromSeries(
SeriesDescription, SeriesDescription,
SeriesNumber, SeriesNumber,
SeriesDate, SeriesDate,
SeriesTime,
ConceptNameCodeSequence, ConceptNameCodeSequence,
SOPClassUID, SOPClassUID,
} = instance; } = instance;
@ -106,6 +107,7 @@ function _getDisplaySetsFromSeries(
SeriesDescription, SeriesDescription,
SeriesNumber, SeriesNumber,
SeriesDate, SeriesDate,
SeriesTime,
SOPInstanceUID, SOPInstanceUID,
SeriesInstanceUID, SeriesInstanceUID,
StudyInstanceUID, StudyInstanceUID,

View File

@ -7,6 +7,7 @@ export default function CreateReportDialogPrompt({
}): Promise<{ }): Promise<{
value: string; value: string;
dataSourceName: string; dataSourceName: string;
series: string;
action: (typeof PROMPT_RESPONSES)[keyof typeof PROMPT_RESPONSES]; action: (typeof PROMPT_RESPONSES)[keyof typeof PROMPT_RESPONSES];
}> { }> {
const { uiDialogService, customizationService } = servicesManager.services; const { uiDialogService, customizationService } = servicesManager.services;
@ -22,10 +23,11 @@ export default function CreateReportDialogPrompt({
content: ReportDialog, content: ReportDialog,
contentProps: { contentProps: {
dataSources: allowMultipleDataSources ? dataSources : undefined, dataSources: allowMultipleDataSources ? dataSources : undefined,
onSave: async ({ reportName, dataSource: selectedDataSource }) => { onSave: async ({ reportName, dataSource: selectedDataSource, series }) => {
resolve({ resolve({
value: reportName, value: reportName,
dataSourceName: selectedDataSource, dataSourceName: selectedDataSource,
series,
action: PROMPT_RESPONSES.CREATE_REPORT, action: PROMPT_RESPONSES.CREATE_REPORT,
}); });
}, },
@ -33,6 +35,7 @@ export default function CreateReportDialogPrompt({
resolve({ resolve({
action: PROMPT_RESPONSES.CANCEL, action: PROMPT_RESPONSES.CANCEL,
value: undefined, value: undefined,
series: undefined,
dataSourceName: undefined, dataSourceName: undefined,
}); });
}, },

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
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';
type DataSource = { type DataSource = {
value: string; value: string;
@ -11,21 +12,53 @@ type DataSource = {
type ReportDialogProps = { type ReportDialogProps = {
dataSources: DataSource[]; dataSources: DataSource[];
hide: () => void; hide: () => void;
onSave: (data: { reportName: string; dataSource: string | null }) => void; onSave: (data: { reportName: string; dataSource: string | null; series: string | null }) => void;
onCancel: () => void; onCancel: () => void;
}; };
function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps) { function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps) {
const { servicesManager } = useSystem();
const [selectedDataSource, setSelectedDataSource] = useState<string | null>( const [selectedDataSource, setSelectedDataSource] = useState<string | null>(
dataSources?.[0]?.value ?? null dataSources?.[0]?.value ?? null
); );
const [selectedSeries, setSelectedSeries] = useState<string | null>(null);
const [reportName, setReportName] = useState(''); const [reportName, setReportName] = useState('');
const { displaySetService } = servicesManager.services;
const seriesOptions = useMemo(() => {
const displaySetsMap = displaySetService.getDisplaySetCache();
const displaySets = Array.from(displaySetsMap.values());
const options = displaySets
.filter(ds => ds.Modality === 'SR')
.map(ds => ({
value: ds.SeriesInstanceUID,
description: ds.SeriesDescription,
label: `${ds.SeriesDescription} ${ds.SeriesDate}/${ds.SeriesTime} ${ds.SeriesNumber}`,
}));
return [
{
value: null,
description: null,
label: 'Create new series',
},
...options,
];
}, [displaySetService]);
useEffect(() => {
const seriesOption = seriesOptions.find(s => s.value === selectedSeries);
const newReportName =
selectedSeries && seriesOption?.description ? seriesOption.description : '';
setReportName(newReportName);
}, [selectedSeries, seriesOptions]);
const handleSave = () => { const handleSave = () => {
onSave({ onSave({
reportName, reportName,
dataSource: selectedDataSource, dataSource: selectedDataSource,
series: selectedSeries,
}); });
hide(); hide();
}; };
@ -40,9 +73,10 @@ function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps
return ( return (
<div className="text-foreground mt-2 flex min-w-[400px] max-w-md flex-col gap-4"> <div className="text-foreground mt-2 flex min-w-[400px] max-w-md flex-col gap-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className={showDataSourceSelect ? 'flex gap-4' : ''}> <div className="flex gap-4">
{showDataSourceSelect && ( {showDataSourceSelect && (
<div className="mt-1 w-1/3"> <div className="mt-1 w-1/2">
<div className="mb-1 pl-1 font-light">Data source</div>
<Select <Select
value={selectedDataSource} value={selectedDataSource}
onValueChange={setSelectedDataSource} onValueChange={setSelectedDataSource}
@ -63,14 +97,40 @@ function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps
</Select> </Select>
</div> </div>
)} )}
<div className={showDataSourceSelect ? 'mt-1 w-2/3' : 'w-full'}> <div className={showDataSourceSelect ? 'mt-1 w-1/2' : 'mt-1 w-full'}>
<div className="mb-1 pl-1 font-light">Series</div>
<Select
value={selectedSeries}
onValueChange={setSelectedSeries}
>
<SelectTrigger>
<SelectValue placeholder="Select a series" />
</SelectTrigger>
<SelectContent>
{seriesOptions.map(series => (
<SelectItem
key={series.value}
value={series.value}
>
{series.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className={showDataSourceSelect ? 'flex gap-4' : ''}>
<div className="mt-4 w-full">
<InputDialog <InputDialog
value={reportName} value={reportName}
onChange={setReportName} onChange={setReportName}
submitOnEnter submitOnEnter
> >
<InputDialog.Field> <InputDialog.Field>
<InputDialog.Input placeholder="Report name" /> <InputDialog.Input
placeholder="Report name"
disabled={!!selectedSeries}
/>
</InputDialog.Field> </InputDialog.Field>
</InputDialog> </InputDialog>
</div> </div>

View File

@ -1,41 +0,0 @@
import { DisplaySetService, Types } from '@ohif/core';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
/**
* Find an SR having the same series description.
* This is used by the store service in order to store DICOM SR's having the
* same Series Description into a single series under consecutive instance numbers
* That way, they are all organized as a set and could have tools to view
* "prior" SR instances.
*
* @param SeriesDescription - is the description to look for
* @param displaySetService - the display sets to search for DICOM SR in
* @returns SeriesMetadata from a DICOM SR having the same series description
*/
export default function findSRWithSameSeriesDescription(
SeriesDescription: string,
displaySetService: DisplaySetService
): Types.SeriesMetadata {
const activeDisplaySets = displaySetService.getActiveDisplaySets();
const srDisplaySets = activeDisplaySets.filter(ds => ds.Modality === 'SR');
const sameSeries = srDisplaySets.find(ds => ds.SeriesDescription === SeriesDescription);
if (sameSeries) {
console.log('Storing to same series', sameSeries);
const { instance } = sameSeries;
const { SeriesInstanceUID, SeriesDescription, SeriesDate, SeriesTime, SeriesNumber, Modality } =
instance;
return {
SeriesInstanceUID,
SeriesDescription,
SeriesDate,
SeriesTime,
SeriesNumber,
Modality,
InstanceNumber: sameSeries.instances.length + 1,
};
}
const SeriesNumber = getNextSRSeriesNumber(displaySetService);
return { SeriesDescription, SeriesNumber };
}

View File

@ -0,0 +1,20 @@
export const getSeriesDateTime = (jsDate: Date = new Date()) => {
const dicomDateTime = getDicomDateTime(jsDate);
return {
SeriesDate: dicomDateTime.date,
SeriesTime: dicomDateTime.time,
};
};
export const getDicomDateTime = (jsDate: Date = new Date()) => {
const month = String(jsDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(jsDate.getUTCDate()).padStart(2, '0');
const year = String(jsDate.getUTCFullYear()).padStart(4, '0');
const date = `${year}${month}${day}`;
const hours = String(jsDate.getUTCHours()).padStart(2, '0');
const minutes = String(jsDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(jsDate.getUTCSeconds()).padStart(2, '0');
const time = `${hours}${minutes}${seconds}`;
return { date, time };
};

View File

@ -1,10 +0,0 @@
const MIN_SR_SERIES_NUMBER = 4700;
export default function getNextSRSeriesNumber(displaySetService) {
const activeDisplaySets = displaySetService.getActiveDisplaySets();
const srDisplaySets = activeDisplaySets.filter(ds => ds.Modality === 'SR');
const srSeriesNumbers = srDisplaySets.map(ds => ds.SeriesNumber);
const maxSeriesNumber = Math.max(...srSeriesNumbers, MIN_SR_SERIES_NUMBER);
return maxSeriesNumber + 1;
}

View File

@ -0,0 +1,48 @@
const MIN_SR_SERIES_NUMBER = 4700;
export function getNextSeriesNumber({ displaySetService, modality, minSeriesNumber }) {
const activeDisplaySets = displaySetService.getActiveDisplaySets();
const modalityDisplaySets = activeDisplaySets.filter(ds => ds.Modality === modality);
const modalitySeriesNumbers = modalityDisplaySets.map(ds => ds.SeriesNumber);
const maxSeriesNumber = Math.max(...modalitySeriesNumbers, minSeriesNumber);
const allSeriesNumbers = activeDisplaySets.map(ds => ds.SeriesNumber);
let finalSeriesNumber = maxSeriesNumber + 1;
while (allSeriesNumbers.includes(finalSeriesNumber)) {
finalSeriesNumber++;
}
return { SeriesNumber: finalSeriesNumber, InstanceNumber: 1 };
}
export function getSRSeriesAndInstanceNumber({ displaySetService, SeriesInstanceUid }) {
if (!SeriesInstanceUid) {
return getNextSeriesNumber({
displaySetService,
modality: 'SR',
minSeriesNumber: MIN_SR_SERIES_NUMBER,
});
}
const displaySetsMap = displaySetService.getDisplaySetCache();
const displaySets = Array.from(displaySetsMap.values());
const srDisplaySet = displaySets.find(
ds => ds.Modality === 'SR' && ds.SeriesInstanceUID === SeriesInstanceUid
);
const InstanceNumber = srDisplaySet.instances?.length + 1;
if (!srDisplaySet?.SeriesNumber || !InstanceNumber) {
return getNextSeriesNumber({
displaySetService,
modality: 'SR',
minSeriesNumber: MIN_SR_SERIES_NUMBER,
});
}
return {
SeriesNumber: srDisplaySet.SeriesNumber,
InstanceNumber,
referenceDisplaySet: srDisplaySet,
};
}

View File

@ -2,8 +2,9 @@ import { utils } from '@ohif/core';
import createReportAsync from '../Actions/createReportAsync'; import createReportAsync from '../Actions/createReportAsync';
import { createReportDialogPrompt } from '../Panels'; import { createReportDialogPrompt } from '../Panels';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES'; import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
import { getSRSeriesAndInstanceNumber } from './getSRSeriesAndInstanceNumber';
import { getSeriesDateTime } from './getCurrentDicomDateTime';
const { const {
filterAnd, filterAnd,
@ -27,7 +28,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
filterMeasurementsBySeriesUID(trackedSeries), filterMeasurementsBySeriesUID(trackedSeries),
filterPlanarMeasurement filterPlanarMeasurement
), ),
defaultSaveTitle = 'Research Derived Series', defaultSaveTitle = 'Create Report',
} = ctx; } = ctx;
let displaySetInstanceUIDs; let displaySetInstanceUIDs;
@ -45,7 +46,12 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
const SeriesDescription = promptResult.value || defaultSaveTitle; const SeriesDescription = promptResult.value || defaultSaveTitle;
const SeriesNumber = getNextSRSeriesNumber(displaySetService); const { SeriesNumber, InstanceNumber, referenceDisplaySet } = getSRSeriesAndInstanceNumber({
displaySetService,
SeriesInstanceUid: promptResult.series,
});
const { SeriesDate, SeriesTime } = referenceDisplaySet ?? getSeriesDateTime();
const getReport = async () => { const getReport = async () => {
return commandsManager.runCommand( return commandsManager.runCommand(
@ -57,6 +63,10 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
options: { options: {
SeriesDescription, SeriesDescription,
SeriesNumber, SeriesNumber,
InstanceNumber,
SeriesInstanceUID: promptResult.series,
SeriesDate,
SeriesTime,
}, },
}, },
'CORNERSTONE_STRUCTURED_REPORT' 'CORNERSTONE_STRUCTURED_REPORT'