feat: cornerstone3D stack and volume viewports (#2787)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: cs3d tools and toolGroups (#20)

* add more tools to work with cs3d mode

* fix hotkeys

* add stack manager usage for stach viewports

* add image scrollbar

* wip viewport overlay

* fix toAnnotation schema for tools

* fix the unnecessary size change that triggered resize

* hanging protocol improvement to allow unmatched errors

* study description matching for hanging protocol

* fix the displaysetOptions to work

* fix handle the active tool when a new viewport is added

* fix separate toolGroups for mode

* apply review comments

* apply review comments

* yarn lock

* feat: overlay component (#21)

* fix default displayset options

* add viewport overlay

* apply review comments

* feat: loading and orientation indicators (#22)

* add loading indicator

* add orientation marker initial work

* apply review comments

* fix: orientation markers (#23)

* finished the orientation markers

* fix various broken cypress tests

* apply review comments

* update yarn lock

* feat: re-working measurement tracking mode with cornerstone3d (#2805)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: Add Measurement tracking mode with cs3D (#2789)

* feat: first render for cornerstone3d tracked viewport

* make tool active work

* wip for SR extension

* renamed dicom sr to cornerstone dicom sr

* remove cornerstone from dicom pdf and video

* move dicom sr logic to sr extension

* feat: Add hydration for the length tool

* fix SR display tool for length using cs3d

* fix default config

* fix: various bugs with sr viewport and tracking

* fix promptying to continue tracking for when SR is created

* feat: add keep trackign of unique identifiers

* fix hydration for same imageIds

* feat: Add SR toolGroup creation on modeEnter

* feat: remove the need for separate mapper for SR hydration

* add SR display for ellipse

* handle hydration of elliptical ROI tool

* remove cornerstone extension

* add arrow mapping

* feat: Add ArrowAnnotate SR display and hydration

* apply review comments

* apply review comments

* move viewport labels to the viewportData

* apply review comments

* fix: integration cypress tests with cornerstone3D and add CINE tool (#2795)

* fix: integration cypress tests with cornerstone3D

* revert to addOrUpdate as it makes more sense

* fix local drag and drop

* fix tests

* move dicomLoaderService to cornerstone extension

* fix various import bugs

* fix bug for local PT series

* fix various unit tests

* bump cs3d versions

* add angle and magnify tool

* bump dependencies to avoid broken peerDeps

* feat: add initial work for capture using cs3d

* feat: show annotations on the image capture

* feat: add svg layer export

* feat: Add CINE Tool

* feat: remove unnecessary viewport rendering for cine state changes

Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>

* docs: modify and improve documentation (#2800)

* cleanup docs versionings

* feat: Add all version explanations

* version docs for 3.0

* wip for changing docs

* wip for updated docs

* add utility module documentation

* fix demo with nohoisting of history

* add slides and video to resources

* apply review comments

* fix: drag and drop SR into SR viewport (#2803)

* update yarn lock

Co-authored-by: Erik <erik.sweed@gmail.com>

* update pathnames to match v3-stable

* update the e2e pathname

* fix: various bugs with regard to tracking workflow (#2811)

* fix: various issues with measurement panel

* fix: update default tool style for annotations

* fix: annotatoin label getting removed

* feat: Add backward compatibility for SR hydration with legacy cornerstone

* fix: cursors and ellipse ROI max style

* fix: ArrowAnnotate SRDisplay

* apply review comments

* bump package versions

* fix: bugin rehydration of SR

* fix: e2e tests

* fix active viewport thickness and arrowTool ui

* add readme for measurement tracking

* use uploaded image for readme

* add back images

* try to fix e2e test

* fix: window level presets hotkeys

* Update README.md

* update yarn lock

* feat: volume api and TMTV mode (#2817)

* feat: volumeAPI and TMTV mode

* feat: use cs3d cache service to obtain viewportData

* wip for volume api

* wip: fusion viewport

* feat: add blend mode option

* wip for image scrollbar

* fix drag and drop thumbnail

* wip for image scrollbar for voluems

* fix: element mismatch bug for scroll

* feat: Add image scrollbar to volumes

* feat: add syncGroups to volume api

* feat: add tmtv mode initial setup

* feat: add initial image options for the stack viewports

* feat: Add custom load strategy for volume viewports via HP

* apply review comments

fix: Jump presets cs3d (#2812)

* feat: Add JumpPreset to OHIF for Cornerstone3D

* fix: accessing viewport service from servicesManager

feat: volume API and TMTV mode (#2814)

* fix: do not display overlays on mip viewports

* feat: add optional disableCommands for toggle buttons

* fix: toggleCrossharis

* feat: config the crosshairs

* feat: add PetSUV Panel for changing metadata

* feat: initial work for the rectangleROIThreshold panel

* wip: measurement service

* roi threshold working

* feat: Add displayText to segmentations

* feat: add remove segmentation

* feat: add csv export

* feat: add RT export for annotations in tmtv mode

* fix: fusion to use pt in tmtv mode and measuremet mappings

* fix: various bugs

* apply review comments

* apply review comments

* feat: add fusion color maps

* add readme to tmtv mode

* Update README.md

* fix: try to fix build

* fix unit tests

* Update README.md

* feat: add about to the panel

* fix: changing strategy in roi panel

* apply review comments

* update package versions

* wip for stackPrefetch

* fix: cornerstone3d hydration and renaming (#2818)

* update readme

* renamed cornerstone extension

* wip for renaming cornerstone3D variables

* wip for renaming cornerstone3D variables

* wip for fixing bugs for SR viewport

* fix: jumpToMeasurement and initial label after hydration

* fix: fileName capitalization

* feat: use the new prefetch stack in the cs3d (#2820)

* feat: use the new prefetch stack in the cs3d

* use viewport scroll api for stack viewport

* fix cine stop when scrollbar changes

* feat: use new prefetch events

* fix loading state to not show repeatedly

* fix: various bugs for tmtv mode thresholding and new icons (#2823)

* feat: make tmtv mode available in worklist

* feat: add new icons for tmtv mode

* feat: add fusion color icon

* fix: parallel scale calculation

* fix: bump Cornerstone to get large image support working

* fix: Fix issues with CPU viewport flipping, including config files

* fix: bump cornerstone to fix magnify tool

* fix: Bump Cornerstone version to fix resetCamera issue in StackViewport

* ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys

* fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors

* feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838)

Also works with the compile time add that the existing cli uses, so that both build types work.
Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes.

* bump: dependency versions to fix hydration bugs (#2848)

* bump: dcmjs version to fix hydration bugs

* try to fix tests

* bump dependency versions

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2022-07-27 12:39:04 -04:00
1 parent 86dcaf6293
commit bd1c38c00c
668 files changed
+41669 -13410

No files matched your search

-240
View File
@@ -1,240 +0,0 @@
import log from '../log';
import utils from '../utils';
// import {
// retrieveMeasurementFromSR,
// stowSRFromMeasurements,
// } from './handleStructuredReport';
import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport';
import cornerstone from 'cornerstone-core';
import cornerstoneTools from 'cornerstone-tools';
import dcmjs from 'dcmjs';
const { MeasurementReport } = dcmjs.adapters.Cornerstone;
/**
*
* @typedef serverType
* @property {string} type - type of the server
* @property {string} wadoRoot - server wado root url
*
*/
/**
* Function to be registered into MeasurementAPI to retrieve measurements from DICOM Structured Reports
*
* @param {serverType} server
* @returns {Promise} Should resolve with OHIF measurementData object
*/
/*const retrieveMeasurements = server => {
log.info('[DICOMSR] retrieveMeasurements');
if (!server || server.type !== 'dicomWeb') {
log.error('[DICOMSR] DicomWeb server is required!');
return Promise.reject({});
}
const serverUrl = server.wadoRoot;
const studies = utils.studyMetadataManager.all();
const latestSeries = findMostRecentStructuredReport(studies);
if (!latestSeries) return Promise.resolve({});
return retrieveMeasurementFromSR(latestSeries, studies, serverUrl);
};*/
/**
*
* @param {object[]} measurementData An array of measurements from the measurements service
* @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param {object} options Naturalized DICOM JSON headers to merge into the displaySet.
* as opposed to Finding Sites.
* that you wish to serialize.
*/
const downloadReport = (
measurementData,
additionalFindingTypes,
options = {}
) => {
const srDataset = generateReport(
measurementData,
additionalFindingTypes,
options
);
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
};
/**
*
* @param {object[]} measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param {object} options Naturalized DICOM JSON headers to merge into the displaySet.
*
*/
const generateReport = (
measurementData,
additionalFindingTypes,
options = {}
) => {
const filteredToolState = _getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
);
const report = MeasurementReport.generateReport(
filteredToolState,
cornerstone.metaData
);
const { dataset } = report;
// Add in top level series options
Object.assign(dataset, options);
return dataset;
};
/**
*
* @param {object[]} measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param {object} dataSource The dataSource that you wish to use to persist the data.
* @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param {object} options Naturalized DICOM JSON headers to merge into the displaySet.
* @return {object} The naturalized report
*/
const storeMeasurements = async (
measurementData,
dataSource,
additionalFindingTypes,
options = {}
) => {
// TODO -> Eventually use the measurements directly and not the dcmjs adapter,
// But it is good enough for now whilst we only have cornerstone as a datasource.
log.info('[DICOMSR] storeMeasurements');
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
return Promise.reject({});
}
try {
const naturalizedReport = generateReport(
measurementData,
additionalFindingTypes,
options
);
const { StudyInstanceUID } = naturalizedReport;
await dataSource.store.dicom(naturalizedReport);
if (StudyInstanceUID) {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
return naturalizedReport;
} catch (error) {
console.warn(error);
log.error(
`[DICOMSR] Error while saving the measurements: ${error.message}`
);
throw new Error(error.message || 'Error while saving the measurements.');
}
};
// _getFilteredCornerstoneToolState
// DIFFERENT IMPLEMENTATION HERE! What's up?
function _getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
) {
const uidFilter = measurementData.map(md => md.id);
const globalToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState();
const filteredToolState = {};
function addToFilteredToolState(imageId, toolType, toolDataI) {
if (!filteredToolState[imageId]) {
filteredToolState[imageId] = {};
}
const imageIdSpecificToolState = filteredToolState[imageId];
if (!imageIdSpecificToolState[toolType]) {
imageIdSpecificToolState[toolType] = {
data: [],
};
}
const measurmentDataI = measurementData.find(md => md.id === toolDataI.id);
const toolData = imageIdSpecificToolState[toolType].data;
let finding;
const findingSites = [];
// NOTE -> Any kind of freetext value abuses the DICOM standard,
// As CodeValues should map 1:1 with CodeMeanings.
// Ideally we would actually use SNOMED codes for this.
if (measurmentDataI.label) {
if (additionalFindingTypes.includes(toolType)) {
finding = {
CodeValue: 'CORNERSTONEFREETEXT',
CodingSchemeDesignator: 'CST4',
CodeMeaning: measurmentDataI.label,
};
} else {
findingSites.push({
CodeValue: 'CORNERSTONEFREETEXT',
CodingSchemeDesignator: 'CST4',
CodeMeaning: measurmentDataI.label,
});
}
}
const measurement = Object.assign({}, toolDataI, {
finding,
findingSites,
});
toolData.push(measurement);
}
const uids = uidFilter.slice();
const imageIds = Object.keys(globalToolState);
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i];
const imageIdSpecificToolState = globalToolState[imageId];
const toolTypes = Object.keys(imageIdSpecificToolState);
for (let j = 0; j < toolTypes.length; j++) {
const toolType = toolTypes[j];
const toolData = imageIdSpecificToolState[toolType].data;
if (toolData) {
for (let k = 0; k < toolData.length; k++) {
const toolDataK = toolData[k];
const uidIndex = uids.findIndex(uid => uid === toolDataK.id);
if (uidIndex !== -1) {
addToFilteredToolState(imageId, toolType, toolDataK);
uids.splice(uidIndex, 1);
if (!uids.length) {
return filteredToolState;
}
}
}
}
}
}
return filteredToolState;
}
export { storeMeasurements, downloadReport };
-15
View File
@@ -1,15 +0,0 @@
import {
//retrieveMeasurements,
storeMeasurements,
downloadReport,
} from './dataExchange';
import isToolSupported from './utils/isToolSupported';
const DICOMSR = {
//retrieveMeasurements,
storeMeasurements,
downloadReport,
isToolSupported,
};
export default DICOMSR;
@@ -1,25 +0,0 @@
/**
* Should Find the requested instance metadata into the displaySets and return
*
* @param {Array} displaySets - List of displaySets
* @param {string} SOPInstanceUID - sopInstanceUID to look for
* @returns {Object} - instance metadata found
*/
const findInstanceMetadataBySopInstanceUID = (displaySets, SOPInstanceUID) => {
let instanceFound;
displaySets.find(displaySet => {
if (!displaySet.images) return false;
instanceFound = displaySet.images.find(
instanceMetadata =>
instanceMetadata.getSOPInstanceUID() === SOPInstanceUID
);
return !!instanceFound;
});
return instanceFound;
};
export default findInstanceMetadataBySopInstanceUID;
@@ -1,67 +0,0 @@
/**
* Should find the most recent Structured Report metadata
*
* @param {Array} studies
* @returns {Object} Series
*/
const findMostRecentStructuredReport = studies => {
let mostRecentStructuredReport;
studies.forEach(study => {
const allSeries = study.getSeries ? study.getSeries() : [];
allSeries.forEach(series => {
// Skip series that may not have instances yet
// This can happen if we have retrieved just the initial
// details about the series via QIDO-RS, but not the full metadata
if (!series.instances.length) {
return;
}
if (isStructuredReportSeries(series)) {
if (
!mostRecentStructuredReport ||
compareSeriesDate(series, mostRecentStructuredReport)
) {
mostRecentStructuredReport = series;
}
}
});
});
return mostRecentStructuredReport;
};
/**
* Checks if series sopClassUID matches with the supported Structured Reports sopClassUID
*
* @param {Object} series - Series metadata
* @returns {boolean}
*/
const isStructuredReportSeries = series => {
const supportedSopClassUIDs = [
'1.2.840.10008.5.1.4.1.1.88.22',
'1.2.840.10008.5.1.4.1.1.11.1',
];
const firstInstance = series.getFirstInstance();
const SOPClassUID = firstInstance.getData().metadata.SOPClassUID;
return supportedSopClassUIDs.includes(SOPClassUID);
};
/**
* Checkes if series1 is newer than series2
*
* @param {Object} series1 - Series Metadata 1
* @param {Object} series2 - Series Metadata 2
* @returns {boolean} true/false if series1 is newer than series2
*/
const compareSeriesDate = (series1, series2) => {
return (
series1._data.SeriesDate > series2._data.SeriesDate ||
(series1._data.SeriesDate === series2._data.SeriesDate &&
series1._data.SeriesTime > series2._data.SeriesTime)
);
};
export default findMostRecentStructuredReport;
@@ -1,19 +0,0 @@
/**
* Retrieve a list of all displaySets of all studies
*
* @param {Object} studies - List of studies loaded into the viewer
* @returns {Object} List of DisplaySets
*/
const getAllDisplaySets = studies => {
let allDisplaySets = [];
studies.forEach(study => {
if (study.getDisplaySets) {
allDisplaySets = allDisplaySets.concat(study.getDisplaySets());
}
});
return allDisplaySets;
};
export default getAllDisplaySets;
@@ -1,14 +0,0 @@
import dcmjs from 'dcmjs';
/**
* Checks if dcmjs has support to determined tool
*
* @param {string} toolName
* @returns {boolean}
*/
const isToolSupported = toolName => {
const adapter = dcmjs.adapters.Cornerstone;
return !!adapter[toolName];
};
export default isToolSupported;
+3 -15
View File
@@ -18,16 +18,8 @@ import log from '../log.js';
* to extend this class, please check it's source before adding new methods.
*/
export class CommandsManager {
constructor({ getActiveContexts } = {}) {
constructor({} = {}) {
this.contexts = {};
if (!getActiveContexts) {
throw new Error(
'CommandsManager was instantiated without getActiveContexts()'
);
}
this._getActiveContexts = getActiveContexts;
}
/**
@@ -122,12 +114,8 @@ export class CommandsManager {
contexts.push(context);
}
} else {
const activeContexts = this._getActiveContexts();
activeContexts.forEach(activeContext => {
const context = this.getContext(activeContext);
if (context) {
contexts.push(context);
}
Object.keys(this.contexts).forEach(contextName => {
contexts.push(this.getContext(contextName));
});
}
@@ -12,7 +12,11 @@ describe('CommandsManager', () => {
options: { passMeToCommandFn: ':wave:' },
},
commandsManagerConfig = {
getActiveContexts: () => ['VIEWER', 'ACTIVE_VIEWER::CORNERSTONE'],
getAppState: () => {
return {
viewers: 'Test',
};
},
};
beforeEach(() => {
@@ -29,12 +33,6 @@ describe('CommandsManager', () => {
expect(localCommandsManager.contexts).toEqual({});
});
it('throws Error if instantiated without getActiveContexts', () => {
expect(() => {
new CommandsManager();
}).toThrow(new Error('CommandsManager was instantiated without getActiveContexts()'));
});
describe('createContext()', () => {
it('creates a context', () => {
commandsManager.createContext(contextName);
@@ -21,13 +21,13 @@ describe('HotkeysManager', () => {
});
it('has expected properties', () => {
const allProperties = Object.keys(hotkeysManager);
const expectedProprties = [
const expectedProperties = [
'hotkeyDefinitions',
'hotkeyDefaults',
'isEnabled',
];
const containsAllExpectedProperties = expectedProprties.every(expected =>
const containsAllExpectedProperties = expectedProperties.every(expected =>
allProperties.includes(expected)
);
+100 -56
View File
@@ -1,8 +1,10 @@
import queryString from 'query-string';
import dicomParser from 'dicom-parser';
import { imageIdToURI } from '../utils';
import getPixelSpacingInformation from '../utils/metadataProvider/getPixelSpacingInformation';
import DicomMetadataStore from '../services/DicomMetadataStore';
import fetchPaletteColorLookupTableData from '../utils/metadataProvider/fetchPaletteColorLookupTableData';
import toNumber from '../utils/toNumber';
class MetadataProvider {
constructor() {
@@ -13,7 +15,17 @@ class MetadataProvider {
writable: false,
value: new Map(),
});
Object.defineProperty(this, 'imageIdToUIDs', {
Object.defineProperty(this, 'imageURIToUIDs', {
configurable: false,
enumerable: false,
writable: false,
value: new Map(),
});
// Can be used to store custom metadata for a specific type.
// For instance, the scaling metadata for PET can be stored here
// as type "scalingModule"
//
Object.defineProperty(this, 'customMetadata', {
configurable: false,
enumerable: false,
writable: false,
@@ -21,13 +33,21 @@ class MetadataProvider {
});
}
addImageIdToUIDs(imageId, uids) {
// This method is a fallback for when you don't have WADO-URI or WADO-RS.
// You can add instances fetched by any method by calling addInstance, and hook an imageId to point at it here.
// An example would be dicom hosted at some random site.
const imageURI = imageIdToURI(imageId);
this.imageURIToUIDs.set(imageURI, uids);
}
this.imageIdToUIDs.set(imageId, uids);
addCustomMetadata(imageId, type, metadata) {
const imageURI = imageIdToURI(imageId);
if (!this.customMetadata.has(type)) {
this.customMetadata.set(type, {});
}
this.customMetadata.get(type)[imageURI] = metadata;
}
_getInstance(imageId) {
@@ -53,6 +73,15 @@ class MetadataProvider {
return instance;
}
// check inside custom metadata
if (this.customMetadata.has(query)) {
const customMetadata = this.customMetadata.get(query);
const imageURI = imageIdToURI(imageId);
if (customMetadata[imageURI]) {
return customMetadata[imageURI];
}
}
return this.getTagFromInstance(query, instance, options);
}
@@ -106,7 +135,7 @@ class MetadataProvider {
metadata = {
modality: instance.Modality,
seriesInstanceUID: instance.SeriesInstanceUID,
seriesNumber: validNumber(instance.SeriesNumber),
seriesNumber: toNumber(instance.SeriesNumber),
studyInstanceUID: instance.StudyInstanceUID,
seriesDate,
seriesTime,
@@ -114,9 +143,14 @@ class MetadataProvider {
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE:
metadata = {
patientAge: validNumber(instance.PatientAge),
patientSize: validNumber(instance.PatientSize),
patientWeight: validNumber(instance.PatientWeight),
patientAge: toNumber(instance.PatientAge),
patientSize: toNumber(instance.PatientSize),
patientWeight: toNumber(instance.PatientWeight),
};
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_DEMOGRAPHIC_MODULE:
metadata = {
patientSex: instance.PatientSex,
};
break;
case WADO_IMAGE_LOADER_TAGS.IMAGE_PLANE_MODULE:
@@ -145,50 +179,56 @@ class MetadataProvider {
metadata = {
frameOfReferenceUID: instance.FrameOfReferenceUID,
rows: validNumber(instance.Rows),
columns: validNumber(instance.Columns),
imageOrientationPatient: validNumber(ImageOrientationPatient),
rowCosines: validNumber(rowCosines),
columnCosines: validNumber(columnCosines),
imagePositionPatient: validNumber(instance.ImagePositionPatient),
sliceThickness: validNumber(instance.SliceThickness),
sliceLocation: validNumber(instance.SliceLocation),
pixelSpacing: validNumber(PixelSpacing),
rowPixelSpacing: validNumber(rowPixelSpacing),
columnPixelSpacing: validNumber(columnPixelSpacing),
rows: toNumber(instance.Rows),
columns: toNumber(instance.Columns),
imageOrientationPatient: toNumber(ImageOrientationPatient),
rowCosines: toNumber(rowCosines),
columnCosines: toNumber(columnCosines),
imagePositionPatient: toNumber(instance.ImagePositionPatient),
sliceThickness: toNumber(instance.SliceThickness),
sliceLocation: toNumber(instance.SliceLocation),
pixelSpacing: toNumber(PixelSpacing),
rowPixelSpacing: toNumber(rowPixelSpacing),
columnPixelSpacing: toNumber(columnPixelSpacing),
};
break;
case WADO_IMAGE_LOADER_TAGS.IMAGE_PIXEL_MODULE:
metadata = {
samplesPerPixel: validNumber(instance.SamplesPerPixel),
samplesPerPixel: toNumber(instance.SamplesPerPixel),
photometricInterpretation: instance.PhotometricInterpretation,
rows: validNumber(instance.Rows),
columns: validNumber(instance.Columns),
bitsAllocated: validNumber(instance.BitsAllocated),
bitsStored: validNumber(instance.BitsStored),
highBit: validNumber(instance.HighBit),
pixelRepresentation: validNumber(instance.PixelRepresentation),
planarConfiguration: validNumber(instance.PlanarConfiguration),
pixelAspectRatio: validNumber(instance.PixelAspectRatio),
smallestPixelValue: validNumber(instance.SmallestPixelValue),
largestPixelValue: validNumber(instance.LargestPixelValue),
redPaletteColorLookupTableDescriptor: validNumber(
rows: toNumber(instance.Rows),
columns: toNumber(instance.Columns),
bitsAllocated: toNumber(instance.BitsAllocated),
bitsStored: toNumber(instance.BitsStored),
highBit: toNumber(instance.HighBit),
pixelRepresentation: toNumber(instance.PixelRepresentation),
planarConfiguration: toNumber(instance.PlanarConfiguration),
pixelAspectRatio: toNumber(instance.PixelAspectRatio),
smallestPixelValue: toNumber(instance.SmallestPixelValue),
largestPixelValue: toNumber(instance.LargestPixelValue),
redPaletteColorLookupTableDescriptor: toNumber(
instance.RedPaletteColorLookupTableDescriptor
),
greenPaletteColorLookupTableDescriptor: validNumber(
greenPaletteColorLookupTableDescriptor: toNumber(
instance.GreenPaletteColorLookupTableDescriptor
),
bluePaletteColorLookupTableDescriptor: validNumber(
bluePaletteColorLookupTableDescriptor: toNumber(
instance.BluePaletteColorLookupTableDescriptor
),
redPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
instance, "RedPaletteColorLookupTableData", "RedPaletteColorLookupTableDescriptor"
instance,
'RedPaletteColorLookupTableData',
'RedPaletteColorLookupTableDescriptor'
),
greenPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
instance, "GreenPaletteColorLookupTableData", "GreenPaletteColorLookupTableDescriptor"
instance,
'GreenPaletteColorLookupTableData',
'GreenPaletteColorLookupTableDescriptor'
),
bluePaletteColorLookupTableData: fetchPaletteColorLookupTableData(
instance, "BluePaletteColorLookupTableData", "BluePaletteColorLookupTableDescriptor"
instance,
'BluePaletteColorLookupTableData',
'BluePaletteColorLookupTableDescriptor'
),
};
@@ -206,8 +246,8 @@ class MetadataProvider {
: [WindowWidth];
metadata = {
windowCenter: validNumber(windowCenter),
windowWidth: validNumber(windowWidth),
windowCenter: toNumber(windowCenter),
windowWidth: toNumber(windowWidth),
};
break;
@@ -218,8 +258,8 @@ class MetadataProvider {
}
metadata = {
rescaleIntercept: validNumber(instance.RescaleIntercept),
rescaleSlope: validNumber(instance.RescaleSlope),
rescaleIntercept: toNumber(instance.RescaleIntercept),
rescaleSlope: toNumber(instance.RescaleSlope),
rescaleType: instance.RescaleType,
};
break;
@@ -331,7 +371,7 @@ class MetadataProvider {
case WADO_IMAGE_LOADER_TAGS.GENERAL_IMAGE_MODULE:
metadata = {
sopInstanceUid: instance.SOPInstanceUID,
instanceNumber: validNumber(instance.InstanceNumber),
instanceNumber: toNumber(instance.InstanceNumber),
lossyImageCompression: instance.LossyImageCompression,
lossyImageCompressionRatio: instance.LossyImageCompressionRatio,
lossyImageCompressionMethod: instance.LossyImageCompressionMethod,
@@ -361,7 +401,14 @@ class MetadataProvider {
}
_getUIDsFromImageID(imageId) {
if (imageId.includes('wadors:')) {
// TODO: adding csiv here is not really correct. Probably need to use
// metadataProvider.addImageIdToUIDs(imageId, {
// StudyInstanceUID,
// SeriesInstanceUID,
// SOPInstanceUID,
// })
// somewhere else
if (imageId.startsWith('wadors:')) {
const strippedImageId = imageId.split('/studies/')[1];
const splitImageId = strippedImageId.split('/');
@@ -381,26 +428,22 @@ class MetadataProvider {
}
// Maybe its a non-standard imageId
return this.imageIdToUIDs.get(imageId);
// check if the imageId starts with http:// or https:// using regex
// Todo: handle non http imageIds
let imageURI;
const urlRegex = /^(http|https):\/\//;
if (urlRegex.test(imageId)) {
imageURI = imageId;
} else {
imageURI = imageIdToURI(imageId);
}
return this.imageURIToUIDs.get(imageURI);
}
}
const metadataProvider = new MetadataProvider();
/**
* Returns the values as an array of javascript numbers
*
* @param element - The javascript object for the specified element in the metadata
* @returns {*}
*/
const validNumber = val => {
if (Array.isArray(val)) {
return val.map(v => (v !== undefined ? Number(v) : v));
} else {
return val !== undefined ? Number(val) : val;
}
};
export default metadataProvider;
const WADO_IMAGE_LOADER_TAGS = {
@@ -414,6 +457,7 @@ const WADO_IMAGE_LOADER_TAGS = {
SOP_COMMON_MODULE: 'sopCommonModule',
PET_ISOTOPE_MODULE: 'petIsotopeModule',
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
PATIENT_DEMOGRAPHIC_MODULE: 'patientDemographicModule',
// react-cornerstone-viewport specifc
PATIENT_MODULE: 'patientModule',
+72 -14
View File
@@ -3,21 +3,52 @@ import windowLevelPresets from './windowLevelPresets';
/*
* Supported Keys: https://craig.is/killing/mice
*/
export default [
{ commandName: 'setToolActive', commandOptions: { toolName: 'Zoom' }, label: 'Zoom', keys: ['z'], isEditable: true },
{ commandName: 'scaleUpViewport', label: 'Zoom In', keys: ['+'], isEditable: true },
{ commandName: 'scaleDownViewport', label: 'Zoom Out', keys: ['-'], isEditable: true },
{ commandName: 'fitViewportToWindow', label: 'Zoom to Fit', keys: ['='], isEditable: true },
{ commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'], isEditable: true },
{ commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'], isEditable: true },
const bindings = [
{
commandName: 'flipViewportVertical',
commandName: 'setToolActive',
commandOptions: { toolName: 'Zoom' },
label: 'Zoom',
keys: ['z'],
isEditable: true,
},
{
commandName: 'scaleUpViewport',
label: 'Zoom In',
keys: ['+'],
isEditable: true,
},
{
commandName: 'scaleDownViewport',
label: 'Zoom Out',
keys: ['-'],
isEditable: true,
},
{
commandName: 'fitViewportToWindow',
label: 'Zoom to Fit',
keys: ['='],
isEditable: true,
},
{
commandName: 'rotateViewportCW',
label: 'Rotate Right',
keys: ['r'],
isEditable: true,
},
{
commandName: 'rotateViewportCCW',
label: 'Rotate Left',
keys: ['l'],
isEditable: true,
},
{
commandName: 'flipViewportHorizontal',
label: 'Flip Horizontally',
keys: ['h'],
isEditable: true,
},
{
commandName: 'flipViewportHorizontal',
commandName: 'flipViewportVertical',
label: 'Flip Vertically',
keys: ['v'],
isEditable: true,
@@ -57,11 +88,36 @@ export default [
keys: ['pagedown'],
isEditable: true,
},
{ commandName: 'nextImage', label: 'Next Image', keys: ['down'], isEditable: true },
{ commandName: 'previousImage', label: 'Previous Image', keys: ['up'], isEditable: true },
{ commandName: 'firstImage', label: 'First Image', keys: ['home'], isEditable: true },
{ commandName: 'lastImage', label: 'Last Image', keys: ['end'], isEditable: true },
{ commandName: 'resetViewport', label: 'Reset', keys: ['space'], isEditable: true },
{
commandName: 'nextImage',
label: 'Next Image',
keys: ['down'],
isEditable: true,
},
{
commandName: 'previousImage',
label: 'Previous Image',
keys: ['up'],
isEditable: true,
},
{
commandName: 'firstImage',
label: 'First Image',
keys: ['home'],
isEditable: true,
},
{
commandName: 'lastImage',
label: 'Last Image',
keys: ['end'],
isEditable: true,
},
{
commandName: 'resetViewport',
label: 'Reset',
keys: ['space'],
isEditable: true,
},
{
commandName: 'cancelMeasurement',
label: 'Cancel Cornerstone Measurement',
@@ -122,3 +178,5 @@ export default [
keys: ['9'],
},
];
export default bindings;
@@ -99,17 +99,28 @@ export default class ExtensionManager {
*
* @param {Object[]} extensions - Array of extensions
*/
registerExtensions = (extensions, dataSources = []) => {
extensions.forEach(extension => {
registerExtensions = async (extensions, dataSources = []) => {
// Todo: we ideally should be able to run registrations in parallel
// but currently since some extensions need to be registered before
// others, we need to run them sequentially. We need a postInit hook
// to avoid this sequential async registration
for (const extension of extensions) {
const hasConfiguration = Array.isArray(extension);
if (hasConfiguration) {
const [ohifExtension, configuration] = extension;
this.registerExtension(ohifExtension, configuration, dataSources);
} else {
this.registerExtension(extension, {}, dataSources);
try {
if (hasConfiguration) {
const [ohifExtension, configuration] = extension;
await this.registerExtension(
ohifExtension,
configuration,
dataSources
);
} else {
await this.registerExtension(extension, {}, dataSources);
}
} catch (error) {
console.error(error);
}
});
}
};
/**
@@ -118,7 +129,11 @@ export default class ExtensionManager {
* @param {Object} extension
* @param {Object} configuration
*/
registerExtension = (extension, configuration = {}, dataSources = []) => {
registerExtension = async (
extension,
configuration = {},
dataSources = []
) => {
if (!extension) {
throw new Error('Attempting to register a null/undefined extension.');
}
@@ -140,10 +155,11 @@ export default class ExtensionManager {
// preRegistrationHook
if (extension.preRegistration) {
extension.preRegistration({
await extension.preRegistration({
servicesManager: this._servicesManager,
commandsManager: this._commandsManager,
hotkeysManager: this._hotkeysManager,
extensionManager: this,
appConfig: this._appConfig,
configuration,
});
@@ -186,6 +202,7 @@ export default class ExtensionManager {
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.UTILITY:
case MODULE_TYPES.HANGING_PROTOCOL:
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
@@ -299,14 +316,6 @@ export default class ExtensionManager {
});
}
_initHangingProtocolModule(extensionModule, extensionId) {
extensionModule.forEach(element => {
this.modulesMap[
`${extensionId}.${MODULE_TYPES.HANGING_PROTOCOL}.${element.name}`
] = element;
});
}
/**
*
* @private
@@ -40,18 +40,18 @@ describe('ExtensionManager.js', () => {
});
describe('registerExtensions()', () => {
it('calls registerExtension() for each extension', () => {
it('calls registerExtension() for each extension', async () => {
extensionManager.registerExtension = jest.fn();
// SUT
const fakeExtensions = [{ one: '1' }, { two: '2' }, { three: '3 ' }];
extensionManager.registerExtensions(fakeExtensions);
await extensionManager.registerExtensions(fakeExtensions);
// Assert
expect(extensionManager.registerExtension.mock.calls.length).toBe(3);
});
it('calls registerExtension() for each extension passing its configuration if tuple', () => {
it('calls registerExtension() for each extension passing its configuration if tuple', async () => {
const fakeConfiguration = { testing: true };
extensionManager.registerExtension = jest.fn();
@@ -61,7 +61,7 @@ describe('ExtensionManager.js', () => {
[{ two: '2' }, fakeConfiguration],
{ three: '3 ' },
];
extensionManager.registerExtensions(fakeExtensions);
await extensionManager.registerExtensions(fakeExtensions);
// Assert
expect(extensionManager.registerExtension.mock.calls[1][1]).toEqual(
@@ -91,30 +91,35 @@ describe('ExtensionManager.js', () => {
expect(extension.preRegistration.mock.calls[0][0]).toEqual({
servicesManager,
commandsManager,
extensionManager,
appConfig,
configuration: extensionConfiguration,
});
});
it('logs a warning if the extension is null or undefined', () => {
it('logs a warning if the extension is null or undefined', async () => {
const undefinedExtension = undefined;
const nullExtension = null;
expect(() => {
extensionManager.registerExtension(undefinedExtension);
}).toThrow('Attempting to register a null/undefined extension.');
await expect(
extensionManager.registerExtension(undefinedExtension)
).rejects.toThrow(
new Error('Attempting to register a null/undefined extension.')
);
expect(() => {
extensionManager.registerExtension(nullExtension);
}).toThrow('Attempting to register a null/undefined extension.');
await expect(
extensionManager.registerExtension(nullExtension)
).rejects.toThrow(
new Error('Attempting to register a null/undefined extension.')
);
});
it('logs a warning if the extension does not have an id', () => {
it('logs a warning if the extension does not have an id', async () => {
const extensionWithoutId = {};
expect(() => {
extensionManager.registerExtension(extensionWithoutId);
}).toThrow(new Error('Extension ID not set'));
await expect(
extensionManager.registerExtension(extensionWithoutId)
).rejects.toThrow(new Error('Extension ID not set'));
});
it('tracks which extensions have been registered', () => {
@@ -153,7 +158,7 @@ describe('ExtensionManager.js', () => {
);
});
it('logs an error if an exception is thrown while retrieving a module', () => {
it('logs an error if an exception is thrown while retrieving a module', async () => {
const extensionWithBadModule = {
id: 'hello-world',
getViewportModule: () => {
@@ -161,9 +166,9 @@ describe('ExtensionManager.js', () => {
},
};
expect(() => {
extensionManager.registerExtension(extensionWithBadModule);
}).toThrow();
await expect(
extensionManager.registerExtension(extensionWithBadModule)
).rejects.toThrow();
});
it('successfully passes dependencies to each module along with extension configuration', () => {
@@ -194,7 +199,7 @@ describe('ExtensionManager.js', () => {
});
});
it('successfully registers a module for each module type', () => {
it('successfully registers a module for each module type', async () => {
const extension = {
id: 'hello-world',
getViewportModule: () => {
@@ -224,9 +229,12 @@ describe('ExtensionManager.js', () => {
getContextModule: () => {
return [{}];
},
getUtilityModule: () => {
return [{}];
},
};
extensionManager.registerExtension(extension);
await extensionManager.registerExtension(extension);
// Registers 1 module per module type
Object.keys(extensionManager.modules).forEach(moduleType => {
@@ -8,4 +8,5 @@ export default {
CONTEXT: 'contextModule',
LAYOUT_TEMPLATE: 'layoutTemplateModule',
HANGING_PROTOCOL: 'hangingProtocolModule',
UTILITY: 'utilityModule',
};
+3 -3
View File
@@ -3,7 +3,6 @@ import { ServicesManager } from './services';
import classes, { CommandsManager, HotkeysManager } from './classes/';
import DICOMWeb from './DICOMWeb';
import DICOMSR from './DICOMSR';
import errorHandler from './errorHandler.js';
import log from './log.js';
import object from './object.js';
@@ -27,6 +26,7 @@ import {
HangingProtocolService,
pubSubServiceInterface,
UserAuthenticationService,
SegmentationService,
} from './services';
import IWebApiDataSource from './DataSources/IWebApiDataSource';
@@ -54,7 +54,6 @@ const OHIF = {
object,
log,
DICOMWeb,
DICOMSR,
viewer: {},
//
CineService,
@@ -68,6 +67,7 @@ const OHIF = {
ViewportGridService,
HangingProtocolService,
UserAuthenticationService,
SegmentationService,
IWebApiDataSource,
DicomMetadataStore,
pubSubServiceInterface,
@@ -91,7 +91,6 @@ export {
object,
log,
DICOMWeb,
DICOMSR,
//
CineService,
UIDialogService,
@@ -104,6 +103,7 @@ export {
ViewportGridService,
HangingProtocolService,
UserAuthenticationService,
SegmentationService,
IWebApiDataSource,
DicomMetadataStore,
pubSubServiceInterface,
+1 -1
View File
@@ -21,7 +21,6 @@ describe('Top level exports', () => {
'object',
'log',
'DICOMWeb',
'DICOMSR',
'OHIF',
//
'CineService',
@@ -33,6 +32,7 @@ describe('Top level exports', () => {
'MeasurementService',
'ToolBarService',
'ViewportGridService',
'SegmentationService',
'HangingProtocolService',
'UserAuthenticationService',
'IWebApiDataSource',
@@ -5,15 +5,17 @@ const publicAPI = {
getState: _getState,
setCine: _setCine,
setIsCineEnabled: _setIsCineEnabled,
playClip: _playClip,
stopClip: _stopClip,
setServiceImplementation,
};
const serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setCine: () =>
console.warn('setCine() NOT IMPLEMENTED'),
_setIsCineEnabled: () =>
console.warn('setIsCineEnabled() NOT IMPLEMENTED'),
_setCine: () => console.warn('setCine() NOT IMPLEMENTED'),
_playClip: () => console.warn('playClip() NOT IMPLEMENTED'),
_stopClip: () => console.warn('stopClip() NOT IMPLEMENTED'),
_setIsCineEnabled: () => console.warn('setIsCineEnabled() NOT IMPLEMENTED'),
};
function _getState() {
@@ -28,10 +30,20 @@ function _setIsCineEnabled(isCineEnabled) {
return serviceImplementation._setIsCineEnabled(isCineEnabled);
}
function _playClip(element, playClipOptions) {
return serviceImplementation._playClip(element, playClipOptions);
}
function _stopClip(element) {
return serviceImplementation._stopClip(element);
}
function setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
}) {
if (getStateImplementation) {
serviceImplementation._getState = getStateImplementation;
@@ -42,6 +54,14 @@ function setServiceImplementation({
if (setIsCineEnabledImplementation) {
serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
}
if (playClipImplementation) {
serviceImplementation._playClip = playClipImplementation;
}
if (stopClipImplementation) {
serviceImplementation._stopClip = stopClipImplementation;
}
}
export default {
@@ -1,33 +1,56 @@
import dcmjs from 'dcmjs'
import dcmjs from 'dcmjs';
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
import createStudyMetadata from './createStudyMetadata';
import EVENTS from './EVENTS';
const EVENTS = {
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
SERIES_UPDATED: 'event::dicomMetadataStore:seriesUpdated',
};
/**
* @example
* studies: [
* {
* StudyInstanceUID: string,
* isLoaded: boolean,
* series: [
* {
* Modality: string,
* SeriesInstanceUID: string,
* SeriesNumber: number,
* SeriesDescription: string,
* instances: [
* {
* // naturalized instance metadata
* SOPInstanceUID: string,
* SOPClassUID: string,
* Rows: number,
* Columns: number,
* PatientSex: string,
* Modality: string,
* InstanceNumber: string,
* },
* {
* // instance 2
* },
* ],
* },
* {
* // series 2
* },
* ],
* },
* ],
*/
const _model = {
studies: [],
// studies: [{
// seriesLists: [
// {
// // Series in study from dicom web server 1 (or different backend 1)
// series: [{
// instances: [{
// ...instanceMetadata // Naturalized DICOM.
// }],
// ...seriesMetadata
// }],
// clientName
// },
// {
// // Series in study from dicom web server 2 (or different backend 2)
// },
// ],
// ...studyMetadata,
// }]
};
function _getStudyInstanceUIDs() {
return _model.studies.map(aStudy => aStudy.StudyInstanceUID)
return _model.studies.map(aStudy => aStudy.StudyInstanceUID);
}
function _getStudy(StudyInstanceUID) {
@@ -60,7 +83,7 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
);
}
function _getInstanceFromImageId(imageId) {
function _getInstanceByImageId(imageId) {
for (let study of _model.studies) {
for (let series of study.series) {
for (let instance of series.instances) {
@@ -72,6 +95,52 @@ function _getInstanceFromImageId(imageId) {
}
}
/**
* Update the metadata of a specific series
* @param {*} StudyInstanceUID
* @param {*} SeriesInstanceUID
* @param {*} metadata metadata inform of key value pairs
* @returns
*/
function _updateMetadataForSeries(
StudyInstanceUID,
SeriesInstanceUID,
metadata
) {
const study = _getStudy(StudyInstanceUID);
if (!study) {
return;
}
const series = study.series.find(
aSeries => aSeries.SeriesInstanceUID === SeriesInstanceUID
);
const { instances } = series;
// update all instances metadata for this series with the new metadata
instances.forEach(instance => {
Object.keys(metadata).forEach(key => {
// if metadata[key] is an object, we need to merge it with the existing
// metadata of the instance
if (typeof metadata[key] === 'object') {
instance[key] = { ...instance[key], ...metadata[key] };
}
// otherwise, we just replace the existing metadata with the new one
else {
instance[key] = metadata[key];
}
});
});
// broadcast the series updated event
this._broadcastEvent(EVENTS.SERIES_UPDATED, {
SeriesInstanceUID,
StudyInstanceUID,
madeInClient: true,
});
}
const BaseImplementation = {
EVENTS,
listeners: {},
@@ -80,7 +149,9 @@ const BaseImplementation = {
// If Arraybuffer, parse to DICOMJSON before naturalizing.
if (dicomJSONDatasetOrP10ArrayBuffer instanceof ArrayBuffer) {
const dicomData = dcmjs.data.DicomMessage.readFile(dicomJSONDatasetOrP10ArrayBuffer);
const dicomData = dcmjs.data.DicomMessage.readFile(
dicomJSONDatasetOrP10ArrayBuffer
);
dicomJSONDataset = dicomData.dict;
} else {
@@ -140,6 +211,7 @@ const BaseImplementation = {
let study = _getStudy(StudyInstanceUID);
if (!study) {
study = createStudyMetadata(StudyInstanceUID);
study.StudyDescription = seriesSummaryMetadata[0].StudyDescription;
_model.studies.push(study);
}
@@ -179,15 +251,18 @@ const BaseImplementation = {
getStudy: _getStudy,
getSeries: _getSeries,
getInstance: _getInstance,
getInstanceFromImageId: _getInstanceFromImageId,
getInstanceByImageId: _getInstanceByImageId,
updateMetadataForSeries: _updateMetadataForSeries,
};
const DicomMetadataStore = Object.assign(
// get study
// iterate over all series
{},
BaseImplementation,
pubSubServiceInterface
);
export { DicomMetadataStore };
export default DicomMetadataStore;
@@ -1,7 +0,0 @@
const EVENTS = {
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
};
export default EVENTS;
@@ -3,6 +3,7 @@ import createSeriesMetadata from './createSeriesMetadata';
function createStudyMetadata(StudyInstanceUID) {
return {
StudyInstanceUID,
StudyDescription: '',
isLoaded: false,
series: [],
/**
@@ -10,7 +11,7 @@ function createStudyMetadata(StudyInstanceUID) {
* @param {object} instance
* @returns {bool} true if series were added; false if series already exist
*/
addInstanceToSeries: function (instance) {
addInstanceToSeries: function(instance) {
const { SeriesInstanceUID } = instance;
const existingSeries = this.series.find(
s => s.SeriesInstanceUID === SeriesInstanceUID
@@ -29,7 +30,7 @@ function createStudyMetadata(StudyInstanceUID) {
* @param {string} instances[].SeriesInstanceUID
* @returns {bool} true if series were added; false if series already exist
*/
addInstancesToSeries: function (instances) {
addInstancesToSeries: function(instances) {
const { SeriesInstanceUID } = instances[0];
const existingSeries = this.series.find(
s => s.SeriesInstanceUID === SeriesInstanceUID
@@ -43,7 +44,7 @@ function createStudyMetadata(StudyInstanceUID) {
}
},
setSeriesMetadata: function (SeriesInstanceUID, seriesMetadata) {
setSeriesMetadata: function(SeriesInstanceUID, seriesMetadata) {
let existingSeries = this.series.find(
s => s.SeriesInstanceUID === SeriesInstanceUID
);
@@ -26,7 +26,7 @@ const findInstance = (instance, displaySets) => {
if (findInSet(instance, displayset.others)) return true;
}
return false;
}
};
export default class DisplaySetService {
constructor() {
@@ -89,6 +89,20 @@ export default class DisplaySetService {
return displaySet;
}
setDisplaySetMetadataInvalidated(displaySetInstanceUID) {
const displaySet = this.getDisplaySetByUID(displaySetInstanceUID);
if (!displaySet) {
return;
}
// broadcast event to update listeners with the new displaySets
this._broadcastEvent(
EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
displaySetInstanceUID
);
}
deleteDisplaySet(displaySetInstanceUID) {
const { activeDisplaySets } = this;
@@ -164,7 +178,7 @@ export default class DisplaySetService {
}
// TODO: This is tricky. How do we know we're not resetting to the same/existing DSs?
// TODO: This is likely run anytime we touch DicomMetadataStore. How do we prevent uneccessary broadcasts?
// TODO: This is likely run anytime we touch DicomMetadataStore. How do we prevent unnecessary broadcasts?
if (displaySetsAdded && displaySetsAdded.length) {
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, {
@@ -213,10 +227,14 @@ export default class DisplaySetService {
this._addDisplaySetsToCache(displaySets);
this._addActiveDisplaySets(displaySets);
instances = instances.filter(instance => !findInstance(instance, displaySets))
instances = instances.filter(
instance => !findInstance(instance, displaySets)
);
}
allDisplaySets = allDisplaySets ? [...allDisplaySets, ...displaySets] : displaySets;
allDisplaySets = allDisplaySets
? [...allDisplaySets, ...displaySets]
: displaySets;
if (!instances.length) return allDisplaySets;
}
@@ -2,6 +2,8 @@ const EVENTS = {
DISPLAY_SETS_ADDED: 'event::displaySetService:displaySetsAdded',
DISPLAY_SETS_CHANGED: 'event::displaySetService:displaySetsChanged',
DISPLAY_SETS_REMOVED: 'event::displaySetService:displaySetsRemoved',
DISPLAY_SET_SERIES_METADATA_INVALIDATED:
'event::displaySetService:displaySetSeriesMetadataInvalidated',
};
export default EVENTS;
@@ -1,6 +1,5 @@
import validate from './lib/validator';
/**
* Match a Metadata instance against rules using Validate.js for validation.
* @param {InstanceMetadata} metadataInstance Metadata instance object
@@ -36,7 +35,13 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
// Create a single attribute object to be validated, since metadataInstance is an
// instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata)
const attributeValue = metadataInstance[attribute];
let attributeValue = metadataInstance[attribute];
if (attributeValue === undefined) {
if (attribute === 'NumberOfStudyRelatedSeries') {
attributeValue = metadataInstance.series?.length;
}
// Add other computable values such as modalities in study
}
const attributeMap = {
[attribute]: attributeValue,
};
@@ -1,4 +1,3 @@
import cloneDeep from 'lodash.clonedeep';
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
import sortBy from '../../utils/sortBy.js';
import ProtocolEngine from './ProtocolEngine';
@@ -6,11 +5,8 @@ import ProtocolEngine from './ProtocolEngine';
const EVENTS = {
STAGE_CHANGE: 'event::hanging_protocol_stage_change',
NEW_LAYOUT: 'event::hanging_protocol_new_layout',
};
const VIEWPORT_SETTING_TYPES = {
PROPS: 'props',
VIEWPORT: 'viewport',
CUSTOM_IMAGE_LOAD_PERFORMED:
'event::hanging_protocol_custom_image_load_performed',
};
class HangingProtocolService {
@@ -20,12 +16,38 @@ class HangingProtocolService {
this.ProtocolEngine = undefined;
this.protocol = undefined;
this.stage = undefined;
/**
* An array that contains for each viewport (viewportIndex) specified in the
* hanging protocol, an object of the form
*
* {
* viewportOptions,
* displaySetsInfo, // contains array of [ { SeriesInstanceUID, displaySetOPtions}, ... ]
* }
*/
this.matchDetails = [];
/**
* displaySetMatchDetails = <displaySetId, match>
* DisplaySetId is the id defined in the hangingProtocol
* match is an object that contains information about
*
* {
* SeriesInstanceUID,
* StudyInstanceUID,
* matchDetails,
* matchingScore,
* sortingInfo
* }
*/
this.displaySetMatchDetails = new Map();
this.hpAlreadyApplied = [];
this.studies = [];
this.customViewportSettings = [];
this.customAttributeRetrievalCallbacks = {};
this.listeners = {};
this.registeredImageLoadStrategies = {};
this.activeImageLoadStrategyName = null;
this.customImageLoadPerformed = false;
Object.defineProperty(this, 'EVENTS', {
value: EVENTS,
writable: false,
@@ -43,6 +65,10 @@ class HangingProtocolService {
// this.ProtocolEngine.reset()
}
getDisplaySetsMatchDetails() {
return this.displaySetMatchDetails;
}
getState() {
return [this.matchDetails, this.hpAlreadyApplied];
}
@@ -54,7 +80,7 @@ class HangingProtocolService {
addProtocols(protocols) {
protocols.forEach(protocol => {
if (this.protocols.indexOf(protocol) === -1) {
this.protocols.push(protocol);
this.protocols.push(this._validateProtocol(protocol));
}
});
}
@@ -81,20 +107,37 @@ class HangingProtocolService {
this._setProtocol(protocol);
}
setHangingProtocolAppliedForViewport(i) {
this.hpAlreadyApplied[i] = true;
/**
* Returns true, if the hangingProtocol has a custom loading strategy for the images
* and its callback has been added to the HangingProtocolService
* @returns {boolean} true
*/
hasCustomImageLoadStrategy() {
return (
this.activeImageLoadStrategyName !== null &&
this.registeredImageLoadStrategies[
this.activeImageLoadStrategyName
] instanceof Function
);
}
getCustomImageLoadPerformed() {
return this.customImageLoadPerformed;
}
/**
* Adds a custom setting that can be chosen in the HangingProtocol UI and applied to a Viewport
*
* @param settingId The ID used to refer to the setting (e.g. 'displayCADMarkers')
* @param settingName The name of the setting to be displayed (e.g. 'Display CAD Markers')
* @param options
* @param callback A function to be run after a viewport is rendered with a series
* Set the strategy callback for loading images to the HangingProtocolService
* @param {string} name strategy name
* @param {Function} callback image loader callback
*/
addCustomViewportSetting(...params) {
this.customViewportSettings.push(...params);
registerImageLoadStrategy(name, callback) {
if (callback instanceof Function && name) {
this.registeredImageLoadStrategies[name] = callback;
}
}
setHangingProtocolAppliedForViewport(i) {
this.hpAlreadyApplied[i] = true;
}
/**
@@ -134,12 +177,82 @@ class HangingProtocolService {
}
}
/**
* Executes the callback function for the custom loading strategy for the images
* if no strategy is set, the default strategy is used
*/
runImageLoadStrategy(data) {
const loader = this.registeredImageLoadStrategies[
this.activeImageLoadStrategyName
];
const loadedData = loader({
data,
displaySetsMatchDetails: this.getDisplaySetsMatchDetails(),
matchDetails: this.matchDetails,
});
// if loader successfully re-arranged the data with the custom strategy
// and returned the new props, then broadcast them
if (!loadedData) {
return;
}
this.customImageLoadPerformed = true;
this._broadcastChange(this.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, loadedData);
}
_validateProtocol(protocol) {
protocol.id = protocol.id || protocol.name;
// Automatically compute some number of attributes if they
// aren't present. Makes defining new HPs easier.
protocol.name = protocol.name || protocol.id;
const { stages } = protocol;
// Generate viewports automatically as required.
stages.forEach(stage => {
if (!stage.viewports) {
stage.viewports = [];
const { rows, columns } = stage.viewportStructure.properties;
for (let i = 0; i < rows * columns; i++) {
stage.viewports.push({
viewportOptions: {},
displaySets: [],
});
}
} else {
stage.viewports.forEach(viewport => {
viewport.viewportOptions = viewport.viewportOptions || {};
if (!viewport.displaySets) {
viewport.displaySets = [];
} else {
viewport.displaySets.forEach(displaySet => {
displaySet.options = displaySet.options || {};
});
}
});
}
});
return protocol;
}
_setProtocol(protocol) {
// TODO: Add proper Protocol class to validate the protocols
// which are entered manually
this.stage = 0;
this.protocol = protocol;
this._updateViewports(protocol);
const { imageLoadStrategy } = protocol;
if (imageLoadStrategy) {
// check if the imageLoadStrategy is a valid strategy
if (
this.registeredImageLoadStrategies[imageLoadStrategy] instanceof
Function
) {
this.activeImageLoadStrategyName = imageLoadStrategy;
}
}
this._updateViewports();
}
/**
@@ -173,6 +286,9 @@ class HangingProtocolService {
return;
}
// reset displaySetMatchDetails
this.displaySetMatchDetails = new Map();
// Retrieve the current stage
const stageModel = this._getCurrentStageModel();
@@ -182,18 +298,14 @@ class HangingProtocolService {
!stageModel ||
!stageModel.viewportStructure ||
!stageModel.viewports ||
!stageModel.displaySets ||
!stageModel.viewports.length
) {
return;
}
// Retrieve the layoutTemplate associated with the current display set's viewport structure
// If no such template name exists, stop here.
// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
const layoutTemplateName = 'gridLayout';
if (!layoutTemplateName) {
return;
}
this.customImageLoadPerformed = false;
const { type: layoutType } = stageModel.viewportStructure;
// Retrieve the properties associated with the current display set's viewport structure template
// If no such layout properties exist, stop here.
@@ -202,97 +314,70 @@ class HangingProtocolService {
return;
}
const { columns: numCols, rows: numRows } = layoutProps;
const { columns: numCols, rows: numRows, layoutOptions = [] } = layoutProps;
this._broadcastChange(this.EVENTS.NEW_LAYOUT, {
layoutType,
numRows,
numCols,
layoutOptions,
});
// Empty the matchDetails associated with the ProtocolEngine.
// This will be used to store the pass/fail details and score
// for each of the viewport matching procedures
// Matching the displaySets
// Note: this is happening before displaySets are created. Here, displaySet
// only contains the information of the id of the displaySet to be matched
// based on some rules
stageModel.displaySets.forEach(displaySet => {
const { bestMatch } = this._matchImages(displaySet);
this.displaySetMatchDetails.set(displaySet.id, bestMatch);
});
// Loop through each viewport
stageModel.viewports.forEach((viewport, viewportIndex) => {
const { viewportOptions } = viewport;
this.hpAlreadyApplied.push(false);
const details = this._matchImages(viewport);
let currentMatch = details.bestMatch;
// DisplaySets for the viewport, Note: this is not the actual displaySet,
// but it is a info to locate the displaySet from the displaySetService
let displaySetsInfo = [];
viewport.displaySets.forEach(({ id, options: displaySetOptions }) => {
const viewportDisplaySet = this.displaySetMatchDetails.get(id);
const currentViewportData = {
viewportIndex,
SeriesInstanceUID: currentMatch && currentMatch.SeriesInstanceUID,
if (viewportDisplaySet) {
const { SeriesInstanceUID } = viewportDisplaySet;
const displaySetInfo = {
SeriesInstanceUID,
displaySetOptions,
};
displaySetsInfo.push(displaySetInfo);
} else {
console.warn(
`
The hanging protocol viewport is requesting to display ${id} displaySet that is not
matched based on the provided criteria (e.g. matching rules).
`
);
}
});
this.matchDetails[viewportIndex] = {
viewportOptions,
displaySetsInfo,
};
// Viewport Settings
//
// protocol defined callback
const protocolCallbacks = viewport.viewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS
);
// manually added callback
const customCallbacks = this.customViewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS
);
const callbacks = protocolCallbacks.concat(customCallbacks);
// if we have callbacks to applied at the app level or at the HP level
if (callbacks.length) {
currentViewportData.renderedCallback = (element, ToolBarService) => {
callbacks.forEach(setting => {
const { commandName, options } = setting;
options.viewportIndex = viewportIndex;
options.element = element;
// Toolbar service to handle tool activation
if (commandName === 'setToolActive') {
ToolBarService.recordInteraction(options);
return;
}
// other commands
this._commandsManager.runCommand(commandName, options);
});
};
}
// initial viewport settings defined by protocol
const protocolInitialViewport = viewport.viewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT
);
// custom added initial viewport settings
const customInitialViewport = this.customViewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT
);
// TODO: conflict might happen between protocol and custom viewport settings
const viewportSettings = protocolInitialViewport.concat(
customInitialViewport
);
if (viewportSettings.length) {
const initialViewport = {};
viewportSettings.forEach(setting => {
const { options } = setting;
if (!options) return;
// Do not manipulate the hp settings
const viewportOptions = cloneDeep(options);
Object.entries(viewportOptions).forEach(([key, value]) => {
initialViewport[key] = value;
});
});
currentViewportData.initialViewport = initialViewport;
}
this.matchDetails[viewportIndex] = currentViewportData;
});
}
// Match images given a list of Studies and a Viewport's image matching reqs
_matchImages(viewport) {
_matchImages(displaySet) {
console.log('ProtocolEngine::matchImages');
// TODO: matching is applied on study and series level, instance
// level matching needs to be added in future
const { studyMatchingRules, seriesMatchingRules } = viewport;
// Todo: handle fusion viewports by not taking the first displaySet rule for the viewport
const { studyMatchingRules, seriesMatchingRules } = displaySet;
const matchingScores = [];
let highestStudyMatchingScore = 0;
@@ -460,6 +545,7 @@ class HangingProtocolService {
* within the measurement service and the source needs to update.
* @return void
*/
// Todo: why do we have a separate broadcastChange function here?
_broadcastChange(eventName, eventData) {
const hasListeners = Object.keys(this.listeners).length > 0;
const hasCallbacks = Array.isArray(this.listeners[eventName]);
@@ -1,41 +1,6 @@
import { HPMatcher } from './HPMatcher.js';
import { sortByScore } from './lib/sortByScore';
const deafultProtocol = {
id: 'defaultProtocol',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2021-02-23T19:22:08.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
stages: [
{
id: 'nwzau7jDkEkL8djfr',
name: 'oneByOne',
viewportStructure: {
type: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportSettings: [],
imageMatchingRules: [],
seriesMatchingRules: [],
studyMatchingRules: [],
},
],
createdDate: '2021-02-23T19:22:08.894Z',
},
],
numberOfPriorsReferenced: -1,
};
export default class ProtocolEngine {
constructor(protocols, customAttributeRetrievalCallbacks) {
this.protocols = protocols;
@@ -117,7 +82,7 @@ export default class ProtocolEngine {
/**
* Finds the best protocols from Protocol Store, matching each protocol matching rules
* with the given study. The best protocol are orded by score and returned in an array
* with the given study. The best protocol are ordered by score and returned in an array
* @param {Object} study StudyMetadata instance object
* @return {Array} Array of match objects or an empty array if no match was found
* Each match object has the score of the matching and the matched
@@ -131,7 +96,10 @@ export default class ProtocolEngine {
// We clone it so that we don't accidentally add the
// numberOfPriorsReferenced rule to the Protocol itself.
let rules = protocol.protocolMatchingRules.slice();
if (!rules) {
if (!rules || !rules.length) {
console.warn(
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules'
);
return;
}
@@ -153,7 +121,7 @@ export default class ProtocolEngine {
return [
{
score: 1,
protocol: deafultProtocol,
protocol: this.protocols.find(protocol => protocol.id === 'default'),
},
];
}
@@ -15,7 +15,7 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
* Measurement schema
*
* @typedef {Object} Measurement
* @property {number} id -
* @property {number} uid -
* @property {string} SOPInstanceUID -
* @property {string} FrameOfReferenceUID -
* @property {string} referenceSeriesUID -
@@ -30,24 +30,31 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
/* Measurement schema keys for object validation. */
const MEASUREMENT_SCHEMA_KEYS = [
'id',
'uid',
'data',
'getReport',
'displayText',
'SOPInstanceUID',
'FrameOfReferenceUID',
'referenceStudyUID',
'referenceSeriesUID',
'displaySetInstanceUID',
'label',
'description',
'type',
'unit',
'points',
'source',
'toolName',
'metadata',
// Todo: we shouldn't need to have all these here.
'area', // TODO: Add concept names instead (descriptor)
'mean',
'stdDev',
'length',
'shortestDiameter',
'longestDiameter',
'text', // NOTE: There is nothing like this in SR.
'points',
'source',
'cachedStats',
];
const EVENTS = {
@@ -65,10 +72,25 @@ const VALUE_TYPES = {
POINT: 'value_type::point',
BIDIRECTIONAL: 'value_type::shortAxisLongAxis', // TODO -> Discuss with Danny. => just using SCOORD values isn't enough here.
ELLIPSE: 'value_type::ellipse',
RECTANGLE: 'value_type::rectangle',
MULTIPOINT: 'value_type::multipoint',
CIRCLE: 'value_type::circle',
ROI_THRESHOLD: 'value_type::roiThreshold',
ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual',
};
/**
* MeasurementService class that supports source management and measurement management.
* Sources can be any library that can provide "annotations" (e.g. cornerstone-tools, cornerstone, etc.)
* The flow, is that by creating a source and mappings (annotation <-> measurement), we
* can convert back and forth between the two. MeasurementPanel in OHIF uses the measurement service
* to manage the measurements, and any edit to the measurements will be reflected back at the
* library level state (e.g. cornerstone-tools, cornerstone, etc.) by converting the
* edited measurements back to the original annotations and then updating the annotations.
*
* Note and Todo: We should be able to support measurements that are composed of multiple
* annotations, but that is not the case at the moment.
*/
class MeasurementService {
constructor() {
this.sources = {};
@@ -92,6 +114,45 @@ class MeasurementService {
Object.assign(this, pubSubServiceInterface);
}
/**
* Adds the given schema to the measurement service schema list.
* This method should be used to add custom tool schema to the measurement service.
* @param {Array} schema schema for validation
*/
addMeasurementSchemaKeys(schema) {
if (!Array.isArray(schema)) {
schema = [schema];
}
MEASUREMENT_SCHEMA_KEYS.push(...schema);
}
/**
* Adds the given valueType to the measurement service valueType object.
* This method should be used to add custom valueType to the measurement service.
* @param {*} valueType
* @returns
*/
addValueType(valueType) {
if (VALUE_TYPES[valueType]) {
return;
}
// check if valuetype is valid , and if values are strings
if (!valueType || typeof valueType !== 'object') {
console.warn(
`MeasurementService: addValueType: invalid valueType: ${valueType}`
);
return;
}
Object.keys(valueType).forEach(key => {
if (!VALUE_TYPES[key]) {
VALUE_TYPES[key] = valueType[key];
}
});
}
/**
* Get all measurements.
*
@@ -106,17 +167,17 @@ class MeasurementService {
}
/**
* Get specific measurement by its id.
* Get specific measurement by its uid.
*
* @param {string} id Id of the measurement
* @param {string} uid measurement uid
* @return {Measurement} Measurement instance
*/
getMeasurement(id) {
getMeasurement(measurementUID) {
let measurement = null;
const measurements = this.measurements[id];
const measurements = this.measurements[measurementUID];
if (measurements && Object.keys(measurements).length > 0) {
measurement = this.measurements[id];
measurement = this.measurements[measurementUID];
}
return measurement;
@@ -138,69 +199,71 @@ class MeasurementService {
throw new Error('Source version not provided.');
}
const id = guid();
// Go over all the keys inside the sources and check if the source
// name and version matches with the existing sources.
const sourceKeys = Object.keys(this.sources);
for (let i = 0; i < sourceKeys.length; i++) {
const source = this.sources[sourceKeys[i]];
if (source.name === name && source.version === version) {
return source;
}
}
const uid = guid();
const source = {
id,
uid,
name,
version,
};
source.addOrUpdate = (definition, measurement) => {
return this.addOrUpdate(source, definition, measurement);
source.annotationToMeasurement = (annotationType, annotation) => {
return this.annotationToMeasurement(source, annotationType, annotation);
};
source.remove = id => {
return this.remove(id, source);
source.remove = (measurementUID, eventDetails) => {
return this.remove(measurementUID, source, eventDetails);
};
source.getAnnotation = (definition, measurementId) => {
return this.getAnnotation(source, definition, measurementId);
source.getAnnotation = (annotationType, measurementId) => {
return this.getAnnotation(source, annotationType, measurementId);
};
log.info(`New '${name}@${version}' source added.`);
this.sources[id] = source;
this.sources[uid] = source;
return source;
}
getSource(name, version) {
const { sources } = this;
const id = this._getSourceId(name, version);
const uid = this._getSourceUID(name, version);
return sources[id];
return sources[uid];
}
getSourceMappings(name, version) {
const { mappings } = this;
const id = this._getSourceId(name, version);
const uid = this._getSourceUID(name, version);
return mappings[id];
}
_getSourceId(name, version) {
const { sources } = this;
const sourceId = Object.keys(sources).find(sourceId => {
const source = sources[sourceId];
return source.name === name && source.version === version;
});
return sourceId;
return mappings[uid];
}
/**
* Add a new measurement matching criteria along with mapping functions.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} definition Definition of the measurement (Annotation Type)
* @param {string} annotationType annotation type to match which can be e.g., Length, Bidirectional, etc.
* @param {MatchingCriteria} matchingCriteria The matching criteria
* @param {Function} toSourceSchema Mapping function to source schema
* @param {Function} toAnnotationSchema Mapping function to annotation schema
* @param {Function} toMeasurementSchema Mapping function to measurement schema
* @return void
*/
addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toAnnotationSchema,
toMeasurementSchema
) {
if (!this._isValidSource(source)) {
@@ -211,11 +274,11 @@ class MeasurementService {
throw new Error('Matching criteria not provided.');
}
if (!definition) {
throw new Error('Definition not provided.');
if (!annotationType) {
throw new Error('annotationType not provided.');
}
if (!toSourceSchema) {
if (!toAnnotationSchema) {
throw new Error('Mapping function to source schema not provided.');
}
@@ -225,19 +288,19 @@ class MeasurementService {
const mapping = {
matchingCriteria,
definition,
toSourceSchema,
annotationType,
toAnnotationSchema,
toMeasurementSchema,
};
if (Array.isArray(this.mappings[source.id])) {
this.mappings[source.id].push(mapping);
if (Array.isArray(this.mappings[source.uid])) {
this.mappings[source.uid].push(mapping);
} else {
this.mappings[source.id] = [mapping];
this.mappings[source.uid] = [mapping];
}
log.info(
`New measurement mapping added to source '${this._getSourceInfo(
`New measurement mapping added to source '${this._getSourceToString(
source
)}'.`
);
@@ -247,81 +310,82 @@ class MeasurementService {
* Get annotation for specific source.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} definition The source definition
* @param {string} measurementId The measurement service measurement id
* @param {string} annotationType The source annotationType
* @param {string} measurementUID The measurement service measurement uid
* @return {Object} Source measurement schema
*/
getAnnotation(source, definition, measurementId) {
getAnnotation(source, annotationType, measurementUID) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
}
if (!definition) {
log.warn('No source definition provided. Exiting early.');
if (!annotationType) {
log.warn('No source annotationType provided. Exiting early.');
return;
}
const measurement = this.getMeasurement(measurementUID);
const mapping = this._getMappingByMeasurementSource(
measurementId,
definition
measurement,
annotationType
);
const measurement = this.getMeasurement(measurementId);
if (mapping) return mapping.toSourceSchema(measurement, definition);
if (mapping) {
return mapping.toAnnotationSchema(measurement, annotationType);
}
const matchingMapping = this._getMatchingMapping(
source,
definition,
annotationType,
measurement
);
if (matchingMapping) {
log.info('Matching mapping found:', matchingMapping);
const { toSourceSchema, definition } = matchingMapping;
return toSourceSchema(measurement, definition);
const { toAnnotationSchema, annotationType } = matchingMapping;
return toAnnotationSchema(measurement, annotationType);
}
}
update(id, measurement, notYetUpdatedAtSource = false) {
if (this.measurements[id]) {
const updatedMeasurement = {
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
};
log.info(
`Updating internal measurement representation...`,
updatedMeasurement
);
this.measurements[id] = updatedMeasurement;
this._broadcastEvent(
// Add an internal flag to say the measurement has not yet been updated at source.
this.EVENTS.MEASUREMENT_UPDATED,
{
source: measurement.source,
measurement: updatedMeasurement,
notYetUpdatedAtSource,
}
);
return updatedMeasurement.id;
update(measurementUID, measurement, notYetUpdatedAtSource = false) {
if (!this.measurements[measurementUID]) {
return;
}
const updatedMeasurement = {
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
};
log.info(
`Updating internal measurement representation...`,
updatedMeasurement
);
this.measurements[measurementUID] = updatedMeasurement;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
measurement: updatedMeasurement,
notYetUpdatedAtSource,
});
return updatedMeasurement.uid;
}
/**
* Add a raw measurement into a source so that it may be
* Converted to/from annotation in the same way. E.g. import serialized data
* Of the same form as the measurement source.
* of the same form as the measurement source.
* @param {MeasurementSource} source The measurement source instance.
* @param {string} definition The source definition you want to add the measurement to.
* @param {string} annotationType The source annotationType you want to add the measurement to.
* @param {object} data The data you wish to add to the source.
* @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source definition.
* @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source annotationType.
*/
addRawMeasurement(
source,
definition,
annotationType,
data,
toMeasurementSchema,
dataSource = {}
@@ -331,10 +395,10 @@ class MeasurementService {
return;
}
const sourceInfo = this._getSourceInfo(source);
const sourceInfo = this._getSourceToString(source);
if (!definition) {
log.warn('No source definition provided. Exiting early.');
if (!annotationType) {
log.warn('No source annotationType provided. Exiting early.');
return;
}
@@ -347,13 +411,11 @@ class MeasurementService {
let measurement = {};
try {
/* Convert measurement */
measurement = toMeasurementSchema(data);
/* Assign measurement source instance */
measurement.source = source;
} catch (error) {
log.warn(
`Failed to map '${sourceInfo}' measurement for definition ${definition}:`,
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
error.message
);
return;
@@ -366,31 +428,27 @@ class MeasurementService {
return;
}
let internalId = data.id;
if (!internalId) {
internalId = guid();
log.warn(`Measurement ID not found. Generating UID: ${internalId}`);
let internalUID = data.id;
if (!internalUID) {
internalUID = guid();
log.warn(`Measurement ID not found. Generating UID: ${internalUID}`);
}
const newMeasurement = {
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
id: internalId,
uid: internalUID,
};
if (this.measurements[internalId]) {
log.info(
`Measurement already defined. Updating measurement.`,
newMeasurement
);
this.measurements[internalId] = newMeasurement;
if (this.measurements[internalUID]) {
this.measurements[internalUID] = newMeasurement;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source,
measurement: newMeasurement,
});
} else {
log.info(`Measurement added.`, newMeasurement);
this.measurements[internalId] = newMeasurement;
this.measurements[internalUID] = newMeasurement;
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
source,
measurement: newMeasurement,
@@ -406,20 +464,20 @@ class MeasurementService {
* Adds or update persisted measurements.
*
* @param {MeasurementSource} source The measurement source instance
* @param {string} definition The source definition
* @param {Measurement} measurement The source measurement
* @return {string} A measurement id
* @param {string} annotationType The source annotationType
* @param {EventDetail} sourceAnnotationDetail for the annotation event
* @return {string} A measurement uid
*/
addOrUpdate(source, definition, sourceMeasurement) {
annotationToMeasurement(source, annotationType, sourceAnnotationDetail) {
if (!this._isValidSource(source)) {
throw new Error('Invalid source.');
}
if (!definition) {
throw new Error('No source definition provided.');
if (!annotationType) {
throw new Error('No source annotationType provided.');
}
const sourceInfo = this._getSourceInfo(source);
const sourceInfo = this._getSourceToString(source);
if (!this._sourceHasMappings(source)) {
throw new Error(
@@ -429,19 +487,17 @@ class MeasurementService {
let measurement = {};
try {
const sourceMappings = this.mappings[source.id];
const sourceMappings = this.mappings[source.uid];
const { toMeasurementSchema } = sourceMappings.find(
mapping => mapping.definition === definition
mapping => mapping.annotationType === annotationType
);
/* Convert measurement */
measurement = toMeasurementSchema(sourceMeasurement);
/* Assign measurement source instance */
measurement = toMeasurementSchema(sourceAnnotationDetail);
measurement.source = source;
} catch (error) {
throw new Error(
`Failed to map '${sourceInfo}' measurement for definition ${definition}:`,
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
error.message
);
}
@@ -452,24 +508,23 @@ class MeasurementService {
);
}
let internalId = sourceMeasurement.id;
if (!internalId) {
internalId = guid();
log.info(`Measurement ID not found. Generating UID: ${internalId}`);
// Todo: we are using uid on the eventDetail, it should be uid of annotation
let internalUID = sourceAnnotationDetail.uid;
if (!internalUID) {
internalUID = guid();
log.info(
`Annotation does not have UID, Generating UID for the created Measurement: ${internalUID}`
);
}
const newMeasurement = {
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
id: internalId,
uid: internalUID,
};
if (this.measurements[internalId]) {
log.info(
`Measurement already defined. Updating measurement.`,
newMeasurement
);
this.measurements[internalId] = newMeasurement;
if (this.measurements[internalUID]) {
this.measurements[internalUID] = newMeasurement;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source,
measurement: newMeasurement,
@@ -477,51 +532,52 @@ class MeasurementService {
});
} else {
log.info('Measurement added.', newMeasurement);
this.measurements[internalId] = newMeasurement;
this.measurements[internalUID] = newMeasurement;
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
source,
measurement: newMeasurement,
});
}
return newMeasurement.id;
return newMeasurement.uid;
}
/**
* Removes a measurement and broadcasts the removed event.
*
* @param {string} id The measurement id
* @param {string} measurementUID The measurement uid
* @param {MeasurementSource} source The measurement source instance
* @return {string} The removed measurement id
*/
remove(id, source) {
if (!id || !this.measurements[id]) {
log.warn(`No id provided, or unable to find measurement by id.`);
remove(measurementUID, source, eventDetails) {
if (!measurementUID || !this.measurements[measurementUID]) {
log.warn(`No uid provided, or unable to find measurement by uid.`);
return;
}
delete this.measurements[id];
delete this.measurements[measurementUID];
this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, {
source,
measurement: id, // This is weird :shrug:
measurement: measurementUID,
...eventDetails,
});
}
clearMeasurements() {
// Make a copy of the measurements
const measurements = { ...this.measurements };
this.measurements = {};
this._jumpToMeasurementCache = {};
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED);
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
}
jumpToMeasurement(viewportIndex, id) {
const measurement = this.measurements[id];
jumpToMeasurement(viewportIndex, measurementUID) {
const measurement = this.measurements[measurementUID];
if (!measurement) {
log.warn(`No id provided, or unable to find measurement by id.`);
log.warn(`No measurement uid, or unable to find by uid.`);
return;
}
this._addJumpToMeasurement(viewportIndex, id);
this._addJumpToMeasurement(viewportIndex, measurementUID);
const eventName = this.EVENTS.JUMP_TO_MEASUREMENT;
@@ -535,10 +591,6 @@ class MeasurementService {
}
}
_addJumpToMeasurement(viewportIndex, id) {
this._jumpToMeasurementCache[viewportIndex] = id;
}
getJumpToMeasurement(viewportIndex) {
return this._jumpToMeasurementCache[viewportIndex];
}
@@ -547,36 +599,43 @@ class MeasurementService {
delete this._jumpToMeasurementCache[viewportIndex];
}
_getMappingByMeasurementSource(measurementId, definition) {
const measurement = this.getMeasurement(measurementId);
if (this._isValidSource(measurement.source)) {
return this.mappings[measurement.source.id].find(
m => m.definition === definition
);
}
_getSourceUID(name, version) {
const { sources } = this;
const sourceUID = Object.keys(sources).find(sourceUID => {
const source = sources[sourceUID];
return source.name === name && source.version === version;
});
return sourceUID;
}
/**
* Clear all measurements and broadcasts cleared event.
*/
clear() {
this.measurements = {};
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED);
_addJumpToMeasurement(viewportIndex, measurementUID) {
this._jumpToMeasurementCache[viewportIndex] = measurementUID;
}
_getMappingByMeasurementSource(measurement, annotationType) {
if (this._isValidSource(measurement.source)) {
return this.mappings[measurement.source.uid].find(
m => m.annotationType === annotationType
);
}
}
/**
* Get measurement mapping function if matching criteria.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} definition The source definition
* @param {string} annotationType The source annotationType
* @param {Measurement} measurement The measurement service measurement
* @return {Object} The mapping based on matched criteria
*/
_getMatchingMapping(source, definition, measurement) {
const sourceMappings = this.mappings[source.id];
_getMatchingMapping(source, annotationType, measurement) {
const sourceMappings = this.mappings[source.uid];
const sourceMappingsByDefinition = sourceMappings.filter(
mapping => mapping.definition === definition
mapping => mapping.annotationType === annotationType
);
/* Criteria Matching */
@@ -594,7 +653,7 @@ class MeasurementService {
* @param {MeasurementSource} source Measurement source
* @return {string} Source information
*/
_getSourceInfo(source) {
_getSourceToString(source) {
return `${source.name}@${source.version}`;
}
@@ -605,7 +664,7 @@ class MeasurementService {
* @return {boolean} Measurement source validation
*/
_isValidSource(source) {
return source && this.sources[source.id];
return source && this.sources[source.uid];
}
/**
@@ -616,7 +675,8 @@ class MeasurementService {
*/
_sourceHasMappings(source) {
return (
Array.isArray(this.mappings[source.id]) && this.mappings[source.id].length
Array.isArray(this.mappings[source.uid]) &&
this.mappings[source.uid].length
);
}
@@ -11,7 +11,7 @@ describe('MeasurementService.js', () => {
let measurementService;
let measurement;
let source;
let definition;
let annotationType;
let matchingCriteria;
let toSourceSchema;
let toMeasurement;
@@ -20,9 +20,9 @@ describe('MeasurementService.js', () => {
beforeEach(() => {
measurementService = new MeasurementService();
source = measurementService.createSource('Test', '1');
definition = 'Length';
annotationType = 'Length';
annotation = {
toolName: definition,
toolName: annotationType,
measurementData: {},
};
measurement = {
@@ -47,7 +47,7 @@ describe('MeasurementService.js', () => {
}
return measurement;
}
};
matchingCriteria = {
valueType: measurementService.VALUE_TYPES.POLYLINE,
points: 2,
@@ -63,13 +63,13 @@ describe('MeasurementService.js', () => {
it('throws Error if no name provided', () => {
expect(() => {
measurementService.createSource(null, '1')
measurementService.createSource(null, '1');
}).toThrow(new Error('Source name not provided.'));
});
it('throws Error if no version provided', () => {
expect(() => {
measurementService.createSource('Testing', null)
measurementService.createSource('Testing', null);
}).toThrow(new Error('Source version not provided.'));
});
});
@@ -78,7 +78,7 @@ describe('MeasurementService.js', () => {
it('adds new mapping', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
@@ -91,7 +91,7 @@ describe('MeasurementService.js', () => {
measurementService.addMapping(
invalidSource,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
@@ -103,7 +103,7 @@ describe('MeasurementService.js', () => {
expect(() => {
measurementService.addMapping(
source,
definition,
annotationType,
null,
toSourceSchema,
toMeasurement
@@ -111,12 +111,11 @@ describe('MeasurementService.js', () => {
}).toThrow(new Error('Matching criteria not provided.'));
});
it('throws Error if no source provided', () => {
expect(() => {
measurementService.addMapping(
null /* source */,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
@@ -124,23 +123,23 @@ describe('MeasurementService.js', () => {
}).toThrow(new Error('Invalid source.'));
});
it('logs warning and return early if no definition provided', () => {
it('logs warning and return early if no AnnotationType provided', () => {
expect(() => {
measurementService.addMapping(
source,
null /* definition */,
null /* AnnotationType */,
matchingCriteria,
toSourceSchema,
toMeasurement
);
}).toThrow(new Error('Definition not provided.'));
}).toThrow(new Error('annotationType not provided.'));
});
it('throws Error if no measurement mapping function provided', () => {
expect(() => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
null /* toSourceSchema */,
toMeasurement
@@ -152,7 +151,7 @@ describe('MeasurementService.js', () => {
expect(() => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
null /* toMeasurement */
@@ -165,27 +164,39 @@ describe('MeasurementService.js', () => {
it('get annotation based on matched criteria', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const measurementId = source.addOrUpdate(definition, annotation);
const mappedAnnotation = source.getAnnotation(definition, measurementId);
const measurementId = source.annotationToMeasurement(
annotationType,
annotation
);
const mappedAnnotation = source.getAnnotation(
annotationType,
measurementId
);
expect(annotation).toBe(mappedAnnotation);
});
it('get annotation based on source and definition', () => {
it('get annotation based on source and annotationType', () => {
measurementService.addMapping(
source,
definition,
annotationType,
{},
toSourceSchema,
toMeasurement
);
const measurementId = source.addOrUpdate(definition, annotation);
const mappedAnnotation = source.getAnnotation(definition, measurementId);
const measurementId = source.annotationToMeasurement(
annotationType,
annotation
);
const mappedAnnotation = source.getAnnotation(
annotationType,
measurementId
);
expect(annotation).toBe(mappedAnnotation);
});
@@ -201,14 +212,14 @@ describe('MeasurementService.js', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
source.addOrUpdate(definition, measurement);
source.addOrUpdate(definition, anotherMeasurement);
source.annotationToMeasurement(annotationType, measurement);
source.annotationToMeasurement(annotationType, anotherMeasurement);
const measurements = measurementService.getMeasurements();
@@ -220,34 +231,34 @@ describe('MeasurementService.js', () => {
it('return measurement service measurement with given id', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const id = source.addOrUpdate(definition, measurement);
const returnedMeasurement = measurementService.getMeasurement(id);
const uid = source.annotationToMeasurement(annotationType, measurement);
const returnedMeasurement = measurementService.getMeasurement(uid);
/* Clear dynamic data */
delete returnedMeasurement.modifiedTimestamp;
expect({ id, ...measurement }).toEqual(returnedMeasurement);
expect({ uid, ...measurement }).toEqual(returnedMeasurement);
});
});
describe('addOrUpdate()', () => {
describe('annotationToMeasurement()', () => {
it('adds new measurements', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
source.addOrUpdate(definition, measurement);
source.addOrUpdate(definition, measurement);
source.annotationToMeasurement(annotationType, measurement);
source.annotationToMeasurement(annotationType, measurement);
const measurements = measurementService.getMeasurements();
@@ -256,39 +267,39 @@ describe('MeasurementService.js', () => {
it('fails to add new measurements when no mapping', () => {
expect(() => {
source.addOrUpdate(definition, measurement);
}).toThrow()
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('fails to add new measurements when invalid mapping function', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
1 /* Invalid */
);
expect(() => {
source.addOrUpdate(definition, measurement);
}).toThrow()
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('adds new measurement with custom id', () => {
const newMeasurement = { id: 1, ...measurement };
it('adds new measurement with custom uid', () => {
const newMeasurement = { uid: 1, ...measurement };
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
/* Add new measurement */
source.addOrUpdate(definition, newMeasurement);
source.annotationToMeasurement(annotationType, newMeasurement);
const savedMeasurement = measurementService.getMeasurement(
newMeasurement.id
newMeasurement.uid
);
/* Clear dynamic data */
@@ -303,32 +314,32 @@ describe('MeasurementService.js', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
expect(() => {
source.addOrUpdate(definition, measurement);
}).toThrow()
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('updates existing measurement', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const id = source.addOrUpdate(definition, measurement);
const uid = source.annotationToMeasurement(annotationType, measurement);
measurement.unit = 'HU';
source.addOrUpdate(definition, { id, ...measurement });
const updatedMeasurement = measurementService.getMeasurement(id);
source.annotationToMeasurement(annotationType, { uid, ...measurement });
const updatedMeasurement = measurementService.getMeasurement(uid);
expect(updatedMeasurement.unit).toBe('HU');
});
@@ -338,7 +349,7 @@ describe('MeasurementService.js', () => {
it('subscribers receive broadcasted add event', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
@@ -354,7 +365,7 @@ describe('MeasurementService.js', () => {
);
/* Add new measurement */
source.addOrUpdate(definition, measurement);
source.annotationToMeasurement(annotationType, measurement);
expect(addCallbackWasCalled).toBe(true);
});
@@ -362,7 +373,7 @@ describe('MeasurementService.js', () => {
it('subscribers receive broadcasted update event', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
@@ -378,10 +389,10 @@ describe('MeasurementService.js', () => {
);
/* Create measurement */
const id = source.addOrUpdate(definition, measurement);
const uid = source.annotationToMeasurement(annotationType, measurement);
/* Update measurement */
source.addOrUpdate(definition, { id, ...measurement });
source.annotationToMeasurement(annotationType, { uid, ...measurement });
expect(updateCallbackWasCalled).toBe(true);
});
@@ -389,7 +400,7 @@ describe('MeasurementService.js', () => {
it('unsubscribes a listener', () => {
measurementService.addMapping(
source,
definition,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
@@ -408,7 +419,7 @@ describe('MeasurementService.js', () => {
unsubscribe();
/* Create measurement */
source.addOrUpdate(definition, measurement);
source.annotationToMeasurement(annotationType, measurement);
expect(updateCallbackWasCalled).toBe(false);
});
@@ -0,0 +1,136 @@
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
const EVENTS = {
SEGMENTATION_UPDATED: 'event::segmentation_updated',
SEGMENTATION_ADDED: 'event::segmentation_added',
SEGMENTATION_REMOVED: 'event::segmentation_removed',
SEGMENTATION_VISIBILITY_CHANGED: 'event::SEGMENTATION_VISIBILITY_CHANGED',
};
const VALUE_TYPES = {};
class SegmentationService {
constructor() {
this.segmentations = {};
this.listeners = {};
Object.defineProperty(this, 'EVENTS', {
value: EVENTS,
writable: false,
enumerable: true,
configurable: false,
});
Object.assign(this, pubSubServiceInterface);
}
/**
* Get all segmentations.
*
* @return Array of segmentations
*/
getSegmentations() {
const segmentations = this._arrayOfObjects(this.segmentations);
return (
segmentations &&
segmentations.map(m => this.segmentations[Object.keys(m)[0]])
);
}
/**
* Get specific segmentation by its id.
*
* @param id If of the segmentation
* @return segmentation instance
*/
getSegmentation(id) {
return this.segmentations[id];
}
addOrUpdateSegmentation(
id,
segmentationSchema,
notYetUpdatedAtSource = false
) {
const segmentation = this.segmentations[id];
if (segmentation) {
Object.assign(segmentation, segmentationSchema);
this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, {
id,
segmentation,
notYetUpdatedAtSource: notYetUpdatedAtSource,
});
return;
}
this.segmentations[id] = {
...segmentationSchema,
visible: true,
};
this._broadcastEvent(this.EVENTS.SEGMENTATION_ADDED, {
id,
segmentation,
});
}
/**
* Toggles the visibility of a segmentation in the state, and broadcasts the event.
* Note: this method does not update the segmentation state in the source. It only
* updates the state, and there should be separate listeners for that.
* @param ids segmentation ids
*/
toggleSegmentationsVisibility(ids) {
ids.forEach(id => {
const segmentation = this.segmentations[id];
if (!segmentation) {
throw new Error(`Segmentation with id ${id} not found.`);
}
segmentation.visible = !segmentation.visible;
this._broadcastEvent(this.EVENTS.SEGMENTATION_VISIBILITY_CHANGED, {
segmentation,
});
});
}
/**
* Removes a segmentation and broadcasts the removed event.
*
* @param {string} id The segmentation id
*/
remove(id) {
if (!id || !this.segmentations[id]) {
console.warn(`No id provided, or unable to find segmentation by id.`);
return;
}
delete this.segmentations[id];
this._broadcastEvent(this.EVENTS.SEGMENTATION_REMOVED, {
id,
});
}
/**
* Clear all segmentations and broadcasts cleared event.
*/
clear() {
Object.keys(this.segmentations).forEach(id => {
this.remove(id);
});
this.segmentations = {};
}
/**
* Converts object of objects to array.
*
* @return {Array} Array of objects
*/
_arrayOfObjects = obj => {
return Object.entries(obj).map(e => ({ [e[0]]: e[1] }));
};
}
export default SegmentationService;
export { EVENTS, VALUE_TYPES };
@@ -0,0 +1,8 @@
import SegmentationService from './SegmentationService';
export default {
name: 'SegmentationService',
create: ({ configuration = {} }) => {
return new SegmentationService();
},
};
@@ -24,7 +24,7 @@ export default class ToolBarService {
// TODO: Do we need to track per context? Or do we allow for a mixed
// definition that adapts based on context?
this.state = {
primaryToolId: 'Wwwc',
primaryToolId: 'WindowLevel',
toggles: {
/* id: true/false */
},
@@ -43,11 +43,13 @@ export default class ToolBarService {
reset() {
this.unsubscriptions.forEach(unsub => unsub());
this.state = {
primaryToolId: 'Wwwc',
primaryToolId: 'WindowLevel',
toggles: {},
groups: {},
};
this.unsubscriptions = [];
this.buttonSections = {};
this.buttons = {};
}
/**
@@ -56,17 +58,24 @@ export default class ToolBarService {
*/
recordInteraction(interaction) {
const commandsManager = this._commandsManager;
const { groupId, itemId, interactionType, commandName, commandOptions } = interaction;
const { groupId, itemId, interactionType, commands } = interaction;
switch (interactionType) {
case 'action': {
commands.forEach(({ commandName, commandOptions, context }) => {
if (commandName) {
commandsManager.runCommand(commandName, commandOptions, context);
}
});
break;
}
case 'tool': {
this.state.primaryToolId = itemId;
// TODO: Force run this for all contexts? Even inactive?
// or... They'll just detect primaryToolId when they spin up and apply...
commandsManager.runCommand('setToolActive', commandOptions);
commands.forEach(
({ commandName = 'setToolActive', commandOptions, context }) => {
commandsManager.runCommand(commandName, commandOptions, context);
}
);
break;
}
case 'toggle': {
@@ -74,30 +83,41 @@ export default class ToolBarService {
this.state.toggles[itemId] === undefined
? true
: !this.state.toggles[itemId];
if (commandOptions) {
commandOptions.toggledState = this.state.toggles[itemId];
}
const { commands } = interaction;
commands.forEach(({ commandName, commandOptions, context }) => {
if (!commandOptions) {
commandOptions = {};
}
if (commandName) {
commandOptions.toggledState = this.state.toggles[itemId];
commandsManager.runCommand(commandName, commandOptions, context);
}
});
break;
}
default:
throw new Error(`Invalid interaction type: ${interactionType}`);
}
// Todo: comment out for now
// Run command if there's one associated
//
// NOTE: Should probably just do this for tools as well?
// But would be nice if we could enforce at least the command name?
let unsubscribe;
if (commandName) {
unsubscribe = commandsManager.runCommand(commandName, commandOptions);
}
// let unsubscribe;
// if (commandName) {
// unsubscribe = commandsManager.runCommand(commandName, commandOptions);
// }
// Storing the unsubscribe for later reseting
if (unsubscribe && typeof unsubscribe === 'function') {
if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
this.unsubscriptions.push(unsubscribe);
}
}
// // Storing the unsubscribe for later reseting
// if (unsubscribe && typeof unsubscribe === 'function') {
// if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
// this.unsubscriptions.push(unsubscribe);
// }
// }
// Track last touched id for each group
if (groupId) {
@@ -4,7 +4,7 @@ const publicAPI = {
name,
getState: _getState,
setActiveViewportIndex: _setActiveViewportIndex,
setDisplaysetForViewport: _setDisplaysetForViewport,
setDisplaySetsForViewport: _setDisplaySetsForViewport,
setLayout: _setLayout,
setCachedLayout: _setCachedLayout,
setServiceImplementation,
@@ -16,8 +16,8 @@ const serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setActiveViewportIndex: () =>
console.warn('setActiveViewportIndex() NOT IMPLEMENTED'),
_setDisplaysetForViewport: () =>
console.warn('setDisplaysetForViewport() NOT IMPLEMENTED'),
_setDisplaySetsForViewport: () =>
console.warn('setDisplaySetsForViewport() NOT IMPLEMENTED'),
_setLayout: () => console.warn('setLayout() NOT IMPLEMENTED'),
_reset: () => console.warn('reset() NOT IMPLEMENTED'),
_setCachedLayout: () => console.warn('setCachedLayout() NOT IMPLEMENTED'),
@@ -32,10 +32,10 @@ function _setActiveViewportIndex(index) {
return serviceImplementation._setActiveViewportIndex(index);
}
function _setDisplaysetForViewport({ viewportIndex, displaySetInstanceUID }) {
return serviceImplementation._setDisplaysetForViewport({
function _setDisplaySetsForViewport({ viewportIndex, displaySetInstanceUIDs }) {
return serviceImplementation._setDisplaySetsForViewport({
viewportIndex,
displaySetInstanceUID,
displaySetInstanceUIDs,
});
}
@@ -58,7 +58,7 @@ function _setCachedLayout({ numCols, numRows, viewports }) {
function setServiceImplementation({
getState: getStateImplementation,
setActiveViewportIndex: setActiveViewportIndexImplementation,
setDisplaysetForViewport: setDisplaysetForViewportImplementation,
setDisplaySetsForViewport: setDisplaySetsForViewportImplementation,
setCachedLayout: setCachedLayoutImplementation,
setLayout: setLayoutImplementation,
reset: resetImplementation,
@@ -70,8 +70,8 @@ function setServiceImplementation({
if (setActiveViewportIndexImplementation) {
serviceImplementation._setActiveViewportIndex = setActiveViewportIndexImplementation;
}
if (setDisplaysetForViewportImplementation) {
serviceImplementation._setDisplaysetForViewport = setDisplaysetForViewportImplementation;
if (setDisplaySetsForViewportImplementation) {
serviceImplementation._setDisplaySetsForViewport = setDisplaySetsForViewportImplementation;
}
if (setLayoutImplementation) {
serviceImplementation._setLayout = setLayoutImplementation;
+2
View File
@@ -12,6 +12,7 @@ import CineService from './CineService';
import HangingProtocolService from './HangingProtocolService';
import pubSubServiceInterface from './_shared/pubSubServiceInterface';
import UserAuthenticationService from './UserAuthenticationService';
import SegmentationService from './SegmentationService';
export {
MeasurementService,
@@ -28,4 +29,5 @@ export {
CineService,
pubSubServiceInterface,
UserAuthenticationService,
SegmentationService,
};
-136
View File
@@ -1,136 +0,0 @@
let stackMap = {};
let configuration = {};
const stackUpdatedCallbacks = [];
/**
* Loop through the current series and add metadata to the
* Cornerstone meta data provider. This will be used to fill information
* into the viewport overlays, and to calculate reference lines and orientation markers
* @param {Object} stackMap stackMap object
* @param {Object} displaySet The set of images to make the stack from
* @return {Array} Array with image IDs
*/
function createAndAddStack(
stackMap,
displaySet,
dataSource,
stackUpdatedCallbacks
) {
const {
images,
displaySetInstanceUID,
StudyInstanceUID,
frameRate,
isClip,
initialImageIdIndex,
} = displaySet;
if (!images) {
return;
}
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const stack = {
StudyInstanceUID,
displaySetInstanceUID,
imageIds,
frameRate,
isClip,
initialImageIdIndex,
};
stackMap[displaySetInstanceUID] = stack;
return stack;
}
configuration = {
createAndAddStack,
};
/**
* This object contains all the functions needed for interacting with the stack manager.
* Generally, findStack is the only function used. If you want to know when new stacks
* come in, you can register a callback with addStackUpdatedCallback.
*/
const StackManager = {
/**
* Removes all current stacks
*/
clearStacks() {
stackMap = {};
},
/**
* Create a stack from an image set, as well as add in the metadata on a per image bases.
* @param displaySet The set of images to make the stack from
* @return {Array} Array with image IDs
*/
makeAndAddStack(displaySet, dataSource) {
return configuration.createAndAddStack(
stackMap,
displaySet,
dataSource,
stackUpdatedCallbacks
);
},
/**
* Find a stack from the currently created stacks.
* @param displaySetInstanceUID The UID of the stack to find.
* @returns {*} undefined if not found, otherwise the stack object is returned.
*/
findStack(displaySetInstanceUID) {
return stackMap[displaySetInstanceUID];
},
/**
* Find a stack or create one if it has not been created yet
* @param displaySet The set of images to make the stack from
* @return {Array} Array with image IDs
*/
findOrCreateStack(displaySet, dataSource) {
let stack = this.findStack(displaySet.displaySetInstanceUID);
if (!stack || !stack.imageIds) {
stack = this.makeAndAddStack(displaySet, dataSource);
}
return stack;
},
/**
* Gets the underlying map of displaySetInstanceUID to stack object.
* WARNING: Do not change this object. It directly affects the manager.
* @returns {{}} map of displaySetInstanceUID -> stack.
*/
getAllStacks() {
return stackMap;
},
/**
* Adds in a callback to be called on a stack being added / updated.
* @param callback must accept at minimum one argument,
* which is the stack that was added / updated.
*/
addStackUpdatedCallback(callback) {
if (typeof callback !== 'function') {
throw new Error('callback must be provided as a function');
}
stackUpdatedCallbacks.push(callback);
},
/**
* Return configuration
*/
getConfiguration() {
return configuration;
},
/**
* Set configuration, in order to provide compatibility
* with other systems by overriding this functions
* @param {Object} config object with functions to be overrided
*
* For now, only makeAndAddStack can be overrided
*/
setConfiguration(config) {
configuration = config;
},
};
export { StackManager };
export default StackManager;
+21
View File
@@ -0,0 +1,21 @@
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this,
args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
export default debounce;
@@ -1,205 +0,0 @@
import cornerstone from 'cornerstone-core';
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import { api } from 'dicomweb-client';
import DICOMWeb from '../DICOMWeb';
import errorHandler from '../errorHandler';
const getImageId = imageObj => {
if (!imageObj) {
return;
}
return typeof imageObj.getImageId === 'function'
? imageObj.getImageId()
: imageObj.url;
};
const findImageIdOnStudies = (studies, displaySetInstanceUID) => {
const study = studies.find(study => {
const displaySet = study.displaySets.some(
displaySet => displaySet.displaySetInstanceUID === displaySetInstanceUID
);
return displaySet;
});
const { series = [] } = study;
const { instances = [] } = series[0] || {};
const instance = instances[0];
return getImageId(instance);
};
const someInvalidStrings = strings => {
const stringsArray = Array.isArray(strings) ? strings : [strings];
const emptyString = string => !string;
let invalid = stringsArray.some(emptyString);
return invalid;
};
const getImageInstance = dataset => {
return dataset && dataset.images && dataset.images[0];
};
const getImageInstanceId = imageInstance => {
return getImageId(imageInstance);
};
const fetchIt = (url, headers = DICOMWeb.getAuthorizationHeader()) => {
return fetch(url, headers).then(response => response.arrayBuffer());
};
const cornerstoneRetriever = imageId => {
return cornerstone.loadAndCacheImage(imageId).then(image => {
return image && image.data && image.data.byteArray.buffer;
});
};
const wadorsRetriever = (
url,
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
headers = DICOMWeb.getAuthorizationHeader(),
errorInterceptor = errorHandler.getHTTPErrorHandler()
) => {
const config = {
url,
headers,
errorInterceptor,
};
const dicomWeb = new api.DICOMwebClient(config);
return dicomWeb.retrieveInstance({
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
});
};
const getImageLoaderType = imageId => {
const loaderRegExp = /^\w+\:/;
const loaderType = loaderRegExp.exec(imageId);
return (
(loaderRegExp.lastIndex === 0 &&
loaderType &&
loaderType[0] &&
loaderType[0].replace(':', '')) ||
''
);
};
class DicomLoaderService {
getLocalData(dataset, studies) {
if (dataset && dataset.localFile) {
// Use referenced imageInstance
const imageInstance = getImageInstance(dataset);
let imageId = getImageInstanceId(imageInstance);
// or Try to get it from studies
if (someInvalidStrings(imageId)) {
imageId = findImageIdOnStudies(studies, dataset.displaySetInstanceUID);
}
if (!someInvalidStrings(imageId)) {
return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId);
}
}
}
getDataByImageType(dataset) {
const imageInstance = getImageInstance(dataset);
if (imageInstance) {
const imageId = getImageInstanceId(imageInstance);
let getDicomDataMethod = fetchIt;
const loaderType = getImageLoaderType(imageId);
switch (loaderType) {
case 'dicomfile':
getDicomDataMethod = cornerstoneRetriever.bind(this, imageId);
break;
case 'wadors':
const url = imageInstance.getData().wadoRoot;
const studyInstanceUID = imageInstance.getStudyInstanceUID();
const seriesInstanceUID = imageInstance.getSeriesInstanceUID();
const sopInstanceUID = imageInstance.getSOPInstanceUID();
const invalidParams = someInvalidStrings([
url,
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
]);
if (invalidParams) {
return;
}
getDicomDataMethod = wadorsRetriever.bind(
this,
url,
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID
);
break;
case 'wadouri':
// Strip out the image loader specifier
imageId = imageId.substring(imageId.indexOf(':') + 1);
if (someInvalidStrings(imageId)) {
return;
}
getDicomDataMethod = fetchIt.bind(this, imageId);
break;
}
return getDicomDataMethod();
}
}
getDataByDatasetType(dataset) {
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
authorizationHeaders,
wadoRoot,
wadoUri,
} = dataset;
// Retrieve wadors or just try to fetch wadouri
if (!someInvalidStrings(wadoRoot)) {
return wadorsRetriever(
wadoRoot,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
authorizationHeaders
);
} else if (!someInvalidStrings(wadoUri)) {
return fetchIt(wadoUri, { headers: authorizationHeaders });
}
}
*getLoaderIterator(dataset, studies) {
yield this.getLocalData(dataset, studies);
yield this.getDataByImageType(dataset);
yield this.getDataByDatasetType(dataset);
}
findDicomDataPromise(dataset, studies) {
const loaderIterator = this.getLoaderIterator(dataset, studies);
// it returns first valid retriever method.
for (const loader of loaderIterator) {
if (loader) {
return loader;
}
}
// in case of no valid loader
throw new Error('Invalid dicom data loader');
}
}
const dicomLoaderService = new DicomLoaderService();
export default dicomLoaderService;
@@ -0,0 +1,115 @@
import { DicomMetadataStore } from '../services/DicomMetadataStore/DicomMetadataStore';
export default function downloadCSVReport(measurementData) {
if (measurementData.length === 0) {
// Prevent download of report with no measurements.
return;
}
const columns = [
'Patient ID',
'Patient Name',
'StudyInstanceUID',
'SeriesInstanceUID',
'SOPInstanceUID',
'Label',
];
const reportMap = {};
measurementData.forEach(measurement => {
const {
referenceStudyUID,
referenceSeriesUID,
getReport,
uid,
} = measurement;
if (!getReport) {
console.warn('Measurement does not have a getReport function');
return;
}
const seriesMetadata = DicomMetadataStore.getSeries(
referenceStudyUID,
referenceSeriesUID
);
const commonRowItems = _getCommonRowItems(measurement, seriesMetadata);
const report = getReport(measurement);
reportMap[uid] = {
report,
commonRowItems,
};
});
// get columns names inside the report from each measurement and
// add them to the rows array (this way we can add columns for any custom
// measurements that may be added in the future)
Object.keys(reportMap).forEach(id => {
const { report } = reportMap[id];
report.columns.forEach(column => {
if (!columns.includes(column)) {
columns.push(column);
}
});
});
const results = _mapReportsToRowArray(reportMap, columns);
let csvContent =
'data:text/csv;charset=utf-8,' +
results.map(res => res.join(',')).join('\n');
_createAndDownloadFile(csvContent);
}
function _mapReportsToRowArray(reportMap, columns) {
const results = [columns];
Object.keys(reportMap).forEach(id => {
const { report, commonRowItems } = reportMap[id];
const row = [];
// For commonRowItems, find the correct index and add the value to the
// correct row in the results array
Object.keys(commonRowItems).forEach(key => {
const index = columns.indexOf(key);
const value = commonRowItems[key];
row[index] = value;
});
// For each annotation data, find the correct index and add the value to the
// correct row in the results array
report.columns.forEach((column, index) => {
const colIndex = columns.indexOf(column);
const value = report.values[index];
row[colIndex] = value;
});
results.push(row);
});
return results;
}
function _getCommonRowItems(measurement, seriesMetadata) {
const firstInstance = seriesMetadata.instances[0];
return {
'Patient ID': firstInstance.PatientID, // Patient ID
'Patient Name': firstInstance.PatientName.Alphabetic, // PatientName
StudyInstanceUID: measurement.referenceStudyUID, // StudyInstanceUID
SeriesInstanceUID: measurement.referenceSeriesUID, // SeriesInstanceUID
SOPInstanceUID: measurement.SOPInstanceUID, // SOPInstanceUID
Label: measurement.label || '', // Label
};
}
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();
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Removes the data loader scheme from the imageId
*
* @param {string} imageId Image ID
* @returns {string} imageId without the data loader scheme
* @memberof Cache
*/
export default function imageIdToURI(imageId) {
const colonIndex = imageId.indexOf(':');
return imageId.substring(colonIndex + 1);
}
+13 -7
View File
@@ -1,11 +1,9 @@
import ObjectPath from './objectPath';
import StackManager from './StackManager.js';
import absoluteUrl from './absoluteUrl';
import guid from './guid';
import sortBy from './sortBy.js';
import sortBySeriesDate from './sortBySeriesDate.js';
import writeScript from './writeScript.js';
import DicomLoaderService from './dicomLoaderService.js';
import b64toBlob from './b64toBlob.js';
//import loadAndCacheDerivedDisplaySets from './loadAndCacheDerivedDisplaySets.js';
import urlUtil from './urlUtil';
@@ -22,9 +20,13 @@ import progressTrackingUtils from './progressTrackingUtils';
import isLowPriorityModality from './isLowPriorityModality';
import { isImage } from './isImage';
import isDisplaySetReconstructable from './isDisplaySetReconstructable';
import imageIdToURI from './imageIdToURI';
import debounce from './debounce';
import roundNumber from './roundNumber';
import downloadCSVReport from './downloadCSVReport';
// Commented out unused functionality.
// Need to implement new mechanism for dervived displaySets using the displaySetManager.
// Need to implement new mechanism for derived displaySets using the displaySetManager.
const utils = {
guid,
@@ -36,9 +38,8 @@ const utils = {
formatDate,
formatPN,
b64toBlob,
StackManager,
DicomLoaderService,
urlUtil,
imageIdToURI,
//loadAndCacheDerivedDisplaySets,
makeDeferred,
makeCancelable,
@@ -51,6 +52,9 @@ const utils = {
isLowPriorityModality,
isImage,
isDisplaySetReconstructable,
debounce,
roundNumber,
downloadCSVReport,
};
export {
@@ -61,8 +65,6 @@ export {
formatDate,
writeScript,
b64toBlob,
StackManager,
DicomLoaderService,
urlUtil,
//loadAndCacheDerivedDisplaySets,
makeDeferred,
@@ -76,6 +78,10 @@ export {
isLowPriorityModality,
isImage,
isDisplaySetReconstructable,
imageIdToURI,
debounce,
roundNumber,
downloadCSVReport,
};
export default utils;
+4 -2
View File
@@ -10,14 +10,16 @@ describe('Top level exports', () => {
'sortBySeriesDate',
'isLowPriorityModality',
'writeScript',
'debounce',
'downloadCSVReport',
'imageIdToURI',
'roundNumber',
'b64toBlob',
'StackManager',
'formatDate',
'formatPN',
//'loadAndCacheDerivedDisplaySets',
'isDisplaySetReconstructable',
'isImage',
'DicomLoaderService',
'urlUtil',
'makeDeferred',
'makeCancelable',
@@ -1,3 +1,9 @@
import toNumber from './toNumber';
// TODO: Is 10% a reasonable spacingTolerance for spacing?
const spacingTolerance = 0.2;
const iopTolerance = 0.01;
/**
* Checks if a series is reconstructable to a 3D volume.
*
@@ -8,7 +14,7 @@ export default function isDisplaySetReconstructable(instances) {
return { value: false };
}
const firstInstance = instances[0].getData().metadata;
const firstInstance = instances[0];
const Modality = firstInstance.Modality;
const isMultiframe = firstInstance.NumberOfFrames > 1;
@@ -35,19 +41,21 @@ function processMultiframe(instance) {
}
function processSingleframe(instances) {
const firstImage = instances[0].getData().metadata;
const firstImageRows = firstImage.Rows;
const firstImageColumns = firstImage.Columns;
const firstImageSamplesPerPixel = firstImage.SamplesPerPixel;
const firstImageOrientationPatient = firstImage.ImageOrientationPatient;
const firstImagePositionPatient = firstImage.ImagePositionPatient;
const firstImage = instances[0];
const firstImageRows = toNumber(firstImage.Rows);
const firstImageColumns = toNumber(firstImage.Columns);
const firstImageSamplesPerPixel = toNumber(firstImage.SamplesPerPixel);
const firstImageOrientationPatient = toNumber(
firstImage.ImageOrientationPatient
);
const firstImagePositionPatient = toNumber(firstImage.ImagePositionPatient);
// Can't reconstruct if we:
// -- Have a different dimensions within a displaySet.
// -- Have a different number of components within a displaySet.
// -- Have different orientations within a displaySet.
for (let i = 1; i < instances.length; i++) {
const instance = instances[i].getData().metadata;
const instance = instances[i];
const {
Rows,
Columns,
@@ -55,11 +63,13 @@ function processSingleframe(instances) {
ImageOrientationPatient,
} = instance;
const imageOrientationPatient = toNumber(ImageOrientationPatient);
if (
Rows !== firstImageRows ||
Columns !== firstImageColumns ||
SamplesPerPixel !== firstImageSamplesPerPixel ||
!_isSameOrientation(ImageOrientationPatient, firstImageOrientationPatient)
!_isSameOrientation(imageOrientationPatient, firstImageOrientationPatient)
) {
return { value: false };
}
@@ -71,8 +81,9 @@ function processSingleframe(instances) {
// If spacing is on a uniform grid but we are missing frames,
// Allow reconstruction, but pass back the number of missing frames.
if (instances.length > 2) {
const lastIpp = instances[instances.length - 1].getData().metadata
.ImagePositionPatient;
const lastIpp = toNumber(
instances[instances.length - 1].ImagePositionPatient
);
// We can't reconstruct if we are missing ImagePositionPatient values
if (!firstImagePositionPatient || !lastIpp) {
@@ -86,11 +97,12 @@ function processSingleframe(instances) {
let previousImagePositionPatient = firstImagePositionPatient;
for (let i = 1; i < instances.length; i++) {
const instance = instances[i].getData().metadata;
const { ImagePositionPatient } = instance;
const instance = instances[i];
// Todo: get metadata from OHIF.MetadataProvider
const imagePositionPatient = toNumber(instance.ImagePositionPatient);
const spacingBetweenFrames = _getPerpendicularDistance(
ImagePositionPatient,
imagePositionPatient,
previousImagePositionPatient
);
const spacingIssue = _getSpacingIssue(
@@ -108,7 +120,7 @@ function processSingleframe(instances) {
}
}
previousImagePositionPatient = ImagePositionPatient;
previousImagePositionPatient = imagePositionPatient;
}
}
@@ -127,10 +139,6 @@ function _isSameOrientation(iop1, iop2) {
);
}
// TODO: Is 10% a reasonable spacingTolerance for spacing?
const spacingTolerance = 0.1;
const iopTolerance = 0.01;
/**
* Checks for spacing issues.
*
+5
View File
@@ -0,0 +1,5 @@
function _round(value, decimals) {
return Number(value.toFixed(decimals));
}
export default _round;
+13
View File
@@ -0,0 +1,13 @@
/**
* Returns the values as an array of javascript numbers
*
* @param val - The javascript object for the specified element in the metadata
* @returns {*}
*/
export default function toNumber(val) {
if (Array.isArray(val)) {
return val.map(v => (v !== undefined ? Number(v) : v));
} else {
return val !== undefined ? Number(val) : val;
}
}