import { Meteor } from 'meteor/meteor'; import { _ } from 'meteor/underscore'; // OHIF Modules import { OHIF } from 'meteor/ohif:core'; import 'meteor/ohif:viewerbase'; /** * Import Constants */ const StudyMetadata = OHIF.viewerbase.metadata.StudyMetadata; const StudySummary = OHIF.viewerbase.metadata.StudySummary; // 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 * @param {Object} LayoutManager Layout Manager Object * @param {Array} studies Array of study metadata * @param {Array} relatedStudies Array of related studies * @param {Object} studyMedadataSource Instance of StudyMetadataSource (ohif-viewerbase) Object to get study metadata */ constructor(layoutManager, studies, priorStudies, studyMetadataSource) { // ----------- // Validations if (!(layoutManager instanceof OHIF.viewerbase.LayoutManager)) { throw new OHIF.viewerbase.OHIFError('ProtocolEngine::constructor layoutManager is not an instance of LayoutManager'); } if (!(studyMetadataSource instanceof OHIF.viewerbase.StudyMetadataSource)) { throw new OHIF.viewerbase.OHIFError('ProtocolEngine::constructor studyMetadataSource is not an instance of StudyMetadataSource'); } // @TODO: Validate "studies" parameter // -------------- // Initialization this.LayoutManager = layoutManager; this.studies = studies; this.priorStudies = priorStudies instanceof Map ? priorStudies : new Map(); this.studyMetadataSource = studyMetadataSource; // Put protocol engine in a known states 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) { OHIF.log.info('ProtocolEngine::findMatchByStudy'); const 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. const rules = protocol.protocolMatchingRules.slice(0); if (!rules) { return; } study.numberOfPriorsReferenced = this.getNumberOfAvailablePriors(study.getStudyInstanceUID()); const rule = new HP.ProtocolMatchingRule('numberOfPriorsReferenced', { numericality: { greaterThanOrEqualTo: protocol.numberOfPriorsReferenced } }); rules.push(rule); const matchedDetails = HP.match(study, rules); if (matchedDetails.score > 0) { matched.push({ score: matchedDetails.score, protocol: protocol }); } }); if (!matched.length) { const defaultProtocol = HP.ProtocolStore.getProtocol('defaultProtocol'); return [{ score: 1, protocol: defaultProtocol }]; } sortByScore(matched); OHIF.log.info('ProtocolEngine::findMatchByStudy matched', matched); return matched; } /** * Populates the MatchedProtocols Collection by running the matching procedure */ updateMatches() { OHIF.log.info('ProtocolEngine::updateMatches'); // Clear all data from the MatchedProtocols Collection MatchedProtocols.remove({}); // For each study, find the matching protocols this.studies.forEach(study => { const matched = this.findMatchByStudy(study); // For each matched protocol, check if it is already in MatchedProtocols matched.forEach(matchedDetail => { const protocol = matchedDetail.protocol; if (!protocol) { return; } const 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 const sorted = MatchedProtocols.find({}, { sort: { score: -1 }, limit: 1 }).fetch(); // Highest scoring Protocol const bestMatch = sorted[0].protocol; OHIF.log.info('ProtocolEngine::getBestMatch bestMatch', bestMatch); return bestMatch; } /** * Get the number of prior studies supplied in the priorStudies map property. * * @param {String} studyInstanceUID The StudyInstanceUID of the study whose priors are needed * @returns {number} The number of available prior studies with the same PatientID */ getNumberOfAvailablePriors(studyInstanceUID) { const priors = this.getAvailableStudyPriors(studyInstanceUID); return priors.length; } /** * Get the array prior studies from a specific study. * * @param {String} studyInstanceUID The StudyInstanceUID of the study whose priors are needed * @returns {Array} The array of available priors or an empty array */ getAvailableStudyPriors(studyInstanceUID) { const priors = this.priorStudies.get(studyInstanceUID); return priors instanceof Array ? priors : []; } // Match images given a list of Studies and a Viewport's image matching reqs matchImages(viewport) { OHIF.log.info('ProtocolEngine::matchImages'); const studyMatchingRules = viewport.studyMatchingRules; const seriesMatchingRules = viewport.seriesMatchingRules; const instanceMatchingRules = viewport.imageMatchingRules; const matchingScores = []; const currentStudy = this.studies[0]; let highestStudyMatchingScore = 0; let highestSeriesMatchingScore = 0; let highestImageMatchingScore = 0; let bestMatch; currentStudy.abstractPriorValue = 0; studyMatchingRules.forEach(rule => { if (rule.attribute === 'abstractPriorValue') { const validatorType = Object.keys(rule.constraint)[0]; const validator = Object.keys(rule.constraint[validatorType])[0]; let abstractPriorValue = rule.constraint[validatorType][validator]; abstractPriorValue = parseInt(abstractPriorValue, 10); // TODO: Restrict or clarify validators for abstractPriorValue? const studies = this.getAvailableStudyPriors(currentStudy.getStudyInstanceUID()); // TODO: Revisit this later: What about two studies with the same // study date? let priorStudy; if (abstractPriorValue === -1) { priorStudy = studies[studies.length - 1]; } else { const studyIndex = Math.max(abstractPriorValue - 1, 0); priorStudy = studies[studyIndex]; } if (!(priorStudy instanceof StudyMetadata) && !(priorStudy instanceof StudySummary)) { return; } // @TODO: Make sure this study is already loaded into the viewer (OHIF.viewer.Studies) // @TypeSafeStudies const priorStudyInstanceUID = priorStudy.getStudyInstanceUID(); const alreadyLoaded = OHIF.viewer.Studies.findBy({ studyInstanceUid: priorStudyInstanceUID }); if (!alreadyLoaded) { this.studyMetadataSource.getByInstanceUID(priorStudyInstanceUID).then(study => { study.abstractPriorValue = abstractPriorValue; const studyMetadata = new OHIF.metadata.StudyMetadata(study); const displaySets = OHIF.viewerbase.sortingManager.getDisplaySets(studyMetadata); studyMetadata.setDisplaySets(displaySets); study.selected = true; study.displaySets = displaySets; OHIF.viewer.Studies.insert(study); OHIF.viewer.StudyMetadataList.insert(studyMetadata); }, error => { console.alert(error) }); // this.studyMetadataSource.getByInstanceUID(priorStudy.studyInstanceUid).then(, 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(study => { const studyMatchDetails = HP.match(study, studyMatchingRules); if ((studyMatchingRules.length && !studyMatchDetails.score) || studyMatchDetails.score < highestStudyMatchingScore) { return; } highestStudyMatchingScore = studyMatchDetails.score; study.forEachSeries(series => { const seriesMatchDetails = HP.match(series, seriesMatchingRules); if ((seriesMatchingRules.length && !seriesMatchDetails.score) || seriesMatchDetails.score < highestSeriesMatchingScore) { return; } highestSeriesMatchingScore = seriesMatchDetails.score; series.forEachInstance((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 // sopClassUid = x00080016 // rows = x00280010 if (!OHIF.viewerbase.isImage(instance.getRawValue('x00080016')) && !instance.getRawValue('x00280010')) { 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 currentSOPInstanceUID = instance.getSOPInstanceUID(); const imageDetails = { studyInstanceUid: study.getStudyInstanceUID(), seriesInstanceUid: series.getSeriesInstanceUID(), sopInstanceUid: currentSOPInstanceUID, currentImageIdIndex: index, matchingScore: totalMatchScore, matchDetails: matchDetails, sortingInfo: { score: totalMatchScore, study: instance.getRawValue('x00080020') + instance.getRawValue('x00080030'), // StudyDate = x00080020 StudyTime = x00080030 series: parseInt(instance.getRawValue('x00200011')), // TODO: change for seriesDateTime SeriesNumber = x00200011 instance: parseInt(instance.getRawValue('x00200013')) // TODO: change for acquisitionTime InstanceNumber = x00200013 } }; // Find the displaySet const displaySet = study.findDisplaySet(displaySet => displaySet.images.find(image => image.getSOPInstanceUID() === currentSOPInstanceUID)); // If the instance was found, set the displaySet ID if (displaySet) { imageDetails.displaySetInstanceUid = displaySet.getUID(); imageDetails.imageId = instance.getImageId(); } 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)); OHIF.log.info('ProtocolEngine::matchImages bestMatch', bestMatch); 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) { OHIF.log.info(`ProtocolEngine::updateViewports viewportIndex: ${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 const 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. const 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. const layoutProps = stageModel.viewportStructure.properties; if (!layoutProps) { return; } // Create an empty array to store the output viewportData const 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) => { const details = this.matchImages(viewport); this.matchDetails[viewportIndex] = details; // Convert any YES/NO values into true/false for Cornerstone const cornerstoneViewportParams = {}; // Cache viewportSettings keys const viewportSettingsKeys = Object.keys(viewport.viewportSettings); viewportSettingsKeys.forEach(key => { let 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 const currentViewportData = { viewportIndex, viewport: cornerstoneViewportParams, ...layoutProps }; const customSettings = []; viewportSettingsKeys.forEach(id => { const setting = HP.CustomViewportSettings[id]; if (!setting) { return; } customSettings.push({ id: id, value: viewport.viewportSettings[id] }); }); currentViewportData.renderedCallback = element => { //console.log('renderedCallback for ' + element.id); customSettings.forEach(customSetting => { OHIF.log.info(`ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}`); OHIF.log.info(`ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}`); const 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) { //@TODO: make OHIFError use Meteor.Error instead of simple Error() throw new OHIF.viewerbase.OHIFError('ProtocolEngine::updateViewports 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) { OHIF.log.info('ProtocolEngine::setHangingProtocol newProtocol', newProtocol); OHIF.log.info(`ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}`); // 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) { OHIF.log.info(`ProtocolEngine::setCurrentProtocolStage stage = ${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); } };