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

+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;