feat(measurement-table) Display support for measurements from DICOM Structured Reports (#225)

This commit is contained in:
Gustavo André Lelis authored and Erik Ziegler committed 2018-07-20 12:40:09 +02:00
1 parent e9832f62f7
commit d94f0eec16
64 files changed
+1797 -78

No files matched your search

@@ -0,0 +1,27 @@
import { OHIF } from 'meteor/ohif:core';
import { measurementTools } from './measurementTools';
import { retrieveMeasurements, storeMeasurements, retrieveTimepoints, storeTimepoints, removeTimepoint, updateTimepoint, disassociateStudy } from './dataExchange';
export const configureApis = () => {
OHIF.measurements.MeasurementApi.setConfiguration({
measurementTools,
dataExchange: {
retrieve: retrieveMeasurements,
store: storeMeasurements
},
dataValidation: {
validation: () => {}
}
});
OHIF.measurements.TimepointApi.setConfiguration({
dataExchange: {
retrieve: retrieveTimepoints,
store: storeTimepoints,
remove: removeTimepoint,
update: updateTimepoint,
disassociate: disassociateStudy
}
});
};
@@ -0,0 +1,59 @@
import { OHIF } from 'meteor/ohif:core';
import { handleSR, getLatestSRSeries } from "../utils/handleSR";
export const retrieveMeasurements = (patientId, timepointIds) => {
OHIF.log.info('retrieveMeasurements');
const latestSeries = getLatestSRSeries();
if(!latestSeries) return Promise.resolve({});
return handleSR(latestSeries).then((value) => {
return {
length: value
}
});
};
export const storeMeasurements = (measurementData, timepointIds) => {
OHIF.log.info('storeMeasurements');
// Here is where we should do any required data transformation and API calls
// TODO: Write SR, STOW back to PACS
return Promise.resolve();
};
export const retrieveTimepoints = filter => {
const studyInstanceUids = OHIF.viewer.StudyMetadataList.all().map(study => study.getStudyInstanceUID());
OHIF.log.info('retrieveTimepoints');
return Promise.resolve([{
timepointType: 'baseline',
timepointId: 'TimepointId',
studyInstanceUids,
patientId: filter.patientId,
earliestDate: new Date(),
latestDate: new Date(),
isLocked: false
}]);
};
export const storeTimepoints = (timepointData) => {
OHIF.log.info('storeTimepoints');
return Promise.resolve();
};
export const updateTimepoint = (timepointData, query) => {
OHIF.log.info('updateTimepoint');
return Promise.resolve();
};
export const removeTimepoint = timepointId => {
OHIF.log.info('removeTimepoint');
return Promise.resolve();
};
export const disassociateStudy = (timepointIds, studyInstanceUid) => {
return Promise.resolve();
};
@@ -0,0 +1,17 @@
import { ToolGroupBaseSchema } from '../schema/toolGroupSchema';
import { length } from '../schema/length';
import { ellipticalRoi } from '../schema/ellipticalRoi';
import { rectangleRoi } from '../schema/rectangleRoi';
const trackedTools = [
length,
ellipticalRoi,
rectangleRoi
];
export const measurementTools = [{
id: 'allTools',
name: 'Measurements',
childTools: trackedTools,
schema: ToolGroupBaseSchema
}];
@@ -0,0 +1,125 @@
import { OHIF } from 'meteor/ohif:core';
import { Session } from 'meteor/session';
import { configureApis } from './configuration/configuration'
import { $ } from 'meteor/jquery';
class MeasurementTable {
constructor() {
configureApis();
Session.set('TimepointsReady', false);
Session.set('MeasurementsReady', false);
this.firstMeasurementActivated = false;
}
async onCreated(instance) {
const { TimepointApi, MeasurementApi } = OHIF.measurements;
OHIF.viewer.data.currentTimepointId = 'TimepointId';
const timepointApi = new TimepointApi(OHIF.viewer.data.currentTimepointId);
const measurementApi = new MeasurementApi(timepointApi);
const apis = {
timepointApi,
measurementApi
};
Object.assign(OHIF.viewer, apis);
Object.assign(instance.data, apis);
const patientId = instance.data.studies[0].patientId;
await timepointApi.retrieveTimepoints({ patientId });
Session.set('TimepointsReady', true);
await measurementApi.retrieveMeasurements(patientId, [OHIF.viewer.data.currentTimepointId]);
Session.set('MeasurementsReady', true);
measurementApi.syncMeasurementsAndToolData();
instance.autorun(() => {
if (!Session.get('TimepointsReady') ||
!Session.get('MeasurementsReady') ||
!Session.get('ViewerReady') ||
this.firstMeasurementActivated) {
return;
}
this.jumpToFirstMeasurement();
});
instance.measurementModifiedHandler = _.throttle((event, instance) => {
OHIF.measurements.MeasurementHandlers.onModified(event, instance);
}, 300);
}
onDestroyed() {
Session.set('TimepointsReady', false);
Session.set('MeasurementsReady', false);
}
jumpToFirstMeasurement() {
// Find and activate the first measurement by Lesion Number
// NOTE: This is inefficient, we should be using a hanging protocol
// to hang the first measurement's imageId immediately, rather
// than changing images after initial loading...
const config = OHIF.measurements.MeasurementApi.getConfiguration();
const tools = config.measurementTools[0].childTools;
const firstTool = tools[Object.keys(tools)[0]];
const measurementTypeId = firstTool.id;
const collection = OHIF.viewer.measurementApi.tools[measurementTypeId];
const sorting = {
sort: {
measurementNumber: -1
}
};
const data = collection.find({}, sorting).fetch();
// TODO: Clean this up, it's probably an inefficient way to get what we need
const groupObject = _.groupBy(data, m => m.measurementNumber);
// Reformat the data
const rows = Object.keys(groupObject).map(key => ({
measurementTypeId: measurementTypeId,
measurementNumber: key,
entries: groupObject[key]
}));
const rowItem = rows[0];
const timepoints = [
OHIF.viewer.timepointApi.current()
];
if (rowItem) {
OHIF.measurements.jumpToRowItem(rowItem, timepoints);
}
this.firstMeasurementActivated = true;
}
};
const measurementEvents = {
'cornerstonetoolsmeasurementadded .imageViewerViewport'(event, instance) {
const originalEvent = event.originalEvent;
OHIF.measurements.MeasurementHandlers.onAdded(originalEvent, instance);
},
'cornerstonetoolsmeasurementmodified .imageViewerViewport'(event, instance) {
const originalEvent = event.originalEvent;
instance.measurementModifiedHandler(originalEvent, instance);
},
'cornerstonemeasurementremoved .imageViewerViewport'(event, instance) {
const originalEvent = event.originalEvent;
OHIF.measurements.MeasurementHandlers.onRemoved(originalEvent, instance);
}
};
export {
MeasurementTable,
measurementEvents
}
@@ -0,0 +1,104 @@
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
import { MeasurementSchemaTypes } from 'meteor/ohif:measurements/both/schema/measurements';
const CornerstoneHandleSchema = MeasurementSchemaTypes.CornerstoneHandleSchema;
const EllipticalRoiHandlesSchema = new SimpleSchema({
start: {
type: CornerstoneHandleSchema,
label: 'Start'
},
end: {
type: CornerstoneHandleSchema,
label: 'End'
},
textBox: {
type: CornerstoneHandleSchema,
label: 'Text Box'
},
});
const MeanStdDevSchema = new SimpleSchema({
count: {
type: Number,
label: 'count',
decimal: true
},
mean: {
type: Number,
label: 'mean',
decimal: true
},
stdDev: {
type: Number,
label: 'stdDev',
decimal: true
},
variance: {
type: Number,
label: 'variance',
decimal: true
}
});
const EllipticalRoiSchema = new SimpleSchema([MeasurementSchemaTypes.CornerstoneToolMeasurement, {
handles: {
type: EllipticalRoiHandlesSchema,
label: 'Handles'
},
measurementNumber: {
type: Number,
label: 'Measurement Number'
},
toolType: {
type: String,
label: 'Measurement Tool Type',
defaultValue: 'ellipticalRoi'
},
location: {
type: String,
label: 'Location',
optional: true
},
description: {
type: String,
label: 'Description',
optional: true
},
area: {
type: Number,
label: 'Ellipse Area value',
decimal: true,
optional: true
},
meanStdDev: {
type: MeanStdDevSchema,
label: 'MeanStd Values',
optional: true
}
}]);
const displayFunction = data => {
let meanValue = '';
if (data.meanStdDev && data.meanStdDev.mean) {
meanValue = data.meanStdDev.mean.toFixed(2) + ' HU';
}
return meanValue;
// let meanValue = data.meanStdDev && data.meanStdDev.mean || 0;
// return numberWithCommas(meanValue).toFixed(2) + ' HU';
//return data.meanStdDev.mean.toFixed(2);
};
export const ellipticalRoi = {
id: 'ellipticalRoi',
name: 'Ellipse',
toolGroup: 'allTools',
cornerstoneToolType: 'ellipticalRoi',
schema: EllipticalRoiSchema,
options: {
measurementTable: {
displayFunction
}
}
};
@@ -0,0 +1,72 @@
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
import { MeasurementSchemaTypes } from 'meteor/ohif:measurements/both/schema/measurements';
const CornerstoneHandleSchema = MeasurementSchemaTypes.CornerstoneHandleSchema;
const LengthHandlesSchema = new SimpleSchema({
start: {
type: CornerstoneHandleSchema,
label: 'Start'
},
end: {
type: CornerstoneHandleSchema,
label: 'End'
},
textBox: {
type: CornerstoneHandleSchema,
label: 'Text Box'
}
});
const LengthSchema = new SimpleSchema([MeasurementSchemaTypes.CornerstoneToolMeasurement, {
handles: {
type: LengthHandlesSchema,
label: 'Handles'
},
measurementNumber: {
type: Number,
label: 'Measurement Number'
},
location: {
type: String,
label: 'Location',
optional: true
},
description: {
type: String,
label: 'Description',
optional: true
},
toolType: {
type: String,
label: 'Measurement Tool Type',
defaultValue: 'length'
},
length: {
type: Number,
label: 'Length',
optional: true,
decimal: true
}
}]);
const displayFunction = data => {
let lengthValue = '';
if (data.length) {
lengthValue = data.length.toFixed(2) + ' mm';
}
return lengthValue;
};
export const length = {
id: 'length',
name: 'Length',
toolGroup: 'allTools',
cornerstoneToolType: 'length',
schema: LengthSchema,
options: {
measurementTable: {
displayFunction
}
}
};
@@ -0,0 +1,101 @@
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
import { MeasurementSchemaTypes } from 'meteor/ohif:measurements/both/schema/measurements';
const CornerstoneHandleSchema = MeasurementSchemaTypes.CornerstoneHandleSchema;
const RectangleRoiHandlesSchema = new SimpleSchema({
start: {
type: CornerstoneHandleSchema,
label: 'Start'
},
end: {
type: CornerstoneHandleSchema,
label: 'End'
},
textBox: {
type: CornerstoneHandleSchema,
label: 'Text Box'
},
});
const MeanStdDevSchema = new SimpleSchema({
count: {
type: Number,
label: 'count',
decimal: true
},
mean: {
type: Number,
label: 'mean',
decimal: true
},
stdDev: {
type: Number,
label: 'stdDev',
decimal: true
},
variance: {
type: Number,
label: 'variance',
decimal: true
}
});
const RectangleRoiSchema = new SimpleSchema([MeasurementSchemaTypes.CornerstoneToolMeasurement, {
handles: {
type: RectangleRoiHandlesSchema,
label: 'Handles'
},
measurementNumber: {
type: Number,
label: 'Measurement Number'
},
toolType: {
type: String,
label: 'Measurement Tool Type',
defaultValue: 'rectangleRoi'
},
location: {
type: String,
label: 'Location',
optional: true
},
description: {
type: String,
label: 'Description',
optional: true
},
area: {
type: Number,
label: 'Rectangle Area value',
decimal: true,
optional: true
},
meanStdDev: {
type: MeanStdDevSchema,
label: 'MeanStd Values',
optional: true
}
}]);
const displayFunction = data => {
let meanValue = '';
if (data.meanStdDev && data.meanStdDev.mean) {
meanValue = data.meanStdDev.mean.toFixed(2) + ' HU';
}
return meanValue;
};
export const rectangleRoi = {
id: 'rectangleRoi',
name: 'Rectangle',
toolGroup: 'allTools',
cornerstoneToolType: 'rectangleRoi',
schema: RectangleRoiSchema,
options: {
measurementTable: {
displayFunction
}
}
};
@@ -0,0 +1,29 @@
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
export const ToolGroupBaseSchema = new SimpleSchema({
toolId: {
type: String,
label: 'Tool ID',
optional: true
},
toolItemId: {
type: String,
label: 'Tool Item ID',
optional: true
},
createdAt: {
type: Date
},
studyInstanceUid: {
type: String,
label: 'Study Instance UID'
},
timepointId: {
type: String,
label: 'Timepoint ID'
},
measurementNumber: {
type: Number,
label: 'Measurement Number'
}
});
@@ -0,0 +1,95 @@
import { OHIF } from 'meteor/ohif:core';
import { cornerstone } from 'meteor/ohif:cornerstone';
function getInstanceMetadata (displaySets, sopInstanceUid) {
let instance;
// Use Array.some so that this loop stops when the internal loop
// has found the correct instance
displaySets.some(displaySet => {
// Search the display set to find the instance metadata for
return displaySet.images.find(instanceMetadata => {
if (instanceMetadata._sopInstanceUID === sopInstanceUid) {
instance = instanceMetadata;
return true;
}
});
});
return instance;
};
export default function getLengthMeasurementData(lengthMeasurementContent, displaySets) {
let lengthStates = [];
lengthMeasurementContent.forEach(groupItemContent => {
const lengthContent = groupItemContent.ContentSequence;
const reference = lengthContent.ContentSequence.ReferencedSOPSequence;
const lengthState = {};
lengthState.measuredValue = groupItemContent.MeasuredValueSequence.NumericValue;
lengthState.handles = {start: {}, end: {}};
[lengthState.handles.start.x,
lengthState.handles.start.y,
lengthState.handles.end.x,
lengthState.handles.end.y] = lengthContent.GraphicData;
// TODO: Save textbox position in GraphicData?
lengthState.handles.textBox = {
hasMoved: false,
movesIndependently: false,
drawnIndependently: true,
allowedOutsideImage: true,
hasBoundingBox: true
}
lengthState.ReferencedInstanceUID = reference.ReferencedSOPInstanceUID;
if (reference.ReferencedFrameNumber && reference.ReferencedFrameNumber !== 'NaN') {
lengthState.ReferencedFrameNumber = reference.ReferencedFrameNumber;
} else {
lengthState.ReferencedFrameNumber = 0;
}
lengthStates.push(lengthState);
});
const lengthMeasurementData = [];
let measurementNumber = 0;
lengthStates.forEach(lengthState => {
const sopInstanceUid = lengthState.ReferencedInstanceUID;
const instanceMetadata = getInstanceMetadata(displaySets, sopInstanceUid);
const imageId = OHIF.viewerbase.getImageId(instanceMetadata);
if (!imageId) {
return;
}
const studyInstanceUid = cornerstone.metaData.get('study', imageId).studyInstanceUid;
const seriesInstanceUid = cornerstone.metaData.get('series', imageId).seriesInstanceUid;
const patientId = instanceMetadata._study.patientId;
const frameIndex = lengthState.ReferencedFrameNumber;
const imagePath = [studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex].join('_');
const measurement = {
handles: lengthState.handles,
length: lengthState.measuredValue,
imageId,
imagePath,
sopInstanceUid,
seriesInstanceUid,
studyInstanceUid,
patientId,
frameIndex,
measurementNumber: ++measurementNumber,
userId: 'UserID',
timepointId: OHIF.viewer.data.currentTimepointId,
toolType: 'length',
_id: imageId + measurementNumber,
};
lengthMeasurementData.push(measurement);
});
return lengthMeasurementData;
};
@@ -0,0 +1,100 @@
import { OHIF } from 'meteor/ohif:core';
import { dcmjs } from 'meteor/ohif:cornerstone';
import getLengthMeasurementData from './getLengthMeasurementData';
const supportedSopClassUIDs = ['1.2.840.10008.5.1.4.1.1.88.22'];
const toArray = function(x) {
return (x.constructor.name === "Array" ? x : [x]);
};
const codeMeaningEquals = (codeMeaningName) => {
return (contentItem) => {
return contentItem.ConceptNameCodeSequence.CodeMeaning === codeMeaningName;
};
};
const imagingMeasurementsToMeasurementData = (dataset, displaySets) => {
// Identify the Imaging Measurements
const imagingMeasurementContent = toArray(dataset.ContentSequence).find(codeMeaningEquals("Imaging Measurements"));
// Retrieve the Measurements themselves
const measurementGroupContent = toArray(imagingMeasurementContent.ContentSequence).find(codeMeaningEquals("Measurement Group"));
// For now, bail out if the dataset is not a TID1500 SR with length measurements
// TODO: generalize to the various kinds of report
// TODO: generalize to the kinds of measurements the Viewer supports
if (dataset.ContentTemplateSequence.TemplateIdentifier !== "1500") {
OHIF.log.warn("This package can currently only interpret DICOM SR TID 1500");
return {};
}
// Filter to find Length measurements in the Structured Report
const lengthMeasurementContent = toArray(measurementGroupContent.ContentSequence).filter(codeMeaningEquals("Length"));
// Retrieve Length Measurement Data
return getLengthMeasurementData(lengthMeasurementContent, displaySets);
};
const retrieveDataFromSR = (Part10SRArrayBuffer) => {
const allStudies = OHIF.viewer.Studies.all();
let allDisplaySets = [];
allStudies.forEach(study => {
allDisplaySets = allDisplaySets.concat(study.displaySets);
});
// get the dicom data as an Object
let dicomData = dcmjs.data.DicomMessage.readFile(Part10SRArrayBuffer);
let dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomData.dict);
// Convert the SR into the kind of object the Measurements package is expecting
return imagingMeasurementsToMeasurementData(dataset, allDisplaySets);
};
export const getLatestSRSeries = () => {
const allStudies = OHIF.viewer.StudyMetadataList.all();
let latestSeries;
allStudies.forEach(study => {
study.getSeries().forEach(series => {
const firstInstance = series.getFirstInstance();
const sopClassUid = firstInstance._instance.sopClassUid;
if (supportedSopClassUIDs.includes(sopClassUid)) {
if(!latestSeries) {
latestSeries = series;
} else if (series._data.seriesDate > latestSeries._data.seriesDate ||
(series._data.seriesDate === latestSeries._data.seriesDate && series._data.seriesTime > latestSeries._data.seriesTime)) {
latestSeries = series;
}
}
});
});
return latestSeries;
};
export const handleSR = (series) => {
const instance = series.getFirstInstance();
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.responseType = 'arraybuffer';
request.open('GET', instance.getDataProperty('wadouri'));
request.onload = function (progressEvent) {
const data = retrieveDataFromSR(progressEvent.currentTarget.response);
resolve(data);
};
request.onerror = function(error) {
reject(error);
};
request.send();
});
};