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 { 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
* that you wish to serialize.
@ -17,7 +26,7 @@ const { log } = OHIF;
* @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(
measurementData,
additionalFindingTypes
@ -37,6 +46,9 @@ const _generateReport = (measurementData, additionalFindingTypes, options = {})
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
dataset.InstanceNumber = options.InstanceNumber ?? 1;
return dataset;
};

View File

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

View File

@ -7,6 +7,7 @@ export default function CreateReportDialogPrompt({
}): Promise<{
value: string;
dataSourceName: string;
series: string;
action: (typeof PROMPT_RESPONSES)[keyof typeof PROMPT_RESPONSES];
}> {
const { uiDialogService, customizationService } = servicesManager.services;
@ -22,10 +23,11 @@ export default function CreateReportDialogPrompt({
content: ReportDialog,
contentProps: {
dataSources: allowMultipleDataSources ? dataSources : undefined,
onSave: async ({ reportName, dataSource: selectedDataSource }) => {
onSave: async ({ reportName, dataSource: selectedDataSource, series }) => {
resolve({
value: reportName,
dataSourceName: selectedDataSource,
series,
action: PROMPT_RESPONSES.CREATE_REPORT,
});
},
@ -33,6 +35,7 @@ export default function CreateReportDialogPrompt({
resolve({
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
series: 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
type DataSource = {
value: string;
@ -11,21 +12,53 @@ type DataSource = {
type ReportDialogProps = {
dataSources: DataSource[];
hide: () => void;
onSave: (data: { reportName: string; dataSource: string | null }) => void;
onSave: (data: { reportName: string; dataSource: string | null; series: string | null }) => void;
onCancel: () => void;
};
function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps) {
const { servicesManager } = useSystem();
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(
dataSources?.[0]?.value ?? null
);
const [selectedSeries, setSelectedSeries] = useState<string | null>(null);
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 = () => {
onSave({
reportName,
dataSource: selectedDataSource,
series: selectedSeries,
});
hide();
};
@ -40,9 +73,10 @@ function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps
return (
<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={showDataSourceSelect ? 'flex gap-4' : ''}>
<div className="flex gap-4">
{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
value={selectedDataSource}
onValueChange={setSelectedDataSource}
@ -63,14 +97,40 @@ function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps
</Select>
</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
value={reportName}
onChange={setReportName}
submitOnEnter
>
<InputDialog.Field>
<InputDialog.Input placeholder="Report name" />
<InputDialog.Input
placeholder="Report name"
disabled={!!selectedSeries}
/>
</InputDialog.Field>
</InputDialog>
</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 { createReportDialogPrompt } from '../Panels';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
import { getSRSeriesAndInstanceNumber } from './getSRSeriesAndInstanceNumber';
import { getSeriesDateTime } from './getCurrentDicomDateTime';
const {
filterAnd,
@ -27,7 +28,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
filterMeasurementsBySeriesUID(trackedSeries),
filterPlanarMeasurement
),
defaultSaveTitle = 'Research Derived Series',
defaultSaveTitle = 'Create Report',
} = ctx;
let displaySetInstanceUIDs;
@ -45,7 +46,12 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
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 () => {
return commandsManager.runCommand(
@ -57,6 +63,10 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
options: {
SeriesDescription,
SeriesNumber,
InstanceNumber,
SeriesInstanceUID: promptResult.series,
SeriesDate,
SeriesTime,
},
},
'CORNERSTONE_STRUCTURED_REPORT'