// 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)) { 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 = []; HangingProtocols.find().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 = HangingProtocols.findOne({ id: '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; var protocolInCollection = MatchedProtocols.findOne({ id: protocol.id }); // If it is not already in the MatchedProtocols Collection, insert it if (!protocolInCollection) { MatchedProtocols.insert(protocol); } }); }); } /** * 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]; } /** * Calculates the number of previous studies in the cached Worklist 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 = WorklistStudies.find({ patientId: study.patientId, studyDate: { $lt: study.studyDate } }); return studies.count(); } findRelatedStudies(protocol, study) { if (!protocol.protocolMatchingRules) { return; } var studies = WorklistStudies.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 = WorklistStudies.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; } var alreadyLoaded = ViewerStudies.findOne({ studyInstanceUid: priorStudy.studyInstanceUid }); if (!alreadyLoaded) { getStudyMetadata(priorStudy.studyInstanceUid, study => { study.abstractPriorValue = abstractPriorValue; ViewerStudies.insert(study); this.studies.push(study); this.matchImages(viewport); this.updateViewports(); }); } } // TODO: Add relative Date / time }); var lastStudyIndex = this.studies.length - 1; this.studies.forEach(function(study) { var 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 (!instance.rows || !instance.columns) { return; } var instanceMatchDetails = HP.match(instance, instanceMatchingRules); var 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); var totalMatchScore = instanceMatchDetails.score + seriesMatchDetails.score + studyMatchDetails.score; var imageDetails = { studyInstanceUid: study.studyInstanceUid, seriesInstanceUid: series.seriesInstanceUid, sopInstanceUid: instance.sopInstanceUid, currentImageIdIndex: index, matchingScore: totalMatchScore, matchDetails: matchDetails }; if ((totalMatchScore > highestImageMatchingScore) || !bestMatch) { highestImageMatchingScore = totalMatchScore; bestMatch = imageDetails; } matchingScores.push(imageDetails); }); }); }); 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); }); }; if (details.bestMatch) { currentViewportData.studyInstanceUid = details.bestMatch.studyInstanceUid; currentViewportData.seriesInstanceUid = details.bestMatch.seriesInstanceUid; currentViewportData.sopInstanceUid = details.bestMatch.sopInstanceUid; currentViewportData.currentImageIdIndex = details.bestMatch.currentImageIdIndex; } viewportData.push(currentViewportData); }); this.LayoutManager.layoutTempleName = layoutTemplateName; this.LayoutManager.layoutProps = layoutProps; this.LayoutManager.viewportData = viewportData; if (viewportIndex !== undefined && viewportData[viewportIndex]) { this.LayoutManager.rerenderViewportWithNewSeries(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); } };