Fix contour annotations RTSS saving (#5545)

* [WIP] Fix contour annotations RTSS saving

* fix: Export image sop image reference provider

* Fixes for download with filename

* Updates to fix metadata issues with CS3D

* Updates to save menu

* Fixing some additional re-save metadata

* Add support for saving with predecessor image sequence

* PR comment fixes

* fix: Load RTSS after save

* fix segmentation save

* fix load of mixed seg/rt studies

* Use frame module to get information on the frame shown

* Fix study browser to use instances.length when numImageFrames not present

* fix: Load of display set split from multiple non-FOR referenes

* Fix RT and SEG applying to wrong series because of using
referenced series sequence without checkout sop instances

* Fix save seg

* Update to released CS3D versions

* test: Add revokeObjectURL to test download blob

* Add hard dependency on mode-basic to fix build order issues

* fix: build

* Try to fix build segmentation

* PR comments

* PR comment update
This commit is contained in:
Bill Wallace authored and GitHub committed 2025-11-21 13:07:26 -05:00
1 parent 9c5043ca2c
commit 57205703bb
68 files changed
+962 -648

No files matched your search

+2 -2
View File
@@ -53,7 +53,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/dicom-image-loader": "4.11.2",
"@cornerstonejs/dicom-image-loader": "4.11.3",
"@emotion/serialize": "1.3.3",
"@ohif/core": "3.12.0-beta.90",
"@ohif/extension-cornerstone": "3.12.0-beta.90",
@@ -79,7 +79,7 @@
"classnames": "2.5.1",
"core-js": "3.45.1",
"cornerstone-math": "0.1.10",
"dcmjs": "0.43.1",
"dcmjs": "0.45.0",
"detect-gpu": "4.0.50",
"dicom-parser": "1.8.21",
"dotenv-webpack": "1.8.0",
+23
View File
@@ -126,6 +126,29 @@ window.config = {
},
},
},
{
friendlyName: 'StaticWado default data',
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'dicomweb',
configuration: {
name: 'DCM4CHEE',
wadoUriRoot: '/dicomweb',
qidoRoot: '/dicomweb',
wadoRoot: '/dicomweb',
qidoSupportsIncludeField: false,
supportsReject: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
staticWado: true,
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
},
},
},
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy',
+3 -3
View File
@@ -37,15 +37,15 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/core": "4.11.2",
"@cornerstonejs/dicom-image-loader": "4.11.2",
"@cornerstonejs/core": "4.11.3",
"@cornerstonejs/dicom-image-loader": "4.11.3",
"@ohif/ui": "3.12.0-beta.90",
"cornerstone-math": "0.1.9",
"dicom-parser": "1.8.21"
},
"dependencies": {
"@babel/runtime": "7.28.2",
"dcmjs": "0.43.1",
"dcmjs": "0.45.0",
"dicomweb-client": "0.10.4",
"gl-matrix": "3.4.3",
"immutability-helper": "3.1.1",
+62 -49
View File
@@ -6,7 +6,7 @@ import DicomMetadataStore from '../services/DicomMetadataStore';
import fetchPaletteColorLookupTableData from '../utils/metadataProvider/fetchPaletteColorLookupTableData';
import toNumber from '../utils/toNumber';
import combineFrameInstance from '../utils/combineFrameInstance';
import formatPN from '../utils/formatPN';
const { calibratedPixelSpacingMetadataProvider, getPixelSpacingInformation } = utilities;
class MetadataProvider {
@@ -123,33 +123,21 @@ class MetadataProvider {
switch (wadoImageLoaderTag) {
case WADO_IMAGE_LOADER_TAGS.GENERAL_SERIES_MODULE:
const { SeriesDate, SeriesTime } = instance;
let seriesDate;
let seriesTime;
if (SeriesDate) {
seriesDate = dicomParser.parseDA(SeriesDate);
}
if (SeriesTime) {
seriesTime = dicomParser.parseTM(SeriesTime);
}
metadata = {
modality: instance.Modality,
seriesInstanceUID: instance.SeriesInstanceUID,
seriesNumber: toNumber(instance.SeriesNumber),
studyInstanceUID: instance.StudyInstanceUID,
seriesDate,
seriesTime,
seriesDescription: instance.SeriesDescription,
seriesDate: instance.SeriesDate,
seriesTime: instance.SeriesTime,
};
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE:
metadata = {
patientAge: toNumber(instance.PatientAge),
patientSize: toNumber(instance.PatientSize),
patientWeight: toNumber(instance.PatientWeight),
patientAge: instance.PatientAge,
patientSize: instance.PatientSize,
patientWeight: instance.PatientWeight,
};
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_DEMOGRAPHIC_MODULE:
@@ -346,16 +334,13 @@ class MetadataProvider {
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_MODULE:
const { PatientName } = instance;
let patientName;
if (PatientName) {
patientName = formatPN(PatientName);
}
metadata = {
patientName,
patientName: instance.PatientName,
patientId: instance.PatientID,
patientSex: instance.PatientSex,
patientBirthDate: instance.PatientBirthDate,
issuerOfPatientId: instance.IssuerOfPatientID,
otherPatientIDsSequence: instance.OtherPatientIDsSequence,
};
break;
@@ -373,9 +358,11 @@ class MetadataProvider {
case WADO_IMAGE_LOADER_TAGS.GENERAL_STUDY_MODULE:
metadata = {
studyDescription: instance.StudyDescription,
studyInstanceUID: instance.StudyInstanceUID,
studyDate: instance.StudyDate,
studyTime: instance.StudyTime,
accessionNumber: instance.AccessionNumber,
studyId: instance.StudyID,
};
break;
@@ -512,9 +499,57 @@ class MetadataProvider {
const metadataProvider = new MetadataProvider();
DicomMetadataStore.setMetaDataProvider(metadataProvider);
export default metadataProvider;
const WADO_IMAGE_LOADER_TAGS = {
// dicomImageLoader specific
GENERAL_SERIES_MODULE: 'generalSeriesModule',
PATIENT_STUDY_MODULE: 'patientStudyModule',
IMAGE_PIXEL_MODULE: 'imagePixelModule',
VOI_LUT_MODULE: 'voiLutModule',
MODALITY_LUT_MODULE: 'modalityLutModule',
SOP_COMMON_MODULE: 'sopCommonModule',
PET_IMAGE_MODULE: 'petImageModule',
PET_ISOTOPE_MODULE: 'petIsotopeModule',
PET_SERIES_MODULE: 'petSeriesModule',
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
PATIENT_DEMOGRAPHIC_MODULE: 'patientDemographicModule',
// react-cornerstone-viewport specific
PATIENT_MODULE: 'patientModule',
GENERAL_IMAGE_MODULE: 'generalImageModule',
GENERAL_STUDY_MODULE: 'generalStudyModule',
CINE_MODULE: 'cineModule',
CALIBRATION_MODULE: 'calibrationModule',
// Computed tags for new data
// Note these get returned in naturalized format
IMAGE_SOP_INSTANCE_REFERENCE: 'ImageSopInstanceReference',
};
const WADO_IMAGE_LOADER = {
/** Returns information on the current frame reference */
frameModule: instance => {
const {
frameNumber = 1,
numberOfFrames = 1,
SOPClassUID: sopClassUID,
SOPInstanceUID: sopInstanceUID,
SeriesInstanceUID: seriesInstanceUID,
StudyInstanceUID: studyInstanceUID,
} = instance;
return {
frameNumber,
numberOfFrames,
sopClassUID,
sopInstanceUID,
seriesInstanceUID,
studyInstanceUID,
};
},
imagePlaneModule: instance => {
const { ImageOrientationPatient, ImagePositionPatient } = instance;
@@ -590,26 +625,4 @@ const WADO_IMAGE_LOADER = {
},
};
const WADO_IMAGE_LOADER_TAGS = {
// dicomImageLoader specific
GENERAL_SERIES_MODULE: 'generalSeriesModule',
PATIENT_STUDY_MODULE: 'patientStudyModule',
IMAGE_PIXEL_MODULE: 'imagePixelModule',
VOI_LUT_MODULE: 'voiLutModule',
MODALITY_LUT_MODULE: 'modalityLutModule',
SOP_COMMON_MODULE: 'sopCommonModule',
PET_IMAGE_MODULE: 'petImageModule',
PET_ISOTOPE_MODULE: 'petIsotopeModule',
PET_SERIES_MODULE: 'petSeriesModule',
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
PATIENT_DEMOGRAPHIC_MODULE: 'patientDemographicModule',
// react-cornerstone-viewport specific
PATIENT_MODULE: 'patientModule',
GENERAL_IMAGE_MODULE: 'generalImageModule',
GENERAL_STUDY_MODULE: 'generalStudyModule',
CINE_MODULE: 'cineModule',
CALIBRATION_MODULE: 'calibrationModule',
};
const INSTANCE = 'instance';
@@ -50,6 +50,12 @@ const _model = {
studies: [],
};
let metaDataProvider;
function _setMetaDataProvider(metaData) {
metaDataProvider = metaData;
}
function _getStudyInstanceUIDs() {
return _model.studies.map(aStudy => aStudy.StudyInstanceUID);
}
@@ -102,7 +108,17 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
return series.getInstance(SOPInstanceUID);
}
/**
* Gets the frame module from the OHIF metadata provider, and then
* uses the study/series/instance uids to get the instance data.
*/
function _getInstanceByImageId(imageId) {
const metadataResult = metaDataProvider?.get('frameModule', imageId);
if (metadataResult) {
const { studyInstanceUID, seriesInstanceUID, sopInstanceUID } = metadataResult;
return _getInstance(studyInstanceUID, seriesInstanceUID, sopInstanceUID);
}
console.warn('No metadata result found for', imageId, 'looking through instances');
for (const study of _model.studies) {
for (const series of study.series) {
for (const instance of series.instances) {
@@ -186,7 +202,8 @@ const BaseImplementation = {
study = _model.studies[_model.studies.length - 1];
}
naturalizedDataset = numberOfFrames > 1 ? addProxyFields(naturalizedDataset) : naturalizedDataset;
naturalizedDataset =
numberOfFrames > 1 ? addProxyFields(naturalizedDataset) : naturalizedDataset;
study.addInstanceToSeries(naturalizedDataset);
},
@@ -289,6 +306,7 @@ const BaseImplementation = {
getInstance: _getInstance,
getInstanceByImageId: _getInstanceByImageId,
updateMetadataForSeries: _updateMetadataForSeries,
setMetaDataProvider: _setMetaDataProvider,
};
const DicomMetadataStore = Object.assign(
// get study
@@ -1,5 +1,5 @@
import { ExtensionManager } from '../../extensions';
import { DisplaySet, InstanceMetadata } from '../../types';
import { DisplaySet, InstanceMetadata, ReferencedSeriesSequence } from '../../types';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import EVENTS from './EVENTS';
@@ -115,16 +115,52 @@ export default class DisplaySetService extends PubSubService {
return this.activeDisplaySets;
}
/**
* Gets the set of display sets with this series instance UID
*
* <b>WARNING: Do not use this method when you have a referenced series sequence
* as this method does NOT check sop instances. Instead, use getDisplaySetsForReference
* to get those with the correct sop instances in them.</b>
*/
public getDisplaySetsForSeries = (seriesInstanceUID: string): DisplaySet[] => {
return [...displaySetCache.values()].filter(
displaySet => displaySet.SeriesInstanceUID === seriesInstanceUID
);
};
/**
* Given a reference to a series/sop, returns the set of display sets
* containing an instance from the references.
*/
public getDisplaySetsForReferences = (
references: ReferencedSeriesSequence | ReferencedSeriesSequence[]
): DisplaySet[] => {
const mapSeriesReferences = new Map<string, Set<string>>();
const referenceArr = Array.isArray(references) ? references : [references];
for (const seriesRef of referenceArr) {
const { SeriesInstanceUID, ReferencedInstanceSequence } = seriesRef;
if (!mapSeriesReferences.has(SeriesInstanceUID)) {
mapSeriesReferences.set(SeriesInstanceUID, new Set<string>());
}
const sops = mapSeriesReferences.get(SeriesInstanceUID);
for (const sopReference of ReferencedInstanceSequence) {
sops.add(sopReference.ReferencedSOPInstanceUID);
}
}
return [...displaySetCache.values()].filter(displaySet => {
const sopReferences = mapSeriesReferences.get(displaySet.SeriesInstanceUID);
if (!sopReferences || !displaySet.instances) {
return;
}
return displaySet.instances.some(instance => sopReferences.has(instance.SOPInstanceUID));
});
};
public getDisplaySetForSOPInstanceUID(
sopInstanceUID: string,
seriesInstanceUID: string,
frameNumber?: number
_frameNumber?: number
): DisplaySet {
const displaySets = seriesInstanceUID
? this.getDisplaySetsForSeries(seriesInstanceUID)
@@ -434,10 +434,13 @@ class MeasurementService extends PubSubService {
const internalUID = data.uid || guid();
const annotationData = data.annotation.data;
const {
annotation: { predecessorImageId, data: annotationData },
} = data;
const newMeasurement = {
finding: annotationData.finding,
predecessorImageId,
findingSites: annotationData.findingSites,
site: annotationData.findingSites?.[0],
...measurement,
@@ -643,8 +646,12 @@ class MeasurementService extends PubSubService {
if (referencedImageId) {
measurement.referencedImageId = referencedImageId;
const instance = DicomMetadataStore.getInstanceByImageId(referencedImageId);
measurement.referenceStudyUID = instance.StudyInstanceUID;
measurement.referenceSeriesUID = instance.SeriesInstanceUID;
if (!instance) {
console.warn("Didn't find instance for", referencedImageId);
} else {
measurement.referenceStudyUID = instance.StudyInstanceUID;
measurement.referenceSeriesUID = instance.SeriesInstanceUID;
}
}
if (displaySetInstanceUID) {
@@ -683,8 +690,7 @@ class MeasurementService extends PubSubService {
removeMany(measurementUIDs: string[]): void {
const measurements = [];
for (const measurementUID of measurementUIDs) {
const measurement =
this.measurements.get(measurementUID)
const measurement = this.measurements.get(measurementUID);
if (!measurementUID || !measurement) {
console.debug(`No uid provided, or unable to find measurement by uid.`);
@@ -727,8 +733,7 @@ class MeasurementService extends PubSubService {
*/
public jumpToMeasurement(viewportId: string, measurementUID: string): void {
const measurement =
this.measurements.get(measurementUID)
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
log.warn(`No measurement uid, or unable to find by uid.`);
@@ -850,8 +855,7 @@ class MeasurementService extends PubSubService {
};
public toggleLockMeasurement(measurementUID: string): void {
const measurement =
this.measurements.get(measurementUID)
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
console.debug(`No measurement found for uid: ${measurementUID}`);
@@ -868,8 +872,7 @@ class MeasurementService extends PubSubService {
}
public toggleVisibilityMeasurement(measurementUID: string, visibility?: boolean): void {
const measurement =
this.measurements.get(measurementUID)
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
console.debug(`No measurement found for uid: ${measurementUID}`);
@@ -893,8 +896,7 @@ class MeasurementService extends PubSubService {
}
public updateColorMeasurement(measurementUID: string, color: number[]): void {
const measurement =
this.measurements.get(measurementUID)
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
console.debug(`No measurement found for uid: ${measurementUID}`);
+27
View File
@@ -1,5 +1,15 @@
import { InstanceMetadata } from './StudyMetadata';
export type ReferencedInstance = {
ReferencedSOPClassUID: string;
ReferencedSOPInstanceUID: string;
};
export type ReferencedSeriesSequence = {
SeriesInstanceUID: string;
ReferencedInstanceSequence: ReferencedInstance[];
};
export type DisplaySet = {
displaySetInstanceUID: string;
instances: InstanceMetadata[];
@@ -16,6 +26,8 @@ export type DisplaySet = {
label?: string;
/** Flag indicating if this is an overlay display set (e.g., SEG, RTSTRUCT) */
isOverlayDisplaySet?: boolean;
/** Flag indicating this is a derived dataset */
isDerived?: boolean;
/** flag indicating if it supports window level */
supportsWindowLevel?: boolean;
@@ -49,6 +61,21 @@ export type DisplaySet = {
SeriesTime?: string;
instance?: InstanceMetadata;
/**
* The predecessor image id refers to the SOP instance that is currently loaded
* into this display set for SEG/SR/RTSTRUCT type values. The name is chosen
* for consistency when this value is used as the origin instance
* for saving a new instance intended to replace this instance where the
* new instance has a "predecessor sequence".
*/
predecessorImageId?: string;
/**
* isLoaded is used for display sets containing a load operation that
* is required before the display set can be shown. This is separate from
* isHydrated, which means it is loaded into view.
*/
isLoaded?: boolean;
isHydrated?: boolean;
isRehydratable?: boolean;
};
@@ -44,6 +44,13 @@ export function addProxyFields(instance) {
enumerable: true,
get: () => {
return instance._parentInstance?.[fieldProxy] ?? instance._parentInstance?._shared?.[fieldProxy];
},
set: (value) => {
Object.defineProperty(instance,fieldProxy, {
writable: true,
enumerable: true,
value,
})
}
});
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Converts a blob to a URL and downloads immediate
*/
export function downloadBlob(content, options?) {
const url = URL.createObjectURL(content);
downloadUrl(url, options);
URL.revokeObjectURL(url);
}
/**
* Trigger file download from an array buffer
* @param buffer
* @param filename
*/
export function downloadDicom(buffer: ArrayBuffer, options) {
const blob = new Blob([buffer], { type: 'application/dicom' });
downloadBlob(blob, options);
}
/**
* Downloads a URL
*/
export function downloadUrl(url, options?) {
const link = document.createElement('a');
link.setAttribute('href', url);
const filename = options?.filename || 'file.dcm';
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export function downloadCsv(csvString: string, options?) {
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
downloadBlob(blob, options);
}
+2 -6
View File
@@ -1,3 +1,4 @@
import { downloadUrl } from './downloadBlob';
import { DicomMetadataStore } from '../services/DicomMetadataStore/DicomMetadataStore';
import formatPN from './formatPN';
@@ -97,10 +98,5 @@ function _getCommonRowItems(measurement, seriesMetadata) {
function _createAndDownloadFile(csvContent) {
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', 'MeasurementReport.csv');
document.body.appendChild(link);
link.click();
downloadUrl(encodedUri, { filename: 'MeasurementReport.csv' });
}
+10
View File
@@ -48,6 +48,8 @@ import areAllImageOrientationsEqual from './areAllImageOrientationsEqual';
import { structuredCloneWithFunctions } from './structuredCloneWithFunctions';
import { buildButtonCommands } from './buildButtonCommands';
import { downloadBlob, downloadUrl, downloadCsv, downloadDicom } from './downloadBlob';
// Commented out unused functionality.
// Need to implement new mechanism for derived displaySets using the displaySetManager.
@@ -99,6 +101,10 @@ const utils = {
getClosestOrientationFromIOP,
calculateScanAxisNormal,
areAllImageOrientationsEqual,
downloadBlob,
downloadUrl,
downloadCsv,
downloadDicom,
};
export {
@@ -137,6 +143,10 @@ export {
MeasurementFilters,
getClosestOrientationFromIOP,
buildButtonCommands,
downloadBlob,
downloadUrl,
downloadCsv,
downloadDicom,
};
export default utils;
@@ -24,3 +24,45 @@ Using the class text size with the ui-button is inconsistent as to whether
it will apply or not. Instead, create a new size value to assign the desired size.
To support this, a new size enum is created, smallTall, which is used in the worklist
for an over-ride.
## `createReportDialogPrompt`
The create report dialog prompt (which is MOSTLY an internal component) has
change the API a bit. The input is now:
- `title` shown in the dialog
- `modality` being stored, used to query existing series
- `minSeriesNumber` is the start of new series of this modality type.
Will get set to 4000 if not determined by the modality
- predecessorImageId is the image id that this series was currently loaded
from. That allows defaulting the dialog to show the specified series instead
of always creating a new series.
The minSeries and predecessor are both optional, so the input doesn't have to be
updated.
The output has been enhanced with:
- `series`, is the series to store do, as referenced by a predecessorImageId value.
This allows exactly specifying which item to replace, which allows selecting it in the
menu by default instead of just guessing what value is being replaced.
- `priorSeriesNumber` is the previously lowest series number at least minSeriesNumber
of all the seris of the given modality type. This allows adding an instance to the
`next` series number by adding 1 to this value.
The priorSeriesNumber will default to 4000 for an unknown modality type, or
3000 for sr, 3100 for seg and 3200 for rtstruct.
## metadataProvider and formatted metadata
The metadata provider has been formatting some fields, which causes inconsistency
between different metadata providers and use of the instance object. The
specific fields that have changed are:
- `patientName`
- `studyDate`, `studyTime`
- `seriesDate`, `seriesTime`
If these fields need formatted versions, it is recommended to add a secondary/computed
metadata provider which simply gets the base metadata module and adds the
formatting. That way different metadata providers are all handled identically.
@@ -39,6 +39,31 @@ return {
}
```
## Replacing Instances in Series
Series such as segmentations and annotations often have the concept of replacing
an earlier instance with a newer one. The official way to do this is using the
`predecessor document sequence` to reference the old object in the new one.
It isn't that the old one is deleted (never delete data), but rather that the new
one is used in preference to the old one. This results in code that looks like:
```
utils.sortStudyInstances(instances);
// Choose the LAST instance in the list as the most recently created one.
const instance = instances[instances.length - 1];
...
const displaySet = {
...
predecessorImageId: instance.imageId,
numImageFrames: instances.length,
}
```
so that the last/latest SOP instance is used by default, and that it is
recorded what imageId was used to load that instance. This then allows saving
new instances in the same series by using a new predecessor sequence.
## Example SOP Class Handler Module
```js