feat(STOW-RS) Save DICOM Structured Reports from Measurement Table back to archive (#236)

* Small refactor into Table Class setting events as static object into class

* feature(STOW-SR) : Initial changes to stow when we have Server.

* Improvements into requests and small fix into style

* Small fix for jumpToRow without refheshing page

* Handling STOW-SR once we don't have server

* feature(STOW-SR) Refactor

* Merge request with makeRequest and delete old file

* Expose wadoproxy to client and update measurement-table to start using wadoproxy.getServerUrl

* Move WADOProxy.getServerUrl to Measurement-table package. Fix querystring issue.

* Remove proxy:enabled in favour of requestOptions.requestFromBrowser

* Bcrypt 2.0.0 to fix Travis
This commit is contained in:
Gustavo André Lelis 2018-07-31 10:01:26 -03:00 committed by Erik Ziegler
parent 13e4dc25e1
commit 26b14f1dab
26 changed files with 1079 additions and 179 deletions

View File

@ -4,7 +4,7 @@ import { Template } from 'meteor/templating';
import { ReactiveDict } from 'meteor/reactive-dict';
import { Tracker } from 'meteor/tracker';
import { OHIF } from 'meteor/ohif:core';
import { MeasurementTable, measurementEvents } from 'meteor/ohif:measurement-table';
import { MeasurementTable } from 'meteor/ohif:measurement-table';
import 'meteor/ohif:cornerstone';
import 'meteor/ohif:viewerbase';
@ -168,7 +168,7 @@ Template.viewer.onRendered(function() {
Template.viewer.events( Object.assign({
// Viewer Events
},
measurementEvents
MeasurementTable.measurementEvents
));
Template.viewer.onDestroyed(function() {

View File

@ -34,6 +34,436 @@
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
},
"bcrypt": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-3.0.0.tgz",
"integrity": "sha512-gjicxsD4e5U3nH0EqiEb5y+fKpsZ7F52wcnmNfu45nxnolWVAYh7NgbdfilY+5x1v6cLspxmzz4hf+ju2pFxhA==",
"requires": {
"nan": "2.10.0",
"node-pre-gyp": "0.10.2"
},
"dependencies": {
"abbrev": {
"version": "1.1.1",
"bundled": true
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true
},
"aproba": {
"version": "1.2.0",
"bundled": true
},
"are-we-there-yet": {
"version": "1.1.5",
"bundled": true,
"requires": {
"delegates": "^1.0.0",
"readable-stream": "^2.0.6"
}
},
"balanced-match": {
"version": "1.0.0",
"bundled": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"chownr": {
"version": "1.0.1",
"bundled": true
},
"code-point-at": {
"version": "1.1.0",
"bundled": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true
},
"core-util-is": {
"version": "1.0.2",
"bundled": true
},
"debug": {
"version": "2.6.9",
"bundled": true,
"requires": {
"ms": "2.0.0"
}
},
"deep-extend": {
"version": "0.6.0",
"bundled": true
},
"delegates": {
"version": "1.0.0",
"bundled": true
},
"detect-libc": {
"version": "1.0.3",
"bundled": true
},
"fs-minipass": {
"version": "1.2.5",
"bundled": true,
"requires": {
"minipass": "^2.2.1"
}
},
"fs.realpath": {
"version": "1.0.0",
"bundled": true
},
"gauge": {
"version": "2.7.4",
"bundled": true,
"requires": {
"aproba": "^1.0.3",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.0",
"object-assign": "^4.1.0",
"signal-exit": "^3.0.0",
"string-width": "^1.0.1",
"strip-ansi": "^3.0.1",
"wide-align": "^1.1.0"
}
},
"glob": {
"version": "7.1.2",
"bundled": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"has-unicode": {
"version": "2.0.1",
"bundled": true
},
"iconv-lite": {
"version": "0.4.23",
"bundled": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"ignore-walk": {
"version": "3.0.1",
"bundled": true,
"requires": {
"minimatch": "^3.0.4"
}
},
"inflight": {
"version": "1.0.6",
"bundled": true,
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.3",
"bundled": true
},
"ini": {
"version": "1.3.5",
"bundled": true
},
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"requires": {
"number-is-nan": "^1.0.0"
}
},
"isarray": {
"version": "1.0.0",
"bundled": true
},
"minimatch": {
"version": "3.0.4",
"bundled": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true
},
"minipass": {
"version": "2.3.3",
"bundled": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"bundled": true
},
"yallist": {
"version": "3.0.2",
"bundled": true
}
}
},
"minizlib": {
"version": "1.1.0",
"bundled": true,
"requires": {
"minipass": "^2.2.1"
}
},
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"requires": {
"minimist": "0.0.8"
}
},
"ms": {
"version": "2.0.0",
"bundled": true
},
"needle": {
"version": "2.2.1",
"bundled": true,
"requires": {
"debug": "^2.1.2",
"iconv-lite": "^0.4.4",
"sax": "^1.2.4"
}
},
"node-pre-gyp": {
"version": "0.10.2",
"bundled": true,
"requires": {
"detect-libc": "^1.0.2",
"mkdirp": "^0.5.1",
"needle": "^2.2.0",
"nopt": "^4.0.1",
"npm-packlist": "^1.1.6",
"npmlog": "^4.0.2",
"rc": "^1.2.7",
"rimraf": "^2.6.1",
"semver": "^5.3.0",
"tar": "^4"
}
},
"nopt": {
"version": "4.0.1",
"bundled": true,
"requires": {
"abbrev": "1",
"osenv": "^0.1.4"
}
},
"npm-bundled": {
"version": "1.0.3",
"bundled": true
},
"npm-packlist": {
"version": "1.1.10",
"bundled": true,
"requires": {
"ignore-walk": "^3.0.1",
"npm-bundled": "^1.0.1"
}
},
"npmlog": {
"version": "4.1.2",
"bundled": true,
"requires": {
"are-we-there-yet": "~1.1.2",
"console-control-strings": "~1.1.0",
"gauge": "~2.7.3",
"set-blocking": "~2.0.0"
}
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true
},
"object-assign": {
"version": "4.1.1",
"bundled": true
},
"once": {
"version": "1.4.0",
"bundled": true,
"requires": {
"wrappy": "1"
}
},
"os-homedir": {
"version": "1.0.2",
"bundled": true
},
"os-tmpdir": {
"version": "1.0.2",
"bundled": true
},
"osenv": {
"version": "0.1.5",
"bundled": true,
"requires": {
"os-homedir": "^1.0.0",
"os-tmpdir": "^1.0.0"
}
},
"path-is-absolute": {
"version": "1.0.1",
"bundled": true
},
"process-nextick-args": {
"version": "2.0.0",
"bundled": true
},
"rc": {
"version": "1.2.8",
"bundled": true,
"requires": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"dependencies": {
"minimist": {
"version": "1.2.0",
"bundled": true
}
}
},
"readable-stream": {
"version": "2.3.5",
"bundled": true,
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.0.3",
"util-deprecate": "~1.0.1"
}
},
"rimraf": {
"version": "2.6.2",
"bundled": true,
"requires": {
"glob": "^7.0.5"
}
},
"safe-buffer": {
"version": "5.1.1",
"bundled": true
},
"safer-buffer": {
"version": "2.1.2",
"bundled": true
},
"sax": {
"version": "1.2.4",
"bundled": true
},
"semver": {
"version": "5.5.0",
"bundled": true
},
"set-blocking": {
"version": "2.0.0",
"bundled": true
},
"signal-exit": {
"version": "3.0.2",
"bundled": true
},
"string-width": {
"version": "1.0.2",
"bundled": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
"strip-ansi": "^3.0.0"
}
},
"string_decoder": {
"version": "1.0.3",
"bundled": true,
"requires": {
"safe-buffer": "~5.1.0"
}
},
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"requires": {
"ansi-regex": "^2.0.0"
}
},
"strip-json-comments": {
"version": "2.0.1",
"bundled": true
},
"tar": {
"version": "4.4.4",
"bundled": true,
"requires": {
"chownr": "^1.0.1",
"fs-minipass": "^1.2.5",
"minipass": "^2.3.3",
"minizlib": "^1.1.0",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.2",
"yallist": "^3.0.2"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"bundled": true
},
"yallist": {
"version": "3.0.2",
"bundled": true
}
}
},
"util-deprecate": {
"version": "1.0.2",
"bundled": true
},
"wide-align": {
"version": "1.1.3",
"bundled": true,
"requires": {
"string-width": "^1.0.2 || 2"
}
},
"wrappy": {
"version": "1.0.2",
"bundled": true
}
}
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -228,6 +658,11 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true
},
"nan": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA=="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -298,6 +733,11 @@
"util-deprecate": "~1.0.1"
}
},
"regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
},
"rimraf": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz",

