ohif-viewer/Packages/ohif-hanging-protocols/client/protocolEngine.js
Evren Ozkan 2e06b652b3 OHIF-152 Fix hanging protocol issues
- Replace “OHIF.viewerbase.showConfirmDialog” with “Viewerbase.dialogUtils.showConfirmDialog”
- Check the length of the filtered images (instead of checking whether it is undefined, because the filtered images is returned as an empty array if not found) to find the display set in protocolEngine
2017-02-01 19:14:41 -05:00

748 lines
26 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { $ } from 'meteor/jquery';
import { _ } from 'meteor/underscore';
// OHIF Modules
import { OHIF } from 'meteor/ohif:core';
import 'meteor/ohif:viewerbase';
// Define a global variable that will be used to refer to the Protocol Engine
// It must be populated by HP.setEngine when the Viewer is initialized and a ProtocolEngine
// is instantiated on top of the LayoutManager. If the global ProtocolEngine variable remains
// undefined, none of the HangingProtocol functions will operate.
ProtocolEngine = undefined;
/**
* Sets the ProtocolEngine global given an instantiated ProtocolEngine. This is done so that
* The functions in the package can depend on a ProtocolEngine variable, but this variable does
* not have to be exported from the application level.
*
* (There may be a better way to do this, but for now this works with no real downside)
*
* @param protocolEngine An instantiated ProtocolEngine linked to a LayoutManager from the
* Viewerbase package
*/
HP.setEngine = function(protocolEngine) {
ProtocolEngine = protocolEngine;
};
// Define an empty object to store callbacks that are used to retrieve custom attributes
// The simplest example for a custom attribute is the Timepoint type (i.e. baseline or follow-up)
// used in the LesionTracker application.
//
// Timepoint type can be obtained given a studyId, and this is done through a custom callback.
// Developers can define attributes (i.e. attributeId = timepointType) with a name ('Timepoint Type')
// and a callback function that is used to calculate them.
//
// The input to the callback, which is called during viewport-image matching rule evaluation
// is the set of attributes that contains the specified attribute. In our example, timepointType is
// linked to the study attributes, and so the inputs to the callback is an object containing
// the study attributes.
HP.CustomAttributeRetrievalCallbacks = {};
/**
* Adds a custom attribute to be used in the HangingProtocol UI and matching rules, including a
* callback that will be used to calculate the attribute value.
*
* @param attributeId The ID used to refer to the attribute (e.g. 'timepointType')
* @param attributeName The name of the attribute to be displayed (e.g. 'Timepoint Type')
* @param callback The function used to calculate the attribute value from the other attributes at its level (e.g. study/series/image)
*/
HP.addCustomAttribute = function(attributeId, attributeName, callback) {
HP.CustomAttributeRetrievalCallbacks[attributeId] = {
name: attributeName,
callback: callback
};
};
// Define an empty object to store callbacks that are used to apply custom viewport settings
// after a viewport is rendered.
HP.CustomViewportSettings = {};
/**
* 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
*/
HP.addCustomViewportSetting = function(settingId, settingName, options, callback) {
HP.CustomViewportSettings[settingId] = {
id: settingId,
text: settingName,
options: options,
callback: callback
};
};
Meteor.startup(function() {
HP.addCustomViewportSetting('wlPreset', 'Window/Level Preset', Object.keys(OHIF.viewer.wlPresets), function(element, optionValue) {
if (OHIF.viewer.wlPresets.hasOwnProperty(optionValue)) {
OHIF.viewerbase.wlPresets.applyWLPreset(optionValue, element);
}
});
});
// Log decisions regarding matching
HP.match = function(attributes, rules) {
var options = {
format: 'grouped'
};
var score = 0;
var details = {
passed: [],
failed: []
};
var requiredFailed = false;
rules.forEach(rule => {
var attribute = rule.attribute;
// If the attributes we are testing (e.g. study, series, or instance attributes) do
// not contain the attribute specified in the rule, check whether or not they have been
// defined in the CustomAttributeRetrievalCallbacks Object.
// TODO: Investigate why attributes.hasOwnProperty(attribute) doesn't work?
if (attributes[attribute] === undefined &&
HP.CustomAttributeRetrievalCallbacks.hasOwnProperty(attribute)) {
var customAttribute = HP.CustomAttributeRetrievalCallbacks[attribute];
attributes[attribute] = customAttribute.callback(attributes);
}
// Format the constraint as required by Validate.js
var testConstraint = {};
testConstraint[attribute] = rule.constraint;
// Use Validate.js to evaluate the constraints on the specified attributes
var errorMessages = validate(attributes, testConstraint, [options]);
if (!errorMessages) {
// If no errorMessages were returned, then validation passed.
// Add the rule's weight to the total score
score += rule.weight;
// Log that this rule passed in the matching details object
details.passed.push({
rule: rule
});
} else {
// If errorMessages were present, then validation failed
// If the rule that failed validation was Required, then
// mark that a required Rule has failed
if (rule.required) {
requiredFailed = true;
}
// Log that this rule failed in the matching details object
// and include any error messages
details.failed.push({
rule: rule,
errorMessages: errorMessages
});
}
});
// If a required Rule has failed Validation, set the matching score to zero
if (requiredFailed) {
score = 0;
}
return {
score: score,
details: details
};
};
var sortByScore = function(arr) {
arr.sort(function(a, b) {
return b.score - a.score;
});
};
HP.ProtocolEngine = class ProtocolEngine {
constructor(LayoutManager, studies) {
this.LayoutManager = LayoutManager;
this.studies = studies;
this.reset();
// Create an array for new stage ids to be stored
// while editing a stage
this.newStageIds = [];
}
/**
* Resets the ProtocolEngine to the best match
*/
reset() {
var protocol = this.getBestMatch();
this.setHangingProtocol(protocol);
}
/**
* Retrieves the current Stage from the current Protocol and stage index
*
* @returns {*} The Stage model for the currently displayed Stage
*/
getCurrentStageModel() {
return this.protocol.stages[this.stage];
}
findMatchByStudy(study) {
var matched = [];
HP.ProtocolStore.getProtocol().forEach(protocol => {
// Clone the protocol's protocolMatchingRules array
// We clone it so that we don't accidentally add the
// numberOfPriorsReferenced rule to the Protocol itself.
var rules = protocol.protocolMatchingRules.slice(0);
if (!rules) {
return;
}
study.numberOfPriorsReferenced = this.getNumberOfAvailablePriors(study);
var rule = new HP.ProtocolMatchingRule('numberOfPriorsReferenced', {
numericality: {
greaterThanOrEqualTo: protocol.numberOfPriorsReferenced
}
});
rules.push(rule);
var matchedDetails = HP.match(study, rules);
if (matchedDetails.score > 0) {
matched.push({
score: matchedDetails.score,
protocol: protocol
});
}
});
if (!matched.length) {
var defaultProtocol = HP.ProtocolStore.getProtocol('defaultProtocol');
return [{
score: 1,
protocol: defaultProtocol
}];
}
sortByScore(matched);
return matched;
}
/**
* Populates the MatchedProtocols Collection by running the matching procedure
*/
updateMatches() {
// Clear all data from the MatchedProtocols Collection
MatchedProtocols.remove({});
// For each study, find the matching protocols
this.studies.forEach(study => {
var matched = this.findMatchByStudy(study);
// For each matched protocol, check if it is already in MatchedProtocols
matched.forEach(function(matchedDetail) {
var protocol = matchedDetail.protocol;
if (!protocol) {
return;
}
var protocolInCollection = MatchedProtocols.findOne({
id: protocol.id
});
// If it is not already in the MatchedProtocols Collection, insert it with its score
if (!protocolInCollection) {
MatchedProtocols.insert(matchedDetail);
}
});
});
}
/**
* Return the best matched Protocol to the current study or set of studies
* @returns {*}
*/
getBestMatch() {
// Run the matching to populate the MatchedProtocols Collection
this.updateMatches();
// Retrieve the highest scoring Protocol
var sorted = MatchedProtocols.find({}, {
sort: {
score: -1
},
limit: 1
}).fetch();
// Return the highest scoring Protocol
return sorted[0].protocol;
}
/**
* Calculates the number of previous studies in the cached StudyList that
* have the same patientId and an earlier study date
*
* @param study The input study
* @returns {any|*} The number of available prior studies with the same patientId
*/
getNumberOfAvailablePriors(study) {
var studies = StudyListStudies.find({
patientId: study.patientId,
studyDate: {
$lt: study.studyDate
}
});
return studies.count();
}
findRelatedStudies(protocol, study) {
if (!protocol.protocolMatchingRules) {
return;
}
var studies = StudyListStudies.find({
patientId: study.patientId,
studyDate: {
$lt: study.studyDate
}
}, {
sort: {
studyDate: -1
}
});
var related = [];
var currentDate = moment(study.studyDate, 'YYYYMMDD');
studies.forEach(function(priorStudy, priorIndex) {
// Calculate an abstract prior value for the study in question
if (priorIndex === (studies.length - 1)) {
priorStudy.abstractPriorValue = -1;
} else {
// Abstract prior index starts from 1 in the DICOM standard
priorStudy.abstractPriorValue = priorIndex + 1;
}
// Calculate the relative time using Moment.js
var priorDate = moment(priorStudy.studyDate, 'YYYYMMDD');
priorStudy.relativeTime = currentDate.diff(priorDate);
var details = HP.match(priorStudy, protocol.protocolMatchingRules);
if (details.score) {
related.push({
score: details.score,
study: priorStudy
});
}
});
sortByScore(related);
return related.map(function(v) {
return v.study;
});
}
// Match images given a list of Studies and a Viewport's image matching reqs
matchImages(viewport) {
var studyMatchingRules = viewport.studyMatchingRules;
var seriesMatchingRules = viewport.seriesMatchingRules;
var instanceMatchingRules = viewport.imageMatchingRules;
var highestStudyMatchingScore = 0;
var highestSeriesMatchingScore = 0;
var highestImageMatchingScore = 0;
var matchingScores = [];
var bestMatch;
var currentStudy = this.studies[0];
currentStudy.abstractPriorValue = 0;
studyMatchingRules.forEach(rule => {
if (rule.attribute === 'abstractPriorValue') {
var validatorType = Object.keys(rule.constraint)[0];
var validator = Object.keys(rule.constraint[validatorType])[0];
var abstractPriorValue = rule.constraint[validatorType][validator];
abstractPriorValue = parseInt(abstractPriorValue, 10);
// TODO: Restrict or clarify validators for abstractPriorValue?
var studies = StudyListStudies.find({
patientId: currentStudy.patientId,
studyDate: {
$lt: currentStudy.studyDate
}
}, {
sort: {
studyDate: -1
}
}).fetch();
// TODO: Revisit this later: What about two studies with the same
// study date?
var priorStudy;
if (abstractPriorValue === -1) {
priorStudy = studies[studies.length - 1];
} else {
var studyIndex = Math.max(abstractPriorValue - 1, 0);
priorStudy = studies[studyIndex];
}
if (!priorStudy) {
return;
}
// @TypeSafeStudies
var alreadyLoaded = OHIF.viewer.Studies.findBy({
studyInstanceUid: priorStudy.studyInstanceUid
});
if (!alreadyLoaded) {
getStudyMetadata(priorStudy.studyInstanceUid, study => {
study.abstractPriorValue = abstractPriorValue;
study.displaySets = OHIF.viewerbase.sortingManager.getDisplaySets(study);
OHIF.viewer.Studies.insert(study);
this.studies.push(study);
this.matchImages(viewport);
this.updateViewports();
});
}
}
// TODO: Add relative Date / time
});
this.studies.forEach(function(study) {
const studyMatchDetails = HP.match(study, studyMatchingRules);
if ((studyMatchingRules.length && !studyMatchDetails.score) ||
studyMatchDetails.score < highestStudyMatchingScore) {
return;
}
highestStudyMatchingScore = studyMatchDetails.score;
study.seriesList.forEach(function(series) {
var seriesMatchDetails = HP.match(series, seriesMatchingRules);
if ((seriesMatchingRules.length && !seriesMatchDetails.score) ||
seriesMatchDetails.score < highestSeriesMatchingScore) {
return;
}
highestSeriesMatchingScore = seriesMatchDetails.score;
series.instances.forEach(function(instance, index) {
// This tests to make sure there is actually image data in this instance
// TODO: Change this when we add PDF and MPEG support
// See https://ohiforg.atlassian.net/browse/LT-227
if (!OHIF.viewerbase.isImage(instance.sopClassUid) && !instance.rows) {
return;
}
const instanceMatchDetails = HP.match(instance, instanceMatchingRules);
const matchDetails = {
passed: [],
failed: []
};
matchDetails.passed = matchDetails.passed.concat(instanceMatchDetails.details.passed);
matchDetails.passed = matchDetails.passed.concat(seriesMatchDetails.details.passed);
matchDetails.passed = matchDetails.passed.concat(studyMatchDetails.details.passed);
matchDetails.failed = matchDetails.failed.concat(instanceMatchDetails.details.failed);
matchDetails.failed = matchDetails.failed.concat(seriesMatchDetails.details.failed);
matchDetails.failed = matchDetails.failed.concat(studyMatchDetails.details.failed);
const totalMatchScore = instanceMatchDetails.score + seriesMatchDetails.score + studyMatchDetails.score;
const imageDetails = {
studyInstanceUid: study.studyInstanceUid,
seriesInstanceUid: series.seriesInstanceUid,
sopInstanceUid: instance.sopInstanceUid,
currentImageIdIndex: index,
matchingScore: totalMatchScore,
matchDetails: matchDetails,
sortingInfo: {
score: totalMatchScore,
study: study.studyDate + study.studyTime,
series: parseInt(series.seriesNumber), // TODO: change for seriesDateTime
instance: parseInt(instance.instanceNumber) // TODO: change for acquisitionTime
}
};
// Filter imageSet function: filter by InstanceUid
const filterImageFn = (image) => {
return image.getData().sopInstanceUid === instance.sopInstanceUid;
};
// Find the displaySet
const displaySet = study.displaySets.find(ds => {
return ds.images.filter(filterImageFn).length > 0;
});
// If the instance was found, set the displaySet ID
if (displaySet) {
imageDetails.displaySetInstanceUid = displaySet.displaySetInstanceUid;
imageDetails.imageId = OHIF.viewerbase.getImageId(instance);
}
if ((totalMatchScore > highestImageMatchingScore) || !bestMatch) {
highestImageMatchingScore = totalMatchScore;
bestMatch = imageDetails;
}
matchingScores.push(imageDetails);
});
});
});
// Sort the matchingScores
const sortingFunction = OHIF.utils.sortBy({
name: 'score',
reverse: true
}, {
name: 'study',
reverse: true
}, {
name: 'instance'
}, {
name: 'series'
});
matchingScores.sort((a, b) => sortingFunction(a.sortingInfo, b.sortingInfo));
return {
bestMatch: bestMatch,
matchingScores: matchingScores
};
}
/**
* Rerenders viewports that are part of the current ProtocolEngine's LayoutManager
* using the matching rules internal to each viewport.
*
* If this function is provided the index of a viewport, only the specified viewport
* is rerendered.
*
* @param viewportIndex
*/
updateViewports(viewportIndex) {
// Make sure we have an active protocol with a non-empty array of display sets
if (!this.protocol || !this.protocol.stages || !this.protocol.stages.length) {
return;
}
// Retrieve the current display set in the display set sequence
var stageModel = this.getCurrentStageModel();
// If the current display set does not fulfill the requirements to be displayed,
// stop here.
if (!stageModel ||
!stageModel.viewportStructure ||
!stageModel.viewports ||
!stageModel.viewports.length) {
return;
}
// Retrieve the layoutTemplate associated with the current display set's viewport structure
// If no such template name exists, stop here.
var layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
if (!layoutTemplateName) {
return;
}
// Retrieve the properties associated with the current display set's viewport structure template
// If no such layout properties exist, stop here.
var layoutProps = stageModel.viewportStructure.properties;
if (!layoutProps) {
return;
}
// Create an empty array to store the output viewportData
var viewportData = [];
// 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
this.matchDetails = [];
// Loop through each viewport
stageModel.viewports.forEach((viewport, viewportIndex) => {
var details = this.matchImages(viewport);
this.matchDetails[viewportIndex] = details;
// Convert any YES/NO values into true/false for Cornerstone
var cornerstoneViewportParams = {};
Object.keys(viewport.viewportSettings).forEach(function(key) {
var value = viewport.viewportSettings[key];
if (value === 'YES') {
value = true;
} else if (value === 'NO') {
value = false;
}
cornerstoneViewportParams[key] = value;
});
// imageViewerViewports occasionally needs relevant layout data in order to set
// the element style of the viewport in question
var currentViewportData = $.extend({
viewportIndex: viewportIndex,
viewport: cornerstoneViewportParams
}, layoutProps);
var customSettings = [];
Object.keys(viewport.viewportSettings).forEach(id => {
var setting = HP.CustomViewportSettings[id];
if (!setting) {
return;
}
customSettings.push({
id: id,
value: viewport.viewportSettings[id]
});
});
currentViewportData.renderedCallback = function(element) {
//console.log('renderedCallback for ' + element.id);
customSettings.forEach(function(customSetting) {
console.log('Applying custom setting: ' + customSetting.id);
console.log('with value: ' + customSetting.value);
var setting = HP.CustomViewportSettings[customSetting.id];
setting.callback(element, customSetting.value);
});
};
let currentMatch = details.bestMatch;
let currentPosition = 1;
const scoresLength = details.matchingScores.length;
while (currentPosition < scoresLength && _.findWhere(viewportData, {
imageId: currentMatch.imageId
})) {
currentMatch = details.matchingScores[currentPosition];
currentPosition++;
}
if (currentMatch && currentMatch.imageId) {
currentViewportData.studyInstanceUid = currentMatch.studyInstanceUid;
currentViewportData.seriesInstanceUid = currentMatch.seriesInstanceUid;
currentViewportData.sopInstanceUid = currentMatch.sopInstanceUid;
currentViewportData.currentImageIdIndex = currentMatch.currentImageIdIndex;
currentViewportData.displaySetInstanceUid = currentMatch.displaySetInstanceUid;
currentViewportData.imageId = currentMatch.imageId;
}
if (!currentViewportData.displaySetInstanceUid) {
throw 'No matching display set found?';
}
viewportData.push(currentViewportData);
});
this.LayoutManager.layoutTemplateName = layoutTemplateName;
this.LayoutManager.layoutProps = layoutProps;
this.LayoutManager.viewportData = viewportData;
if (viewportIndex !== undefined && viewportData[viewportIndex]) {
this.LayoutManager.rerenderViewportWithNewDisplaySet(viewportIndex, viewportData[viewportIndex]);
} else {
this.LayoutManager.updateViewports();
}
}
/**
* Sets the current Hanging Protocol to the specified Protocol
* An optional argument can also be used to prevent the updating of the Viewports
*
* @param newProtocol
* @param updateViewports
*/
setHangingProtocol(newProtocol, updateViewports=true) {
// Reset the array of newStageIds
this.newStageIds = [];
if (HP.Protocol.prototype.isPrototypeOf(newProtocol)) {
this.protocol = newProtocol;
} else {
this.protocol = new HP.Protocol();
this.protocol.fromObject(newProtocol);
}
this.stage = 0;
// Update viewports by default
if (updateViewports) {
this.updateViewports();
}
MatchedProtocols.update({}, {
$set: {
selected: false
}
}, {
multi: true
});
MatchedProtocols.update({
id: this.protocol.id
}, {
$set: {
selected: true
}
});
}
/**
* Changes the current stage to a new stage index in the display set sequence
*
* @param stage An integer value specifying the index of the desired Stage
*/
setCurrentProtocolStage(stage) {
if (!this.protocol || !this.protocol.stages || !this.protocol.stages.length) {
return;
}
if (stage >= this.protocol.stages.length) {
return;
}
this.stage = stage;
this.updateViewports();
}
/**
* Retrieves the number of Stages in the current Protocol
*/
getNumProtocolStages() {
if (!this.protocol || !this.protocol.stages || !this.protocol.stages.length) {
return;
}
return this.protocol.stages.length;
}
/**
* Switches to the next protocol stage in the display set sequence
*/
nextProtocolStage() {
this.setCurrentProtocolStage(++this.stage);
}
/**
* Switches to the previous protocol stage in the display set sequence
*/
previousProtocolStage() {
this.setCurrentProtocolStage(--this.stage);
}
};