Updates for Timepoint assocation (LT-60) and Conformance Checks (LT-92, LT-93, LT-94)

This commit is contained in:
Erik Ziegler committed 2016-02-08 19:59:39 +01:00
1 parent 6c4eae3699
commit beaa56c0b3
144 files changed
+5050 -2416

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
/**
* Retrieve a location name (e.g. Liver Right) from the
* PatientLocations Collection by id, if it exists. Otherwise,
* return an empty string.
*
* @param id
* @returns {*|string}
*/
function getLocationName(id) {
var locationObject = PatientLocations.findOne(id);
if (!locationObject || !locationObject.location) {
return '';
}
return locationObject.location;
}
/**
* Update the Timepoint object for a specific Measurement.
* If no measurement exists yet, one will be created.
*
* Input is toolData from the lesion or nonTarget tool
*
* @param lesionData
*/
function updateLesionData(lesionData) {
var study = Studies.findOne({
studyInstanceUid: lesionData.studyInstanceUid
});
if (!study) {
log.warn('Study is not associated with a timepoint');
return;
}
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
log.warn('Timepoint in an image is not present in the Timepoints Collection?');
return;
}
// Find the specific lesion to be updated
var existingMeasurement;
if (lesionData.id && lesionData.id !== 'notready') {
existingMeasurement = Measurements.findOne(lesionData.id);
} else {
existingMeasurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
}
// Create a structure for the timepointData based
// on this Lesion's toolData
var timepointData = {
seriesInstanceUid: lesionData.seriesInstanceUid,
studyInstanceUid: lesionData.studyInstanceUid,
sopInstanceUid: lesionData.sopInstanceUid,
handles: lesionData.handles,
imageId: lesionData.imageId
};
if (lesionData.isTarget === true) {
timepointData.shortestDiameter = lesionData.widthMeasurement;
timepointData.longestDiameter = lesionData.measurementText;
} else {
timepointData.response = lesionData.response;
}
// If no such lesion exists, we need to add one
if (!existingMeasurement) {
// Create a data structure for the Measurement
// based on the current tool data
var measurement = {
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget,
patientId: lesionData.patientId,
id: lesionData.id
};
// Retrieve the location name given the locationUID
if (lesionData.locationUID !== undefined) {
var locationObj = PatientLocations.findOne({
locationUID: lesionData.locationUID
});
measurement.location = locationObj.location;
measurement.isNodal = locationObj.isNodal;
}
// Add toolData parameters to the Measurement at this Timepoint
measurement.timepoints = {};
measurement.timepoints[timepoint.timepointId] = timepointData;
// Set a flag to prevent duplication of toolData
measurement.toolDataInsertedManually = true;
// Increment and store the absolute Lesion Number for this Measurement
measurement.lesionNumberAbsolute = Measurements.find().count() + 1;
// Insert this into the Measurements Collection
// Save the ID into the toolData (not sure if this works?)
measurement.id = Measurements.insert(measurement);
// Update the database entry so it can be readded next time the study is loaded
Measurements.update(measurement.id, {
$set: {
toolDataInsertedManually: false
}
});
} else {
lesionData.id = existingMeasurement._id;
lesionData.isNodal = existingMeasurement.isNodal;
// Update timepoints from lesion data
existingMeasurement.timepoints[timepoint.timepointId] = timepointData;
Measurements.update(existingMeasurement._id, {
$set: {
timepoints: existingMeasurement.timepoints
}
});
}
}
/**
* Returns new lesion number according to timepointId
* @param timepointId
* @param isTarget
* @returns {*}
*/
function getNewLesionNumber(timepointId, isTarget) {
// Get all current lesion measurements
var numMeasurements = Measurements.find({
isTarget: isTarget
}).count();
// If no measurements exist yet, start at 1
if (!numMeasurements) {
return 1;
}
// Find related measurements (i.e. target or non-target)
var measurements = Measurements.find({
isTarget: isTarget
}, {
sort: {
lesionNumber: 1
}
}).fetch();
// If measurements exist, find the last lesion number
// from the given timepoint
var lesionNumberCounter = 1;
// Search through every Measurement to see which ones
// already have data for this Timepoint, if we find one that
// doesn't have data, we will stop there and use that as the
// current Measurement
measurements.every(function(measurement) {
// If this measurement has no data for this Timepoint,
// use this as the current Measurement
if (!measurement.timepoints[timepointId]) {
lesionNumberCounter = measurement.lesionNumber;
return false;
}
lesionNumberCounter++;
return true;
});
return lesionNumberCounter;
}
/**
* If the current Lesion Number already exists
* for any other timepoint, returns lesion locationUID
* @param lesionData
* @returns {*}
*/
function lesionNumberExists(lesionData) {
var measurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
if (!measurement) {
return;
}
return measurement.locationUID;
}
LesionManager = {
updateLesionData: updateLesionData,
getNewLesionNumber: getNewLesionNumber,
lesionNumberExists: lesionNumberExists,
getLocationName: getLocationName
};
@@ -0,0 +1,388 @@
// Define the Trial Criteria Structure
TrialCriteriaConstraints = {
RECIST: RECIST,
irRC: irRC
};
/**
* RECIST 1.1 Trial Criteria Definition
*
* Baseline Checks:
* - Extranodal lesions must be >/= 10 mm long axis AND >/= double the acquisition slice thickness by CT and MR
* - Extranodal lesions must be >/= 20 mm on chest x-ray (although x-rays rarely used for clinical trial assessment)
* - Nodal lesions must be >/= 15 mm short axis AND >/= double the acquisition slice thickness by CT and MR
* - Up to a max of 2 target lesions per organ
* - Up to a max of 5 target lesions total
* - Non-targets can only be assessed as 'present'
* - Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)
* - Time Point Measurement Total = Sum of long axis measurements for extranodal target lesion + short axis measurements for nodal lesions
*/
function RECIST(image) {
var acquisitionSliceThickness;
if (image) {
acquisitionSliceThickness = image.acquisitionSliceThickness;
// TODO: Use metaData to determine if this is a chest X-ray
var isChestXray = false;
}
// Define the RECIST 1.1 structure
var criteria = {
baseline: {
target: {},
nonTarget: {},
group: {}
}
};
if (acquisitionSliceThickness) {
criteria.baseline.target.nodal = {
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(15, 2 * acquisitionSliceThickness),
message: '^Nodal lesions must be >= 15 mm short axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else {
criteria.baseline.target.nodal = {
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: 15,
message: '^Nodal target lesions must be >= %{count} mm short axis'
}
}
};
}
criteria.baseline.target.all = {
// - Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)
response: {
exclusion: {
within: {
CR: 'Complete Response (CR)',
UN: 'Unknown (UN)',
NE: 'Non-evaluable (NE)',
EX: 'Excluded (EX)'
},
message: '^Target lesions must have a length and cannot be marked as %{value} at baseline.'
}
},
totalLesionBurden: {
numericality: {
greaterThanOrEqualTo: 2, // TODO: Check this, the value wasn't specified!
message: '^Total lesion burden (SPD target lesions + SPD new lesions) should be greater than %{count}.'
}
}
};
criteria.baseline.nonTarget.all = {
// - Non-targets can only be assessed as 'present'
response: {
// This is a workaround since Validating equality to something is not implemented yet
// https://github.com/ansman/validate.js/issues/79
presence: {
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
},
inclusion: {
within: ['Present'],
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
}
}
};
criteria.baseline.perOrgan = {
numberOfLesionsPerOrgan: {
numericality: {
lessThanOrEqualTo: 2,
//message: '^A maximum of %{count} target lesions per organ are allowed at Baseline.'
}
}
};
criteria.baseline.group = {
totalNumberOfLesions: {
numericality: {
lessThanOrEqualTo: 5,
//message: '^A maximum of %{count} target lesions total are allowed at Baseline.'
}
}
};
if (acquisitionSliceThickness) {
criteria.baseline.target.extraNodal = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(10, 2 * acquisitionSliceThickness),
message: '^Extranodal lesions must be >= 10 mm long axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else if (isChestXray) {
criteria.baseline.target.extraNodal = {
// -
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 20,
message: '^Extranodal lesions must be >= %{count} mm on chest X-ray'
}
}
};
} else {
criteria.baseline.target.extraNodal = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 10,
message: '^Extranodal target lesions must be >= %{count} mm long axis'
}
}
};
}
return criteria;
}
/**
* irRC Trial Criteria Definition
*
* Baseline Checks:
* - Target lesions must be >/= 10 X 10 mm
* - Up to a max of 5 target lesions per organ
* - Up to a max of 10 target lesions total
* - Non-targets can only be assessed as 'present'
* - Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)
*/
function irRC(image) {
var acquisitionSliceThickness;
if (image) {
acquisitionSliceThickness = image.acquisitionSliceThickness;
}
// Define the irRC structure
var criteria = {
baseline: {
target: {},
nonTarget: {}
},
followup: {
newLesions: {
target: {}
},
target: {}
},
all: {}
};
if (acquisitionSliceThickness) {
criteria.baseline.target.all = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(10, acquisitionSliceThickness),
message: '^Target lesions must be >= 10 mm long axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(10, acquisitionSliceThickness),
message: '^Target lesions must be >= 10 mm short axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else {
criteria.baseline.target.all = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 10,
message: '^Target lesions must be >= %{count} mm long axis.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: 10,
message: '^Target lesions must be >= %{count} mm short axis.'
}
}
};
}
criteria.baseline.target.all.response = {
exclusion: {
within: {
CR: 'Complete Response (CR)',
UN: 'Unknown (UN)',
NE: 'Non-evaluable (NE)',
EX: 'Excluded (EX)'
},
message: '^^Target lesions must have a length and cannot be marked as %{value} at baseline.'
}
};
criteria.baseline.nonTarget.all = {
response: {
// This is a workaround since Validating equality to something is not implemented yet
// https://github.com/ansman/validate.js/issues/79
presence: {
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
},
inclusion: {
within: ['Present'],
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
}
}
};
criteria.baseline.perOrgan = {
numberOfLesionsPerOrgan: {
numericality: {
lessThanOrEqualTo: 5,
//message: '^A maximum of %{count} target lesions per organ are allowed at Baseline.'
}
}
};
criteria.baseline.group = {
totalNumberOfLesions: {
numericality: {
lessThanOrEqualTo: 10,
//message: '^A maximum of %{count} target lesions total are allowed at Baseline.'
}
}
};
if (acquisitionSliceThickness) {
criteria.followup.newLesions.target.all = {
// - New target lesions must be >/= 5 X 5 mm AND >/= double the acquisition slice thickness by CT and MR
longestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(5, 2 * acquisitionSliceThickness),
message: '^New target lesions must be >= 5 mm long axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(5, 2 * acquisitionSliceThickness),
message: '^New target lesions must be >= 5 mm short axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else {
criteria.followup.newLesions.target.all = {
// - New target lesions must be >/= 5 X 5 mm
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 5,
message: '^New target lesions must be >= %{count} mm long axis.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: 5,
message: '^New target lesions must be >= %{count} mm short axis.'
}
}
};
}
criteria.followup.group = {
numberOfLesionsPerOrgan: {
numericality: {
lessThanOrEqualTo: 5,
message: '^A maximum of %{count} target lesions per organ are allowed at Followup.'
}
},
totalNumberOfLesions: {
numericality: {
lessThanOrEqualTo: 10,
message: '^A maximum of %{count} target lesions total are allowed at Followup.'
}
}
};
// TODO: Check the actual requirement for total burden!
criteria.all.group = {
totalLesionBurden: {
numericality: {
greaterThanOrEqualTo: 100,
message: '^Total lesion burden (SPD target lesions + SPD new lesions) should be greater than %{count}.'
}
}
};
return criteria;
}
/**
* Retrieve trial criteria constraints based on the image that measurements appear upon
* If no image is specified, it is assumed that group or per Organ level criteria are desired.
*
* @param criteriaType A valid Trial Criteria set name (e.g. 'RECIST' or 'irRC')
* @param imageId A Cornerstone Image ID
* @returns {*} An Object of Trial Criteria that can be used to validate measurements' conformance
*/
getTrialCriteriaConstraints = function(criteriaType, imageId) {
if (!TrialCriteriaConstraints[criteriaType]) {
throw 'No such Trial Criteria defined: ' + criteriaType;
}
// If no imageId was specified, skip customization of the criteria
// and return the requested criteria right away
var criteria;
if (!imageId) {
criteria = TrialCriteriaConstraints[criteriaType]();
return criteria;
}
// Otherwise, retrieve the series metaData to identify the modality of the image
var seriesMetaData = cornerstoneTools.metaData.get('series', imageId);
// TODO: Get the rest of the metaData that has already been loaded by Cornerstone
var image = {};
// If we are looking at an MR or CT image, we should pass the slice thickness
// to the Trial Criteria functions so that they can customize the validation rules
if (seriesMetaData.modality === 'MR' || seriesMetaData.modality === 'CT') {
var instanceMetaData = cornerstoneTools.metaData.get('instance', imageId);
image.acquisitionSliceThickness = instanceMetaData.sliceThickness;
}
// Retrieve the study metaData in order to find the timepoint type
var studyMetaData = cornerstoneTools.metaData.get('study', imageId);
if (!studyMetaData) {
return;
}
// Retrieve the Study document from the Collection of associated Studies
var study = Studies.findOne({
studyInstanceUid: studyMetaData.studyInstanceUid
});
if (!study) {
log.warn('No study/timepoint association.');
return;
}
// Find the related Timepoint document
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
log.warn('Timepoint related to study is missing.');
return;
}
// Retrieve the Timepoint's type (e.g. 'baseline' or 'followup')
var timepointType = timepoint.timepointType;
// Obtain the customized trial criteria given the image metaData
criteria = TrialCriteriaConstraints[criteriaType](image);
// Return the relevant criteria given the current timepoint type
return criteria[timepointType];
};
@@ -0,0 +1,343 @@
// Create a client-only Collection to store our Validation Errors
ValidationErrors = new Meteor.Collection(null);
// Set Validate.js Library's default options
validate.options = {
format: 'detailed'
};
/**
* Creates an array of validation error messages given an Object of validation errors
* and an optional prefix for the messages. An example of a useful prefix would be
* the location of the measurement or something like 'Target 1 '.
*
* @param validationErrors
* @param prefix
*/
function addValidationErrorsToCollection(validationErrors, prefix, type) {
// If no input was given, stop here
if (!validationErrors || !validationErrors.length) {
return;
}
// Loop through each of the entries in the validationErrors Array
validationErrors.forEach(function(validationError) {
var existingError = ValidationErrors.findOne({
attribute: validationError.attribute,
validator: validationError.validator,
error: validationError.error,
prefix: prefix
});
if (existingError) {
ValidationErrors.update(existingError._id, {
$set: {
value: validationError.value
}
});
} else {
validationError.type = type;
validationError.prefix = prefix;
ValidationErrors.insert(validationError);
}
});
}
/**
* Runs conformance checks related to a group of measurements. This function
* searches the input object of Constraints and looks for the 'group' attribute.
*
* It calculates some general group-level values for the current set of Measurements
* and validates these using the input constraints.
*
* @param constraints
* @returns {Array} Array of error messages related to the input conformance checks
*/
function assessGroupOfMeasurements(constraints) {
// Retrieve the group-level constraints
var groupConstraints = constraints.group;
// If no group-level constraints exist, stop here
if (!groupConstraints) {
return;
}
var type = 'group';
ValidationErrors.remove({
type: type
});
// Get the criteria type so we can calculate total lesion burden
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
// Calculate some simple group-level Measurement statistics for validation
var testStructure = {
totalNumberOfLesions: Measurements.find().count(),
totalLesionBurden: calculateTotalLesionBurden(criteriaType)
};
// Run the conformance checks with the validate.js library
var validationErrors = validate(testStructure, groupConstraints);
// Return any error messages as a flattened array of errors
addValidationErrorsToCollection(validationErrors, '', type);
}
/**
* Runs conformance checks related to per-organ sets of measurements.
*
* This function searches the input object of Constraints and looks for the
* 'perOrgan' attribute.
*
* It calculates some general per-organ statistics for the current set of Measurements
* and validates these using the input constraints.
*
* @param constraints
* @returns {Array} Array of error messages related to the input conformance checks
*/
function assessMeasurementPerOrgan(constraints) {
// Retrieve the per-organ constraints
var perOrganConstraints = constraints.perOrgan;
// If no per-organ constraints exist, stop here
if (!perOrganConstraints) {
return;
}
// Create a list of all unique locations that contain measurements
// by looping through the Measurements Collection
var organLocations = [];
Measurements.find().forEach(function(measurement) {
if (organLocations.indexOf(measurement.location) > -1) {
return;
}
organLocations.push(measurement.location);
});
var type = 'perOrgan';
ValidationErrors.remove({
type: type
});
// Loop through each unique organ location in order to validate
// the per-organ constraints for each organ
organLocations.forEach(function(location) {
// Calculate the number of Lesions per Organ
var numberOfLesionsPerOrgan = Measurements.find({
location: location
}).count();
// Store per-organ Measurement statistics for validation
// Right now this is only the numberOfLesionsPerOrgan, but later
// this may include other checks
var testStructure = {
numberOfLesionsPerOrgan: numberOfLesionsPerOrgan
};
// Run the conformance checks with the validate.js library
var validationErrors = validate(testStructure, perOrganConstraints);
// Obtain any error messages as a flattened array of errors, prefixed
// with the Organ name, in the form 'Liver Left: '
addValidationErrorsToCollection(validationErrors, location + ': ', type);
});
}
/**
* Runs conformance checks on a single Measurement given the
* cornerstone toolData related to it.
*
* @param constraints
* @param measurementData CornerstoneTools toolData Object for this specific Measurement
* @returns {Array} Array of error messages related to the input conformance checks
*/
function assessSingleMeasurement(constraints, measurementData) {
// Check whether this is a Target or Non-Target Measurement
var targetType = measurementData.isTarget ? 'target' : 'nonTarget';
// Retrieve any target/non-target-specific single-measurement constraints
// from the input constraint structure
var measurementConstraints = constraints[targetType];
// If no relevant constraints exist, stop here
if (!measurementConstraints) {
return;
}
// Check whether this is a Nodal or Extranodal Measurement
var nodalType = measurementData.isNodal ? 'nodal' : 'extraNodal';
// Retrieve any nodal/extra-nodal-specific constraints to see if we can apply them
var constraintsToApply;
if (measurementData.isNodal !== undefined && measurementConstraints[nodalType]) {
// Check if we have enough information (about nodality of this Measurement,
// and nodality-specific constraints) to apply nodality-specific constraints
constraintsToApply = measurementConstraints[nodalType];
} else if (measurementConstraints.all) {
// If we have no data about the nodality of this Measurement, or no relevant
// specific constraints, we should apply the constraints valid for 'all' nodality
// types
constraintsToApply = measurementConstraints.all;
}
// Calculate a lesion name based on whether or not we have a Target or Non-target
// Measurement, and the lesion number of this Measurement.
var lesionName = measurementData.isTarget ? 'Target' : 'Non-target';
lesionName = lesionName + ' ' + measurementData.lesionNumber + ': ';
ValidationErrors.remove({
prefix: lesionName
});
// Use validate.js to check the criteria
var validationErrors = validate(measurementData, constraintsToApply);
if (validationErrors) {
validationErrors.forEach(function(error) {
error.measurementId = measurementData._id;
});
}
// Use the Lesion Name as a prefix to concatenate any validation error messages into
// an array to return
addValidationErrorsToCollection(validationErrors, lesionName);
}
/**
* Validate from a single Measurement up the chain to include group and perOrgan
* conformance checks
*
* @param measurementData The CornerstoneTools toolData for a single Measurement
*/
function validateSingleMeasurement(measurementData) {
// Obtain the name of the current TrialResponseAssessmentCriteria that
// we are using.
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
var currentConstraints = getTrialCriteriaConstraints(criteriaType, measurementData.imageId);
if (!currentConstraints) {
log.warn('No relevant contraints could be applied');
return;
}
// Find the relevant Measurement in the Measurements Collection
var measurement = Measurements.findOne(measurementData.id);
// If no such Measurement exists, stop here
if (!measurement) {
log.warn('No Measurement found?');
return;
}
// Find the current timepointId that the user was editing the Measurement on
var timepointId = measurementData.timepointId;
// Find the specific measurement data for this Measurement at this Timepoint
var currentMeasurement = measurement.timepoints[timepointId];
// Include target and nodal flags on the timepoint-specific data so it is easier to validate
// TODO: Rethink what to pass to assessSingleMeasurement?
currentMeasurement.isTarget = measurement.isTarget;
currentMeasurement.isNodal = measurement.isNodal;
currentMeasurement.lesionNumber = measurement.lesionNumber;
currentMeasurement._id = measurement._id;
// Run the single-measurement-specific conformance checks
// If any messages exist, add them to the array of messages
assessSingleMeasurement(currentConstraints, currentMeasurement);
validateGroups();
}
function validateGroups() {
// Obtain the name of the current TrialResponseAssessmentCriteria that
// we are using.
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
Timepoints.find().forEach(function(timepoint) {
// TODO: Criteria for the specific image are retrieved from the general set of criteria.
// - The acquisitionSliceThickness, for example, may be pulled from the image metadata
// - The organ in question, e.g. Chest X-ray, may determine the exact specifications for the current trial criteria
var currentConstraints = getTrialCriteriaConstraints(criteriaType);
// Retrieve the current constraints which apply to the specific Timepoint type
// (e.g. baseline, followup) that this Measurement is being edited on.
var timepointConstraints = currentConstraints[timepoint.timepointType];
if (!timepointConstraints) {
return;
}
// Run the group-level conformance checks
assessGroupOfMeasurements(timepointConstraints);
// Run the per-organ conformance checks
assessMeasurementPerOrgan(timepointConstraints);
});
}
function validateAll() {
// Obtain the name of the current TrialResponseAssessmentCriteria that
// we are using.
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
Measurements.find().forEach(function(measurement) {
Object.keys(measurement.timepoints).forEach(function(timepointId) {
var currentMeasurement = measurement.timepoints[timepointId];
currentMeasurement.isTarget = measurement.isTarget;
currentMeasurement.isNodal = measurement.isNodal;
currentMeasurement.lesionNumber = measurement.lesionNumber;
currentMeasurement._id = measurement._id;
// TODO: Criteria for the specific image are retrieved from the general set of criteria.
// - The acquisitionSliceThickness, for example, may be pulled from the image metadata
// - The organ in question, e.g. Chest X-ray, may determine the exact specifications for the current trial criteria
var currentConstraints = getTrialCriteriaConstraints(criteriaType, currentMeasurement.imageId);
// Run the single-measurement-specific conformance checks
// If any messages exist, add them to the array of messages
assessSingleMeasurement(currentConstraints, currentMeasurement);
});
});
validateGroups();
}
var validationTimeout = 400;
/**
* Validate the measurements after a set delay period
*
* @param measurementData Input measurement data from CornerstoneTools
*/
function validateDelayed(measurementData) {
// Erase any currently-waiting validation call
clearTimeout(validationTimeout);
// Set a timeout to run validation after a delay
// Currently this is 400 milliseconds
setTimeout(function() {
validateSingleMeasurement(measurementData);
}, validationTimeout);
}
/**
* Validate all measurements after a set delay period
*/
function validateAllDelayed() {
// Erase any currently-waiting validation call
clearTimeout(validationTimeout);
// Set a timeout to run validation after a delay
// Currently this is 400 milliseconds
setTimeout(function() {
validateAll();
}, validationTimeout);
}
TrialResponseCriteria = {
validateAll: validateAll,
validateAllDelayed: validateAllDelayed,
validateSingleMeasurement: validateSingleMeasurement,
validateDelayed: validateDelayed,
validateGroups: validateGroups
};
@@ -12,7 +12,7 @@ activateMeasurements = function(element, measurementId, templateData, viewportIn
var timepointData = getTimepointObject(imageId);
var measurementData = Measurements.findOne(measurementId);
var measurementAtTimepoint = measurementData.timepoints[timepointData.timepointID];
var measurementAtTimepoint = measurementData.timepoints[timepointData.timepointId];
if (!measurementAtTimepoint) {
return;
}
@@ -35,11 +35,11 @@ activateMeasurements = function(element, measurementId, templateData, viewportIn
}
if (imageIdIndex === elementCurrentImageIdIndex) {
activateTool(element, measurementData, timepointData.timepointID);
activateTool(element, measurementData, timepointData.timepointId);
} else {
cornerstone.loadAndCacheImage(imageIds[imageIdIndex]).then(function(image) {
cornerstone.displayImage(element, image);
activateTool(element, measurementData, timepointData.timepointID);
activateTool(element, measurementData, timepointData.timepointId);
});
}
};
@@ -50,9 +50,9 @@ activateMeasurements = function(element, measurementId, templateData, viewportIn
*
* @param element
* @param measurementData
* @param timepointID
* @param timepointId
*/
function activateTool(element, measurementData, timepointID) {
function activateTool(element, measurementData, timepointId) {
deactivateAllToolData(element, 'lesion');
deactivateAllToolData(element, 'nonTarget');
@@ -62,7 +62,7 @@ function activateTool(element, measurementData, timepointID) {
return;
}
var measurementAtTimepoint = measurementData.timepoints[timepointID];
var measurementAtTimepoint = measurementData.timepoints[timepointId];
for (var i = 0; i < toolData.data.length; i++) {
data = toolData.data[i];
@@ -0,0 +1,59 @@
/**
* Calculates total lesion burden given the Trial Criteria Type.
* Supports RECIST 1.1 and irRC at present, defaults to RECIST.
*
* @param criteriaType Either 'RECIST' or 'irRC'
* @returns {*}
*/
calculateTotalLesionBurden = function(criteriaType) {
var totalBurden;
var measurements = Measurements.find({
isTarget: true
});
switch (criteriaType) {
default:
case 'RECIST':
// - Time Point Measurement Total =
// Sum of long axis measurements for extranodal target lesion +
// short axis measurements for nodal lesions
var sumLongAxisExtranodal = 0,
sumShortAxisNodal = 0;
measurements.forEach(function(measurement) {
var LD = parseFloat(measurement.longestDiameter);
var SD = parseFloat(measurement.longestDiameter);
if (measurement.nodal === true) {
sumShortAxisNodal += SD;
} else {
sumLongAxisExtranodal += LD;
}
});
totalBurden = sumLongAxisExtranodal + sumShortAxisNodal;
break;
case 'irRC':
// - Time Point Measurement Total = SPD target lesions + SPD new lesions
// (SPD = sum of product of long axis and short axis diameters)
var sumProductLesions = 0,
sumProductNewLesions = 0;
measurements.forEach(function(measurement) {
var LD = parseFloat(measurement.longestDiameter);
var SD = parseFloat(measurement.shortestDiameter);
var product = LD * SD;
if (measurement.newLesion === true) {
sumProductNewLesions += product;
} else {
sumProductLesions += product;
}
});
totalBurden = sumProductLesions + sumProductNewLesions;
break;
}
return totalBurden;
};
@@ -19,12 +19,11 @@ clearMeasurementTimepointData = function(measurementId, timepointId) {
delete data.timepoints[timepointId];
if (Object.keys(data.timepoints).length === 0) {
if (!Object.keys(data.timepoints).length) {
Meteor.call('removeMeasurement', measurementId, function(error, response) {
if (error) {
log.warn(error);
}
console.log('Removed!');
});
} else {
// Update the Timepoint object of the Measurement document
+3
View File
@@ -35,4 +35,7 @@ clearTools = function() {
// Remove patient's measurements
Meteor.call('removeMeasurementsByPatientId', patientId);
// Clear all validation errors
ValidationErrors.remove({});
};
@@ -0,0 +1,44 @@
/**
* Calculates a Timepoint's name based on how many timepoints exist between it
* and the latest Baseline. Names returned are in the form of 'Baseline', or
* 'Follow-up 1', 'Follow-up 2', and so on.
*
* @param timepoint
* @returns {*} The timepoint name
*/
getTimepointName = function(timepoint) {
// Check if this is a Baseline timepoint, if it is, return 'Baseline'
if (timepoint.timepointType === 'baseline') {
return 'Baseline';
}
// Retrieve all of the relevant follow-up timepoints for this patient
var followupTimepoints = Timepoints.find({
patientId: timepoint.patientId,
timepointType: timepoint.timepointType
}, {
sort: {
latestDate: 1
}
});
// Create an array of just timepointIds, so we can use indexOf
// on it to find the current timepoint's relative position
var followupTimepointIds = followupTimepoints.map(function(timepoint) {
return timepoint.timepointId;
});
// Calculate the index of the current timepoint in the array of all
// relevant follow-up timepoints
var index = followupTimepointIds.indexOf(timepoint.timepointId) + 1;
// If index is 0, it means that the current timepoint was not in the list
// Log a warning and return here
if (!index) {
log.warn('Current follow-up was not in the list of relevant follow-ups?');
return;
}
// Return the timepoint name as 'Follow-up N'
return 'Follow-up ' + index;
};
@@ -11,6 +11,8 @@ getTimepointObject = function(imageId) {
}
return Timepoints.findOne({
timepointName: study.studyDate
studyInstanceUids: {
$in: [study.studyInstanceUid]
}
});
};
@@ -0,0 +1,12 @@
handleMeasurementAdded = function(e, eventData) {
log.info('CornerstoneToolsMeasurementAdded');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
LesionManager.updateLesionData(measurementData);
TrialResponseCriteria.validateDelayed(measurementData);
break;
}
};
@@ -0,0 +1,12 @@
handleMeasurementModified = function(e, eventData) {
log.info('CornerstoneToolsMeasurementModified');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
LesionManager.updateLesionData(measurementData);
TrialResponseCriteria.validateDelayed(measurementData);
break;
}
};
@@ -0,0 +1,19 @@
handleMeasurementRemoved = function(e, eventData) {
log.info('CornerstoneToolsMeasurementRemoved');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
var measurement = Measurements.findOne(measurementData.id, {
reactive: false
});
if (!measurement) {
return;
}
clearMeasurementTimepointData(measurement._id, measurementData.timepointId);
break;
}
};
@@ -0,0 +1,30 @@
pixelSpacingAutorunCheck = function() {
log.info('lesionTool button change autorun');
/*if (!Session.get('ViewerData')) {
return;
}*/
// Get oncology tools
var oncologyTools = $('button#lesion, button#nonTarget');
// TODO: Set activeViewport for empty viewport element
var activeViewportIndex = Session.get('activeViewport');
if (activeViewportIndex === undefined) {
return;
}
var element = $('.imageViewerViewport').get(activeViewportIndex);
var enabledElement = cornerstone.getEnabledElement(element);
// Check value of rowPixelSpacing & columnPixelSpacing to define as unavailable
if (!enabledElement ||
!enabledElement.image ||
!enabledElement.image.rowPixelSpacing ||
!enabledElement.image.columnPixelSpacing) {
// Disable Lesion Buttons
oncologyTools.prop('disabled', true);
} else {
// Enable Lesion Buttons
oncologyTools.prop('disabled', false);
}
};
@@ -0,0 +1,75 @@
syncMeasurementAndToolData = function(data) {
// Check what toolType we should be adding this to, based on the isTarget value
// of the stored Measurement
var toolType = data.isTarget ? 'lesion' : 'nonTarget';
var toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.toolState;
// Loop through the timepoint data for this measurement
Object.keys(data.timepoints).forEach(function(key) {
var storedData = data.timepoints[key];
var imageId = storedData.imageId;
if (!toolState[imageId]) {
toolState[imageId] = {};
}
// This is probably not the best approach to prevent duplicates
if (toolState[imageId][toolType] && toolState[imageId][toolType].data) {
var measurementHasNoIdYet = false;
toolState[imageId][toolType].data.forEach(function(measurement) {
if (measurement.id === 'notready') {
measurementHasNoIdYet = true;
return false;
}
});
// Stop here if it appears that we are creating this measurement right now,
// and would not like this function to add another copy of it to the toolData
if (measurementHasNoIdYet === true) {
return;
}
}
if (!toolState[imageId][toolType]) {
toolState[imageId][toolType] = {
data: []
};
} else {
var alreadyExists = false;
if (toolState[imageId][toolType].data.length) {
toolState[imageId][toolType].data.forEach(function(measurement) {
if (measurement.id === data._id) {
alreadyExists = true;
// Update the toolData lesionNumber from the Measurement
measurement.lesionNumber = data.lesionNumber;
return false;
}
});
}
if (alreadyExists === true) {
return;
}
}
// Create measurementData structure based on the lesion data at this timepoint
// We will add this into the toolData for this imageId
var measurementData = storedData;
measurementData.isTarget = data.isTarget;
measurementData.lesionNumber = data.lesionNumber;
measurementData.measurementText = data.measurementText;
measurementData.isDeleted = data.isDeleted;
measurementData.location = data.location;
measurementData.locationUID = data.locationUID;
measurementData.patientId = patientId;
measurementData.visible = data.visible;
measurementData.active = data.active;
measurementData.uid = data.uid;
measurementData.id = data._id;
toolState[imageId][toolType].data.push(measurementData);
TrialResponseCriteria.validateSingleMeasurement(measurementData);
});
};
@@ -0,0 +1,13 @@
updateRelatedElements = function(imageId) {
// Get all on-screen elements with this imageId
var enabledElements = cornerstone.getEnabledElementsByImageId(imageId);
// TODO=Check original event to prevent duplicate updateImage calls
// Loop through these elements
enabledElements.forEach(function(enabledElement) {
// Update the display so the tool is removed
var element = enabledElement.element;
cornerstone.updateImage(element);
});
};
-250
View File
@@ -1,250 +0,0 @@
// uuid.js
//
// Copyright (c) 2010-2012 Robert Kieffer
// MIT License - http://opensource.org/licenses/mit-license.php
(function() {
var _global = this;
// Unique ID creation requires a high quality random # generator. We feature
// detect to determine the best RNG source, normalizing to a function that
// returns 128-bits of randomness, since that's what's usually required
var _rng;
// Allow for MSIE11 msCrypto
var _crypto = _global.crypto || _global.msCrypto;
// Node.js crypto-based RNG - http://nodejs.org/docs/v0.6.2/api/crypto.html
//
// Moderately fast, high quality
if (typeof(_global.require) == 'function') {
try {
var _rb = _global.require('crypto').randomBytes;
_rng = _rb && function() {return _rb(16);};
} catch(e) {}
}
if (!_rng && _crypto && _crypto.getRandomValues) {
// WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
//
// Moderately fast, high quality
var _rnds8 = new Uint8Array(16);
_rng = function whatwgRNG() {
_crypto.getRandomValues(_rnds8);
return _rnds8;
};
}
if (!_rng) {
// Math.random()-based (RNG)
//
// If all else fails, use Math.random(). It's fast, but is of unspecified
// quality.
var _rnds = new Array(16);
_rng = function() {
for (var i = 0, r; i < 16; i++) {
if ((i & 0x03) === 0) r = Math.random() * 0x100000000;
_rnds[i] = r >>> ((i & 0x03) << 3) & 0xff;
}
return _rnds;
};
}
// Buffer class to use
var BufferClass = typeof(_global.Buffer) == 'function' ? _global.Buffer : Array;
// Maps for number <-> hex string conversion
var _byteToHex = [];
var _hexToByte = {};
for (var i = 0; i < 256; i++) {
_byteToHex[i] = (i + 0x100).toString(16).substr(1);
_hexToByte[_byteToHex[i]] = i;
}
// **`parse()` - Parse a UUID into it's component bytes**
function parse(s, buf, offset) {
var i = (buf && offset) || 0, ii = 0;
buf = buf || [];
s.toLowerCase().replace(/[0-9a-f]{2}/g, function(oct) {
if (ii < 16) { // Don't overflow!
buf[i + ii++] = _hexToByte[oct];
}
});
// Zero out remaining bytes if string was short
while (ii < 16) {
buf[i + ii++] = 0;
}
return buf;
}
// **`unparse()` - Convert UUID byte array (ala parse()) into a string**
function unparse(buf, offset) {
var i = offset || 0, bth = _byteToHex;
return bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]];
}
// **`v1()` - Generate time-based UUID**
//
// Inspired by https://github.com/LiosK/UUID.js
// and http://docs.python.org/library/uuid.html
// random #'s we need to init node and clockseq
var _seedBytes = _rng();
// Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1)
var _nodeId = [
_seedBytes[0] | 0x01,
_seedBytes[1], _seedBytes[2], _seedBytes[3], _seedBytes[4], _seedBytes[5]
];
// Per 4.2.2, randomize (14 bit) clockseq
var _clockseq = (_seedBytes[6] << 8 | _seedBytes[7]) & 0x3fff;
// Previous uuid creation time
var _lastMSecs = 0, _lastNSecs = 0;
// See https://github.com/broofa/node-uuid for API details
function v1(options, buf, offset) {
var i = buf && offset || 0;
var b = buf || [];
options = options || {};
var clockseq = options.clockseq != null ? options.clockseq : _clockseq;
// UUID timestamps are 100 nano-second units since the Gregorian epoch,
// (1582-10-15 00:00). JSNumbers aren't precise enough for this, so
// time is handled internally as 'msecs' (integer milliseconds) and 'nsecs'
// (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00.
var msecs = options.msecs != null ? options.msecs : new Date().getTime();
// Per 4.2.1.2, use count of uuid's generated during the current clock
// cycle to simulate higher resolution clock
var nsecs = options.nsecs != null ? options.nsecs : _lastNSecs + 1;
// Time since last uuid creation (in msecs)
var dt = (msecs - _lastMSecs) + (nsecs - _lastNSecs) / 10000;
// Per 4.2.1.2, Bump clockseq on clock regression
if (dt < 0 && options.clockseq == null) {
clockseq = clockseq + 1 & 0x3fff;
}
// Reset nsecs if clock regresses (new clockseq) or we've moved onto a new
// time interval
if ((dt < 0 || msecs > _lastMSecs) && options.nsecs == null) {
nsecs = 0;
}
// Per 4.2.1.2 Throw error if too many uuids are requested
if (nsecs >= 10000) {
throw new Error('uuid.v1(): Can\'t create more than 10M uuids/sec');
}
_lastMSecs = msecs;
_lastNSecs = nsecs;
_clockseq = clockseq;
// Per 4.1.4 - Convert from unix epoch to Gregorian epoch
msecs += 12219292800000;
// `time_low`
var tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000;
b[i++] = tl >>> 24 & 0xff;
b[i++] = tl >>> 16 & 0xff;
b[i++] = tl >>> 8 & 0xff;
b[i++] = tl & 0xff;
// `time_mid`
var tmh = (msecs / 0x100000000 * 10000) & 0xfffffff;
b[i++] = tmh >>> 8 & 0xff;
b[i++] = tmh & 0xff;
// `time_high_and_version`
b[i++] = tmh >>> 24 & 0xf | 0x10; // include version
b[i++] = tmh >>> 16 & 0xff;
// `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant)
b[i++] = clockseq >>> 8 | 0x80;
// `clock_seq_low`
b[i++] = clockseq & 0xff;
// `node`
var node = options.node || _nodeId;
for (var n = 0; n < 6; n++) {
b[i + n] = node[n];
}
return buf ? buf : unparse(b);
}
// **`v4()` - Generate random UUID**
// See https://github.com/broofa/node-uuid for API details
function v4(options, buf, offset) {
// Deprecated - 'format' argument, as supported in v1.2
var i = buf && offset || 0;
if (typeof(options) == 'string') {
buf = options == 'binary' ? new BufferClass(16) : null;
options = null;
}
options = options || {};
var rnds = options.random || (options.rng || _rng)();
// Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
rnds[6] = (rnds[6] & 0x0f) | 0x40;
rnds[8] = (rnds[8] & 0x3f) | 0x80;
// Copy bytes to buffer, if provided
if (buf) {
for (var ii = 0; ii < 16; ii++) {
buf[i + ii] = rnds[ii];
}
}
return buf || unparse(rnds);
}
// Export public API
var uuid = v4;
uuid.v1 = v1;
uuid.v4 = v4;
uuid.parse = parse;
uuid.unparse = unparse;
uuid.BufferClass = BufferClass;
if (typeof(module) != 'undefined' && module.exports) {
// Publish as node.js module
module.exports = uuid;
} else if (typeof define === 'function' && define.amd) {
// Publish as AMD module
define(function() {return uuid;});
} else {
// Publish as global (in browsers)
var _previousRoot = _global.uuid;
// **`noConflict()` - (browser only) to reset global 'uuid' var**
uuid.noConflict = function() {
_global.uuid = _previousRoot;
return uuid;
};
_global.uuid = uuid;
}
}).call(this);
@@ -0,0 +1,108 @@
/**
* Opens a new tab in the tabbed worklist environment using
* a given timepoint and new tab title.
*
* @param timepointId The UID of the Timepoint to be opened
* @param title The title to be used for the tab heading
*/
openNewTabWithTimepoint = function(timepointId, title) {
log.info('openNewTabWithTimepoint: ' + timepointId + ' ' + title);
// Generate a unique ID to represent this tab
// We can't just use the Mongo entry ID because
// then it will change after hot-reloading.
var contentid = uuid.new();
var timepoint = Timepoints.findOne({
timepointId: timepointId
});
if (!timepoint) {
throw 'No such timepoint exists';
}
// Get the relevant studyInstanceUids given the timepoints
var data = getDataFromTimepoint(timepoint);
if (!data.studyInstanceUids) {
throw 'No studies found that are related to this timepoint';
}
// Create a new entry in the WorklistTabs Collection
WorklistTabs.insert({
title: title,
contentid: contentid,
active: false,
timepointId: timepointId
});
// Update the ViewerData global object
ViewerData[contentid] = {
title: title,
contentid: contentid,
studyInstanceUids: data.studyInstanceUids,
timepointIds: data.timepointIds
};
// Switch to the new tab
switchToTab(contentid);
};
/**
* Retrieves related studies given a Baseline or Follow-up Timepoint
*
* @param timepoint
* @returns {Array}
*/
function getDataFromTimepoint(timepoint) {
var relatedStudies = [];
// Include the specified studyInstanceUids
// NOTE: Temporarily added [0], since we only need one study per timepoint to load immediately?
relatedStudies = relatedStudies.concat(timepoint.studyInstanceUids[0]);
// If this is the baseline, we should stop here and return the relevant studies
if (isBaseline(timepoint)) {
return {
studyInstanceUids: relatedStudies,
timepointIds: [timepoint.timepointId]
};
}
// Otherwise, this is a follow-up exam, so we should also find the baseline timepoint,
// and all studies related to it. We also enforce that the Baseline should have a studyDate
// prior to the latest studyDate in the current (Follow-up) Timepoint.
var baseline = Timepoints.findOne({
timepointType: 'baseline',
patientId: timepoint.patientId,
latestDate: {
$lte: timepoint.latestDate
}
});
var timepointIds = [];
if (baseline) {
// NOTE: Temporarily added [0], since we only need one study per timepoint to load immediately?
relatedStudies = relatedStudies.concat(baseline.studyInstanceUids[0]);
timepointIds.push(baseline.timepointId);
} else {
log.warn('No Baseline found while opening a Follow-up Timepoint');
}
timepointIds.push(timepoint.timepointId);
return {
studyInstanceUids: relatedStudies,
timepointIds: timepointIds
};
}
/**
* Checks if a Timepoints is a baseline or not
* (abstracting this for later use, since I expect it to get more complex)
*
* @param timepoint a document from the Timepoints Collection
* @returns {boolean} Whether or not the timepoint is stored as a Baseline
*/
function isBaseline(timepoint) {
return (timepoint.timepointType === 'baseline');
}
@@ -0,0 +1,37 @@
Meteor.startup(function() {
Worklist.subscriptions = ['studies', 'timepoints'];
Worklist.callbacks['dblClickOnStudy'] = dblClickOnStudy;
Worklist.callbacks['middleClickOnStudy'] = dblClickOnStudy;
});
/**
* Lesion Tracker method including Timepoints / other studies
*/
function dblClickOnStudy(data) {
// Use the formatPN template helper to clean up the patient name
var title = formatPN(data.patientName);
var study = Studies.findOne({
studyInstanceUid: data.studyInstanceUid
});
// Check if the study has been associated, and if not, just open it on its own
if (!study) {
// Open a new tab with this study
openNewTab(data.studyInstanceUid, title);
return;
}
// Find the relevant timepoint given the clicked-on study
var timepoint = Timepoints.findOne({
studyInstanceUids: {
$in: [data.studyInstanceUid]
}
});
if (!timepoint) {
openNewTab(data.studyInstanceUid, title);
return;
}
openNewTabWithTimepoint(timepoint.timepointId, title);
}