View File

@ -5,6 +5,7 @@
"main": "index.js",
"dependencies": {
"@babel/runtime": "7.0.0-beta.51",
"bcrypt": "^2.0.0",
"loglevel": "^1.6.1"
},
"devDependencies": {

View File

@ -29,6 +29,14 @@ async function makeRequest(url, options) {
});
}
if(options.method) {
requestOpt.method = options.method;
}
if (options.method === 'POST' && options.body) {
requestOpt.body = options.body;
}
return new Promise((resolve, reject) => {
// TODO: Kept getting weird build errors from RegExp
const isAbsolute = parsed.href.indexOf('http://') === 0 || parsed.href.indexOf('https://') === 0;

View File

@ -1,5 +1,6 @@
import { OHIF } from 'meteor/ohif:core';
import { handleSR, getLatestSRSeries } from "../utils/handleSR";
import { retrieveMeasurementFromSR, stowSRFromMeasurements } from '../utils/handleSR';
import { getLatestSRSeries } from '../utils/srUtils';
export const retrieveMeasurements = (patientId, timepointIds) => {
OHIF.log.info('retrieveMeasurements');
@ -8,7 +9,7 @@ export const retrieveMeasurements = (patientId, timepointIds) => {
if(!latestSeries) return Promise.resolve({});
return handleSR(latestSeries).then((value) => {
return retrieveMeasurementFromSR(latestSeries).then((value) => {
return {
length: value
}
@ -17,11 +18,17 @@ export const retrieveMeasurements = (patientId, timepointIds) => {
export const storeMeasurements = (measurementData, timepointIds) => {
OHIF.log.info('storeMeasurements');
const server = OHIF.servers.getCurrentServer();
if (!server || server.type !== 'dicomWeb') {
return Promise.resolve({});
}
// Here is where we should do any required data transformation and API calls
const studyInstanceUid = measurementData[Object.keys(measurementData)[0]][0].studyInstanceUid
// TODO: Write SR, STOW back to PACS
return Promise.resolve();
return stowSRFromMeasurements(measurementData).then( () => {
OHIF.studies.deleteStudyMetadataPromise(studyInstanceUid);
});
};
export const retrieveTimepoints = filter => {

View File

@ -1,7 +1,6 @@
import { OHIF } from 'meteor/ohif:core';
import { Session } from 'meteor/session';
import { configureApis } from './configuration/configuration'
import { $ } from 'meteor/jquery';
class MeasurementTable {
constructor() {
@ -9,8 +8,6 @@ class MeasurementTable {
Session.set('TimepointsReady', false);
Session.set('MeasurementsReady', false);
this.firstMeasurementActivated = false;
}
async onCreated(instance) {
@ -38,6 +35,8 @@ class MeasurementTable {
measurementApi.syncMeasurementsAndToolData();
this.firstMeasurementActivated = false;
instance.autorun(() => {
if (!Session.get('TimepointsReady') ||
!Session.get('MeasurementsReady') ||
@ -100,26 +99,25 @@ class MeasurementTable {
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);
}
static 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
MeasurementTable
}

View File

@ -1,26 +1,8 @@
import { OHIF } from 'meteor/ohif:core';
import { cornerstone } from 'meteor/ohif:cornerstone';
import { getInstanceMetadata } from './srUtils';
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) {
export default getLengthMeasurementData = (lengthMeasurementContent, displaySets) => {
let lengthStates = [];
lengthMeasurementContent.forEach(groupItemContent => {

View File

@ -0,0 +1,79 @@
export default measurementGroupContentItem = (measurement, sopClassUid) => {
const {
handles,
length,
frameIndex,
sopInstanceUid
} = measurement;
const measurementContentItem = [
{
RelationshipType: 'HAS OBS CONTEXT',
ValueType: 'TEXT',
ConceptNameCodeSequence: {
CodeValue: '112039',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Tracking Identifier',
},
TextValue: 'web annotation',
},
{
RelationshipType: 'HAS OBS CONTEXT',
ValueType: 'UIDREF',
ConceptNameCodeSequence: {
CodeValue: '112040',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Tracking Unique Identifier',
},
UID: dcmjs.data.DicomMetaDictionary.uid(),
},
{
RelationshipType: 'CONTAINS',
ValueType: 'CODE',
ConceptNameCodeSequence: {
CodeValue: '121071',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Finding',
},
ConceptCodeSequence: {
CodeValue: 'SAMPLEFINDING',
CodingSchemeDesignator: '99dcmjs',
CodeMeaning: 'Sample Finding',
},
},
{
RelationshipType: 'CONTAINS',
ValueType: 'NUM',
ConceptNameCodeSequence: {
CodeValue: 'G-D7FE',
CodingSchemeDesignator: 'SRT',
CodeMeaning: 'Length',
},
MeasuredValueSequence: {
MeasurementUnitsCodeSequence: {
CodeValue: 'mm',
CodingSchemeDesignator: 'UCUM',
CodingSchemeVersion: '1.4',
CodeMeaning: 'millimeter',
},
NumericValue: length,
},
ContentSequence: {
RelationshipType: 'INFERRED FROM',
ValueType: 'SCOORD',
GraphicType: 'POLYLINE',
GraphicData: [ handles.start.x, handles.start.y, handles.end.x, handles.end.y ],
ContentSequence: {
RelationshipType: 'SELECTED FROM',
ValueType: 'IMAGE',
ReferencedSOPSequence: {
ReferencedSOPClassUID: sopClassUid,
ReferencedSOPInstanceUID: sopInstanceUid,
ReferencedFrameNumber: frameIndex,
}
},
},
},
];
return measurementContentItem;
};

View File

@ -1,100 +1,63 @@
import { OHIF } from 'meteor/ohif:core';
import { dcmjs } from 'meteor/ohif:cornerstone';
import getLengthMeasurementData from './getLengthMeasurementData';
import retrieveDataFromSR from './retrieveDataFromSR';
import retrieveDataFromMeasurements from './retrieveDataFromMeasurements';
import {
multipartEncode
} from './srUtils';
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 retrieveMeasurementFromSR = async (series) => {
const instance = series.getFirstInstance();
const options = {
method: 'GET',
responseType: 'arraybuffer',
};
const url = instance.getDataProperty('wadouri');
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();
});
try {
const result = await DICOMWeb.makeRequest(url, options);
const data = await result.arrayBuffer();
const measurementData = retrieveDataFromSR(data);
return Promise.resolve(measurementData);
} catch(error) {
return Promise.reject(error);
}
};
/**
* Retrieve the DICOMWeb STOW-RS URL for the current server.
*
* See ftp://medical.nema.org/medical/dicom/current/output/html/part18.html#sect_6.6.1.1
*
* @return {string|null} The URL, rerouted to the proxy if the WADOProxy is enabled.
*/
function getStowRsUrl() {
const server = OHIF.servers.getCurrentServer();
const url = `${server.wadoRoot}/studies`;
return WADOProxy.convertURL(url, server);
}
const stowSRFromMeasurements = async (measurements) => {
const serverUrl = getStowRsUrl();
const reportDataset = retrieveDataFromMeasurements(measurements);
const boundary = dcmjs.data.DicomMetaDictionary.uid();
const options = {
method: 'POST',
body: multipartEncode(reportDataset, boundary),
headers: {
'Content-Type': `multipart/related; type=application/dicom; boundary=${boundary}`
}
};
try {
await DICOMWeb.makeRequest(serverUrl, options);
return Promise.resolve();
} catch(error) {
return Promise.reject(error);
}
};
export {
retrieveMeasurementFromSR,
stowSRFromMeasurements
}

View File

@ -0,0 +1,199 @@
import { dcmjs } from 'meteor/ohif:cornerstone';
import { getInstanceMetadata, getAllDisplaySets } from './srUtils';
import getLengthMeasurementGroupContentItem from './getLengthMeasurementGroupContentItem';
export default retrieveDataFromMeasurements = (measurements) => {
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = measurements.allTools[0];
const displaySets = getAllDisplaySets();
const instanceMetadata = getInstanceMetadata(displaySets, sopInstanceUid);
const sopClassUid = instanceMetadata._data.sopClassUid;
// TODO: figure out what is needed to make a dcmjs dataset from
// information available in the viewer. Apparently the raw dicom is not available
// directly.
const derivationSourceDataset = {
StudyInstanceUID: studyInstanceUid,
SeriesInstanceUID: seriesInstanceUid,
SOPInstanceUID: sopInstanceUid,
SOPClassUID: sopClassUid
};
report = new dcmjs.derivations.StructuredReport([derivationSourceDataset]);
dataset = report.dataset;
// TODO: what is the correct metaheader
// http://dicom.nema.org/medical/Dicom/current/output/chtml/part10/chapter_7.html
// TODO: move meta creation to dcmjs
const fileMetaInformationVersionArray = new Uint16Array(1);
fileMetaInformationVersionArray[0] = 1;
dataset._meta = {
FileMetaInformationVersion: fileMetaInformationVersionArray.buffer,
MediaStorageSOPClassUID: dataset.SOPClassUID,
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
TransferSyntaxUID: "1.2.840.10008.1.2.1", // Explicit little endian (always for dcmjs?)
ImplementationClassUID: dcmjs.data.DicomMetaDictionary.uid(), // TODO: could be git hash or other valid id
ImplementationVersionName: "OHIFViewer"
};
// TODO: factor out a lot of this back into dcmjs for use in both
// the example and in the Viewer
const measurementGroupContentSequence = [];
for (measurement of measurements.allTools) {
if (measurement.toolType === 'length') {
measurementGroupContentSequence.push.apply(measurementGroupContentSequence, getLengthMeasurementGroupContentItem(measurement, sopClassUid));
}
}
// TODO: make a TID1550 derivation as an SR subclass
dataset = Object.assign(dataset, {
ConceptNameCodeSequence: {
CodeValue: '126000',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Imaging Measurement Report'
},
ContinuityOfContent: 'SEPARATE',
PerformedProcedureCodeSequence: [],
CompletionFlag: 'COMPLETE',
VerificationFlag: 'UNVERIFIED',
ReferencedPerformedProcedureStepSequence: [],
InstanceNumber: 1,
CurrentRequestedProcedureEvidenceSequence: {
StudyInstanceUID: dataset.StudyInstanceUID,
ReferencedSeriesSequence: {
SeriesInstanceUID: dataset.SeriesInstanceUID,
ReferencedSOPSequence: {
ReferencedSOPClassUID: derivationSourceDataset.SOPClassUID,
ReferencedSOPInstanceUID: derivationSourceDataset.SOPInstanceUID
},
},
},
CodingSchemeIdentificationSequence: {
CodingSchemeDesignator: "99dcmjs",
CodingSchemeName: "Codes used for dcmjs",
CodingSchemeVersion: "0",
CodingSchemeResponsibleOrganization: "https://github.com/pieper/dcmjs"
},
ContentTemplateSequence: {
MappingResource: 'DCMR',
TemplateIdentifier: '1500'
},
ContentSequence: [
{
RelationshipType: 'HAS CONCEPT MOD',
ValueType: 'CODE',
ConceptNameCodeSequence: {
CodeValue: '121049',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Language of Content Item and Descendants',
},
ConceptCodeSequence: {
CodeValue: 'eng',
CodingSchemeDesignator: 'RFC3066',
CodeMeaning: 'English',
},
ContentSequence: {
RelationshipType: 'HAS CONCEPT MOD',
ValueType: 'CODE',
ConceptNameCodeSequence: {
CodeValue: '121046',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Country of Language',
},
ConceptCodeSequence: {
CodeValue: 'US',
CodingSchemeDesignator: 'ISO3166_1',
CodeMeaning: 'United States',
}
}
},
{
RelationshipType: 'HAS OBS CONTEXT',
ValueType: 'PNAME',
ConceptNameCodeSequence: {
CodeValue: '121008',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Person Observer Name',
},
PersonName: 'user^web' // TODO: these can be options argument for constructor
},
{
RelationshipType: 'HAS OBS CONTEXT',
ValueType: 'TEXT',
ConceptNameCodeSequence: {
CodeValue: 'RP-100006',
CodingSchemeDesignator: '99dcmjs',
CodeMeaning: "Person Observer's Login Name"
},
TextValue: 'user'
},
{
RelationshipType: 'HAS CONCEPT MOD',
ValueType: 'CODE',
ConceptNameCodeSequence: {
CodeValue: '121058',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Procedure reported'
},
ConceptCodeSequence: {
CodeValue: '1',
CodingSchemeDesignator: '99dcmjs',
CodeMeaning: 'Unknown procedure'
},
},
{
RelationshipType: 'CONTAINS',
ValueType: 'CONTAINER',
ConceptNameCodeSequence: {
CodeValue: '111028',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Image Library',
},
ContinuityOfContent: 'SEPARATE',
ContentSequence: {
RelationshipType: 'CONTAINS',
ValueType: 'CONTAINER',
ConceptNameCodeSequence: {
CodeValue: '126200',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Image Library Group',
},
ContinuityOfContent: 'SEPARATE',
ContentSequence: {
RelationshipType: 'CONTAINS',
ValueType: 'IMAGE',
ReferencedSOPSequence: {
// TODO: this should refer to the UIDs extracted from the toolState / instance info
ReferencedSOPClassUID: dataset.SOPClassUID,
ReferencedSOPInstanceUID: dataset.SOPInstanceUID,
},
},
},
},
{
RelationshipType: 'CONTAINS',
ValueType: 'CONTAINER',
ConceptNameCodeSequence: {
CodeValue: '126010',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Imaging Measurements', // TODO: would be nice to abstract the code sequences (in a dictionary? a service?)
},
ContinuityOfContent: 'SEPARATE',
ContentSequence: {
RelationshipType: 'CONTAINS',
ValueType: 'CONTAINER',
ConceptNameCodeSequence: {
CodeValue: '125007',
CodingSchemeDesignator: 'DCM',
CodeMeaning: 'Measurement Group',
},
ContinuityOfContent: 'SEPARATE',
ContentSequence: measurementGroupContentSequence,
},
},
]
});
return dataset;
}

View File

@ -0,0 +1,37 @@
import { dcmjs } from 'meteor/ohif:cornerstone';
import getLengthMeasurementData from './getLengthMeasurementData';
import { toArray, codeMeaningEquals, getAllDisplaySets } from './srUtils'
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);
};
export default retrieveDataFromSR = (Part10SRArrayBuffer) => {
const allDisplaySets = getAllDisplaySets();
// 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);
};

View File

@ -0,0 +1,105 @@
import { dcmjs } from 'meteor/ohif:cornerstone';
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 getAllDisplaySets = () => {
const allStudies = OHIF.viewer.Studies.all();
let allDisplaySets = [];
allStudies.forEach(study => {
allDisplaySets = allDisplaySets.concat(study.displaySets);
});
return allDisplaySets;
};
const 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;
};
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;
};
const stringToArray = (string) => {
return Uint8Array.from(Array.from(string).map(letter => letter.charCodeAt(0)))
};
const multipartEncode = (dataset, boundary) => {
const denaturalizedMetaheader = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(dataset._meta);
const dicomDict = new dcmjs.data.DicomDict(denaturalizedMetaheader);
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(dataset);
const part10Buffer = dicomDict.write();
const header = `\r\n--${boundary}\r\nContent-Type: application/dicom\r\n\r\n`;
const footer = `\r\n--${boundary}--`;
headerArray = stringToArray(header);
contentArray = new Uint8Array(part10Buffer);
footerArray = stringToArray(footer);
const multipartArray = new Uint8Array(headerArray.length + contentArray.length + footerArray.length);
multipartArray.set(headerArray, 0);
multipartArray.set(contentArray, headerArray.length);
multipartArray.set(footerArray, headerArray.length + contentArray.length);
return(multipartArray.buffer);
};
export {
codeMeaningEquals,
getAllDisplaySets,
getInstanceMetadata,
getLatestSRSeries,
multipartEncode,
toArray
}

View File

@ -15,6 +15,7 @@ Package.onUse(function(api) {
api.use('ohif:cornerstone-settings');
api.use('ohif:viewerbase');
api.use('ohif:measurements');
api.use('ohif:wadoproxy');
api.mainModule('client/index.js', 'client');
});

View File

@ -9,4 +9,21 @@
{{/each}}
{{/each}}
{{/let}}
{{#if hasAnyMeasurement}}
<div class="report-area">
{{#if saveEnabled}}
{{#if hasUnsavedChanges}}
<div class="unsaved-changes-alert">
You have unsaved changes
</div>
{{/if}}
{{/if}}
{{#form (extend api=instance.api)}}
{{#button class='btn p-x-2' action='exportCSV'}}Export CSV{{/button}}
{{#if saveEnabled}}
{{#button class='btn p-x-2' action='save' disabled=(not hasUnsavedChanges)}}Save to Archive{{/button}}
{{/if}}
{{/form}}
</div>
{{/if}}
</template>

View File

@ -8,9 +8,68 @@ Template.measurementLightTableView.onCreated(() => {
instance.data.measurementGroups = new ReactiveVar();
instance.path = 'viewer.studyViewer.measurements';
instance.saveObserver = new Tracker.Dependency();
instance.api = {
save() {
const successHandler = () => {
OHIF.ui.unsavedChanges.clear(`${instance.path}.*`);
instance.saveObserver.changed();
};
// Display the error messages
const errorHandler = data => {
OHIF.ui.showDialog('dialogInfo', Object.assign({ class: 'themed' }, data));
};
const promise = instance.data.measurementApi.storeMeasurements();
promise.then(successHandler).catch(errorHandler);
OHIF.ui.showDialog('dialogLoading', {
promise,
text: 'Measurements saved.'
});
return promise;
},
exportCSV() {
const { measurementApi, timepointApi } = instance.data;
OHIF.measurements.exportCSV(measurementApi, timepointApi);
}
};
instance.autorun(() => {
measurementApi.changeObserver.depend();
const data = OHIF.measurements.getMeasurementsGroupedByNumber(measurementApi, timepointApi);
instance.data.measurementGroups.set(data);
});
});
Template.measurementLightTableView.helpers({
hasUnsavedChanges() {
const instance = Template.instance();
// Run this computation on save or every time any measurement / timepoint suffer changes
OHIF.ui.unsavedChanges.depend();
instance.saveObserver.depend();
return OHIF.ui.unsavedChanges.probe('viewer.*') !== 0;
},
hasAnyMeasurement() {
const instance = Template.instance();
const groups = instance.data.measurementGroups.get();
if (!groups) {
return false;
}
const group = groups.find(item => item.measurementRows.length > 0);
return group;
},
saveEnabled() {
const server = OHIF.servers.getCurrentServer();
return (server && server.type === 'dicomWeb');
}
});

View File

@ -11,3 +11,9 @@
theme('background-color', '$activeColor')
theme('border', '1px solid $uiBorderColorActive')
color: #000
.unsaved-changes-alert
padding-bottom: 10px;
font-size: 11px;
color: #FF0;
font-weight: 100;

View File

@ -205,12 +205,6 @@ async function resultDataToStudyMetadata(server, studyInstanceUid, resultData) {
const seriesMap = {};
const seriesList = [];
if (!WADOProxy) {
WADOProxy = {
convertURL: (a => a)
};
}
if (!resultData.length) {
return;
}

View File

@ -6,11 +6,13 @@ import 'meteor/ohif:viewerbase';
// promises and prevent unnecessary subsequent calls to the server
const StudyMetaDataPromises = new Map();
/**
* Delete the study promise cached to ensure next retrieve will get it retrieved by server call
* Delete the cached study metadata retrieval promise to ensure that the browser will
* re-retrieve the study metadata when it is next requested
*
* @param {String} studyInstanceUid The UID of the Study to be removed from cache
*
*
*/
OHIF.studies.deleteStudyMetadataPromise = (studyInstanceUid) => {
if (StudyMetaDataPromises.has(studyInstanceUid)) {

View File

@ -0,0 +1,18 @@
import queryString from 'query-string';
WADOProxy.convertURL = (url, serverConfiguration) => {
if (!url) {
return null;
}
if (serverConfiguration.requestOptions &&
serverConfiguration.requestOptions.requestFromBrowser === true) {
return url;
}
const { settings } = WADOProxy;
const serverId = serverConfiguration._id;
const query = queryString.stringify({url, serverId});
return `${settings.uri}?${query}`;
}

View File

@ -1,7 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { OHIF } from 'meteor/ohif:core';
Settings = Object.assign({
WADOProxy.settings = Object.assign({
uri : OHIF.utils.absoluteUrl("/__wado_proxy"),
enabled: true
}, (Meteor.settings && Meteor.settings.proxy) ? Meteor.settings.proxy : {});
}, (Meteor.settings && Meteor.settings.proxy) ? Meteor.settings.proxy : {});

View File

@ -0,0 +1 @@
WADOProxy = {};

View File

@ -5,6 +5,7 @@ Package.describe({
});
Npm.depends({
'query-string': '5.1.1',
'performance-now': '2.1.0'
});
@ -12,17 +13,16 @@ Package.onUse(function(api) {
api.versionsFrom('1.4');
api.use('ecmascript');
api.use('standard-app-packages');
api.use('clinical:router@2.0.19');
api.use('ohif:core');
api.use('ohif:servers');
api.addFiles('server/namespace.js', 'server');
api.addFiles('server/initialize.js', 'server');
api.addFiles('both/namespace.js', ['client', 'server']);
api.addFiles('both/convertURL.js', ['client', 'server']);
api.addFiles('both/initialize.js', ['client', 'server']);
api.addFiles('server/routes.js', 'server');
api.addFiles('server/convertURL.js', 'server');
// Global exports
api.export('WADOProxy');
});

View File

@ -1,15 +0,0 @@
const querystring = require("querystring");
WADOProxy.convertURL = (url, serverConfiguration) => {
if (!Settings.enabled) {
return url;
}
if (!url) {
return null;
}
const serverId = serverConfiguration._id;
const query = querystring.stringify({url, serverId});
return `${Settings.uri}?${query}`;
}

View File

@ -1 +0,0 @@
WADOProxy = {};

View File

@ -170,4 +170,4 @@ const handleRequest = function() {
// Setup a Route using Iron Router to avoid Cross-origin resource sharing
// (CORS) errors. We only handle this route on the Server.
Router.route(Settings.uri.replace(OHIF.utils.absoluteUrl(), ''), handleRequest, { where: 'server' });
Router.route(WADOProxy.settings.uri.replace(OHIF.utils.absoluteUrl(), ''), handleRequest, { where: 'server' });

View File

@ -3,7 +3,7 @@ import { Session } from 'meteor/session';
import { Tracker } from 'meteor/tracker';
import { ReactiveDict } from 'meteor/reactive-dict';
import { OHIF } from 'meteor/ohif:core';
import { MeasurementTable, measurementEvents } from 'meteor/ohif:measurement-table';
import { MeasurementTable } from 'meteor/ohif:measurement-table';
import 'meteor/ohif:viewerbase';
import 'meteor/ohif:metadata';
@ -133,7 +133,7 @@ Template.viewer.onRendered(function() {
Template.viewer.events( Object.assign({
// Viewer Events
},
measurementEvents
MeasurementTable.measurementEvents
));
Template.viewer.helpers({