From 31158d1d0d410277706422d9954f670d99a3e71b Mon Sep 17 00:00:00 2001 From: Georg Schramm Date: Wed, 27 Nov 2024 19:06:49 +0100 Subject: [PATCH 1/4] replace pydicom's read_file / write_file --- examples/fileio/convert_kul_nifti_to_dicom.py | 76 +- pymirc/fileio/labelvol_to_rtstruct.py | 521 +++---- pymirc/fileio/read_dicom.py | 1301 +++++++++-------- pymirc/fileio/read_rtstruct.py | 517 ++++--- setup.py | 28 +- 5 files changed, 1313 insertions(+), 1130 deletions(-) diff --git a/examples/fileio/convert_kul_nifti_to_dicom.py b/examples/fileio/convert_kul_nifti_to_dicom.py index 7deb0c9..ff40dd6 100644 --- a/examples/fileio/convert_kul_nifti_to_dicom.py +++ b/examples/fileio/convert_kul_nifti_to_dicom.py @@ -1,8 +1,10 @@ import sys, os -pymirc_path = os.path.join('..','..') -if not pymirc_path in sys.path: sys.path.append(pymirc_path) -import numpy as np +pymirc_path = os.path.join("..", "..") +if not pymirc_path in sys.path: + sys.path.append(pymirc_path) + +import numpy as np import nibabel as nib import pydicom @@ -11,52 +13,60 @@ from argparse import ArgumentParser parser = ArgumentParser() -parser.add_argument('nifti_file', help = 'nifti file to be converted into dicom') -parser.add_argument('ref_dcm_file', help = 'the reference dicom file') -parser.add_argument('output_dir', help = 'output directory') - -parser.add_argument('--dcm_tag_file', help = 'txt file with dcm tags to copy', - default = 'dcm_tags_to_copy.txt') -parser.add_argument('--series_desc_prefix', help = 'prefix for dcm series description', - default = '(KUL Segmentation)') -parser.add_argument('--output_modality', help = 'dicom modality of output', - default = 'CT') - -args = parser.parse_args() +parser.add_argument("nifti_file", help="nifti file to be converted into dicom") +parser.add_argument("ref_dcm_file", help="the reference dicom file") +parser.add_argument("output_dir", help="output directory") + +parser.add_argument( + "--dcm_tag_file", + help="txt file with dcm tags to copy", + default="dcm_tags_to_copy.txt", +) +parser.add_argument( + "--series_desc_prefix", + help="prefix for dcm series description", + default="(KUL Segmentation)", +) +parser.add_argument("--output_modality", help="dicom modality of output", default="CT") + +args = parser.parse_args() output_dir = args.output_dir -#----------------------------------------------------------------------------------------- +# ----------------------------------------------------------------------------------------- if os.path.isdir(output_dir): - raise FileExistsError('output directory ' + output_dir + ' already exists') + raise FileExistsError("output directory " + output_dir + " already exists") # load the nifti and the affine and convert to LPS orientation -nii = nib.load(args.nifti_file) -nii = nib.as_closest_canonical(nii) -vol_ras = nii.get_data() +nii = nib.load(args.nifti_file) +nii = nib.as_closest_canonical(nii) +vol_ras = nii.get_data() affine_ras = nii.affine -vol = np.flip(np.flip(vol_ras, 0), 1) +vol = np.flip(np.flip(vol_ras, 0), 1) affine = affine_ras.copy() -affine[0,-1] = (-1 * nii.affine @ np.array([vol.shape[0]-1,0,0,1]))[0] -affine[1,-1] = (-1 * nii.affine @ np.array([0,vol.shape[1]-1,0,1]))[1] +affine[0, -1] = (-1 * nii.affine @ np.array([vol.shape[0] - 1, 0, 0, 1]))[0] +affine[1, -1] = (-1 * nii.affine @ np.array([0, vol.shape[1] - 1, 0, 1]))[1] # load the list of dicom tags to copy from the reference header from an input text file -with open(args.dcm_tag_file,'r') as f: - tags_to_copy = [x.strip() for x in f.read().splitlines()] +with open(args.dcm_tag_file, "r") as f: + tags_to_copy = [x.strip() for x in f.read().splitlines()] # read the reference dicom volume -ref_dcm = pydicom.read_file(args.ref_dcm_file) +ref_dcm = pydicom.dcmread(args.ref_dcm_file) # create the dictionary of tags and values that are copied from the reference dicom header dcm_header_kwargs = {} for tag in tags_to_copy: - if tag in ref_dcm: - dcm_header_kwargs[tag] = ref_dcm.data_element(tag).value + if tag in ref_dcm: + dcm_header_kwargs[tag] = ref_dcm.data_element(tag).value # write the dicoms -pymf.write_3d_static_dicom(vol, output_dir, - affine = affine, - SeriesDescription = args.series_desc_prefix + ' ' + ref_dcm.SeriesDescription, - modality = args.output_modality, - **dcm_header_kwargs) +pymf.write_3d_static_dicom( + vol, + output_dir, + affine=affine, + SeriesDescription=args.series_desc_prefix + " " + ref_dcm.SeriesDescription, + modality=args.output_modality, + **dcm_header_kwargs +) diff --git a/pymirc/fileio/labelvol_to_rtstruct.py b/pymirc/fileio/labelvol_to_rtstruct.py index 8f0ca83..70eb2f3 100644 --- a/pymirc/fileio/labelvol_to_rtstruct.py +++ b/pymirc/fileio/labelvol_to_rtstruct.py @@ -1,5 +1,5 @@ import pymirc.image_operations as pymi -import numpy as np +import numpy as np import pydicom import os import warnings @@ -9,255 +9,282 @@ from .read_dicom import DicomVolume + # ------------------------------------------------------------------------ -def labelvol_to_rtstruct(roi_vol, - aff, - refdcm_file, - filename, - uid_base = '1.2.826.0.1.3680043.9.7147.', - seriesDescription = 'test rois', - structureSetLabel = 'RTstruct', - structureSetName = 'my rois', - connect_holes = True, - roinames = None, - roidescriptions = None, - roigenerationalgs = None, - roi_colors = [['255','0','0'], ['0', '0','255'],['0', '255','0'], - ['255','0','255'],['255','255','0'],['0','255','255']], - tags_to_copy = ['PatientName','PatientID','AccessionNumber','StudyID', - 'StudyDescription','StudyDate','StudyTime', - 'SeriesDate','SeriesTime'], - tags_to_add = None): - - """Convert a 3D array with integer ROI label to RTstruct - - Parameters - --------- - - roi_vol : 3d numpy integer array - in LPS orientation containing the ROI labels - 0 is considered background - 1 ... ROI-1 - n ... ROI-n - - - aff : 2d 4x4 numpy array - affine matrix that maps from voxel to (LPS) world coordinates - - refdcm_file : string or list - A single reference dicom file or a list of reference files (multiple CT slices) - From this file several dicom tags are copied (e.g. the FrameOfReferenceUID). - In case a list of files is given, the dicom tags are copied from the first file, - however all SOPInstanceUIDs are added to the ContourImageSequence (needed for some - RT systems) - - filename : string - name of the output rtstruct file - - uid_base : string, optional - uid base used to generate some dicom UID - - seriesDescription : string, optional - dicom series description for the rtstruct series - - structureSetLabel, structureSetName : string, optional - Label and Name of the structSet - - connect_holes : bool, optional - whether to connect inner holes to their outer parents contour - default: True - this connection is needed to show holes correctly in MIM - - roinames, roidescriptions, roigenerationalgs : lists, optional - containing strings for ROIName, ROIDescription and ROIGenerationAlgorithm - - roi_colors: list of lists containing 3 integer strings (0 - 255), optional - used as ROI display colors - - tags_to_copy: list of strings, list optional - extra dicom tags to copy from the refereced dicom file - - tags_to_add: dictionary - with valid dicom tags to add in the header - """ - - roinumbers = np.unique(roi_vol) - roinumbers = roinumbers[roinumbers > 0] - nrois = len(roinumbers) - - if isinstance(refdcm_file, list): - refdcm = pydicom.read_file(refdcm_file[0]) - else: - refdcm = pydicom.read_file(refdcm_file) - - file_meta = pydicom.Dataset() - - file_meta.ImplementationClassUID = uid_base + '1.1.1' - file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.481.3' - file_meta.MediaStorageSOPInstanceUID = pydicom.uid.generate_uid('1.2.840.10008.5.1.4.1.1.481.3.') - - ds = pydicom.FileDataset(filename, {}, file_meta = file_meta, preamble=b"\0" * 128) - - ds.Modality = 'RTSTRUCT' - ds.SeriesDescription = seriesDescription - - #--- copy dicom tags from reference dicom file - for tag in tags_to_copy: - if tag in refdcm: - setattr(ds,tag, refdcm.data_element(tag).value) +def labelvol_to_rtstruct( + roi_vol, + aff, + refdcm_file, + filename, + uid_base="1.2.826.0.1.3680043.9.7147.", + seriesDescription="test rois", + structureSetLabel="RTstruct", + structureSetName="my rois", + connect_holes=True, + roinames=None, + roidescriptions=None, + roigenerationalgs=None, + roi_colors=[ + ["255", "0", "0"], + ["0", "0", "255"], + ["0", "255", "0"], + ["255", "0", "255"], + ["255", "255", "0"], + ["0", "255", "255"], + ], + tags_to_copy=[ + "PatientName", + "PatientID", + "AccessionNumber", + "StudyID", + "StudyDescription", + "StudyDate", + "StudyTime", + "SeriesDate", + "SeriesTime", + ], + tags_to_add=None, +): + """Convert a 3D array with integer ROI label to RTstruct + + Parameters + --------- + + roi_vol : 3d numpy integer array + in LPS orientation containing the ROI labels + 0 is considered background + 1 ... ROI-1 + n ... ROI-n + + + aff : 2d 4x4 numpy array + affine matrix that maps from voxel to (LPS) world coordinates + + refdcm_file : string or list + A single reference dicom file or a list of reference files (multiple CT slices) + From this file several dicom tags are copied (e.g. the FrameOfReferenceUID). + In case a list of files is given, the dicom tags are copied from the first file, + however all SOPInstanceUIDs are added to the ContourImageSequence (needed for some + RT systems) + + filename : string + name of the output rtstruct file + + uid_base : string, optional + uid base used to generate some dicom UID + + seriesDescription : string, optional + dicom series description for the rtstruct series + + structureSetLabel, structureSetName : string, optional + Label and Name of the structSet + + connect_holes : bool, optional + whether to connect inner holes to their outer parents contour - default: True + this connection is needed to show holes correctly in MIM + + roinames, roidescriptions, roigenerationalgs : lists, optional + containing strings for ROIName, ROIDescription and ROIGenerationAlgorithm + + roi_colors: list of lists containing 3 integer strings (0 - 255), optional + used as ROI display colors + + tags_to_copy: list of strings, list optional + extra dicom tags to copy from the refereced dicom file + + tags_to_add: dictionary + with valid dicom tags to add in the header + """ + + roinumbers = np.unique(roi_vol) + roinumbers = roinumbers[roinumbers > 0] + nrois = len(roinumbers) + + if isinstance(refdcm_file, list): + refdcm = pydicom.dcmread(refdcm_file[0]) + else: + refdcm = pydicom.dcmread(refdcm_file) + + file_meta = pydicom.Dataset() + + file_meta.ImplementationClassUID = uid_base + "1.1.1" + file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.481.3" + file_meta.MediaStorageSOPInstanceUID = pydicom.uid.generate_uid( + "1.2.840.10008.5.1.4.1.1.481.3." + ) + + ds = pydicom.FileDataset(filename, {}, file_meta=file_meta, preamble=b"\0" * 128) + + ds.Modality = "RTSTRUCT" + ds.SeriesDescription = seriesDescription + + # --- copy dicom tags from reference dicom file + for tag in tags_to_copy: + if tag in refdcm: + setattr(ds, tag, refdcm.data_element(tag).value) + else: + warnings.warn(tag + " not in reference dicom file -> will not be written") + + ds.StudyInstanceUID = refdcm.StudyInstanceUID + ds.SeriesInstanceUID = pydicom.uid.generate_uid(uid_base) + + ds.SOPClassUID = "1.2.840.10008.5.1.4.1.1.481.3" + ds.SOPInstanceUID = pydicom.uid.generate_uid(uid_base) + + ds.StructureSetLabel = structureSetLabel + ds.StructureSetName = structureSetName + ds.StructureSetTime = datetime.datetime.now().time() + ds.StructureSetDate = datetime.datetime.now().date() + + dfr = pydicom.Dataset() + dfr.FrameOfReferenceUID = refdcm.FrameOfReferenceUID + + ds.ReferencedFrameOfReferenceSequence = pydicom.Sequence([dfr]) + + if tags_to_add is not None: + for tag, value in tags_to_add.items(): + setattr(ds, tag, value) + + ####################################################################### + ####################################################################### + # write the ReferencedFrameOfReferenceSequence + + contourImageSeq = pydicom.Sequence() + + if isinstance(refdcm_file, list): + # in case we got all reference dicom files we add all SOPInstanceUIDs + # otherwise some RT planning systems refuse to read the RTstructs + + # sort the reference dicom files according to slice position + dcmVol = DicomVolume(refdcm_file) + # we have to read the data to get dicom slices properly sorted + # the actual returned image is not needed + dummyvol = dcmVol.get_data() + + # calculate the slice offset between the image and ROI volume + sl_offset = int(round((np.linalg.inv(dcmVol.affine) @ aff[:, -1])[2])) + + # find the bounding box in the last direction + ob_sls = find_objects(roi_vol > 0) + z_start = min([x[2].start for x in ob_sls]) + z_end = max([x[2].stop for x in ob_sls]) + + for i in np.arange(z_start, z_end): + tmp = pydicom.Dataset() + tmp.ReferencedSOPClassUID = dcmVol.sorted_SOPClassUIDs[i + sl_offset] + tmp.ReferencedSOPInstanceUID = dcmVol.sorted_SOPInstanceUIDs[i + sl_offset] + contourImageSeq.append(tmp) else: - warnings.warn(tag + ' not in reference dicom file -> will not be written') - - - ds.StudyInstanceUID = refdcm.StudyInstanceUID - ds.SeriesInstanceUID = pydicom.uid.generate_uid(uid_base) - - ds.SOPClassUID = '1.2.840.10008.5.1.4.1.1.481.3' - ds.SOPInstanceUID = pydicom.uid.generate_uid(uid_base) - - ds.StructureSetLabel = structureSetLabel - ds.StructureSetName = structureSetName - ds.StructureSetTime = datetime.datetime.now().time() - ds.StructureSetDate = datetime.datetime.now().date() - - dfr = pydicom.Dataset() - dfr.FrameOfReferenceUID = refdcm.FrameOfReferenceUID - - ds.ReferencedFrameOfReferenceSequence = pydicom.Sequence([dfr]) - - if tags_to_add is not None: - for tag, value in tags_to_add.items(): - setattr(ds, tag, value) - - ####################################################################### - ####################################################################### - # write the ReferencedFrameOfReferenceSequence - - contourImageSeq = pydicom.Sequence() - - if isinstance(refdcm_file, list): - # in case we got all reference dicom files we add all SOPInstanceUIDs - # otherwise some RT planning systems refuse to read the RTstructs - - # sort the reference dicom files according to slice position - dcmVol = DicomVolume(refdcm_file) - # we have to read the data to get dicom slices properly sorted - # the actual returned image is not needed - dummyvol = dcmVol.get_data() - - # calculate the slice offset between the image and ROI volume - sl_offset = int(round((np.linalg.inv(dcmVol.affine) @ aff[:,-1])[2])) - - # find the bounding box in the last direction - ob_sls = find_objects(roi_vol > 0) - z_start = min([x[2].start for x in ob_sls]) - z_end = max([x[2].stop for x in ob_sls]) - - for i in np.arange(z_start,z_end): - tmp = pydicom.Dataset() - tmp.ReferencedSOPClassUID = dcmVol.sorted_SOPClassUIDs[i + sl_offset] - tmp.ReferencedSOPInstanceUID = dcmVol.sorted_SOPInstanceUIDs[i + sl_offset] - contourImageSeq.append(tmp) - else: - dcmVol = None - tmp = pydicom.Dataset() - tmp.ReferencedSOPClassUID = refdcm.SOPClassUID - tmp.ReferencedSOPInstanceUID = refdcm.SOPInstanceUID - contourImageSeq.append(tmp) - - tmp2 = pydicom.Dataset() - tmp2.SeriesInstanceUID = refdcm.SeriesInstanceUID - tmp2.ContourImageSequence = contourImageSeq - - tmp3 = pydicom.Dataset() - tmp3.ReferencedSOPClassUID = '1.2.840.10008.3.1.2.3.1' - # TODO SOP just copied from MIM rtstructs - tmp3.ReferencedSOPInstanceUID = refdcm.StudyInstanceUID - tmp3.RTReferencedSeriesSequence = pydicom.Sequence([tmp2]) - - tmp4 = pydicom.Dataset() - tmp4.FrameOfReferenceUID = refdcm.FrameOfReferenceUID - tmp4.RTReferencedStudySequence = pydicom.Sequence([tmp3]) - - ds.ReferencedFrameOfReferenceSequence = pydicom.Sequence([tmp4]) - - ####################################################################### - ####################################################################### - - ds.StructureSetROISequence = pydicom.Sequence() - ds.ROIContourSequence = pydicom.Sequence() - - if roinames is None: roinames = ['ROI-' + str(x) for x in roinumbers] - if roidescriptions is None: roidescriptions = ['ROI-' + str(x) for x in roinumbers] - if roigenerationalgs is None: roigenerationalgs = len(roinumbers) * ['MANUAL'] - - # loop over the ROIs - for iroi, roinumber in enumerate(roinumbers): - dssr = pydicom.Dataset() - dssr.ROINumber = roinumber - dssr.ROIName = roinames[iroi] - dssr.ROIDescription = roidescriptions[iroi] - dssr.ROIGenerationAlgorithm = roigenerationalgs[iroi] - dssr.ReferencedFrameOfReferenceUID = dfr.FrameOfReferenceUID - - ds.StructureSetROISequence.append(dssr) - + dcmVol = None + tmp = pydicom.Dataset() + tmp.ReferencedSOPClassUID = refdcm.SOPClassUID + tmp.ReferencedSOPInstanceUID = refdcm.SOPInstanceUID + contourImageSeq.append(tmp) + + tmp2 = pydicom.Dataset() + tmp2.SeriesInstanceUID = refdcm.SeriesInstanceUID + tmp2.ContourImageSequence = contourImageSeq + + tmp3 = pydicom.Dataset() + tmp3.ReferencedSOPClassUID = "1.2.840.10008.3.1.2.3.1" + # TODO SOP just copied from MIM rtstructs + tmp3.ReferencedSOPInstanceUID = refdcm.StudyInstanceUID + tmp3.RTReferencedSeriesSequence = pydicom.Sequence([tmp2]) + + tmp4 = pydicom.Dataset() + tmp4.FrameOfReferenceUID = refdcm.FrameOfReferenceUID + tmp4.RTReferencedStudySequence = pydicom.Sequence([tmp3]) + + ds.ReferencedFrameOfReferenceSequence = pydicom.Sequence([tmp4]) + ####################################################################### ####################################################################### - # write ROIContourSequence containing the actual 2D polygon points of the ROI - - # generate binary volume for the current ROI - bin_vol = (roi_vol == dssr.ROINumber).astype(int) - - # find the bounding box in the last direction - ob_sls = find_objects(bin_vol) - z_start = min([x[2].start for x in ob_sls]) - z_end = max([x[2].stop for x in ob_sls]) - - ds_roi_contour = pydicom.Dataset() - ds_roi_contour.ROIDisplayColor = roi_colors[iroi % len(roi_colors)] - ds_roi_contour.ReferencedROINumber = dssr.ROINumber - ds_roi_contour.ContourSequence = pydicom.Sequence() - - # loop over the slices in the 2 direction to create 2D polygons - for sl in np.arange(z_start, z_end): - ds_contour = pydicom.Dataset() - - if dcmVol is None: - ds_contour.ReferencedSOPInstanceUID = refdcm.SOPInstanceUID - ds_contour.ReferencedSOPClassUID = refdcm.SOPClassUID - else: - ds_contour.ReferencedSOPInstanceUID = dcmVol.sorted_SOPInstanceUIDs[sl + sl_offset] - ds_contour.ReferencedSOPClassUID = dcmVol.sorted_SOPClassUIDs[sl + sl_offset] - - bin_slice = bin_vol[:,:,sl] - - if bin_slice.max() > 0: - contours = pymi.binary_2d_image_to_contours(bin_slice, connect_holes = connect_holes) - - for ic in range(len(contours)): - npoints = contours[ic].shape[0] - - contour = np.zeros((npoints,3)) - - for ipoint in range(npoints): - contour[ipoint,:] = (aff @ np.concatenate((contours[ic][ipoint,:],[sl,1])))[:-1] - - dsci = pydicom.Dataset() - dsci.ContourGeometricType = 'CLOSED_PLANAR' - dsci.NumberOfContourPoints = contour.shape[0] - dsci.ContourImageSequence = pydicom.Sequence([ds_contour]) - dsci.ContourData = contour.flatten().tolist() - - # ContourImageSequence contains 1 element per 2D contour - ds_roi_contour.ContourSequence.append(dsci) - - # has to contain one element per ROI - ds.ROIContourSequence.append(ds_roi_contour) - - ####################################################################### - ####################################################################### - - pydicom.filewriter.write_file(os.path.join('.',filename), - ds, write_like_original = False) + ds.StructureSetROISequence = pydicom.Sequence() + ds.ROIContourSequence = pydicom.Sequence() + + if roinames is None: + roinames = ["ROI-" + str(x) for x in roinumbers] + if roidescriptions is None: + roidescriptions = ["ROI-" + str(x) for x in roinumbers] + if roigenerationalgs is None: + roigenerationalgs = len(roinumbers) * ["MANUAL"] + + # loop over the ROIs + for iroi, roinumber in enumerate(roinumbers): + dssr = pydicom.Dataset() + dssr.ROINumber = roinumber + dssr.ROIName = roinames[iroi] + dssr.ROIDescription = roidescriptions[iroi] + dssr.ROIGenerationAlgorithm = roigenerationalgs[iroi] + dssr.ReferencedFrameOfReferenceUID = dfr.FrameOfReferenceUID + + ds.StructureSetROISequence.append(dssr) + + ####################################################################### + ####################################################################### + # write ROIContourSequence containing the actual 2D polygon points of the ROI + + # generate binary volume for the current ROI + bin_vol = (roi_vol == dssr.ROINumber).astype(int) + + # find the bounding box in the last direction + ob_sls = find_objects(bin_vol) + z_start = min([x[2].start for x in ob_sls]) + z_end = max([x[2].stop for x in ob_sls]) + + ds_roi_contour = pydicom.Dataset() + ds_roi_contour.ROIDisplayColor = roi_colors[iroi % len(roi_colors)] + ds_roi_contour.ReferencedROINumber = dssr.ROINumber + ds_roi_contour.ContourSequence = pydicom.Sequence() + + # loop over the slices in the 2 direction to create 2D polygons + for sl in np.arange(z_start, z_end): + ds_contour = pydicom.Dataset() + + if dcmVol is None: + ds_contour.ReferencedSOPInstanceUID = refdcm.SOPInstanceUID + ds_contour.ReferencedSOPClassUID = refdcm.SOPClassUID + else: + ds_contour.ReferencedSOPInstanceUID = dcmVol.sorted_SOPInstanceUIDs[ + sl + sl_offset + ] + ds_contour.ReferencedSOPClassUID = dcmVol.sorted_SOPClassUIDs[ + sl + sl_offset + ] + + bin_slice = bin_vol[:, :, sl] + + if bin_slice.max() > 0: + contours = pymi.binary_2d_image_to_contours( + bin_slice, connect_holes=connect_holes + ) + + for ic in range(len(contours)): + npoints = contours[ic].shape[0] + + contour = np.zeros((npoints, 3)) + + for ipoint in range(npoints): + contour[ipoint, :] = ( + aff @ np.concatenate((contours[ic][ipoint, :], [sl, 1])) + )[:-1] + + dsci = pydicom.Dataset() + dsci.ContourGeometricType = "CLOSED_PLANAR" + dsci.NumberOfContourPoints = contour.shape[0] + dsci.ContourImageSequence = pydicom.Sequence([ds_contour]) + dsci.ContourData = contour.flatten().tolist() + + # ContourImageSequence contains 1 element per 2D contour + ds_roi_contour.ContourSequence.append(dsci) + + # has to contain one element per ROI + ds.ROIContourSequence.append(ds_roi_contour) + + ####################################################################### + ####################################################################### + pydicom.filewriter.write_file( + os.path.join(".", filename), ds, write_like_original=False + ) diff --git a/pymirc/fileio/read_dicom.py b/pymirc/fileio/read_dicom.py index 52fd01a..3e6c901 100644 --- a/pymirc/fileio/read_dicom.py +++ b/pymirc/fileio/read_dicom.py @@ -6,645 +6,758 @@ import pydicom as dicom -#-------------------------------------------------------------- +# -------------------------------------------------------------- + class DicomVolume: - """get 3D or 4D numpy arrays from a list of 2D dicom files - - Parameters - ---------- - filelist : list or str - either: - (1) a list of 2d dicom files containing the image data of a 3D/4D dicom series - (2) a string containing a pattern passed to glob.glob to generate the file list in (1) - - dicomlist: list of pydicom FileDatasets - instead of specifing filelist, the list of pydicom FileDatasets can also be - given directly. In this case filelist must not be given! - - fallback_series_type : 2 element tuple - series type to use if not given in the header as tag SeriesType. - Valid values for the 1st element are: "STATIC", "DYNAMIC", "GATED", "WHOLE BODY" - Valid values for the 2nd element are: "IMAGE", "REPROJECTION" - - verbose: bool - print verbose output - - Note - ---- - The aim of this class is to get 3D/4D numpy arrays from a set of 2D dicom files of a dicom series - in defined orientation (LPS). - - Example - ------- - dcm_vol = DicomVolume('mydicom_dir/*.dcm') - img_arr = dcm_vol.get_data() - img_aff = dcm_vol.affine - dcm_hdr = dcm_vol.firstdcmheader - """ - def __init__(self, filelist = None, dicomlist= None, fallback_series_type = ('STATIC','IMAGE'), verbose = True): - - self.verbose = verbose - - if isinstance(filelist,list): self.filelist = filelist - elif isinstance(filelist,str): self.filelist = glob.glob(filelist) - else: self.filelist = None - - self.dicomlist = dicomlist - - # throw error if neither filelist nor dicomlist are given - if (self.filelist is None) and (self.dicomlist is None): - raise InputError('Either filelist or dicomlist must be given as input') - - # throw error if both filelist and dicomlist are given - if (self.filelist is not None) and (self.dicomlist is not None): - raise InputError('Either filelist or dicomlist must be given as input') - - # attach the first dicom header to the object - if self.filelist is not None: - self.firstdcmheader = dicom.read_file(self.filelist[0]) - else: - self.firstdcmheader = self.dicomlist[0] - - # the extra check if the dimension of the pixel array is bigger than 2 is needed - # because there are GE CT with erroneouly contain NumberOfFrames in classical slice by - # slice dicom files - if ('NumberOfFrames' in self.firstdcmheader) and (self.firstdcmheader.pixel_array.ndim > 2): - # case of multi slice data (3d array in 1 dicom file - # getting the ImageOrientationPatient attribute is not trivial - # since it is stored in different tags by different vendors - - if 'DetectorInformationSequence' in self.firstdcmheader: - # this is for multi slice data of the Siemens symbia spect - iop = self.firstdcmheader.DetectorInformationSequence[0].ImageOrientationPatient - else: - try: - # this is for multi slice data of molecubes - iop = self.firstdcmheader.SharedFunctionalGroupsSequence[0].PlaneOrientationSequence[0].ImageOrientationPatient - except AttributeError: - # this is for multi slice data from PMOD - try: - iop = self.firstdcmheader.PerFrameFunctionalGroupsSequence[0].PlaneOrientationSequence[0].ImageOrientationPatient - except AttributeError: - # this is for multi slice data from RayStation - iop = self.firstdcmheader.ImageOrientationPatient - - self.x = np.array(iop[:3], dtype = float) - self.y = np.array(iop[3:], dtype = float) - - # set member variable that shows whether data has been read in - self.read_all_dcms = True - - else: - self.x = np.array(self.firstdcmheader.ImageOrientationPatient[0:3], dtype = float) - self.y = np.array(self.firstdcmheader.ImageOrientationPatient[3:] , dtype = float) - - # set member variable that shows whether data has been read in - self.read_all_dcms = False - - self.n = np.cross(self.x,self.y) - - # get the row and column pixelspacing - if 'PixelSpacing' in self.firstdcmheader: - self.pixelspacing = np.array(self.firstdcmheader.PixelSpacing, dtype = float) - else: - self.pixelspacing = np.array(self.firstdcmheader.SharedFunctionalGroupsSequence[0].PixelMeasuresSequence[0].PixelSpacing) - - self.dr = self.pixelspacing[0] - self.dc = self.pixelspacing[1] - - # approximately transform slices in patient coord. system - self.normaxis = np.argmax(np.abs(self.n)) - self.normdir = np.sign(self.n[self.normaxis]) - self.rowaxis = np.argmax(np.abs(self.x)) - self.rowdir = np.sign(self.x[self.rowaxis]) - self.colaxis = np.argmax(np.abs(self.y)) - self.coldir = np.sign(self.y[self.colaxis]) - - # read the number of frames (time slices) - if 'NumberOfTimeSlices' in self.firstdcmheader: self.NumTimeSlices = self.firstdcmheader.NumberOfTimeSlices - else: self.NumTimeSlices = 1 - - - # get the dicom series type to see whether we have a static or dynamic acq. - if "SeriesType" in self.firstdcmheader: - self.series_type = self.firstdcmheader.SeriesType - else: - self.series_type = dicom.multival.MultiValue(str, fallback_series_type) - warnings.warn(f'Cannot find SeriesType in first dicom header. Setting it to {fallback_series_type}') - - #------------------------------------------------------------------------------------------------------ - def reorient_volume(self, patvol): - """reorient the raw dicom volume to LPS orientation + """get 3D or 4D numpy arrays from a list of 2D dicom files Parameters ---------- - patvol : 3d numpy array + filelist : list or str + either: + (1) a list of 2d dicom files containing the image data of a 3D/4D dicom series + (2) a string containing a pattern passed to glob.glob to generate the file list in (1) - Returns - ------- - 3d numpy array - reoriented numpy array in LPS orientation - """ + dicomlist: list of pydicom FileDatasets + instead of specifing filelist, the list of pydicom FileDatasets can also be + given directly. In this case filelist must not be given! - # check the directions of the norm, col and row dir and revert some axis if necessary - if(self.normdir == -1): - patvol = patvol[::-1,:,:] - self.offset = self.offset + (self.n0 - 1)*self.v0 - self.v0 = -1.0*self.v0 - if(self.coldir == -1): - patvol = patvol[:,::-1,:] - self.offset = self.offset + (self.n1 - 1)*self.v1 - self.v1 = -1.0*self.v1 - if(self.rowdir == -1): - patvol = patvol[:,:,::-1] - self.offset = self.offset + (self.n2 - 1)*self.v2 - self.v2 = -1.0*self.v2 - - # now we want to make sure that the 0, 1, 2 axis of our 3d volume corrrespond - # to the x, y, z axis in the patient coordinate system - # therefore we might need to swap some axis - if(self.normaxis == 0 and self.colaxis == 1 and self.rowaxis == 2): - self.yvoxsize, self.zvoxsize = self.dr, self.dc - self.xvoxsize = self.sliceDistance - elif(self.normaxis == 0 and self.colaxis == 2 and self.rowaxis == 1): - if self.verbose: print('--- swapping axis 1 and 2') - patvol = np.swapaxes(patvol,1,2) - self.v1, self.v2 = self.v2, self.v1 - self.zvoxsize, self.yvoxsize = self.dr, self.dc - self.xvoxsize = self.sliceDistance - elif(self.normaxis == 1 and self.colaxis == 0 and self.rowaxis == 2): - if self.verbose: print('--- swapping axis 0 and 1') - patvol = np.swapaxes(patvol,0,1) - self.v0, self.v1 = self.v1, self.v0 - self.xvoxsize, self.zvoxsize = self.dr, self.dc - self.yvoxsize = self.sliceDistance - elif(self.normaxis == 1 and self.colaxis == 2 and self.rowaxis == 0): - if self.verbose: print('--- swapping axis 0 and 1') - if self.verbose: print('--- swapping axis 0 and 2') - patvol = np.swapaxes(np.swapaxes(patvol,0,1),0,2) - self.v0, self.v1 = self.v1, self.v0 - self.v0, self.v2 = self.v2, self.v0 - self.zvoxsize, self.xvoxsize = self.dr, self.dc - self.yvoxsize = self.sliceDistance - elif(self.normaxis == 2 and self.colaxis == 1 and self.rowaxis == 0): - if self.verbose: print('--- swapping axis 0 and 2') - patvol = np.swapaxes(patvol,0,2) - self.v0, self.v2 = self.v2, self.v0 - self.yvoxsize, self.xvoxsize = self.dr, self.dc - self.zvoxsize = self.sliceDistance - elif(self.normaxis == 2 and self.colaxis == 0 and self.rowaxis == 1): - if self.verbose: print('--- swapping axis 0 and 2') - if self.verbose: print('--- swapping axis 0 and 1') - patvol = np.swapaxes(np.swapaxes(patvol,0,2),0,1) - self.v0, self.v2 = self.v2, self.v0 - self.v0, self.v1 = self.v1, self.v0 - self.xvoxsize, self.yvoxsize = self.dr, self.dc - self.zvoxsize = self.sliceDistance - - # update the volume dimensions - self.n0, self.n1, self.n2 = patvol.shape - - return patvol - - #------------------------------------------------------------------------------------------------------ - def get_data(self, frames = None): - """get the actual 3D or 4D image data + fallback_series_type : 2 element tuple + series type to use if not given in the header as tag SeriesType. + Valid values for the 1st element are: "STATIC", "DYNAMIC", "GATED", "WHOLE BODY" + Valid values for the 2nd element are: "IMAGE", "REPROJECTION" - Parameters - ---------- - frames : list of ints, optional - if the data is 4D this can be a list of frame number to be read - the default None means read all frames + verbose: bool + print verbose output Note ---- - This is a high level function that call the underlying function for - reading 3D, 4D or multislice data sets. + The aim of this class is to get 3D/4D numpy arrays from a set of 2D dicom files of a dicom series + in defined orientation (LPS). - Returns + Example ------- - a 3D or 4D numpy array - array containing the data + dcm_vol = DicomVolume('mydicom_dir/*.dcm') + img_arr = dcm_vol.get_data() + img_aff = dcm_vol.affine + dcm_hdr = dcm_vol.firstdcmheader """ - if not self.read_all_dcms: - if self.verbose: print('Analyzing dicom headers') - - if self.dicomlist is None: - self.dicomlist = [dicom.read_file(x) for x in self.filelist] - - # check if some images have a SOPclassUID that does not belong to images and drop them - # SOPClassUID '1.2.840.10008.5.1.4.1.1.66' means Raw Data Storage - # '1.2.840.10008.5.1.4.1.1.66.x' for x in (1,2,3,4) are also not images - self.dicomlist = [x for x in self.dicomlist if not x.SOPClassUID.startswith('1.2.840.10008.5.1.4.1.1.66')] - - self.read_all_dcms = True - - self.TemporalPositionIdentifiers = [] - - # to figure out which 2d dicom file belongs to which time frame - # we use the TemporalPositionIdentifier (not very common) or the acquisition date time - for dcm in self.dicomlist: - if (dcm.Modality == 'MR') and ('AcquisitionNumber' in dcm): - self.TemporalPositionIdentifiers.append(dcm.AcquisitionNumber) - elif (dcm.Modality == 'MR') and ('EchoNumbers' in dcm): - self.TemporalPositionIdentifiers.append(dcm.EchoNumbers) - elif 'TemporalPositionIdentifier' in dcm: - self.TemporalPositionIdentifiers.append(dcm.TemporalPositionIdentifier) + + def __init__( + self, + filelist=None, + dicomlist=None, + fallback_series_type=("STATIC", "IMAGE"), + verbose=True, + ): + + self.verbose = verbose + + if isinstance(filelist, list): + self.filelist = filelist + elif isinstance(filelist, str): + self.filelist = glob.glob(filelist) else: - if 'AcquisitionDate' in dcm: - acq_d = dcm.AcquisitionDate - else: - acq_d = '19700101' - - if 'AcquisitionTime' in dcm: - acq_t = dcm.AcquisitionTime - else: - acq_t = '000000' - - # if the trigger time is in the data we add it to the acq. time - # this is needed to read GE gated PET data - if 'TriggerTime' in dcm: - acq_t += ('.' + str(dcm.TriggerTime)) - - self.TemporalPositionIdentifiers.append(acq_d + acq_t) - - self.TemporalPositionIdentifiers = np.array(self.TemporalPositionIdentifiers) - self.uniq_TemporalPositionIdentifiers = np.unique(self.TemporalPositionIdentifiers) - self.uniq_TemporalPositionIdentifiers.sort() - - # if an MR data contains multiple echos, we interpret it as dynamic data - if ((self.dicomlist[0].Modality == 'MR') and (len(self.uniq_TemporalPositionIdentifiers) > 1)): - self.series_type[0] = 'DYNAMIC' - warnings.warn(f'Found multiple Temporal Positions in MR data set. Setting series type to DYNAMIC') - - # read static image - if (self.series_type[0] == 'STATIC') or (self.series_type[0] == 'WHOLE BODY'): - self.nframes = 1 - - # the extra check if the dimension of the pixel array is bigger than 2 is needed - # because there are GE CT with erroneouly contain NumberOfFrames in classical slice by - # slice dicom files - if 'NumberOfFrames' in self.firstdcmheader and (self.firstdcmheader.pixel_array.ndim > 2): - # read multi slice data (the whole 3d volume is in one dicom file) + self.filelist = None + + self.dicomlist = dicomlist + + # throw error if neither filelist nor dicomlist are given + if (self.filelist is None) and (self.dicomlist is None): + raise InputError("Either filelist or dicomlist must be given as input") + + # throw error if both filelist and dicomlist are given + if (self.filelist is not None) and (self.dicomlist is not None): + raise InputError("Either filelist or dicomlist must be given as input") + + # attach the first dicom header to the object if self.filelist is not None: - data = self.get_multislice_3d_data(dicom.read_file(self.filelist[0])) + self.firstdcmheader = dicom.dcmread(self.filelist[0]) else: - data = self.get_multislice_3d_data(self.dicomlist[0]) - else: - # read 3d data stored in multiple 2d dicom files - data = self.get_3d_data(self.dicomlist) - - # read dynamic / gated images - else: - self.nframes = len(self.uniq_TemporalPositionIdentifiers) - if frames is None: frames = np.arange(self.nframes) + 1 - - data = [] - self.AcquisitionTimes = np.empty(self.nframes, dtype = object) - self.AcquisitionDates = np.empty(self.nframes, dtype = object) - - for frame in frames: - if self.verbose: print('Reading frame ' + str(frame) + ' / ' + str(self.nframes)) - inds = np.where(self.TemporalPositionIdentifiers == self.uniq_TemporalPositionIdentifiers[frame - 1])[0] - data.append(self.get_3d_data([self.dicomlist[i] for i in inds])) - - # add the acuqisiton date and time of every frame - if 'AcquisitionTime' in self.dicomlist[inds[0]]: - self.AcquisitionTimes[frame - 1] = self.dicomlist[inds[0]].AcquisitionTime - if 'AcquisitionDate' in self.dicomlist[inds[0]]: - self.AcquisitionDates[frame - 1] = self.dicomlist[inds[0]].AcquisitionDate - - data = np.squeeze(np.array(data)) - - return data - - #------------------------------------------------------------------------------------------------------ - def get_multislice_3d_data(self, dcm_data): - """get data from a multislice 3D dicom file (as e.g. used in SPECT or molecubes dicoms) + self.firstdcmheader = self.dicomlist[0] + + # the extra check if the dimension of the pixel array is bigger than 2 is needed + # because there are GE CT with erroneouly contain NumberOfFrames in classical slice by + # slice dicom files + if ("NumberOfFrames" in self.firstdcmheader) and ( + self.firstdcmheader.pixel_array.ndim > 2 + ): + # case of multi slice data (3d array in 1 dicom file + # getting the ImageOrientationPatient attribute is not trivial + # since it is stored in different tags by different vendors + + if "DetectorInformationSequence" in self.firstdcmheader: + # this is for multi slice data of the Siemens symbia spect + iop = self.firstdcmheader.DetectorInformationSequence[ + 0 + ].ImageOrientationPatient + else: + try: + # this is for multi slice data of molecubes + iop = ( + self.firstdcmheader.SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + except AttributeError: + # this is for multi slice data from PMOD + try: + iop = ( + self.firstdcmheader.PerFrameFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + except AttributeError: + # this is for multi slice data from RayStation + iop = self.firstdcmheader.ImageOrientationPatient + + self.x = np.array(iop[:3], dtype=float) + self.y = np.array(iop[3:], dtype=float) + + # set member variable that shows whether data has been read in + self.read_all_dcms = True - Parameters - ---------- - dcm_data : pydicom FileDataset - as returned by pydicom.read_file + else: + self.x = np.array( + self.firstdcmheader.ImageOrientationPatient[0:3], dtype=float + ) + self.y = np.array( + self.firstdcmheader.ImageOrientationPatient[3:], dtype=float + ) - Returns - ------- - a 3D numpy array - """ - pixelarray = dcm_data.pixel_array.copy() - - self.Nslices, self.Nrows, self.Ncols = pixelarray.shape - - if 'RescaleSlope' in dcm_data: - pixelarray = pixelarray * dcm_data.RescaleSlope - elif 'SharedFunctionalGroupsSequence' in dcm_data: - try: - # molecubes multi slice data - pixelarray = pixelarray * float(dcm_data.SharedFunctionalGroupsSequence[0].PixelValueTransformationSequence[0].RescaleSlope) - except AttributeError: - # pmod multi slice data - pixelarray = pixelarray * float(dcm_data.PerFrameFunctionalGroupsSequence[0].PixelValueTransformationSequence[0].RescaleSlope) - - - if 'RescaleIntercept' in dcm_data: - pixelarray = pixelarray + dcm_data.RescaleIntercept - elif 'SharedFunctionalGroupsSequence' in dcm_data: - try: - # molecubes multi slice data - pixelarray = pixelarray + float(dcm_data.SharedFunctionalGroupsSequence[0].PixelValueTransformationSequence[0].RescaleIntercept) - except AttributeError: - # pmod multi slice data - pixelarray = pixelarray + float(dcm_data.PerFrameFunctionalGroupsSequence[0].PixelValueTransformationSequence[0].RescaleIntercept) - - if 'SliceThickness' in dcm_data: - self.sliceDistance = float(dcm_data.SliceThickness) - else: - # PMOD multi slice data - self.sliceDistance = float(dcm_data.SharedFunctionalGroupsSequence[0].PixelMeasuresSequence[0].SliceThickness) - - self.n0, self.n1, self.n2 = pixelarray.shape - - # generate the directional vectors and the offset - self.v1 = np.array([self.y[0]*self.dr, - self.y[1]*self.dr, - self.y[2]*self.dr]) - - self.v2 = np.array([self.x[0]*self.dc, - self.x[1]*self.dc, - self.x[2]*self.dc]) - - self.v0 = np.cross(self.v2, self.v1) - self.v0 /= np.sqrt((self.v0**2).sum()) - self.v0 *= self.sliceDistance - - # heuristic modification of v0 and normdir if SpacingBetweenSlices is negative - # tested on Siemens SPECT data - if 'SpacingBetweenSlices' in dcm_data: - if float(dcm_data.SpacingBetweenSlices) < 0: - self.v0 *= -1 - self.normdir *= -1 - - ipp = None - - if 'DetectorInformationSequence' in dcm_data: - if 'ImagePositionPatient' in dcm_data.DetectorInformationSequence[0]: - ipp = dcm_data.DetectorInformationSequence[0].ImagePositionPatient - self.offset = np.array(ipp, dtype = float) - elif 'PerFrameFunctionalGroupsSequence' in dcm_data: - if 'PlanePositionSequence' in dcm_data.PerFrameFunctionalGroupsSequence[0]: - # this is for molecubes dicom data - ipp = dcm_data.PerFrameFunctionalGroupsSequence[0].PlanePositionSequence[0].ImagePositionPatient - self.offset = np.array(ipp, dtype = float) - elif 'ImagePositionPatient' in dcm_data: - # this is for RayStation dicom data - ipp = dcm_data.ImagePositionPatient - self.offset = np.array(ipp, dtype = float) - - if ipp is None: - self.offset = np.zeros(3) - warnings.warn('Cannot find ImagePositionPatient in dicom header. Setting it to [0,0,0]') - - # reorient the patient volume to standard LPS orientation - patvol = self.reorient_volume(pixelarray) - - self.voxsize = np.array([self.xvoxsize, self.yvoxsize, self.zvoxsize]) - - self.affine = np.eye(4) - self.affine[:3,0] = self.v0 - self.affine[:3,1] = self.v1 - self.affine[:3,2] = self.v2 - self.affine[:3,3] = self.offset - - - return patvol - - - #------------------------------------------------------------------------------------------------------ - def get_3d_data(self, dicomlist): - """get the 3D data from a list of dicom data sets + # set member variable that shows whether data has been read in + self.read_all_dcms = False - Parameters - ---------- - dicomframes : list - list of dicom objects from pydicom + self.n = np.cross(self.x, self.y) - Returns - ------- - a 3D numpy array - """ - d = [self.distanceMeasure(x) for x in dicomlist] - - # sort the list according to the distance measure - dicomlistsorted = [x for (y,x) in sorted(zip(d,dicomlist))] - pixelarraylistsorted = [x.pixel_array for x in dicomlistsorted] - - # store the sorted list of SOPInstanceUIDs which is needed when writing RTstructs - self.sorted_SOPClassUIDs = [x.SOPClassUID for x in dicomlistsorted] - self.sorted_SOPInstanceUIDs = [x.SOPInstanceUID for x in dicomlistsorted] - - self.Nslices = len(dicomlistsorted) - self.Nrows, self.Ncols = pixelarraylistsorted[0].shape - - if 'RescaleSlope' in dicomlistsorted[0]: - RescaleSlopes = [float(x.RescaleSlope) for x in dicomlistsorted] - else: - RescaleSlopes = [1.0] * len(dicomlistsorted) - - if 'RescaleIntercept' in dicomlistsorted[0]: - RescaleIntercepts = [float(x.RescaleIntercept) for x in dicomlistsorted] - else: - RescaleIntercepts = [0.0] * len(dicomlistsorted) - - # rescale the pixelarrays with the rescale slopes and intercepts - for i in range(len(pixelarraylistsorted)): - pixelarraylistsorted[i] = pixelarraylistsorted[i]*RescaleSlopes[i] + RescaleIntercepts[i] - - # get the first and last ImagePositionPatient vectors - self.T1 = np.array(dicomlistsorted[0].ImagePositionPatient , dtype = float) - self.TN = np.array(dicomlistsorted[-1].ImagePositionPatient, dtype = float) - self.dT = self.T1 - self.TN - - # get the distance between the dicom slices - self.sliceDistance = sorted(d)[1] - sorted(d)[0] - - # calculate the (x,y,z) offset of voxel [0,0,0] - self.offset = 1.0*self.T1 - - # now we calculate the direction vectors when moving 1 voxel along axis 0, 1, 2 - self.v0 = np.array([1.0*self.dT[0]/ (1 - self.Nslices), - 1.0*self.dT[1]/ (1 - self.Nslices), - 1.0*self.dT[2]/ (1 - self.Nslices)]) - - self.v1 = np.array([self.y[0]*self.dr, - self.y[1]*self.dr, - self.y[2]*self.dr]) - - self.v2 = np.array([self.x[0]*self.dc, - self.x[1]*self.dc, - self.x[2]*self.dc]) - - # generate a 3d volume from the sorted list of 2d pixelarrays - patvol = np.array(pixelarraylistsorted) - self.n0, self.n1, self.n2 = patvol.shape - - # reorient the patient volume to standard LPS orientation - patvol = self.reorient_volume(patvol) - - self.voxsize = np.array([self.xvoxsize, self.yvoxsize, self.zvoxsize]) - - # create affine matrix - self.affine = np.eye(4) - self.affine[:3,0] = self.v0 - self.affine[:3,1] = self.v1 - self.affine[:3,2] = self.v2 - self.affine[:3,3] = self.offset - - return patvol - - #-------------------------------------------------------------------------- - def get_3d_overlay_img(self, tag = 0x6002): - """ Read dicom overlay information and convert it to a binary image. - - Parameters - ---------- - tag : int in hex, optional - overlay tag to use, default 0x6002 + # get the row and column pixelspacing + if "PixelSpacing" in self.firstdcmheader: + self.pixelspacing = np.array(self.firstdcmheader.PixelSpacing, dtype=float) + else: + self.pixelspacing = np.array( + self.firstdcmheader.SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + + self.dr = self.pixelspacing[0] + self.dc = self.pixelspacing[1] + + # approximately transform slices in patient coord. system + self.normaxis = np.argmax(np.abs(self.n)) + self.normdir = np.sign(self.n[self.normaxis]) + self.rowaxis = np.argmax(np.abs(self.x)) + self.rowdir = np.sign(self.x[self.rowaxis]) + self.colaxis = np.argmax(np.abs(self.y)) + self.coldir = np.sign(self.y[self.colaxis]) + + # read the number of frames (time slices) + if "NumberOfTimeSlices" in self.firstdcmheader: + self.NumTimeSlices = self.firstdcmheader.NumberOfTimeSlices + else: + self.NumTimeSlices = 1 - Note - ---- - (1) up to 8 overlays can be saved in the tags 0x6000, 0x6002, 0x6004, 0x6006, 0x6008, 0x600a, 0x600c, 0x600e + # get the dicom series type to see whether we have a static or dynamic acq. + if "SeriesType" in self.firstdcmheader: + self.series_type = self.firstdcmheader.SeriesType + else: + self.series_type = dicom.multival.MultiValue(str, fallback_series_type) + warnings.warn( + f"Cannot find SeriesType in first dicom header. Setting it to {fallback_series_type}" + ) + + # ------------------------------------------------------------------------------------------------------ + def reorient_volume(self, patvol): + """reorient the raw dicom volume to LPS orientation + + Parameters + ---------- + patvol : 3d numpy array + + Returns + ------- + 3d numpy array + reoriented numpy array in LPS orientation + """ + + # check the directions of the norm, col and row dir and revert some axis if necessary + if self.normdir == -1: + patvol = patvol[::-1, :, :] + self.offset = self.offset + (self.n0 - 1) * self.v0 + self.v0 = -1.0 * self.v0 + if self.coldir == -1: + patvol = patvol[:, ::-1, :] + self.offset = self.offset + (self.n1 - 1) * self.v1 + self.v1 = -1.0 * self.v1 + if self.rowdir == -1: + patvol = patvol[:, :, ::-1] + self.offset = self.offset + (self.n2 - 1) * self.v2 + self.v2 = -1.0 * self.v2 + + # now we want to make sure that the 0, 1, 2 axis of our 3d volume corrrespond + # to the x, y, z axis in the patient coordinate system + # therefore we might need to swap some axis + if self.normaxis == 0 and self.colaxis == 1 and self.rowaxis == 2: + self.yvoxsize, self.zvoxsize = self.dr, self.dc + self.xvoxsize = self.sliceDistance + elif self.normaxis == 0 and self.colaxis == 2 and self.rowaxis == 1: + if self.verbose: + print("--- swapping axis 1 and 2") + patvol = np.swapaxes(patvol, 1, 2) + self.v1, self.v2 = self.v2, self.v1 + self.zvoxsize, self.yvoxsize = self.dr, self.dc + self.xvoxsize = self.sliceDistance + elif self.normaxis == 1 and self.colaxis == 0 and self.rowaxis == 2: + if self.verbose: + print("--- swapping axis 0 and 1") + patvol = np.swapaxes(patvol, 0, 1) + self.v0, self.v1 = self.v1, self.v0 + self.xvoxsize, self.zvoxsize = self.dr, self.dc + self.yvoxsize = self.sliceDistance + elif self.normaxis == 1 and self.colaxis == 2 and self.rowaxis == 0: + if self.verbose: + print("--- swapping axis 0 and 1") + if self.verbose: + print("--- swapping axis 0 and 2") + patvol = np.swapaxes(np.swapaxes(patvol, 0, 1), 0, 2) + self.v0, self.v1 = self.v1, self.v0 + self.v0, self.v2 = self.v2, self.v0 + self.zvoxsize, self.xvoxsize = self.dr, self.dc + self.yvoxsize = self.sliceDistance + elif self.normaxis == 2 and self.colaxis == 1 and self.rowaxis == 0: + if self.verbose: + print("--- swapping axis 0 and 2") + patvol = np.swapaxes(patvol, 0, 2) + self.v0, self.v2 = self.v2, self.v0 + self.yvoxsize, self.xvoxsize = self.dr, self.dc + self.zvoxsize = self.sliceDistance + elif self.normaxis == 2 and self.colaxis == 0 and self.rowaxis == 1: + if self.verbose: + print("--- swapping axis 0 and 2") + if self.verbose: + print("--- swapping axis 0 and 1") + patvol = np.swapaxes(np.swapaxes(patvol, 0, 2), 0, 1) + self.v0, self.v2 = self.v2, self.v0 + self.v0, self.v1 = self.v1, self.v0 + self.xvoxsize, self.yvoxsize = self.dr, self.dc + self.zvoxsize = self.sliceDistance + + # update the volume dimensions + self.n0, self.n1, self.n2 = patvol.shape + + return patvol + + # ------------------------------------------------------------------------------------------------------ + def get_data(self, frames=None): + """get the actual 3D or 4D image data + + Parameters + ---------- + frames : list of ints, optional + if the data is 4D this can be a list of frame number to be read + the default None means read all frames + + Note + ---- + This is a high level function that call the underlying function for + reading 3D, 4D or multislice data sets. + + Returns + ------- + a 3D or 4D numpy array + array containing the data + """ + if not self.read_all_dcms: + if self.verbose: + print("Analyzing dicom headers") + + if self.dicomlist is None: + self.dicomlist = [dicom.dcmread(x) for x in self.filelist] + + # check if some images have a SOPclassUID that does not belong to images and drop them + # SOPClassUID '1.2.840.10008.5.1.4.1.1.66' means Raw Data Storage + # '1.2.840.10008.5.1.4.1.1.66.x' for x in (1,2,3,4) are also not images + self.dicomlist = [ + x + for x in self.dicomlist + if not x.SOPClassUID.startswith("1.2.840.10008.5.1.4.1.1.66") + ] + + self.read_all_dcms = True + + self.TemporalPositionIdentifiers = [] + + # to figure out which 2d dicom file belongs to which time frame + # we use the TemporalPositionIdentifier (not very common) or the acquisition date time + for dcm in self.dicomlist: + if (dcm.Modality == "MR") and ("AcquisitionNumber" in dcm): + self.TemporalPositionIdentifiers.append(dcm.AcquisitionNumber) + elif (dcm.Modality == "MR") and ("EchoNumbers" in dcm): + self.TemporalPositionIdentifiers.append(dcm.EchoNumbers) + elif "TemporalPositionIdentifier" in dcm: + self.TemporalPositionIdentifiers.append( + dcm.TemporalPositionIdentifier + ) + else: + if "AcquisitionDate" in dcm: + acq_d = dcm.AcquisitionDate + else: + acq_d = "19700101" + + if "AcquisitionTime" in dcm: + acq_t = dcm.AcquisitionTime + else: + acq_t = "000000" + + # if the trigger time is in the data we add it to the acq. time + # this is needed to read GE gated PET data + if "TriggerTime" in dcm: + acq_t += "." + str(dcm.TriggerTime) + + self.TemporalPositionIdentifiers.append(acq_d + acq_t) + + self.TemporalPositionIdentifiers = np.array( + self.TemporalPositionIdentifiers + ) + self.uniq_TemporalPositionIdentifiers = np.unique( + self.TemporalPositionIdentifiers + ) + self.uniq_TemporalPositionIdentifiers.sort() + + # if an MR data contains multiple echos, we interpret it as dynamic data + if (self.dicomlist[0].Modality == "MR") and ( + len(self.uniq_TemporalPositionIdentifiers) > 1 + ): + self.series_type[0] = "DYNAMIC" + warnings.warn( + f"Found multiple Temporal Positions in MR data set. Setting series type to DYNAMIC" + ) + + # read static image + if (self.series_type[0] == "STATIC") or (self.series_type[0] == "WHOLE BODY"): + self.nframes = 1 + + # the extra check if the dimension of the pixel array is bigger than 2 is needed + # because there are GE CT with erroneouly contain NumberOfFrames in classical slice by + # slice dicom files + if "NumberOfFrames" in self.firstdcmheader and ( + self.firstdcmheader.pixel_array.ndim > 2 + ): + # read multi slice data (the whole 3d volume is in one dicom file) + if self.filelist is not None: + data = self.get_multislice_3d_data(dicom.dcmread(self.filelist[0])) + else: + data = self.get_multislice_3d_data(self.dicomlist[0]) + else: + # read 3d data stored in multiple 2d dicom files + data = self.get_3d_data(self.dicomlist) + + # read dynamic / gated images + else: + self.nframes = len(self.uniq_TemporalPositionIdentifiers) + if frames is None: + frames = np.arange(self.nframes) + 1 + + data = [] + self.AcquisitionTimes = np.empty(self.nframes, dtype=object) + self.AcquisitionDates = np.empty(self.nframes, dtype=object) + + for frame in frames: + if self.verbose: + print("Reading frame " + str(frame) + " / " + str(self.nframes)) + inds = np.where( + self.TemporalPositionIdentifiers + == self.uniq_TemporalPositionIdentifiers[frame - 1] + )[0] + data.append(self.get_3d_data([self.dicomlist[i] for i in inds])) + + # add the acuqisiton date and time of every frame + if "AcquisitionTime" in self.dicomlist[inds[0]]: + self.AcquisitionTimes[frame - 1] = self.dicomlist[ + inds[0] + ].AcquisitionTime + if "AcquisitionDate" in self.dicomlist[inds[0]]: + self.AcquisitionDates[frame - 1] = self.dicomlist[ + inds[0] + ].AcquisitionDate + + data = np.squeeze(np.array(data)) + + return data + + # ------------------------------------------------------------------------------------------------------ + def get_multislice_3d_data(self, dcm_data): + """get data from a multislice 3D dicom file (as e.g. used in SPECT or molecubes dicoms) + + Parameters + ---------- + dcm_data : pydicom FileDataset + as returned by pydicom.dcmread + + Returns + ------- + a 3D numpy array + """ + pixelarray = dcm_data.pixel_array.copy() + + self.Nslices, self.Nrows, self.Ncols = pixelarray.shape + + if "RescaleSlope" in dcm_data: + pixelarray = pixelarray * dcm_data.RescaleSlope + elif "SharedFunctionalGroupsSequence" in dcm_data: + try: + # molecubes multi slice data + pixelarray = pixelarray * float( + dcm_data.SharedFunctionalGroupsSequence[0] + .PixelValueTransformationSequence[0] + .RescaleSlope + ) + except AttributeError: + # pmod multi slice data + pixelarray = pixelarray * float( + dcm_data.PerFrameFunctionalGroupsSequence[0] + .PixelValueTransformationSequence[0] + .RescaleSlope + ) + + if "RescaleIntercept" in dcm_data: + pixelarray = pixelarray + dcm_data.RescaleIntercept + elif "SharedFunctionalGroupsSequence" in dcm_data: + try: + # molecubes multi slice data + pixelarray = pixelarray + float( + dcm_data.SharedFunctionalGroupsSequence[0] + .PixelValueTransformationSequence[0] + .RescaleIntercept + ) + except AttributeError: + # pmod multi slice data + pixelarray = pixelarray + float( + dcm_data.PerFrameFunctionalGroupsSequence[0] + .PixelValueTransformationSequence[0] + .RescaleIntercept + ) + + if "SliceThickness" in dcm_data: + self.sliceDistance = float(dcm_data.SliceThickness) + else: + # PMOD multi slice data + self.sliceDistance = float( + dcm_data.SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .SliceThickness + ) + + self.n0, self.n1, self.n2 = pixelarray.shape + + # generate the directional vectors and the offset + self.v1 = np.array( + [self.y[0] * self.dr, self.y[1] * self.dr, self.y[2] * self.dr] + ) + + self.v2 = np.array( + [self.x[0] * self.dc, self.x[1] * self.dc, self.x[2] * self.dc] + ) + + self.v0 = np.cross(self.v2, self.v1) + self.v0 /= np.sqrt((self.v0**2).sum()) + self.v0 *= self.sliceDistance + + # heuristic modification of v0 and normdir if SpacingBetweenSlices is negative + # tested on Siemens SPECT data + if "SpacingBetweenSlices" in dcm_data: + if float(dcm_data.SpacingBetweenSlices) < 0: + self.v0 *= -1 + self.normdir *= -1 + + ipp = None + + if "DetectorInformationSequence" in dcm_data: + if "ImagePositionPatient" in dcm_data.DetectorInformationSequence[0]: + ipp = dcm_data.DetectorInformationSequence[0].ImagePositionPatient + self.offset = np.array(ipp, dtype=float) + elif "PerFrameFunctionalGroupsSequence" in dcm_data: + if "PlanePositionSequence" in dcm_data.PerFrameFunctionalGroupsSequence[0]: + # this is for molecubes dicom data + ipp = ( + dcm_data.PerFrameFunctionalGroupsSequence[0] + .PlanePositionSequence[0] + .ImagePositionPatient + ) + self.offset = np.array(ipp, dtype=float) + elif "ImagePositionPatient" in dcm_data: + # this is for RayStation dicom data + ipp = dcm_data.ImagePositionPatient + self.offset = np.array(ipp, dtype=float) + + if ipp is None: + self.offset = np.zeros(3) + warnings.warn( + "Cannot find ImagePositionPatient in dicom header. Setting it to [0,0,0]" + ) + + # reorient the patient volume to standard LPS orientation + patvol = self.reorient_volume(pixelarray) + + self.voxsize = np.array([self.xvoxsize, self.yvoxsize, self.zvoxsize]) + + self.affine = np.eye(4) + self.affine[:3, 0] = self.v0 + self.affine[:3, 1] = self.v1 + self.affine[:3, 2] = self.v2 + self.affine[:3, 3] = self.offset + + return patvol + + # ------------------------------------------------------------------------------------------------------ + def get_3d_data(self, dicomlist): + """get the 3D data from a list of dicom data sets + + Parameters + ---------- + dicomframes : list + list of dicom objects from pydicom + + Returns + ------- + a 3D numpy array + """ + d = [self.distanceMeasure(x) for x in dicomlist] + + # sort the list according to the distance measure + dicomlistsorted = [x for (y, x) in sorted(zip(d, dicomlist))] + pixelarraylistsorted = [x.pixel_array for x in dicomlistsorted] + + # store the sorted list of SOPInstanceUIDs which is needed when writing RTstructs + self.sorted_SOPClassUIDs = [x.SOPClassUID for x in dicomlistsorted] + self.sorted_SOPInstanceUIDs = [x.SOPInstanceUID for x in dicomlistsorted] + + self.Nslices = len(dicomlistsorted) + self.Nrows, self.Ncols = pixelarraylistsorted[0].shape + + if "RescaleSlope" in dicomlistsorted[0]: + RescaleSlopes = [float(x.RescaleSlope) for x in dicomlistsorted] + else: + RescaleSlopes = [1.0] * len(dicomlistsorted) - (2) the generation of the binary label image was only tested for transaxial CT overlays so far + if "RescaleIntercept" in dicomlistsorted[0]: + RescaleIntercepts = [float(x.RescaleIntercept) for x in dicomlistsorted] + else: + RescaleIntercepts = [0.0] * len(dicomlistsorted) - (3) so far it only works for negative origins + # rescale the pixelarrays with the rescale slopes and intercepts + for i in range(len(pixelarraylistsorted)): + pixelarraylistsorted[i] = ( + pixelarraylistsorted[i] * RescaleSlopes[i] + RescaleIntercepts[i] + ) - Returns - ------- - 3d numpy array - a binary array containing the overlay information - """ - # up to know we assume that the input dicom list is a 3D volume - # so we use all dicom files as input for the overlay + # get the first and last ImagePositionPatient vectors + self.T1 = np.array(dicomlistsorted[0].ImagePositionPatient, dtype=float) + self.TN = np.array(dicomlistsorted[-1].ImagePositionPatient, dtype=float) + self.dT = self.T1 - self.TN + + # get the distance between the dicom slices + self.sliceDistance = sorted(d)[1] - sorted(d)[0] + + # calculate the (x,y,z) offset of voxel [0,0,0] + self.offset = 1.0 * self.T1 + + # now we calculate the direction vectors when moving 1 voxel along axis 0, 1, 2 + self.v0 = np.array( + [ + 1.0 * self.dT[0] / (1 - self.Nslices), + 1.0 * self.dT[1] / (1 - self.Nslices), + 1.0 * self.dT[2] / (1 - self.Nslices), + ] + ) + + self.v1 = np.array( + [self.y[0] * self.dr, self.y[1] * self.dr, self.y[2] * self.dr] + ) + + self.v2 = np.array( + [self.x[0] * self.dc, self.x[1] * self.dc, self.x[2] * self.dc] + ) + + # generate a 3d volume from the sorted list of 2d pixelarrays + patvol = np.array(pixelarraylistsorted) + self.n0, self.n1, self.n2 = patvol.shape + + # reorient the patient volume to standard LPS orientation + patvol = self.reorient_volume(patvol) + + self.voxsize = np.array([self.xvoxsize, self.yvoxsize, self.zvoxsize]) - if not self.read_all_dcms: - if self.dicomlist is None: - self.dicomlist = [dicom.read_file(x) for x in self.filelist] + # create affine matrix + self.affine = np.eye(4) + self.affine[:3, 0] = self.v0 + self.affine[:3, 1] = self.v1 + self.affine[:3, 2] = self.v2 + self.affine[:3, 3] = self.offset - self.read_all_dcms = True + return patvol - d = [self.distanceMeasure(x) for x in self.dicomlist] + # -------------------------------------------------------------------------- + def get_3d_overlay_img(self, tag=0x6002): + """Read dicom overlay information and convert it to a binary image. - nrows = self.firstdcmheader.Rows - ncols = self.firstdcmheader.Columns + Parameters + ---------- + tag : int in hex, optional + overlay tag to use, default 0x6002 - # sort the list according to the distance measure - dicomlistsorted = [x for (y,x) in sorted(zip(d,self.dicomlist))] + Note + ---- + (1) up to 8 overlays can be saved in the tags 0x6000, 0x6002, 0x6004, 0x6006, 0x6008, 0x600a, 0x600c, 0x600e - overlay_imgs = [] - - for dcm in dicomlistsorted: - if [tag,0x3000] in dcm: - # read the number of rows and columns for the overlay image - orows = dcm[tag,0x0010].value - ocols = dcm[tag,0x0011].value + (2) the generation of the binary label image was only tested for transaxial CT overlays so far - # read the overlay origin - orig = dcm[tag,0x0050].value + (3) so far it only works for negative origins - # read the overlay data - overlay = dcm[tag,0x3000].value + Returns + ------- + 3d numpy array + a binary array containing the overlay information + """ + # up to know we assume that the input dicom list is a 3D volume + # so we use all dicom files as input for the overlay - # the bit order of np.unpackbits is not the one of the dicom overlay standard - # which is why we need to reverse it (middle reshape) - tmp = np.unpackbits(np.frombuffer(overlay, dtype = 'uint8')).reshape(-1,8)[:,::-1].flatten()[:(orows*ocols)].reshape(orows,ocols) - - # crop the image to the correct dimensions - # if the origin is negative, we have to crop the image - if orig[0] < 0: - tmp = tmp[-orig[0]:,:] + if not self.read_all_dcms: + if self.dicomlist is None: + self.dicomlist = [dicom.dcmread(x) for x in self.filelist] - if orig[1] < 0: - tmp = tmp[:,-orig[1]:] + self.read_all_dcms = True - r = min(nrows,tmp.shape[0]) - c = min(ncols,tmp.shape[1]) + d = [self.distanceMeasure(x) for x in self.dicomlist] - tmp2 = np.zeros((nrows,ncols), dtype = 'uint8') - tmp2[:r,:c] = tmp[:r,:c] - - #tmp = tmp[-orig[0]:(-orig[0]+nrows),-orig[1]:(-orig[1]+ncols)] + nrows = self.firstdcmheader.Rows + ncols = self.firstdcmheader.Columns - overlay_imgs.append(tmp2) - else: - overlay_imgs.append(np.zeros((nrows,ncols), dtype = 'uint8')) + # sort the list according to the distance measure + dicomlistsorted = [x for (y, x) in sorted(zip(d, self.dicomlist))] - return np.swapaxes(np.array(overlay_imgs),0,2) + overlay_imgs = [] - #-------------------------------------------------------------------------- - def distanceMeasure(self,dicomslice): - # see http://nipy.org/nibabel/dicom/dicom_orientation.html - # d = position vector in the direction of the normal vector - T = np.array(dicomslice.ImagePositionPatient, dtype = float) - d = np.dot(T,self.n) - return d + for dcm in dicomlistsorted: + if [tag, 0x3000] in dcm: + # read the number of rows and columns for the overlay image + orows = dcm[tag, 0x0010].value + ocols = dcm[tag, 0x0011].value - #def setAttibute(self, attribute, value): - # for dcm in dicomlist: setattr(dcm,attribute,value) - # for dcm2 in dicomlistsorted: setattr(dcm2,attribute,value) + # read the overlay origin + orig = dcm[tag, 0x0050].value + + # read the overlay data + overlay = dcm[tag, 0x3000].value + + # the bit order of np.unpackbits is not the one of the dicom overlay standard + # which is why we need to reverse it (middle reshape) + tmp = ( + np.unpackbits(np.frombuffer(overlay, dtype="uint8")) + .reshape(-1, 8)[:, ::-1] + .flatten()[: (orows * ocols)] + .reshape(orows, ocols) + ) + + # crop the image to the correct dimensions + # if the origin is negative, we have to crop the image + if orig[0] < 0: + tmp = tmp[-orig[0] :, :] + + if orig[1] < 0: + tmp = tmp[:, -orig[1] :] + + r = min(nrows, tmp.shape[0]) + c = min(ncols, tmp.shape[1]) + + tmp2 = np.zeros((nrows, ncols), dtype="uint8") + tmp2[:r, :c] = tmp[:r, :c] + + # tmp = tmp[-orig[0]:(-orig[0]+nrows),-orig[1]:(-orig[1]+ncols)] + + overlay_imgs.append(tmp2) + else: + overlay_imgs.append(np.zeros((nrows, ncols), dtype="uint8")) + + return np.swapaxes(np.array(overlay_imgs), 0, 2) + + # -------------------------------------------------------------------------- + def distanceMeasure(self, dicomslice): + # see http://nipy.org/nibabel/dicom/dicom_orientation.html + # d = position vector in the direction of the normal vector + T = np.array(dicomslice.ImagePositionPatient, dtype=float) + d = np.dot(T, self.n) + return d + + # def setAttibute(self, attribute, value): + # for dcm in dicomlist: setattr(dcm,attribute,value) + # for dcm2 in dicomlistsorted: setattr(dcm2,attribute,value) + + # def write(self): + # for i in xrange(len(self.filelist)): + # if self.verbose: print("\nWriting dicom file: ", self.filelist[i]) + # dicom.dcmwrite(self.filelist[i],dicomlist[i]) - #def write(self): - # for i in xrange(len(self.filelist)): - # if self.verbose: print("\nWriting dicom file: ", self.filelist[i]) - # dicom.write_file(self.filelist[i],dicomlist[i]) ################################################################################ ################################################################################ ################################################################################ + class DicomSearch: - - def __init__(self, path, pattern = '*.dcm'): - self.path = path - self.pattern = pattern - self.allfiles = glob.glob(os.path.join(self.path,self.pattern)) - - self.UIDs = [] - - # first read all dicom images to get the UIDs - for fname in self.allfiles: - dicomfile = dicom.read_file(fname, force = True) - if 'SeriesInstanceUID' not in dicomfile: - continue - self.UIDs.append(dicomfile.SeriesInstanceUID) - dicomfile.clear() - - # now lets remove all duplicates - self.uniqueUIDs = list(set(self.UIDs)) - - self.inds = [] - self.files = [] - self.SeriesDescription = [] - self.AcquisitionDate = [] - self.AcquisitionTime = [] - self.PatientName = [] - self.Modality = [] - - # now read 1 dicom file of each unique UID and extract some usefule information - for uid in self.uniqueUIDs: - self.inds.append([i for i in range(len(self.UIDs)) if self.UIDs[i] == uid]) - self.files.append([self.allfiles[x] for x in self.inds[-1]]) - - dicomfile = dicom.read_file(self.files[-1][0]) - if 'SeriesDescription' in dicomfile : self.SeriesDescription.append(dicomfile.SeriesDescription) - else : self.SeriesDescription.append(None) - if 'AcquisitionDate' in dicomfile : self.AcquisitionDate.append(dicomfile.AcquisitionDate) - else : self.AcquisitionDate.append(None) - if 'AcquisitionTime' in dicomfile : self.AcquisitionTime.append(dicomfile.AcquisitionTime) - else : self.AcquisitionTime.append(None) - if 'PatientName' in dicomfile : self.PatientName.append(dicomfile.PatientName) - else : self.PatientName.append(None) - if 'Modality' in dicomfile : self.Modality.append(dicomfile.Modality) - else : self.Modality.append(None) - - dicomfile.clear() + + def __init__(self, path, pattern="*.dcm"): + self.path = path + self.pattern = pattern + self.allfiles = glob.glob(os.path.join(self.path, self.pattern)) + + self.UIDs = [] + + # first read all dicom images to get the UIDs + for fname in self.allfiles: + dicomfile = dicom.dcmread(fname, force=True) + if "SeriesInstanceUID" not in dicomfile: + continue + self.UIDs.append(dicomfile.SeriesInstanceUID) + dicomfile.clear() + + # now lets remove all duplicates + self.uniqueUIDs = list(set(self.UIDs)) + + self.inds = [] + self.files = [] + self.SeriesDescription = [] + self.AcquisitionDate = [] + self.AcquisitionTime = [] + self.PatientName = [] + self.Modality = [] + + # now read 1 dicom file of each unique UID and extract some usefule information + for uid in self.uniqueUIDs: + self.inds.append([i for i in range(len(self.UIDs)) if self.UIDs[i] == uid]) + self.files.append([self.allfiles[x] for x in self.inds[-1]]) + + dicomfile = dicom.dcmread(self.files[-1][0]) + if "SeriesDescription" in dicomfile: + self.SeriesDescription.append(dicomfile.SeriesDescription) + else: + self.SeriesDescription.append(None) + if "AcquisitionDate" in dicomfile: + self.AcquisitionDate.append(dicomfile.AcquisitionDate) + else: + self.AcquisitionDate.append(None) + if "AcquisitionTime" in dicomfile: + self.AcquisitionTime.append(dicomfile.AcquisitionTime) + else: + self.AcquisitionTime.append(None) + if "PatientName" in dicomfile: + self.PatientName.append(dicomfile.PatientName) + else: + self.PatientName.append(None) + if "Modality" in dicomfile: + self.Modality.append(dicomfile.Modality) + else: + self.Modality.append(None) + + dicomfile.clear() diff --git a/pymirc/fileio/read_rtstruct.py b/pymirc/fileio/read_rtstruct.py index 2d57233..4472a84 100644 --- a/pymirc/fileio/read_rtstruct.py +++ b/pymirc/fileio/read_rtstruct.py @@ -3,248 +3,279 @@ import numpy as np import pylab as py -from scipy.spatial import ConvexHull -from matplotlib.patches import Polygon +from scipy.spatial import ConvexHull +from matplotlib.patches import Polygon -#--------------------------------------------------------------------- + +# --------------------------------------------------------------------- def contour_orientation(c): - """ Orientation of a 2D closed Polygon - - Parameters - ---------- - c : (n,2) numpy array - containing the x,y coordinates of the contour points - - Returns - ------- - bool - meaning counter-clockwise and clockwise orientation - - References - ---------- - https://en.wikipedia.org/wiki/Curve_orientation - - """ - cc = ConvexHull(c[:,:2]) - cc.vertices.sort() - x = c[cc.vertices,0] - y = c[cc.vertices,1] - - ic = 2 - d = 0 - - while d == 0: - d = (x[1] - x[0])*(y[ic] - y[0]) - (x[ic] - x[0])*(y[1] - y[0]) - ic = (ic + 1) % c.shape[0] - - ori = (d > 0) - - return ori - -#--------------------------------------------------------------------- - -def read_rtstruct_contour_data(rtstruct_file, - roinames = None): - """Read dicom RTSTRUCT contour data - - Parameters - ---------- - rtstruct_file : str - a dicom RTSTRUCT file - - roinames : list of strings - ROINames to read - default None means read all ROIs - - Returns - ------- - list of length n - with the contour data of all n ROIs saved in the RTSTRUCT. - Every element of the list is a dictionary wih several keys. - The actual contour points are saved in the key 'contour_points' - which itself is a list of (x,3) numpy arrays containg the coordinates - of the 2D planar contours. - - Note - ---- - The most important dicom fields for RTSTRUCT are: - -FrameOfReferenceUID - -ROIContourSequence (1 element for every ROI) - -ReferenceROINumber - -ContourSequence (1 element for every 2D contour in a given ROI) - -ContourData - -GeometruCType - """ - ds = pydicom.read_file(rtstruct_file) - - # get the Frame of Reference UID - FrameOfReferenceUID = [x.FrameOfReferenceUID for x in ds.ReferencedFrameOfReferenceSequence] - - ctrs = ds.ROIContourSequence - - contour_data = [] - - allroinames = [x.ROIName if 'ROIName' in x else '' for x in ds.StructureSetROISequence] - - if roinames is None: roinames = allroinames.copy() - - for roiname in roinames: - i = allroinames.index(roiname) - - if 'ContourSequence' in ctrs[i]: - contour_seq = ctrs[i].ContourSequence - else: - warnings.warn(f"The ROI with name '{roiname}' appears to be empty.") - contour_seq = [] - - - contour_points = [] - contour_orientations = [] - - for cs in contour_seq: - cp = np.array(cs.ContourData).reshape(-1,3) - if cp.shape[0] >= 3: - contour_points.append(cp) - contour_orientations.append(contour_orientation(cp[:,:2])) - - if len(contour_points) > 0: - cd = {'contour_points': contour_points, - 'contour_orientations': contour_orientations, - 'GeometricType': cs.ContourGeometricType, - 'Number': ctrs[i].ReferencedROINumber, - 'FrameOfReferenceUID': FrameOfReferenceUID} - - for key in ['ROIName','ROIDescription','ROINumber','ReferencedFrameOfReferenceUID','ROIGenerationAlgorithm']: - if key in ds.StructureSetROISequence[i]: cd[key] = getattr(ds.StructureSetROISequence[i], key) - - contour_data.append(cd) - - - return contour_data - -#---------------------------------------------------------------------------------------------------- - -def convert_contour_data_to_roi_indices(contour_data, - aff, - shape, - radius = None, - use_contour_orientation = True): - """Convert RTSTRUCT 2D polygon contour data to 3D indices - - Parameters - ---------- - contour_data : list - of contour data as returned from read_rtstruct_contour_data() - - aff: 2d 4x4 numpy array - affine matrix that maps from voxel to world coordinates - of volume where ROIs should be applied - - shape : 3 element tuple - shape of the of volume where ROIs should be applied - - radius : float, optional - passed to matplotlib.patches.Polygon.contains_point() - - use_contour_orientation: bool - whether to use the orientation of a contour (clockwise vs counter clockwise) - to determine whether a contour defines a ROI or a holes "within" an ROI. - This approach is used by some vendors to store "holes" in 2D slices of 3D segmentations. - - Returns - ------- - list - containing the voxel indices of all ROIs - - Note - ---- - (1) matplotlib.patches.Polygon.contains_point() is used to determine whether - a voxel is inside a 2D RTSTRUCT polygon. There is ambiguity for voxels that only - lie partly inside the polygon. - - Example - ------- - dcm = pymirc.fileio.DicomVolume('mydcm_dir/*.dcm') - vol = dcm.get_data() - - contour_data = pymirc.fileio.read_rtstruct_contour_data('my_rtstruct_file.dcm') - roi_inds = pymirc.fileio.convert_contour_data_to_roi_indices(contour_data, dcm.affine, vol.hape) - - print('ROI name.....:', [x['ROIName'] for x in contour_data]) - print('ROI number...:', [x['ROINumber'] for x in contour_data]) - print('ROI mean.....:', [vol[x].mean() for x in roi_inds]) - """ - roi_inds = [] - - for iroi in range(len(contour_data)): - contour_points = contour_data[iroi]['contour_points'] - contour_orientations = np.array(contour_data[iroi]['contour_orientations']) - - roi_number = int(contour_data[iroi]['Number']) - - roi_inds0 = [] - roi_inds1 = [] - roi_inds2 = [] - - # calculate the slices of all contours - sls = np.array([int(round((np.linalg.inv(aff) @ np.concatenate([x[0,:],[1]]))[2])) for x in contour_points]) - sls_uniq = np.unique(sls) - - for sl in sls_uniq: - sl_inds = np.where(sls == sl)[0] - - if np.any(np.logical_not(contour_orientations[sl_inds])): - # case where we have negative contours (holes) in the slices - bin_img = np.zeros(shape[:2], dtype = np.int16) - for ip in sl_inds: - cp = contour_points[ip] - - # get the minimum and maximum voxel coordinate of the contour in the slice - i_min = np.floor((np.linalg.inv(aff) @ np.concatenate([cp.min(axis=0),[1]]))[:2]).astype(int) - i_max = np.ceil((np.linalg.inv(aff) @ np.concatenate([cp.max(axis=0),[1]]))[:2]).astype(int) - - n_test = i_max + 1 - i_min - - poly = Polygon(cp[:,:-1], True) - - contour_orientation = contour_orientations[ip] - - for i in np.arange(i_min[0], i_min[0] + n_test[0]): - for j in np.arange(i_min[1], i_min[1] + n_test[1]): - if poly.contains_point((aff @ np.array([i,j,sl,1]))[:2], radius = radius): - if use_contour_orientation: - if contour_orientation: - bin_img[i,j] += 1 - else: - bin_img[i,j] -= 1 - else: - bin_img[i,j] += 1 - inds0, inds1 = np.where(bin_img > 0) - inds2 = np.repeat(sl,len(inds0)) - - roi_inds0 = roi_inds0 + inds0.tolist() - roi_inds1 = roi_inds1 + inds1.tolist() - roi_inds2 = roi_inds2 + inds2.tolist() - - else: - # case where we don't have negative contours (holes) in the slices - for ip in sl_inds: - cp = contour_points[ip] - - # get the minimum and maximum voxel coordinate of the contour in the slice - i_min = np.floor((np.linalg.inv(aff) @ np.concatenate([cp.min(axis=0),[1]]))[:2]).astype(int) - i_max = np.ceil((np.linalg.inv(aff) @ np.concatenate([cp.max(axis=0),[1]]))[:2]).astype(int) - - n_test = i_max + 1 - i_min - - poly = Polygon(cp[:,:-1], True) - - for i in np.arange(i_min[0], i_min[0] + n_test[0]): - for j in np.arange(i_min[1], i_min[1] + n_test[1]): - if poly.contains_point((aff @ np.array([i,j,sl,1]))[:2], radius = radius): - roi_inds0.append(i) - roi_inds1.append(j) - roi_inds2.append(sl) - - roi_inds.append((np.array(roi_inds0), np.array(roi_inds1), np.array(roi_inds2))) - - return roi_inds + """Orientation of a 2D closed Polygon + + Parameters + ---------- + c : (n,2) numpy array + containing the x,y coordinates of the contour points + + Returns + ------- + bool + meaning counter-clockwise and clockwise orientation + + References + ---------- + https://en.wikipedia.org/wiki/Curve_orientation + + """ + cc = ConvexHull(c[:, :2]) + cc.vertices.sort() + x = c[cc.vertices, 0] + y = c[cc.vertices, 1] + + ic = 2 + d = 0 + + while d == 0: + d = (x[1] - x[0]) * (y[ic] - y[0]) - (x[ic] - x[0]) * (y[1] - y[0]) + ic = (ic + 1) % c.shape[0] + + ori = d > 0 + + return ori + + +# --------------------------------------------------------------------- + + +def read_rtstruct_contour_data(rtstruct_file, roinames=None): + """Read dicom RTSTRUCT contour data + + Parameters + ---------- + rtstruct_file : str + a dicom RTSTRUCT file + + roinames : list of strings + ROINames to read - default None means read all ROIs + + Returns + ------- + list of length n + with the contour data of all n ROIs saved in the RTSTRUCT. + Every element of the list is a dictionary wih several keys. + The actual contour points are saved in the key 'contour_points' + which itself is a list of (x,3) numpy arrays containg the coordinates + of the 2D planar contours. + + Note + ---- + The most important dicom fields for RTSTRUCT are: + -FrameOfReferenceUID + -ROIContourSequence (1 element for every ROI) + -ReferenceROINumber + -ContourSequence (1 element for every 2D contour in a given ROI) + -ContourData + -GeometruCType + """ + ds = pydicom.dcmread(rtstruct_file) + + # get the Frame of Reference UID + FrameOfReferenceUID = [ + x.FrameOfReferenceUID for x in ds.ReferencedFrameOfReferenceSequence + ] + + ctrs = ds.ROIContourSequence + + contour_data = [] + + allroinames = [ + x.ROIName if "ROIName" in x else "" for x in ds.StructureSetROISequence + ] + + if roinames is None: + roinames = allroinames.copy() + + for roiname in roinames: + i = allroinames.index(roiname) + + if "ContourSequence" in ctrs[i]: + contour_seq = ctrs[i].ContourSequence + else: + warnings.warn(f"The ROI with name '{roiname}' appears to be empty.") + contour_seq = [] + + contour_points = [] + contour_orientations = [] + + for cs in contour_seq: + cp = np.array(cs.ContourData).reshape(-1, 3) + if cp.shape[0] >= 3: + contour_points.append(cp) + contour_orientations.append(contour_orientation(cp[:, :2])) + + if len(contour_points) > 0: + cd = { + "contour_points": contour_points, + "contour_orientations": contour_orientations, + "GeometricType": cs.ContourGeometricType, + "Number": ctrs[i].ReferencedROINumber, + "FrameOfReferenceUID": FrameOfReferenceUID, + } + + for key in [ + "ROIName", + "ROIDescription", + "ROINumber", + "ReferencedFrameOfReferenceUID", + "ROIGenerationAlgorithm", + ]: + if key in ds.StructureSetROISequence[i]: + cd[key] = getattr(ds.StructureSetROISequence[i], key) + + contour_data.append(cd) + + return contour_data + + +# ---------------------------------------------------------------------------------------------------- + + +def convert_contour_data_to_roi_indices( + contour_data, aff, shape, radius=None, use_contour_orientation=True +): + """Convert RTSTRUCT 2D polygon contour data to 3D indices + + Parameters + ---------- + contour_data : list + of contour data as returned from read_rtstruct_contour_data() + + aff: 2d 4x4 numpy array + affine matrix that maps from voxel to world coordinates + of volume where ROIs should be applied + + shape : 3 element tuple + shape of the of volume where ROIs should be applied + + radius : float, optional + passed to matplotlib.patches.Polygon.contains_point() + + use_contour_orientation: bool + whether to use the orientation of a contour (clockwise vs counter clockwise) + to determine whether a contour defines a ROI or a holes "within" an ROI. + This approach is used by some vendors to store "holes" in 2D slices of 3D segmentations. + + Returns + ------- + list + containing the voxel indices of all ROIs + + Note + ---- + (1) matplotlib.patches.Polygon.contains_point() is used to determine whether + a voxel is inside a 2D RTSTRUCT polygon. There is ambiguity for voxels that only + lie partly inside the polygon. + + Example + ------- + dcm = pymirc.fileio.DicomVolume('mydcm_dir/*.dcm') + vol = dcm.get_data() + + contour_data = pymirc.fileio.read_rtstruct_contour_data('my_rtstruct_file.dcm') + roi_inds = pymirc.fileio.convert_contour_data_to_roi_indices(contour_data, dcm.affine, vol.hape) + + print('ROI name.....:', [x['ROIName'] for x in contour_data]) + print('ROI number...:', [x['ROINumber'] for x in contour_data]) + print('ROI mean.....:', [vol[x].mean() for x in roi_inds]) + """ + roi_inds = [] + + for iroi in range(len(contour_data)): + contour_points = contour_data[iroi]["contour_points"] + contour_orientations = np.array(contour_data[iroi]["contour_orientations"]) + + roi_number = int(contour_data[iroi]["Number"]) + + roi_inds0 = [] + roi_inds1 = [] + roi_inds2 = [] + + # calculate the slices of all contours + sls = np.array( + [ + int(round((np.linalg.inv(aff) @ np.concatenate([x[0, :], [1]]))[2])) + for x in contour_points + ] + ) + sls_uniq = np.unique(sls) + + for sl in sls_uniq: + sl_inds = np.where(sls == sl)[0] + + if np.any(np.logical_not(contour_orientations[sl_inds])): + # case where we have negative contours (holes) in the slices + bin_img = np.zeros(shape[:2], dtype=np.int16) + for ip in sl_inds: + cp = contour_points[ip] + + # get the minimum and maximum voxel coordinate of the contour in the slice + i_min = np.floor( + (np.linalg.inv(aff) @ np.concatenate([cp.min(axis=0), [1]]))[:2] + ).astype(int) + i_max = np.ceil( + (np.linalg.inv(aff) @ np.concatenate([cp.max(axis=0), [1]]))[:2] + ).astype(int) + + n_test = i_max + 1 - i_min + + poly = Polygon(cp[:, :-1], True) + + contour_orientation = contour_orientations[ip] + + for i in np.arange(i_min[0], i_min[0] + n_test[0]): + for j in np.arange(i_min[1], i_min[1] + n_test[1]): + if poly.contains_point( + (aff @ np.array([i, j, sl, 1]))[:2], radius=radius + ): + if use_contour_orientation: + if contour_orientation: + bin_img[i, j] += 1 + else: + bin_img[i, j] -= 1 + else: + bin_img[i, j] += 1 + inds0, inds1 = np.where(bin_img > 0) + inds2 = np.repeat(sl, len(inds0)) + + roi_inds0 = roi_inds0 + inds0.tolist() + roi_inds1 = roi_inds1 + inds1.tolist() + roi_inds2 = roi_inds2 + inds2.tolist() + + else: + # case where we don't have negative contours (holes) in the slices + for ip in sl_inds: + cp = contour_points[ip] + + # get the minimum and maximum voxel coordinate of the contour in the slice + i_min = np.floor( + (np.linalg.inv(aff) @ np.concatenate([cp.min(axis=0), [1]]))[:2] + ).astype(int) + i_max = np.ceil( + (np.linalg.inv(aff) @ np.concatenate([cp.max(axis=0), [1]]))[:2] + ).astype(int) + + n_test = i_max + 1 - i_min + + poly = Polygon(cp[:, :-1], True) + + for i in np.arange(i_min[0], i_min[0] + n_test[0]): + for j in np.arange(i_min[1], i_min[1] + n_test[1]): + if poly.contains_point( + (aff @ np.array([i, j, sl, 1]))[:2], radius=radius + ): + roi_inds0.append(i) + roi_inds1.append(j) + roi_inds2.append(sl) + + roi_inds.append((np.array(roi_inds0), np.array(roi_inds1), np.array(roi_inds2))) + + return roi_inds diff --git a/setup.py b/setup.py index e808bac..bb9f705 100644 --- a/setup.py +++ b/setup.py @@ -2,28 +2,30 @@ setuptools.setup( name="pymirc", - use_scm_version={'fallback_version':'unkown'}, - setup_requires=['setuptools_scm','setuptools_scm_git_archive'], + use_scm_version={"fallback_version": "unkown"}, + setup_requires=["setuptools_scm", "setuptools_scm_git_archive"], author="Georg Schramm, Tom Eelbode, Jeroen Bertels", author_email="georg.schramm@kuleuven.be", description="Python imaging utilities developed in the medical imaging research center of KU Leuven", long_description="Python imaging utilities developed in the medical imaging research center of KU Leuven", - license='LGPL-3.0-or-later', - license_files = ('LICENSE',), + license="LGPL-3.0-or-later", + license_files=("LICENSE",), long_description_content_type="text/markdown", url="https://github.com/gschramm/pymirc", - packages=setuptools.find_packages(exclude = ["data","examples","tutorial"]), + packages=setuptools.find_packages(exclude=["data", "examples", "tutorial"]), classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "Operating System :: OS Independent", ], - python_requires='>=3.6', - install_requires=['numpy>=1.15', - 'scipy>=1.1', - 'matplotlib>=2.2.2', - 'pydicom>=1.1,<3', - 'scikit-image>=0.14', - 'numba>=0.39', - 'nibabel>=3.0'], + python_requires=">=3.6", + install_requires=[ + "numpy>=1.15", + "scipy>=1.1", + "matplotlib>=2.2.2", + "pydicom>=1.1", + "scikit-image>=0.14", + "numba>=0.39", + "nibabel>=3.0", + ], ) From 803df71bc9c46aedf23f9ba65bacc1c333c76f14 Mon Sep 17 00:00:00 2001 From: Georg Schramm Date: Wed, 27 Nov 2024 19:07:29 +0100 Subject: [PATCH 2/4] update dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bb9f705..1d3ff01 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ "numpy>=1.15", "scipy>=1.1", "matplotlib>=2.2.2", - "pydicom>=1.1", + "pydicom>=2.0", "scikit-image>=0.14", "numba>=0.39", "nibabel>=3.0", From 373fad6d0e03e31081a0586a086cbd18a20f0be2 Mon Sep 17 00:00:00 2001 From: Georg Schramm Date: Wed, 27 Nov 2024 22:27:18 +0100 Subject: [PATCH 3/4] black reformatting --- pymirc/__init__.py | 1 + pymirc/fileio/__init__.py | 9 +- .../fileio/radioPharmaceuticalInfoSequence.py | 577 ++++----- pymirc/fileio/write_dicom.py | 852 +++++++------ pymirc/image_operations/__init__.py | 22 +- pymirc/image_operations/aff_transform.py | 389 +++--- pymirc/image_operations/backward_3d_warp.py | 170 +-- .../binary_2d_image_to_contours.py | 322 ++--- pymirc/image_operations/grad.py | 415 ++++--- pymirc/image_operations/mincostpath.py | 293 ++--- .../random_deformation_field.py | 136 +- pymirc/image_operations/reorient.py | 124 +- pymirc/image_operations/resample_cont_cont.py | 85 +- pymirc/image_operations/resample_img_cont.py | 294 ++--- pymirc/image_operations/rigid_registration.py | 203 +-- pymirc/image_operations/zoom3d.py | 650 +++++----- pymirc/metrics/cost_functions.py | 377 +++--- pymirc/metrics/tf_losses.py | 121 +- pymirc/metrics/tf_metrics.py | 399 +++--- pymirc/viewer/threeaxisviewer.py | 1100 ++++++++++------- 20 files changed, 3500 insertions(+), 3039 deletions(-) diff --git a/pymirc/__init__.py b/pymirc/__init__.py index b080113..1edd318 100644 --- a/pymirc/__init__.py +++ b/pymirc/__init__.py @@ -4,6 +4,7 @@ from . import viewer from pkg_resources import get_distribution, DistributionNotFound + try: __version__ = get_distribution(__name__).version except DistributionNotFound: diff --git a/pymirc/fileio/__init__.py b/pymirc/fileio/__init__.py index 3757a55..aedea01 100644 --- a/pymirc/fileio/__init__.py +++ b/pymirc/fileio/__init__.py @@ -1,6 +1,9 @@ -from .read_dicom import DicomVolume, DicomSearch -from .write_dicom import write_3d_static_dicom, write_4d_dicom, write_dicom_slice -from .read_rtstruct import read_rtstruct_contour_data, convert_contour_data_to_roi_indices +from .read_dicom import DicomVolume, DicomSearch +from .write_dicom import write_3d_static_dicom, write_4d_dicom, write_dicom_slice +from .read_rtstruct import ( + read_rtstruct_contour_data, + convert_contour_data_to_roi_indices, +) from .labelvol_to_rtstruct import labelvol_to_rtstruct diff --git a/pymirc/fileio/radioPharmaceuticalInfoSequence.py b/pymirc/fileio/radioPharmaceuticalInfoSequence.py index e0d4c8b..3e83672 100644 --- a/pymirc/fileio/radioPharmaceuticalInfoSequence.py +++ b/pymirc/fileio/radioPharmaceuticalInfoSequence.py @@ -2,284 +2,309 @@ import warnings + def GE_PET_nuclide_code_dict(): - d = { - '11C':'C-105A1', - '13N':'C-107A1', - '14O':'C-1018C', - '15O':'C-B1038', - '18F':'C-111A1', - '22Na':'C-155A1', - '38K':'C-135A4', - '43Sc':'126605', - '44Sc':'126600', - '45Ti':'C-166A2', - '51Mn':'126601', - '52Fe':'C-130A1', - '52Mn':'C-149A1', - '52mMn':'126607', - '60Cu':'C-127A4', - '61Cu':'C-127A1', - '62Cu':'C-127A5', - '62Zn':'C-141A1', - '64Cu':'C-127A2', - '66Ga':'C-131A1', - '68Ga':'C-131A3', - '68Ge':'C-128A2', - '70As': '126602', - '72As':'C-115A2', - '73Se':'C-116A2', - '75Br':'C-113A1', - '76Br':'C-113A2', - '77Br':'C-113A3', - '82Rb':'C-159A2', - '86Y':'C-162A3', - '89Zr':'C-168A4', - '90Nb':'126603', - '90Y':'C-162A7', - '94mTc':'C-163AA', - '124I':'C-114A5', - '152Tb':'126606'} - - return d + d = { + "11C": "C-105A1", + "13N": "C-107A1", + "14O": "C-1018C", + "15O": "C-B1038", + "18F": "C-111A1", + "22Na": "C-155A1", + "38K": "C-135A4", + "43Sc": "126605", + "44Sc": "126600", + "45Ti": "C-166A2", + "51Mn": "126601", + "52Fe": "C-130A1", + "52Mn": "C-149A1", + "52mMn": "126607", + "60Cu": "C-127A4", + "61Cu": "C-127A1", + "62Cu": "C-127A5", + "62Zn": "C-141A1", + "64Cu": "C-127A2", + "66Ga": "C-131A1", + "68Ga": "C-131A3", + "68Ge": "C-128A2", + "70As": "126602", + "72As": "C-115A2", + "73Se": "C-116A2", + "75Br": "C-113A1", + "76Br": "C-113A2", + "77Br": "C-113A3", + "82Rb": "C-159A2", + "86Y": "C-162A3", + "89Zr": "C-168A4", + "90Nb": "126603", + "90Y": "C-162A7", + "94mTc": "C-163AA", + "124I": "C-114A5", + "152Tb": "126606", + } + + return d + def radioPharmaceuticalDict(): - rpd = { - '126752' : ['28H1 ^89^Zr' ,'DCM'], - '126713' : ['2FA F^18^' ,'DCM'], - '126751' : ['7D12 ^89^Zr' ,'DCM'], - '126750' : ['7E11 ^89^Zr' ,'DCM'], - 'C-B1043' : ['Acetate C^11^' ,'SRT'], - '126729' : ['AGN-150998 ^89^Zr' ,'DCM'], - 'C-B103C' : ['Ammonia N^13^' ,'SRT'], - '126754' : ['Anti-B220 ^89^Zr' ,'DCM'], - '126700' : ['ATSM Cu^60^' ,'DCM'], - '126701' : ['ATSM Cu^61^' ,'DCM'], - '126702' : ['ATSM Cu^62^' ,'DCM'], - 'C-B07DB' : ['ATSM Cu^64^' ,'SRT'], - '126722' : ['Benralizumab ^89^Zr' ,'DCM'], - '126516' : ['Bevacizumab ^89^Zr' ,'DCM'], - '126727' : ['Blinatumomab ^89^Zr' ,'DCM'], - '126735' : ['Brentuximab ^89^Zr' ,'DCM'], - 'C-B07DC' : ['Butanol O^15^' ,'SRT'], - 'C-B103B' : ['Carbon dioxide O^15^' ,'SRT'], - 'C-B1045' : ['Carbon monoxide C^11^' ,'SRT'], - 'C-B103A' : ['Carbon monoxide O^15^' ,'SRT'], - 'C-B103F' : ['Carfentanil C^11^' ,'SRT'], - '126513' : ['Cetuximab ^89^Zr' ,'DCM'], - '126517' : ['cG250-F(ab)(2) ^89^Zr' ,'DCM'], - '126703' : ['Choline C^11^' ,'DCM'], - '126715' : ['CLR1404 I^124^' ,'DCM'], - '126716' : ['CLR1404 I^131^' ,'DCM'], - '126746' : ['cMAb U36 ^89^Zr' ,'DCM'], - '126515' : ['cU36 ^89^Zr' ,'DCM'], - '126762' : ['Df-[FK](2) ^89^Zr' ,'DCM'], - '126763' : ['Df-[FK](2)-3PEG(4) ^89^Zr' ,'DCM'], - '126520' : ['Df-CD45 ^89^Zr' ,'DCM'], - '126760' : ['Df-FK ^89^Zr' ,'DCM'], - '126761' : ['Df-FK-PEG(3) ^89^Zr' ,'DCM'], - '126747' : ['DN30 ^89^Zr' ,'DCM'], - '126519' : ['E4G10 ^89^Zr' ,'DCM'], - '126732' : ['Ecromeximab ^89^Zr' ,'DCM'], - 'C2713594' : ['Edotreotide Ga^68^' ,'UMLS'], - 'C-B07DD' : ['EDTA Ga^68^' ,'SRT'], - '126704' : ['Fallypride C^11^' ,'DCM'], - '126705' : ['Fallypride F^18^' ,'DCM'], - '126706' : ['FLB 457 C^11^' ,'DCM'], - '126501' : ['Florbetaben F^18^' ,'DCM'], - 'C-E0269' : ['Florbetapir F^18^' ,'SRT'], - '126503' : ['Flubatine F^18^' ,'DCM'], - '126712' : ['Flubatine F^18^' ,'DCM'], - 'C-E0265' : ['Fluciclatide F^18^' ,'SRT'], - 'C-E026A' : ['Fluciclovine F^18^' ,'SRT'], - 'C-B07DE' : ['Flumazenil C^11^' ,'SRT'], - 'C-B07DF' : ['Flumazenil F^18^' ,'SRT'], - 'C-B07E0' : ['Fluorethyltyrosin F^18^' ,'SRT'], - 'C-B07E4' : ['Fluorobenzothiazole F^18^' ,'SRT'], - 'C-E0273' : ['Fluorocholine F^18^' ,'SRT'], - 'C-B1031' : ['Fluorodeoxyglucose F^18^' ,'SRT'], - 'C1831937' : ['Fluoroestradiol (FES) F^18^' ,'UMLS'], - 'C1541539' : ['Fluoroetanidazole F^18^' ,'UMLS'], - 'C-B1034' : ['Fluoro-L-dopa F^18^' ,'SRT'], - 'C-B07E2' : ['Fluoromethane F^18^' ,'SRT'], - 'C-B07E1' : ['Fluoromisonidazole F^18^' ,'SRT'], - 'C2934038' : ['Fluoropropyl-dihydrotetrabenazine (DTBZ) F^18^' ,'UMLS'], - '126707' : ['Fluorotriopride F^18^' ,'DCM'], - 'C-B07E3' : ['Fluorouracil F^18^' ,'SRT'], - 'C-E0267' : ['Flutemetamol F^18^' ,'SRT'], - '126748' : ['Fresolimumab ^89^Zr' ,'DCM'], - '126731' : ['GA201 ^89^Zr' ,'DCM'], - 'C-B1046' : ['Germanium Ge^68^' ,'SRT'], - '126724' : ['Glembatumumab vedotin ^89^Zr' ,'DCM'], - 'C-B103D' : ['Glutamate N^13^' ,'SRT'], - '126709' : ['Glutamine C^11^' ,'DCM'], - '126710' : ['Glutamine C^14^' ,'DCM'], - '126711' : ['Glutamine F^18^' ,'DCM'], - 'C2981788' : ['ISO-1 F^18^' ,'UMLS'], - '126514' : ['J591 ^89^Zr' ,'DCM'], - '126740' : ['Margetuximab ^89^Zr' ,'DCM'], - '126730' : ['MEDI-551 ^89^Zr' ,'DCM'], - 'C-B07E5' : ['Mespiperone C^11^' ,'SRT'], - 'C-B103E' : ['Methionine C^11^' ,'SRT'], - '126738' : ['Mogamulizumab ^89^Zr' ,'DCM'], - '126510' : ['Monoclonal Antibody (mAb) ^64^Cu' ,'DCM'], - '126511' : ['Monoclonal Antibody (mAb) ^89^Zr' ,'DCM'], - 'C-B07E6' : ['Monoclonal antibody I^124^' ,'SRT'], - '126753' : ['Nanocolloidal albumin ^89^Zr' ,'DCM'], - '126714' : ['Nifene F^18^' ,'DCM'], - '126721' : ['Obinituzimab ^89^Zr' ,'DCM'], - '126723' : ['Ocaratuzumab ^89^Zr' ,'DCM'], - 'C-B1038' : ['Oxygen O^15^' ,'SRT'], - 'C-B1039' : ['Oxygen-water O^15^' ,'SRT'], - 'C-B1044' : ['Palmitate C^11^' ,'SRT'], - '126736' : ['Panitumumab ^89^Zr' ,'DCM'], - '126728' : ['Pegdinetanib ^89^Zr' ,'DCM'], - '126725' : ['Pinatuzumab vedotin ^89^Zr' ,'DCM'], - '126500' : ['Pittsburgh compound B C^11^' ,'DCM'], - '126726' : ['Polatuzumab vedotin ^89^Zr' ,'DCM'], - 'C-B07E7' : ['PTSM Cu^62^' ,'SRT'], - '126518' : ['R1507 ^89^Zr' ,'DCM'], - 'C-B1042' : ['Raclopride C^11^' ,'SRT'], - '126742' : ['Ranibizumab ^89^Zr' ,'DCM'], - '126737' : ['Rituximab ^89^Zr' ,'DCM'], - '126755' : ['RO5323441 ^89^Zr' ,'DCM'], - '126756' : ['RO542908 ^89^Zr' ,'DCM'], - '126733' : ['Roledumab ^89^Zr' ,'DCM'], - 'C-B1037' : ['Rubidium chloride Rb^82^' ,'SRT'], - '126741' : ['SAR3419 ^89^Zr' ,'DCM'], - 'C-B1032' : ['Sodium fluoride F^18^' ,'SRT'], - 'C-B07E8' : ['Sodium iodide I^124^' ,'SRT'], - 'C-B1047' : ['Sodium Na^22^' ,'SRT'], - 'C-B1033' : ['Spiperone F^18^' ,'SRT'], - '126502' : ['T807 F^18^' ,'DCM'], - 'C-B1036' : ['Thymidine (FLT) F^18^' ,'SRT'], - '126512' : ['Trastuzumab ^89^Zr' ,'DCM'], - '126749' : ['TRC105 ^89^Zr' ,'DCM'], - 'C1742831' : ['tyrosine-3-octreotate Ga^68^' ,'UMLS'], - '126739' : ['Ublituximab ^89^Zr' ,'DCM'], - '126734' : ['XmAb5574 ^89^Zr' ,'DCM']} - - - return rpd - -#----------------------------------------------------------------------------------- + rpd = { + "126752": ["28H1 ^89^Zr", "DCM"], + "126713": ["2FA F^18^", "DCM"], + "126751": ["7D12 ^89^Zr", "DCM"], + "126750": ["7E11 ^89^Zr", "DCM"], + "C-B1043": ["Acetate C^11^", "SRT"], + "126729": ["AGN-150998 ^89^Zr", "DCM"], + "C-B103C": ["Ammonia N^13^", "SRT"], + "126754": ["Anti-B220 ^89^Zr", "DCM"], + "126700": ["ATSM Cu^60^", "DCM"], + "126701": ["ATSM Cu^61^", "DCM"], + "126702": ["ATSM Cu^62^", "DCM"], + "C-B07DB": ["ATSM Cu^64^", "SRT"], + "126722": ["Benralizumab ^89^Zr", "DCM"], + "126516": ["Bevacizumab ^89^Zr", "DCM"], + "126727": ["Blinatumomab ^89^Zr", "DCM"], + "126735": ["Brentuximab ^89^Zr", "DCM"], + "C-B07DC": ["Butanol O^15^", "SRT"], + "C-B103B": ["Carbon dioxide O^15^", "SRT"], + "C-B1045": ["Carbon monoxide C^11^", "SRT"], + "C-B103A": ["Carbon monoxide O^15^", "SRT"], + "C-B103F": ["Carfentanil C^11^", "SRT"], + "126513": ["Cetuximab ^89^Zr", "DCM"], + "126517": ["cG250-F(ab)(2) ^89^Zr", "DCM"], + "126703": ["Choline C^11^", "DCM"], + "126715": ["CLR1404 I^124^", "DCM"], + "126716": ["CLR1404 I^131^", "DCM"], + "126746": ["cMAb U36 ^89^Zr", "DCM"], + "126515": ["cU36 ^89^Zr", "DCM"], + "126762": ["Df-[FK](2) ^89^Zr", "DCM"], + "126763": ["Df-[FK](2)-3PEG(4) ^89^Zr", "DCM"], + "126520": ["Df-CD45 ^89^Zr", "DCM"], + "126760": ["Df-FK ^89^Zr", "DCM"], + "126761": ["Df-FK-PEG(3) ^89^Zr", "DCM"], + "126747": ["DN30 ^89^Zr", "DCM"], + "126519": ["E4G10 ^89^Zr", "DCM"], + "126732": ["Ecromeximab ^89^Zr", "DCM"], + "C2713594": ["Edotreotide Ga^68^", "UMLS"], + "C-B07DD": ["EDTA Ga^68^", "SRT"], + "126704": ["Fallypride C^11^", "DCM"], + "126705": ["Fallypride F^18^", "DCM"], + "126706": ["FLB 457 C^11^", "DCM"], + "126501": ["Florbetaben F^18^", "DCM"], + "C-E0269": ["Florbetapir F^18^", "SRT"], + "126503": ["Flubatine F^18^", "DCM"], + "126712": ["Flubatine F^18^", "DCM"], + "C-E0265": ["Fluciclatide F^18^", "SRT"], + "C-E026A": ["Fluciclovine F^18^", "SRT"], + "C-B07DE": ["Flumazenil C^11^", "SRT"], + "C-B07DF": ["Flumazenil F^18^", "SRT"], + "C-B07E0": ["Fluorethyltyrosin F^18^", "SRT"], + "C-B07E4": ["Fluorobenzothiazole F^18^", "SRT"], + "C-E0273": ["Fluorocholine F^18^", "SRT"], + "C-B1031": ["Fluorodeoxyglucose F^18^", "SRT"], + "C1831937": ["Fluoroestradiol (FES) F^18^", "UMLS"], + "C1541539": ["Fluoroetanidazole F^18^", "UMLS"], + "C-B1034": ["Fluoro-L-dopa F^18^", "SRT"], + "C-B07E2": ["Fluoromethane F^18^", "SRT"], + "C-B07E1": ["Fluoromisonidazole F^18^", "SRT"], + "C2934038": ["Fluoropropyl-dihydrotetrabenazine (DTBZ) F^18^", "UMLS"], + "126707": ["Fluorotriopride F^18^", "DCM"], + "C-B07E3": ["Fluorouracil F^18^", "SRT"], + "C-E0267": ["Flutemetamol F^18^", "SRT"], + "126748": ["Fresolimumab ^89^Zr", "DCM"], + "126731": ["GA201 ^89^Zr", "DCM"], + "C-B1046": ["Germanium Ge^68^", "SRT"], + "126724": ["Glembatumumab vedotin ^89^Zr", "DCM"], + "C-B103D": ["Glutamate N^13^", "SRT"], + "126709": ["Glutamine C^11^", "DCM"], + "126710": ["Glutamine C^14^", "DCM"], + "126711": ["Glutamine F^18^", "DCM"], + "C2981788": ["ISO-1 F^18^", "UMLS"], + "126514": ["J591 ^89^Zr", "DCM"], + "126740": ["Margetuximab ^89^Zr", "DCM"], + "126730": ["MEDI-551 ^89^Zr", "DCM"], + "C-B07E5": ["Mespiperone C^11^", "SRT"], + "C-B103E": ["Methionine C^11^", "SRT"], + "126738": ["Mogamulizumab ^89^Zr", "DCM"], + "126510": ["Monoclonal Antibody (mAb) ^64^Cu", "DCM"], + "126511": ["Monoclonal Antibody (mAb) ^89^Zr", "DCM"], + "C-B07E6": ["Monoclonal antibody I^124^", "SRT"], + "126753": ["Nanocolloidal albumin ^89^Zr", "DCM"], + "126714": ["Nifene F^18^", "DCM"], + "126721": ["Obinituzimab ^89^Zr", "DCM"], + "126723": ["Ocaratuzumab ^89^Zr", "DCM"], + "C-B1038": ["Oxygen O^15^", "SRT"], + "C-B1039": ["Oxygen-water O^15^", "SRT"], + "C-B1044": ["Palmitate C^11^", "SRT"], + "126736": ["Panitumumab ^89^Zr", "DCM"], + "126728": ["Pegdinetanib ^89^Zr", "DCM"], + "126725": ["Pinatuzumab vedotin ^89^Zr", "DCM"], + "126500": ["Pittsburgh compound B C^11^", "DCM"], + "126726": ["Polatuzumab vedotin ^89^Zr", "DCM"], + "C-B07E7": ["PTSM Cu^62^", "SRT"], + "126518": ["R1507 ^89^Zr", "DCM"], + "C-B1042": ["Raclopride C^11^", "SRT"], + "126742": ["Ranibizumab ^89^Zr", "DCM"], + "126737": ["Rituximab ^89^Zr", "DCM"], + "126755": ["RO5323441 ^89^Zr", "DCM"], + "126756": ["RO542908 ^89^Zr", "DCM"], + "126733": ["Roledumab ^89^Zr", "DCM"], + "C-B1037": ["Rubidium chloride Rb^82^", "SRT"], + "126741": ["SAR3419 ^89^Zr", "DCM"], + "C-B1032": ["Sodium fluoride F^18^", "SRT"], + "C-B07E8": ["Sodium iodide I^124^", "SRT"], + "C-B1047": ["Sodium Na^22^", "SRT"], + "C-B1033": ["Spiperone F^18^", "SRT"], + "126502": ["T807 F^18^", "DCM"], + "C-B1036": ["Thymidine (FLT) F^18^", "SRT"], + "126512": ["Trastuzumab ^89^Zr", "DCM"], + "126749": ["TRC105 ^89^Zr", "DCM"], + "C1742831": ["tyrosine-3-octreotate Ga^68^", "UMLS"], + "126739": ["Ublituximab ^89^Zr", "DCM"], + "126734": ["XmAb5574 ^89^Zr", "DCM"], + } + + return rpd + + +# ----------------------------------------------------------------------------------- + def radioNuclideDict(): - isd = { - 'C-105A1' : ['^11^Carbon', 'SRT'], - 'C-107A1' : ['^13^Nitrogen', 'SRT'], - 'C-1018C' : ['^14^Oxygen', 'SRT'], - 'C-B1038' : ['^15^Oxygen', 'SRT'], - 'C-111A1' : ['^18^Fluorine', 'SRT'], - 'C-155A1' : ['^22^Sodium', 'SRT'], - 'C-135A4' : ['^38^Potassium', 'SRT'], - '126605' : ['^43^Scandium', 'DCM'], - '126600' : ['^44^Scandium', 'DCM'], - 'C-166A2' : ['^45^Titanium', 'SRT'], - '126601' : ['^51^Manganese', 'DCM'], - 'C-130A1' : ['^52^Iron', 'SRT'], - 'C-149A1' : ['^52^Manganese', 'SRT'], - '126607' : ['^52m^Manganese', 'DCM'], - 'C-127A4' : ['^60^Copper', 'SRT'], - 'C-127A1' : ['^61^Copper', 'SRT'], - 'C-127A5' : ['^62^Copper', 'SRT'], - 'C-141A1' : ['^62^Zinc', 'SRT'], - 'C-127A2' : ['^64^Copper', 'SRT'], - 'C-131A1' : ['^66^Gallium', 'SRT'], - 'C-131A3' : ['^68^Gallium', 'SRT'], - 'C-128A2' : ['^68^Germanium', 'SRT'], - '126602' : ['^70^Arsenic', 'DCM'], - 'C-115A2' : ['^72^Arsenic', 'SRT'], - 'C-116A2' : ['^73^Selenium', 'SRT'], - 'C-113A1' : ['^75^Bromine', 'SRT'], - 'C-113A2' : ['^76^Bromine', 'SRT'], - 'C-113A3' : ['^77^Bromine', 'SRT'], - 'C-159A2' : ['^82^Rubidium', 'SRT'], - 'C-162A3' : ['^86^Yttrium', 'SRT'], - 'C-168A4' : ['^89^Zirconium', 'SRT'], - '126603' : ['^90^Niobium', 'DCM'], - 'C-162A7' : ['^90^Yttrium', 'SRT'], - 'C-163AA' : ['^94m^Technetium', 'SRT'], - 'C-114A5' : ['^124^Iodine', 'SRT'], - '126606' : ['^152^Terbium', 'DCM']} - - return isd - - -#----------------------------------------------------------------------------------- - -def radioNuclideCodeSequence(codeval = 'C-111A1'): - - rnd = radioNuclideDict() - - ds = Dataset() - - if codeval in rnd.keys(): - ds.CodeValue = codeval - val = rnd[codeval] - ds.CodeMeaning = val[0] - ds.CodingSchemeDesignator = val[1] - else: - warnings.warn('code value: ' + codeval + ' not supported') - - return ds - - -#----------------------------------------------------------------------------------- - -def radioPharmaceuticalCodeSequence(codeval = 'C-B1031'): - - radiopharmadict = radioPharmaceuticalDict() - - ds = Dataset() - - if codeval in radiopharmadict.keys(): - ds.CodeValue = codeval - val = radiopharmadict[codeval] - ds.CodeMeaning = val[0] - ds.CodingSchemeDesignator = val[1] - else: - warnings.warn('code value: ' + codeval + ' not supported') - - return ds - -#----------------------------------------------------------------------------------- - -def radioPharmaceuticalInfoSequence(nuclidecode = None, - pharmacode = None, - StartDateTime = None, - TotalDose = None, # Dose in Bq - HalfLife = None, # half life in s - PositronFraction = None # positron fraction - ): - ds = Dataset() - - if nuclidecode != None: - rns = radioNuclideCodeSequence(codeval = nuclidecode) - ds.RadionuclideCodeSequence = [rns] - if pharmacode != None: - rps = radioPharmaceuticalCodeSequence(codeval = pharmacode) - ds.RadiopharmaceuticalCodeSequence = [rps] - ds.Radiopharmaceutical = rps.CodeMeaning - - # some settings for known isotopes - if HalfLife == None: - if nuclidecode == 'C-111A1': HalfLife = 6586.2 #18-F - elif nuclidecode == 'C-105A1': HalfLife = 1223.4 #11-C - elif nuclidecode == 'C-107A1': HalfLife = 597.9 #13-N - elif nuclidecode == 'C-B1038': HalfLife = 122.24 #15-O - elif nuclidecode == 'C-131A3': HalfLife = 4057.74 #68-Ga - if HalfLife != None: ds.RadionuclideHalfLife = HalfLife - - if PositronFraction == None: - if nuclidecode == 'C-111A1': PositronFraction = 0.967 #18-F - elif nuclidecode == 'C-105A1': PositronFraction = 0.998 #11-C - elif nuclidecode == 'C-107A1': PositronFraction = 0.998 #13-N - elif nuclidecode == 'C-B1038': PositronFraction = 0.999 #15-O - elif nuclidecode == 'C-131A3': PositronFraction = 0.889 #68-Ga - if PositronFraction != None: ds.RadionuclidePositronFraction = PositronFraction - - if TotalDose != None: ds.RadionuclideTotalDose = TotalDose - if StartDateTime != None: ds.RadiopharmaceuticalStartDateTime = StartDateTime - - return ds + isd = { + "C-105A1": ["^11^Carbon", "SRT"], + "C-107A1": ["^13^Nitrogen", "SRT"], + "C-1018C": ["^14^Oxygen", "SRT"], + "C-B1038": ["^15^Oxygen", "SRT"], + "C-111A1": ["^18^Fluorine", "SRT"], + "C-155A1": ["^22^Sodium", "SRT"], + "C-135A4": ["^38^Potassium", "SRT"], + "126605": ["^43^Scandium", "DCM"], + "126600": ["^44^Scandium", "DCM"], + "C-166A2": ["^45^Titanium", "SRT"], + "126601": ["^51^Manganese", "DCM"], + "C-130A1": ["^52^Iron", "SRT"], + "C-149A1": ["^52^Manganese", "SRT"], + "126607": ["^52m^Manganese", "DCM"], + "C-127A4": ["^60^Copper", "SRT"], + "C-127A1": ["^61^Copper", "SRT"], + "C-127A5": ["^62^Copper", "SRT"], + "C-141A1": ["^62^Zinc", "SRT"], + "C-127A2": ["^64^Copper", "SRT"], + "C-131A1": ["^66^Gallium", "SRT"], + "C-131A3": ["^68^Gallium", "SRT"], + "C-128A2": ["^68^Germanium", "SRT"], + "126602": ["^70^Arsenic", "DCM"], + "C-115A2": ["^72^Arsenic", "SRT"], + "C-116A2": ["^73^Selenium", "SRT"], + "C-113A1": ["^75^Bromine", "SRT"], + "C-113A2": ["^76^Bromine", "SRT"], + "C-113A3": ["^77^Bromine", "SRT"], + "C-159A2": ["^82^Rubidium", "SRT"], + "C-162A3": ["^86^Yttrium", "SRT"], + "C-168A4": ["^89^Zirconium", "SRT"], + "126603": ["^90^Niobium", "DCM"], + "C-162A7": ["^90^Yttrium", "SRT"], + "C-163AA": ["^94m^Technetium", "SRT"], + "C-114A5": ["^124^Iodine", "SRT"], + "126606": ["^152^Terbium", "DCM"], + } + + return isd + + +# ----------------------------------------------------------------------------------- + + +def radioNuclideCodeSequence(codeval="C-111A1"): + + rnd = radioNuclideDict() + + ds = Dataset() + + if codeval in rnd.keys(): + ds.CodeValue = codeval + val = rnd[codeval] + ds.CodeMeaning = val[0] + ds.CodingSchemeDesignator = val[1] + else: + warnings.warn("code value: " + codeval + " not supported") + + return ds + + +# ----------------------------------------------------------------------------------- + + +def radioPharmaceuticalCodeSequence(codeval="C-B1031"): + + radiopharmadict = radioPharmaceuticalDict() + + ds = Dataset() + + if codeval in radiopharmadict.keys(): + ds.CodeValue = codeval + val = radiopharmadict[codeval] + ds.CodeMeaning = val[0] + ds.CodingSchemeDesignator = val[1] + else: + warnings.warn("code value: " + codeval + " not supported") + + return ds + + +# ----------------------------------------------------------------------------------- + + +def radioPharmaceuticalInfoSequence( + nuclidecode=None, + pharmacode=None, + StartDateTime=None, + TotalDose=None, # Dose in Bq + HalfLife=None, # half life in s + PositronFraction=None, # positron fraction +): + ds = Dataset() + + if nuclidecode != None: + rns = radioNuclideCodeSequence(codeval=nuclidecode) + ds.RadionuclideCodeSequence = [rns] + if pharmacode != None: + rps = radioPharmaceuticalCodeSequence(codeval=pharmacode) + ds.RadiopharmaceuticalCodeSequence = [rps] + ds.Radiopharmaceutical = rps.CodeMeaning + + # some settings for known isotopes + if HalfLife == None: + if nuclidecode == "C-111A1": + HalfLife = 6586.2 # 18-F + elif nuclidecode == "C-105A1": + HalfLife = 1223.4 # 11-C + elif nuclidecode == "C-107A1": + HalfLife = 597.9 # 13-N + elif nuclidecode == "C-B1038": + HalfLife = 122.24 # 15-O + elif nuclidecode == "C-131A3": + HalfLife = 4057.74 # 68-Ga + if HalfLife != None: + ds.RadionuclideHalfLife = HalfLife + + if PositronFraction == None: + if nuclidecode == "C-111A1": + PositronFraction = 0.967 # 18-F + elif nuclidecode == "C-105A1": + PositronFraction = 0.998 # 11-C + elif nuclidecode == "C-107A1": + PositronFraction = 0.998 # 13-N + elif nuclidecode == "C-B1038": + PositronFraction = 0.999 # 15-O + elif nuclidecode == "C-131A3": + PositronFraction = 0.889 # 68-Ga + if PositronFraction != None: + ds.RadionuclidePositronFraction = PositronFraction + + if TotalDose != None: + ds.RadionuclideTotalDose = TotalDose + if StartDateTime != None: + ds.RadiopharmaceuticalStartDateTime = StartDateTime + + return ds diff --git a/pymirc/fileio/write_dicom.py b/pymirc/fileio/write_dicom.py index a0a104c..b439f94 100644 --- a/pymirc/fileio/write_dicom.py +++ b/pymirc/fileio/write_dicom.py @@ -7,444 +7,496 @@ import pydicom as dicom from pydicom.dataset import Dataset, FileDataset -from time import time - -def write_dicom_slice(pixel_array, # 2D array in LP orientation - filename = None, - outputdir = 'mydcmdir', - suffix = '.dcm', - modality = 'PT', - SecondaryCaptureDeviceManufctur = 'KUL', - uid_base = '1.2.826.0.1.3680043.9.7147.', # UID root for Georg Schramm - PatientName = 'Test^Patient', - PatientID = '08150815', - AccessionNumber = '08150815', - StudyDescription = 'test study', - SeriesDescription = 'test series', - PixelSpacing = ['1','1'], - SliceThickness = '1', - ImagePositionPatient = ['0','0','0'], - ImageOrientationPatient = ['1','0','0','0','1','0'], - CorrectedImage = ['DECY','ATTN','SCAT','DTIM','LIN','CLN'], - ImageType = 'STATIC', - RescaleSlope = None, - RescaleIntercept = None, - StudyInstanceUID = None, - SeriesInstanceUID = None, - SOPInstanceUID = None, - FrameOfReferenceUID = None, - RadiopharmaceuticalInformationSequence = None, - PatientGantryRelationshipCodeSequence = None, - sl = None, - frm = None, - verbose = False, - **kwargs): - """write a 2D PET dicom slice - - Parameters - --------- - - pixel_array : 2d numpy array - array that contains the image values - - filename : str, optional - name of the output dicom file (default: None -> automatically generated) - - outputdir : string, optional - output directory fir dicom file (default: mydcmdir) - - suffix : string, optional - suffix for dicom file (default '.dcm') - - sl, frm : int, optional - slice and frame numbers that are appended to the file name prefix if given - - SecondaryCaptureDeviceManufctur --| - uid_base | - PatientName | - PatientID | - AccessionNumber | - StudyDescription | - SeriesDescription | - PixelSpacing | - SliceThickness | - ImagePositionPatient | - ImageOrientationPatient | - CorrectedImage | ... dicom tags that should be present in a minimal - ImageType | dicom header - RescaleSlope | see function definition for default values - RescaleIntercept | default None means that they are creacted automatically - StudyInstanceUID | - SeriesInstanceUID | - SOPInstanceUID | - FrameOfReferenceUID | - RadiopharmaceuticalInformationSequence | - PatientGantryRelationshipCodeSequence --| - - **kwargs : additional tags from the standard dicom dictionary to write - the following tags could be useful: - StudyDate - StudyTime - SeriesDate - SeriesTime - AcquisitionDate - AcquisitionTime - PatientBirthDate - PatientSex - PatientAge - PatientSize - PatientWeight - ActualFrameDuration - PatientPosition - DecayCorrectionDateTime - ImagesInAcquisition - SliceLocation - NumberOfSlices - Units - DecayCorrection - ReconstructionMethod - FrameReferenceTime - DecayFactor - DoseCalibrationFactor - ImageIndex - - Returns - ------- - str - containing the ouput file name - - """ - # create output dir if it does not exist - if not os.path.exists(outputdir): os.mkdir(outputdir) - - # Populate required values for file meta information - file_meta = Dataset() - - if modality == 'PT': file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.128' - elif modality == 'NM': file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.20' - elif modality == 'CT': file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.2' - elif modality == 'MR': file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.4' - else: file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.1.1.4' - - # the MediaStorageSOPInstanceUID sould be the same as SOPInstanceUID - # however it is stored in the meta information header to have faster access - if SOPInstanceUID is None: SOPInstanceUID = dicom.uid.generate_uid(uid_base) - file_meta.MediaStorageSOPInstanceUID = SOPInstanceUID - file_meta.ImplementationClassUID = uid_base + '1.1.1' - - prefix = modality - # append the frame and slice number to the file name prefix if given - # not needed for the dicom standard but can be usefule for less dicom conform "2D" readers - if frm is not None: prefix += f'.{frm:03}' - if sl is not None: prefix += f'.{sl:05}' - - filename = f'{prefix}.{SOPInstanceUID}{suffix}' - - # Create the FileDataset instance (initially no data elements, but file_meta - # supplied) - ds = FileDataset(filename, {}, file_meta=file_meta, preamble=b"\0" * 128) - - # Add the data elements -- not trying to set all required here. Check DICOM - # standard - ds.PatientName = PatientName - ds.PatientID = PatientID - ds.AccessionNumber = AccessionNumber - - ds.Modality = modality - ds.StudyDescription = StudyDescription - ds.SeriesDescription = SeriesDescription - - if StudyInstanceUID is None: StudyInstanceUID = dicom.uid.generate_uid(uid_base) - ds.StudyInstanceUID = StudyInstanceUID - - if SeriesInstanceUID is None: SeriesInstanceUID = dicom.uid.generate_uid(uid_base) - ds.SeriesInstanceUID = SeriesInstanceUID - - if FrameOfReferenceUID is None: FrameOfReferenceUID = dicom.uid.generate_uid(uid_base) - ds.FrameOfReferenceUID = FrameOfReferenceUID - - ds.SOPInstanceUID = SOPInstanceUID - ds.SOPClassUID = file_meta.MediaStorageSOPClassUID - - ds.SecondaryCaptureDeviceManufctur = SecondaryCaptureDeviceManufctur - - ## These are the necessary imaging components of the FileDataset object. - ds.SamplesPerPixel = 1 - - if modality == 'PT' or modality == 'NM': ds.PhotometricInterpretation = "MONOCHROME1" - else: ds.PhotometricInterpretation = "MONOCHROME2" - - ds.HighBit = 15 - ds.BitsStored = 16 - ds.BitsAllocated = 16 - - # PixelRepresentation is 0 for uint16, 1 for int16 - - if pixel_array.dtype == np.uint16: - ds.PixelRepresentation = 0 - ds.RescaleIntercept = 0 - ds.RescaleSlope = 1 - elif pixel_array.dtype == np.int16: - ds.PixelRepresentation = 1 - ds.RescaleIntercept = 0 - ds.RescaleSlope = 1 - else: - ds.PixelRepresentation = 0 - - # rescale the input pixel array to uint16 if needed - if RescaleIntercept is None: RescaleIntercept = pixel_array.min() - if RescaleIntercept != 0: pixel_array = 1.0*pixel_array - RescaleIntercept - - if RescaleSlope is None: - if pixel_array.max() != 0: RescaleSlope = 1.0*pixel_array.max()/(2**16 - 1) - else: RescaleSlope = 1.0 - if RescaleSlope != 1: pixel_array = 1.0*pixel_array/RescaleSlope - - pixel_array = pixel_array.astype(np.uint16) - - ds.RescaleIntercept = RescaleIntercept - ds.RescaleSlope = RescaleSlope - - # we have to transpose the column and row direction in the dicoms - ds.PixelData = pixel_array.transpose().tobytes() - ds.Columns = pixel_array.shape[0] - ds.Rows = pixel_array.shape[1] - - # the pixel spacing also has to be inverted (transposed array saved!) - ds.PixelSpacing = PixelSpacing[::-1] - ds.SliceThickness = SliceThickness - - # Set the transfer syntax - ds.is_little_endian = True - ds.is_implicit_VR = True - - # Set creation date/time - dt = datetime.datetime.now() - timeStr = dt.strftime('%H%M%S.%f') # long format with micro seconds - ds.ContentDate = dt.strftime('%Y%m%d') - ds.ContentTime = timeStr - - # voxel coordinate tags - ds.ImagePositionPatient = ImagePositionPatient - ds.ImageOrientationPatient = ImageOrientationPatient - - # special NM tags - if modality == 'PT' or modality == 'NM': - ds.CorrectedImage = CorrectedImage - ds.ImageType = ImageType - - if RadiopharmaceuticalInformationSequence != None: - rpi = RadiopharmaceuticalInformationSequence - # this is needed otherwise the dicoms cannot be read - rpi.is_undefined_length = True - rpi[0].RadionuclideCodeSequence.is_undefined_length = True - - ds.RadiopharmaceuticalInformationSequence = rpi - - # add all key word arguments to dicom structure - for key, value in kwargs.items(): - if dicom.datadict.tag_for_keyword(key) != None: setattr(ds,key,value) - else: warnings.warn(key + ' not in standard dicom dictionary -> will not be written') - - if verbose: print("Writing file", os.path.join(outputdir,filename)) - dicom.filewriter.write_file(os.path.join(outputdir,filename), ds, write_like_original = False) - - return os.path.join(outputdir,filename) +from time import time + + +def write_dicom_slice( + pixel_array, # 2D array in LP orientation + filename=None, + outputdir="mydcmdir", + suffix=".dcm", + modality="PT", + SecondaryCaptureDeviceManufctur="KUL", + uid_base="1.2.826.0.1.3680043.9.7147.", # UID root for Georg Schramm + PatientName="Test^Patient", + PatientID="08150815", + AccessionNumber="08150815", + StudyDescription="test study", + SeriesDescription="test series", + PixelSpacing=["1", "1"], + SliceThickness="1", + ImagePositionPatient=["0", "0", "0"], + ImageOrientationPatient=["1", "0", "0", "0", "1", "0"], + CorrectedImage=["DECY", "ATTN", "SCAT", "DTIM", "LIN", "CLN"], + ImageType="STATIC", + RescaleSlope=None, + RescaleIntercept=None, + StudyInstanceUID=None, + SeriesInstanceUID=None, + SOPInstanceUID=None, + FrameOfReferenceUID=None, + RadiopharmaceuticalInformationSequence=None, + PatientGantryRelationshipCodeSequence=None, + sl=None, + frm=None, + verbose=False, + **kwargs, +): + """write a 2D PET dicom slice + + Parameters + --------- + + pixel_array : 2d numpy array + array that contains the image values + + filename : str, optional + name of the output dicom file (default: None -> automatically generated) + + outputdir : string, optional + output directory fir dicom file (default: mydcmdir) + + suffix : string, optional + suffix for dicom file (default '.dcm') + + sl, frm : int, optional + slice and frame numbers that are appended to the file name prefix if given + + SecondaryCaptureDeviceManufctur --| + uid_base | + PatientName | + PatientID | + AccessionNumber | + StudyDescription | + SeriesDescription | + PixelSpacing | + SliceThickness | + ImagePositionPatient | + ImageOrientationPatient | + CorrectedImage | ... dicom tags that should be present in a minimal + ImageType | dicom header + RescaleSlope | see function definition for default values + RescaleIntercept | default None means that they are creacted automatically + StudyInstanceUID | + SeriesInstanceUID | + SOPInstanceUID | + FrameOfReferenceUID | + RadiopharmaceuticalInformationSequence | + PatientGantryRelationshipCodeSequence --| + + **kwargs : additional tags from the standard dicom dictionary to write + the following tags could be useful: + StudyDate + StudyTime + SeriesDate + SeriesTime + AcquisitionDate + AcquisitionTime + PatientBirthDate + PatientSex + PatientAge + PatientSize + PatientWeight + ActualFrameDuration + PatientPosition + DecayCorrectionDateTime + ImagesInAcquisition + SliceLocation + NumberOfSlices + Units + DecayCorrection + ReconstructionMethod + FrameReferenceTime + DecayFactor + DoseCalibrationFactor + ImageIndex + + Returns + ------- + str + containing the ouput file name + + """ + # create output dir if it does not exist + if not os.path.exists(outputdir): + os.mkdir(outputdir) + + # Populate required values for file meta information + file_meta = Dataset() + + if modality == "PT": + file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.128" + elif modality == "NM": + file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.20" + elif modality == "CT": + file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.2" + elif modality == "MR": + file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.4" + else: + file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.4" -########################################################################################### + # the MediaStorageSOPInstanceUID sould be the same as SOPInstanceUID + # however it is stored in the meta information header to have faster access + if SOPInstanceUID is None: + SOPInstanceUID = dicom.uid.generate_uid(uid_base) + file_meta.MediaStorageSOPInstanceUID = SOPInstanceUID + file_meta.ImplementationClassUID = uid_base + "1.1.1" -def write_3d_static_dicom(vol_lps, - outputdir, - affine = np.eye(4), - uid_base = '1.2.826.0.1.3680043.9.7147.', - RescaleSlope = None, - RescaleIntercept = None, - StudyInstanceUID = None, - SeriesInstanceUID = None, - FrameOfReferenceUID = None, - use_sl_in_fname = False, - frm = None, - **kwargs): - """write a 3d PET volume to 2D dicom files + prefix = modality + # append the frame and slice number to the file name prefix if given + # not needed for the dicom standard but can be usefule for less dicom conform "2D" readers + if frm is not None: + prefix += f".{frm:03}" + if sl is not None: + prefix += f".{sl:05}" - Parameters - ---------- + filename = f"{prefix}.{SOPInstanceUID}{suffix}" - vol_lps : 3d numpy array - a 3D array in LPS orientation containing the image + # Create the FileDataset instance (initially no data elements, but file_meta + # supplied) + ds = FileDataset(filename, {}, file_meta=file_meta, preamble=b"\0" * 128) - outputdir : str, optional - the output directory for the dicom files + # Add the data elements -- not trying to set all required here. Check DICOM + # standard + ds.PatientName = PatientName + ds.PatientID = PatientID + ds.AccessionNumber = AccessionNumber - affine : 2d 4x4 numpy array, optional - affine transformation mapping from voxel to LPS coordinates - The voxel sizes, the direction vectors and the LPS origin - are derived from it. + ds.Modality = modality + ds.StudyDescription = StudyDescription + ds.SeriesDescription = SeriesDescription - uid_base : str, optional - base string for UID (default 1.2.826.0.1.3680043.9.7147) + if StudyInstanceUID is None: + StudyInstanceUID = dicom.uid.generate_uid(uid_base) + ds.StudyInstanceUID = StudyInstanceUID - RescaleSlope : float, optional - rescale Slope (default None -> maximum of image / (2**16 - 1)) + if SeriesInstanceUID is None: + SeriesInstanceUID = dicom.uid.generate_uid(uid_base) + ds.SeriesInstanceUID = SeriesInstanceUID - RescaleIntercept : float, optional - resalce Intercept (default None -> 0) + if FrameOfReferenceUID is None: + FrameOfReferenceUID = dicom.uid.generate_uid(uid_base) + ds.FrameOfReferenceUID = FrameOfReferenceUID - StudyInstanceUID : str, optional - dicom study instance UID (default None -> autom. created) + ds.SOPInstanceUID = SOPInstanceUID + ds.SOPClassUID = file_meta.MediaStorageSOPClassUID - SeriesInstanceUID : str, optional - dicom series instance UID (default None -> autom. created) + ds.SecondaryCaptureDeviceManufctur = SecondaryCaptureDeviceManufctur - FrameOfReferenceUID : str, optional - dicom frame of reference UID (default None -> autom. created) + ## These are the necessary imaging components of the FileDataset object. + ds.SamplesPerPixel = 1 - use_sl_in_fname : bool, optional - use the slice number in the file names (default False) + if modality == "PT" or modality == "NM": + ds.PhotometricInterpretation = "MONOCHROME1" + else: + ds.PhotometricInterpretation = "MONOCHROME2" + + ds.HighBit = 15 + ds.BitsStored = 16 + ds.BitsAllocated = 16 + + # PixelRepresentation is 0 for uint16, 1 for int16 + + if pixel_array.dtype == np.uint16: + ds.PixelRepresentation = 0 + ds.RescaleIntercept = 0 + ds.RescaleSlope = 1 + elif pixel_array.dtype == np.int16: + ds.PixelRepresentation = 1 + ds.RescaleIntercept = 0 + ds.RescaleSlope = 1 + else: + ds.PixelRepresentation = 0 + + # rescale the input pixel array to uint16 if needed + if RescaleIntercept is None: + RescaleIntercept = pixel_array.min() + if RescaleIntercept != 0: + pixel_array = 1.0 * pixel_array - RescaleIntercept + + if RescaleSlope is None: + if pixel_array.max() != 0: + RescaleSlope = 1.0 * pixel_array.max() / (2**16 - 1) + else: + RescaleSlope = 1.0 + if RescaleSlope != 1: + pixel_array = 1.0 * pixel_array / RescaleSlope + + pixel_array = pixel_array.astype(np.uint16) + + ds.RescaleIntercept = RescaleIntercept + ds.RescaleSlope = RescaleSlope + + # we have to transpose the column and row direction in the dicoms + ds.PixelData = pixel_array.transpose().tobytes() + ds.Columns = pixel_array.shape[0] + ds.Rows = pixel_array.shape[1] + + # the pixel spacing also has to be inverted (transposed array saved!) + ds.PixelSpacing = PixelSpacing[::-1] + ds.SliceThickness = SliceThickness + + # Set the transfer syntax + ds.is_little_endian = True + ds.is_implicit_VR = True + + # Set creation date/time + dt = datetime.datetime.now() + timeStr = dt.strftime("%H%M%S.%f") # long format with micro seconds + ds.ContentDate = dt.strftime("%Y%m%d") + ds.ContentTime = timeStr + + # voxel coordinate tags + ds.ImagePositionPatient = ImagePositionPatient + ds.ImageOrientationPatient = ImageOrientationPatient + + # special NM tags + if modality == "PT" or modality == "NM": + ds.CorrectedImage = CorrectedImage + ds.ImageType = ImageType + + if RadiopharmaceuticalInformationSequence != None: + rpi = RadiopharmaceuticalInformationSequence + # this is needed otherwise the dicoms cannot be read + rpi.is_undefined_length = True + rpi[0].RadionuclideCodeSequence.is_undefined_length = True + + ds.RadiopharmaceuticalInformationSequence = rpi + + # add all key word arguments to dicom structure + for key, value in kwargs.items(): + if dicom.datadict.tag_for_keyword(key) != None: + setattr(ds, key, value) + else: + warnings.warn( + key + " not in standard dicom dictionary -> will not be written" + ) - frm : int, optional - frm number for file name (default None means it is not used in file name) + if verbose: + print("Writing file", os.path.join(outputdir, filename)) + dicom.filewriter.write_file( + os.path.join(outputdir, filename), ds, write_like_original=False + ) - **kwargs : dict, optional - passed to write_dicom_slice + return os.path.join(outputdir, filename) - Returns - ------- - list - containing the file names of the written 2D dicom files - Note - ---- - This function is a wrapper around write_dicom_slice. - """ - # get the voxel sizes, direction vectors and the origin from the affine in case given - ux = affine[:-1,0] - uy = affine[:-1,1] - uz = affine[:-1,2] +########################################################################################### - xvoxsize = np.sqrt((ux**2).sum()) - yvoxsize = np.sqrt((uy**2).sum()) - zvoxsize = np.sqrt((uz**2).sum()) - - nx = ux / xvoxsize - ny = uy / yvoxsize - lps_origin = affine[:-1,-1] +def write_3d_static_dicom( + vol_lps, + outputdir, + affine=np.eye(4), + uid_base="1.2.826.0.1.3680043.9.7147.", + RescaleSlope=None, + RescaleIntercept=None, + StudyInstanceUID=None, + SeriesInstanceUID=None, + FrameOfReferenceUID=None, + use_sl_in_fname=False, + frm=None, + **kwargs, +): + """write a 3d PET volume to 2D dicom files - if RescaleSlope is None: RescaleSlope = (vol_lps.max() - vol_lps.min()) / (2**16 - 1) - if RescaleIntercept is None: RescaleIntercept = vol_lps.min() + Parameters + ---------- - # calculate the normalized direction vector in z direction - nz = np.cross(nx,ny) + vol_lps : 3d numpy array + a 3D array in LPS orientation containing the image - if StudyInstanceUID is None: StudyInstanceUID = dicom.uid.generate_uid(uid_base) - if SeriesInstanceUID is None: SeriesInstanceUID = dicom.uid.generate_uid(uid_base) - if FrameOfReferenceUID is None: FrameOfReferenceUID = dicom.uid.generate_uid(uid_base) + outputdir : str, optional + the output directory for the dicom files - numSlices = vol_lps.shape[2] + affine : 2d 4x4 numpy array, optional + affine transformation mapping from voxel to LPS coordinates + The voxel sizes, the direction vectors and the LPS origin + are derived from it. - fnames = [] + uid_base : str, optional + base string for UID (default 1.2.826.0.1.3680043.9.7147) - if frm is None: - instance_number_offset = 0 - else: - instance_number_offset = (frm-1) * vol_lps.shape[-1] + RescaleSlope : float, optional + rescale Slope (default None -> maximum of image / (2**16 - 1)) + RescaleIntercept : float, optional + resalce Intercept (default None -> 0) - for i in range(vol_lps.shape[-1]): - if use_sl_in_fname: - sl = i - else: - sl = None - - fnames.append(write_dicom_slice(vol_lps[:,:,i], - uid_base = uid_base, - ImagePositionPatient = (lps_origin + i*zvoxsize*nz).astype('str').tolist(), - ImageOrientationPatient = np.concatenate((nx,ny)).astype('str').tolist(), - PixelSpacing = [str(xvoxsize), str(yvoxsize)], - SliceThickness = str(zvoxsize), - RescaleSlope = RescaleSlope, - RescaleIntercept = RescaleIntercept, - StudyInstanceUID = StudyInstanceUID, - SeriesInstanceUID = SeriesInstanceUID, - FrameOfReferenceUID = FrameOfReferenceUID, - outputdir = outputdir, - NumberOfSlices = numSlices, - InstanceNumber = i + 1 + instance_number_offset, - sl = sl, - frm = frm, - **kwargs)) - - return fnames + StudyInstanceUID : str, optional + dicom study instance UID (default None -> autom. created) -################################################################################################### + SeriesInstanceUID : str, optional + dicom series instance UID (default None -> autom. created) -def write_4d_dicom(vol_lps, - outputdir, - uid_base = '1.2.826.0.1.3680043.9.7147.', - SeriesType = 'DYNAMIC', - **kwargs): + FrameOfReferenceUID : str, optional + dicom frame of reference UID (default None -> autom. created) - """ write 4D volume to 2D dicom files + use_sl_in_fname : bool, optional + use the slice number in the file names (default False) - Parameters - ---------- + frm : int, optional + frm number for file name (default None means it is not used in file name) - vol_lps : 4d numpy array - a 4D array in LPST orientation containing the image. - the timing axis has to be the left most axis. + **kwargs : dict, optional + passed to write_dicom_slice - outputdir : str - the output directory for the dicom files - - uid_base : str, optional - base string for UID (default 1.2.826.0.1.3680043.9.7147) + Returns + ------- + list + containing the file names of the written 2D dicom files - **kwargs : dict - passed to write_3d_static_dicom - note: Every kwarg can be a list of length nframes or a single value. - In the first case, each time frame gets a different value - (e.g. useful for AcquisitionTime or ActualFrameDuration). - In the second case, each time frame gets the same values - (e.g. for PatientWeight or affine) + Note + ---- + This function is a wrapper around write_dicom_slice. + """ + # get the voxel sizes, direction vectors and the origin from the affine in case given + ux = affine[:-1, 0] + uy = affine[:-1, 1] + uz = affine[:-1, 2] - Returns - ------- - list of lists - containing the filenames of the written 2D dicom files - each element containg the filenames of one frame + xvoxsize = np.sqrt((ux**2).sum()) + yvoxsize = np.sqrt((uy**2).sum()) + zvoxsize = np.sqrt((uz**2).sum()) - Note - ---- - This function is a wrapper around write_3d_static_dicom. - """ - numFrames = vol_lps.shape[0] + nx = ux / xvoxsize + ny = uy / yvoxsize - SeriesInstanceUID = dicom.uid.generate_uid(uid_base) + lps_origin = affine[:-1, -1] - if not 'StudyInstanceUID' in kwargs: - kwargs['StudyInstanceUID'] = dicom.uid.generate_uid(uid_base) - if not 'FrameOfReferenceUID' in kwargs: - kwargs['FrameOfReferenceUID'] = dicom.uid.generate_uid(uid_base) + if RescaleSlope is None: + RescaleSlope = (vol_lps.max() - vol_lps.min()) / (2**16 - 1) + if RescaleIntercept is None: + RescaleIntercept = vol_lps.min() - numSlices = vol_lps.shape[-1] + # calculate the normalized direction vector in z direction + nz = np.cross(nx, ny) - fnames = [] + if StudyInstanceUID is None: + StudyInstanceUID = dicom.uid.generate_uid(uid_base) + if SeriesInstanceUID is None: + SeriesInstanceUID = dicom.uid.generate_uid(uid_base) + if FrameOfReferenceUID is None: + FrameOfReferenceUID = dicom.uid.generate_uid(uid_base) - for i in range(numFrames): - kw = {} - for key, value in kwargs.items(): - if type(value) is list: kw[key] = value[i] - else: kw[key] = value - - fnames.append(write_3d_static_dicom(vol_lps[i,...], - outputdir, - uid_base = uid_base, - TemporalPositionIdentifier = i + 1, - NumberOfTemporalPositions = numFrames, - SeriesInstanceUID = SeriesInstanceUID, - SeriesType = SeriesType, - frm = i + 1, - **kw)) - - return fnames + numSlices = vol_lps.shape[2] + + fnames = [] + + if frm is None: + instance_number_offset = 0 + else: + instance_number_offset = (frm - 1) * vol_lps.shape[-1] + + for i in range(vol_lps.shape[-1]): + if use_sl_in_fname: + sl = i + else: + sl = None + + fnames.append( + write_dicom_slice( + vol_lps[:, :, i], + uid_base=uid_base, + ImagePositionPatient=(lps_origin + i * zvoxsize * nz) + .astype("str") + .tolist(), + ImageOrientationPatient=np.concatenate((nx, ny)).astype("str").tolist(), + PixelSpacing=[str(xvoxsize), str(yvoxsize)], + SliceThickness=str(zvoxsize), + RescaleSlope=RescaleSlope, + RescaleIntercept=RescaleIntercept, + StudyInstanceUID=StudyInstanceUID, + SeriesInstanceUID=SeriesInstanceUID, + FrameOfReferenceUID=FrameOfReferenceUID, + outputdir=outputdir, + NumberOfSlices=numSlices, + InstanceNumber=i + 1 + instance_number_offset, + sl=sl, + frm=frm, + **kwargs, + ) + ) + + return fnames + + +################################################################################################### + + +def write_4d_dicom( + vol_lps, + outputdir, + uid_base="1.2.826.0.1.3680043.9.7147.", + SeriesType="DYNAMIC", + **kwargs, +): + """write 4D volume to 2D dicom files + + Parameters + ---------- + + vol_lps : 4d numpy array + a 4D array in LPST orientation containing the image. + the timing axis has to be the left most axis. + + outputdir : str + the output directory for the dicom files + + uid_base : str, optional + base string for UID (default 1.2.826.0.1.3680043.9.7147) + + **kwargs : dict + passed to write_3d_static_dicom + note: Every kwarg can be a list of length nframes or a single value. + In the first case, each time frame gets a different value + (e.g. useful for AcquisitionTime or ActualFrameDuration). + In the second case, each time frame gets the same values + (e.g. for PatientWeight or affine) + + Returns + ------- + list of lists + containing the filenames of the written 2D dicom files + each element containg the filenames of one frame + + Note + ---- + This function is a wrapper around write_3d_static_dicom. + """ + numFrames = vol_lps.shape[0] + + SeriesInstanceUID = dicom.uid.generate_uid(uid_base) + + if not "StudyInstanceUID" in kwargs: + kwargs["StudyInstanceUID"] = dicom.uid.generate_uid(uid_base) + if not "FrameOfReferenceUID" in kwargs: + kwargs["FrameOfReferenceUID"] = dicom.uid.generate_uid(uid_base) + + numSlices = vol_lps.shape[-1] + + fnames = [] + + for i in range(numFrames): + kw = {} + for key, value in kwargs.items(): + if type(value) is list: + kw[key] = value[i] + else: + kw[key] = value + + fnames.append( + write_3d_static_dicom( + vol_lps[i, ...], + outputdir, + uid_base=uid_base, + TemporalPositionIdentifier=i + 1, + NumberOfTemporalPositions=numFrames, + SeriesInstanceUID=SeriesInstanceUID, + SeriesType=SeriesType, + frm=i + 1, + **kw, + ) + ) + + return fnames diff --git a/pymirc/image_operations/__init__.py b/pymirc/image_operations/__init__.py index fcd2fac..1a1d37d 100644 --- a/pymirc/image_operations/__init__.py +++ b/pymirc/image_operations/__init__.py @@ -1,12 +1,12 @@ -from .aff_transform import aff_transform, kul_aff -from .backward_3d_warp import backward_3d_warp -from .random_deformation_field import random_deformation_field -from .zoom3d import zoom3d, downsample_3d_0, downsample_3d_1, downsample_3d_2 -from .zoom3d import upsample_3d_0, upsample_3d_1, upsample_3d_2 +from .aff_transform import aff_transform, kul_aff +from .backward_3d_warp import backward_3d_warp +from .random_deformation_field import random_deformation_field +from .zoom3d import zoom3d, downsample_3d_0, downsample_3d_1, downsample_3d_2 +from .zoom3d import upsample_3d_0, upsample_3d_1, upsample_3d_2 from .binary_2d_image_to_contours import binary_2d_image_to_contours -from .mincostpath import mincostpath -from .resample_img_cont import resample_img_cont -from .resample_cont_cont import resample_cont_cont -from .grad import grad, div, complex_grad, complex_div -from .reorient import reorient_image_and_affine, flip_image_and_affine -from .rigid_registration import rigid_registration +from .mincostpath import mincostpath +from .resample_img_cont import resample_img_cont +from .resample_cont_cont import resample_cont_cont +from .grad import grad, div, complex_grad, complex_div +from .reorient import reorient_image_and_affine, flip_image_and_affine +from .rigid_registration import rigid_registration diff --git a/pymirc/image_operations/aff_transform.py b/pymirc/image_operations/aff_transform.py index bd9ab6c..8620c8d 100644 --- a/pymirc/image_operations/aff_transform.py +++ b/pymirc/image_operations/aff_transform.py @@ -3,179 +3,216 @@ from numba import njit, prange -#------------------------------------------------------------------------------- -@njit(parallel = True) -def aff_transform(volume, aff_mat, output_shape, trilin = True, cval = 0., os0 = 1, os1 = 1, os2 = 1): - """ Affine transformation of a 3D volume in parallel (using numba's njit). - - Parameters - ---------- - volume : a 3D numpy array - contaning an image volume - - aff_mat : a 4x4 2D numpy array - affine transformation matrix - - output_shape : 3 element tuple - containing the shape of the output volume - - trilin : bool, optional - whether to use trilinear interpolation (default True) - - cval : float, optional - value of "outside" voxels (default 0) - - os0, os1, os2 : int - oversampling factors along the 0, 1, and 2 axis (default 1 -> no oversampling) - - Returns - ------- - 3D numpy array - a transformed volume - - Note - ---- - The purpose of the function is to reproduce the results - of scipy.ndimage.affine_transform (with order = 1 and prefilter = False) - in a faster way on a multi CPU system. - """ - # the dimensions of the output volume - n0, n1, n2 = output_shape - - # the dimenstion of the input volume - n0_in, n1_in, n2_in = volume.shape - - # the sizes of the temporary oversampled array - # the oversampling is needed in case we go from - # small voxels to big voxels - n0_os = n0*os0 - n1_os = n1*os1 - n2_os = n2*os2 - - os_output_volume = np.zeros((n0_os, n1_os, n2_os)) - if cval != 0: os_output_volume += cval - - for i in prange(n0_os): - for j in range(n1_os): - for k in range(n2_os): - tmp_x = aff_mat[0,0]*i/os0 + aff_mat[0,1]*j/os1 + aff_mat[0,2]*k/os2 + aff_mat[0,3] - tmp_y = aff_mat[1,0]*i/os0 + aff_mat[1,1]*j/os1 + aff_mat[1,2]*k/os2 + aff_mat[1,3] - tmp_z = aff_mat[2,0]*i/os0 + aff_mat[2,1]*j/os1 + aff_mat[2,2]*k/os2 + aff_mat[2,3] - - if trilin: - # trilinear interpolatio mode - # https://en.wikipedia.org/wiki/Trilinear_interpolation - x0 = math.floor(tmp_x) - x1 = math.ceil(tmp_x) - y0 = math.floor(tmp_y) - y1 = math.ceil(tmp_y) - z0 = math.floor(tmp_z) - z1 = math.ceil(tmp_z) - - if (x0 >= 0) and (x1 < n0_in) and (y0 >= 0) and (y1 < n1_in) and (z0 >= 0) and (z1 < n2_in): - xd = (tmp_x - x0) - yd = (tmp_y - y0) - zd = (tmp_z - z0) - - c00 = volume[x0,y0,z0]*(1 - xd) + volume[x1,y0,z0]*xd - c01 = volume[x0,y0,z1]*(1 - xd) + volume[x1,y0,z1]*xd - c10 = volume[x0,y1,z0]*(1 - xd) + volume[x1,y1,z0]*xd - c11 = volume[x0,y1,z1]*(1 - xd) + volume[x1,y1,z1]*xd - - c0 = c00*(1 - yd) + c10*yd - c1 = c01*(1 - yd) + c11*yd - - os_output_volume[i,j,k] = c0*(1 - zd) + c1*zd - - else: - # no interpolation mode - x = round(tmp_x) - y = round(tmp_y) - z = round(tmp_z) - - if ((x >= 0) and (x < n0_in) and (y >= 0) and (y < n1_in) and (z >= 0) and (z < n2_in)): - os_output_volume[i,j,k] = volume[x,y,z] - - if os0 == 1 and os1 == 1 and os2 == 1: - # case that were was no oversampling - output_volume = os_output_volume - else: - output_volume = np.zeros((n0, n1, n2)) - # case with oversampling, we have to average neighbors - for i in range(n0): - for j in range(n1): - for k in range(n2): - for ii in range(os0): - for jj in range(os1): - for kk in range(os2): - output_volume[i,j,k] += os_output_volume[i*os0 + ii, j*os1 + jj, k*os2 + kk] / (os0*os1*os2) - - return output_volume - -#---------------------------------------------------------------------- - -def kul_aff(params, origin = None): - """ KUL affine transformation matrix - - Parameters - ---------- - - params : 6 element numpy array - containing 3 translations and 3 rotation angles. - The definition of the rotations is the following (purely historical): - parms[3] ... rotation around 001 axis - parms[4] ... rotation around 100 axis - parms[5] ... rotation around 010 axis - The order of the rotations is first 010, second 100, third 001 - - origin : 3 element numpy array, optional - containing the origin for the rotations (rotation center) - The default None means origin = [0,0,0] - """ - - if len(params) > 3: - t001 = params[3] - t100 = params[4] - t010 = params[5] - else: - t001 = 0 - t100 = 0 - t010 = 0 - - # set up matrix for rotation around 001 axis - P001 = np.zeros((3,3)) - P001[0,0] = math.cos(t001) - P001[1,1] = math.cos(t001) - P001[0,1] = -math.sin(t001) - P001[1,0] = math.sin(t001) - P001[2,2] = 1 - - P100 = np.zeros((3,3)) - P100[1,1] = math.cos(t100) - P100[2,2] = math.cos(t100) - P100[1,2] = -math.sin(t100) - P100[2,1] = math.sin(t100) - P100[0,0] = 1 - - P010 = np.zeros((3,3)) - P010[0,0] = math.cos(t010) - P010[2,2] = math.cos(t010) - P010[0,2] = math.sin(t010) - P010[2,0] = -math.sin(t010) - P010[1,1] = 1 - - R = np.eye(4) - R[:3,:3] = (P001 @ P100 @ P010) - - if origin is not None: - T = np.eye(4) - T[:-1,-1] -= origin - R = np.linalg.inv(T) @ (R @ T) - - TR = np.eye(4) - TR[0,-1] = params[0] - TR[1,-1] = params[1] - TR[2,-1] = params[2] - R = R @ TR - - return R + +# ------------------------------------------------------------------------------- +@njit(parallel=True) +def aff_transform( + volume, aff_mat, output_shape, trilin=True, cval=0.0, os0=1, os1=1, os2=1 +): + """Affine transformation of a 3D volume in parallel (using numba's njit). + + Parameters + ---------- + volume : a 3D numpy array + contaning an image volume + + aff_mat : a 4x4 2D numpy array + affine transformation matrix + + output_shape : 3 element tuple + containing the shape of the output volume + + trilin : bool, optional + whether to use trilinear interpolation (default True) + + cval : float, optional + value of "outside" voxels (default 0) + + os0, os1, os2 : int + oversampling factors along the 0, 1, and 2 axis (default 1 -> no oversampling) + + Returns + ------- + 3D numpy array + a transformed volume + + Note + ---- + The purpose of the function is to reproduce the results + of scipy.ndimage.affine_transform (with order = 1 and prefilter = False) + in a faster way on a multi CPU system. + """ + # the dimensions of the output volume + n0, n1, n2 = output_shape + + # the dimenstion of the input volume + n0_in, n1_in, n2_in = volume.shape + + # the sizes of the temporary oversampled array + # the oversampling is needed in case we go from + # small voxels to big voxels + n0_os = n0 * os0 + n1_os = n1 * os1 + n2_os = n2 * os2 + + os_output_volume = np.zeros((n0_os, n1_os, n2_os)) + if cval != 0: + os_output_volume += cval + + for i in prange(n0_os): + for j in range(n1_os): + for k in range(n2_os): + tmp_x = ( + aff_mat[0, 0] * i / os0 + + aff_mat[0, 1] * j / os1 + + aff_mat[0, 2] * k / os2 + + aff_mat[0, 3] + ) + tmp_y = ( + aff_mat[1, 0] * i / os0 + + aff_mat[1, 1] * j / os1 + + aff_mat[1, 2] * k / os2 + + aff_mat[1, 3] + ) + tmp_z = ( + aff_mat[2, 0] * i / os0 + + aff_mat[2, 1] * j / os1 + + aff_mat[2, 2] * k / os2 + + aff_mat[2, 3] + ) + + if trilin: + # trilinear interpolatio mode + # https://en.wikipedia.org/wiki/Trilinear_interpolation + x0 = math.floor(tmp_x) + x1 = math.ceil(tmp_x) + y0 = math.floor(tmp_y) + y1 = math.ceil(tmp_y) + z0 = math.floor(tmp_z) + z1 = math.ceil(tmp_z) + + if ( + (x0 >= 0) + and (x1 < n0_in) + and (y0 >= 0) + and (y1 < n1_in) + and (z0 >= 0) + and (z1 < n2_in) + ): + xd = tmp_x - x0 + yd = tmp_y - y0 + zd = tmp_z - z0 + + c00 = volume[x0, y0, z0] * (1 - xd) + volume[x1, y0, z0] * xd + c01 = volume[x0, y0, z1] * (1 - xd) + volume[x1, y0, z1] * xd + c10 = volume[x0, y1, z0] * (1 - xd) + volume[x1, y1, z0] * xd + c11 = volume[x0, y1, z1] * (1 - xd) + volume[x1, y1, z1] * xd + + c0 = c00 * (1 - yd) + c10 * yd + c1 = c01 * (1 - yd) + c11 * yd + + os_output_volume[i, j, k] = c0 * (1 - zd) + c1 * zd + + else: + # no interpolation mode + x = round(tmp_x) + y = round(tmp_y) + z = round(tmp_z) + + if ( + (x >= 0) + and (x < n0_in) + and (y >= 0) + and (y < n1_in) + and (z >= 0) + and (z < n2_in) + ): + os_output_volume[i, j, k] = volume[x, y, z] + + if os0 == 1 and os1 == 1 and os2 == 1: + # case that were was no oversampling + output_volume = os_output_volume + else: + output_volume = np.zeros((n0, n1, n2)) + # case with oversampling, we have to average neighbors + for i in range(n0): + for j in range(n1): + for k in range(n2): + for ii in range(os0): + for jj in range(os1): + for kk in range(os2): + output_volume[i, j, k] += os_output_volume[ + i * os0 + ii, j * os1 + jj, k * os2 + kk + ] / (os0 * os1 * os2) + + return output_volume + + +# ---------------------------------------------------------------------- + + +def kul_aff(params, origin=None): + """KUL affine transformation matrix + + Parameters + ---------- + + params : 6 element numpy array + containing 3 translations and 3 rotation angles. + The definition of the rotations is the following (purely historical): + parms[3] ... rotation around 001 axis + parms[4] ... rotation around 100 axis + parms[5] ... rotation around 010 axis + The order of the rotations is first 010, second 100, third 001 + + origin : 3 element numpy array, optional + containing the origin for the rotations (rotation center) + The default None means origin = [0,0,0] + """ + + if len(params) > 3: + t001 = params[3] + t100 = params[4] + t010 = params[5] + else: + t001 = 0 + t100 = 0 + t010 = 0 + + # set up matrix for rotation around 001 axis + P001 = np.zeros((3, 3)) + P001[0, 0] = math.cos(t001) + P001[1, 1] = math.cos(t001) + P001[0, 1] = -math.sin(t001) + P001[1, 0] = math.sin(t001) + P001[2, 2] = 1 + + P100 = np.zeros((3, 3)) + P100[1, 1] = math.cos(t100) + P100[2, 2] = math.cos(t100) + P100[1, 2] = -math.sin(t100) + P100[2, 1] = math.sin(t100) + P100[0, 0] = 1 + + P010 = np.zeros((3, 3)) + P010[0, 0] = math.cos(t010) + P010[2, 2] = math.cos(t010) + P010[0, 2] = math.sin(t010) + P010[2, 0] = -math.sin(t010) + P010[1, 1] = 1 + + R = np.eye(4) + R[:3, :3] = P001 @ P100 @ P010 + + if origin is not None: + T = np.eye(4) + T[:-1, -1] -= origin + R = np.linalg.inv(T) @ (R @ T) + + TR = np.eye(4) + TR[0, -1] = params[0] + TR[1, -1] = params[1] + TR[2, -1] = params[2] + R = R @ TR + + return R diff --git a/pymirc/image_operations/backward_3d_warp.py b/pymirc/image_operations/backward_3d_warp.py index 987427c..ede9025 100644 --- a/pymirc/image_operations/backward_3d_warp.py +++ b/pymirc/image_operations/backward_3d_warp.py @@ -3,80 +3,96 @@ from numba import njit, prange -#------------------------------------------------------------------------------- -@njit(parallel = True) -def backward_3d_warp(volume, d0, d1, d2, trilin = True, cval = 0.): - """ Backwarp warp of 3D volume in parallel using numba's njit - - Parameters - ---------- - volume : 3d numpy array - containing the image (volume) - - d0, d1, d2 : 3d numpy array - containing the 3 components of the deformation field - - trilin : bool, optional - whether to use trilinear interpolation (default True) - - cval : float, optional - value used for filling outside the FOV (default 0) - - Returns - ------- - 3d numpy array : - warped volume - - Note - ---- - The value of the backward warped array is determined as: - warped_volume[i,j,k] = volume[i - d0[i,j,k], k - d1[i,j,k], j - d2[i,j,k]] - (using trinlinear interpolation by default) - """ - # the dimensions of the output volume - n0, n1, n2 = volume.shape - - output_volume = np.zeros((n0, n1, n2)) - if cval != 0: output_volume += cval - - for i in prange(n0): - for j in range(n1): - for k in range(n2): - tmp_x = i - d0[i,j,k] - tmp_y = j - d1[i,j,k] - tmp_z = k - d2[i,j,k] - - if trilin: - # trilinear interpolatio mode - # https://en.wikipedia.org/wiki/Trilinear_interpolation - x0 = math.floor(tmp_x) - x1 = math.ceil(tmp_x) - y0 = math.floor(tmp_y) - y1 = math.ceil(tmp_y) - z0 = math.floor(tmp_z) - z1 = math.ceil(tmp_z) - - if (x0 >= 0) and (x1 < n0) and (y0 >= 0) and (y1 < n1) and (z0 >= 0) and (z1 < n2): - xd = (tmp_x - x0) - yd = (tmp_y - y0) - zd = (tmp_z - z0) - - c00 = volume[x0,y0,z0]*(1 - xd) + volume[x1,y0,z0]*xd - c01 = volume[x0,y0,z1]*(1 - xd) + volume[x1,y0,z1]*xd - c10 = volume[x0,y1,z0]*(1 - xd) + volume[x1,y1,z0]*xd - c11 = volume[x0,y1,z1]*(1 - xd) + volume[x1,y1,z1]*xd - - c0 = c00*(1 - yd) + c10*yd - c1 = c01*(1 - yd) + c11*yd - - output_volume[i,j,k] = c0*(1 - zd) + c1*zd - else: - # no interpolation mode - x = round(tmp_x) - y = round(tmp_y) - z = round(tmp_z) - - if ((x >= 0) and (x < n0) and (y >= 0) and (y < n1) and (z >= 0) and (z < n2)): - output_volume[i,j,k] = volume[x,y,z] - - return output_volume + +# ------------------------------------------------------------------------------- +@njit(parallel=True) +def backward_3d_warp(volume, d0, d1, d2, trilin=True, cval=0.0): + """Backwarp warp of 3D volume in parallel using numba's njit + + Parameters + ---------- + volume : 3d numpy array + containing the image (volume) + + d0, d1, d2 : 3d numpy array + containing the 3 components of the deformation field + + trilin : bool, optional + whether to use trilinear interpolation (default True) + + cval : float, optional + value used for filling outside the FOV (default 0) + + Returns + ------- + 3d numpy array : + warped volume + + Note + ---- + The value of the backward warped array is determined as: + warped_volume[i,j,k] = volume[i - d0[i,j,k], k - d1[i,j,k], j - d2[i,j,k]] + (using trinlinear interpolation by default) + """ + # the dimensions of the output volume + n0, n1, n2 = volume.shape + + output_volume = np.zeros((n0, n1, n2)) + if cval != 0: + output_volume += cval + + for i in prange(n0): + for j in range(n1): + for k in range(n2): + tmp_x = i - d0[i, j, k] + tmp_y = j - d1[i, j, k] + tmp_z = k - d2[i, j, k] + + if trilin: + # trilinear interpolatio mode + # https://en.wikipedia.org/wiki/Trilinear_interpolation + x0 = math.floor(tmp_x) + x1 = math.ceil(tmp_x) + y0 = math.floor(tmp_y) + y1 = math.ceil(tmp_y) + z0 = math.floor(tmp_z) + z1 = math.ceil(tmp_z) + + if ( + (x0 >= 0) + and (x1 < n0) + and (y0 >= 0) + and (y1 < n1) + and (z0 >= 0) + and (z1 < n2) + ): + xd = tmp_x - x0 + yd = tmp_y - y0 + zd = tmp_z - z0 + + c00 = volume[x0, y0, z0] * (1 - xd) + volume[x1, y0, z0] * xd + c01 = volume[x0, y0, z1] * (1 - xd) + volume[x1, y0, z1] * xd + c10 = volume[x0, y1, z0] * (1 - xd) + volume[x1, y1, z0] * xd + c11 = volume[x0, y1, z1] * (1 - xd) + volume[x1, y1, z1] * xd + + c0 = c00 * (1 - yd) + c10 * yd + c1 = c01 * (1 - yd) + c11 * yd + + output_volume[i, j, k] = c0 * (1 - zd) + c1 * zd + else: + # no interpolation mode + x = round(tmp_x) + y = round(tmp_y) + z = round(tmp_z) + + if ( + (x >= 0) + and (x < n0) + and (y >= 0) + and (y < n1) + and (z >= 0) + and (z < n2) + ): + output_volume[i, j, k] = volume[x, y, z] + + return output_volume diff --git a/pymirc/image_operations/binary_2d_image_to_contours.py b/pymirc/image_operations/binary_2d_image_to_contours.py index c69cccc..041c158 100644 --- a/pymirc/image_operations/binary_2d_image_to_contours.py +++ b/pymirc/image_operations/binary_2d_image_to_contours.py @@ -1,164 +1,180 @@ import numpy as np from copy import deepcopy -from matplotlib.patches import Polygon +from matplotlib.patches import Polygon from skimage import measure -from scipy.ndimage.morphology import binary_fill_holes +from scipy.ndimage.morphology import binary_fill_holes from scipy.ndimage.measurements import find_objects -from scipy.ndimage import label - -#--------------------------------------------------------------------------- -def cheese_to_contours(img, connect_holes = True): - """Conversion of a 2d binary image "cheese" image into a set of contours - - Parameters - ---------- - img : 2d binary numpy array - containing the pixelized segmentation - - connect_holes : bool, optional - whether to connect inner holes to their outer parents contour - default: True - this connection is needed to show holes correctly in MIM - - Returns - ------- - list - of Nx2 numpy arrays containg the contours - - Notes - ----- - A binary cheese image contains an object with holes, but nothing inside those holes. - - Finding the contours is performed via the marching squares algorithm from skimage. - First, the contours of the image with filled holes are generated. - Second (optional), the contours of the holes are generated and connected to - their parent contours. - """ - # we have to 0 pad the image, otherwise the contours are not closed - # at the outside - bin_img = np.pad(img, 1, 'constant') - - bin_img_filled = binary_fill_holes(bin_img) - - bin_holes = bin_img_filled - bin_img - - # get the outer contours of the filled image (no holes) - outer_contours = measure.find_contours(bin_img_filled == 1, 0.5, positive_orientation = 'high') - # get the inner contours (contours around holes) - inner_contours = measure.find_contours(bin_holes == 1, 0.5, positive_orientation = 'low') - - # test in which outer contour a given inner contour lies - if connect_holes: - contours = deepcopy(outer_contours) - outer_polys = [] - for i in range(len(outer_contours)): - outer_polys.append(Polygon(outer_contours[i], True)) - - parent_contour_number = np.zeros(len(inner_contours), dtype = int) - for j in range(len(inner_contours)): - for i in range(len(outer_contours)): - if outer_polys[i].contains_point(inner_contours[j][0,:]): - parent_contour_number[j] = i - - # get the closest point on the outer contours for the start point - # of the inner contour - - closest_outer_points = [] - closest_outer_points_is_in_contour = [] - for j in range(len(inner_contours)): - oc = deepcopy(outer_contours[parent_contour_number[j]]) - - ip = inner_contours[j][0,:] - dist = np.apply_along_axis(lambda x: (x[0] - ip[0])**2 + (x[1] - ip[1])**2, 1, oc) - closest_outer_points.append(oc[np.argmin(dist),:]) - - cop = deepcopy(closest_outer_points[j]) - - # check if closet point is already in contour - closest_outer_points_is_in_contour.append(np.any(np.all(np.isin(oc,cop,True),axis=1))) - - con = deepcopy(contours[parent_contour_number[j]]) - - i_point = np.where(np.apply_along_axis(lambda x: np.array_equal(x,cop),1,con))[0][0] - - contours[parent_contour_number[j]] = np.concatenate([con[:(i_point+1),:], - inner_contours[j], - np.array(cop).reshape(1,2), - con[i_point:,:]]) - else: - contours = outer_contours + inner_contours - - # subtract 1 from the contour coordinates because we had to use 0 padding - for i in range(len(contours)): - contours[i] -= 1 - - return contours - -#--------------------------------------------------------------------------- -def binary_2d_image_to_contours(img, connect_holes = True): - """Conversion of a 2d binary image image into a set of contours - - Parameters - ---------- - img : 2d binary numpy array - containing the pixelized segmentation - - connect_holes : bool, optional - whether to connect inner holes to their outer parents contour - default: True - this connection is needed to show holes correctly in MIM - - Returns - ------- - list - of Nx2 numpy arrays containg the contours - - Notes - ----- - Finding the contours is performed via the marching squares algorithm from skimage. - Special care is taken of holes and object inside holes. - """ - img_copy = img.copy() - - regions, nrois = label(img_copy) - obj_slices = find_objects(regions) - - contours = [] - - while nrois > 0: - # test if found objets only have one label - for i in range(len(obj_slices)): - obj = img_copy[obj_slices[i]] - lab = label(obj) - if lab[1] == 1: - # if there is only one label, the object is a "cheese image" - # where we can calculate the contours - tmp = cheese_to_contours(obj, connect_holes = connect_holes)[0] - tmp[:,0] += obj_slices[i][0].start - tmp[:,1] += obj_slices[i][1].start - - contours.append(tmp) - - # delete the processed rois from the label array - img_copy[regions == (i+1)] = 0 - - regions, nrois_new = label(img_copy) - obj_slices = find_objects(regions) - - if nrois_new != nrois: - nrois = nrois_new +from scipy.ndimage import label + + +# --------------------------------------------------------------------------- +def cheese_to_contours(img, connect_holes=True): + """Conversion of a 2d binary image "cheese" image into a set of contours + + Parameters + ---------- + img : 2d binary numpy array + containing the pixelized segmentation + + connect_holes : bool, optional + whether to connect inner holes to their outer parents contour - default: True + this connection is needed to show holes correctly in MIM + + Returns + ------- + list + of Nx2 numpy arrays containg the contours + + Notes + ----- + A binary cheese image contains an object with holes, but nothing inside those holes. + + Finding the contours is performed via the marching squares algorithm from skimage. + First, the contours of the image with filled holes are generated. + Second (optional), the contours of the holes are generated and connected to + their parent contours. + """ + # we have to 0 pad the image, otherwise the contours are not closed + # at the outside + bin_img = np.pad(img, 1, "constant") + + bin_img_filled = binary_fill_holes(bin_img) + + bin_holes = bin_img_filled - bin_img + + # get the outer contours of the filled image (no holes) + outer_contours = measure.find_contours( + bin_img_filled == 1, 0.5, positive_orientation="high" + ) + # get the inner contours (contours around holes) + inner_contours = measure.find_contours( + bin_holes == 1, 0.5, positive_orientation="low" + ) + + # test in which outer contour a given inner contour lies + if connect_holes: + contours = deepcopy(outer_contours) + outer_polys = [] + for i in range(len(outer_contours)): + outer_polys.append(Polygon(outer_contours[i], True)) + + parent_contour_number = np.zeros(len(inner_contours), dtype=int) + for j in range(len(inner_contours)): + for i in range(len(outer_contours)): + if outer_polys[i].contains_point(inner_contours[j][0, :]): + parent_contour_number[j] = i + + # get the closest point on the outer contours for the start point + # of the inner contour + + closest_outer_points = [] + closest_outer_points_is_in_contour = [] + for j in range(len(inner_contours)): + oc = deepcopy(outer_contours[parent_contour_number[j]]) + + ip = inner_contours[j][0, :] + dist = np.apply_along_axis( + lambda x: (x[0] - ip[0]) ** 2 + (x[1] - ip[1]) ** 2, 1, oc + ) + closest_outer_points.append(oc[np.argmin(dist), :]) + + cop = deepcopy(closest_outer_points[j]) + + # check if closet point is already in contour + closest_outer_points_is_in_contour.append( + np.any(np.all(np.isin(oc, cop, True), axis=1)) + ) + + con = deepcopy(contours[parent_contour_number[j]]) + + i_point = np.where( + np.apply_along_axis(lambda x: np.array_equal(x, cop), 1, con) + )[0][0] + + contours[parent_contour_number[j]] = np.concatenate( + [ + con[: (i_point + 1), :], + inner_contours[j], + np.array(cop).reshape(1, 2), + con[i_point:, :], + ] + ) else: - for i in range(nrois): - obj = (regions == (i + 1)).astype(int) + contours = outer_contours + inner_contours - for tmp in cheese_to_contours(obj, connect_holes = connect_holes)[:1]: - contours.append(tmp) - - # delete the processed rois from the label array - img_copy[regions == (i+1)] = 0 + # subtract 1 from the contour coordinates because we had to use 0 padding + for i in range(len(contours)): + contours[i] -= 1 - regions, nrois = label(img_copy) - obj_slices = find_objects(regions) + return contours - return contours + +# --------------------------------------------------------------------------- +def binary_2d_image_to_contours(img, connect_holes=True): + """Conversion of a 2d binary image image into a set of contours + + Parameters + ---------- + img : 2d binary numpy array + containing the pixelized segmentation + + connect_holes : bool, optional + whether to connect inner holes to their outer parents contour - default: True + this connection is needed to show holes correctly in MIM + + Returns + ------- + list + of Nx2 numpy arrays containg the contours + + Notes + ----- + Finding the contours is performed via the marching squares algorithm from skimage. + Special care is taken of holes and object inside holes. + """ + img_copy = img.copy() + + regions, nrois = label(img_copy) + obj_slices = find_objects(regions) + + contours = [] + + while nrois > 0: + # test if found objets only have one label + for i in range(len(obj_slices)): + obj = img_copy[obj_slices[i]] + lab = label(obj) + if lab[1] == 1: + # if there is only one label, the object is a "cheese image" + # where we can calculate the contours + tmp = cheese_to_contours(obj, connect_holes=connect_holes)[0] + tmp[:, 0] += obj_slices[i][0].start + tmp[:, 1] += obj_slices[i][1].start + + contours.append(tmp) + + # delete the processed rois from the label array + img_copy[regions == (i + 1)] = 0 + + regions, nrois_new = label(img_copy) + obj_slices = find_objects(regions) + + if nrois_new != nrois: + nrois = nrois_new + else: + for i in range(nrois): + obj = (regions == (i + 1)).astype(int) + + for tmp in cheese_to_contours(obj, connect_holes=connect_holes)[:1]: + contours.append(tmp) + + # delete the processed rois from the label array + img_copy[regions == (i + 1)] = 0 + + regions, nrois = label(img_copy) + obj_slices = find_objects(regions) + + return contours diff --git a/pymirc/image_operations/grad.py b/pymirc/image_operations/grad.py index cc074a0..5c12846 100644 --- a/pymirc/image_operations/grad.py +++ b/pymirc/image_operations/grad.py @@ -2,282 +2,323 @@ import numpy from numba import njit, stencil + @stencil def fwd_diff2d_0(x): - return x[1,0] - x[0,0] + return x[1, 0] - x[0, 0] + @stencil def back_diff2d_0(x): - return x[0,0] - x[-1,0] + return x[0, 0] - x[-1, 0] + @stencil def fwd_diff2d_1(x): - return x[0,1] - x[0,0] + return x[0, 1] - x[0, 0] + @stencil def back_diff2d_1(x): - return x[0,0] - x[0,-1] + return x[0, 0] - x[0, -1] + + +# ----------------------------------------------------------------------------- -#----------------------------------------------------------------------------- @stencil def fwd_diff3d_0(x): - return x[1,0,0] - x[0,0,0] + return x[1, 0, 0] - x[0, 0, 0] + @stencil def back_diff3d_0(x): - return x[0,0,0] - x[-1,0,0] + return x[0, 0, 0] - x[-1, 0, 0] + @stencil def fwd_diff3d_1(x): - return x[0,1,0] - x[0,0,0] + return x[0, 1, 0] - x[0, 0, 0] + @stencil def back_diff3d_1(x): - return x[0,0,0] - x[0,-1,0] + return x[0, 0, 0] - x[0, -1, 0] + @stencil def fwd_diff3d_2(x): - return x[0,0,1] - x[0,0,0] + return x[0, 0, 1] - x[0, 0, 0] + @stencil def back_diff3d_2(x): - return x[0,0,0] - x[0,0,-1] + return x[0, 0, 0] - x[0, 0, -1] + + +# ----------------------------------------------------------------------------- -#----------------------------------------------------------------------------- @stencil def fwd_diff4d_0(x): - return x[1,0,0,0] - x[0,0,0,0] + return x[1, 0, 0, 0] - x[0, 0, 0, 0] + @stencil def back_diff4d_0(x): - return x[0,0,0,0] - x[-1,0,0,0] + return x[0, 0, 0, 0] - x[-1, 0, 0, 0] + @stencil def fwd_diff4d_1(x): - return x[0,1,0,0] - x[0,0,0,0] + return x[0, 1, 0, 0] - x[0, 0, 0, 0] + @stencil def back_diff4d_1(x): - return x[0,0,0,0] - x[0,-1,0,0] + return x[0, 0, 0, 0] - x[0, -1, 0, 0] + @stencil def fwd_diff4d_2(x): - return x[0,0,1,0] - x[0,0,0,0] + return x[0, 0, 1, 0] - x[0, 0, 0, 0] + @stencil def back_diff4d_2(x): - return x[0,0,0,0] - x[0,0,-1,0] + return x[0, 0, 0, 0] - x[0, 0, -1, 0] + @stencil def fwd_diff4d_3(x): - return x[0,0,0,1] - x[0,0,0,0] + return x[0, 0, 0, 1] - x[0, 0, 0, 0] + @stencil def back_diff4d_3(x): - return x[0,0,0,0] - x[0,0,0,-1] + return x[0, 0, 0, 0] - x[0, 0, 0, -1] + +# ----------------------------------------------------------------------------- -#----------------------------------------------------------------------------- -@njit(parallel = True) +@njit(parallel=True) def grad2d(x, g): - fwd_diff2d_0(x, out = g[0,]) - fwd_diff2d_1(x, out = g[1,]) + fwd_diff2d_0(x, out=g[0,]) + fwd_diff2d_1(x, out=g[1,]) -@njit(parallel = True) + +@njit(parallel=True) def grad3d(x, g): - fwd_diff3d_0(x, out = g[0,]) - fwd_diff3d_1(x, out = g[1,]) - fwd_diff3d_2(x, out = g[2,]) + fwd_diff3d_0(x, out=g[0,]) + fwd_diff3d_1(x, out=g[1,]) + fwd_diff3d_2(x, out=g[2,]) + -@njit(parallel = True) +@njit(parallel=True) def grad4d(x, g): - fwd_diff4d_0(x, out = g[0,]) - fwd_diff4d_1(x, out = g[1,]) - fwd_diff4d_2(x, out = g[2,]) - fwd_diff4d_3(x, out = g[3,]) - -def grad(x,g): - """ - Calculate the gradient of 2d,3d, or 4d array via the finite forward diffence - - Arguments - --------- - - x ... a 2d, 3d, or 4d numpy array - g ... (output) array of size ((x.ndim,), x.shape) used to store the ouput - - Examples - -------- - - import numpy - import pynucmed - x = numpy.random.rand(20,20,20) - g = numpy.zeros((x.ndim,) + x.shape) - pynucmed.misc.grad(x,g) - y = pynucmed.misc.div(g) - - Note - ---- - - This implementation uses the numba stencil decorators in combination with - jit in parallel nopython mode - - """ - ndim = x.ndim - if ndim == 2: grad2d(x, g) - elif ndim == 3: grad3d(x, g) - elif ndim == 4: grad4d(x, g) - else : raise TypeError('Invalid dimension of input') - -#----------------------------------------------------------------------------- - -def complex_grad(x,g): - """ - Calculate the gradient of 2d,3d, or 4d complex array via the finite forward diffence - - Arguments - --------- - - x ... a complex numpy arrays represented by 2 float arrays - 2D ... x.shape = [n0,n1,2] - 3D ... x.shape = [n0,n1,n2,2] - 4D ... x.shape = [n0,n1,n2,n3,2] - - g ... (output) array of size ((2*x[...,0].ndim,), x[...,0].shape) used to store the ouput - - Note - ---- - - This implementation uses the numba stencil decorators in combination with - jit in parallel nopython mode. - The gradient is calculated separately for the real and imag part and - concatenated together. - - """ - ndim = x[...,0].ndim - if ndim == 2: - grad2d(x[...,0], g[:ndim,...]) - grad2d(x[...,1], g[ndim:,...]) - elif ndim == 3: - grad3d(x[...,0], g[:ndim,...]) - grad3d(x[...,1], g[ndim:,...]) - elif ndim == 4: - grad4d(x[...,0], g[:ndim,...]) - grad4d(x[...,1], g[ndim:,...]) - else : raise TypeError('Invalid dimension of input') - -#----------------------------------------------------------------------------- - -@njit(parallel = True) + fwd_diff4d_0(x, out=g[0,]) + fwd_diff4d_1(x, out=g[1,]) + fwd_diff4d_2(x, out=g[2,]) + fwd_diff4d_3(x, out=g[3,]) + + +def grad(x, g): + """ + Calculate the gradient of 2d,3d, or 4d array via the finite forward diffence + + Arguments + --------- + + x ... a 2d, 3d, or 4d numpy array + g ... (output) array of size ((x.ndim,), x.shape) used to store the ouput + + Examples + -------- + + import numpy + import pynucmed + x = numpy.random.rand(20,20,20) + g = numpy.zeros((x.ndim,) + x.shape) + pynucmed.misc.grad(x,g) + y = pynucmed.misc.div(g) + + Note + ---- + + This implementation uses the numba stencil decorators in combination with + jit in parallel nopython mode + + """ + ndim = x.ndim + if ndim == 2: + grad2d(x, g) + elif ndim == 3: + grad3d(x, g) + elif ndim == 4: + grad4d(x, g) + else: + raise TypeError("Invalid dimension of input") + + +# ----------------------------------------------------------------------------- + + +def complex_grad(x, g): + """ + Calculate the gradient of 2d,3d, or 4d complex array via the finite forward diffence + + Arguments + --------- + + x ... a complex numpy arrays represented by 2 float arrays + 2D ... x.shape = [n0,n1,2] + 3D ... x.shape = [n0,n1,n2,2] + 4D ... x.shape = [n0,n1,n2,n3,2] + + g ... (output) array of size ((2*x[...,0].ndim,), x[...,0].shape) used to store the ouput + + Note + ---- + + This implementation uses the numba stencil decorators in combination with + jit in parallel nopython mode. + The gradient is calculated separately for the real and imag part and + concatenated together. + + """ + ndim = x[..., 0].ndim + if ndim == 2: + grad2d(x[..., 0], g[:ndim, ...]) + grad2d(x[..., 1], g[ndim:, ...]) + elif ndim == 3: + grad3d(x[..., 0], g[:ndim, ...]) + grad3d(x[..., 1], g[ndim:, ...]) + elif ndim == 4: + grad4d(x[..., 0], g[:ndim, ...]) + grad4d(x[..., 1], g[ndim:, ...]) + else: + raise TypeError("Invalid dimension of input") + + +# ----------------------------------------------------------------------------- + + +@njit(parallel=True) def div2d(g): - tmp = numpy.zeros(g.shape) - back_diff2d_0(g[0,], out = tmp[0,]) - back_diff2d_1(g[1,], out = tmp[1,]) + tmp = numpy.zeros(g.shape) + back_diff2d_0(g[0,], out=tmp[0,]) + back_diff2d_1(g[1,], out=tmp[1,]) + + return tmp[0,] + tmp[1,] - return tmp[0,] + tmp[1,] -@njit(parallel = True) +@njit(parallel=True) def div3d(g): - tmp = numpy.zeros(g.shape) - back_diff3d_0(g[0,], out = tmp[0,]) - back_diff3d_1(g[1,], out = tmp[1,]) - back_diff3d_2(g[2,], out = tmp[2,]) - - return tmp[0,] + tmp[1,] + tmp[2,] - -@njit(parallel = True) + tmp = numpy.zeros(g.shape) + back_diff3d_0(g[0,], out=tmp[0,]) + back_diff3d_1(g[1,], out=tmp[1,]) + back_diff3d_2(g[2,], out=tmp[2,]) + + return tmp[0,] + tmp[1,] + tmp[2,] + + +@njit(parallel=True) def div4d(g): - tmp = numpy.zeros(g.shape) - back_diff4d_0(g[0,], out = tmp[0,]) - back_diff4d_1(g[1,], out = tmp[1,]) - back_diff4d_2(g[2,], out = tmp[2,]) - back_diff4d_3(g[3,], out = tmp[3,]) - - return tmp[0,] + tmp[1,] + tmp[2,] + tmp[3,] + tmp = numpy.zeros(g.shape) + back_diff4d_0(g[0,], out=tmp[0,]) + back_diff4d_1(g[1,], out=tmp[1,]) + back_diff4d_2(g[2,], out=tmp[2,]) + back_diff4d_3(g[3,], out=tmp[3,]) + + return tmp[0,] + tmp[1,] + tmp[2,] + tmp[3,] + def div(g): - """ - Calculate the divergence of 2d, 3d, or 4d array via the finite backward diffence + """ + Calculate the divergence of 2d, 3d, or 4d array via the finite backward diffence + + Arguments + --------- - Arguments - --------- - - g ... a gradient array of size ((x.ndim,), x.shape) + g ... a gradient array of size ((x.ndim,), x.shape) - Returns - ------- + Returns + ------- - an array of size g.shape[1:] + an array of size g.shape[1:] - Examples - -------- + Examples + -------- - import numpy - import pynucmed - x = numpy.random.rand(20,20,20) - g = numpy.zeros((x.ndim,) + x.shape) - pynucmed.misc.grad(x,g) - y = pynucmed.misc.div(g) + import numpy + import pynucmed + x = numpy.random.rand(20,20,20) + g = numpy.zeros((x.ndim,) + x.shape) + pynucmed.misc.grad(x,g) + y = pynucmed.misc.div(g) - Note - ---- + Note + ---- - This implementation uses the numba stencil decorators in combination with - jit in parallel nopython mode + This implementation uses the numba stencil decorators in combination with + jit in parallel nopython mode - See also - -------- + See also + -------- - pynucmed.misc.grad - """ - ndim = g.shape[0] - if ndim == 2: return div2d(g) - elif ndim == 3: return div3d(g) - elif ndim == 4: return div4d(g) - else : raise TypeError('Invalid dimension of input') + pynucmed.misc.grad + """ + ndim = g.shape[0] + if ndim == 2: + return div2d(g) + elif ndim == 3: + return div3d(g) + elif ndim == 4: + return div4d(g) + else: + raise TypeError("Invalid dimension of input") def complex_div(g): - """ - Calculate the divergence of 2d, 3d, or 4d "complex" array via the finite backward diffence + """ + Calculate the divergence of 2d, 3d, or 4d "complex" array via the finite backward diffence + + Arguments + --------- - Arguments - --------- - - g ... a gradient array of size (2*(x.ndim,), x.shape) + g ... a gradient array of size (2*(x.ndim,), x.shape) - Returns - ------- + Returns + ------- - a real array of shape (g.shape[1:] + (2,)) representing the complex array by 2 real arrays + a real array of shape (g.shape[1:] + (2,)) representing the complex array by 2 real arrays - Note - ---- + Note + ---- - This implementation uses the numba stencil decorators in combination with - jit in parallel nopython mode + This implementation uses the numba stencil decorators in combination with + jit in parallel nopython mode - See also - -------- + See also + -------- - pynucmed.misc.grad - """ + pynucmed.misc.grad + """ - ndim = g.shape[0] // 2 - tmp = numpy.zeros(g.shape[1:] + (2,)) + ndim = g.shape[0] // 2 + tmp = numpy.zeros(g.shape[1:] + (2,)) - if ndim == 2: - tmp[...,0] = div2d(g[:ndim,...]) - tmp[...,1] = div2d(g[ndim:,...]) - elif ndim == 3: - tmp[...,0] = div3d(g[:ndim,...]) - tmp[...,1] = div3d(g[ndim:,...]) - elif ndim == 4: - tmp[...,0] = div4d(g[:ndim,...]) - tmp[...,1] = div4d(g[ndim:,...]) - else: raise TypeError('Invalid dimension of input') + if ndim == 2: + tmp[..., 0] = div2d(g[:ndim, ...]) + tmp[..., 1] = div2d(g[ndim:, ...]) + elif ndim == 3: + tmp[..., 0] = div3d(g[:ndim, ...]) + tmp[..., 1] = div3d(g[ndim:, ...]) + elif ndim == 4: + tmp[..., 0] = div4d(g[:ndim, ...]) + tmp[..., 1] = div4d(g[ndim:, ...]) + else: + raise TypeError("Invalid dimension of input") - return tmp + return tmp diff --git a/pymirc/image_operations/mincostpath.py b/pymirc/image_operations/mincostpath.py index be23f6a..31f1328 100755 --- a/pymirc/image_operations/mincostpath.py +++ b/pymirc/image_operations/mincostpath.py @@ -1,151 +1,154 @@ import numpy as np from numba import njit, jit -def mincostpath(img, poscost, cyclic = False, wrapcols = None): - """ compute a minimum cost path in a cost image, assuming the path consists of a single point per column - - The cost of the entire path is the sum of the local costs. The - local cost in a pixel has two components: - 1) the cost value of the pixel, obtained from the cost image - 2) the transition cost: a cost can be assigned to the row - difference of consecutive path points to encourage horizontal lines. - - Parameters - ---------- - img : 2D numpy array - the local cost image. - - poscost : 1D numpy array - with the extra cost assigned to the postion of the left - neighbor: - poscost[0] is the cost for the direct left neighbor (same row). - poscost[1] is the cost for the neighbors above and below - the direct left neighbor (1 row difference). - poscost[2] for the left "neighbors" with a 2 row difference. - and so on. - The positional costs should be NON-DECREASING with row difference. - It is recommended to set poscost[0] = 0, and - poscost[i+1] >= poscost[i] - - cyclic: bool, default False - when set, it is assumed that the image is horizontally - periodic. To ensure that the last path point connects - with the first one, a temporary image is created by - concatenating the first WRAPCOLS columns to the right and - the last WRAPCOLS columns to the left. - This mode allows computation of circular paths by - applying NImincostpath after polar transform. - - wrapcols: int - the number of columns used to model the cyclic nature - of the image. Default is (number_of_columns)/2, so the - temporary image is twice as wide as the original one. - - Returns - ------- - 1D numpy array - the minimum cost path consisting of the column indices - - Note - ---- - Python reimplementation of J. Nuyts' nimincostpath.pro - """ - - if cyclic: - if wrapcols is None: wrapcols = img.shape[1] // 2 - tmpimg = np.pad(img, ((0,0),(wrapcols,wrapcols)), 'wrap') - else: - tmpimg = img - - res = mincostpath_backend(tmpimg, poscost) - - if cyclic: - res = res[wrapcols: (wrapcols + img.shape[1])] - - return res - - -#------------------------------------------------------------------------------------------------------- + +def mincostpath(img, poscost, cyclic=False, wrapcols=None): + """compute a minimum cost path in a cost image, assuming the path consists of a single point per column + + The cost of the entire path is the sum of the local costs. The + local cost in a pixel has two components: + 1) the cost value of the pixel, obtained from the cost image + 2) the transition cost: a cost can be assigned to the row + difference of consecutive path points to encourage horizontal lines. + + Parameters + ---------- + img : 2D numpy array + the local cost image. + + poscost : 1D numpy array + with the extra cost assigned to the postion of the left + neighbor: + poscost[0] is the cost for the direct left neighbor (same row). + poscost[1] is the cost for the neighbors above and below + the direct left neighbor (1 row difference). + poscost[2] for the left "neighbors" with a 2 row difference. + and so on. + The positional costs should be NON-DECREASING with row difference. + It is recommended to set poscost[0] = 0, and + poscost[i+1] >= poscost[i] + + cyclic: bool, default False + when set, it is assumed that the image is horizontally + periodic. To ensure that the last path point connects + with the first one, a temporary image is created by + concatenating the first WRAPCOLS columns to the right and + the last WRAPCOLS columns to the left. + This mode allows computation of circular paths by + applying NImincostpath after polar transform. + + wrapcols: int + the number of columns used to model the cyclic nature + of the image. Default is (number_of_columns)/2, so the + temporary image is twice as wide as the original one. + + Returns + ------- + 1D numpy array + the minimum cost path consisting of the column indices + + Note + ---- + Python reimplementation of J. Nuyts' nimincostpath.pro + """ + + if cyclic: + if wrapcols is None: + wrapcols = img.shape[1] // 2 + tmpimg = np.pad(img, ((0, 0), (wrapcols, wrapcols)), "wrap") + else: + tmpimg = img + + res = mincostpath_backend(tmpimg, poscost) + + if cyclic: + res = res[wrapcols : (wrapcols + img.shape[1])] + + return res + + +# ------------------------------------------------------------------------------------------------------- + @njit() def mincostpath_backend(img, poscost): - """ compute a minimum cost path in a cost image, assuming the path consists of a single point per column - - The cost of the entire path is the sum of the local costs. The - local cost in a pixel has two components: - 1) the cost value of the pixel, obtained from the cost image - 2) the transition cost: a cost can be assigned to the row - difference of consecutive path points to encourage horizontal lines. - - Parameters - ---------- - img : 2D numpy array - the local cost image. - - poscost - a 1D numpy array with the extra cost assigned to the postion of the left - neighbor: - poscost[0] is the cost for the direct left neighbor (same row). - poscost[1] is the cost for the neighbors above and below - the direct left neighbor (1 row difference). - poscost[2] for the left "neighbors" with a 2 row difference. - and so on. - The positional costs should be NON-DECREASING with row difference. - It is recommended to set poscost[0] = 0, and - poscost[i+1] >= poscost[i] - - Returns - ------- - 1D numpy array - the minimum cost path consisting of the column indices - - Note - ---- - Python reimplementation of J. Nuyts' NCmincostpath.c - """ - costimg = img.copy() - n0, n1 = costimg.shape - nposcost = poscost.shape[0] - colimg = np.zeros(costimg.shape, dtype = np.int32) - - for j in range(1,n1): - for i in range(0, n0): - # calculate cost of immediate left neighbor - mincost = costimg[i,j-1] + poscost[0] - minleft = i - - # compare to "upper" left neighbors - npos = min(nposcost - 1, i) - for offset in range(1, npos + 1): - cost = costimg[i - offset, j-1] + poscost[offset] - - if cost < mincost: - mincost = cost - minleft = i - offset - - # compare to "lower" left neighbors - npos = min(nposcost - 1, n0 - i - 1) - for offset in range(1, npos + 1): - cost = costimg[i + offset, j-1] + poscost[offset] - - if cost < mincost: - mincost = cost - minleft = i + offset - - costimg[i,j] += mincost - colimg[i,j] = minleft - - # now costimg contains the costs of the paths from the left, and - # colimg contains the min cost left neighbor of each pixel. - # so the last point of the contour can be found by selecting - # the pixel with min cost in the last column. - - path = np.zeros(n1, dtype = np.int32) - last_point = np.argmin(costimg[:,-1]) - path[-1] = last_point - - for jback in range(1,n1): - last_point = colimg[last_point, n1 - jback] - path[n1 - jback - 1] = last_point - - return path + """compute a minimum cost path in a cost image, assuming the path consists of a single point per column + + The cost of the entire path is the sum of the local costs. The + local cost in a pixel has two components: + 1) the cost value of the pixel, obtained from the cost image + 2) the transition cost: a cost can be assigned to the row + difference of consecutive path points to encourage horizontal lines. + + Parameters + ---------- + img : 2D numpy array + the local cost image. + + poscost + a 1D numpy array with the extra cost assigned to the postion of the left + neighbor: + poscost[0] is the cost for the direct left neighbor (same row). + poscost[1] is the cost for the neighbors above and below + the direct left neighbor (1 row difference). + poscost[2] for the left "neighbors" with a 2 row difference. + and so on. + The positional costs should be NON-DECREASING with row difference. + It is recommended to set poscost[0] = 0, and + poscost[i+1] >= poscost[i] + + Returns + ------- + 1D numpy array + the minimum cost path consisting of the column indices + + Note + ---- + Python reimplementation of J. Nuyts' NCmincostpath.c + """ + costimg = img.copy() + n0, n1 = costimg.shape + nposcost = poscost.shape[0] + colimg = np.zeros(costimg.shape, dtype=np.int32) + + for j in range(1, n1): + for i in range(0, n0): + # calculate cost of immediate left neighbor + mincost = costimg[i, j - 1] + poscost[0] + minleft = i + + # compare to "upper" left neighbors + npos = min(nposcost - 1, i) + for offset in range(1, npos + 1): + cost = costimg[i - offset, j - 1] + poscost[offset] + + if cost < mincost: + mincost = cost + minleft = i - offset + + # compare to "lower" left neighbors + npos = min(nposcost - 1, n0 - i - 1) + for offset in range(1, npos + 1): + cost = costimg[i + offset, j - 1] + poscost[offset] + + if cost < mincost: + mincost = cost + minleft = i + offset + + costimg[i, j] += mincost + colimg[i, j] = minleft + + # now costimg contains the costs of the paths from the left, and + # colimg contains the min cost left neighbor of each pixel. + # so the last point of the contour can be found by selecting + # the pixel with min cost in the last column. + + path = np.zeros(n1, dtype=np.int32) + last_point = np.argmin(costimg[:, -1]) + path[-1] = last_point + + for jback in range(1, n1): + last_point = colimg[last_point, n1 - jback] + path[n1 - jback - 1] = last_point + + return path diff --git a/pymirc/image_operations/random_deformation_field.py b/pymirc/image_operations/random_deformation_field.py index 7174131..6c37e2d 100644 --- a/pymirc/image_operations/random_deformation_field.py +++ b/pymirc/image_operations/random_deformation_field.py @@ -1,61 +1,83 @@ import numpy as np -from numba import njit, prange +from numba import njit, prange from scipy.ndimage import gaussian_filter -from .zoom3d import zoom3d - -def random_deformation_field(shape, shift = 2., n = 30, npad = 5, gaussian_std = 6): - """Generate a smooth random 3d deformation vector field - - Parameters - ---------- - shape : 3 element tuple - shape of the deformation fields - - shift : float, 3 element tuple, optional - standard devitiation of the displacements in the deformation fields (default 2) - - n : int, 3 element tuple, optional - dimension of low resolution grid on which random deformations are sampled (default 30) - - npad: int, 3 element tuple, optional - number of voxels used for 0 padding of the low resolution grid (default 5) - - gaussian_std: float, 3 element tuple, optional - standard deviation of gaussian kernel used to smooth the low resolution deformations (default 6) - - Returns - ------- - tuple of 3 3d numpy arrays - containing the displacements in the 3 dimensions - """ - if not isinstance(shift, tuple): - shift = (shift,) * 3 - - if not isinstance(n, tuple): - n = (n,) * 3 - - if not isinstance(npad, tuple): - npad = (npad,) * 3 - - if not isinstance(gaussian_std, tuple): - gaussian_std = (gaussian_std,) * 3 - - d0 = gaussian_filter(np.pad(np.random.randn(n[0],n[1],n[2]), npad[0], mode = 'constant'), gaussian_std[0]) - d1 = gaussian_filter(np.pad(np.random.randn(n[0],n[1],n[2]), npad[1], mode = 'constant'), gaussian_std[1]) - d2 = gaussian_filter(np.pad(np.random.randn(n[0],n[1],n[2]), npad[2], mode = 'constant'), gaussian_std[2]) - - d0 = zoom3d(d0, np.array(shape) / np.array(d0.shape)) - d1 = zoom3d(d1, np.array(shape) / np.array(d1.shape)) - d2 = zoom3d(d2, np.array(shape) / np.array(d2.shape)) - - d0 *= shift[0]/d0.std() - d1 *= shift[1]/d1.std() - d2 *= shift[2]/d2.std() - - d0 = d0[:min(d0.shape[0], shape[0]), :min(d0.shape[1], shape[1]), :min(d0.shape[2], shape[2])] - d1 = d1[:min(d1.shape[0], shape[0]), :min(d1.shape[1], shape[1]), :min(d1.shape[2], shape[2])] - d2 = d2[:min(d2.shape[0], shape[0]), :min(d2.shape[1], shape[1]), :min(d2.shape[2], shape[2])] - - return d0, d1, d2 +from .zoom3d import zoom3d + + +def random_deformation_field(shape, shift=2.0, n=30, npad=5, gaussian_std=6): + """Generate a smooth random 3d deformation vector field + + Parameters + ---------- + shape : 3 element tuple + shape of the deformation fields + + shift : float, 3 element tuple, optional + standard devitiation of the displacements in the deformation fields (default 2) + + n : int, 3 element tuple, optional + dimension of low resolution grid on which random deformations are sampled (default 30) + + npad: int, 3 element tuple, optional + number of voxels used for 0 padding of the low resolution grid (default 5) + + gaussian_std: float, 3 element tuple, optional + standard deviation of gaussian kernel used to smooth the low resolution deformations (default 6) + + Returns + ------- + tuple of 3 3d numpy arrays + containing the displacements in the 3 dimensions + """ + if not isinstance(shift, tuple): + shift = (shift,) * 3 + + if not isinstance(n, tuple): + n = (n,) * 3 + + if not isinstance(npad, tuple): + npad = (npad,) * 3 + + if not isinstance(gaussian_std, tuple): + gaussian_std = (gaussian_std,) * 3 + + d0 = gaussian_filter( + np.pad(np.random.randn(n[0], n[1], n[2]), npad[0], mode="constant"), + gaussian_std[0], + ) + d1 = gaussian_filter( + np.pad(np.random.randn(n[0], n[1], n[2]), npad[1], mode="constant"), + gaussian_std[1], + ) + d2 = gaussian_filter( + np.pad(np.random.randn(n[0], n[1], n[2]), npad[2], mode="constant"), + gaussian_std[2], + ) + + d0 = zoom3d(d0, np.array(shape) / np.array(d0.shape)) + d1 = zoom3d(d1, np.array(shape) / np.array(d1.shape)) + d2 = zoom3d(d2, np.array(shape) / np.array(d2.shape)) + + d0 *= shift[0] / d0.std() + d1 *= shift[1] / d1.std() + d2 *= shift[2] / d2.std() + + d0 = d0[ + : min(d0.shape[0], shape[0]), + : min(d0.shape[1], shape[1]), + : min(d0.shape[2], shape[2]), + ] + d1 = d1[ + : min(d1.shape[0], shape[0]), + : min(d1.shape[1], shape[1]), + : min(d1.shape[2], shape[2]), + ] + d2 = d2[ + : min(d2.shape[0], shape[0]), + : min(d2.shape[1], shape[1]), + : min(d2.shape[2], shape[2]), + ] + + return d0, d1, d2 diff --git a/pymirc/image_operations/reorient.py b/pymirc/image_operations/reorient.py index f99a445..60170b1 100644 --- a/pymirc/image_operations/reorient.py +++ b/pymirc/image_operations/reorient.py @@ -2,78 +2,80 @@ from nibabel.orientations import inv_ornt_aff import numpy as np + def reorient_image_and_affine(img, aff): - """ Reorient an image and and affine such that the affine is approx. diagonal - and such that the elements on the main diagonal are positiv - - Parameters - ---------- - img : 3D numpy array - containing the image - - aff : 2D numpy array - (4,4) affine transformation matrix from image to anatomical coordinate system in - homogeneous coordinates - - Returns - ------- - a tuple with the reoriented image and the accordingly transformed affine - - Note - ---- - The reorientation uses nibabel's io_orientation() and apply_orientation() - """ - ornt = nib.io_orientation(aff) - img_t = nib.apply_orientation(img, ornt) - aff_t = aff.dot(inv_ornt_aff(ornt, img.shape)) - - return img_t, aff_t + """Reorient an image and and affine such that the affine is approx. diagonal + and such that the elements on the main diagonal are positiv + + Parameters + ---------- + img : 3D numpy array + containing the image + + aff : 2D numpy array + (4,4) affine transformation matrix from image to anatomical coordinate system in + homogeneous coordinates + + Returns + ------- + a tuple with the reoriented image and the accordingly transformed affine + + Note + ---- + The reorientation uses nibabel's io_orientation() and apply_orientation() + """ + ornt = nib.io_orientation(aff) + img_t = nib.apply_orientation(img, ornt) + aff_t = aff.dot(inv_ornt_aff(ornt, img.shape)) + + return img_t, aff_t + def flip_image_and_affine(img, aff, flip_dirs): - """ Flip an image along given axis and transform the affine accordingly + """Flip an image along given axis and transform the affine accordingly - Parameters - ---------- - img : numpy array (2D, 3D) - containing the image + Parameters + ---------- + img : numpy array (2D, 3D) + containing the image - aff : 2D numpy array - (4,4) affine transformation matrix from image to anatomical coordinate system in - homogeneous coordinates + aff : 2D numpy array + (4,4) affine transformation matrix from image to anatomical coordinate system in + homogeneous coordinates - flip_dirs : int or tuple of ints - containing the axis where the flip should be applied + flip_dirs : int or tuple of ints + containing the axis where the flip should be applied - Returns - ------- - a tuple with the flipped image and the accordingly transformed affine - """ - if not isinstance(flip_dirs, tuple): - flip_dirs = (flip_dirs,) + Returns + ------- + a tuple with the flipped image and the accordingly transformed affine + """ + if not isinstance(flip_dirs, tuple): + flip_dirs = (flip_dirs,) - # flip the image - img = np.flip(img, flip_dirs) + # flip the image + img = np.flip(img, flip_dirs) - for flip_dir in flip_dirs: - # tmp is the diagonal of the flip affine (all ones, but -1 in the flip direction) - tmp = np.ones(img.ndim + 1) - tmp[flip_dir] = -1 + for flip_dir in flip_dirs: + # tmp is the diagonal of the flip affine (all ones, but -1 in the flip direction) + tmp = np.ones(img.ndim + 1) + tmp[flip_dir] = -1 - # tmp2 is the (i,j,k,1) vector for the 0,0,0 voxel - tmp2 = np.zeros(img.ndim + 1) - tmp2[-1] = 1 + # tmp2 is the (i,j,k,1) vector for the 0,0,0 voxel + tmp2 = np.zeros(img.ndim + 1) + tmp2[-1] = 1 - # tmp2 is the (i,j,k,1) vector for the last voxel along the flip direction - tmp3 = tmp2.copy() - tmp3[flip_dir] = img.shape[flip_dir] - 1 + # tmp2 is the (i,j,k,1) vector for the last voxel along the flip direction + tmp3 = tmp2.copy() + tmp3[flip_dir] = img.shape[flip_dir] - 1 - # this is the affine that does the flipping - # the flipping is actual a shift to the center, followed by an inversion - # followed by in the inverse shift - flip_aff = np.diag(tmp) - flip_aff[flip_dir,-1] = ((aff @ tmp2) + (aff @ tmp3))[flip_dir] + # this is the affine that does the flipping + # the flipping is actual a shift to the center, followed by an inversion + # followed by in the inverse shift + flip_aff = np.diag(tmp) + flip_aff[flip_dir, -1] = ((aff @ tmp2) + (aff @ tmp3))[flip_dir] - # transform the affine - aff = flip_aff @ aff + # transform the affine + aff = flip_aff @ aff - return img, aff + return img, aff diff --git a/pymirc/image_operations/resample_cont_cont.py b/pymirc/image_operations/resample_cont_cont.py index 6770c25..db76215 100644 --- a/pymirc/image_operations/resample_cont_cont.py +++ b/pymirc/image_operations/resample_cont_cont.py @@ -1,42 +1,45 @@ def resample_cont_cont(contourrows, resamplestruct): - """ compute a contour as a set of columns and rows - - from the output of mincostpath (which was applied to an image resampled with - resample_img_cont) - - Parameters - ---------- - contourrows : 1d numpy array - specifying a contour produced by NImincostpath. This - contour goes from left to right and has a single point in every - column. Therefore, it is specified as a 1D array, giving the - row coordinate for every column. - - resamplestruct : dictionary - returned resample_img_cont. The idea is to - first resample an image with resample_img_cont, then compute - a minimum cost path contour on the resampled image, and then - convert that contour back to the 2D image coordinates with - resample_cont_cont. - - Returns - ------- - (1d numpy array, 1d numpy array) - contraining the contour indices in the space after the inverse resampling - - Note - ---- - Python reimplementation of J. Nuyts' Niresample_cont_cont - """ - - inrows = contourrows - (resamplestruct['nroutrows'] - 1.0) / 2.0 - - if resamplestruct["steps"][resamplestruct["nroutrows"] - 1] < resamplestruct["steps"][0]: - inrows = -inrows - - inrows *= resamplestruct["stepsize"] - - outcols = resamplestruct["cencols"] + inrows * resamplestruct["normcols"] - outrows = resamplestruct["cenrows"] + inrows * resamplestruct["normrows"] - - return outcols, outrows + """compute a contour as a set of columns and rows + + from the output of mincostpath (which was applied to an image resampled with + resample_img_cont) + + Parameters + ---------- + contourrows : 1d numpy array + specifying a contour produced by NImincostpath. This + contour goes from left to right and has a single point in every + column. Therefore, it is specified as a 1D array, giving the + row coordinate for every column. + + resamplestruct : dictionary + returned resample_img_cont. The idea is to + first resample an image with resample_img_cont, then compute + a minimum cost path contour on the resampled image, and then + convert that contour back to the 2D image coordinates with + resample_cont_cont. + + Returns + ------- + (1d numpy array, 1d numpy array) + contraining the contour indices in the space after the inverse resampling + + Note + ---- + Python reimplementation of J. Nuyts' Niresample_cont_cont + """ + + inrows = contourrows - (resamplestruct["nroutrows"] - 1.0) / 2.0 + + if ( + resamplestruct["steps"][resamplestruct["nroutrows"] - 1] + < resamplestruct["steps"][0] + ): + inrows = -inrows + + inrows *= resamplestruct["stepsize"] + + outcols = resamplestruct["cencols"] + inrows * resamplestruct["normcols"] + outrows = resamplestruct["cenrows"] + inrows * resamplestruct["normrows"] + + return outcols, outrows diff --git a/pymirc/image_operations/resample_img_cont.py b/pymirc/image_operations/resample_img_cont.py index c76600d..b676e65 100644 --- a/pymirc/image_operations/resample_img_cont.py +++ b/pymirc/image_operations/resample_img_cont.py @@ -1,142 +1,156 @@ import numpy as np -from scipy.ndimage import map_coordinates - -def resample_img_cont(img, cntcols, cntrows, nroutrows, - stepsize = 1., outside_above = True, - interp_contour = False, resamplestruct = None): - """ Resample an image along a contour. - - The input is an image and a contour. The output is a resampled - version of the input image, in which the original contour becomes - a horizontal line in the center. - The main aim is to generate an input for NImincostpath, which - computes a minimum cost path contour from left to right in the - resampled image. - The combination of NIresample_img_cont, NImincostpath and - NIresample_cont_cont can be used to refine the input contour. - - Parameters - ---------- - img : 2d numpy array - image which must be resampled - - cntcols : 1d numpy array - column coordinates of the input contour - - cntrows : 1d array - row coordinates of the input contour - - nroutrows : int - the number of IDL 'rows' of the output image (in python the 1 dimention), - which is a resampled version of the input image. The resampled image will - be a strip along the input contour with a width of NROUTROWS * - STEPSIZE, so NROUTROWS must be increased if you choose a smaller - STEPSIZE. - - stepsize : float, optional - specifying how fine the input image must be sampled - perpendicular to the original contour. A value 0.5 means that - the vertical pixel size in the resampled image corresponds to - half the pixel size in the original image. - - interp_countour : bool, optional - !!! not implemented yet !!! - scalar, specifying how fine the input contour must be - sampled. If set to zero or not supplied, the input contour is - not resampled. If set to a non-zero value, the input image is - sampled along the contour with steps of INTERP_CONTOUR. A - smaller value for INTERP_CONTOUR will produce an output image - with more columns. - - resamplestruct : dict, optional - If set to zero or non-existing, it receives a structure which - contains all parameters required for resampling. This can be - used to resample another image OF THE SAME SIZE in exactly the - same way. - If it is a structure, all inputs except IMG are ignored, IMG is - resampled as prescribed by RESAMPLESTRUCT. - Can also be used in a call to resample_cont_cont. - - outside_above : bool, optional - If set, then it is assumed that the contour is closed, and the - inside of the contour will be at the bottom in the resampled - image. If not set, the resampling direction depends on the order - of the coordinates. For a closed contour in clockwise direction, - the inside of the image will be at the bottom. For the same - contour in counter clockwise direction, it will be at the top. - - Returns - ------- - 2d numpy array - an image of NROUTROWS with dimension (cntcols.shape[0], nroutrows) - - Note - ---- - only for 2D images. When RESAMPLESTRUCT is supplied, the input - image must have the same size as that in the call that returned - RESAMPLESTRUCT. - - Python reimplementation J. Nuyts' NIresample_img_cont - """ - - if resamplestruct is not None: - ncols = resamplestruct['ncols'] - nrows = resamplestruct['nrows'] - nroutrows = resamplestruct['nroutrows'] - cencols = resamplestruct['cencols'] - cenrows = resamplestruct['cenrows'] - normcols = resamplestruct['normcols'] - normrows = resamplestruct['normrows'] - steps = resamplestruct['steps'] - npoints = cencols.shape[0] - else: - if interp_contour: - #NIlin_contour, cencols, cenrows, cntcols, cntrows, interp_contour - raise Exception('interp_contour not implemented yet') +from scipy.ndimage import map_coordinates + + +def resample_img_cont( + img, + cntcols, + cntrows, + nroutrows, + stepsize=1.0, + outside_above=True, + interp_contour=False, + resamplestruct=None, +): + """Resample an image along a contour. + + The input is an image and a contour. The output is a resampled + version of the input image, in which the original contour becomes + a horizontal line in the center. + The main aim is to generate an input for NImincostpath, which + computes a minimum cost path contour from left to right in the + resampled image. + The combination of NIresample_img_cont, NImincostpath and + NIresample_cont_cont can be used to refine the input contour. + + Parameters + ---------- + img : 2d numpy array + image which must be resampled + + cntcols : 1d numpy array + column coordinates of the input contour + + cntrows : 1d array + row coordinates of the input contour + + nroutrows : int + the number of IDL 'rows' of the output image (in python the 1 dimention), + which is a resampled version of the input image. The resampled image will + be a strip along the input contour with a width of NROUTROWS * + STEPSIZE, so NROUTROWS must be increased if you choose a smaller + STEPSIZE. + + stepsize : float, optional + specifying how fine the input image must be sampled + perpendicular to the original contour. A value 0.5 means that + the vertical pixel size in the resampled image corresponds to + half the pixel size in the original image. + + interp_countour : bool, optional + !!! not implemented yet !!! + scalar, specifying how fine the input contour must be + sampled. If set to zero or not supplied, the input contour is + not resampled. If set to a non-zero value, the input image is + sampled along the contour with steps of INTERP_CONTOUR. A + smaller value for INTERP_CONTOUR will produce an output image + with more columns. + + resamplestruct : dict, optional + If set to zero or non-existing, it receives a structure which + contains all parameters required for resampling. This can be + used to resample another image OF THE SAME SIZE in exactly the + same way. + If it is a structure, all inputs except IMG are ignored, IMG is + resampled as prescribed by RESAMPLESTRUCT. + Can also be used in a call to resample_cont_cont. + + outside_above : bool, optional + If set, then it is assumed that the contour is closed, and the + inside of the contour will be at the bottom in the resampled + image. If not set, the resampling direction depends on the order + of the coordinates. For a closed contour in clockwise direction, + the inside of the image will be at the bottom. For the same + contour in counter clockwise direction, it will be at the top. + + Returns + ------- + 2d numpy array + an image of NROUTROWS with dimension (cntcols.shape[0], nroutrows) + + Note + ---- + only for 2D images. When RESAMPLESTRUCT is supplied, the input + image must have the same size as that in the call that returned + RESAMPLESTRUCT. + + Python reimplementation J. Nuyts' NIresample_img_cont + """ + + if resamplestruct is not None: + ncols = resamplestruct["ncols"] + nrows = resamplestruct["nrows"] + nroutrows = resamplestruct["nroutrows"] + cencols = resamplestruct["cencols"] + cenrows = resamplestruct["cenrows"] + normcols = resamplestruct["normcols"] + normrows = resamplestruct["normrows"] + steps = resamplestruct["steps"] + npoints = cencols.shape[0] else: - cencols = cntcols - cenrows = cntrows - - nrows, ncols = img.shape - offset = 1 - - npoints = cencols.shape[0] - index = np.arange(npoints) - right = np.clip(index + offset, None, npoints - 1) - left = np.clip(index - offset, 0, None) - normcols = cenrows[right] - cenrows[left] - normrows = -(cencols[right] - cencols[left]) - normval = np.sqrt(normcols**2 + normrows**2) - normcols /= normval - normrows /= normval - - # This way, the inside of a cyclic contour is at the bottom in the - # resampled image if the contour is given in clockwise direction. - #---------------------------- - steps = -np.arange(nroutrows, dtype = float) * stepsize - steps -= steps.mean() - - if outside_above: - testval = (normcols * (cencols - cencols.mean()) + normrows * (cenrows - cenrows.mean())).sum() - if testval < 0: steps = np.flip(steps) - - resamplestruct = {'ncols' : ncols, - 'nrows' : nrows, - 'nroutrows' : nroutrows, - 'cencols' : cencols, - 'cenrows' : cenrows, - 'normcols' : normcols, - 'normrows' : normrows, - 'stepsize' : stepsize, - 'steps' : steps} - - interpcols = np.zeros((npoints, nroutrows)) - interprows = np.zeros((npoints, nroutrows)) - - for col in range(npoints): - interpcols[col,:] = cencols[col] + steps * normcols[col] - interprows[col,:] = cenrows[col] + steps * normrows[col] - - outimg = map_coordinates(img, [interprows, interpcols], order = 1, prefilter = False) - - return outimg, resamplestruct + if interp_contour: + # NIlin_contour, cencols, cenrows, cntcols, cntrows, interp_contour + raise Exception("interp_contour not implemented yet") + else: + cencols = cntcols + cenrows = cntrows + + nrows, ncols = img.shape + offset = 1 + + npoints = cencols.shape[0] + index = np.arange(npoints) + right = np.clip(index + offset, None, npoints - 1) + left = np.clip(index - offset, 0, None) + normcols = cenrows[right] - cenrows[left] + normrows = -(cencols[right] - cencols[left]) + normval = np.sqrt(normcols**2 + normrows**2) + normcols /= normval + normrows /= normval + + # This way, the inside of a cyclic contour is at the bottom in the + # resampled image if the contour is given in clockwise direction. + # ---------------------------- + steps = -np.arange(nroutrows, dtype=float) * stepsize + steps -= steps.mean() + + if outside_above: + testval = ( + normcols * (cencols - cencols.mean()) + + normrows * (cenrows - cenrows.mean()) + ).sum() + if testval < 0: + steps = np.flip(steps) + + resamplestruct = { + "ncols": ncols, + "nrows": nrows, + "nroutrows": nroutrows, + "cencols": cencols, + "cenrows": cenrows, + "normcols": normcols, + "normrows": normrows, + "stepsize": stepsize, + "steps": steps, + } + + interpcols = np.zeros((npoints, nroutrows)) + interprows = np.zeros((npoints, nroutrows)) + + for col in range(npoints): + interpcols[col, :] = cencols[col] + steps * normcols[col] + interprows[col, :] = cenrows[col] + steps * normrows[col] + + outimg = map_coordinates(img, [interprows, interpcols], order=1, prefilter=False) + + return outimg, resamplestruct diff --git a/pymirc/image_operations/rigid_registration.py b/pymirc/image_operations/rigid_registration.py index 7b3450c..7cc3801 100644 --- a/pymirc/image_operations/rigid_registration.py +++ b/pymirc/image_operations/rigid_registration.py @@ -5,92 +5,117 @@ from pymirc.image_operations import kul_aff, aff_transform from pymirc.metrics.cost_functions import neg_mutual_information, regis_cost_func -def rigid_registration(vol_float, vol_fixed, aff_float, aff_fixed, - downsample_facs = [4], metric = neg_mutual_information, - opts = {'ftol':1e-2,'xtol':1e-2,'disp':True,'maxiter':20,'maxfev':5000}, - method = 'Powell', metric_kwargs = {}): - """ rigidly coregister a 3D floating volume to a fixed (reference) volume - - Parameters - ---------- - vol_float : 3D numpy array - the floating volume - - vol_fixed : 3D numpy array - the fixed (reference) volume - - aff_float : 2D 4x4 numpy array - affine transformation matrix that maps from pixel to world coordinates for floating volume - - aff_fixed : 2D 4x4 numpy array - affine transformation matrix that maps from pixel to world coordinates for fixed volume - - downsample_facs : None of array_like - perform registrations on downsampled grids before registering original volumes - (multi-resolution approach) - - metric : function(x,y, **kwargs) - metric that compares transformed floating and fixed volume - - metric_kwargs : dict - keyword arguments passed to the metric function - - opts : dictionary - passed to scipy.optimize.minimize as options - - method : string - passed to scipy.optimize.minimize as method (which optimizer to use) - - Returns - ------- - tuple of 3 - - the transformed (coregistered) floating volume - - the registration affine transformation matrix - - the 6 registration parameters (3 translations, 3 rotations) from which the - affine matrix was derived - - Note - ---- - - To apply the registration affine transformation use: - new_vol = aff_transform(vol, reg_aff, vol_fixed.shape, cval = vol.min()) - - """ - # define the affine transformation that maps the floating to the fixed voxel grid - # we need this to avoid the additional interpolation in case the voxel sizes are not the same - pre_affine = np.linalg.inv(aff_float) @ aff_fixed - - reg_params = np.zeros(6) - - # (1) initial registration with downsampled arrays - if downsample_facs is not None: - for dsf in downsample_facs: - ds_aff = np.diag([dsf,dsf,dsf,1.]) - - # down sample fixed volume - vol_fixed_ds = aff_transform(vol_fixed, ds_aff, - np.ceil(np.array(vol_fixed.shape)/dsf).astype(int)) - - res = minimize(regis_cost_func, reg_params, method = method, options = opts, - args = (vol_fixed_ds, vol_float, True, True, metric, pre_affine @ ds_aff, - metric_kwargs)) - - reg_params = res.x.copy() - # we have to scale the translations by the down sample factor since they are in voxels - reg_params[:3] *= dsf - - - # (2) registration with full arrays - res = minimize(regis_cost_func, reg_params, method = method, options = opts, - args = (vol_fixed, vol_float, True, True, metric, pre_affine, - metric_kwargs)) - reg_params = res.x.copy() - - - # define the final affine transformation that maps from the PET grid to the rotated CT grid - reg_aff = pre_affine @ kul_aff(reg_params, origin = np.array(vol_fixed.shape)/2) - - # transform the floating volume - vol_float_coreg = aff_transform(vol_float, reg_aff, vol_fixed.shape, cval = vol_float.min()) - - return vol_float_coreg, reg_aff, reg_params + +def rigid_registration( + vol_float, + vol_fixed, + aff_float, + aff_fixed, + downsample_facs=[4], + metric=neg_mutual_information, + opts={"ftol": 1e-2, "xtol": 1e-2, "disp": True, "maxiter": 20, "maxfev": 5000}, + method="Powell", + metric_kwargs={}, +): + """rigidly coregister a 3D floating volume to a fixed (reference) volume + + Parameters + ---------- + vol_float : 3D numpy array + the floating volume + + vol_fixed : 3D numpy array + the fixed (reference) volume + + aff_float : 2D 4x4 numpy array + affine transformation matrix that maps from pixel to world coordinates for floating volume + + aff_fixed : 2D 4x4 numpy array + affine transformation matrix that maps from pixel to world coordinates for fixed volume + + downsample_facs : None of array_like + perform registrations on downsampled grids before registering original volumes + (multi-resolution approach) + + metric : function(x,y, **kwargs) + metric that compares transformed floating and fixed volume + + metric_kwargs : dict + keyword arguments passed to the metric function + + opts : dictionary + passed to scipy.optimize.minimize as options + + method : string + passed to scipy.optimize.minimize as method (which optimizer to use) + + Returns + ------- + tuple of 3 + - the transformed (coregistered) floating volume + - the registration affine transformation matrix + - the 6 registration parameters (3 translations, 3 rotations) from which the + affine matrix was derived + + Note + ---- + + To apply the registration affine transformation use: + new_vol = aff_transform(vol, reg_aff, vol_fixed.shape, cval = vol.min()) + + """ + # define the affine transformation that maps the floating to the fixed voxel grid + # we need this to avoid the additional interpolation in case the voxel sizes are not the same + pre_affine = np.linalg.inv(aff_float) @ aff_fixed + + reg_params = np.zeros(6) + + # (1) initial registration with downsampled arrays + if downsample_facs is not None: + for dsf in downsample_facs: + ds_aff = np.diag([dsf, dsf, dsf, 1.0]) + + # down sample fixed volume + vol_fixed_ds = aff_transform( + vol_fixed, ds_aff, np.ceil(np.array(vol_fixed.shape) / dsf).astype(int) + ) + + res = minimize( + regis_cost_func, + reg_params, + method=method, + options=opts, + args=( + vol_fixed_ds, + vol_float, + True, + True, + metric, + pre_affine @ ds_aff, + metric_kwargs, + ), + ) + + reg_params = res.x.copy() + # we have to scale the translations by the down sample factor since they are in voxels + reg_params[:3] *= dsf + + # (2) registration with full arrays + res = minimize( + regis_cost_func, + reg_params, + method=method, + options=opts, + args=(vol_fixed, vol_float, True, True, metric, pre_affine, metric_kwargs), + ) + reg_params = res.x.copy() + + # define the final affine transformation that maps from the PET grid to the rotated CT grid + reg_aff = pre_affine @ kul_aff(reg_params, origin=np.array(vol_fixed.shape) / 2) + + # transform the floating volume + vol_float_coreg = aff_transform( + vol_float, reg_aff, vol_fixed.shape, cval=vol_float.min() + ) + + return vol_float_coreg, reg_aff, reg_params diff --git a/pymirc/image_operations/zoom3d.py b/pymirc/image_operations/zoom3d.py index 881fd76..49585b7 100644 --- a/pymirc/image_operations/zoom3d.py +++ b/pymirc/image_operations/zoom3d.py @@ -3,374 +3,386 @@ from numba import prange, njit -#--------------------------------------------------------------------------- -@njit(parallel = True) -def upsample_3d_0(array, zoom, cval = 0): - """Upsample a 3D array in the 0 (left-most) direction - - Parameters - ---------- - array : 3D numpy array - array to be upsampled - - zoom : float > 1 - zoom factor. a zoom factors of 2 means that the shape will double - cval : float, optional - constant value used for background - - Returns - ------- - 3D numpy array - the upsampled array - """ - delta = 1./zoom +# --------------------------------------------------------------------------- +@njit(parallel=True) +def upsample_3d_0(array, zoom, cval=0): + """Upsample a 3D array in the 0 (left-most) direction - # number of elements in array with big voxels - nb,n1,n2 = array.shape + Parameters + ---------- + array : 3D numpy array + array to be upsampled - # number of elements in arrray with small voxels - ns = math.ceil(nb/delta) + zoom : float > 1 + zoom factor. a zoom factors of 2 means that the shape will double - new_array = np.zeros((ns,n1,n2)) - if cval != 0: new_array += cval - - - for j in prange(n1): - for i in range(ns): - for k in range(n2): - # calculate the bin center of the small voxel array - # in coordinates in the big voxel array - ib_c = delta*(i - 0.5*ns + 0.5) + 0.5*nb - 0.5 - - fl_l = math.floor(ib_c) - fl_r = fl_l + 1 + cval : float, optional + constant value used for background - if (fl_l >= 0): - a = array[fl_l,j,k] * (fl_r - ib_c) - else: - a = cval - - if (fl_r < nb): - b = array[fl_r,j,k] * (ib_c - fl_l) - else: - b = cval - - new_array[i,j,k] = a + b + Returns + ------- + 3D numpy array + the upsampled array + """ + delta = 1.0 / zoom - return new_array - -#--------------------------------------------------------------------------- -@njit(parallel = True) -def upsample_3d_1(array, zoom, cval = 0): - """Upsample a 3D array in the 1 (middle) direction - - Parameters - ---------- - array : 3D numpy array - array to be upsampled + # number of elements in array with big voxels + nb, n1, n2 = array.shape - zoom : float > 1 - zoom factor. a zoom factors of 2 means that the shape will double + # number of elements in arrray with small voxels + ns = math.ceil(nb / delta) - cval : float, optional - constant value used for background + new_array = np.zeros((ns, n1, n2)) + if cval != 0: + new_array += cval - Returns - ------- - 3D numpy array - the upsampled array - """ - delta = 1./zoom + for j in prange(n1): + for i in range(ns): + for k in range(n2): + # calculate the bin center of the small voxel array + # in coordinates in the big voxel array + ib_c = delta * (i - 0.5 * ns + 0.5) + 0.5 * nb - 0.5 - # number of elements in array with big voxels - n0,nb,n2 = array.shape + fl_l = math.floor(ib_c) + fl_r = fl_l + 1 - # number of elements in arrray with small voxels - ns = math.ceil(nb/delta) + if fl_l >= 0: + a = array[fl_l, j, k] * (fl_r - ib_c) + else: + a = cval - new_array = np.zeros((n0,ns,n2)) - if cval != 0: new_array += cval + if fl_r < nb: + b = array[fl_r, j, k] * (ib_c - fl_l) + else: + b = cval - for i in prange(n0): - for j in range(ns): - for k in range(n2): - # calculate the bin center of the small voxel array - # in coordinates in the big voxel array - ib_c = delta*(j - 0.5*ns + 0.5) + 0.5*nb - 0.5 + new_array[i, j, k] = a + b - fl_l = math.floor(ib_c) - fl_r = fl_l + 1 + return new_array - if (fl_l >= 0): - a = array[i,fl_l,k] * (fl_r - ib_c) - else: - a = cval - if (fl_r < nb): - b = array[i,fl_r,k] * (ib_c - fl_l) - else: - b = cval +# --------------------------------------------------------------------------- +@njit(parallel=True) +def upsample_3d_1(array, zoom, cval=0): + """Upsample a 3D array in the 1 (middle) direction - new_array[i,j,k] = a + b + Parameters + ---------- + array : 3D numpy array + array to be upsampled - return new_array + zoom : float > 1 + zoom factor. a zoom factors of 2 means that the shape will double -#--------------------------------------------------------------------------- -@njit(parallel = True) -def upsample_3d_2(array, zoom, cval = 0): - """Upsample a 3D array in the 2 (right-most) direction + cval : float, optional + constant value used for background - Parameters - ---------- - array : 3D numpy array - array to be upsampled + Returns + ------- + 3D numpy array + the upsampled array + """ + delta = 1.0 / zoom - zoom : float > 1 - zoom factor. a zoom factors of 2 means that the shape will double + # number of elements in array with big voxels + n0, nb, n2 = array.shape - cval : float, optional - constant value used for background + # number of elements in arrray with small voxels + ns = math.ceil(nb / delta) - Returns - ------- - 3D numpy array - the upsampled array - """ - delta = 1./zoom + new_array = np.zeros((n0, ns, n2)) + if cval != 0: + new_array += cval - # number of elements in array with big voxels - n0,n1,nb = array.shape + for i in prange(n0): + for j in range(ns): + for k in range(n2): + # calculate the bin center of the small voxel array + # in coordinates in the big voxel array + ib_c = delta * (j - 0.5 * ns + 0.5) + 0.5 * nb - 0.5 - # number of elements in arrray with small voxels - ns = math.ceil(nb/delta) + fl_l = math.floor(ib_c) + fl_r = fl_l + 1 - new_array = np.zeros((n0,n1,ns)) - if cval != 0: new_array += cval + if fl_l >= 0: + a = array[i, fl_l, k] * (fl_r - ib_c) + else: + a = cval - for i in prange(n0): - for j in range(n1): - for k in range(ns): - # calculate the bin center of the small voxel array - # in coordinates in the big voxel array - ib_c = delta*(k - 0.5*ns + 0.5) + 0.5*nb - 0.5 + if fl_r < nb: + b = array[i, fl_r, k] * (ib_c - fl_l) + else: + b = cval - fl_l = math.floor(ib_c) - fl_r = fl_l + 1 + new_array[i, j, k] = a + b - if (fl_l >= 0): - a = array[i,j,fl_l] * (fl_r - ib_c) - else: - a = cval + return new_array - if (fl_r < nb): - b = array[i,j,fl_r] * (ib_c - fl_l) - else: - b = cval - new_array[i,j,k] = a + b +# --------------------------------------------------------------------------- +@njit(parallel=True) +def upsample_3d_2(array, zoom, cval=0): + """Upsample a 3D array in the 2 (right-most) direction - return new_array + Parameters + ---------- + array : 3D numpy array + array to be upsampled + zoom : float > 1 + zoom factor. a zoom factors of 2 means that the shape will double + cval : float, optional + constant value used for background -#--------------------------------------------------------------------------- -@njit(parallel = True) -def downsample_3d_0(array, zoom, cval = 0): - """Downsample a 3D array in the 0 (left-most) direction + Returns + ------- + 3D numpy array + the upsampled array + """ + delta = 1.0 / zoom - Parameters - ---------- - array : 3D numpy array - array to be upsampled + # number of elements in array with big voxels + n0, n1, nb = array.shape - zoom : float < 1 - zoom factor. a zoom factors of 0.5 means that the shape will reduced by a factor of 2 + # number of elements in arrray with small voxels + ns = math.ceil(nb / delta) - cval : float, optional - constant value used for background + new_array = np.zeros((n0, n1, ns)) + if cval != 0: + new_array += cval - Returns - ------- - 3D numpy array - the upsampled array - """ - delta = 1./zoom + for i in prange(n0): + for j in range(n1): + for k in range(ns): + # calculate the bin center of the small voxel array + # in coordinates in the big voxel array + ib_c = delta * (k - 0.5 * ns + 0.5) + 0.5 * nb - 0.5 - # number of elements in array with small voxels - ns,n1,n2 = array.shape + fl_l = math.floor(ib_c) + fl_r = fl_l + 1 - # number of elements in arrray with big voxels - nb = math.ceil(ns/delta) + if fl_l >= 0: + a = array[i, j, fl_l] * (fl_r - ib_c) + else: + a = cval - new_array = np.zeros((nb,n1,n2)) - if cval != 0: new_array += cval + if fl_r < nb: + b = array[i, j, fl_r] * (ib_c - fl_l) + else: + b = cval - for j in prange(n1): - for i in range(ns): - for k in range(n2): - ib_l = (1./delta) *(i - 0.5*ns) + 0.5*nb - ib_r = (1./delta) *(i + 1 - 0.5*ns) + 0.5*nb + new_array[i, j, k] = a + b - left_bin = math.floor(ib_l) - right_bin = math.ceil(ib_r) - 1 + return new_array - if left_bin == right_bin: - new_array[left_bin,j,k] += array[i,j,k] / delta - else: - c = math.ceil(ib_l) - new_array[left_bin,j,k] += array[i,j,k]*(c - ib_l) - new_array[right_bin,j,k] += array[i,j,k]*(ib_r - c) - return new_array +# --------------------------------------------------------------------------- +@njit(parallel=True) +def downsample_3d_0(array, zoom, cval=0): + """Downsample a 3D array in the 0 (left-most) direction -#--------------------------------------------------------------------------- -@njit(parallel = True) -def downsample_3d_1(array, zoom, cval = 0): - """Downsample a 3D array in the 1 (middle) direction + Parameters + ---------- + array : 3D numpy array + array to be upsampled - Parameters - ---------- - array : 3D numpy array - array to be upsampled + zoom : float < 1 + zoom factor. a zoom factors of 0.5 means that the shape will reduced by a factor of 2 - zoom : float < 1 - zoom factor. a zoom factors of 0.5 means that the shape will reduced by a factor of 2 + cval : float, optional + constant value used for background - cval : float, optional - constant value used for background + Returns + ------- + 3D numpy array + the upsampled array + """ + delta = 1.0 / zoom - Returns - ------- - 3D numpy array - the upsampled array - """ - delta = 1./zoom + # number of elements in array with small voxels + ns, n1, n2 = array.shape - # number of elements in array with small voxels - n0,ns,n2 = array.shape - - # number of elements in arrray with big voxels - nb = math.ceil(ns/delta) - - new_array = np.zeros((n0,nb,n2)) - if cval != 0: new_array += cval - - for i in prange(n0): - for j in range(ns): - for k in range(n2): - ib_l = (1./delta) *(j - 0.5*ns) + 0.5*nb - ib_r = (1./delta) *(j + 1 - 0.5*ns) + 0.5*nb - - left_bin = math.floor(ib_l) - right_bin = math.ceil(ib_r) - 1 - - if left_bin == right_bin: - new_array[i,left_bin,k] += array[i,j,k] / delta - else: - c = math.ceil(ib_l) - new_array[i,left_bin,k] += array[i,j,k]*(c - ib_l) - new_array[i,right_bin,k] += array[i,j,k]*(ib_r - c) - - return new_array - -#--------------------------------------------------------------------------- -@njit(parallel = True) -def downsample_3d_2(array, zoom, cval = 0): - """Downsample a 3D array in the 2 (right-most) direction - - Parameters - ---------- - array : 3D numpy array - array to be upsampled - - zoom : float < 1 - zoom factor. a zoom factors of 0.5 means that the shape will reduced by a factor of 2 - - cval : float, optional - constant value used for background - - Returns - ------- - 3D numpy array - the upsampled array - """ - delta = 1./zoom - - # number of elements in array with small voxels - n0,n1,ns = array.shape - - # number of elements in arrray with big voxels - nb = math.ceil(ns/delta) - - new_array = np.zeros((n0,n1,nb)) - if cval != 0: new_array += cval - - for i in prange(n0): - for j in range(n1): - for k in range(ns): - ib_l = (1./delta) *(k - 0.5*ns) + 0.5*nb - ib_r = (1./delta) *(k + 1 - 0.5*ns) + 0.5*nb - - left_bin = math.floor(ib_l) - right_bin = math.ceil(ib_r) - 1 - - if left_bin == right_bin: - new_array[i,j,left_bin] += array[i,j,k] / delta - else: - c = math.ceil(ib_l) - new_array[i,j,left_bin] += array[i,j,k]*(c - ib_l) - new_array[i,j,right_bin] += array[i,j,k]*(ib_r - c) - - return new_array - -#--------------------------------------------------------------------------- - -def zoom3d(vol, zoom, cval = 0): - """Zoom (upsample or downsample) a 3d array along all axis. - - Parameters - ---------- - vol : 3d numpy array - volume to be zoomed - - zoom : float or 3 element tuple/list/array of floats - the zoom factors along each axis. - if a scalar is provided, the same zoom is applied - along each axis. - - cval : float - constant value around the input array needed for boarder voxels (default 0) - - Returns - ------- - 3d numpy arrays - zoomed version of the input array. - - Note - ---- - This function is supposed to be similar to scipy.ndimage.zoom - but much faster (parallel via numba) and better if the zoom factors - are < 1 (down sampling). - """ - if not isinstance(zoom, (list, tuple, np.ndarray)): - zoom = 3*[zoom] - - if zoom[0] > 1: - vol = upsample_3d_0(vol, zoom[0], cval = cval) - elif zoom[0] < 1: - vol = downsample_3d_0(vol, zoom[0], cval = cval) - - if zoom[1] > 1: - vol = upsample_3d_1(vol, zoom[1], cval = cval) - elif zoom[1] < 1: - vol = downsample_3d_1(vol, zoom[1], cval = cval) - - if zoom[2] > 1: - vol = upsample_3d_2(vol, zoom[2], cval = cval) - elif zoom[2] < 1: - vol = downsample_3d_2(vol, zoom[2], cval = cval) - - return vol - -#---------------------------------------------------------------- + # number of elements in arrray with big voxels + nb = math.ceil(ns / delta) + + new_array = np.zeros((nb, n1, n2)) + if cval != 0: + new_array += cval + + for j in prange(n1): + for i in range(ns): + for k in range(n2): + ib_l = (1.0 / delta) * (i - 0.5 * ns) + 0.5 * nb + ib_r = (1.0 / delta) * (i + 1 - 0.5 * ns) + 0.5 * nb + + left_bin = math.floor(ib_l) + right_bin = math.ceil(ib_r) - 1 + + if left_bin == right_bin: + new_array[left_bin, j, k] += array[i, j, k] / delta + else: + c = math.ceil(ib_l) + new_array[left_bin, j, k] += array[i, j, k] * (c - ib_l) + new_array[right_bin, j, k] += array[i, j, k] * (ib_r - c) + + return new_array + + +# --------------------------------------------------------------------------- +@njit(parallel=True) +def downsample_3d_1(array, zoom, cval=0): + """Downsample a 3D array in the 1 (middle) direction + + Parameters + ---------- + array : 3D numpy array + array to be upsampled + + zoom : float < 1 + zoom factor. a zoom factors of 0.5 means that the shape will reduced by a factor of 2 + + cval : float, optional + constant value used for background + + Returns + ------- + 3D numpy array + the upsampled array + """ + delta = 1.0 / zoom + + # number of elements in array with small voxels + n0, ns, n2 = array.shape + + # number of elements in arrray with big voxels + nb = math.ceil(ns / delta) + + new_array = np.zeros((n0, nb, n2)) + if cval != 0: + new_array += cval + + for i in prange(n0): + for j in range(ns): + for k in range(n2): + ib_l = (1.0 / delta) * (j - 0.5 * ns) + 0.5 * nb + ib_r = (1.0 / delta) * (j + 1 - 0.5 * ns) + 0.5 * nb + + left_bin = math.floor(ib_l) + right_bin = math.ceil(ib_r) - 1 + + if left_bin == right_bin: + new_array[i, left_bin, k] += array[i, j, k] / delta + else: + c = math.ceil(ib_l) + new_array[i, left_bin, k] += array[i, j, k] * (c - ib_l) + new_array[i, right_bin, k] += array[i, j, k] * (ib_r - c) + + return new_array + + +# --------------------------------------------------------------------------- +@njit(parallel=True) +def downsample_3d_2(array, zoom, cval=0): + """Downsample a 3D array in the 2 (right-most) direction + + Parameters + ---------- + array : 3D numpy array + array to be upsampled + + zoom : float < 1 + zoom factor. a zoom factors of 0.5 means that the shape will reduced by a factor of 2 + + cval : float, optional + constant value used for background + + Returns + ------- + 3D numpy array + the upsampled array + """ + delta = 1.0 / zoom + + # number of elements in array with small voxels + n0, n1, ns = array.shape + + # number of elements in arrray with big voxels + nb = math.ceil(ns / delta) + + new_array = np.zeros((n0, n1, nb)) + if cval != 0: + new_array += cval + + for i in prange(n0): + for j in range(n1): + for k in range(ns): + ib_l = (1.0 / delta) * (k - 0.5 * ns) + 0.5 * nb + ib_r = (1.0 / delta) * (k + 1 - 0.5 * ns) + 0.5 * nb + + left_bin = math.floor(ib_l) + right_bin = math.ceil(ib_r) - 1 + + if left_bin == right_bin: + new_array[i, j, left_bin] += array[i, j, k] / delta + else: + c = math.ceil(ib_l) + new_array[i, j, left_bin] += array[i, j, k] * (c - ib_l) + new_array[i, j, right_bin] += array[i, j, k] * (ib_r - c) + + return new_array + + +# --------------------------------------------------------------------------- + + +def zoom3d(vol, zoom, cval=0): + """Zoom (upsample or downsample) a 3d array along all axis. + + Parameters + ---------- + vol : 3d numpy array + volume to be zoomed + + zoom : float or 3 element tuple/list/array of floats + the zoom factors along each axis. + if a scalar is provided, the same zoom is applied + along each axis. + + cval : float + constant value around the input array needed for boarder voxels (default 0) + + Returns + ------- + 3d numpy arrays + zoomed version of the input array. + + Note + ---- + This function is supposed to be similar to scipy.ndimage.zoom + but much faster (parallel via numba) and better if the zoom factors + are < 1 (down sampling). + """ + if not isinstance(zoom, (list, tuple, np.ndarray)): + zoom = 3 * [zoom] + + if zoom[0] > 1: + vol = upsample_3d_0(vol, zoom[0], cval=cval) + elif zoom[0] < 1: + vol = downsample_3d_0(vol, zoom[0], cval=cval) + + if zoom[1] > 1: + vol = upsample_3d_1(vol, zoom[1], cval=cval) + elif zoom[1] < 1: + vol = downsample_3d_1(vol, zoom[1], cval=cval) + + if zoom[2] > 1: + vol = upsample_3d_2(vol, zoom[2], cval=cval) + elif zoom[2] < 1: + vol = downsample_3d_2(vol, zoom[2], cval=cval) + + return vol + + +# ---------------------------------------------------------------- diff --git a/pymirc/metrics/cost_functions.py b/pymirc/metrics/cost_functions.py index 4b6fc30..30afd43 100644 --- a/pymirc/metrics/cost_functions.py +++ b/pymirc/metrics/cost_functions.py @@ -5,190 +5,207 @@ from pymirc.image_operations import aff_transform, kul_aff -#------------------------------------------------------------------------------- -@njit(parallel = True) -def jointhisto_3dvols(x, y, nbins = 40, normalize = True): - """Calculate the joint histogram between two 3d volumes. - Parameters - ---------- - x : 3D numpy array - first input +# ------------------------------------------------------------------------------- +@njit(parallel=True) +def jointhisto_3dvols(x, y, nbins=40, normalize=True): + """Calculate the joint histogram between two 3d volumes. - y : 3D numpy array - second input + Parameters + ---------- + x : 3D numpy array + first input - nbins : int, optional - number of bins in the joint histogram (default 40) + y : 3D numpy array + second input + + nbins : int, optional + number of bins in the joint histogram (default 40) + + normalize : bool, optional + divide the counts in each bin by the number of data points (default True) + + Returns + ------- + 2D numpy array of shape (nbins, nbins) + containing the joint histogram + + Note + ---- + E.g. useful for rigid registration of two 3d volumes with + using mutual information. + + The implementaion is optimized for a multi cpu system using + numba. - normalize : bool, optional - divide the counts in each bin by the number of data points (default True) + The left edges of the bins are at np.arange(nbins) * binwidth + jmin + where jmin is the joint minimum between x and y and + binwidth = (jmax - jmin) / (nbins - 1). + """ + + xmin = x.min() + xmax = x.max() + + ymin = y.min() + ymax = y.max() + + xbinwidth = (xmax - xmin) / (nbins - 1) + ybinwidth = (ymax - ymin) / (nbins - 1) + + n0, n1, n2 = x.shape + ntot = n0 * n1 * n2 + + # we create n0 temporary 2d histograms to avoid race conditions + tmp = np.zeros((n0, nbins, nbins), dtype=np.uint64) + # the dtype of the joint histo is float64 because we might want to normalize it + jhisto = np.zeros((nbins, nbins), dtype=np.float64) + + for i in prange(n0): + for j in range(n1): + for k in range(n2): + tmp[ + i, + math.floor((x[i, j, k] - xmin) / xbinwidth), + math.floor((y[i, j, k] - ymin) / ybinwidth), + ] += 1 + + for j in prange(nbins): + for k in range(nbins): + for i in range(n0): + if normalize: + jhisto[j, k] += tmp[i, j, k] / ntot + else: + jhisto[j, k] += tmp[i, j, k] + + return jhisto - Returns - ------- - 2D numpy array of shape (nbins, nbins) - containing the joint histogram - - Note - ---- - E.g. useful for rigid registration of two 3d volumes with - using mutual information. - - The implementaion is optimized for a multi cpu system using - numba. - - The left edges of the bins are at np.arange(nbins) * binwidth + jmin - where jmin is the joint minimum between x and y and - binwidth = (jmax - jmin) / (nbins - 1). - """ - - xmin = x.min() - xmax = x.max() - - ymin = y.min() - ymax = y.max() - - xbinwidth = (xmax - xmin) / (nbins - 1) - ybinwidth = (ymax - ymin) / (nbins - 1) - - n0, n1, n2 = x.shape - ntot = n0*n1*n2 - - # we create n0 temporary 2d histograms to avoid race conditions - tmp = np.zeros((n0, nbins, nbins), dtype = np.uint64) - # the dtype of the joint histo is float64 because we might want to normalize it - jhisto = np.zeros((nbins, nbins), dtype = np.float64) - - for i in prange(n0): - for j in range(n1): - for k in range(n2): - tmp[i,math.floor((x[i,j,k]-xmin)/xbinwidth),math.floor((y[i,j,k]-ymin)/ybinwidth)] += 1 - - for j in prange(nbins): - for k in range(nbins): - for i in range(n0): - if normalize: - jhisto[j,k] += tmp[i,j,k] / ntot - else: - jhisto[j,k] += tmp[i,j,k] - - return jhisto - - -#---------------------------------------------------------------- -def neg_mutual_information(x, y, nbins = 40, norm = True): - """Negative mutual information between two 3D volumes - - Parameters - ---------- - x : 3D numpy array - first input - - y : 3D numpy array - second input - - nbins : int, optional - number of bins in the joint histogram (default 40) - - norm: bool, optional - whether to use normalized version of MI (default True) - - Returns - ------- - float - containing the negative mutual information - - References - ---------- - Maes et al: IEEE TRANSACTIONS ON MEDICAL IMAGING, VOL. 16, NO. 2, APRIL 1997 - Studholme et al: Pattern Recognition 32 (1999) 71 86 - """ - p_xy = jointhisto_3dvols(x, y, nbins = nbins, normalize = True) - ixy = np.where(p_xy > 0) - - # calculate the outer product of the marginal distributions - p_x = p_xy.sum(axis = 1) - p_y = p_xy.sum(axis = 0) - - if norm: - # normalized mututal information - # Studholme et al: Pattern Recognition 32 (1999) 71 86 - # has 1:1 corespondance to ECC defined by Maes et al - - ix = np.where(p_x > 0) - iy = np.where(p_y > 0) - - mi = -(((p_x[ix]*np.log(p_x[ix])).sum() + (p_y[iy]*np.log(p_y[iy])).sum()) / - (p_xy[ixy]*np.log(p_xy[ixy])).sum()) - - else: - # conventional mutual information - # Maes et al: IEEE TRANSACTIONS ON MEDICAL IMAGING, VOL. 16, NO. 2, APRIL 1997 - p_x_p_y = np.outer(p_x, p_y) - mi = -(p_xy[ixy] * np.log(p_xy[ixy]/p_x_p_y[ixy])).sum() - - return mi - -#---------------------------------------------------------------- -def regis_cost_func(params, img_fix, img_float, verbose = False, - rotate = True, metric = neg_mutual_information, pre_affine = None, - metric_kwargs = {}): - """Generic cost function for rigid registration - - Parameters - ---------- - - params : 3 or 6 element numpy array - If rotate is False it contains the 3 translations - If rotate is True it contains the 3 translations and the 3 rotation angles - - img_fix : 3D numpy array - containg the fixed (reference) volume - - img_float : 3D numpy array - containg the floating (moving) volume - - verbose : bool, optional - print verbose output (default False) - - rotate : bool, optional - rotate volume as well on top of translations (6 degrees of freedom) - - metric : function(img_fix, aff_transform(img_float, ...)) -> R, optional - metric used to compare fixed and floating volume (default neg_mutual_information) - - pre_affine : 2D 4x4 numpy array - affine transformation applied before doing the rotation and shifting - - metric_kwargs : dictionary - key word arguments passed to the metric function - - Returns - ------- - float - the metric between the fixed and floating volume - - See Also - -------- - kul_aff() - """ - if rotate: - p = params.copy() - else: - p = np.concatenate((params,np.zeros(3))) - - if pre_affine is not None: - af = pre_affine @ kul_aff(p, origin = np.array(img_fix.shape)/2) - else: - af = kul_aff(params, origin = np.array(img_fix.shape)/2) - - m = metric(img_fix, aff_transform(img_float, af, img_fix.shape, cval = img_float.min()), **metric_kwargs) - - if verbose: - print(params) - print(m) - print('') - - return m +# ---------------------------------------------------------------- +def neg_mutual_information(x, y, nbins=40, norm=True): + """Negative mutual information between two 3D volumes + Parameters + ---------- + x : 3D numpy array + first input + + y : 3D numpy array + second input + + nbins : int, optional + number of bins in the joint histogram (default 40) + + norm: bool, optional + whether to use normalized version of MI (default True) + + Returns + ------- + float + containing the negative mutual information + + References + ---------- + Maes et al: IEEE TRANSACTIONS ON MEDICAL IMAGING, VOL. 16, NO. 2, APRIL 1997 + Studholme et al: Pattern Recognition 32 (1999) 71 86 + """ + p_xy = jointhisto_3dvols(x, y, nbins=nbins, normalize=True) + ixy = np.where(p_xy > 0) + + # calculate the outer product of the marginal distributions + p_x = p_xy.sum(axis=1) + p_y = p_xy.sum(axis=0) + + if norm: + # normalized mututal information + # Studholme et al: Pattern Recognition 32 (1999) 71 86 + # has 1:1 corespondance to ECC defined by Maes et al + + ix = np.where(p_x > 0) + iy = np.where(p_y > 0) + + mi = -( + ((p_x[ix] * np.log(p_x[ix])).sum() + (p_y[iy] * np.log(p_y[iy])).sum()) + / (p_xy[ixy] * np.log(p_xy[ixy])).sum() + ) + + else: + # conventional mutual information + # Maes et al: IEEE TRANSACTIONS ON MEDICAL IMAGING, VOL. 16, NO. 2, APRIL 1997 + p_x_p_y = np.outer(p_x, p_y) + mi = -(p_xy[ixy] * np.log(p_xy[ixy] / p_x_p_y[ixy])).sum() + + return mi + + +# ---------------------------------------------------------------- +def regis_cost_func( + params, + img_fix, + img_float, + verbose=False, + rotate=True, + metric=neg_mutual_information, + pre_affine=None, + metric_kwargs={}, +): + """Generic cost function for rigid registration + + Parameters + ---------- + + params : 3 or 6 element numpy array + If rotate is False it contains the 3 translations + If rotate is True it contains the 3 translations and the 3 rotation angles + + img_fix : 3D numpy array + containg the fixed (reference) volume + + img_float : 3D numpy array + containg the floating (moving) volume + + verbose : bool, optional + print verbose output (default False) + + rotate : bool, optional + rotate volume as well on top of translations (6 degrees of freedom) + + metric : function(img_fix, aff_transform(img_float, ...)) -> R, optional + metric used to compare fixed and floating volume (default neg_mutual_information) + + pre_affine : 2D 4x4 numpy array + affine transformation applied before doing the rotation and shifting + + metric_kwargs : dictionary + key word arguments passed to the metric function + + Returns + ------- + float + the metric between the fixed and floating volume + + See Also + -------- + kul_aff() + """ + if rotate: + p = params.copy() + else: + p = np.concatenate((params, np.zeros(3))) + + if pre_affine is not None: + af = pre_affine @ kul_aff(p, origin=np.array(img_fix.shape) / 2) + else: + af = kul_aff(params, origin=np.array(img_fix.shape) / 2) + + m = metric( + img_fix, + aff_transform(img_float, af, img_fix.shape, cval=img_float.min()), + **metric_kwargs + ) + + if verbose: + print(params) + print(m) + print("") + + return m diff --git a/pymirc/metrics/tf_losses.py b/pymirc/metrics/tf_losses.py index be994c6..09e971d 100644 --- a/pymirc/metrics/tf_losses.py +++ b/pymirc/metrics/tf_losses.py @@ -1,59 +1,67 @@ import tensorflow as tf -if tf.__version__.startswith('1.'): - from keras import backend as K + +if tf.__version__.startswith("1."): + from keras import backend as K else: - from tensorflow.keras import backend as K + from tensorflow.keras import backend as K -from .tf_metrics import soft_dice_coef, soft_dice_coef_3d, soft_jaccard_index, IoU, ssim_3d, generalized_dice_coeff +from .tf_metrics import ( + soft_dice_coef, + soft_dice_coef_3d, + soft_jaccard_index, + IoU, + ssim_3d, + generalized_dice_coeff, +) -def weighted_binary_crossentropy(weights=[.5, 1]): - '''Returns a weighted binary crossentropy function. +def weighted_binary_crossentropy(weights=[0.5, 1]): + """Returns a weighted binary crossentropy function. Arguments: weights - First element of list is weight given to the background and the second element to the foreground (i.e. where the GT is equal to 1) - ''' + """ + def weighted_binary_crossentropy(y_true, y_pred): weight_mask = (1 - y_true) * weights[0] + y_true * weights[1] - return K.mean( - weight_mask * K.binary_crossentropy(y_pred, y_true), axis=-1) + return K.mean(weight_mask * K.binary_crossentropy(y_pred, y_true), axis=-1) return weighted_binary_crossentropy def dice(y_true, y_pred): - ''' + """ Equal to 1 minus the soft dice coefficient defined in metrics - ''' + """ return 1 - soft_dice_coef(y_true, y_pred) def dice_3d(y_true, y_pred): - ''' + """ Equal to 1 minus the soft dice coefficient defined in metrics - ''' + """ return 1 - soft_dice_coef_3d(y_true, y_pred) def binary_crossentropy(y_true, y_pred): - '''Redefinition of keras's binary crossentropy''' + """Redefinition of keras's binary crossentropy""" return K.mean(K.binary_crossentropy(y_true, y_pred), axis=-1) def jaccard(y_true, y_pred): - '''Equal to 1 minus the jaccard index defined in metrics''' + """Equal to 1 minus the jaccard index defined in metrics""" return 1 - soft_jaccard_index(y_true, y_pred) def IoU_loss(y_true, y_pred): - '''Equal to 1 minus the intersection over union defined in metrics''' + """Equal to 1 minus the intersection over union defined in metrics""" return 1 - IoU(y_true, y_pred) -def focal_loss(gamma=2., alpha=.25, from_logits=False): - '''Focal loss +def focal_loss(gamma=2.0, alpha=0.25, from_logits=False): + """Focal loss Focal loss as defined in: Lin, Tsung-Yi, et al. "Focal loss for dense object detection." arXiv preprint arXiv:1708.02002 (2017). @@ -64,62 +72,65 @@ def focal_loss(gamma=2., alpha=.25, from_logits=False): Arguments: gamma - exponent for downweighting easy samples (advised value in paper is 2) alpha - weight for class imbalance (same as in weighted crossentropy) Should correspond to the inverse frequency of the foreground pixels. - ''' + """ + def focal_loss_fixed(y_true, y_pred): - weight_mask = 10*(alpha * y_pred * (1 - y_true) + (1 - alpha) * ( - 1 - y_pred) * y_true) + weight_mask = 10 * ( + alpha * y_pred * (1 - y_true) + (1 - alpha) * (1 - y_pred) * y_true + ) return K.mean( - weight_mask * K.binary_crossentropy(y_true, y_pred, from_logits=from_logits), axis=-1) + weight_mask + * K.binary_crossentropy(y_true, y_pred, from_logits=from_logits), + axis=-1, + ) return focal_loss_fixed def ssim_3d_loss(x, y, **kwargs): - """ Compute the structural similarity loss between two batches of 3D single channel images + """Compute the structural similarity loss between two batches of 3D single channel images - Parameters - ---------- + Parameters + ---------- - x,y : tensorflow tensors with shape [batch_size,depth,height,width,1] - containing a batch of 3D images with 1 channel - **kwargs : dict - passed to tf_ssim_3d + x,y : tensorflow tensors with shape [batch_size,depth,height,width,1] + containing a batch of 3D images with 1 channel + **kwargs : dict + passed to tf_ssim_3d - Returns - ------- - a 1D tensorflow tensor of length batch_size containing the 1 - SSIM for - every image pair in the batch + Returns + ------- + a 1D tensorflow tensor of length batch_size containing the 1 - SSIM for + every image pair in the batch - See also - ---------- - tf_ssim_3d - """ - return 1 - ssim_3d(x, y, **kwargs) + See also + ---------- + tf_ssim_3d + """ + return 1 - ssim_3d(x, y, **kwargs) def generalized_dice_loss(**kwargs): - """ Generalized dice loss function which is 1 - (generalized dice score) - - Paramters - --------- - - y_true : tf tensor - containing the label data. dimensions (n_batch, n0, n1, ...., n_feat) + """Generalized dice loss function which is 1 - (generalized dice score) - y_pred : tf tensor - containing the predicted data. dimensions (n_batch, n0, n1, ...., n_feat) + Paramters + --------- - **kwargs : passed to dice_coeff + y_true : tf tensor + containing the label data. dimensions (n_batch, n0, n1, ...., n_feat) - Returns - ------- + y_pred : tf tensor + containing the predicted data. dimensions (n_batch, n0, n1, ...., n_feat) - A wrapper function that returns 1 - generalized_dice_coeff(y_true, y_pred, **kwargs) - """ + **kwargs : passed to dice_coeff - def generalized_dice_loss_wrapper(y_true, y_pred): - return 1 - generalized_dice_coeff(y_true, y_pred, **kwargs) + Returns + ------- - return generalized_dice_loss_wrapper + A wrapper function that returns 1 - generalized_dice_coeff(y_true, y_pred, **kwargs) + """ + def generalized_dice_loss_wrapper(y_true, y_pred): + return 1 - generalized_dice_coeff(y_true, y_pred, **kwargs) + return generalized_dice_loss_wrapper diff --git a/pymirc/metrics/tf_metrics.py b/pymirc/metrics/tf_metrics.py index 85efc59..9f16da9 100644 --- a/pymirc/metrics/tf_metrics.py +++ b/pymirc/metrics/tf_metrics.py @@ -1,21 +1,22 @@ import numpy as np import tensorflow as tf -if tf.__version__.startswith('1.'): - from keras import backend as K + +if tf.__version__.startswith("1."): + from keras import backend as K else: - from tensorflow.keras import backend as K + from tensorflow.keras import backend as K def dice_coef(y_true, y_pred): - '''Returns the dice coefficient after thresholding the predictions at + """Returns the dice coefficient after thresholding the predictions at a 0.5 confidence level. dice = 2*intersection / union - ''' + """ threshold = 0.5 - y_true = K.cast(K.greater(y_true, threshold), 'float32') - y_pred = K.cast(K.greater(y_pred, threshold), 'float32') + y_true = K.cast(K.greater(y_true, threshold), "float32") + y_pred = K.cast(K.greater(y_pred, threshold), "float32") intersection = K.sum(y_true * y_pred, axis=[1, 2]) union = K.sum(y_true, axis=[1, 2]) + K.sum(y_pred, axis=[1, 2]) # avoid division by zero by adding 1 @@ -24,46 +25,48 @@ def dice_coef(y_true, y_pred): def soft_dice_coef(y_true, y_pred): - '''Returns the soft dice coefficient. + """Returns the soft dice coefficient. This is calculated the same way as the normal dice coefficient, but without thresholding. This provides a continuous metric but with the same trend as the actual dice coefficient. This is especially useful as a loss function. - ''' + """ intersection = K.sum(y_true * y_pred, axis=[1, 2]) union = K.sum(y_true, axis=[1, 2]) + K.sum(y_pred, axis=[1, 2]) dice = (2.0 * intersection + K.epsilon()) / (union + K.epsilon()) return dice + def soft_dice_coef_3d(y_true, y_pred): - '''Returns the soft dice coefficient. + """Returns the soft dice coefficient. This is calculated the same way as the normal dice coefficient, but without thresholding. This provides a continuous metric but with the same trend as the actual dice coefficient. This is especially useful as a loss function. - ''' + """ intersection = K.sum(y_true * y_pred, axis=[1, 2, 3]) union = K.sum(y_true, axis=[1, 2, 3]) + K.sum(y_pred, axis=[1, 2, 3]) dice = (2.0 * intersection + K.epsilon()) / (union + K.epsilon()) return dice - def jaccard_index(y_true, y_pred): - '''Returns the jaccard index + """Returns the jaccard index jaccard index = intersection / union - ''' + """ threshold = 0.5 - y_true = K.cast(K.greater(y_true, threshold), 'float32') - y_pred = K.cast(K.greater(y_pred, threshold), 'float32') + y_true = K.cast(K.greater(y_true, threshold), "float32") + y_pred = K.cast(K.greater(y_pred, threshold), "float32") intersection = K.sum(y_true * y_pred, axis=[1, 2]) union = K.sum(y_true, axis=[1, 2]) + K.sum(y_pred, axis=[1, 2]) - jaccard = (intersection + K.epsilon()) / (union - intersection + K.epsilon()) # this contains as many elements as there are classes + jaccard = (intersection + K.epsilon()) / ( + union - intersection + K.epsilon() + ) # this contains as many elements as there are classes return jaccard @@ -75,187 +78,207 @@ def soft_jaccard_index(y_true, y_pred): def IoU(y_true, y_pred): - '''Returns the intersection over union + """Returns the intersection over union This is mathematically equivalent to the jaccard index. - ''' + """ return jaccard_index(y_true, y_pred) + def tf_gauss_kernel_3d(sigma, size): - """Generate 3D Gaussian kernel - - Parameters - ---------- - - sigma : float - width of the gaussian - size : int - size of the gaussian (should be odd an approx 2*int(3.5*sigma + 0.5) + 1 - - Returns - ------- - tensorflow tensor with dimension [size,size,size,1,1] with tf.reduce_sum(k) = 1 - """ - size = tf.convert_to_tensor(size, tf.int32) - sigma = tf.convert_to_tensor(sigma, tf.float32) - - coords = tf.cast(tf.range(size), tf.float32) - tf.cast(size - 1, tf.float32) / 2.0 - - g = -0.5*tf.square(coords) / tf.square(sigma) - g = tf.nn.softmax(g) - - g = tf.einsum('i,j,k->ijk', g, g, g) - g = tf.expand_dims(tf.expand_dims(g, -1), -1) - - return g - -def ssim_3d(x, y, sigma = 1.5, size = 11, L = None, K1 = 0.01, K2 = 0.03, return_image = False): - """ Compute the structural similarity between two batches of 3D single channel images - - Parameters - ---------- - - x,y : tensorflow tensors with shape [batch_size,depth,height,width,1] - containing a batch of 3D images with 1 channel - L : float - dynamic range of the images. - By default (None) it is set to tf.reduce_max(y) - tf.reduce_min(y) - K1, K2 : float - small constants needed to avoid division by 0 see [1]. - Default 0.01, 0.03 - sigma : float - width of the gaussian filter in pixels - Default 1.5 - size : int - size of the gaussian kernel used to calculate local means and std.devs - Default 11 - - Returns - ------- - a 1D tensorflow tensor of length batch_size containing the SSIM for - every image pair in the batch - - Note - ---- - (1) This implementation is very close to [1] and - from skimage.metrics import structural_similarity - structural_similarity(x, y, gaussian_weights = True, full = True, data_range = L) - (2) The default way of how the dynamic range L is calculated (based on y) - is different from [1] and structural_similarity() - - References - ---------- - [1] Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. - (2004). Image quality assessment: From error visibility to - structural similarity. IEEE Transactions on Image Processing - """ - if (x.shape[-1] != 1) or (y.shape[-1] != 1): - raise ValueError('Last dimension of input x has to be 1') - - if L is None: - L = tf.reduce_max(y) - tf.reduce_min(y) - - C1 = (K1*L)**2 - C2 = (K2*L)**2 - - shape = x.shape - kernel = tf_gauss_kernel_3d(sigma, size) - - mu_x = tf.nn.conv3d(x, kernel, strides = [1,1,1,1,1], padding = 'VALID') - mu_y = tf.nn.conv3d(y, kernel, strides = [1,1,1,1,1], padding = 'VALID') - - mu_x_sq = mu_x*mu_x - mu_y_sq = mu_y*mu_y - mu_x_y = mu_x*mu_y - - sig_x_sq = tf.nn.conv3d(x*x, kernel, strides = [1,1,1,1,1], padding = 'VALID') - mu_x_sq - sig_y_sq = tf.nn.conv3d(y*y, kernel, strides = [1,1,1,1,1], padding = 'VALID') - mu_y_sq - sig_xy = tf.nn.conv3d(x*y, kernel, strides = [1,1,1,1,1], padding = 'VALID') - mu_x_y - - SSIM= (2*mu_x_y + C1)*(2*sig_xy + C2) / ((mu_x_sq + mu_y_sq + C1)*(sig_x_sq + sig_y_sq + C2)) - - if not return_image: - SSIM = tf.reduce_mean(SSIM, [1,2,3,4]) - - return SSIM - -def generalized_dice_coeff(y_true, - y_pred, - eps = tf.keras.backend.epsilon(), - reduce_along_batch = False, - reduce_along_features = True, - feature_weights = None, - threshold = None, - keepdims = False): - """ Generalized Dice coefficient for a tensor containing a batch of ndim images with multiple features - Sudre et al. "Generalised Dice overlap as a deep learning loss function for highly - unbalanced segmentations" https://arxiv.org/abs/1707.03237 - - Parameters - ---------- - - y_true : tf tensor - containing the label data. dimensions (n_batch, n0, n1, ...., n_feat) - - y_pred : tf tensor - containing the predicted data. dimensions (n_batch, n0, n1, ...., n_feat) - - eps : float, default tf.keras.backend.epsilon() - a small constant that prevents division by 0 - - reduce_along_batch : bool, default False - reduce (sum) the loss values along the batch dimension - - reduce_along_features : bool, default True - reduce (sum) the loss values along the feature dimension - - feature_weights : None or 1D tf tensor of length n_feat - feature weights as defined in Generalized Dice Loss - If None, every feature gets the same weight "1" -> standard Dice coefficient - If 1D tf array, the "Generalized Dice Score" including feature weighting is calculated - - This only has an effect if reduce_along_features is True. - - threshold : None or float, default None - if None, no thresholding is done and thus the dice coefficient is 'soft' (depending on the input). - If a threshold is provided the volumes are thresholded first. - - keepdims : bool, default False - One can choose similar to other functions to keep the reduced dims. - - Returns - ------- - a tensor of shape (n_batch) containing the generalized (feature weighted) Dice coefficient - per batch sample and feature depending on reduce_along_batch and reduce_along_features - """ - - ndim = tf.rank(y_true) - ax = tf.range(1, ndim - 1) - - if threshold is not None: - y_true = tf.cast(tf.math.greater(y_true, threshold), y_true.dtype) - y_pred = tf.cast(tf.math.greater(y_pred, threshold), y_pred.dtype) - - intersection = tf.math.reduce_sum(y_true * y_pred, axis = ax, keepdims=keepdims) - - denom = tf.math.reduce_sum(y_true, axis = ax, keepdims=keepdims) + tf.math.reduce_sum(y_pred, axis = ax, keepdims=keepdims) - - # now reduce the dice coeff across the feature dimension - if reduce_along_features: + """Generate 3D Gaussian kernel + + Parameters + ---------- + + sigma : float + width of the gaussian + size : int + size of the gaussian (should be odd an approx 2*int(3.5*sigma + 0.5) + 1 + + Returns + ------- + tensorflow tensor with dimension [size,size,size,1,1] with tf.reduce_sum(k) = 1 + """ + size = tf.convert_to_tensor(size, tf.int32) + sigma = tf.convert_to_tensor(sigma, tf.float32) + + coords = tf.cast(tf.range(size), tf.float32) - tf.cast(size - 1, tf.float32) / 2.0 + + g = -0.5 * tf.square(coords) / tf.square(sigma) + g = tf.nn.softmax(g) + + g = tf.einsum("i,j,k->ijk", g, g, g) + g = tf.expand_dims(tf.expand_dims(g, -1), -1) + + return g + + +def ssim_3d(x, y, sigma=1.5, size=11, L=None, K1=0.01, K2=0.03, return_image=False): + """Compute the structural similarity between two batches of 3D single channel images + + Parameters + ---------- + + x,y : tensorflow tensors with shape [batch_size,depth,height,width,1] + containing a batch of 3D images with 1 channel + L : float + dynamic range of the images. + By default (None) it is set to tf.reduce_max(y) - tf.reduce_min(y) + K1, K2 : float + small constants needed to avoid division by 0 see [1]. + Default 0.01, 0.03 + sigma : float + width of the gaussian filter in pixels + Default 1.5 + size : int + size of the gaussian kernel used to calculate local means and std.devs + Default 11 + + Returns + ------- + a 1D tensorflow tensor of length batch_size containing the SSIM for + every image pair in the batch + + Note + ---- + (1) This implementation is very close to [1] and + from skimage.metrics import structural_similarity + structural_similarity(x, y, gaussian_weights = True, full = True, data_range = L) + (2) The default way of how the dynamic range L is calculated (based on y) + is different from [1] and structural_similarity() + + References + ---------- + [1] Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. + (2004). Image quality assessment: From error visibility to + structural similarity. IEEE Transactions on Image Processing + """ + if (x.shape[-1] != 1) or (y.shape[-1] != 1): + raise ValueError("Last dimension of input x has to be 1") + + if L is None: + L = tf.reduce_max(y) - tf.reduce_min(y) + + C1 = (K1 * L) ** 2 + C2 = (K2 * L) ** 2 + + shape = x.shape + kernel = tf_gauss_kernel_3d(sigma, size) + + mu_x = tf.nn.conv3d(x, kernel, strides=[1, 1, 1, 1, 1], padding="VALID") + mu_y = tf.nn.conv3d(y, kernel, strides=[1, 1, 1, 1, 1], padding="VALID") + + mu_x_sq = mu_x * mu_x + mu_y_sq = mu_y * mu_y + mu_x_y = mu_x * mu_y + + sig_x_sq = ( + tf.nn.conv3d(x * x, kernel, strides=[1, 1, 1, 1, 1], padding="VALID") - mu_x_sq + ) + sig_y_sq = ( + tf.nn.conv3d(y * y, kernel, strides=[1, 1, 1, 1, 1], padding="VALID") - mu_y_sq + ) + sig_xy = ( + tf.nn.conv3d(x * y, kernel, strides=[1, 1, 1, 1, 1], padding="VALID") - mu_x_y + ) + + SSIM = ( + (2 * mu_x_y + C1) + * (2 * sig_xy + C2) + / ((mu_x_sq + mu_y_sq + C1) * (sig_x_sq + sig_y_sq + C2)) + ) + + if not return_image: + SSIM = tf.reduce_mean(SSIM, [1, 2, 3, 4]) + + return SSIM + + +def generalized_dice_coeff( + y_true, + y_pred, + eps=tf.keras.backend.epsilon(), + reduce_along_batch=False, + reduce_along_features=True, + feature_weights=None, + threshold=None, + keepdims=False, +): + """Generalized Dice coefficient for a tensor containing a batch of ndim images with multiple features + Sudre et al. "Generalised Dice overlap as a deep learning loss function for highly + unbalanced segmentations" https://arxiv.org/abs/1707.03237 + + Parameters + ---------- + + y_true : tf tensor + containing the label data. dimensions (n_batch, n0, n1, ...., n_feat) + + y_pred : tf tensor + containing the predicted data. dimensions (n_batch, n0, n1, ...., n_feat) + + eps : float, default tf.keras.backend.epsilon() + a small constant that prevents division by 0 + + reduce_along_batch : bool, default False + reduce (sum) the loss values along the batch dimension + + reduce_along_features : bool, default True + reduce (sum) the loss values along the feature dimension + + feature_weights : None or 1D tf tensor of length n_feat + feature weights as defined in Generalized Dice Loss + If None, every feature gets the same weight "1" -> standard Dice coefficient + If 1D tf array, the "Generalized Dice Score" including feature weighting is calculated + + This only has an effect if reduce_along_features is True. + + threshold : None or float, default None + if None, no thresholding is done and thus the dice coefficient is 'soft' (depending on the input). + If a threshold is provided the volumes are thresholded first. + + keepdims : bool, default False + One can choose similar to other functions to keep the reduced dims. + + Returns + ------- + a tensor of shape (n_batch) containing the generalized (feature weighted) Dice coefficient + per batch sample and feature depending on reduce_along_batch and reduce_along_features + """ + + ndim = tf.rank(y_true) + ax = tf.range(1, ndim - 1) + + if threshold is not None: + y_true = tf.cast(tf.math.greater(y_true, threshold), y_true.dtype) + y_pred = tf.cast(tf.math.greater(y_pred, threshold), y_pred.dtype) + + intersection = tf.math.reduce_sum(y_true * y_pred, axis=ax, keepdims=keepdims) + + denom = tf.math.reduce_sum(y_true, axis=ax, keepdims=keepdims) + tf.math.reduce_sum( + y_pred, axis=ax, keepdims=keepdims + ) + + # now reduce the dice coeff across the feature dimension + if reduce_along_features: + + if feature_weights is None: + # if no weights are given, we use the same constant weight for all features + feature_weights = 1 - if feature_weights is None: - # if no weights are given, we use the same constant weight for all features - feature_weights = 1 + intersection = tf.math.reduce_sum( + intersection * feature_weights, axis=-1, keepdims=keepdims + ) + denom = tf.math.reduce_sum(denom * feature_weights, axis=-1, keepdims=keepdims) - intersection = tf.math.reduce_sum(intersection*feature_weights, axis = -1, keepdims=keepdims) - denom = tf.math.reduce_sum(denom*feature_weights, axis = -1, keepdims=keepdims) + if reduce_along_batch: + intersection = tf.math.reduce_sum(intersection, axis=0, keepdims=keepdims) + denom = tf.math.reduce_sum(denom, axis=0, keepdims=keepdims) - if reduce_along_batch: - intersection = tf.math.reduce_sum(intersection, axis = 0, keepdims=keepdims) - denom = tf.math.reduce_sum(denom, axis = 0, keepdims=keepdims) + return (2 * intersection + eps) / (denom + eps) - return (2 * intersection + eps) / (denom + eps) # aliases # dice = DICE = dice_coef diff --git a/pymirc/viewer/threeaxisviewer.py b/pymirc/viewer/threeaxisviewer.py index eb6b104..f72b079 100644 --- a/pymirc/viewer/threeaxisviewer.py +++ b/pymirc/viewer/threeaxisviewer.py @@ -2,502 +2,640 @@ import numpy as np from mpl_toolkits.axes_grid1 import ImageGrid -from matplotlib.widgets import Slider, TextBox +from matplotlib.widgets import Slider, TextBox from mpl_toolkits.axes_grid1.inset_locator import inset_axes + class ThreeAxisViewer: - """ simplistic three axis viewer for multiple aligned 3d or 4d arrays - - Parameters - ---------- - vols : list - a list of 3d or 4d numpy arrays containing the image volumes - vxosize: list, optional - a 3 element with the voxel size - width: float, optional - width of the figure - sl_x, sl_y, sl_z, sl_t: int, optional - slices to show at beginning - ls : string, optional - str specifying the line style of the cross hair (use '' for no cross hair) - imshow_kwargs : list of dictionaries - list of dictionaries with keyword arguments passed to pylab.imshow() - rowlabels : list of strings - containing the labels for every row (volume) - - Note - ---- - Scrolling with the mouse or the up and down keys can be used to scroll thorugh the slices. - The left and right keys scroll through time frames. - The viewer expects the input volumes to be in LPS orientation. - If the input is 4D, the time axis should be the left most axis. - - Example - ------- - ims_kwargs = [{'vmin':-1,'vmax':1},{'vmin':-2,'vmax':2,'cmap':py.cm.jet}] - vi = ThreeAxisViewer([np.random.randn(90,90,80),np.random.randn(90,90,80)], imshow_kwargs = ims_kwargs) - """ - def __init__(self, vols, - ovols = None, - voxsize = [1.,1.,1.], - width = None, - sl_x = None, - sl_y = None, - sl_z = None, - sl_t = 0, - ls = ':', - rowlabels = None, - imshow_kwargs = {}, - oimshow_kwargs = {}): - - # image volumes - if not isinstance(vols,list): - self.vols = [vols] - else: - self.vols = vols - - self.n_vols = len(self.vols) - self.ndim = self.vols[0].ndim - - # overlay volumes - if ovols is None: - self.ovols = None - else: - if not isinstance(ovols,list): - self.ovols = [ovols] - else: - self.ovols = ovols - - # factor used to hide / show overlays - self.ofac = 1 - - if self.ndim: self.nframes = self.vols[0].shape[0] - else: self.nframes = 1 - - # set up the slice objects for correct slicing of 3d and 4d arrays - if self.ndim == 3: - self.ix = 0 - self.iy = 1 - self.iz = 2 - - if self.ndim == 4: - self.ix = 1 - self.iy = 2 - self.iz = 3 - - self.shape = self.vols[0].shape - - if sl_x is None: - self.sl_x = self.shape[self.ix] // 2 - else: - self.sl_x = sl_x - - if sl_y is None: - self.sl_y = self.shape[self.iy] // 2 - else: - self.sl_y = sl_y - - if sl_z is None: - self.sl_z = self.shape[self.iz] // 2 - else: - self.sl_z = sl_z - - self.sl_t = sl_t - - if self.ndim == 4: - self.fstr = ', ' + str(self.sl_t) - else: - self.fstr = '' - - # kwargs for real volumes - self.imshow_kwargs = imshow_kwargs - - if not isinstance(self.imshow_kwargs,list): - tmp = self.imshow_kwargs.copy() - self.imshow_kwargs = [] - for i in range(self.n_vols): self.imshow_kwargs.append(tmp.copy()) - - for i in range(self.n_vols): - if not 'cmap' in self.imshow_kwargs[i]: - self.imshow_kwargs[i]['cmap'] = py.cm.Greys - if not 'interpolation' in self.imshow_kwargs[i]: - self.imshow_kwargs[i]['interpolation'] = 'nearest' - if not 'vmin' in self.imshow_kwargs[i]: - self.imshow_kwargs[i]['vmin'] = self.vols[i].min() - if not 'vmax' in self.imshow_kwargs[i]: - self.imshow_kwargs[i]['vmax'] = self.vols[i].max() - - # overlay imshow kwargs - - self.oimshow_kwargs = oimshow_kwargs - if not isinstance(self.oimshow_kwargs,list): - tmp = self.oimshow_kwargs.copy() - self.oimshow_kwargs = [] - for i in range(self.n_vols): self.oimshow_kwargs.append(tmp.copy()) - - for i in range(self.n_vols): - if not 'cmap' in self.oimshow_kwargs[i]: - self.oimshow_kwargs[i]['cmap'] = py.cm.hot - if not 'alpha' in self.oimshow_kwargs[i]: - self.oimshow_kwargs[i]['alpha'] = 0.5 - if not 'interpolation' in self.oimshow_kwargs[i]: - self.oimshow_kwargs[i]['interpolation'] = 'nearest' - if self.ovols is not None: - if self.ovols[i] is not None: - if not 'vmin' in self.oimshow_kwargs[i]: - self.oimshow_kwargs[i]['vmin'] = self.ovols[i].min() - if not 'vmax' in self.oimshow_kwargs[i]: - self.oimshow_kwargs[i]['vmax'] = self.ovols[i].max() - - # generat the slice objects sl0, sl2, sl2 - self.recalculate_slices() - - # set up the figure with the images - if width == None: width = min(12,24/len(self.vols)) - fig_asp = self.n_vols*max(self.shape[self.iy], - self.shape[self.iz]) / (2*self.shape[self.ix] + self.shape[self.iy]) - - self.fig, self.ax = py.subplots(self.n_vols, 3, figsize = (width,width*fig_asp), squeeze = False) - self.axes = self.fig.get_axes() - - self.imgs = [] - self.oimgs = None - - for i in range(self.n_vols): - im0 = np.squeeze(self.vols[i][tuple(self.sl0)].T) - im1 = np.squeeze(np.flip(self.vols[i][tuple(self.sl1)].T,0)) - im2 = np.squeeze(np.flip(self.vols[i][tuple(self.sl2)].T,0)) - - tmp = [] - tmp.append(self.ax[i,0].imshow(im0, aspect=voxsize[1]/voxsize[0], **self.imshow_kwargs[i])) - tmp.append(self.ax[i,1].imshow(im1, aspect=voxsize[2]/voxsize[0], **self.imshow_kwargs[i])) - tmp.append(self.ax[i,2].imshow(im2, aspect=voxsize[2]/voxsize[1], **self.imshow_kwargs[i])) - - self.imgs.append(tmp) - - self.ax[i,0].set_axis_off() - self.ax[i,1].set_axis_off() - self.ax[i,2].set_axis_off() - - - if rowlabels is None: - self.fig.subplots_adjust(left=0,right=0.95,bottom=0,top=0.97,wspace=0.01,hspace=0.01) - - else: - for ivol, label in enumerate(rowlabels): - self.fig.text(0.01,1 - (ivol + 0.5)/self.n_vols, label, rotation='vertical', - size = 'large', verticalalignment = 'center') - self.fig.subplots_adjust(left=0.03,right=0.95,bottom=0,top=0.97,wspace=0.01,hspace=0.01) - self.cb_ax = [] - - # align all subplots in a row to bottom and add axes for colorbars - for irow in range(self.n_vols): - bboxes = [] - for icol in range(3): - bboxes.append(self.ax[irow,icol].get_position()) - y0s = [x.y0 for x in bboxes] - y0min = min(y0s) - - for icol in range(3): - bbox = bboxes[icol] - self.ax[irow,icol].set_position([bbox.x0, y0min, bbox.x1-bbox.x0, bbox.y1-bbox.y0]) - - self.cb_ax.append(inset_axes(self.ax[irow,-1], width=0.01*width, - height=0.8*width*fig_asp/self.n_vols, - loc='lower left', bbox_to_anchor=(1.05, 0., 1, 1), - bbox_transform=self.ax[irow,-1].transAxes, borderpad=0)) - - - # add the overlay images in case given - if self.ovols is not None: - self.oimgs = [] - for i in range(self.n_vols): - if self.ovols[i] is not None: - oim0 = np.squeeze(self.ovols[i][tuple(self.sl0)].T) - oim1 = np.squeeze(np.flip(self.ovols[i][tuple(self.sl1)].T,0)) - oim2 = np.squeeze(np.flip(self.ovols[i][tuple(self.sl2)].T,0)) - - tmp = [] - tmp.append(self.ax[i,0].imshow(oim0, aspect=voxsize[1]/voxsize[0], **self.oimshow_kwargs[i])) - tmp.append(self.ax[i,1].imshow(oim1, aspect=voxsize[2]/voxsize[0], **self.oimshow_kwargs[i])) - tmp.append(self.ax[i,2].imshow(oim2, aspect=voxsize[2]/voxsize[1], **self.oimshow_kwargs[i])) - - self.oimgs.append(tmp) + """simplistic three axis viewer for multiple aligned 3d or 4d arrays + + Parameters + ---------- + vols : list + a list of 3d or 4d numpy arrays containing the image volumes + vxosize: list, optional + a 3 element with the voxel size + width: float, optional + width of the figure + sl_x, sl_y, sl_z, sl_t: int, optional + slices to show at beginning + ls : string, optional + str specifying the line style of the cross hair (use '' for no cross hair) + imshow_kwargs : list of dictionaries + list of dictionaries with keyword arguments passed to pylab.imshow() + rowlabels : list of strings + containing the labels for every row (volume) + + Note + ---- + Scrolling with the mouse or the up and down keys can be used to scroll thorugh the slices. + The left and right keys scroll through time frames. + The viewer expects the input volumes to be in LPS orientation. + If the input is 4D, the time axis should be the left most axis. + + Example + ------- + ims_kwargs = [{'vmin':-1,'vmax':1},{'vmin':-2,'vmax':2,'cmap':py.cm.jet}] + vi = ThreeAxisViewer([np.random.randn(90,90,80),np.random.randn(90,90,80)], imshow_kwargs = ims_kwargs) + """ + + def __init__( + self, + vols, + ovols=None, + voxsize=[1.0, 1.0, 1.0], + width=None, + sl_x=None, + sl_y=None, + sl_z=None, + sl_t=0, + ls=":", + rowlabels=None, + imshow_kwargs={}, + oimshow_kwargs={}, + ): + + # image volumes + if not isinstance(vols, list): + self.vols = [vols] else: - self.oimgs.append(None) - - - self.ax[0,0].set_title(str(self.sl_z) + self.fstr, fontsize='small') - self.ax[0,1].set_title(str(self.sl_y) + self.fstr, fontsize='small') - self.ax[0,2].set_title(str(self.sl_x) + self.fstr, fontsize='small') - - # add colors bars - self.cb_top_labels = [] - self.cb_bottom_labels = [] - - for i in range(self.n_vols): - self.cb_ax[i].imshow(np.arange(128).reshape((128,1)), aspect = 0.2, origin = 'lower', - cmap = self.imshow_kwargs[i]['cmap']) - self.cb_top_labels.append(self.cb_ax[i].text(1.2, 1, f'{self.imshow_kwargs[i]["vmax"]:.1E}', - transform = self.cb_ax[i].transAxes, rotation = 90, - horizontalalignment = 'left', verticalalignment = 'top', size = 'small')) - self.cb_bottom_labels.append(self.cb_ax[i].text(1.2, 0, f'{self.imshow_kwargs[i]["vmin"]:.1E}', - transform = self.cb_ax[i].transAxes, rotation = 90, - horizontalalignment = 'left', verticalalignment = 'bottom', size = 'small')) - self.cb_ax[i].set_xticks([]) - self.cb_ax[i].set_yticks([]) - - - # connect the image figure with actions - self.fig.canvas.mpl_connect('scroll_event',self.onscroll) - self.fig.canvas.mpl_connect('button_press_event',self.onbuttonpress) - self.fig.canvas.mpl_connect('key_press_event', self.onkeypress) - self.fig.show() - - # add cross hair - self.l0x = [] - self.l0y = [] - self.l1x = [] - self.l1y = [] - self.l2x = [] - self.l2y = [] - - self.showCross = True - if ls == '': self.showCross = False - - if self.showCross: - for i in range(self.n_vols): - self.l0x.append(self.axes[3*i + 0].axvline(self.sl_x, color = 'r',ls = ls)) - self.l0y.append(self.axes[3*i + 0].axhline(self.sl_y, color = 'r',ls = ls)) - - self.l1x.append(self.axes[3*i + 1].axvline(self.sl_x, color = 'r',ls = ls)) - self.l1y.append(self.axes[3*i + 1].axhline(self.shape[self.iz] - self.sl_z, color = 'r',ls = ls)) - - self.l2x.append(self.axes[3*i + 2].axvline(self.sl_y, color = 'r',ls = ls)) - self.l2y.append(self.axes[3*i + 2].axhline(self.shape[self.iz] - self.sl_z, color = 'r',ls = ls)) - - # list for contour definitions - self.contour_configs = [] - - #------------------------------------------------------------------------ - def update_colorbars(self): - for i in range(self.n_vols): - self.cb_top_labels[i].set_text(f'{self.imshow_kwargs[i]["vmax"]:.1E}') - self.cb_bottom_labels[i].set_text(f'{self.imshow_kwargs[i]["vmin"]:.1E}') - - #------------------------------------------------------------------------ - def set_vmin(self, i, val): - if i < self.n_vols: - self.imshow_kwargs[i]['vmin'] = val - self.imgs[i][0].set_clim([self.imshow_kwargs[i]['vmin'], self.imshow_kwargs[i]['vmax']]) - self.imgs[i][1].set_clim([self.imshow_kwargs[i]['vmin'], self.imshow_kwargs[i]['vmax']]) - self.imgs[i][2].set_clim([self.imshow_kwargs[i]['vmin'], self.imshow_kwargs[i]['vmax']]) - - self.update_colorbars() - self.fig.canvas.draw() - - #------------------------------------------------------------------------ - def set_vmax(self, i, val): - if i < self.n_vols: - self.imshow_kwargs[i]['vmax'] = val - self.imgs[i][0].set_clim([self.imshow_kwargs[i]['vmin'], self.imshow_kwargs[i]['vmax']]) - self.imgs[i][1].set_clim([self.imshow_kwargs[i]['vmin'], self.imshow_kwargs[i]['vmax']]) - self.imgs[i][2].set_clim([self.imshow_kwargs[i]['vmin'], self.imshow_kwargs[i]['vmax']]) - - self.update_colorbars() - self.fig.canvas.draw() - - #------------------------------------------------------------------------ - def redraw_transversal(self): - for i in range(self.n_vols): - self.imgs[i][0].set_data(np.squeeze(self.vols[i][tuple(self.sl0)].T)) - if (self.oimgs is not None) and (self.oimgs[i] is not None): - self.oimgs[i][0].set_data(self.ofac*np.squeeze(self.ovols[i][tuple(self.sl0)].T)) - - self.ax[0,0].set_title(str(self.sl_z) + self.fstr,fontsize='small') - if self.showCross: - for l in self.l0x: l.set_xdata(self.sl_x) - for l in self.l0y: l.set_ydata(self.sl_y) - py.draw() - - #------------------------------------------------------------------------ - def redraw_coronal(self): - - for i in range(self.n_vols): - self.imgs[i][1].set_data(np.squeeze(np.flip(self.vols[i][tuple(self.sl1)].T,0))) - if (self.oimgs is not None) and (self.oimgs[i] is not None): - self.oimgs[i][1].set_data(self.ofac*np.squeeze(np.flip(self.ovols[i][tuple(self.sl1)].T,0))) - self.ax[0,1].set_title(str(self.sl_y) + self.fstr,fontsize='small') - if self.showCross: - for l in self.l1x: l.set_xdata(self.sl_x) - for l in self.l1y: l.set_ydata(self.shape[self.iz] - self.sl_z - 1) - py.draw() - - #------------------------------------------------------------------------ - def redraw_sagittal(self): - for i in range(self.n_vols): - self.imgs[i][2].set_data(np.squeeze(np.flip(self.vols[i][tuple(self.sl2)].T,0))) - if (self.oimgs is not None) and (self.oimgs[i] is not None): - self.oimgs[i][2].set_data(self.ofac*np.squeeze(np.flip(self.ovols[i][tuple(self.sl2)].T,0))) - - self.ax[0,2].set_title(str(self.sl_x) + self.fstr,fontsize='small') - if self.showCross: - for l in self.l2x: l.set_xdata(self.sl_y) - for l in self.l2y: l.set_ydata(self.shape[self.iz] - self.sl_z - 1) - py.draw() - - #------------------------------------------------------------------------ - def redraw(self): - self.redraw_transversal() - self.redraw_coronal() - self.redraw_sagittal() - - # draw all contour lines - if len(self.contour_configs) > 0: - for cfg in self.contour_configs: - for i in range(3): - # remove drawn contour lines first - while(len(self.ax[cfg[1],i].collections) > 0): - for col in self.ax[cfg[1],i].collections: - col.remove() - - self.ax[cfg[1],i].contour(self.imgs[cfg[0]][i].get_array(), cfg[2], **cfg[3]) - self.fig.canvas.draw() - #------------------------------------------------------------------------ - def recalculate_slices(self): - if self.ndim == 3: - self.sl0 = [slice(None)]*self.ndim - self.sl0[self.iz] = slice(self.sl_z, self.sl_z+1) - self.sl1 = [slice(None)]*self.ndim - self.sl1[self.iy] = slice(self.sl_y, self.sl_y+1) - self.sl2 = [slice(None)]*self.ndim - self.sl2[self.ix] = slice(self.sl_x, self.sl_x+1) - elif self.ndim == 4: - self.sl0 = [slice(None)]*self.ndim - self.sl0[self.iz] = slice(self.sl_z, self.sl_z+1) - self.sl0[0] = slice(self.sl_t, self.sl_t+1) - self.sl1 = [slice(None)]*self.ndim - self.sl1[self.iy] = slice(self.sl_y, self.sl_y+1) - self.sl1[0] = slice(self.sl_t, self.sl_t+1) - self.sl2 = [slice(None)]*self.ndim - self.sl2[self.ix] = slice(self.sl_x, self.sl_x+1) - self.sl2[0] = slice(self.sl_t, self.sl_t+1) - self.fstr = ', ' + str(self.sl_t) - - #------------------------------------------------------------------------ - def add_contour(self, source, target, levels, contour_kwargs): - self.contour_configs.append([source, target, levels, contour_kwargs]) - self.redraw() - - #------------------------------------------------------------------------ - def remove_contour(self, k): - if k < len(self.contour_configs): - cfg = self.contour_configs[k] - for i in range(3): - # remove drawn contour lines first - while(len(self.ax[cfg[1],i].collections) > 0): - for col in self.ax[cfg[1],i].collections: - col.remove() - self.contour_configs.pop(k) - self.redraw() - - #------------------------------------------------------------------------ - def onkeypress(self,event): - if ((event.key == 'left' or event.key == 'right' or event.key == 'up' or event.key == 'down') and - (self.ndim >= 3)): - if event.key == 'left' and self.ndim == 4: - self.sl_t = (self.sl_t - 1) % self.nframes - self.recalculate_slices() - self.redraw() - elif event.key == 'right' and self.ndim == 4: - self.sl_t = (self.sl_t + 1) % self.nframes - self.recalculate_slices() - self.redraw() - else: - if event.inaxes in self.axes: - iax = self.axes.index(event.inaxes) - - if (iax %3 == 0): - if event.key == 'up': - self.sl_z = (self.sl_z + 1) % self.shape[self.iz] - elif event.key == 'down': - self.sl_z = (self.sl_z - 1) % self.shape[self.iz] - - self.recalculate_slices() - self.redraw() + self.vols = vols - elif (iax %3 == 1): - if event.key == 'up': - self.sl_y = (self.sl_y + 1) % self.shape[self.iy] - elif event.key == 'down': - self.sl_y = (self.sl_y - 1) % self.shape[self.iy] + self.n_vols = len(self.vols) + self.ndim = self.vols[0].ndim - self.recalculate_slices() - self.redraw() + # overlay volumes + if ovols is None: + self.ovols = None + else: + if not isinstance(ovols, list): + self.ovols = [ovols] + else: + self.ovols = ovols - elif (iax %3 == 2): - if event.key == 'up': - self.sl_x = (self.sl_x + 1) % self.shape[self.ix] - elif event.key == 'down': - self.sl_x = (self.sl_x - 1) % self.shape[self.ix] + # factor used to hide / show overlays + self.ofac = 1 - self.recalculate_slices() - self.redraw() - elif event.key == 'a': - self.ofac = 1 - self.ofac - self.redraw() - - #------------------------------------------------------------------------ - def onbuttonpress(self,event): - if py.get_current_fig_manager().toolbar.mode == '': - if event.inaxes in self.axes: - iax = self.axes.index(event.inaxes) - if iax < 3*self.n_vols: - if iax % 3 == 0: - self.sl_x = int(event.xdata) % self.shape[self.ix] - self.sl_y = int(event.ydata) % self.shape[self.iy] - self.recalculate_slices() - self.redraw() - elif iax % 3 == 1: - self.sl_x = int(event.xdata) % self.shape[self.ix] - self.sl_z = (self.shape[self.iz] - int(event.ydata)) % self.shape[self.iz] - self.recalculate_slices() - self.redraw() - elif iax % 3 == 2: - self.sl_y = int(event.xdata) % self.shape[self.iy] - self.sl_z = (self.shape[self.iz] - int(event.ydata)) % self.shape[self.iz] - self.recalculate_slices() - self.redraw() + if self.ndim: + self.nframes = self.vols[0].shape[0] + else: + self.nframes = 1 - #------------------------------------------------------------------------ - def onscroll(self,event): - if event.inaxes in self.axes: - iax = self.axes.index(event.inaxes) + # set up the slice objects for correct slicing of 3d and 4d arrays + if self.ndim == 3: + self.ix = 0 + self.iy = 1 + self.iz = 2 - if (iax %3 == 0): - if event.button == 'up': - self.sl_z = (self.sl_z + 1) % self.shape[self.iz] - elif event.button == 'down': - self.sl_z = (self.sl_z - 1) % self.shape[self.iz] + if self.ndim == 4: + self.ix = 1 + self.iy = 2 + self.iz = 3 - self.recalculate_slices() - self.redraw() + self.shape = self.vols[0].shape - elif (iax %3 == 1): - if event.button == 'up': - self.sl_y = (self.sl_y + 1) % self.shape[self.iy] - elif event.button == 'down': - self.sl_y = (self.sl_y - 1) % self.shape[self.iy] + if sl_x is None: + self.sl_x = self.shape[self.ix] // 2 + else: + self.sl_x = sl_x - self.recalculate_slices() - self.redraw() + if sl_y is None: + self.sl_y = self.shape[self.iy] // 2 + else: + self.sl_y = sl_y - elif (iax %3 == 2): - if event.button == 'up': - self.sl_x = (self.sl_x + 1) % self.shape[self.ix] - elif event.button == 'down': - self.sl_x = (self.sl_x - 1) % self.shape[self.ix] + if sl_z is None: + self.sl_z = self.shape[self.iz] // 2 + else: + self.sl_z = sl_z + + self.sl_t = sl_t + if self.ndim == 4: + self.fstr = ", " + str(self.sl_t) + else: + self.fstr = "" + + # kwargs for real volumes + self.imshow_kwargs = imshow_kwargs + + if not isinstance(self.imshow_kwargs, list): + tmp = self.imshow_kwargs.copy() + self.imshow_kwargs = [] + for i in range(self.n_vols): + self.imshow_kwargs.append(tmp.copy()) + + for i in range(self.n_vols): + if not "cmap" in self.imshow_kwargs[i]: + self.imshow_kwargs[i]["cmap"] = py.cm.Greys + if not "interpolation" in self.imshow_kwargs[i]: + self.imshow_kwargs[i]["interpolation"] = "nearest" + if not "vmin" in self.imshow_kwargs[i]: + self.imshow_kwargs[i]["vmin"] = self.vols[i].min() + if not "vmax" in self.imshow_kwargs[i]: + self.imshow_kwargs[i]["vmax"] = self.vols[i].max() + + # overlay imshow kwargs + + self.oimshow_kwargs = oimshow_kwargs + if not isinstance(self.oimshow_kwargs, list): + tmp = self.oimshow_kwargs.copy() + self.oimshow_kwargs = [] + for i in range(self.n_vols): + self.oimshow_kwargs.append(tmp.copy()) + + for i in range(self.n_vols): + if not "cmap" in self.oimshow_kwargs[i]: + self.oimshow_kwargs[i]["cmap"] = py.cm.hot + if not "alpha" in self.oimshow_kwargs[i]: + self.oimshow_kwargs[i]["alpha"] = 0.5 + if not "interpolation" in self.oimshow_kwargs[i]: + self.oimshow_kwargs[i]["interpolation"] = "nearest" + if self.ovols is not None: + if self.ovols[i] is not None: + if not "vmin" in self.oimshow_kwargs[i]: + self.oimshow_kwargs[i]["vmin"] = self.ovols[i].min() + if not "vmax" in self.oimshow_kwargs[i]: + self.oimshow_kwargs[i]["vmax"] = self.ovols[i].max() + + # generat the slice objects sl0, sl2, sl2 self.recalculate_slices() + + # set up the figure with the images + if width == None: + width = min(12, 24 / len(self.vols)) + fig_asp = ( + self.n_vols + * max(self.shape[self.iy], self.shape[self.iz]) + / (2 * self.shape[self.ix] + self.shape[self.iy]) + ) + + self.fig, self.ax = py.subplots( + self.n_vols, 3, figsize=(width, width * fig_asp), squeeze=False + ) + self.axes = self.fig.get_axes() + + self.imgs = [] + self.oimgs = None + + for i in range(self.n_vols): + im0 = np.squeeze(self.vols[i][tuple(self.sl0)].T) + im1 = np.squeeze(np.flip(self.vols[i][tuple(self.sl1)].T, 0)) + im2 = np.squeeze(np.flip(self.vols[i][tuple(self.sl2)].T, 0)) + + tmp = [] + tmp.append( + self.ax[i, 0].imshow( + im0, aspect=voxsize[1] / voxsize[0], **self.imshow_kwargs[i] + ) + ) + tmp.append( + self.ax[i, 1].imshow( + im1, aspect=voxsize[2] / voxsize[0], **self.imshow_kwargs[i] + ) + ) + tmp.append( + self.ax[i, 2].imshow( + im2, aspect=voxsize[2] / voxsize[1], **self.imshow_kwargs[i] + ) + ) + + self.imgs.append(tmp) + + self.ax[i, 0].set_axis_off() + self.ax[i, 1].set_axis_off() + self.ax[i, 2].set_axis_off() + + if rowlabels is None: + self.fig.subplots_adjust( + left=0, right=0.95, bottom=0, top=0.97, wspace=0.01, hspace=0.01 + ) + + else: + for ivol, label in enumerate(rowlabels): + self.fig.text( + 0.01, + 1 - (ivol + 0.5) / self.n_vols, + label, + rotation="vertical", + size="large", + verticalalignment="center", + ) + self.fig.subplots_adjust( + left=0.03, right=0.95, bottom=0, top=0.97, wspace=0.01, hspace=0.01 + ) + self.cb_ax = [] + + # align all subplots in a row to bottom and add axes for colorbars + for irow in range(self.n_vols): + bboxes = [] + for icol in range(3): + bboxes.append(self.ax[irow, icol].get_position()) + y0s = [x.y0 for x in bboxes] + y0min = min(y0s) + + for icol in range(3): + bbox = bboxes[icol] + self.ax[irow, icol].set_position( + [bbox.x0, y0min, bbox.x1 - bbox.x0, bbox.y1 - bbox.y0] + ) + + self.cb_ax.append( + inset_axes( + self.ax[irow, -1], + width=0.01 * width, + height=0.8 * width * fig_asp / self.n_vols, + loc="lower left", + bbox_to_anchor=(1.05, 0.0, 1, 1), + bbox_transform=self.ax[irow, -1].transAxes, + borderpad=0, + ) + ) + + # add the overlay images in case given + if self.ovols is not None: + self.oimgs = [] + for i in range(self.n_vols): + if self.ovols[i] is not None: + oim0 = np.squeeze(self.ovols[i][tuple(self.sl0)].T) + oim1 = np.squeeze(np.flip(self.ovols[i][tuple(self.sl1)].T, 0)) + oim2 = np.squeeze(np.flip(self.ovols[i][tuple(self.sl2)].T, 0)) + + tmp = [] + tmp.append( + self.ax[i, 0].imshow( + oim0, + aspect=voxsize[1] / voxsize[0], + **self.oimshow_kwargs[i], + ) + ) + tmp.append( + self.ax[i, 1].imshow( + oim1, + aspect=voxsize[2] / voxsize[0], + **self.oimshow_kwargs[i], + ) + ) + tmp.append( + self.ax[i, 2].imshow( + oim2, + aspect=voxsize[2] / voxsize[1], + **self.oimshow_kwargs[i], + ) + ) + + self.oimgs.append(tmp) + else: + self.oimgs.append(None) + + self.ax[0, 0].set_title(str(self.sl_z) + self.fstr, fontsize="small") + self.ax[0, 1].set_title(str(self.sl_y) + self.fstr, fontsize="small") + self.ax[0, 2].set_title(str(self.sl_x) + self.fstr, fontsize="small") + + # add colors bars + self.cb_top_labels = [] + self.cb_bottom_labels = [] + + for i in range(self.n_vols): + self.cb_ax[i].imshow( + np.arange(128).reshape((128, 1)), + aspect=0.2, + origin="lower", + cmap=self.imshow_kwargs[i]["cmap"], + ) + self.cb_top_labels.append( + self.cb_ax[i].text( + 1.2, + 1, + f'{self.imshow_kwargs[i]["vmax"]:.1E}', + transform=self.cb_ax[i].transAxes, + rotation=90, + horizontalalignment="left", + verticalalignment="top", + size="small", + ) + ) + self.cb_bottom_labels.append( + self.cb_ax[i].text( + 1.2, + 0, + f'{self.imshow_kwargs[i]["vmin"]:.1E}', + transform=self.cb_ax[i].transAxes, + rotation=90, + horizontalalignment="left", + verticalalignment="bottom", + size="small", + ) + ) + self.cb_ax[i].set_xticks([]) + self.cb_ax[i].set_yticks([]) + + # connect the image figure with actions + self.fig.canvas.mpl_connect("scroll_event", self.onscroll) + self.fig.canvas.mpl_connect("button_press_event", self.onbuttonpress) + self.fig.canvas.mpl_connect("key_press_event", self.onkeypress) + self.fig.show() + + # add cross hair + self.l0x = [] + self.l0y = [] + self.l1x = [] + self.l1y = [] + self.l2x = [] + self.l2y = [] + + self.showCross = True + if ls == "": + self.showCross = False + + if self.showCross: + for i in range(self.n_vols): + self.l0x.append( + self.axes[3 * i + 0].axvline(self.sl_x, color="r", ls=ls) + ) + self.l0y.append( + self.axes[3 * i + 0].axhline(self.sl_y, color="r", ls=ls) + ) + + self.l1x.append( + self.axes[3 * i + 1].axvline(self.sl_x, color="r", ls=ls) + ) + self.l1y.append( + self.axes[3 * i + 1].axhline( + self.shape[self.iz] - self.sl_z, color="r", ls=ls + ) + ) + + self.l2x.append( + self.axes[3 * i + 2].axvline(self.sl_y, color="r", ls=ls) + ) + self.l2y.append( + self.axes[3 * i + 2].axhline( + self.shape[self.iz] - self.sl_z, color="r", ls=ls + ) + ) + + # list for contour definitions + self.contour_configs = [] + + # ------------------------------------------------------------------------ + def update_colorbars(self): + for i in range(self.n_vols): + self.cb_top_labels[i].set_text(f'{self.imshow_kwargs[i]["vmax"]:.1E}') + self.cb_bottom_labels[i].set_text(f'{self.imshow_kwargs[i]["vmin"]:.1E}') + + # ------------------------------------------------------------------------ + def set_vmin(self, i, val): + if i < self.n_vols: + self.imshow_kwargs[i]["vmin"] = val + self.imgs[i][0].set_clim( + [self.imshow_kwargs[i]["vmin"], self.imshow_kwargs[i]["vmax"]] + ) + self.imgs[i][1].set_clim( + [self.imshow_kwargs[i]["vmin"], self.imshow_kwargs[i]["vmax"]] + ) + self.imgs[i][2].set_clim( + [self.imshow_kwargs[i]["vmin"], self.imshow_kwargs[i]["vmax"]] + ) + + self.update_colorbars() + self.fig.canvas.draw() + + # ------------------------------------------------------------------------ + def set_vmax(self, i, val): + if i < self.n_vols: + self.imshow_kwargs[i]["vmax"] = val + self.imgs[i][0].set_clim( + [self.imshow_kwargs[i]["vmin"], self.imshow_kwargs[i]["vmax"]] + ) + self.imgs[i][1].set_clim( + [self.imshow_kwargs[i]["vmin"], self.imshow_kwargs[i]["vmax"]] + ) + self.imgs[i][2].set_clim( + [self.imshow_kwargs[i]["vmin"], self.imshow_kwargs[i]["vmax"]] + ) + + self.update_colorbars() + self.fig.canvas.draw() + + # ------------------------------------------------------------------------ + def redraw_transversal(self): + for i in range(self.n_vols): + self.imgs[i][0].set_data(np.squeeze(self.vols[i][tuple(self.sl0)].T)) + if (self.oimgs is not None) and (self.oimgs[i] is not None): + self.oimgs[i][0].set_data( + self.ofac * np.squeeze(self.ovols[i][tuple(self.sl0)].T) + ) + + self.ax[0, 0].set_title(str(self.sl_z) + self.fstr, fontsize="small") + if self.showCross: + for l in self.l0x: + l.set_xdata(self.sl_x) + for l in self.l0y: + l.set_ydata(self.sl_y) + py.draw() + + # ------------------------------------------------------------------------ + def redraw_coronal(self): + + for i in range(self.n_vols): + self.imgs[i][1].set_data( + np.squeeze(np.flip(self.vols[i][tuple(self.sl1)].T, 0)) + ) + if (self.oimgs is not None) and (self.oimgs[i] is not None): + self.oimgs[i][1].set_data( + self.ofac * np.squeeze(np.flip(self.ovols[i][tuple(self.sl1)].T, 0)) + ) + self.ax[0, 1].set_title(str(self.sl_y) + self.fstr, fontsize="small") + if self.showCross: + for l in self.l1x: + l.set_xdata(self.sl_x) + for l in self.l1y: + l.set_ydata(self.shape[self.iz] - self.sl_z - 1) + py.draw() + + # ------------------------------------------------------------------------ + def redraw_sagittal(self): + for i in range(self.n_vols): + self.imgs[i][2].set_data( + np.squeeze(np.flip(self.vols[i][tuple(self.sl2)].T, 0)) + ) + if (self.oimgs is not None) and (self.oimgs[i] is not None): + self.oimgs[i][2].set_data( + self.ofac * np.squeeze(np.flip(self.ovols[i][tuple(self.sl2)].T, 0)) + ) + + self.ax[0, 2].set_title(str(self.sl_x) + self.fstr, fontsize="small") + if self.showCross: + for l in self.l2x: + l.set_xdata(self.sl_y) + for l in self.l2y: + l.set_ydata(self.shape[self.iz] - self.sl_z - 1) + py.draw() + + # ------------------------------------------------------------------------ + def redraw(self): + self.redraw_transversal() + self.redraw_coronal() + self.redraw_sagittal() + + # draw all contour lines + if len(self.contour_configs) > 0: + for cfg in self.contour_configs: + for i in range(3): + # remove drawn contour lines first + while len(self.ax[cfg[1], i].collections) > 0: + for col in self.ax[cfg[1], i].collections: + col.remove() + + self.ax[cfg[1], i].contour( + self.imgs[cfg[0]][i].get_array(), cfg[2], **cfg[3] + ) + self.fig.canvas.draw() + + # ------------------------------------------------------------------------ + def recalculate_slices(self): + if self.ndim == 3: + self.sl0 = [slice(None)] * self.ndim + self.sl0[self.iz] = slice(self.sl_z, self.sl_z + 1) + self.sl1 = [slice(None)] * self.ndim + self.sl1[self.iy] = slice(self.sl_y, self.sl_y + 1) + self.sl2 = [slice(None)] * self.ndim + self.sl2[self.ix] = slice(self.sl_x, self.sl_x + 1) + elif self.ndim == 4: + self.sl0 = [slice(None)] * self.ndim + self.sl0[self.iz] = slice(self.sl_z, self.sl_z + 1) + self.sl0[0] = slice(self.sl_t, self.sl_t + 1) + self.sl1 = [slice(None)] * self.ndim + self.sl1[self.iy] = slice(self.sl_y, self.sl_y + 1) + self.sl1[0] = slice(self.sl_t, self.sl_t + 1) + self.sl2 = [slice(None)] * self.ndim + self.sl2[self.ix] = slice(self.sl_x, self.sl_x + 1) + self.sl2[0] = slice(self.sl_t, self.sl_t + 1) + self.fstr = ", " + str(self.sl_t) + + # ------------------------------------------------------------------------ + def add_contour(self, source, target, levels, contour_kwargs): + self.contour_configs.append([source, target, levels, contour_kwargs]) self.redraw() + + # ------------------------------------------------------------------------ + def remove_contour(self, k): + if k < len(self.contour_configs): + cfg = self.contour_configs[k] + for i in range(3): + # remove drawn contour lines first + while len(self.ax[cfg[1], i].collections) > 0: + for col in self.ax[cfg[1], i].collections: + col.remove() + self.contour_configs.pop(k) + self.redraw() + + # ------------------------------------------------------------------------ + def onkeypress(self, event): + if ( + event.key == "left" + or event.key == "right" + or event.key == "up" + or event.key == "down" + ) and (self.ndim >= 3): + if event.key == "left" and self.ndim == 4: + self.sl_t = (self.sl_t - 1) % self.nframes + self.recalculate_slices() + self.redraw() + elif event.key == "right" and self.ndim == 4: + self.sl_t = (self.sl_t + 1) % self.nframes + self.recalculate_slices() + self.redraw() + else: + if event.inaxes in self.axes: + iax = self.axes.index(event.inaxes) + + if iax % 3 == 0: + if event.key == "up": + self.sl_z = (self.sl_z + 1) % self.shape[self.iz] + elif event.key == "down": + self.sl_z = (self.sl_z - 1) % self.shape[self.iz] + + self.recalculate_slices() + self.redraw() + + elif iax % 3 == 1: + if event.key == "up": + self.sl_y = (self.sl_y + 1) % self.shape[self.iy] + elif event.key == "down": + self.sl_y = (self.sl_y - 1) % self.shape[self.iy] + + self.recalculate_slices() + self.redraw() + + elif iax % 3 == 2: + if event.key == "up": + self.sl_x = (self.sl_x + 1) % self.shape[self.ix] + elif event.key == "down": + self.sl_x = (self.sl_x - 1) % self.shape[self.ix] + + self.recalculate_slices() + self.redraw() + elif event.key == "a": + self.ofac = 1 - self.ofac + self.redraw() + + # ------------------------------------------------------------------------ + def onbuttonpress(self, event): + if py.get_current_fig_manager().toolbar.mode == "": + if event.inaxes in self.axes: + iax = self.axes.index(event.inaxes) + if iax < 3 * self.n_vols: + if iax % 3 == 0: + self.sl_x = int(event.xdata) % self.shape[self.ix] + self.sl_y = int(event.ydata) % self.shape[self.iy] + self.recalculate_slices() + self.redraw() + elif iax % 3 == 1: + self.sl_x = int(event.xdata) % self.shape[self.ix] + self.sl_z = ( + self.shape[self.iz] - int(event.ydata) + ) % self.shape[self.iz] + self.recalculate_slices() + self.redraw() + elif iax % 3 == 2: + self.sl_y = int(event.xdata) % self.shape[self.iy] + self.sl_z = ( + self.shape[self.iz] - int(event.ydata) + ) % self.shape[self.iz] + self.recalculate_slices() + self.redraw() + + # ------------------------------------------------------------------------ + def onscroll(self, event): + if event.inaxes in self.axes: + iax = self.axes.index(event.inaxes) + + if iax % 3 == 0: + if event.button == "up": + self.sl_z = (self.sl_z + 1) % self.shape[self.iz] + elif event.button == "down": + self.sl_z = (self.sl_z - 1) % self.shape[self.iz] + + self.recalculate_slices() + self.redraw() + + elif iax % 3 == 1: + if event.button == "up": + self.sl_y = (self.sl_y + 1) % self.shape[self.iy] + elif event.button == "down": + self.sl_y = (self.sl_y - 1) % self.shape[self.iy] + + self.recalculate_slices() + self.redraw() + + elif iax % 3 == 2: + if event.button == "up": + self.sl_x = (self.sl_x + 1) % self.shape[self.ix] + elif event.button == "down": + self.sl_x = (self.sl_x - 1) % self.shape[self.ix] + + self.recalculate_slices() + self.redraw() From d721d52fbc77cb6ac6e8f927ac71e88c3d846939 Mon Sep 17 00:00:00 2001 From: Georg Schramm Date: Wed, 27 Nov 2024 22:47:42 +0100 Subject: [PATCH 4/4] remove bug in re-drawing of cross hair when using matplotlib 3.9 --- pymirc/viewer/threeaxisviewer.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pymirc/viewer/threeaxisviewer.py b/pymirc/viewer/threeaxisviewer.py index f72b079..4b5bd7e 100644 --- a/pymirc/viewer/threeaxisviewer.py +++ b/pymirc/viewer/threeaxisviewer.py @@ -431,9 +431,9 @@ def redraw_transversal(self): self.ax[0, 0].set_title(str(self.sl_z) + self.fstr, fontsize="small") if self.showCross: for l in self.l0x: - l.set_xdata(self.sl_x) + l.set_xdata((self.sl_x,)) for l in self.l0y: - l.set_ydata(self.sl_y) + l.set_ydata((self.sl_y,)) py.draw() # ------------------------------------------------------------------------ @@ -450,9 +450,9 @@ def redraw_coronal(self): self.ax[0, 1].set_title(str(self.sl_y) + self.fstr, fontsize="small") if self.showCross: for l in self.l1x: - l.set_xdata(self.sl_x) + l.set_xdata((self.sl_x,)) for l in self.l1y: - l.set_ydata(self.shape[self.iz] - self.sl_z - 1) + l.set_ydata((self.shape[self.iz] - self.sl_z - 1,)) py.draw() # ------------------------------------------------------------------------ @@ -469,9 +469,9 @@ def redraw_sagittal(self): self.ax[0, 2].set_title(str(self.sl_x) + self.fstr, fontsize="small") if self.showCross: for l in self.l2x: - l.set_xdata(self.sl_y) + l.set_xdata((self.sl_y,)) for l in self.l2y: - l.set_ydata(self.shape[self.iz] - self.sl_z - 1) + l.set_ydata((self.shape[self.iz] - self.sl_z - 1,)) py.draw() # ------------------------------------------------------------------------