From 398b74d70deb32824a74e98210127326887dfa77 Mon Sep 17 00:00:00 2001 From: Erik Ziegler Date: Wed, 27 Feb 2019 10:28:17 +0100 Subject: [PATCH] feat(adapters): Add adapter to generate segments & geometry from SEG for easier use in VTKjs (migrated from vtkDisplay example) --- .../src/adapters/VTKjs/Segmentation.js | 217 ++++++++++++++++++ packages/adapters/src/bitArray.js | 1 + packages/adapters/src/colors.js | 1 + 3 files changed, 219 insertions(+) diff --git a/packages/adapters/src/adapters/VTKjs/Segmentation.js b/packages/adapters/src/adapters/VTKjs/Segmentation.js index c763c7c32e..80dfc29b5e 100644 --- a/packages/adapters/src/adapters/VTKjs/Segmentation.js +++ b/packages/adapters/src/adapters/VTKjs/Segmentation.js @@ -1,3 +1,220 @@ +import Colors from "../../colors.js"; +import BitArray from "../../bitArray.js"; + +// TODO: Is there a better name for this? RGBAInt? +// Should we move it to Colors.js +function dicomlab2RGBA(cielab) { + const rgba = Colors.dicomlab2RGB(cielab).map(x => Math.round(x * 255)); + rgba.push(255); + + return rgba; +} + +// TODO: Copied these functions in from VTK Math so we don't need a dependency. +// I guess we should put them somewhere +// https://github.com/Kitware/vtk-js/blob/master/Sources/Common/Core/Math/index.js +function cross(x, y, out) { + const Zx = x[1] * y[2] - x[2] * y[1]; + const Zy = x[2] * y[0] - x[0] * y[2]; + const Zz = x[0] * y[1] - x[1] * y[0]; + out[0] = Zx; + out[1] = Zy; + out[2] = Zz; +} + +function norm(x, n = 3) { + switch (n) { + case 1: + return Math.abs(x); + case 2: + return Math.sqrt(x[0] * x[0] + x[1] * x[1]); + case 3: + return Math.sqrt(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]); + default: { + let sum = 0; + for (let i = 0; i < n; i++) { + sum += x[i] * x[i]; + } + return Math.sqrt(sum); + } + } +} + +function normalize(x) { + const den = norm(x); + if (den !== 0.0) { + x[0] /= den; + x[1] /= den; + x[2] /= den; + } + return den; +} + +function subtract(a, b, out) { + out[0] = a[0] - b[0]; + out[1] = a[1] - b[1]; + out[2] = a[2] - b[2]; +} + +// TODO: This is a useful utility on its own. We should move it somewhere? +// dcmjs.adapters.vtk.Multiframe? dcmjs.utils? +function geometryFromFunctionalGroups(dataset, PerFrameFunctionalGroups) { + const geometry = {}; + const pixelMeasures = + dataset.SharedFunctionalGroupsSequence.PixelMeasuresSequence; + const planeOrientation = + dataset.SharedFunctionalGroupsSequence.PlaneOrientationSequence; + + // Find the origin of the volume from the PerFrameFunctionalGroups' ImagePositionPatient values + // + // TODO: assumes sorted frames. This should read the ImagePositionPatient from each frame and + // sort them to obtain the first and last position along the acquisition axis. + const firstFunctionalGroup = PerFrameFunctionalGroups[0]; + const lastFunctionalGroup = + PerFrameFunctionalGroups[PerFrameFunctionalGroups.length - 1]; + const firstPosition = firstFunctionalGroup.PlanePositionSequence.ImagePositionPatient.map( + Number + ); + const lastPosition = lastFunctionalGroup.PlanePositionSequence.ImagePositionPatient.map( + Number + ); + + geometry.origin = firstPosition; + + // NB: DICOM PixelSpacing is defined as Row then Column, + // unlike ImageOrientationPatient + geometry.spacing = [ + pixelMeasures.PixelSpacing[1], + pixelMeasures.PixelSpacing[0], + pixelMeasures.SpacingBetweenSlices + ].map(Number); + + geometry.dimensions = [ + dataset.Columns, + dataset.Rows, + PerFrameFunctionalGroups.length + ].map(Number); + + const orientation = planeOrientation.ImageOrientationPatient.map(Number); + const columnStepToPatient = orientation.slice(0, 3); + const rowStepToPatient = orientation.slice(3, 6); + + geometry.planeNormal = []; + + cross(columnStepToPatient, rowStepToPatient, geometry.planeNormal); + + geometry.sliceStep = []; + subtract(lastPosition, firstPosition, geometry.sliceStep); + normalize(geometry.sliceStep); + geometry.direction = columnStepToPatient + .concat(rowStepToPatient) + .concat(geometry.sliceStep); + + return geometry; +} + export default class Segmentation { constructor() {} + + /** + * Produces an array of Segments from an input DICOM Segmentation dataset + * + * Segments are returned with Geometry values that can be used to create + * VTK Image Data objects. + * + * @example Example usage to create VTK Volume actors from each segment: + * + * const actors = []; + * const segments = generateToolState(dataset); + * segments.forEach(segment => { + * // now make actors using the segment information + * const scalarArray = vtk.Common.Core.vtkDataArray.newInstance({ + * name: "Scalars", + * numberOfComponents: 1, + * values: segment.pixelData, + * }); + * + * const imageData = vtk.Common.DataModel.vtkImageData.newInstance(); + * imageData.getPointData().setScalars(scalarArray); + * imageData.setDimensions(geometry.dimensions); + * imageData.setSpacing(geometry.spacing); + * imageData.setOrigin(geometry.origin); + * imageData.setDirection(geometry.direction); + * + * const mapper = vtk.Rendering.Core.vtkVolumeMapper.newInstance(); + * mapper.setInputData(imageData); + * mapper.setSampleDistance(2.); + * + * const actor = vtk.Rendering.Core.vtkVolume.newInstance(); + * actor.setMapper(mapper); + * + * actors.push(actor); + * }); + * + * @param dataset + * @return {{}} + */ + static generateSegments(dataset) { + if (dataset.SegmentSequence.constructor.name !== "Array") { + dataset.SegmentSequence = [dataset.SegmentSequence]; + } + + dataset.SegmentSequence.forEach(segment => { + // TODO: other interesting fields could be extracted from the segment + // TODO: Read SegmentsOverlay field + // http://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.8.20.2.html + + // TODO: Looks like vtkColor only wants RGB in 0-1 values. + // Why was this example converting to RGBA with 0-255 values? + const color = dicomlab2RGBA(segment.RecommendedDisplayCIELabValue); + + segments[segment.SegmentNumber] = { + color, + functionalGroups: [], + offset: null, + size: null, + pixelData: null + }; + }); + + // make a list of functional groups per segment + dataset.PerFrameFunctionalGroupsSequence.forEach(functionalGroup => { + const segmentNumber = + functionalGroup.SegmentIdentificationSequence + .ReferencedSegmentNumber; + + segments[segmentNumber].functionalGroups.push(functionalGroup); + }); + + // determine per-segment index into the pixel data + // TODO: only handles one-bit-per pixel + const frameSize = Math.ceil((dataset.Rows * dataset.Columns) / 8); + let nextOffset = 0; + + Object.keys(segments).forEach(segmentNumber => { + const segment = segments[segmentNumber]; + + segment.numberOfFrames = segment.functionalGroups.length; + segment.size = segment.numberOfFrames * frameSize; + segment.offset = nextOffset; + + nextOffset = segment.offset + segment.size; + + const packedSegment = dataset.PixelData.slice( + segment.offset, + nextOffset + ); + + segment.pixelData = BitArray.unpack(packedSegment); + + const geometry = geometryFromFunctionalGroups( + dataset, + segment.functionalGroups + ); + + segment.geometry = geometry; + }); + + return segments; + } } diff --git a/packages/adapters/src/bitArray.js b/packages/adapters/src/bitArray.js index e329e187d3..10b0f70f3c 100644 --- a/packages/adapters/src/bitArray.js +++ b/packages/adapters/src/bitArray.js @@ -8,6 +8,7 @@ const BitArray = { }; export { BitArray }; +export default BitArray; function getBytesForBinaryFrame(numPixels) { // Check whether the 1-bit pixels exactly fit into bytes diff --git a/packages/adapters/src/colors.js b/packages/adapters/src/colors.js index 2fc26cff13..4f6a8a24e6 100644 --- a/packages/adapters/src/colors.js +++ b/packages/adapters/src/colors.js @@ -146,3 +146,4 @@ class Colors { } export { Colors }; +export default Colors;