diff --git a/src/adapters/Cornerstone/ArrowAnnotate.js b/src/adapters/Cornerstone/ArrowAnnotate.js index bccb7084..8fd05a47 100644 --- a/src/adapters/Cornerstone/ArrowAnnotate.js +++ b/src/adapters/Cornerstone/ArrowAnnotate.js @@ -85,11 +85,12 @@ class ArrowAnnotate { static getTID300RepresentationArguments(tool) { const points = [tool.handles.start]; - let { finding, findingSites } = tool; + let { finding, findingSites, trackingIdentifier } = tool; const TID300RepresentationArguments = { points, - trackingIdentifierTextValue: `cornerstoneTools@^4.0.0:ArrowAnnotate`, + trackingIdentifierTextValue: + trackingIdentifier || `cornerstoneTools@^4.0.0:ArrowAnnotate`, findingSites: findingSites || [] }; diff --git a/src/adapters/Cornerstone/Bidirectional.js b/src/adapters/Cornerstone/Bidirectional.js index 7650287f..abd2ea53 100644 --- a/src/adapters/Cornerstone/Bidirectional.js +++ b/src/adapters/Cornerstone/Bidirectional.js @@ -8,6 +8,9 @@ const LONG_AXIS = "Long Axis"; const SHORT_AXIS = "Short Axis"; const FINDING = "121071"; const FINDING_SITE = "G-C0E3"; +const COMMENT = "121106"; +const TRACKING_IDENTIFIER = "112039"; +const TRACKING_UNIQUE_IDENTIFIER = "112040"; class Bidirectional { constructor() {} @@ -24,6 +27,21 @@ class Bidirectional { group => group.ConceptNameCodeSequence.CodeValue === FINDING_SITE ); + const comment = toArray(ContentSequence).find( + group => group.ConceptNameCodeSequence.CodeValue === COMMENT + ); + + const trackingIdentifier = toArray(ContentSequence).find( + group => + group.ConceptNameCodeSequence.CodeValue === TRACKING_IDENTIFIER + ); + + const trackingUniqueIdentifier = toArray(ContentSequence).find( + group => + group.ConceptNameCodeSequence.CodeValue === + TRACKING_UNIQUE_IDENTIFIER + ); + const longAxisNUMGroup = toArray(ContentSequence).find( group => group.ConceptNameCodeSequence.CodeMeaning === LONG_AXIS ); @@ -129,7 +147,6 @@ class Bidirectional { isCreating: false, longestDiameter, shortestDiameter, - toolType: "Bidirectional", toolName: "Bidirectional", visible: true, finding: findingGroup @@ -137,7 +154,10 @@ class Bidirectional { : undefined, findingSites: findingSiteGroups.map(fsg => { return { ...fsg.ConceptCodeSequence }; - }) + }), + comment: comment ? comment.TextValue : undefined, + trackingIdentifier: trackingIdentifier.TextValue, + trackingUniqueIdentifier: trackingUniqueIdentifier.UID }; return state; @@ -154,11 +174,14 @@ class Bidirectional { shortestDiameter, longestDiameter, finding, - findingSites + findingSites, + comment, + trackingIdentifier, + trackingUniqueIdentifier } = tool; const trackingIdentifierTextValue = - "cornerstoneTools@^4.0.0:Bidirectional"; + trackingIdentifier || "cornerstoneTools@^4.0.0:Bidirectional"; return { longAxis: { @@ -172,8 +195,10 @@ class Bidirectional { longAxisLength: longestDiameter, shortAxisLength: shortestDiameter, trackingIdentifierTextValue, + trackingUniqueIdentifier, finding: finding, - findingSites: findingSites || [] + findingSites: findingSites || [], + comment: comment }; } } diff --git a/src/adapters/Cornerstone/EllipticalRoi.js b/src/adapters/Cornerstone/EllipticalRoi.js index c0e3c6b9..7076848d 100644 --- a/src/adapters/Cornerstone/EllipticalRoi.js +++ b/src/adapters/Cornerstone/EllipticalRoi.js @@ -115,7 +115,13 @@ class EllipticalRoi { } static getTID300RepresentationArguments(tool) { - const { cachedStats, handles, finding, findingSites } = tool; + const { + cachedStats, + handles, + finding, + findingSites, + trackingIdentifier + } = tool; const { start, end } = handles; const { area } = cachedStats; @@ -145,7 +151,7 @@ class EllipticalRoi { } const trackingIdentifierTextValue = - "cornerstoneTools@^4.0.0:EllipticalRoi"; + trackingIdentifier || "cornerstoneTools@^4.0.0:EllipticalRoi"; return { area, diff --git a/src/adapters/Cornerstone/Freehand.js b/src/adapters/Cornerstone/Freehand.js index 4c695662..04c278a7 100644 --- a/src/adapters/Cornerstone/Freehand.js +++ b/src/adapters/Cornerstone/Freehand.js @@ -1,38 +1,158 @@ import MeasurementReport from "./MeasurementReport"; import TID300Polyline from "../../utilities/TID300/Polyline"; import CORNERSTONE_4_TAG from "./cornerstone4Tag"; +import { toArray } from "../helpers.js"; + +const FREEHAND = "Freehand"; +const FINDING = "121071"; +const FINDING_SITE = "G-C0E3"; +const COMMENT = "121106"; +const TRACKING_IDENTIFIER = "112039"; +const TRACKING_UNIQUE_IDENTIFIER = "112040"; + +const statNameMap = { + Minimum: "min", + Maximum: "max", + Mean: "mean", + "Standard Deviation": "stdDev" +}; class Freehand { constructor() {} - static measurementContentToLengthState(groupItemContent) { - const content = groupItemContent.ContentSequence; - const { ReferencedSOPSequence } = content.ContentSequence; - const { - ReferencedSOPInstanceUID, - ReferencedFrameNumber - } = ReferencedSOPSequence; - const state = { - sopInstanceUid: ReferencedSOPInstanceUID, - frameIndex: ReferencedFrameNumber || 0, - toolType: Freehand.toolType + static parseNumComponent(num) { + const SCOORDGroup = toArray(num.ContentSequence).find( + group => group.ValueType === "SCOORD" + ); + const { ReferencedSOPSequence } = SCOORDGroup.ContentSequence; + return { + type: num.ConceptNameCodeSequence.CodeMeaning, + value: num.MeasuredValueSequence.NumericValue, + points: Freehand.extractPoints(SCOORDGroup.GraphicData), + ReferencedSOPSequence: ReferencedSOPSequence }; + } + + static parseNumGroup(numGroup) { + const stats = {}; + const scoords = []; + const refs = []; + + numGroup.forEach(num => { + const { + type, + value, + points, + ReferencedSOPSequence + } = Freehand.parseNumComponent(num); + scoords.push(points); + refs.push(ReferencedSOPSequence); + stats[statNameMap[type]] = value; + }); + return { scoords, refs, stats }; + } + + static extractPoints(points) { + const allPoints = []; + + for (let i = 0; i < points.length; i += 2) { + // TODO z + allPoints.push({ x: points[i], y: points[i + 1] }); + } + + return allPoints; + } - // TODO: To be implemented! - // Needs to add points, lengths + static measurementContentToLengthState(MeasurementGroup) { + const { ContentSequence } = MeasurementGroup; + + const findingGroup = toArray(ContentSequence).find( + group => group.ConceptNameCodeSequence.CodeValue === FINDING + ); + + const findingSiteGroups = toArray(ContentSequence).filter( + group => group.ConceptNameCodeSequence.CodeValue === FINDING_SITE + ); + + const comment = toArray(ContentSequence).find( + group => group.ConceptNameCodeSequence.CodeValue === COMMENT + ); + + const trackingIdentifier = toArray(ContentSequence).find( + group => + group.ConceptNameCodeSequence.CodeValue === TRACKING_IDENTIFIER + ); + + const trackingUniqueIdentifier = toArray(ContentSequence).find( + group => + group.ConceptNameCodeSequence.CodeValue === + TRACKING_UNIQUE_IDENTIFIER + ); + const NUMGroup = toArray(ContentSequence).filter( + group => group.ValueType === "NUM" + ); + const { scoords, refs, stats } = Freehand.parseNumGroup(NUMGroup); + // TODO get/handle distinct shapes. using the first shape for now + const state = { + sopInstanceUid: refs[0].ReferencedSOPInstanceUID, + frameIndex: refs[0].ReferencedFrameNumber || 0, + toolType: Freehand.toolType, + handles: { + points: scoords[0], + textBox: {}, + invalidHandlePlacement: false + }, + active: false, + visible: true, + toolName: "Freehand", + invalidated: false, + finding: findingGroup + ? findingGroup.ConceptCodeSequence + : undefined, + findingSites: findingSiteGroups.map(fsg => { + return { ...fsg.ConceptCodeSequence }; + }), + comment: comment ? comment.TextValue : undefined, + trackingIdentifier: trackingIdentifier.TextValue, + trackingUniqueIdentifier: trackingUniqueIdentifier.UID, + meanStdDev: stats // TODO check if SUV/Pet + }; return state; } // TODO: this function is required for all Cornerstone Tool Adapters, since it is called by MeasurementReport. static getMeasurementData(measurementContent) { - return measurementContent.map(Freehand.measurementContentToLengthState); + return Freehand.measurementContentToLengthState(measurementContent); } - static getTID300RepresentationArguments(/*tool*/) { - // TO BE IMPLEMENTED + static getTID300RepresentationArguments(tool) { + const { + handles, + finding, + findingSites, + trackingIdentifier, + perimeter, + area, + meanStdDev, + meanStdDevSUV, + pixelUnit + } = tool; + const points = handles.points; + // console.error('tool', handles); + const trackingIdentifierTextValue = + trackingIdentifier || "cornerstoneTools@^4.0.0:Freehand"; + return { - /*points, lengths*/ + points, + perimeter, + area, + meanStdDev, + meanStdDevSUV, + pixelUnit, + trackingIdentifierTextValue, + finding, + findingSites: findingSites || [] }; } } @@ -41,7 +161,17 @@ Freehand.toolType = "Freehand"; Freehand.utilityToolType = "Freehand"; Freehand.TID300Representation = TID300Polyline; Freehand.isValidCornerstoneTrackingIdentifier = TrackingIdentifier => { - return false; // TODO + if (!TrackingIdentifier.includes(":")) { + return false; + } + + const [cornerstone4Tag, toolType] = TrackingIdentifier.split(":"); + + if (cornerstone4Tag !== CORNERSTONE_4_TAG) { + return false; + } + + return toolType === FREEHAND; }; MeasurementReport.registerTool(Freehand); diff --git a/src/adapters/Cornerstone/Length.js b/src/adapters/Cornerstone/Length.js index 517e5602..968961fb 100644 --- a/src/adapters/Cornerstone/Length.js +++ b/src/adapters/Cornerstone/Length.js @@ -70,12 +70,13 @@ class Length { } static getTID300RepresentationArguments(tool) { - const { handles, finding, findingSites } = tool; + const { handles, finding, findingSites, trackingIdentifier } = tool; const point1 = handles.start; const point2 = handles.end; const distance = tool.length; - const trackingIdentifierTextValue = "cornerstoneTools@^4.0.0:Length"; + const trackingIdentifierTextValue = + trackingIdentifier || "cornerstoneTools@^4.0.0:Length"; return { point1, diff --git a/src/adapters/Cornerstone/MeasurementReport.js b/src/adapters/Cornerstone/MeasurementReport.js index d20afc60..a180e87f 100644 --- a/src/adapters/Cornerstone/MeasurementReport.js +++ b/src/adapters/Cornerstone/MeasurementReport.js @@ -69,8 +69,18 @@ export default class MeasurementReport { "generalSeriesModule", firstImageId ); + const patientModule = metadataProvider.get( + "patientModule", + firstImageId + ); //const sopCommonModule = metadataProvider.get('sopCommonModule', firstImageId); const { studyInstanceUID, seriesInstanceUID } = generalSeriesModule; + const { + patientID, + patientName, + patientBirthDate, + patientSex + } = patientModule; // Loop through each image in the toolData Object.keys(toolState).forEach(imageId => { @@ -125,9 +135,13 @@ export default class MeasurementReport { const derivationSourceDataset = { StudyInstanceUID: studyInstanceUID, - SeriesInstanceUID: seriesInstanceUID + SeriesInstanceUID: seriesInstanceUID, //SOPInstanceUID: sopInstanceUID, // TODO: Necessary? //SOPClassUID: sopClassUID, + PatientID: patientID, + PatientName: patientName, + PatientBirthDate: patientBirthDate, + PatientSex: patientSex }; const _meta = { @@ -158,9 +172,10 @@ export default class MeasurementReport { derivationSourceDataset._meta = _meta; derivationSourceDataset._vrMap = _vrMap; - const report = new StructuredReport([derivationSourceDataset]); + const report = new StructuredReport([derivationSourceDataset], options); const contentItem = MeasurementReport.contentItem( - derivationSourceDataset + derivationSourceDataset, + options ); // Merge the derived dataset with the content from the Measurement Report diff --git a/src/utilities/TID1500/TID1500MeasurementReport.js b/src/utilities/TID1500/TID1500MeasurementReport.js index aa782349..7c84397b 100644 --- a/src/utilities/TID1500/TID1500MeasurementReport.js +++ b/src/utilities/TID1500/TID1500MeasurementReport.js @@ -19,6 +19,22 @@ export default class TID1500MeasurementReport { PersonName: "unknown^unknown" }; + this.ProcedureReported = { + RelationshipType: "HAS CONCEPT MOD", + ValueType: "CODE", + ConceptNameCodeSequence: { + CodeValue: "121058", + CodingSchemeDesignator: "DCM", + CodeMeaning: "Procedure reported" + }, + // The default per PS3.21 + ConceptCodeSequence: { + CodeValue: "P0-0099A", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Imaging procedure" + } + }; + this.tid1500 = { ConceptNameCodeSequence: { CodeValue: "126000", @@ -73,20 +89,7 @@ export default class TID1500MeasurementReport { } }, this.PersonObserverName, - { - RelationshipType: "HAS CONCEPT MOD", - ValueType: "CODE", - ConceptNameCodeSequence: { - CodeValue: "121058", - CodingSchemeDesignator: "DCM", - CodeMeaning: "Procedure reported" - }, - ConceptCodeSequence: { - CodeValue: "1", - CodingSchemeDesignator: "99dcmjs", - CodeMeaning: "Unknown procedure" - } - }, + this.ProcedureReported, { RelationshipType: "CONTAINS", ValueType: "CONTAINER", @@ -118,7 +121,10 @@ export default class TID1500MeasurementReport { if (options.PersonName) { this.PersonObserverName.PersonName = options.PersonName; } - + if (options.ProcedureReported) { + this.ProcedureReported.ConceptCodeSequence = + options.ProcedureReported; + } // Add the Measurement Groups to the Measurement Report this.addTID1501MeasurementGroups(derivationSourceDataset, options); @@ -190,5 +196,20 @@ export default class TID1500MeasurementReport { }; this.tid1500.ContentSequence.push(ImagingMeasurments); + + if (options.qualitativeEvaluations) { + const qualitativeEvaluations = { + RelationshipType: "CONTAINS", + ValueType: "CONTAINER", + ConceptNameCodeSequence: { + CodeValue: "C0034375", + CodingSchemeDesignator: "UMLS", + CodeMeaning: "Qualitative Evaluations" + }, + ContinuityOfContent: "SEPARATE", + ContentSequence: options.qualitativeEvaluations + }; + this.tid1500.ContentSequence.push(qualitativeEvaluations); + } } } diff --git a/src/utilities/TID300/Bidirectional.js b/src/utilities/TID300/Bidirectional.js index bd7dd476..6f5b3ecb 100644 --- a/src/utilities/TID300/Bidirectional.js +++ b/src/utilities/TID300/Bidirectional.js @@ -16,8 +16,8 @@ export default class Bidirectional extends TID300Measurement { RelationshipType: "CONTAINS", ValueType: "NUM", ConceptNameCodeSequence: { - CodeValue: "G-A185", - CodingSchemeDesignator: "SRT", + CodeValue: "103339001", // David Clunie's recommendation + CodingSchemeDesignator: "SCT", CodeMeaning: "Long Axis" }, MeasuredValueSequence: { @@ -50,8 +50,8 @@ export default class Bidirectional extends TID300Measurement { RelationshipType: "CONTAINS", ValueType: "NUM", ConceptNameCodeSequence: { - CodeValue: "G-A186", - CodingSchemeDesignator: "SRT", + CodeValue: "103340004", // David Clunie's recommendation + CodingSchemeDesignator: "SCT", CodeMeaning: "Short Axis" }, MeasuredValueSequence: { diff --git a/src/utilities/TID300/Length.js b/src/utilities/TID300/Length.js index bc418f4d..a5d1d7fa 100644 --- a/src/utilities/TID300/Length.js +++ b/src/utilities/TID300/Length.js @@ -10,8 +10,8 @@ export default class Length extends TID300Measurement { RelationshipType: "CONTAINS", ValueType: "NUM", ConceptNameCodeSequence: { - CodeValue: "G-D7FE", - CodingSchemeDesignator: "SRT", + CodeValue: "410668003", // David Clunie's recommendation + CodingSchemeDesignator: "SCT", CodeMeaning: "Length" }, MeasuredValueSequence: { diff --git a/src/utilities/TID300/Polyline.js b/src/utilities/TID300/Polyline.js index b91ac2a0..1f964eb6 100644 --- a/src/utilities/TID300/Polyline.js +++ b/src/utilities/TID300/Polyline.js @@ -1,5 +1,6 @@ import { DicomMetaDictionary } from "../../DicomMetaDictionary.js"; import TID300Measurement from "./TID300Measurement.js"; +import utilities from "../index.js"; /** * Expand an array of points stored as objects into @@ -12,97 +13,147 @@ function expandPoints(points) { const allPoints = []; points.forEach(point => { - allPoints.push(point[0]); - allPoints.push(point[1]); - if (point[2] !== undefined) { - allPoints.push(point[2]); + allPoints.push(point.x); + allPoints.push(point.y); + if (point.z !== undefined) { + allPoints.push(point.z); } }); return allPoints; } +function getMeasurementComponent( + measurementType, + value, + unit, + GraphicData, + use3DSpatialCoordinates, + ReferencedSOPSequence +) { + return { + // TODO: This feels weird to repeat the GraphicData + RelationshipType: "CONTAINS", + ValueType: "NUM", + ConceptNameCodeSequence: + utilities.quantificationCodedTermMap[measurementType], + MeasuredValueSequence: { + MeasurementUnitsCodeSequence: + utilities.unitsCodedTermMap[unit] || + utilities.unitsCodedTermMap["1"], + NumericValue: value + }, + ContentSequence: { + RelationshipType: "INFERRED FROM", + ValueType: use3DSpatialCoordinates ? "SCOORD3D" : "SCOORD", + GraphicType: "POLYLINE", + GraphicData, + ContentSequence: use3DSpatialCoordinates + ? undefined + : { + RelationshipType: "SELECTED FROM", + ValueType: "IMAGE", + ReferencedSOPSequence + } + } + }; +} + export default class Polyline extends TID300Measurement { contentItem() { const { points, ReferencedSOPSequence, - use3DSpatialCoordinates = false + use3DSpatialCoordinates = false, + perimeter, + area, + meanStdDev, + meanStdDevSUV, + pixelUnit } = this.props; - // Combine all lengths to save the perimeter - // @ToDO The permiter has to be implemented - // const reducer = (accumulator, currentValue) => accumulator + currentValue; - // const perimeter = lengths.reduce(reducer); - const perimeter = {}; const GraphicData = expandPoints(points); - // TODO: Add Mean and STDev value of (modality?) pixels - - return this.getMeasurement([ - { - RelationshipType: "CONTAINS", - ValueType: "NUM", - ConceptNameCodeSequence: { - CodeValue: "G-A197", - CodingSchemeDesignator: "SRT", - CodeMeaning: "Perimeter" // TODO: Look this up from a Code Meaning dictionary - }, - MeasuredValueSequence: { - MeasurementUnitsCodeSequence: { - CodeValue: "mm", - CodingSchemeDesignator: "UCUM", - CodingSchemeVersion: "1.4", - CodeMeaning: "millimeter" - }, - NumericValue: perimeter - }, - ContentSequence: { - RelationshipType: "INFERRED FROM", - ValueType: use3DSpatialCoordinates ? "SCOORD3D" : "SCOORD", - GraphicType: "POLYLINE", + const measurements = []; + // Talking with Steve, we decided it should not be calculated here. + // it should either come from cornerstone tools or should not be saved + if (perimeter) { + measurements.push( + getMeasurementComponent( + "Perimeter", + perimeter, + "mm", GraphicData, - ContentSequence: use3DSpatialCoordinates - ? undefined - : { - RelationshipType: "SELECTED FROM", - ValueType: "IMAGE", - ReferencedSOPSequence - } - } - }, - { - // TODO: This feels weird to repeat the GraphicData - RelationshipType: "CONTAINS", - ValueType: "NUM", - ConceptNameCodeSequence: { - CodeValue: "G-A166", - CodingSchemeDesignator: "SRT", - CodeMeaning: "Area" // TODO: Look this up from a Code Meaning dictionary - }, - MeasuredValueSequence: { - MeasurementUnitsCodeSequence: { - CodeValue: "mm2", - CodingSchemeDesignator: "UCUM", - CodingSchemeVersion: "1.4", - CodeMeaning: "SquareMilliMeter" - }, - NumericValue: perimeter - }, - ContentSequence: { - RelationshipType: "INFERRED FROM", - ValueType: use3DSpatialCoordinates ? "SCOORD3D" : "SCOORD", - GraphicType: "POLYLINE", + use3DSpatialCoordinates, + ReferencedSOPSequence + ) + ); + } + if (area) { + measurements.push( + getMeasurementComponent( + "Area", + area, + "mm2", GraphicData, - ContentSequence: use3DSpatialCoordinates - ? undefined - : { - RelationshipType: "SELECTED FROM", - ValueType: "IMAGE", - ReferencedSOPSequence - } - } + use3DSpatialCoordinates, + ReferencedSOPSequence + ) + ); + } + // if the pixelUnit is not sent, check if tool calculated SUV, otherwise put HU. we cannot check modality here + const unit = pixelUnit || (meanStdDevSUV ? "suv" : "hu"); + const stats = meanStdDevSUV || meanStdDev; + if (stats) { + if (stats.min) { + measurements.push( + getMeasurementComponent( + "Min", + stats.min, + unit, + GraphicData, + use3DSpatialCoordinates, + ReferencedSOPSequence + ) + ); + } + if (stats.max) { + measurements.push( + getMeasurementComponent( + "Max", + stats.max, + unit, + GraphicData, + use3DSpatialCoordinates, + ReferencedSOPSequence + ) + ); + } + if (stats.stdDev) { + measurements.push( + getMeasurementComponent( + "StdDev", + stats.stdDev, + unit, + GraphicData, + use3DSpatialCoordinates, + ReferencedSOPSequence + ) + ); } - ]); + if (stats.mean) { + measurements.push( + getMeasurementComponent( + "Mean", + stats.mean, + unit, + GraphicData, + use3DSpatialCoordinates, + ReferencedSOPSequence + ) + ); + } + } + return this.getMeasurement(measurements); } } diff --git a/src/utilities/TID300/TID300Measurement.js b/src/utilities/TID300/TID300Measurement.js index ae4ef1ff..6f573de1 100644 --- a/src/utilities/TID300/TID300Measurement.js +++ b/src/utilities/TID300/TID300Measurement.js @@ -11,13 +11,34 @@ export default class TID300Measurement { ...this.getTrackingGroups(), ...this.getFindingGroup(), ...this.getFindingSiteGroups(), + ...this.getComment(), ...contentSequenceEntries ]; } - getTrackingGroups() { - let { trackingIdentifierTextValue } = this.props; + getComment() { + let { comment } = this.props; + return comment + ? [ + { + RelationshipType: "CONTAINS", + ValueType: "TEXT", + ConceptNameCodeSequence: { + CodeValue: "121106", + CodingSchemeDesignator: "DCM", + CodeMeaning: "Comment" + }, + TextValue: comment + } + ] + : []; + } + getTrackingGroups() { + let { + trackingIdentifierTextValue, + trackingUniqueIdentifier + } = this.props; return [ { RelationshipType: "HAS OBS CONTEXT", @@ -37,7 +58,7 @@ export default class TID300Measurement { CodingSchemeDesignator: "DCM", CodeMeaning: "Tracking Unique Identifier" }, - UID: DicomMetaDictionary.uid() + UID: trackingUniqueIdentifier || DicomMetaDictionary.uid() } ]; } @@ -79,7 +100,7 @@ export default class TID300Measurement { CodeMeaning } = findingSite; return { - RelationshipType: "CONTAINS", + RelationshipType: "HAS CONCEPT MOD", ValueType: "CODE", ConceptNameCodeSequence: { CodeValue: "G-C0E3", diff --git a/src/utilities/index.js b/src/utilities/index.js index 75e29a64..aa35f320 100644 --- a/src/utilities/index.js +++ b/src/utilities/index.js @@ -2,10 +2,83 @@ import TID1500 from "./TID1500/index.js"; import TID300 from "./TID300/index.js"; import message from "./Message.js"; +const unitsCodedTermMap = { + hu: { + CodeValue: "hnsf'U", + CodingSchemeDesignator: "UCUM", + CodingSchemeVersion: "1.4", + CodeMeaning: "Hounsfield Unit" + }, + suv: { + CodeValue: "{SUVbw}g/ml", + CodingSchemeDesignator: "UCUM", + CodingSchemeVersion: "1.4", + CodeMeaning: "Standardized Uptake Value body weight" + }, + mm: { + CodeValue: "mm", + CodingSchemeDesignator: "UCUM", + CodingSchemeVersion: "1.4", + CodeMeaning: "MilliMeter" + }, + mm2: { + CodeValue: "mm2", + CodingSchemeDesignator: "UCUM", + CodingSchemeVersion: "1.4", + CodeMeaning: "SquareMilliMeter" + }, + "1": { + CodeValue: "1", + CodingSchemeDesignator: "UCUM", + CodingSchemeVersion: "1.4", + CodeMeaning: "no units" + } +}; + +const quantificationCodedTermMap = { + Perimeter: { + CodeValue: "131191004", + CodingSchemeDesignator: "SCT", + CodeMeaning: "Perimeter" + }, + Area: { + CodeValue: "2798000", + CodingSchemeDesignator: "SCT", + CodeMeaning: "Area" + }, + Volume: { + CodeValue: "1185650", + CodingSchemeDesignator: "SCT", + CodeMeaning: "Volume" + }, + Min: { + CodeValue: "R-404FB", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Min" + }, + Max: { + CodeValue: "G-A437", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Max" + }, + StdDev: { + CodeValue: "R-10047", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Standard Deviation" + }, + Mean: { + CodeValue: "R-00317", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Mean" + } +}; + const utilities = { TID1500, TID300, - message + message, + unitsCodedTermMap, + quantificationCodedTermMap }; export default utilities; diff --git a/test/test_adapters.js b/test/test_adapters.js index 3c715208..e8fa774e 100644 --- a/test/test_adapters.js +++ b/test/test_adapters.js @@ -1,5 +1,177 @@ +const expect = require("chai").expect; +const dcmjs = require("../build/dcmjs"); +const { MeasurementReport } = dcmjs.adapters.Cornerstone; -exports.test = () => { - console.log('no tests yet'); +const bidirectionalToolstate = { + "1.3.6.1.4.1.14519.5.2.1.4334.1501.204038471157187984095376885814&frame=1": { + Bidirectional: { + data: [ + { + toolType: "Bidirectional", + isCreating: false, + visible: true, + active: false, + invalidated: true, + handles: { + start: { + x: 358.3673400878906, + y: 204.6700439453125, + index: 0, + drawnIndependently: false, + allowedOutsideImage: false, + highlight: true, + active: false, + }, + end: { + x: 358.6566162109375, + y: 182.97471618652344, + index: 1, + drawnIndependently: false, + allowedOutsideImage: false, + highlight: true, + active: false, + }, + perpendicularStart: { + x: 363.9358215332031, + y: 193.8946990966797, + index: 2, + drawnIndependently: false, + allowedOutsideImage: false, + highlight: true, + active: false, + locked: false, + }, + perpendicularEnd: { + x: 353.088134765625, + y: 193.75006103515625, + index: 3, + drawnIndependently: false, + allowedOutsideImage: false, + highlight: true, + active: false, + }, + textBox: { + x: 358.3673400878906, + y: 204.6700439453125, + index: null, + drawnIndependently: true, + allowedOutsideImage: true, + highlight: false, + active: false, + hasMoved: false, + movesIndependently: false, + hasBoundingBox: true, + }, + }, + aimId: "2.25.746948110250556618597051441934648086314", + longestDiameter: 16.1, + unit: "mm", + shortestDiameter: 8.1, + trackingIdentifier: "web annotation", + trackingUniqueIdentifier: + "2.25.797388882284565263543728324912886045063", + finding: { + CodeValue: "ROI", + CodingSchemeDesignator: "99EPAD", + CodeMeaning: "ROI Only", + }, + findingSites: [], + comment: "undefined / undefined / undefined / undefined", + }, + ], + }, + }, +}; +const imageIds =[ + '1.3.6.1.4.1.14519.5.2.1.4334.1501.204038471157187984095376885814&frame=1' +]; + +const bidirectionalMetaDataProvider = { + get(type, imageId) { + if (type === "generalSeriesModule") { + if (imageIds.includes(imageId)) { + return { + studyInstanceUID: '1.3.6.1.4.1.14519.5.2.1.4334.1501.772823147212833057678103865443', + seriesInstanceUID: '1.3.6.1.4.1.14519.5.2.1.4334.1501.128241934543986080196677451907', + }; + } + } + if (type === "sopCommonModule") { + if (imageIds.includes(imageId)) { + return { + sopInstanceUID: imageId.split("&frame=")[0], + sopClassUID: '1.2.840.10008.5.1.4.1.1.2' + }; + } + } + if (type === "frameNumber") { + if (imageIds.includes(imageId)) { + return imageId.split("&frame=")[1] + ? imageId.split("&frame=")[1] + : 1; + } + } + if (type === "patientModule") { + if (imageIds.includes(imageId)) { + return { + patientID: 'AMC-001', + patientName : '', + patientBirthDate: '', + patientSex: '', + }; + } + } + return null; + }, +}; + +const IMAGING_MEASUREMENT = "126010"; +const LONG_AXIS = "103339001"; +const SHORT_AXIS = "103340004"; + +function toArray(x) { + return Array.isArray(x) ? x : [x]; } +const tests = { + test_bidirectional_adapter: () => { + const report = MeasurementReport.generateReport( + bidirectionalToolstate, + bidirectionalMetaDataProvider, + {} + ); + expect(report.dataset.Modality).to.equal("SR"); + expect(report.dataset.ContentSequence).to.not.equal(undefined); + + const measurement = toArray(report.dataset.ContentSequence).find( + (group) => group.ConceptNameCodeSequence.CodeValue === IMAGING_MEASUREMENT + ); + const longaxis = toArray(measurement.ContentSequence[0].ContentSequence).find( + (group) => group.ConceptNameCodeSequence.CodeValue === LONG_AXIS + ); + expect(longaxis.MeasuredValueSequence.NumericValue).to.equal(16.1); + + const shortaxis = toArray(measurement.ContentSequence[0].ContentSequence).find( + (group) => group.ConceptNameCodeSequence.CodeValue === SHORT_AXIS + ); + expect(shortaxis.MeasuredValueSequence.NumericValue).to.equal(8.1); + + console.log("Finished test_bidirectional_adapter"); + }, +}; + +exports.test = async (testToRun) => { + Object.keys(tests).forEach((testName) => { + if ( + testToRun && + !testName.toLowerCase().includes(testToRun.toLowerCase()) + ) { + console.log("-- Skipping " + testName); + return false; + } + console.log("-- Starting " + testName); + tests[testName](); + }); +}; + +exports.tests = tests; diff --git a/test/test_sr.js b/test/test_sr.js index 3c715208..bf564459 100644 --- a/test/test_sr.js +++ b/test/test_sr.js @@ -1,5 +1,252 @@ +const expect = require("chai").expect; +const dcmjs = require("../build/dcmjs"); -exports.test = () => { - console.log('no tests yet'); +const fs = require("fs"); +const { http, https } = require("follow-redirects"); +const os = require("os"); +const path = require("path"); + +const { DicomMetaDictionary, DicomDict, DicomMessage } = dcmjs.data; + +const IMAGING_MEASUREMENT = "126010"; +const FINDING = "121071"; +const FINDING_SITE_SCT = "363698007"; +const FINDING_SITE_SRT = "G-C0E3"; +const LONG_AXIS = "G-A185"; +const SHORT_AXIS = "G-A186"; +const LENGTH = "G-D7FE"; + +function toArray(x) { + return Array.isArray(x) ? x : [x]; +} + +function downloadToFile(url, filePath) { + return new Promise((resolve, reject) => { + const fileStream = fs.createWriteStream(filePath); + const request = https + .get(url, response => { + response.pipe(fileStream); + fileStream.on("finish", () => { + resolve(filePath); + }); + }) + .on("error", reject); + }); +} + + +const tests = { + test_point: () => { + const srURL = + "https://github.com/dcmjs-org/data/releases/download/DICOMSR_Prostate_X/ProstateX-sr.dcm"; + const srFilePath = path.join(os.tmpdir(), "ProstateX-sr.dcm"); + + downloadToFile(srURL, srFilePath).then(() => { + const arrayBuffer = fs.readFileSync(srFilePath) + .buffer; + const dicomDict = DicomMessage.readFile( + arrayBuffer + ); + const dataset = DicomMetaDictionary.naturalizeDataset( + dicomDict.dict + ); + expect(dataset.Modality).to.equal('SR'); + expect(dataset.ContentSequence).to.not.equal(undefined); + + const measurement = toArray(dataset.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === IMAGING_MEASUREMENT + ); + const finding = toArray(measurement.ContentSequence[0].ContentSequence).find( + (group) => group.ConceptNameCodeSequence.CodeValue === FINDING + ); + expect(finding.ConceptCodeSequence.CodeValue).to.equal("52988006"); + expect(finding.ConceptCodeSequence.CodingSchemeDesignator).to.equal("SCT"); + expect(finding.ConceptCodeSequence.CodeMeaning).to.equal("Lesion"); + + const findingSite = toArray(measurement.ContentSequence[0].ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === FINDING_SITE_SCT + ); + expect(findingSite.ConceptCodeSequence.CodeValue).to.equal("279706003"); + expect(findingSite.ConceptCodeSequence.CodingSchemeDesignator).to.equal("SCT"); + expect(findingSite.ConceptCodeSequence.CodeMeaning).to.equal("Peripheral zone of the prostate"); + + const roi = toArray( + measurement.ContentSequence[0].ContentSequence + ).find((group) => group.ValueType === "SCOORD3D"); + + expect(roi.GraphicType).to.equal("POINT"); + expect(roi.GraphicData).to.deep.equal([ + -0.7838299870491028, + 30.95800018310547, + -29.05820083618164, + ]); + + console.log("Finished test_point"); + }); + }, + test_bounding_box: () => { + const srURL = + "https://github.com/dcmjs-org/data/releases/download/DICOMSR_PetCtLung_BB/Lung_Dx-SR.dcm"; + const srFilePath = path.join(os.tmpdir(), "Lung_Dx-SR.dcm"); + + downloadToFile(srURL, srFilePath).then(() => { + const arrayBuffer = fs.readFileSync(srFilePath) + .buffer; + const dicomDict = DicomMessage.readFile( + arrayBuffer + ); + const dataset = DicomMetaDictionary.naturalizeDataset( + dicomDict.dict + ); + expect(dataset.Modality).to.equal('SR'); + expect(dataset.ContentSequence).to.not.equal(undefined); + const measurement = toArray(dataset.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === IMAGING_MEASUREMENT + ); + const finding = toArray(measurement.ContentSequence[0].ContentSequence).find( + (group) => group.ConceptNameCodeSequence.CodeValue === FINDING + ); + expect(finding.ConceptCodeSequence.CodeValue).to.equal("108369006"); + expect(finding.ConceptCodeSequence.CodingSchemeDesignator).to.equal("SCT"); + expect(finding.ConceptCodeSequence.CodeMeaning).to.equal("Neoplasm"); + + const findingSite = toArray(measurement.ContentSequence[0].ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === FINDING_SITE_SCT + ); + expect(findingSite.ConceptCodeSequence.CodeValue).to.equal("39607008"); + expect(findingSite.ConceptCodeSequence.CodingSchemeDesignator).to.equal("SCT"); + expect(findingSite.ConceptCodeSequence.CodeMeaning).to.equal("Lung"); + + const roi = toArray( + measurement.ContentSequence[0].ContentSequence + ).find((group) => group.ValueType === "SCOORD"); + + expect(roi.GraphicType).to.equal("POLYLINE"); + expect(roi.GraphicData).to.deep.equal([ + 134, + 198, + 165, + 198, + 165, + 233, + 134, + 233, + 134, + 198 + ]); + console.log("Finished test_bounding_box"); + }); + }, + test_bidirectional: () => { + const srURL = + "https://github.com/dcmjs-org/data/releases/download/DICOMSR_CCC2018_Bidirectional/ccc2018_bidirectional_sr.dcm"; + const srFilePath = path.join(os.tmpdir(), "ccc2018_bidirectional_sr.dcm"); + + downloadToFile(srURL, srFilePath).then(() => { + const arrayBuffer = fs.readFileSync(srFilePath) + .buffer; + const dicomDict = DicomMessage.readFile( + arrayBuffer + ); + const dataset = DicomMetaDictionary.naturalizeDataset( + dicomDict.dict + ); + expect(dataset.Modality).to.equal('SR'); + expect(dataset.ContentSequence).to.not.equal(undefined); + const measurement = toArray(dataset.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === IMAGING_MEASUREMENT + ); + const finding = toArray(measurement.ContentSequence.ContentSequence).find( + (group) => group.ConceptNameCodeSequence.CodeValue === FINDING + ); + expect(finding.ConceptCodeSequence.CodeValue).to.equal("RID5741"); + expect(finding.ConceptCodeSequence.CodingSchemeDesignator).to.equal("RADLEX"); + expect(finding.ConceptCodeSequence.CodeMeaning).to.equal("solid"); + + const findingSite = toArray(measurement.ContentSequence.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === FINDING_SITE_SRT + ); + expect(findingSite.ConceptCodeSequence.CodeValue).to.equal("39607008"); + expect(findingSite.ConceptCodeSequence.CodingSchemeDesignator).to.equal("SRT"); + expect(findingSite.ConceptCodeSequence.CodeMeaning).to.equal("Lung structure"); + + const longaxis = toArray(measurement.ContentSequence.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === LONG_AXIS + ); + + expect(longaxis.MeasuredValueSequence.NumericValue).to.equal(16.1); + + const shortaxis = toArray(measurement.ContentSequence.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === SHORT_AXIS + ); + + expect(shortaxis.MeasuredValueSequence.NumericValue).to.equal(8.1); + + console.log("Finished test_bidirectional"); + }); + }, + test_length: () => { + const srURL = + "https://github.com/dcmjs-org/data/releases/download/DICOMSR_CCC2017_Length/ccc2017_length_sr.dcm"; + const srFilePath = path.join(os.tmpdir(), "ccc2017_length_sr.dcm"); + + downloadToFile(srURL, srFilePath).then(() => { + const arrayBuffer = fs.readFileSync(srFilePath) + .buffer; + const dicomDict = DicomMessage.readFile( + arrayBuffer + ); + const dataset = DicomMetaDictionary.naturalizeDataset( + dicomDict.dict + ); + expect(dataset.Modality).to.equal('SR'); + expect(dataset.ContentSequence).to.not.equal(undefined); + const measurement = toArray(dataset.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === IMAGING_MEASUREMENT + ); + + const findingSite = toArray(measurement.ContentSequence.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === FINDING_SITE_SRT + ); + expect(findingSite.ConceptCodeSequence.CodeValue).to.equal("T-87000"); + expect(findingSite.ConceptCodeSequence.CodingSchemeDesignator).to.equal("SRT"); + expect(findingSite.ConceptCodeSequence.CodeMeaning).to.equal("Ovary"); + + const length = toArray(measurement.ContentSequence.ContentSequence).find( + (group) => + group.ConceptNameCodeSequence.CodeValue === LENGTH + ); + + expect(length.MeasuredValueSequence.NumericValue).to.equal(51.86852852); + + console.log("Finished test_bidirectional"); + }); + }, } +exports.test = async testToRun => { + Object.keys(tests).forEach(testName => { + if ( + testToRun && + !testName.toLowerCase().includes(testToRun.toLowerCase()) + ) { + console.log("-- Skipping " + testName); + return false; + } + console.log("-- Starting " + testName); + tests[testName](); + }); +}; + +exports.tests = tests; +