diff --git a/Packages/ohif-dicom-services/package.js b/Packages/ohif-dicom-services/package.js index afa995c99..763c3d85a 100755 --- a/Packages/ohif-dicom-services/package.js +++ b/Packages/ohif-dicom-services/package.js @@ -18,6 +18,7 @@ Package.onUse(function(api) { api.addFiles('server/DICOMWeb/getString.js', 'server'); api.addFiles('server/DICOMWeb/getModalities.js', 'server'); api.addFiles('server/DICOMWeb/getAttribute.js', 'server'); + api.addFiles('server/DICOMWeb/getBulkData.js', 'server'); api.export('DICOMWeb', 'server'); diff --git a/Packages/ohif-dicom-services/server/DICOMWeb/getBulkData.js b/Packages/ohif-dicom-services/server/DICOMWeb/getBulkData.js new file mode 100644 index 000000000..efb78c4b4 --- /dev/null +++ b/Packages/ohif-dicom-services/server/DICOMWeb/getBulkData.js @@ -0,0 +1,193 @@ + +const ASCII = 'ascii'; +const http = Npm.require('http'), url = Npm.require('url'); + +function getMultipartContentInfo(headers) { + + let dict = null, + multipartRegex = /^\s*multipart\/[^;]+/g, + contentType = headers['content-type']; + + if (typeof contentType === 'string' && multipartRegex.test(contentType)) { + let match, + pairRegex = /;\s*([^=]+)=([^;\r\n]+)/g; + pairRegex.lastIndex = multipartRegex.lastIndex; + while ((match = pairRegex.exec(contentType)) !== null) { + let key = match[1], value = match[2]; + if (dict === null) { + dict = {}; + } + dict[key] = value; + } + } + + return dict; + +} + +function parseContentHeader(data, offset) { + + let endOfHeader = data.indexOf('\r\n\r\n', offset, ASCII); + + if (endOfHeader < 0 || endOfHeader <= offset || endOfHeader > data.length) { + throw { + name: 'DICOMWebContentParsingError', + message: 'End of content header cannot be determined...' + }; + } + + let match, + header = { + start: offset, + end: endOfHeader + 4, + fields: {} + }, + headerRegex = /([\w\-]+):\s*([^\r\n]+)(?:\r\n)?/g, + headerContentRegex = /\t([^\r\n]+)(?:\r\n)?/g, + headerString = data.toString('ascii', offset, endOfHeader); + + while ((match = headerRegex.exec(headerString)) !== null) { + let key = match[1].toLowerCase(), value = match[2]; + while (headerString.charAt(headerRegex.lastIndex) === '\t') { + headerContentRegex.lastIndex = headerRegex.lastIndex; + if ((match = headerContentRegex.exec(headerString)) !== null) { + headerRegex.lastIndex = headerContentRegex.lastIndex; + value += ' ' + match[1]; + continue; + } + break; + } + header.fields[key] = value; + } + + return header; + +} + +function parseResponse(headers, data) { + + let contentInfo = getMultipartContentInfo(headers); + + if (!contentInfo || !contentInfo.boundary) { + throw { + name: 'DICOMWebContentParsingError', + message: 'Content boundary not specified...' + }; + } + + let boundary = contentInfo.boundary, + delimiter = '--' + boundary + '\r\n', + index = data.indexOf(delimiter, 0, ASCII); + + if (index < 0) { + throw { + name: 'DICOMWebContentParsingError', + message: 'The specified boundary could not be found...' + }; + } + + let closingIndex, closingDelimiter = Buffer.from('\r\n--' + boundary + '--', ASCII); + + index += delimiter.length; + if (data[index] !== 0x0D || data[index + 1] !== 0x0A) { + // multipart content headers are present, so let's parse them... + let contentHeader = parseContentHeader(data, index); + if (contentHeader && contentHeader.end > index) { + if (contentHeader.fields['content-length'] > 0) { + let endOfData = contentHeader.end + parseInt(contentHeader.fields['content-length'], 10); + if (endOfData === data.indexOf(closingDelimiter, endOfData)) { + // content-length is valid... + return data.slice(contentHeader.end, endOfData); + } + } + index = contentHeader.end; + } + } else { + // no headers, the content comes right after... + index += 2; + } + + closingIndex = data.indexOf(closingDelimiter, index); + if (closingIndex < 0 || closingIndex < index) { + throw { + name: 'DICOMWebContentParsingError', + message: 'The end of the content could not be determined...' + }; + } + + return data.slice(index, closingIndex); + +} + +function makeRequest(geturl, options, callback) { + + let parsedUrl = url.parse(geturl); + + let requestOpt = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + headers: { + Accept: 'multipart/related; type=application/octet-stream' + }, + path: parsedUrl.path, + method: 'GET' + }; + + if (options.auth) { + requestOpt.auth = options.auth; + } + + let req = http.request(requestOpt, function(resp) { + + let data = []; + + if (resp.statusCode !== 200) { + callback({ + name: 'DICOMWebRequestError', + message: `Unexpected status code for DICOMWeb response (${resp.statusCode})...` + }, null); + } + + resp.on('data', function(chunk) { + data.push(chunk); + }); + + resp.on('end', function() { + try { + callback(null, parseResponse(resp.headers, Buffer.concat(data))); + } catch (error) { + callback(error, null); + } + }); + + }); + + req.end(); + +} + +const makeRequestSync = Meteor.wrapAsync(makeRequest); + +DICOMWeb.getBulkData = function(geturl, options) { + + if (options.logRequests) { + console.log(geturl); + } + + if (options.logTiming) { + console.time(geturl); + } + + var result = makeRequestSync(geturl, options); + + if (options.logTiming) { + console.timeEnd(geturl); + } + + if (options.logResponses) { + console.log(result); + } + + return result; + +}; diff --git a/Packages/ohif-study-list/server/services/wado/retrieveMetadata.js b/Packages/ohif-study-list/server/services/wado/retrieveMetadata.js index 0185ba4d8..26e7ef14a 100644 --- a/Packages/ohif-study-list/server/services/wado/retrieveMetadata.js +++ b/Packages/ohif-study-list/server/services/wado/retrieveMetadata.js @@ -1,5 +1,42 @@ import { parseFloatArray } from '../../lib/parseFloatArray'; +/** + * Simple cache schema for retrieved color palettes. + */ +const paletteColorCache = { + count: 0, + maxAge: 24 * 60 * 60 * 1000, // 24h cache? + entries: {}, + isValidUID: function (paletteUID) { + return typeof paletteUID === 'string' && paletteUID.length > 0; + }, + get: function (paletteUID) { + let entry = null; + if (this.entries.hasOwnProperty(paletteUID)) { + entry = this.entries[paletteUID]; + // check how the entry is... + if ((Date.now() - entry.time) > this.maxAge) { + // entry is too old... remove entry. + delete this.entries[paletteUID]; + this.count--; + entry = null; + } + } + return entry; + }, + add: function (entry) { + if (this.isValidUID(entry.uid)) { + let paletteUID = entry.uid; + if (this.entries.hasOwnProperty(paletteUID) !== true) { + this.count++; // increment cache entry count... + } + entry.time = Date.now(); + this.entries[paletteUID] = entry; + // @TODO: Add logic to get rid of old entries and reduce memory usage... + } + } +}; + /** * Creates a URL for a WADO search * @@ -29,6 +66,51 @@ function getSourceImageInstanceUid(instance) { } } + +/** + * Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation. + * + * @param server {Object} Current server; + * @param instance {Object} The retrieved instance metadata; + * @returns {String} The ReferenceSOPInstanceUID + */ +function getPaletteColors(server, instance) { + + let entry = null, + paletteUID = DICOMWeb.getString(instance['00281199']); + + if (paletteColorCache.isValidUID(paletteUID)) { + entry = paletteColorCache.get(paletteUID); + } else { + paletteUID = null; + } + + if (!entry) { + // no entry on cache... Fetch remote data. + try { + let r, g, b; + // Palettes are quite small (standard defines a limit of 64KiB but it's usually much less... like 512 ~ 1024 bytes) + // ... so no problem if we store them using base64 encoding. + r = DICOMWeb.getBulkData(instance['00281201'].BulkDataURI, server.requestOptions).toString('base64'); + g = DICOMWeb.getBulkData(instance['00281202'].BulkDataURI, server.requestOptions).toString('base64'); + b = DICOMWeb.getBulkData(instance['00281203'].BulkDataURI, server.requestOptions).toString('base64'); + entry = { red: r, green: g, blue: b }; + if (paletteUID !== null) { + // when paletteUID is present, the entry can be cached... + entry.uid = paletteUID; + paletteColorCache.add(entry); + } + } catch (error) { + console.log(`(${error.name}) ${error.message}`); + } + } + + return entry; + +} + + + /** * Parses result data from a WADO search into Study MetaData * Returns an object populated with study metadata, including the @@ -125,13 +207,18 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) { // Get additional information if the instance uses "PALETTE COLOR" photometric interpretation if (instanceSummary.photometricInterpretation === 'PALETTE COLOR') { - instanceSummary.paletteColorLookupTableUID = DICOMWeb.getString(instance['00281199']); - instanceSummary.redPaletteColorLookupTableDescriptor = DICOMWeb.getString(instance['00281101']); - instanceSummary.greenPaletteColorLookupTableDescriptor = DICOMWeb.getString(instance['00281102']); - instanceSummary.bluePaletteColorLookupTableDescriptor = DICOMWeb.getString(instance['00281103']); - instanceSummary.redPaletteColorLookupTableData = instance['00281201']; - instanceSummary.greenPaletteColorLookupTableData = instance['00281202']; - instanceSummary.bluePaletteColorLookupTableData = instance['00281203']; + let palettes = getPaletteColors(server, instance); + if (palettes) { + if (palettes.uid) { + instanceSummary.paletteColorLookupTableUID = palettes.uid; + } + instanceSummary.redPaletteColorLookupTable = palettes.red; + instanceSummary.greenPaletteColorLookupTable = palettes.green; + instanceSummary.bluePaletteColorLookupTable = palettes.blue; + instanceSummary.redPaletteColorLookupTableDescriptor = DICOMWeb.getString(instance['00281101']); + instanceSummary.greenPaletteColorLookupTableDescriptor = DICOMWeb.getString(instance['00281102']); + instanceSummary.bluePaletteColorLookupTableDescriptor = DICOMWeb.getString(instance['00281103']); + } } if (server.imageRendering === 'wadouri') { @@ -142,6 +229,7 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) { } series.instances.push(instanceSummary); + }); return studyData;