src_app_dataController.js
import {ListenerHandler} from '../utils/listen.js';
import {mergeObjects} from '../utils/operator.js';
import {MaskFactory} from '../image/maskFactory.js';
import {ImageFactory} from '../image/imageFactory.js';
import {AnnotationGroupFactory} from '../image/annotationGroupFactory.js';
import {imageEventNames} from '../image/image.js';
import {annotationGroupEventNames} from '../image/annotationGroup.js';
import {safeGet} from '../dicom/dataElement.js';
import {
getVolumeIdTagValue,
getPostLoadVolumeIdTagValue
} from '../dicom/dicomVolume.js';
import {hasAnyPixelDataElement} from '../dicom/dicomTag.js';
import {getReferencedSeriesUID} from '../dicom/dicomImage.js';
// doc imports
/* eslint-disable no-unused-vars */
import {Image} from '../image/image.js';
import {DataElement} from '../dicom/dataElement.js';
import {AnnotationGroup} from '../image/annotationGroup.js';
/* eslint-enable no-unused-vars */
/**
* Related DICOM tag keys.
*/
const TagKeys = {
Modality: '00080060'
};
/**
* List of data event names.
*
* @type {string[]}
*/
export const dataEventNames = [
'dataadd',
'dataremove',
'dataimageset',
'dataupdate'
];
/**
* Merge meta datas.
*
* @param {Object<string, DataElement>} meta0 The first data to merge.
* @param {Object<string, DataElement>} meta1 The second data to merge.
* @param {string} meta1Id A meta1 specific id.
* @returns {Object<string, DataElement>} The merged data.
*/
function mergeMeta(meta0, meta1, meta1Id) {
// update meta data
let idKey = '';
if (typeof meta1['00020010'] !== 'undefined') {
// dicom case, use 'InstanceNumber'
idKey = '00200013';
} else {
idKey = 'imageUid';
}
// possible time suffix
// merge
return mergeObjects(
meta0,
meta1,
idKey,
'value',
meta1Id
);
}
/**
* DICOM data: meta and possible image.
*/
export class DicomData {
/**
* DICOM meta data.
*
* @type {Object<string, DataElement>}
*/
meta;
/**
* Image extracted from meta data.
*
* @type {Image|undefined}
*/
image;
/**
* Annotattion group extracted from meta data.
*
* @type {AnnotationGroup|undefined}
*/
annotationGroup;
/**
* Image buffer used to build image.
*
* @type {any|undefined}
*/
buffer;
/**
* Number of files/urls associated to the data.
*
* @type {number}
*/
numberOfFiles;
/**
* List of data creation warning.
*
* @type {string[]}
*/
warn = [];
/**
* Duplicate origin flag. If true, the image slice append
* will be blocked. Use a DicomSliceDataList to create the
* full image.
*
* @type {boolean}
*/
#hasDuplicateOrigin = false;
/**
* @param {Object<string, DataElement>} meta The DICOM meta data.
*/
constructor(meta) {
this.meta = meta;
}
/**
* Get the image complete flag (for image data).
*
* @returns {boolean|undefined} True if the image is complete.
*/
getComplete() {
let res;
if (typeof this.image !== 'undefined') {
res = this.image.getComplete();
}
return res;
}
/**
* Set the image complete flag (for image data).
*
* @param {boolean} flag True if the image is complete.
*/
setComplete(flag) {
if (typeof this.image !== 'undefined') {
this.image.setComplete(flag);
}
}
/**
* Get the duplicate origin flag.
*
* @returns {boolean} The flag.
*/
hasDuplicateOrigin() {
return this.#hasDuplicateOrigin;
}
/**
* Append slice and update meta data.
*
* @param {DicomData} data The data to append.
*/
appendData(data) {
// only process if no duplicate origins were found
// if there was, then the image must be created when
// the load finishes via a DicomSliceDataList.buildImage
if (!this.#hasDuplicateOrigin) {
// append slice to current image
if (typeof this.image !== 'undefined' &&
typeof data.image !== 'undefined'
) {
this.#appendSlice(data.image);
}
this.#mergeMeta(data.meta);
}
}
/**
* Append a slice image to this image.
*
* @param {Image} image The image to append.
*/
#appendSlice(image) {
// check if append is possible
const geom0 = this.image.getGeometry();
const geom1 = image.getGeometry();
const canAppend = geom0.canAppendOrigin(
geom1.getOrigin(), geom1.getInitialTime());
// store result if not possible to stop future appends
if (!canAppend.success) {
this.#hasDuplicateOrigin = true;
}
// append if possible
if (!this.#hasDuplicateOrigin) {
this.image.appendSlice(image);
}
}
/**
* Merge meta data to this meta.
*
* @param {Object<string, DataElement>} meta The data to merge.
*/
#mergeMeta(meta) {
const meta1IdNum = getVolumeIdTagValue(meta);
let meta1Id;
if (typeof meta1IdNum !== 'undefined') {
meta1Id = meta1IdNum.toString();
}
this.meta = mergeMeta(this.meta, meta, meta1Id);
}
}
/**
* DICOM slice data list.
*/
export class DicomSliceDataList {
/**
* @type {DicomData[]|undefined}
*/
#list = [];
/**
* Add a clone of the input data to the local list.
*
* @param {DicomData} data The data to clone and add.
*/
addClone(data) {
const clone = new DicomData(structuredClone(data.meta));
clone.image = data.image.clone();
this.add(clone);
}
/**
* Add data to the local list.
*
* @param {DicomData} data The data to add.
*/
add(data) {
this.#list.push(data);
}
/**
* Build a data from the stored slice data.
*
* @returns {{image, meta}} The result data.
*/
buildData() {
// get and check the number of volumes
const numberOfVolumes = this.#getNumberOfVolumes();
if (typeof numberOfVolumes === 'undefined') {
throw new Error('Non constant number of volumes');
}
if (numberOfVolumes === 0) {
throw new Error('Duplicate origins but no volumes');
}
if (numberOfVolumes === 1) {
throw new Error('Duplicate origins but just one volume');
}
// get indices per volumes
const volsIndices = this.#getVolumesIndices(
numberOfVolumes, getPostLoadVolumeIdTagValue);
if (typeof volsIndices === 'undefined') {
throw new Error('Cannot create image for multi-volume');
}
// reset times and append slice
let image;
let meta;
for (let i = 0; i < volsIndices.length; ++i) {
const indices = volsIndices[i];
// meta
// TODO fix slow when in indices loop
const sliceMeta = this.#list[indices[0]].meta;
if (typeof meta === 'undefined') {
meta = sliceMeta;
} else {
const sliceMetaId = i + ':' +
getPostLoadVolumeIdTagValue(sliceMeta);
meta = mergeMeta(
meta, sliceMeta, sliceMetaId);
}
// image
for (const index of indices) {
const sliceImage = this.#list[index].image;
sliceImage.getGeometry().setInitialTime(i);
if (typeof image === 'undefined') {
image = sliceImage;
} else {
image.appendSlice(sliceImage);
}
}
}
return {image, meta};
}
/**
* Get the list of indices per volume.
*
* @param {number} numberOfVolumes The number of expected volumes.
* @param {Function} volumeIndexGetter A function to get the volume index from
* meta data.
* @returns {number[][]|undefined} List of indices per volume or
* undefined if something went wrong.
*/
#getVolumesIndices(numberOfVolumes, volumeIndexGetter) {
const originList = this.#getOriginList();
const volumesIndices = [];
const volIndexValues = [];
for (const item of originList) {
// build volume index list
if (volIndexValues.length === 0) {
for (const index of item.indices) {
const relData = this.#list[index];
const volumeIndex = volumeIndexGetter(relData.meta);
if (volIndexValues.includes(volumeIndex)) {
// duplicate volume index
return;
} else {
volIndexValues.push(volumeIndex);
}
}
if (volIndexValues.length !== numberOfVolumes) {
// too many indices
return;
}
volIndexValues.sort();
}
// add indices to volume indices
for (const index of item.indices) {
const relData = this.#list[index];
const volumeIndex = volumeIndexGetter(relData.meta);
const volIndex = volIndexValues.indexOf(volumeIndex);
if (volIndex === -1) {
// unknown index
return;
}
// add data index to volume indices
if (typeof volumesIndices[volIndex] === 'undefined') {
volumesIndices[volIndex] = [];
}
volumesIndices[volIndex].push(index);
}
}
// check same size
const numberOfSlices = originList.length;
for (const list of volumesIndices) {
if (list.length !== numberOfSlices) {
// wrong number of slices
return;
}
}
return volumesIndices;
}
/**
* Get the number of volumes of a data.
*
* @returns {number|undefined} The number of volumes or
* undefined if non constant.
*/
#getNumberOfVolumes() {
const originList = this.#getOriginList();
if (originList.length === 0) {
return 0;
}
const count = originList[0].count;
// check if constant count
for (const item of originList) {
if (item.count !== count) {
// non constant number of origins
return;
}
}
return count;
}
/**
* Get the list of origins and thein occurences in a list
* of {pos, indices, count}.
*
* @returns {object[]} A list of origins and
* their number of occurences.
*/
#getOriginList() {
// equal callback
const getEqualPosCallback = function (pos) {
return function (element) {
return element.pos.equals(pos);
};
};
const res = [];
for (let i = 0; i < this.#list.length; ++i) {
const relData = this.#list[i];
const origin = relData.image.getGeometry().getOrigin();
const findCallback = getEqualPosCallback(origin);
const found = res.find(findCallback);
if (typeof found === 'undefined') {
res.push({
pos: origin,
indices: [i],
count: 1
});
} else {
++found.count;
found.indices.push(i);
}
}
return res;
}
}
/**
* DicomData controller.
*/
export class DataController {
/**
* List of DICOM data.
*
* @type {Object<string, DicomData>}
*/
#dataList = {};
/**
* Temporary slice list for data with duplicate origin
* that needs to be created once the load is finished.
*
* @type {Object<string, DicomSliceDataList>}
*/
#tmpSliceList = {};
/**
* List of DICOM data.
*
* @type {Object<string, DicomData>}
*/
#dataListStashed = {};
/**
* Distinct data loaded counter.
*
* @type {number}
*/
#dataIdCounter = -1;
/**
* Listener handler.
*
* @type {ListenerHandler}
*/
#listenerHandler = new ListenerHandler();
/**
* Get the next data id.
*
* @returns {string} The data id.
*/
getNextDataId() {
++this.#dataIdCounter;
return this.#dataIdCounter.toString();
}
/**
* Get the list of ids in the data storage.
*
* @returns {string[]} The list of data ids.
*/
getDataIds() {
return Object.keys(this.#dataList);
}
/**
* Reset the class: empty the data storage.
*/
reset() {
this.#dataList = {};
}
/**
* Get a data at a given index.
*
* @param {string} dataId The data id.
* @returns {DicomData|undefined} The DICOM data.
*/
get(dataId) {
return this.#dataList[dataId];
}
/**
* Get the list of dataIds that contain the input UIDs.
*
* @param {string[]} uids A list of UIDs.
* @returns {string[]} The list of dataIds that contain the UIDs.
*/
getDataIdsFromSopUids(uids) {
const res = [];
// check input
if (typeof uids === 'undefined' ||
uids.length === 0) {
return res;
}
const keys = Object.keys(this.#dataList);
for (const key of keys) {
if (typeof this.#dataList[key].image !== 'undefined' &&
this.#dataList[key].image.containsImageUids(uids)) {
res.push(key);
}
}
return res;
}
/**
* Get the first data id with the given SeriesInstanceUID.
*
* @param {string} uid The SeriesInstanceUID.
* @returns {string} The data id.
*/
getDataIdFromSeriesUid(uid) {
let res;
const keys = Object.keys(this.#dataList);
for (const key of keys) {
const image = this.#dataList[key].image;
if (typeof image !== 'undefined') {
const imageSeriesUID = image.getMeta().SeriesInstanceUID;
if (uid === imageSeriesUID) {
res = key;
break;
}
}
}
return res;
}
/**
* Set the image at a given index.
*
* @param {string} dataId The data id.
* @param {Image} image The image to set.
*/
setImage(dataId, image) {
this.#dataList[dataId].image = image;
// propagate image events
for (const eventName of imageEventNames) {
image.addEventListener(eventName, this.#getFireEvent(dataId));
}
/**
* Data image set event.
*
* @event DataController#dataimageset
* @type {object}
* @property {string} type The event type.
* @property {Array} value The event value, first element is the image.
* @property {string} dataid The data id.
*/
this.#fireEvent({
type: 'dataimageset',
value: [image],
dataid: dataId
});
}
/**
* Interpret dicom data and create its 'content': image in
* most cases, annotationGroup for dicom SR.
*
* @param {DicomData} data The dicom data.
*/
#setDataContent(data) {
const modality = safeGet(data.meta, TagKeys.Modality);
let factory;
if (hasAnyPixelDataElement(data.meta)) {
if (modality === 'SEG') {
// DICOM seg case
// find the referenced data to allow for geometry creation
const referencedSeriesUID = getReferencedSeriesUID(data.meta);
if (typeof referencedSeriesUID === 'undefined') {
throw new Error('Cannot create mask image: ' +
'the DICOM seg does not have a referenced series UID');
}
// get the reference data id
const refDataId = this.getDataIdFromSeriesUid(referencedSeriesUID);
if (typeof refDataId === 'undefined') {
throw new Error('Cannot create mask image: ' +
'the DICOM seg referenced series is not loaded');
}
// create image
factory = new MaskFactory();
if (typeof factory.checkElements(data.meta) === 'undefined') {
data.image = factory.create(
data.meta,
data.buffer,
this.#dataList[refDataId].image
);
}
} else {
// image case
factory = new ImageFactory();
if (typeof factory.checkElements(data.meta) === 'undefined') {
data.image = factory.create(
data.meta,
data.buffer,
data.numberOfFiles
);
}
}
} else if (modality === 'SR') {
// annotation case
factory = new AnnotationGroupFactory();
if (typeof factory.checkElements(data.meta) === 'undefined') {
data.annotationGroup = factory.create(data.meta);
}
}
// add warnings if present
if (typeof factory !== 'undefined' &&
typeof factory.getWarning() !== 'undefined') {
data.warn.push(factory.getWarning());
}
}
/**
* Add a new data.
*
* @param {string} dataId The data id.
* @param {DicomData} data The data.
* @returns {boolean} False if the data cannot be added.
*/
add(dataId, data) {
if (typeof dataId === 'undefined' ||
typeof this.#dataList[dataId] !== 'undefined') {
return false;
}
// store the new image
this.#dataList[dataId] = data;
// create the data content if not present
if (typeof data.image === 'undefined' &&
typeof data.annotationGroup === 'undefined') {
// create content
this.#setDataContent(data);
// store data for possible processing at complete time
// (see markDataAsComplete)
if (typeof data.numberOfFiles !== 'undefined' &&
data.numberOfFiles > 1) {
this.#tmpSliceList[dataId] = new DicomSliceDataList();
// add first data as clone since this data
// is the base for future appends with no
// duplicate origin
this.#tmpSliceList[dataId].addClone(data);
}
}
// propagate image events
if (typeof data.image !== 'undefined') {
for (const eventName of imageEventNames) {
data.image.addEventListener(eventName, this.#getFireEvent(dataId));
}
}
// propagate annotation group events
if (typeof data.annotationGroup !== 'undefined') {
for (const eventName of annotationGroupEventNames) {
data.annotationGroup.addEventListener(
eventName, this.#getFireEvent(dataId));
}
}
/**
* Data add event.
*
* @event DataController#dataadd
* @type {object}
* @property {string} type The event type.
* @property {string} dataid The data id.
*/
this.#fireEvent({
type: 'dataadd',
dataid: dataId
});
return true;
}
/**
* Remove a data from the list.
*
* @param {string} dataId The data id.
*/
remove(dataId) {
if (typeof this.#dataList[dataId] !== 'undefined') {
// stop propagating image events
const image = this.#dataList[dataId].image;
if (typeof image !== 'undefined') {
for (const eventName of imageEventNames) {
image.removeEventListener(eventName, this.#getFireEvent(dataId));
}
}
// stop propagating annotation group events
const annotationGroup = this.#dataList[dataId].annotationGroup;
if (typeof annotationGroup !== 'undefined') {
for (const eventName of annotationGroupEventNames) {
annotationGroup.removeEventListener(
eventName, this.#getFireEvent(dataId));
}
}
// remove data from list
delete this.#dataList[dataId];
/**
* Data remove event.
*
* @event DataController#dataremove
* @type {object}
* @property {string} type The event type.
* @property {string} dataid The data id.
*/
this.#fireEvent({
type: 'dataremove',
dataid: dataId
});
}
}
/**
* Stash a data from the list.
*
* @param {string} dataId The data id.
*/
stash(dataId) {
if (typeof this.#dataList[dataId] !== 'undefined') {
this.#dataListStashed[dataId] = this.#dataList[dataId];
this.remove(dataId);
}
}
/**
* Unstash a data from the list.
*
* @param {string} dataId The data id.
*/
unstash(dataId) {
if (typeof this.#dataListStashed[dataId] !== 'undefined') {
this.add(dataId, this.#dataListStashed[dataId]);
delete this.#dataListStashed[dataId];
}
}
/**
* Get the list of ids in the stashed data storage.
*
* @returns {string[]} The list of data ids.
*/
getStashedDataIds() {
return Object.keys(this.#dataListStashed);
}
/**
* Get a stashed data at a given index.
*
* @param {string} dataId The data id.
* @returns {DicomData|undefined} The DICOM data.
*/
getStashed(dataId) {
return this.#dataListStashed[dataId];
}
/**
* Update the current data.
*
* @param {string} dataId The data id.
* @param {DicomData} data The data.
*/
update(dataId, data) {
if (typeof this.#dataList[dataId] === 'undefined') {
throw new Error('Cannot find data to update: ' + dataId);
}
const dataToUpdate = this.#dataList[dataId];
// create the data content
this.#setDataContent(data);
// store data for possible processing at complete time
// (see markDataAsComplete)
if (typeof this.#tmpSliceList[dataId] !== 'undefined') {
this.#tmpSliceList[dataId].add(data);
}
// append data if no duplicate origin
// (if has duplicates, data will be created in markDataAsComplete)
if (!dataToUpdate.hasDuplicateOrigin()) {
dataToUpdate.appendData(data);
}
/**
* Data udpate event.
*
* @event DataController#dataupdate
* @type {object}
* @property {string} type The event type.
* @property {string} dataid The data id.
*/
this.#fireEvent({
type: 'dataupdate',
dataid: dataId
});
}
/**
* Mark a data a complete (fully loaded).
*
* @param {string} dataId The data id.
* @returns {{imageHasChanged}} An object with an imageHasChanged property.
*/
markDataAsComplete(dataId) {
const data = this.#dataList[dataId];
if (typeof data === 'undefined') {
throw new Error('Cannot find data to mark as complete: ' + dataId);
}
const res = {imageHasChanged: false};
// data with duplicate origin case: build image
// from final slice list
if (typeof this.#tmpSliceList[dataId] !== 'undefined' &&
data.hasDuplicateOrigin()
) {
const finalData = this.#tmpSliceList[dataId].buildData();
// set image: sends dataimageset event
this.setImage(dataId, finalData.image);
// set meta
data.meta = finalData.meta;
// reset tmp var
delete this.#tmpSliceList[dataId];
// mark image as changed
res.imageHasChanged = true;
}
// mark image as complete
data.setComplete(true);
return res;
}
/**
* Add an event listener to this class.
*
* @param {string} type The event type.
* @param {Function} callback The function associated with the provided
* event type, will be called with the fired event.
*/
addEventListener(type, callback) {
this.#listenerHandler.add(type, callback);
}
/**
* Remove an event listener from this class.
*
* @param {string} type The event type.
* @param {Function} callback The function associated with the provided
* event type.
*/
removeEventListener(type, callback) {
this.#listenerHandler.remove(type, callback);
}
/**
* Fire an event: call all associated listeners with the input event object.
*
* @param {object} event The event to fire.
*/
#fireEvent = (event) => {
this.#listenerHandler.fireEvent(event);
};
/**
* Get a fireEvent function that adds the input data id
* to the event value.
*
* @param {string} dataId The data id.
* @returns {Function} A fireEvent function.
*/
#getFireEvent(dataId) {
return (event) => {
event.dataid = dataId;
this.#fireEvent(event);
};
}
} // DataController class