def test_enc_dataset_multiframe(self): """Test encoding a multiframe compressed dataset""" ds = self.ds_enc_mf assert ds.file_meta.TransferSyntaxUID.is_compressed out = self.enc.encode(ds, idx=0) uncompressed_len = get_expected_length(ds, 'bytes') assert uncompressed_len / ds.NumberOfFrames > len(out)
def get_pixeldata(ds: "Dataset") -> "np.ndarray": """Return a :class:`numpy.ndarray` of the pixel data. .. versionadded:: 2.1 Parameters ---------- ds : pydicom.dataset.Dataset The :class:`Dataset` containing an :dcm:`Image Pixel <part03/sect_C.7.6.3.html>` module and the *Pixel Data* to be converted. Returns ------- numpy.ndarray The contents of (7FE0,0010) *Pixel Data* as a 1D array. """ expected_len = get_expected_length(ds, 'pixels') frame_len = expected_len // getattr(ds, "NumberOfFrames", 1) # Empty destination array for our decoded pixel data arr = np.empty(expected_len, pixel_dtype(ds)) generate_offsets = range(0, expected_len, frame_len) for frame, offset in zip(generate_frames(ds, False), generate_offsets): arr[offset:offset + frame_len] = frame return arr
def test_enc_dataset(self): """Test encoding a compressed dataset""" ds = self.ds_enc assert ds.file_meta.TransferSyntaxUID.is_compressed out = self.enc.encode(ds) uncompressed_len = get_expected_length(ds, 'bytes') assert uncompressed_len > len(out)
def test_length_in_pixels(self, shape, bits, length): """Test get_expected_length(ds, unit='pixels').""" ds = Dataset() ds.Rows = shape[1] ds.Columns = shape[2] ds.BitsAllocated = bits if shape[0] != 0: ds.NumberOfFrames = shape[0] ds.SamplesPerPixel = shape[3] assert length[1] == get_expected_length(ds, unit='pixels')
def test_length_in_pixels(self, shape, bits, length): """Test get_expected_length(ds, unit='pixels').""" ds = Dataset() ds.Rows = shape[1] ds.Columns = shape[2] ds.BitsAllocated = bits if shape[0] != 0: ds.NumberOfFrames = shape[0] ds.SamplesPerPixel = shape[3] assert length[1] == get_expected_length(ds, unit='pixels')
def test_length_in_bytes(self, shape, bits, length): """Test get_expected_length(ds, unit='bytes').""" ds = Dataset() ds.PhotometricInterpretation = 'MONOCHROME2' ds.Rows = shape[1] ds.Columns = shape[2] ds.BitsAllocated = bits if shape[0] != 0: ds.NumberOfFrames = shape[0] ds.SamplesPerPixel = shape[3] assert length[0] == get_expected_length(ds, unit='bytes')
def test_encode(self): """Test encoding""" ds = dcmread(EXPL) assert 'PlanarConfiguration' not in ds expected = get_expected_length(ds, 'bytes') assert expected == len(ds.PixelData) ref = ds.pixel_array del ds.PixelData ds.compress(RLELossless, ref, encoding_plugin='pylibjpeg') assert expected > len(ds.PixelData) assert np.array_equal(ref, ds.pixel_array) assert id(ref) != id(ds.pixel_array)
def test_length_ybr_422(self, shape, bits, length): """Test get_expected_length for YBR_FULL_422.""" if shape[3] != 3 or bits == 1: return ds = Dataset() ds.PhotometricInterpretation = 'YBR_FULL_422' ds.Rows = shape[1] ds.Columns = shape[2] ds.BitsAllocated = bits if shape[0] != 0: ds.NumberOfFrames = shape[0] ds.SamplesPerPixel = shape[3] assert length[2] == get_expected_length(ds, unit='bytes')
def add_segments( self, pixel_array: np.ndarray, segment_descriptions: Sequence[SegmentDescription], plane_positions: Optional[Sequence[PlanePositionSequence]] = None ) -> Dataset: """Adds one or more segments to the segmentation image. Parameters ---------- pixel_array: numpy.ndarray Array of segmentation pixel data of boolean, unsigned integer or floating point data type representing a mask image. If `pixel_array` is a floating-point array or a binary array (containing only the values ``True`` and ``False`` or ``0`` and ``1``), the segment number used to encode the segment is taken from segment_descriptions. Otherwise, if pixel_array contains multiple integer values, each value is treated as a different segment whose segment number is that integer value. In this case, all segments found in the array must be described in `segment_descriptions`. Note that this is valid for both ``"BINARY"`` and ``"FRACTIONAL"`` segmentations. For ``"FRACTIONAL"`` segmentations, values either encode the probability of a given pixel belonging to a segment (if `fractional_type` is ``"PROBABILITY"``) or the extent to which a segment occupies the pixel (if `fractional_type` is ``"OCCUPANCY"``). When `pixel_array` has a floating point data type, only one segment can be encoded. Additional segments can be subsequently added to the `Segmentation` instance using the ``add_segments()`` method. If `pixel_array` represents a 3D image, the first dimension represents individual 2D planes and these planes must be ordered based on their position in the three-dimensional patient coordinate system (first along the X axis, second along the Y axis, and third along the Z axis). If `pixel_array` represents a tiled 2D image, the first dimension represents individual 2D tiles (for one channel and z-stack) and these tiles must be ordered based on their position in the tiled total pixel matrix (first along the row dimension and second along the column dimension, which are defined in the three-dimensional slide coordinate system by the direction cosines encoded by the *Image Orientation (Slide)* attribute). segment_descriptions: Sequence[highdicom.seg.content.SegmentDescription] Description of each segment encoded in `pixel_array`. In the case of pixel arrays with multiple integer values, the segment description with the corresponding segment number is used to describe each segment. plane_positions: Sequence[highdicom.content.PlanePositionSequence], optional Position of each plane in `pixel_array` relative to the three-dimensional patient or slide coordinate system. Note ---- Items of `segment_descriptions` must be sorted by segment number in ascending order. In case `segmentation_type` is ``"BINARY"``, the number of items per sequence must match the number of unique positive pixel values in `pixel_array`. In case `segmentation_type` is ``"FRACTIONAL"``, only one segment can be encoded by `pixel_array` and hence only one item is permitted per sequence. """ # noqa if pixel_array.ndim == 2: pixel_array = pixel_array[np.newaxis, ...] if pixel_array.ndim != 3: raise ValueError('Pixel array must be a 2D or 3D array.') if pixel_array.shape[1:3] != (self.Rows, self.Columns): raise ValueError( 'Pixel array representing segments has the wrong number of ' 'rows and columns.' ) described_segment_numbers = np.array([ int(item.SegmentNumber) for item in segment_descriptions ]) # Check that there are no duplicated segment numbers in the segment # descriptions if not (np.diff(described_segment_numbers) > 0).all(): raise ValueError( 'Segment descriptions must be sorted by segment number.' ) if pixel_array.dtype in (np.bool, np.uint8, np.uint16): segments_present = np.unique( pixel_array[pixel_array > 0].astype(np.uint16) ) # Special case where the mask is binary and there is a single # segment description. Allow the mark the positive segment with # the correct segment number if (np.array_equal(segments_present, np.array([1])) and len(segment_descriptions) == 1): pixel_array = pixel_array.astype(np.uint8) pixel_array *= described_segment_numbers.item() # Otherwise, the pixel values in the pixel array must all belong to # a described segment else: if not np.all( np.in1d(segments_present, described_segment_numbers) ): raise ValueError( 'Pixel array contains segments that lack descriptions.' ) elif (pixel_array.dtype == np.float): unique_values = np.unique(pixel_array) if np.min(unique_values) < 0.0 or np.max(unique_values) > 1.0: raise ValueError( 'Floating point pixel array values must be in the ' 'range [0, 1].' ) if len(segment_descriptions) != 1: raise ValueError( 'When providing a float-valued pixel array, provide only ' 'a single segment description' ) if self.SegmentationType == SegmentationTypeValues.BINARY.value: non_boolean_values = np.logical_and( unique_values > 0.0, unique_values < 1.0 ) if np.any(non_boolean_values): raise ValueError( 'Floating point pixel array values must be either ' '0.0 or 1.0 in case of BINARY segmentation type.' ) pixel_array = pixel_array.astype(np.bool) else: raise TypeError('Pixel array has an invalid data type.') # Check that the new segments do not already exist if len(set(described_segment_numbers) & self._segment_inventory) > 0: raise ValueError('Segment with given segment number already exists') # Set the optional tag value SegmentsOverlapValues to NO to indicate # that the segments do not overlap. We can know this for sure if it's # the first segment (or set of segments) to be added because they are # contained within a single pixel array. if len(self._segment_inventory) == 0: self.SegmentsOverlap = SegmentsOverlapValues.NO.value else: # If this is not the first set of segments to be added, we cannot # be sure whether there is overlap with the existing segments self.SegmentsOverlap = SegmentsOverlapValues.UNDEFINED.value src_img = self._source_images[0] is_multiframe = hasattr(src_img, 'NumberOfFrames') if self._coordinate_system == CoordinateSystemNames.SLIDE: if hasattr(src_img, 'PerFrameFunctionalGroupsSequence'): source_plane_positions = [ item.PlanePositionSlideSequence for item in src_img.PerFrameFunctionalGroupsSequence ] else: # If Dimension Organization Type is TILED_FULL, plane # positions are implicit and need to be computed. image_origin = src_img.TotalPixelMatrixOriginSequence[0] orientation = tuple( float(v) for v in src_img.ImageOrientationSlide ) tiles_per_column = int( np.ceil( src_img.TotalPixelMatrixRows / src_img.Rows ) ) tiles_per_row = int( np.ceil( src_img.TotalPixelMatrixColumns / src_img.Columns ) ) num_focal_planes = getattr( src_img, 'NumberOfFocalPlanes', 1 ) row_range = range(1, tiles_per_column + 1) column_range = range(1, tiles_per_row + 1) depth_range = range(1, num_focal_planes + 1) shared_fg = self.SharedFunctionalGroupsSequence[0] pixel_measures = shared_fg.PixelMeasuresSequence[0] pixel_spacing = tuple( float(v) for v in pixel_measures.PixelSpacing ) slice_thickness = getattr( pixel_measures, 'SliceThickness', 1.0 ) spacing_between_slices = getattr( pixel_measures, 'SpacingBetweenSlices', 1.0 ) source_plane_positions = [ compute_plane_positions_tiled_full( row_index=r, column_index=c, depth_index=d, x_offset=image_origin.XOffsetInSlideCoordinateSystem, y_offset=image_origin.YOffsetInSlideCoordinateSystem, z_offset=1.0, # TODO rows=self.Rows, columns=self.Columns, image_orientation=orientation, pixel_spacing=pixel_spacing, slice_thickness=slice_thickness, spacing_between_slices=spacing_between_slices ) for r, c, d in itertools.product( row_range, column_range, depth_range ) ] else: if is_multiframe: source_plane_positions = [ item.PlanePositionSequence for item in src_img.PerFrameFunctionalGroupsSequence ] else: source_plane_positions = [ PlanePositionSequence( coordinate_system=CoordinateSystemNames.PATIENT, image_position=src_img.ImagePositionPatient ) for src_img in self._source_images ] if plane_positions is None: plane_positions = source_plane_positions are_spatial_locations_preserved = ( all( plane_positions[i] == source_plane_positions[i] for i in range(len(plane_positions)) ) and self._plane_orientation == self._source_plane_orientation ) if pixel_array.shape[0] != len(plane_positions): raise ValueError( 'Number of pixel array planes does not match number of ' 'provided image positions.' ) # For each dimension other than the Referenced Segment Number, # obtain the value of the attribute that the Dimension Index Pointer # points to in the element of the Plane Position Sequence or # Plane Position Slide Sequence. # Per definition, this is the Image Position Patient attribute # in case of the patient coordinate system, or the # X/Y/Z Offset In Slide Coordinate System and the Column/Row # Position in Total Image Pixel Matrix attributes in case of the # the slide coordinate system. plane_position_values = np.array([ [ np.array(p[0][indexer.DimensionIndexPointer].value) for indexer in self.DimensionIndexSequence[1:] ] for p in plane_positions ]) # Planes need to be sorted according to the Dimension Index Value # based on the order of the items in the Dimension Index Sequence. # Here we construct an index vector that we can subsequently use to # sort planes before adding them to the Pixel Data element. _, plane_sort_index = np.unique( plane_position_values, axis=0, return_index=True ) # Get unique values of attributes in the Plane Position Sequence or # Plane Position Slide Sequence, which define the position of the plane # with respect to the three dimensional patient or slide coordinate # system, respectively. These can subsequently be used to look up the # relative position of a plane relative to the indexed dimension. dimension_position_values = [ np.unique(plane_position_values[:, index], axis=0) for index in range(plane_position_values.shape[1]) ] # When using binary segmentation type, the previous frames may have been # padded to be a multiple of 8. In this case, we need to decode the # pixel data, add the new pixels and then re-encode. This process # should be avoided if it is not necessary in order to improve # efficiency. if (self.SegmentationType == SegmentationTypeValues.BINARY.value and ((self.Rows * self.Columns * self.SamplesPerPixel) % 8) > 0): re_encode_pixel_data = True logger.warning( 'pixel data needs to be re-encoded for binary bitpacking - ' 'consider using FRACTIONAL instead of BINARY segmentation type' ) # If this is the first segment added, the pixel array is empty if hasattr(self, 'PixelData') and len(self.PixelData) > 0: full_pixel_array = self.pixel_array.flatten() else: full_pixel_array = np.array([], np.bool) else: re_encode_pixel_data = False # Before adding new pixel data, remove trailing null padding byte if len(self.PixelData) == get_expected_length(self) + 1: self.PixelData = self.PixelData[:-1] for i, segment_number in enumerate(described_segment_numbers): if pixel_array.dtype == np.float: # Floating-point numbers must be mapped to 8-bit integers in # the range [0, max_fractional_value]. planes = np.around( pixel_array * float(self.MaximumFractionalValue) ) planes = planes.astype(np.uint8) elif pixel_array.dtype in (np.uint8, np.uint16): # Labeled masks must be converted to binary masks. planes = np.zeros(pixel_array.shape, dtype=np.bool) planes[pixel_array == segment_number] = True elif pixel_array.dtype == np.bool: planes = pixel_array contained_plane_index = [] for j in plane_sort_index: if np.sum(planes[j]) == 0: logger.info( 'skip empty plane {} of segment #{}'.format( j, segment_number ) ) continue contained_plane_index.append(j) logger.info( 'add plane #{} for segment #{}'.format( j, segment_number ) ) pffp_item = Dataset() frame_content_item = Dataset() frame_content_item.DimensionIndexValues = [segment_number] # Look up the position of the plane relative to the indexed # dimension. try: if self._coordinate_system == CoordinateSystemNames.SLIDE: index_values = [ np.where( (dimension_position_values[idx] == pos) )[0][0] + 1 for idx, pos in enumerate(plane_position_values[j]) ] else: # In case of the patient coordinate system, the # value of the attribute the Dimension Index Sequence # points to (Image Position Patient) has a value # multiplicity greater than one. index_values = [ np.where( (dimension_position_values[idx] == pos).all( axis=1 ) )[0][0] + 1 for idx, pos in enumerate(plane_position_values[j]) ] except IndexError as error: raise IndexError( 'Could not determine position of plane #{} in ' 'three dimensional coordinate system based on ' 'dimension index values: {}'.format(j, error) ) frame_content_item.DimensionIndexValues.extend(index_values) pffp_item.FrameContentSequence = [frame_content_item] if self._coordinate_system == CoordinateSystemNames.SLIDE: pffp_item.PlanePositionSlideSequence = plane_positions[j] else: pffp_item.PlanePositionSequence = plane_positions[j] # Determining the source images that map to the frame is not # always trivial. Since DerivationImageSequence is a type 2 # attribute, we leave its value empty. pffp_item.DerivationImageSequence = [] if are_spatial_locations_preserved: derivation_image_item = Dataset() derivation_code = codes.cid7203.Segmentation derivation_image_item.DerivationCodeSequence = [ CodedConcept( derivation_code.value, derivation_code.scheme_designator, derivation_code.meaning, derivation_code.scheme_version ), ] derivation_src_img_item = Dataset() if len(plane_sort_index) > len(self._source_images): # A single multi-frame source image src_img_item = self.SourceImageSequence[0] # Frame numbers are one-based derivation_src_img_item.ReferencedFrameNumber = j + 1 else: # Multiple single-frame source images src_img_item = self.SourceImageSequence[j] derivation_src_img_item.ReferencedSOPClassUID = \ src_img_item.ReferencedSOPClassUID derivation_src_img_item.ReferencedSOPInstanceUID = \ src_img_item.ReferencedSOPInstanceUID purpose_code = \ codes.cid7202.SourceImageForImageProcessingOperation derivation_src_img_item.PurposeOfReferenceCodeSequence = [ CodedConcept( purpose_code.value, purpose_code.scheme_designator, purpose_code.meaning, purpose_code.scheme_version ), ] derivation_src_img_item.SpatialLocationsPreserved = 'YES' derivation_image_item.SourceImageSequence = [ derivation_src_img_item, ] pffp_item.DerivationImageSequence.append( derivation_image_item ) else: logger.warning('spatial locations not preserved') identification = Dataset() identification.ReferencedSegmentNumber = segment_number pffp_item.SegmentIdentificationSequence = [ identification, ] self.PerFrameFunctionalGroupsSequence.append(pffp_item) self.NumberOfFrames += 1 contained_plane_index = np.array(contained_plane_index, dtype=int) if re_encode_pixel_data: full_pixel_array = np.concatenate([ full_pixel_array, planes[contained_plane_index].flatten() ]) else: self.PixelData += self._encode_pixels( planes[contained_plane_index] ) # In case of a tiled Total Pixel Matrix pixel data for the same # segment may be added. if segment_number not in self._segment_inventory: self.SegmentSequence.append(segment_descriptions[i]) self._segment_inventory.add(segment_number) # Re-encode the whole pixel array at once if necessary if re_encode_pixel_data: self.PixelData = self._encode_pixels(full_pixel_array) # Add back the null trailing byte if required if len(self.PixelData) % 2 == 1: self.PixelData += b'0'
def get_pixeldata(ds): """Return a :class:`numpy.ndarray` of the pixel data. Parameters ---------- ds : pydicom.dataset.Dataset The :class:`Dataset` containing an Image Pixel module and the *Pixel Data* to be converted. Returns ------- numpy.ndarray The contents of (7FE0,0010) *Pixel Data* as a 1D array. Raises ------ AttributeError If `ds` is missing a required element. NotImplementedError If `ds` contains pixel data in an unsupported format. ValueError If the actual length of the pixel data doesn't match the expected length. """ tsyntax = ds.file_meta.TransferSyntaxUID # The check of transfer syntax must be first if tsyntax not in SUPPORTED_TRANSFER_SYNTAXES: raise NotImplementedError( "Unable to convert the pixel data as there are no pylibjpeg " "plugins available to decode pixel data encoded using '{}'".format( tsyntax.name)) # Check required elements required_elements = [ 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', 'SamplesPerPixel', 'PhotometricInterpretation', 'PixelData', ] missing = [elem for elem in required_elements if elem not in ds] if missing: raise AttributeError( "Unable to convert the pixel data as the following required " "elements are missing from the dataset: " + ", ".join(missing)) # Calculate the expected length of the pixel data (in bytes) # Note: this does NOT include the trailing null byte for odd length data expected_len = get_expected_length(ds) if ds.PhotometricInterpretation == 'YBR_FULL_422': # JPEG Transfer Syntaxes # Plugin should have already resampled the pixel data # see PS3.3 C.7.6.3.1.2 expected_len = expected_len // 2 * 3 p_interp = ds.PhotometricInterpretation # How long each frame is in bytes nr_frames = getattr(ds, 'NumberOfFrames', 1) frame_len = expected_len // nr_frames # The decoded data will be placed here arr = np.empty(expected_len, np.uint8) decoder = _DECODERS[tsyntax] LOGGER.debug("Decoding {} Pixel Data using {}".format(tsyntax, decoder)) # Generators for the encoded JPEG image frame(s) and insertion offsets generate_frames = generate_pixel_data_frame(ds.PixelData, nr_frames) generate_offsets = range(0, expected_len, frame_len) pixel_module = ds.group_dataset(0x0028) for frame, offset in zip(generate_frames, generate_offsets): # Encoded JPEG data to be sent to the decoder arr[offset:offset + frame_len] = decoder(frame, pixel_module) if tsyntax in [JPEG2000, JPEG2000Lossless] and APPLY_J2K_CORRECTIONS: j2k_parameters = get_j2k_parameters(frame) if j2k_parameters: shift = ds.BitsAllocated - j2k_parameters['precision'] if (shift and not j2k_parameters['is_signed'] and bool(ds.PixelRepresentation)): # Correct for a mismatch between J2K and Pixel Representation # by converting unsigned data to signed (2's complement) pixel_module.PixelRepresentation = 0 # This probably isn't very efficient arr = arr.view(pixel_dtype(pixel_module)) np.left_shift(arr, shift, out=arr) arr = arr.astype(pixel_dtype(ds)) return np.right_shift(arr, shift) return arr.view(pixel_dtype(ds))
def get_pixeldata(ds: "Dataset") -> "numpy.ndarray": """Use the GDCM package to decode *Pixel Data*. Returns ------- numpy.ndarray A correctly sized (but not shaped) array of the entire data volume Raises ------ ImportError If the required packages are not available. TypeError If the image could not be read by GDCM or if the *Pixel Data* type is unsupported. AttributeError If the decoded amount of data does not match the expected amount. """ if not HAVE_GDCM: raise ImportError("The GDCM handler requires both gdcm and numpy") if HAVE_GDCM_IN_MEMORY_SUPPORT: gdcm_data_element = create_data_element(ds) gdcm_image = create_image(ds, gdcm_data_element) else: gdcm_image_reader = create_image_reader(ds) if not gdcm_image_reader.Read(): raise TypeError("GDCM could not read DICOM image") gdcm_image = gdcm_image_reader.GetImage() # GDCM returns char* as type str. Python 3 decodes this to # unicode strings by default. # The SWIG docs mention that they always decode byte streams # as utf-8 strings for Python 3, with the `surrogateescape` # error handler configured. # Therefore, we can encode them back to their original bytearray # representation on Python 3 by using the same parameters. pixel_bytearray = gdcm_image.GetBuffer().encode("utf-8", "surrogateescape") # Here we need to be careful because in some cases, GDCM reads a # buffer that is too large, so we need to make sure we only include # the first n_rows * n_columns * dtype_size bytes. expected_length_bytes = get_expected_length(ds) if ds.PhotometricInterpretation == 'YBR_FULL_422': # GDCM has already resampled the pixel data, see PS3.3 C.7.6.3.1.2 expected_length_bytes = expected_length_bytes // 2 * 3 if len(pixel_bytearray) > expected_length_bytes: # We make sure that all the bytes after are in fact zeros padding = pixel_bytearray[expected_length_bytes:] if numpy.any(numpy.frombuffer(padding, numpy.byte)): pixel_bytearray = pixel_bytearray[:expected_length_bytes] else: # We revert to the old behavior which should then result # in a Numpy error later on. pass numpy_dtype = pixel_dtype(ds) arr = numpy.frombuffer(pixel_bytearray, dtype=numpy_dtype) expected_length_pixels = get_expected_length(ds, 'pixels') if arr.size != expected_length_pixels: raise AttributeError( f"Amount of pixel data {arr.size} does not match the " f"expected data {expected_length_pixels}" ) file_meta: "FileMetaDataset" = ds.file_meta # type: ignore[has-type] tsyntax = cast(UID, file_meta.TransferSyntaxUID) if ( config.APPLY_J2K_CORRECTIONS and tsyntax in [JPEG2000, JPEG2000Lossless] ): nr_frames = getattr(ds, 'NumberOfFrames', 1) codestream = next(generate_pixel_data(ds.PixelData, nr_frames))[0] params = get_j2k_parameters(codestream) j2k_precision = cast( int, params.setdefault("precision", ds.BitsStored) ) j2k_sign = params.setdefault("is_signed", None) if not j2k_sign and ds.PixelRepresentation == 1: # Convert unsigned J2K data to 2's complement shift = cast(int, ds.BitsAllocated) - j2k_precision pixel_module = ds.group_dataset(0x0028) pixel_module.PixelRepresentation = 0 dtype = pixel_dtype(pixel_module) arr = (arr.astype(dtype) << shift).astype(numpy_dtype) >> shift if should_change_PhotometricInterpretation_to_RGB(ds): ds.PhotometricInterpretation = "RGB" return arr.copy()
def get_pixeldata(ds, read_only=False): """Return an ndarray of the Pixel Data. Parameters ---------- ds : dataset.Dataset The DICOM dataset containing an Image Pixel module and the Pixel Data to be converted. read_only : bool, optional If False (default) then returns a writeable array that no longer uses the original memory. If True and the value of (0028,0100) *Bits Allocated* > 1 then returns a read-only array that uses the original memory buffer of the pixel data. If *Bits Allocated* = 1 then always returns a writeable array. Returns ------- np.ndarray The contents of the Pixel Data element (7FE0,0010) as a 1D array. Raises ------ AttributeError If the dataset is missing a required element. NotImplementedError If the dataset contains pixel data in an unsupported format. ValueError If the actual length of the pixel data doesn't match the expected length. """ transfer_syntax = ds.file_meta.TransferSyntaxUID # The check of transfer syntax must be first if transfer_syntax not in SUPPORTED_TRANSFER_SYNTAXES: raise NotImplementedError( "Unable to convert the pixel data as the transfer syntax " "is not supported by the numpy pixel data handler.") # Check required elements required_elements = [ 'PixelData', 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', 'SamplesPerPixel' ] missing = [elem for elem in required_elements if elem not in ds] if missing: raise AttributeError( "Unable to convert the pixel data as the following required " "elements are missing from the dataset: " + ", ".join(missing)) # Calculate the expected length of the pixel data (in bytes) # Note: this does NOT include the trailing null byte for odd length data expected_len = get_expected_length(ds) # Check that the actual length of the pixel data is as expected actual_length = len(ds.PixelData) # Correct for the trailing NULL byte padding for odd length data if actual_length != (expected_len + expected_len % 2): raise ValueError( "The length of the pixel data in the dataset doesn't match the " "expected amount ({0} vs. {1} bytes). The dataset may be " "corrupted or there may be an issue with the pixel data handler.". format(actual_length, expected_len + expected_len % 2)) # Unpack the pixel data into a 1D ndarray if ds.BitsAllocated == 1: # Skip any trailing padding bits nr_pixels = get_expected_length(ds, unit='pixels') arr = unpack_bits(ds.PixelData)[:nr_pixels] else: # Skip the trailing padding byte if present arr = np.frombuffer(ds.PixelData[:expected_len], dtype=pixel_dtype(ds)) if should_change_PhotometricInterpretation_to_RGB(ds): ds.PhotometricInterpretation = "RGB" if not read_only and ds.BitsAllocated > 1: return arr.copy() return arr
def get_pixeldata(dicom_dataset): """ Use the GDCM package to decode the PixelData attribute Returns ------- numpy.ndarray A correctly sized (but not shaped) numpy array of the entire data volume Raises ------ ImportError if the required packages are not available TypeError if the image could not be read by GDCM if the pixel data type is unsupported AttributeError if the decoded amount of data does not match the expected amount """ if not HAVE_GDCM: msg = ("GDCM requires both the gdcm package and numpy " "and one or more could not be imported") raise ImportError(msg) if HAVE_GDCM_IN_MEMORY_SUPPORT: gdcm_data_element = create_data_element(dicom_dataset) gdcm_image = create_image(dicom_dataset, gdcm_data_element) else: gdcm_image_reader = create_image_reader(dicom_dataset.filename) if not gdcm_image_reader.Read(): raise TypeError("GDCM could not read DICOM image") gdcm_image = gdcm_image_reader.GetImage() # GDCM returns char* as type str. Under Python 2 `str` are # byte arrays by default. Python 3 decodes this to # unicode strings by default. # The SWIG docs mention that they always decode byte streams # as utf-8 strings for Python 3, with the `surrogateescape` # error handler configured. # Therefore, we can encode them back to their original bytearray # representation on Python 3 by using the same parameters. if compat.in_py2: pixel_bytearray = gdcm_image.GetBuffer() else: pixel_bytearray = gdcm_image.GetBuffer().encode( "utf-8", "surrogateescape") # Here we need to be careful because in some cases, GDCM reads a # buffer that is too large, so we need to make sure we only include # the first n_rows * n_columns * dtype_size bytes. expected_length_bytes = get_expected_length(dicom_dataset) if len(pixel_bytearray) > expected_length_bytes: # We make sure that all the bytes after are in fact zeros padding = pixel_bytearray[expected_length_bytes:] if numpy.any(numpy.frombuffer(padding, numpy.byte)): pixel_bytearray = pixel_bytearray[:expected_length_bytes] else: # We revert to the old behavior which should then result # in a Numpy error later on. pass numpy_dtype = pixel_dtype(dicom_dataset) pixel_array = numpy.frombuffer(pixel_bytearray, dtype=numpy_dtype) expected_length_pixels = get_expected_length(dicom_dataset, 'pixels') if pixel_array.size != expected_length_pixels: raise AttributeError("Amount of pixel data %d does " "not match the expected data %d" % (pixel_array.size, expected_length_pixels)) if should_change_PhotometricInterpretation_to_RGB(dicom_dataset): dicom_dataset.PhotometricInterpretation = "RGB" return pixel_array.copy()
def clean(self, fix_interpretation=True, pixel_data_attribute="PixelData"): """ take a dicom image and a list of pixel coordinates, and return a cleaned file (if output file is specified) or simply plot the cleaned result (if no file is specified) Parameters ========== add_padding: add N=margin pixels of padding margin: pixels of padding to add, if add_padding True fix_interpretation: fix the photometric interpretation if found off """ if not self.results: bot.warning("Use %s.detect() to find coordinates first." % self) else: bot.info("Scrubbing %s." % self.dicom_file) # Load in dicom file, and image data dicom = read_file(self.dicom_file, force=True) pixel_data = getattr(dicom, pixel_data_attribute) # Get expected and actual length of the pixel data (bytes, expected does not include trailing null byte) expected_length = get_expected_length(dicom) actual_length = len(pixel_data) padded_expected_length = expected_length + expected_length % 2 full_length = expected_length / 2 * 3 # upsampled data is a third larger full_length += ( 1 if full_length % 2 else 0 ) # trailing padding byte if even length # If we have YBR_FULL_2, must be RGB to obtain pixel data if ( not dicom.file_meta.TransferSyntaxUID.is_compressed and dicom.PhotometricInterpretation == "YBR_FULL_422" and fix_interpretation and actual_length >= full_length ): bot.warning( "Updating dicom.PhotometricInterpretation to RGB, set fix_interpretation to False to skip." ) photometric_original = dicom.PhotometricInterpretation dicom.PhotometricInterpretation = "RGB" self.original = dicom.pixel_array dicom.PhotometricInterpretation = photometric_original else: self.original = dicom.pixel_array # Compile coordinates from result, generate list of tuples with coordinate and value # keepcoordinates == 1 (included in mask) and coordinates == 0 (remove). coordinates = [] for item in self.results["results"]: # We iterate through coordinates in order specified in file for coordinate_set in item.get("coordinates", []): # Each is a list with [value, coordinate] mask_value, new_coordinates = coordinate_set if not isinstance(new_coordinates, list): new_coordinates = [new_coordinates] for new_coordinate in new_coordinates: # Case 1: an "all" indicates applying to entire image if new_coordinate.lower() == "all": # no frames, just X, Y if len(self.original.shape) == 2: # minr, minc, maxr, maxc = [0, 0, Y, X] new_coordinate = [ 0, 0, self.original.shape[1], self.original.shape[0], ] # (frames, X, Y, channel) OR (frames, X,Y) if len(self.original.shape) >= 3: new_coordinate = [ 0, 0, self.original.shape[2], self.original.shape[1], ] else: new_coordinate = [int(x) for x in new_coordinate.split(",")] coordinates.append( (mask_value, new_coordinate) ) # [(1, [1,2,3,4]),...(0, [1,2,3,4])] # Instead of writing directly to data, create a mask of 1s (start keeping all) # For 4D, (frames, X, Y, channel) if len(self.original.shape) == 4: mask = numpy.ones(self.original.shape[1:3], dtype=numpy.uint8) # For 2D, (X, Y) or 3D (X, Y channel) else: mask = numpy.ones(self.original.shape[0:2], dtype=numpy.uint8) # Here we apply the coordinates to the mask, 1==keep, 0==clean for coordinate_value, coordinate in coordinates: minr, minc, maxr, maxc = coordinate # Update the mask: values set to 0 to be black mask[minc:maxc, minr:maxr] = coordinate_value # Now apply finished mask to the data if len(self.original.shape) == 4: # np.tile does the copying and stacking of masks into the channel dim to produce 3D masks # transposition to convert tile output (channel, X, Y) into (X, Y, channel) # see: https://github.com/nquach/anonymize/blob/master/anonymize.py#L154 channel3mask = numpy.transpose(numpy.tile(mask, (3, 1, 1)), (1, 2, 0)) # use numpy.tile to copy and stack the 3D masks into 4D array to apply to 4D pixel data # tile converts (X, Y, channels) -> (frames, X, Y, channels), presumed ordering for 4D pixel data final_mask = numpy.tile(channel3mask, (self.original.shape[0], 1, 1, 1)) # apply final 4D mask to 4D pixel data self.cleaned = final_mask * self.original # greyscale: no need to stack into the channel dim since it doesnt exist elif len(self.original.shape) == 3: # numpy.tile converts (X, Y) -> (frames, X, Y) final_mask = numpy.tile(mask, (self.original.shape[0], 1, 1)) self.cleaned = final_mask * self.original elif len(self.original.shape) == 2: self.cleaned = mask * self.original else: bot.warning( "Pixel array dimension %s is not recognized." % (str(self.original.shape)) )
def add_segments( self, pixel_array: np.ndarray, segment_descriptions: Sequence[SegmentDescription], plane_positions: Optional[Sequence[PlanePositionSequence]] = None ) -> None: """Adds one or more segments to the segmentation image. Parameters ---------- pixel_array: numpy.ndarray Array of segmentation pixel data of boolean, unsigned integer or floating point data type representing a mask image. If `pixel_array` is a floating-point array or a binary array (containing only the values ``True`` and ``False`` or ``0`` and ``1``), the segment number used to encode the segment is taken from `segment_descriptions`. Otherwise, if `pixel_array` contains multiple integer values, each value is treated as a different segment whose segment number is that integer value. In this case, all segments found in the array must be described in `segment_descriptions`. Note that this is valid for both ``"BINARY"`` and ``"FRACTIONAL"`` segmentations. For ``"FRACTIONAL"`` segmentations, values either encode the probability of a given pixel belonging to a segment (if `fractional_type` is ``"PROBABILITY"``) or the extent to which a segment occupies the pixel (if `fractional_type` is ``"OCCUPANCY"``). When `pixel_array` has a floating point data type, only one segment can be encoded. Additional segments can be subsequently added to the `Segmentation` instance using the ``add_segments()`` method. If `pixel_array` represents a 3D image, the first dimension represents individual 2D planes and these planes must be ordered based on their position in the three-dimensional patient coordinate system (first along the X axis, second along the Y axis, and third along the Z axis). If `pixel_array` represents a tiled 2D image, the first dimension represents individual 2D tiles (for one channel and z-stack) and these tiles must be ordered based on their position in the tiled total pixel matrix (first along the row dimension and second along the column dimension, which are defined in the three-dimensional slide coordinate system by the direction cosines encoded by the *Image Orientation (Slide)* attribute). segment_descriptions: Sequence[highdicom.seg.content.SegmentDescription] Description of each segment encoded in `pixel_array`. In the case of pixel arrays with multiple integer values, the segment description with the corresponding segment number is used to describe each segment. plane_positions: Sequence[highdicom.content.PlanePositionSequence], optional Position of each plane in `pixel_array` relative to the three-dimensional patient or slide coordinate system. Raises ------ ValueError When - The pixel array is not 2D or 3D numpy array - The shape of the pixel array does not match the source images - The numbering of the segment descriptions is not monotonically increasing by 1 - The numbering of the segment descriptions does not begin at 1 (for the first segments added to the instance) or at one greater than the last added segment (for subsequent segments) - One or more segments already exist within the segmentation instance - The segmentation is binary and the pixel array contains integer values that belong to segments that are not described in the segment descriptions - The segmentation is binary and pixel array has floating point values not equal to 0.0 or 1.0 - The segmentation is fractional and pixel array has floating point values outside the range 0.0 to 1.0 - The segmentation is fractional and pixel array has floating point values outside the range 0.0 to 1.0 - Plane positions are provided but the length of the array does not match the number of frames in the pixel array TypeError When the dtype of the pixel array is invalid Note ---- Segments must be sorted by segment number in ascending order and increase by 1. Additionally, the first segment description must have a segment number one greater than the segment number of the last segment added to the segmentation, or 1 if this is the first segment added. In case `segmentation_type` is ``"BINARY"``, the number of items in `segment_descriptions` must be greater than or equal to the number of unique positive pixel values in `pixel_array`. It is possible for some segments described in `segment_descriptions` not to appear in the `pixel_array`. In case `segmentation_type` is ``"FRACTIONAL"``, only one segment can be encoded by `pixel_array` and hence only one item is permitted in `segment_descriptions`. """ # noqa if pixel_array.ndim == 2: pixel_array = pixel_array[np.newaxis, ...] if pixel_array.ndim != 3: raise ValueError('Pixel array must be a 2D or 3D array.') if pixel_array.shape[1:3] != (self.Rows, self.Columns): raise ValueError( 'Pixel array representing segments has the wrong number of ' 'rows and columns.') # Determine the expected starting number of the segments to ensure # they will be continuous with existing segments if self._segment_inventory: # Next segment number is one greater than the largest existing # segment number seg_num_start = max(self._segment_inventory) + 1 else: # No existing segments so start at 1 seg_num_start = 1 # Check segment numbers # Check the existing descriptions described_segment_numbers = np.array( [int(item.SegmentNumber) for item in segment_descriptions]) # Check segment numbers in the segment descriptions are # monotonically increasing by 1 if not (np.diff(described_segment_numbers) == 1).all(): raise ValueError( 'Segment descriptions must be sorted by segment number ' 'and monotonically increasing by 1.') if described_segment_numbers[0] != seg_num_start: if seg_num_start == 1: msg = ('Segment descriptions should be numbered starting ' f'from 1. Found {described_segment_numbers[0]}. ') else: msg = ('Segment descriptions should be numbered to ' 'continue from existing segments. Expected the first ' f'segment to be numbered {seg_num_start} but found ' f'{described_segment_numbers[0]}.') raise ValueError(msg) if pixel_array.dtype in (np.bool_, np.uint8, np.uint16): segments_present = np.unique(pixel_array[pixel_array > 0].astype( np.uint16)) # Special case where the mask is binary and there is a single # segment description. Mark the positive segment with # the correct segment number if (np.array_equal(segments_present, np.array([1])) and len(segment_descriptions) == 1): pixel_array = pixel_array.astype(np.uint8) pixel_array *= described_segment_numbers.item() # Otherwise, the pixel values in the pixel array must all belong to # a described segment else: if not np.all( np.in1d(segments_present, described_segment_numbers)): raise ValueError('Pixel array contains segments that lack ' 'descriptions.') elif (pixel_array.dtype in (np.float_, np.float32, np.float64)): unique_values = np.unique(pixel_array) if np.min(unique_values) < 0.0 or np.max(unique_values) > 1.0: raise ValueError( 'Floating point pixel array values must be in the ' 'range [0, 1].') if len(segment_descriptions) != 1: raise ValueError( 'When providing a float-valued pixel array, provide only ' 'a single segment description') if self.SegmentationType == SegmentationTypeValues.BINARY.value: non_boolean_values = np.logical_and(unique_values > 0.0, unique_values < 1.0) if np.any(non_boolean_values): raise ValueError( 'Floating point pixel array values must be either ' '0.0 or 1.0 in case of BINARY segmentation type.') pixel_array = pixel_array.astype(np.bool_) else: raise TypeError('Pixel array has an invalid data type.') # Check that the new segments do not already exist if len(set(described_segment_numbers) & self._segment_inventory) > 0: raise ValueError( 'Segment with given segment number already exists') # Set the optional tag value SegmentsOverlapValues to NO to indicate # that the segments do not overlap. We can know this for sure if it's # the first segment (or set of segments) to be added because they are # contained within a single pixel array. if len(self._segment_inventory) == 0: self.SegmentsOverlap = SegmentsOverlapValues.NO.value else: # If this is not the first set of segments to be added, we cannot # be sure whether there is overlap with the existing segments self.SegmentsOverlap = SegmentsOverlapValues.UNDEFINED.value src_image = self._source_images[0] is_multiframe = hasattr(src_image, 'NumberOfFrames') if is_multiframe: source_plane_positions = \ self.DimensionIndexSequence.get_plane_positions_of_image( src_image ) else: source_plane_positions = \ self.DimensionIndexSequence.get_plane_positions_of_series( self._source_images ) if plane_positions is None: if pixel_array.shape[0] != len(source_plane_positions): if is_multiframe: raise ValueError( 'Number of frames in pixel array does not match number ' ' of frames in source image.') else: raise ValueError( 'Number of frames in pixel array does not match number ' 'of source images.') plane_positions = source_plane_positions else: if pixel_array.shape[0] != len(plane_positions): raise ValueError( 'Number of pixel array planes does not match number of ' 'provided plane positions.') plane_position_values, plane_sort_index = \ self.DimensionIndexSequence.get_index_values(plane_positions) are_spatial_locations_preserved = ( all(plane_positions[i] == source_plane_positions[i] for i in range(len(plane_positions))) and self._plane_orientation == self._source_plane_orientation) # Get unique values of attributes in the Plane Position Sequence or # Plane Position Slide Sequence, which define the position of the plane # with respect to the three dimensional patient or slide coordinate # system, respectively. These can subsequently be used to look up the # relative position of a plane relative to the indexed dimension. dimension_position_values = [ np.unique(plane_position_values[:, index], axis=0) for index in range(plane_position_values.shape[1]) ] # In certain circumstances, we can add new pixels without unpacking the # previous ones, which is more efficient. This can be done when using # non-encapsulated transfer syntaxes when there is no padding required # for each frame to be a multiple of 8 bits. framewise_encoding = False is_encaps = self.file_meta.TransferSyntaxUID.is_encapsulated if not is_encaps: if self.SegmentationType == SegmentationTypeValues.FRACTIONAL.value: framewise_encoding = True elif self.SegmentationType == SegmentationTypeValues.BINARY.value: # Framewise encoding can only be used if there is no padding # This requires the number of pixels in each frame to be # multiple of 8 if (self.Rows * self.Columns * self.SamplesPerPixel) % 8 == 0: framewise_encoding = True else: logger.warning( 'pixel data needs to be re-encoded for binary ' 'bitpacking - consider using FRACTIONAL instead of ' 'BINARY segmentation type') if framewise_encoding: # Before adding new pixel data, remove trailing null padding byte if len(self.PixelData) == get_expected_length(self) + 1: self.PixelData = self.PixelData[:-1] else: # In the case of encapsulated transfer syntaxes, we will accumulate # a list of encoded frames to re-encapsulate at the end if is_encaps: if hasattr(self, 'PixelData') and len(self.PixelData) > 0: # Undo the encapsulation but not the encoding within each # frame full_frames_list = decode_data_sequence(self.PixelData) else: full_frames_list = [] else: if hasattr(self, 'PixelData') and len(self.PixelData) > 0: full_pixel_array = self.pixel_array.flatten() else: full_pixel_array = np.array([], np.bool_) for i, segment_number in enumerate(described_segment_numbers): if pixel_array.dtype in (np.float_, np.float32, np.float64): # Floating-point numbers must be mapped to 8-bit integers in # the range [0, max_fractional_value]. planes = np.around(pixel_array * float(self.MaximumFractionalValue)) planes = planes.astype(np.uint8) elif pixel_array.dtype in (np.uint8, np.uint16): # Labeled masks must be converted to binary masks. planes = np.zeros(pixel_array.shape, dtype=np.bool_) planes[pixel_array == segment_number] = True elif pixel_array.dtype == np.bool_: planes = pixel_array else: raise TypeError('Pixel array has an invalid data type.') contained_plane_index = [] for j in plane_sort_index: if np.sum(planes[j]) == 0: logger.info('skip empty plane {} of segment #{}'.format( j, segment_number)) continue contained_plane_index.append(j) logger.info('add plane #{} for segment #{}'.format( j, segment_number)) pffp_item = Dataset() frame_content_item = Dataset() frame_content_item.DimensionIndexValues = [segment_number] # Look up the position of the plane relative to the indexed # dimension. try: if self._coordinate_system == CoordinateSystemNames.SLIDE: index_values = [ np.where((dimension_position_values[idx] == pos))[0][0] + 1 for idx, pos in enumerate(plane_position_values[j]) ] else: # In case of the patient coordinate system, the # value of the attribute the Dimension Index Sequence # points to (Image Position Patient) has a value # multiplicity greater than one. index_values = [ np.where((dimension_position_values[idx] == pos).all(axis=1))[0][0] + 1 for idx, pos in enumerate(plane_position_values[j]) ] except IndexError as error: raise IndexError( 'Could not determine position of plane #{} in ' 'three dimensional coordinate system based on ' 'dimension index values: {}'.format(j, error)) frame_content_item.DimensionIndexValues.extend(index_values) pffp_item.FrameContentSequence = [frame_content_item] if self._coordinate_system == CoordinateSystemNames.SLIDE: pffp_item.PlanePositionSlideSequence = plane_positions[j] else: pffp_item.PlanePositionSequence = plane_positions[j] # Determining the source images that map to the frame is not # always trivial. Since DerivationImageSequence is a type 2 # attribute, we leave its value empty. pffp_item.DerivationImageSequence = [] if are_spatial_locations_preserved: derivation_image_item = Dataset() derivation_code = codes.cid7203.Segmentation derivation_image_item.DerivationCodeSequence = [ CodedConcept(derivation_code.value, derivation_code.scheme_designator, derivation_code.meaning, derivation_code.scheme_version), ] derivation_src_img_item = Dataset() if len(plane_sort_index) > len(self._source_images): # A single multi-frame source image src_img_item = self.SourceImageSequence[0] # Frame numbers are one-based derivation_src_img_item.ReferencedFrameNumber = j + 1 else: # Multiple single-frame source images src_img_item = self.SourceImageSequence[j] derivation_src_img_item.ReferencedSOPClassUID = \ src_img_item.ReferencedSOPClassUID derivation_src_img_item.ReferencedSOPInstanceUID = \ src_img_item.ReferencedSOPInstanceUID purpose_code = \ codes.cid7202.SourceImageForImageProcessingOperation derivation_src_img_item.PurposeOfReferenceCodeSequence = [ CodedConcept(purpose_code.value, purpose_code.scheme_designator, purpose_code.meaning, purpose_code.scheme_version), ] derivation_src_img_item.SpatialLocationsPreserved = 'YES' derivation_image_item.SourceImageSequence = [ derivation_src_img_item, ] pffp_item.DerivationImageSequence.append( derivation_image_item) else: logger.warning('spatial locations not preserved') identification = Dataset() identification.ReferencedSegmentNumber = segment_number pffp_item.SegmentIdentificationSequence = [ identification, ] self.PerFrameFunctionalGroupsSequence.append(pffp_item) self.NumberOfFrames += 1 if framewise_encoding: # Straightforward concatenation of the binary data self.PixelData += self._encode_pixels( planes[contained_plane_index]) else: if is_encaps: # Encode this frame and add to the list for encapsulation # at the end for f in contained_plane_index: full_frames_list.append(self._encode_pixels(planes[f])) else: # Concatenate the 1D array for re-encoding at the end full_pixel_array = np.concatenate([ full_pixel_array, planes[contained_plane_index].flatten() ]) # In case of a tiled Total Pixel Matrix pixel data for the same # segment may be added. if segment_number not in self._segment_inventory: self.SegmentSequence.append(segment_descriptions[i]) self._segment_inventory.add(segment_number) # Re-encode the whole pixel array at once if necessary if not framewise_encoding: if is_encaps: self.PixelData = encapsulate(full_frames_list) else: self.PixelData = self._encode_pixels(full_pixel_array) # Add back the null trailing byte if required if len(self.PixelData) % 2 == 1: self.PixelData += b'0'
def get_pixeldata(ds): """Return a :class:`numpy.ndarray` of the pixel data. Parameters ---------- ds : Dataset The :class:`Dataset` containing an Image Pixel, Floating Point Image Pixel or Double Floating Point Image Pixel module and the *Pixel Data*, *Float Pixel Data* or *Double Float Pixel Data* to be converted. If (0028,0004) *Photometric Interpretation* is `'YBR_FULL_422'` then the pixel data will be resampled to 3 channel data as per Part 3, :dcm:`Annex C.7.6.3.1.2 <part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2>` of the DICOM Standard. Returns ------- np.ndarray The contents of (7FE0,0010) *Pixel Data* as a 1D array. """ tsyntax = ds.file_meta.TransferSyntaxUID # The check of transfer syntax must be first if tsyntax not in SUPPORTED_TRANSFER_SYNTAXES: raise NotImplementedError( "Unable to convert the pixel data as the transfer syntax " "is not supported by the pylibjpeg pixel data handler." ) # Check required elements required_elements = [ 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', 'SamplesPerPixel', 'PhotometricInterpretation', 'PixelData', ] missing = [elem for elem in required_elements if elem not in ds] if missing: raise AttributeError( "Unable to convert the pixel data as the following required " "elements are missing from the dataset: " + ", ".join(missing) ) # Calculate the expected length of the pixel data (in bytes) # Note: this does NOT include the trailing null byte for odd length data expected_len = get_expected_length(ds) if ds.PhotometricInterpretation == 'YBR_FULL_422': # libjpeg has already resampled the pixel data, see PS3.3 C.7.6.3.1.2 expected_len = expected_len // 2 * 3 p_interp = ds.PhotometricInterpretation # How long each frame is in bytes nr_frames = getattr(ds, 'NumberOfFrames', 1) frame_len = expected_len // nr_frames # The decoded data will be placed here arr = np.empty(expected_len, np.uint8) # Generators for the encoded JPG image frame(s) and insertion offsets generate_frames = generate_pixel_data_frame(ds.PixelData, nr_frames) generate_offsets = range(0, expected_len, frame_len) for frame, offset in zip(generate_frames, generate_offsets): # Encoded JPG data to be sent to the decoder frame = np.frombuffer(frame, np.uint8) arr[offset:offset + frame_len] = decode_pixel_data( frame, ds.group_dataset(0x0028) ) return arr.view(pixel_dtype(ds))
def get_pixeldata(ds, read_only=False): """Return an ndarray of the Pixel Data. Parameters ---------- ds : dataset.Dataset The DICOM dataset containing an Image Pixel module and the Pixel Data to be converted. read_only : bool, optional If False (default) then returns a writeable array that no longer uses the original memory. If True and the value of (0028,0100) *Bits Allocated* > 1 then returns a read-only array that uses the original memory buffer of the pixel data. If *Bits Allocated* = 1 then always returns a writeable array. Returns ------- np.ndarray The contents of the Pixel Data element (7FE0,0010) as a 1D array. Raises ------ AttributeError If the dataset is missing a required element. NotImplementedError If the dataset contains pixel data in an unsupported format. ValueError If the actual length of the pixel data doesn't match the expected length. """ transfer_syntax = ds.file_meta.TransferSyntaxUID # The check of transfer syntax must be first if transfer_syntax not in SUPPORTED_TRANSFER_SYNTAXES: raise NotImplementedError( "Unable to convert the pixel data as the transfer syntax " "is not supported by the numpy pixel data handler." ) # Check required elements required_elements = ['PixelData', 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', 'SamplesPerPixel'] missing = [elem for elem in required_elements if elem not in ds] if missing: raise AttributeError( "Unable to convert the pixel data as the following required " "elements are missing from the dataset: " + ", ".join(missing) ) # Calculate the expected length of the pixel data (in bytes) # Note: this does NOT include the trailing null byte for odd length data expected_len = get_expected_length(ds) # Check that the actual length of the pixel data is as expected actual_length = len(ds.PixelData) # Correct for the trailing NULL byte padding for odd length data padded_expected_len = expected_len + expected_len % 2 if actual_length < padded_expected_len: raise ValueError( "The length of the pixel data in the dataset doesn't match the " "expected amount ({0} vs. {1} bytes). The dataset may be " "corrupted or there may be an issue with the pixel data handler." .format(actual_length, padded_expected_len) ) elif actual_length > padded_expected_len: # PS 3.5, Section 8.1.1 msg = ( "The length of the pixel data in the dataset ({} bytes) indicates " "it contains excess padding. {} bytes will be removed from the " "end of the data" .format(actual_length, actual_length - expected_len) ) warnings.warn(msg) # Unpack the pixel data into a 1D ndarray if ds.BitsAllocated == 1: # Skip any trailing padding bits nr_pixels = get_expected_length(ds, unit='pixels') arr = unpack_bits(ds.PixelData)[:nr_pixels] else: # Skip the trailing padding byte if present arr = np.frombuffer(ds.PixelData[:expected_len], dtype=pixel_dtype(ds)) if should_change_PhotometricInterpretation_to_RGB(ds): ds.PhotometricInterpretation = "RGB" if not read_only and ds.BitsAllocated > 1: return arr.copy() return arr
def get_pixeldata(dicom_dataset): """ Use the GDCM package to decode the PixelData attribute Returns ------- numpy.ndarray A correctly sized (but not shaped) numpy array of the entire data volume Raises ------ ImportError if the required packages are not available TypeError if the image could not be read by GDCM if the pixel data type is unsupported AttributeError if the decoded amount of data does not match the expected amount """ if not HAVE_GDCM: msg = ("GDCM requires both the gdcm package and numpy " "and one or more could not be imported") raise ImportError(msg) if HAVE_GDCM_IN_MEMORY_SUPPORT: gdcm_data_element = create_data_element(dicom_dataset) gdcm_image = create_image(dicom_dataset, gdcm_data_element) else: gdcm_image_reader = create_image_reader(dicom_dataset.filename) if not gdcm_image_reader.Read(): raise TypeError("GDCM could not read DICOM image") gdcm_image = gdcm_image_reader.GetImage() # GDCM returns char* as type str. Under Python 2 `str` are # byte arrays by default. Python 3 decodes this to # unicode strings by default. # The SWIG docs mention that they always decode byte streams # as utf-8 strings for Python 3, with the `surrogateescape` # error handler configured. # Therefore, we can encode them back to their original bytearray # representation on Python 3 by using the same parameters. if compat.in_py2: pixel_bytearray = gdcm_image.GetBuffer() else: pixel_bytearray = gdcm_image.GetBuffer().encode( "utf-8", "surrogateescape") # Here we need to be careful because in some cases, GDCM reads a # buffer that is too large, so we need to make sure we only include # the first n_rows * n_columns * dtype_size bytes. expected_length_bytes = get_expected_length(dicom_dataset) if len(pixel_bytearray) > expected_length_bytes: # We make sure that all the bytes after are in fact zeros padding = pixel_bytearray[expected_length_bytes:] if numpy.any(numpy.frombuffer(padding, numpy.byte)): pixel_bytearray = pixel_bytearray[:expected_length_bytes] else: # We revert to the old behavior which should then result # in a Numpy error later on. pass numpy_dtype = pixel_dtype(dicom_dataset) pixel_array = numpy.frombuffer(pixel_bytearray, dtype=numpy_dtype) expected_length_pixels = get_expected_length(dicom_dataset, 'pixels') if pixel_array.size != expected_length_pixels: raise AttributeError("Amount of pixel data %d does " "not match the expected data %d" % (pixel_array.size, expected_length_pixels)) if should_change_PhotometricInterpretation_to_RGB(dicom_dataset): dicom_dataset.PhotometricInterpretation = "RGB" return pixel_array.copy()
def clean(self, fix_interpretation=True, pixel_data_attribute="PixelData"): """ take a dicom image and a list of pixel coordinates, and return a cleaned file (if output file is specified) or simply plot the cleaned result (if no file is specified) Parameters ========== add_padding: add N=margin pixels of padding margin: pixels of padding to add, if add_padding True fix_interpretation: fix the photometric interpretation if found off """ if not self.results: bot.warning("Use %s.detect() to find coordinates first." % self) else: bot.info("Scrubbing %s." % self.dicom_file) # Load in dicom file, and image data dicom = read_file(self.dicom_file, force=True) pixel_data = getattr(dicom, pixel_data_attribute) # Get expected and actual length of the pixel data (bytes, expected does not include trailing null byte) expected_length = get_expected_length(dicom) actual_length = len(pixel_data) padded_expected_length = expected_length + expected_length % 2 full_length = expected_length / 2 * 3 # upsampled data is a third larger full_length += (1 if full_length % 2 else 0 ) # trailing padding byte if even length # If we have YBR_FULL_2, must be RGB to obtain pixel data if (not dicom.file_meta.TransferSyntaxUID.is_compressed and dicom.PhotometricInterpretation == "YBR_FULL_422" and fix_interpretation and actual_length >= full_length): bot.warning( "Updating dicom.PhotometricInterpretation to RGB, set fix_interpretation to False to skip." ) photometric_original = dicom.PhotometricInterpretation dicom.PhotometricInterpretation = "RGB" self.original = dicom.pixel_array dicom.PhotometricInterpretation = photometric_original else: self.original = dicom.pixel_array # Compile coordinates from result coordinates = [] for item in self.results["results"]: if len(item["coordinates"]) > 0: for coordinate_set in item["coordinates"]: # Coordinates expected to be list separated by commas new_coordinates = [ int(x) for x in coordinate_set.split(",") ] coordinates.append( new_coordinates) # [[1,2,3,4],...[1,2,3,4]] # Instead of writing directly to data, create a mask # For 4D, (frames, X, Y, channel) if len(self.original.shape) == 4: mask = numpy.zeros(self.original.shape[1:3], dtype=numpy.uint8) # For 3D, (X, Y, channel) else: mask = numpy.zeros(self.original.shape[0:2], dtype=numpy.uint8) for coordinate in coordinates: minr, minc, maxr, maxc = coordinate # Update the mask: values set to 0 to be black mask[minc:maxc, minr:maxr] = 1 # Now apply finished mask to the data if len(self.original.shape) == 4: # np.tile does the copying and stacking of masks into the channel dim to produce 3D masks # transposition to convert tile output (channel, X, Y) into (X, Y, channel) # see: https://github.com/nquach/anonymize/blob/master/anonymize.py#L154 channel3mask = numpy.transpose(numpy.tile(mask, (3, 1, 1)), (1, 2, 0)) # use numpy.tile to copy and stack the 3D masks into 4D array to apply to 4D pixel data # tile converts (X, Y, channels) -> (frames, X, Y, channels), presumed ordering for 4D pixel data final_mask = numpy.tile(channel3mask, (self.original.shape[0], 1, 1, 1)) # apply final 4D mask to 4D pixel data self.cleaned = final_mask * self.original # greyscale: no need to stack into the channel dim since it doesnt exist elif len(self.original.shape) == 3: # numpy.tile converts (X, Y) -> (frames, X, Y) final_mask = numpy.tile(mask, (self.original.shape[0], 1, 1)) self.cleaned = final_mask * self.original else: bot.warning("Pixel array dimension %s is not recognized." % (self.original.shape))
def get_pixeldata(ds, read_only=False): """Return a :class:`numpy.ndarray` of the pixel data. .. versionchanged:: 1.4 * Added support for uncompressed pixel data with a *Photometric Interpretation* of ``YBR_FULL_422``. * Added support for *Float Pixel Data* and *Double Float Pixel Data* Parameters ---------- ds : Dataset The :class:`Dataset` containing an Image Pixel, Floating Point Image Pixel or Double Floating Point Image Pixel module and the *Pixel Data*, *Float Pixel Data* or *Double Float Pixel Data* to be converted. If (0028,0004) *Photometric Interpretation* is `'YBR_FULL_422'` then the pixel data will be resampled to 3 channel data as per Part 3, :dcm:`Annex C.7.6.3.1.2 <part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2>` of the DICOM Standard. read_only : bool, optional If ``False`` (default) then returns a writeable array that no longer uses the original memory. If ``True`` and the value of (0028,0100) *Bits Allocated* > 1 then returns a read-only array that uses the original memory buffer of the pixel data. If *Bits Allocated* = 1 then always returns a writeable array. Returns ------- np.ndarray The contents of (7FE0,0010) *Pixel Data*, (7FE0,0008) *Float Pixel Data* or (7FE0,0009) *Double Float Pixel Data* as a 1D array. Raises ------ AttributeError If `ds` is missing a required element. NotImplementedError If `ds` contains pixel data in an unsupported format. ValueError If the actual length of the pixel data doesn't match the expected length. """ transfer_syntax = ds.file_meta.TransferSyntaxUID # The check of transfer syntax must be first if transfer_syntax not in SUPPORTED_TRANSFER_SYNTAXES: raise NotImplementedError( "Unable to convert the pixel data as the transfer syntax " "is not supported by the numpy pixel data handler.") # Check required elements keywords = ['PixelData', 'FloatPixelData', 'DoubleFloatPixelData'] px_keyword = [kw for kw in keywords if kw in ds] if len(px_keyword) != 1: raise AttributeError( "Unable to convert the pixel data: one of Pixel Data, Float " "Pixel Data or Double Float Pixel Data must be present in " "the dataset") required_elements = [ 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', 'SamplesPerPixel', 'PhotometricInterpretation' ] missing = [elem for elem in required_elements if elem not in ds] if missing: raise AttributeError( "Unable to convert the pixel data as the following required " "elements are missing from the dataset: " + ", ".join(missing)) # May be Pixel Data, Float Pixel Data or Double Float Pixel Data pixel_data = getattr(ds, px_keyword[0]) # Calculate the expected length of the pixel data (in bytes) # Note: this does NOT include the trailing null byte for odd length data expected_len = get_expected_length(ds) # Check that the actual length of the pixel data is as expected actual_length = len(pixel_data) # Correct for the trailing NULL byte padding for odd length data padded_expected_len = expected_len + expected_len % 2 if actual_length < padded_expected_len: if actual_length == expected_len: warnings.warn( "The odd length pixel data is missing a trailing padding byte") else: raise ValueError( "The length of the pixel data in the dataset ({} bytes) " "doesn't match the expected length ({} bytes). " "The dataset may be corrupted or there may be an issue " "with the pixel data handler.".format(actual_length, padded_expected_len)) elif actual_length > padded_expected_len: # PS 3.5, Section 8.1.1 msg = ( "The length of the pixel data in the dataset ({} bytes) indicates " "it contains excess padding. {} bytes will be removed from the " "end of the data".format(actual_length, actual_length - expected_len)) # PS 3.3, Annex C.7.6.3 if ds.PhotometricInterpretation == 'YBR_FULL_422': # Check to ensure we do have subsampled YBR 422 data ybr_full_length = expected_len / 2 * 3 + expected_len / 2 * 3 % 2 # >= as may also include excess padding if actual_length >= ybr_full_length: msg = ("The Photometric Interpretation of the dataset is " "YBR_FULL_422, however the length of the pixel data " "({} bytes) is a third larger than expected ({} bytes) " "which indicates that this may be incorrect. You may " "need to change the Photometric Interpretation to " "the correct value.".format(actual_length, expected_len)) warnings.warn(msg) # Unpack the pixel data into a 1D ndarray if ds.BitsAllocated == 1: # Skip any trailing padding bits nr_pixels = get_expected_length(ds, unit='pixels') arr = unpack_bits(pixel_data)[:nr_pixels] else: # Skip the trailing padding byte(s) if present dtype = pixel_dtype(ds, as_float=('Float' in px_keyword[0])) arr = np.frombuffer(pixel_data[:expected_len], dtype=dtype) if ds.PhotometricInterpretation == 'YBR_FULL_422': # PS3.3 C.7.6.3.1.2: YBR_FULL_422 data needs to be resampled # Y1 Y2 B1 R1 -> Y1 B1 R1 Y2 B1 R1 out = np.zeros(expected_len // 2 * 3, dtype=dtype) out[::6] = arr[::4] # Y1 out[3::6] = arr[1::4] # Y2 out[1::6], out[4::6] = arr[2::4], arr[2::4] # B out[2::6], out[5::6] = arr[3::4], arr[3::4] # R arr = out if should_change_PhotometricInterpretation_to_RGB(ds): ds.PhotometricInterpretation = "RGB" if not read_only and ds.BitsAllocated > 1: return arr.copy() return arr
def get_pixeldata(ds, read_only=False): """Return a :class:`numpy.ndarray` of the *Pixel Data*. Parameters ---------- ds : Dataset The :class:`Dataset` containing an Image Pixel module and the *Pixel Data* to be converted. read_only : bool, optional If ``False`` (default) then returns a writeable array that no longer uses the original memory. If ``True`` and the value of (0028,0100) *Bits Allocated* > 1 then returns a read-only array that uses the original memory buffer of the pixel data. If *Bits Allocated* = 1 then always returns a writeable array. Returns ------- np.ndarray The contents of (7FE0,0010) *Pixel Data* as a 1D array. Raises ------ AttributeError If `ds` is missing a required element. NotImplementedError If `ds` contains pixel data in an unsupported format. ValueError If the actual length of the pixel data doesn't match the expected length. """ transfer_syntax = ds.file_meta.TransferSyntaxUID # The check of transfer syntax must be first if transfer_syntax not in SUPPORTED_TRANSFER_SYNTAXES: raise NotImplementedError( "Unable to convert the pixel data as the transfer syntax " "is not supported by the numpy pixel data handler." ) # Check required elements required_elements = ['PixelData', 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', 'SamplesPerPixel'] missing = [elem for elem in required_elements if elem not in ds] if missing: raise AttributeError( "Unable to convert the pixel data as the following required " "elements are missing from the dataset: " + ", ".join(missing) ) # Calculate the expected length of the pixel data (in bytes) # Note: this does NOT include the trailing null byte for odd length data expected_len = get_expected_length(ds) # Check that the actual length of the pixel data is as expected actual_length = len(ds.PixelData) # Correct for the trailing NULL byte padding for odd length data padded_expected_len = expected_len + expected_len % 2 if actual_length < padded_expected_len: if actual_length == expected_len: warnings.warn( "The pixel data length is odd and misses a padding byte.") else: raise ValueError( "The length of the pixel data in the dataset ({} bytes) " "doesn't match the expected length ({} bytes). " "The dataset may be corrupted or there may be an issue " "with the pixel data handler." .format(actual_length, padded_expected_len) ) elif actual_length > padded_expected_len: # PS 3.5, Section 8.1.1 msg = ( "The length of the pixel data in the dataset ({} bytes) indicates " "it contains excess padding. {} bytes will be removed from the " "end of the data" .format(actual_length, actual_length - expected_len) ) warnings.warn(msg) # Unpack the pixel data into a 1D ndarray if ds.BitsAllocated == 1: # Skip any trailing padding bits nr_pixels = get_expected_length(ds, unit='pixels') arr = unpack_bits(ds.PixelData)[:nr_pixels] else: # Skip the trailing padding byte if present arr = np.frombuffer(ds.PixelData[:expected_len], dtype=pixel_dtype(ds)) if should_change_PhotometricInterpretation_to_RGB(ds): ds.PhotometricInterpretation = "RGB" if not read_only and ds.BitsAllocated > 1: return arr.copy() return arr